170 lines
6.0 KiB
PHP
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]);
|
||
|
|
}
|
||
|
|
}
|