282 lines
11 KiB
PHP
282 lines
11 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\Payment\DTOs\PaymentResult;
|
|
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
|
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
|
|
use Modules\Core\Shipping\DTOs\ShipmentRequest;
|
|
use Modules\Core\Shipping\Enums\ExtraService;
|
|
use Modules\Core\Shipping\Models\Shipment;
|
|
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,
|
|
private readonly OrderPaymentResolutionService $resolution,
|
|
) {}
|
|
|
|
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.');
|
|
}
|
|
|
|
/**
|
|
* Creates the shipment only — the order stays at ready_for_dispatch
|
|
* until the carrier actually picks the parcel up. That move (and the
|
|
* "on its way" email) happens in Modules\Core\Order\Listeners\
|
|
* AdvanceFulfillmentOnCarrierCheckpoint, on the first InTransit /
|
|
* CollectedFromSender checkpoint — synced from the carrier, or entered
|
|
* by hand for a manual carrier.
|
|
*/
|
|
public function createShipment(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());
|
|
}
|
|
|
|
return OrderFulfillmentResult::success('Shipment created. The order moves to Dispatched when the carrier picks it up.');
|
|
}
|
|
|
|
/**
|
|
* Records an integrated carrier's voucher that wasn't created through
|
|
* our API — the carrier's system was down and a pre-numbered paper
|
|
* voucher was used, or the courier wrote his own at pickup. It's still
|
|
* that carrier's voucher, so PollShipmentTrackingJob picks up its
|
|
* history once the carrier's API has it.
|
|
*
|
|
* @param array<int, ExtraService> $services
|
|
*/
|
|
public function addManualVoucher(Order $order, string $carrier, string $voucherNumber, array $services = []): OrderFulfillmentResult
|
|
{
|
|
if (! $this->canAddManualVoucher($order)) {
|
|
return OrderFulfillmentResult::failure('A voucher can only be added from Ready for Dispatch until the order is delivered.');
|
|
}
|
|
|
|
$voucherNumber = trim($voucherNumber);
|
|
|
|
if (Shipment::where('tracking_reference', $voucherNumber)->exists()) {
|
|
return OrderFulfillmentResult::failure("Voucher {$voucherNumber} is already recorded.");
|
|
}
|
|
|
|
Shipment::create([
|
|
'order_id' => $order->id,
|
|
'carrier' => $carrier,
|
|
'source' => Shipment::SOURCE_MANUAL_VOUCHER,
|
|
'tracking_reference' => $voucherNumber,
|
|
'meta' => [
|
|
'services' => array_map(fn (ExtraService $service) => $service->value, $services),
|
|
'cod_amount' => $this->flow->isCod($order) ? $order->total->decimal : null,
|
|
],
|
|
]);
|
|
|
|
return OrderFulfillmentResult::success("Voucher {$voucherNumber} added.");
|
|
}
|
|
|
|
public function canAddManualVoucher(Order $order): bool
|
|
{
|
|
return in_array($order->status, ['ready_for_dispatch', 'dispatched', 'delivery_failed'], true)
|
|
&& ! $order->isStorePickupOrder();
|
|
}
|
|
|
|
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.');
|
|
}
|
|
|
|
/**
|
|
* For a COD order, independent of `status` entirely — offered by the
|
|
* single "Update Status" action regardless of current status (see
|
|
* OrderStatusFlow::canMarkPaid()). For a bank transfer order, status
|
|
* genuinely does advance here too (see below) — unlike COD, a bank
|
|
* transfer order has been sitting at 'awaiting_payment' since checkout
|
|
* (BankTransferPaymentDriver::pay() deliberately never advances it),
|
|
* and this click is the only thing that ever will.
|
|
*/
|
|
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 or bank-transfer DRIVER
|
|
// (see OrderStatusFlow::isCod()/isBankTransfer(), which check
|
|
// PaymentMethod::driver, never the merchant-chosen `type` slug
|
|
// directly — a store could name that method "cod", "pay-on-delivery",
|
|
// "wire", anything). Neither ever runs a Transaction-recording event
|
|
// through to completion at checkout (COD dispatches nothing capture-
|
|
// shaped at all; bank transfer's pay() returns Pending with no event
|
|
// dispatched — see that driver's own docblock). 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.
|
|
$type = (string) $order->meta['payment_method'];
|
|
|
|
$this->transactions->record(
|
|
$order,
|
|
type: 'capture',
|
|
driver: $type,
|
|
result: new PaymentResult(
|
|
status: PaymentResultStatus::Succeeded,
|
|
reference: "manual-{$type}-{$order->id}",
|
|
amount: $order->total,
|
|
),
|
|
);
|
|
|
|
$this->writer->markPaid($order, self::class.'::markPaid');
|
|
|
|
if ($this->flow->isBankTransfer($order)) {
|
|
$this->resolution->advancePastAwaitingPayment($order, self::class.'::markPaid');
|
|
}
|
|
|
|
return OrderFulfillmentResult::success('Order marked as paid.');
|
|
}
|
|
|
|
/**
|
|
* A cancelled shipment doesn't block creating a new one.
|
|
*/
|
|
public function canCreateShipment(Order $order): bool
|
|
{
|
|
return $order->status === 'ready_for_dispatch'
|
|
&& ! $order->isStorePickupOrder()
|
|
&& $order->shipments()->whereNull('cancelled_at')->doesntExist()
|
|
&& $this->resolveFulfillmentService($order) !== null;
|
|
}
|
|
|
|
/**
|
|
* The order's shipping method — manual carriers keep their name,
|
|
* tracking URL template and cash-on-delivery setting in its `data`.
|
|
*/
|
|
public function shippingMethodFor(Order $order): ?ShippingMethod
|
|
{
|
|
$code = $order->shippingAddress?->shipping_option;
|
|
|
|
return $code ? ShippingMethod::where('code', $code)->first() : 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]);
|
|
}
|
|
}
|