Files
core/src/Order/Services/OrderFulfillmentService.php
T

170 lines
6.0 KiB
PHP

<?php
namespace Modules\Core\Order\Services;
use Lunar\Models\Order;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Order\DTOs\OrderFulfillmentResult;
use Modules\Core\Order\Events\OrderPickedUp;
use Modules\Core\Order\Events\OrderReadyForDispatch;
use Modules\Core\Order\Events\OrderReadyForPickup;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Throwable;
/**
* The staff-facing fulfillment/return/payment workflow behind the three
* header actions in Modules\Core\Shipping\Extensions\OrderViewExtension
* ("Create Shipment", "Update Status", "Mark Paid") — every guard check,
* status write (via Modules\Core\Order\Services\OrderStatusWriter), and
* event dispatch lives here, keeping this workflow usable and testable
* independent of Filament.
*
* Every method re-validates its own precondition internally (not just
* trusted from the caller's own visible()-equivalent check) — protects
* against a stale page load racing a concurrent automatic transition
* (e.g. a carrier tracking checkpoint advancing the same order between
* page load and button click).
*/
class OrderFulfillmentService
{
public function __construct(
private readonly OrderStatusWriter $writer,
private readonly OrderStatusFlow $flow,
) {}
public function markReady(Order $order): OrderFulfillmentResult
{
if ($order->status !== 'processing') {
return OrderFulfillmentResult::failure('This order must be in Processing before it can be marked ready.');
}
$target = $order->isStorePickupOrder() ? 'ready_for_pickup' : 'ready_for_dispatch';
$this->writer->write($order, $target, self::class.'::markReady');
if ($order->isStorePickupOrder()) {
OrderReadyForPickup::dispatch($order);
} else {
OrderReadyForDispatch::dispatch($order);
}
return OrderFulfillmentResult::success('Order marked ready.');
}
public function createShipmentAndDispatch(Order $order, ShipmentRequest $request): OrderFulfillmentResult
{
if ($order->status !== 'ready_for_dispatch') {
return OrderFulfillmentResult::failure('This order is not ready to be dispatched.');
}
$service = $this->resolveFulfillmentService($order);
if (! $service) {
return OrderFulfillmentResult::failure('No carrier fulfillment integration is configured for this order.');
}
try {
$service->createShipment($order, $request);
} catch (Throwable $e) {
report($e);
return OrderFulfillmentResult::failure('Failed to create shipment: '.$e->getMessage());
}
$this->writer->write($order, 'dispatched', self::class.'::createShipmentAndDispatch');
return OrderFulfillmentResult::success('Shipment created and order dispatched.');
}
public function markPickedUp(Order $order): OrderFulfillmentResult
{
if ($order->status !== 'ready_for_pickup') {
return OrderFulfillmentResult::failure('This order is not ready for pickup.');
}
$this->writer->write($order, 'picked_up', self::class.'::markPickedUp');
OrderPickedUp::dispatch($order);
return OrderFulfillmentResult::success('Order marked as picked up.');
}
/**
* The general-purpose entry point for any transition with no special
* side effect — a manual override, not restricted to the guided next
* step(s), so staff can revert to an earlier status in the order's
* own branch. Validates $to is actually a member of
* OrderStatusFlow::allOptions() before writing (server-side
* re-validation of whatever the Select offered) — still refuses a
* status from the WRONG branch or an unknown value.
*/
public function transitionTo(Order $order, string $to): OrderFulfillmentResult
{
if (! array_key_exists($to, $this->flow->allOptions($order))) {
return OrderFulfillmentResult::failure('That status is not valid for this order.');
}
$this->writer->write($order, $to, self::class.'::transitionTo');
return OrderFulfillmentResult::success('Order status updated.');
}
/**
* Independent of `status` entirely — offered by the single "Update
* Status" action regardless of current status (see
* OrderStatusFlow::canMarkPaid()).
*/
public function markPaid(Order $order): OrderFulfillmentResult
{
if (! $this->flow->canMarkPaid($order)) {
return OrderFulfillmentResult::failure('This order cannot be marked paid right now.');
}
$this->writer->markPaid($order, self::class.'::markPaid');
return OrderFulfillmentResult::success('Order marked as paid.');
}
public function canCreateShipment(Order $order): bool
{
return $order->status === 'ready_for_dispatch'
&& ! $order->isStorePickupOrder()
&& $order->shipments()->exists() === false
&& $this->resolveFulfillmentService($order) !== null;
}
/**
* Public wrapper around resolveCarrier() — Modules\Core\Shipping\
* Extensions\OrderViewExtension needs to know which carrier an order
* uses to branch the "Create Shipment" form (Box Now's box-size
* repeater vs. every other carrier's plain weight field).
*/
public function carrierFor(Order $order): ?string
{
return $this->resolveCarrier($order);
}
private function resolveCarrier(Order $order): ?string
{
$code = $order->shippingAddress?->shipping_option;
if (! $code) {
return null;
}
return ShippingMethod::where('code', $code)->value('driver');
}
private function resolveFulfillmentService(Order $order): ?CarrierFulfillmentInterface
{
$carrier = $this->resolveCarrier($order);
if (! $carrier) {
return null;
}
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
}
}