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

206 lines
8.0 KiB
PHP
Raw Normal View History

<?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\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
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,
private readonly TransactionRecorder $transactions,
) {}
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.');
}
// canMarkPaid() only ever returns true for an order whose payment
// method resolves to the cash-on-delivery DRIVER (see
// OrderStatusFlow::isCod(), which checks PaymentMethod::driver,
// never the merchant-chosen `type` slug directly — a store could
// name that method "cod", "pay-on-delivery", anything). Such an
// order never runs through Payment's pay()/authorize() flow at
// checkout, so nothing else records a Transaction for it. Money
// changes hands right here, at this click, so this is the one
// place that write can happen; there is no earlier Payment event
// to hang it off of the way Modules\Core\Order\Listeners\
// RecordPaymentTransaction does for a gateway driver. See
// TransactionRecorder's own docblock — it already anticipated
// exactly this "manually-triggered ... from Filament" call site.
//
// $driver below is the payment method's own `type` slug (whatever
// the merchant named it, e.g. 'cash-on-delivery' or 'cod') —
// Transaction.driver's established meaning everywhere else in this
// codebase (see RecordPaymentTransaction/TransactionRecorder's own
// docblocks) is that type key, never the underlying driver CLASS.
// No fallback guess here: CheckoutService::initiatePayment() always
// writes Order.meta['payment_method'] before charging, and
// canMarkPaid() already guarantees this order got that far.
$this->transactions->record(
$order,
type: 'capture',
driver: (string) $order->meta['payment_method'],
result: new PaymentResult(
status: PaymentResultStatus::Succeeded,
reference: 'cod-manual-'.$order->id,
amount: $order->total,
),
);
$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]);
}
}