2026-09-14 00:03:06 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Modules\Core\Order\Services;
|
|
|
|
|
|
|
|
|
|
use Lunar\Models\Order;
|
|
|
|
|
use Modules\Core\Payment\Models\PaymentMethod;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Two status sequences — carrier, pickup (Order::isStorePickupOrder()) —
|
|
|
|
|
* NOT four. Payment method (prepaid vs. cash-on-delivery) does not affect
|
|
|
|
|
* the status SEQUENCE at all; it only affects Order::paid, an entirely
|
|
|
|
|
* separate field this class also offers a transition for (see
|
|
|
|
|
* canMarkPaid()). `status` never includes a "paid" step — COD
|
|
|
|
|
* reconciliation can happen at any point in, or after, the fulfillment
|
|
|
|
|
* journey (same-day to months later), so it cannot occupy a fixed slot in
|
|
|
|
|
* a linear sequence.
|
|
|
|
|
*/
|
|
|
|
|
class OrderStatusFlow
|
|
|
|
|
{
|
|
|
|
|
private const FLOW_CARRIER = [
|
|
|
|
|
'awaiting_payment', 'processing', 'ready_for_dispatch', 'dispatched',
|
|
|
|
|
'delivered', 'completed', 'return_requested', 'returned',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
private const FLOW_PICKUP = [
|
|
|
|
|
'awaiting_payment', 'processing', 'ready_for_pickup', 'picked_up',
|
|
|
|
|
'completed', 'return_requested', 'returned',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
private const REFUND_OPTIONS = ['partially_refunded', 'refunded'];
|
|
|
|
|
|
|
|
|
|
private const RETURN_ELIGIBLE_FROM = ['delivered', 'picked_up', 'completed'];
|
|
|
|
|
|
|
|
|
|
public function resolveFlow(Order $order): array
|
|
|
|
|
{
|
|
|
|
|
return $order->isStorePickupOrder() ? self::FLOW_PICKUP : self::FLOW_CARRIER;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Order.meta['payment_method'] (written by
|
|
|
|
|
* Modules\Core\Checkout\Services\CheckoutService::initiatePayment())
|
|
|
|
|
* is the durable source of truth. Falls back to the most recent
|
|
|
|
|
* Transaction.driver (a payment TYPE slug) only if meta is missing —
|
|
|
|
|
* e.g. an order placed before this field existed.
|
|
|
|
|
*/
|
|
|
|
|
public function isCod(Order $order): bool
|
|
|
|
|
{
|
|
|
|
|
$type = $order->meta['payment_method'] ?? $order->transactions()->latest('id')->value('driver');
|
|
|
|
|
|
|
|
|
|
if ($type === null) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return PaymentMethod::where('type', $type)->value('driver') === 'cash-on-delivery';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array<string, string> value => label — every status in the
|
|
|
|
|
* order's own branch (carrier or pickup), plus the refund options,
|
|
|
|
|
* for a manual-override "New status" select. Deliberately not
|
|
|
|
|
* filtered to nextOptions()'s guided next-step(s) — staff can jump
|
|
|
|
|
* to any status in their branch, including reverting to an earlier
|
|
|
|
|
* one (e.g. undoing a mistaken click). transitionTo() still
|
|
|
|
|
* validates $to is actually a member of this set server-side.
|
|
|
|
|
*/
|
|
|
|
|
public function allOptions(Order $order): array
|
|
|
|
|
{
|
|
|
|
|
$statuses = [...$this->resolveFlow($order), ...self::REFUND_OPTIONS, 'delivery_failed'];
|
|
|
|
|
|
|
|
|
|
return collect($statuses)
|
|
|
|
|
->unique()
|
|
|
|
|
->mapWithKeys(fn (string $status) => [$status => $this->label($status)])
|
|
|
|
|
->all();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array<string, string> value => label — status-sequence
|
|
|
|
|
* transitions offered as the guided next step(s). Does not include
|
|
|
|
|
* the "mark paid" pseudo-option — see canMarkPaid().
|
|
|
|
|
*/
|
|
|
|
|
public function nextOptions(Order $order): array
|
|
|
|
|
{
|
|
|
|
|
$flow = $this->resolveFlow($order);
|
|
|
|
|
$current = $order->status;
|
|
|
|
|
$position = array_search($current, $flow, true);
|
|
|
|
|
|
|
|
|
|
$options = [];
|
|
|
|
|
|
|
|
|
|
if ($position !== false && isset($flow[$position + 1])) {
|
|
|
|
|
$options[] = $flow[$position + 1];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// delivery_failed — a possible outcome of any delivery attempt,
|
|
|
|
|
// carrier flow only, checked on $current directly (not on the
|
|
|
|
|
// flow's literal next value) since it's a branch on the attempt
|
|
|
|
|
// itself, not on sequence position.
|
|
|
|
|
if ($current === 'dispatched') {
|
|
|
|
|
$options[] = 'delivery_failed';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// From delivery_failed: retry dispatch, or give up and treat as
|
|
|
|
|
// a return.
|
|
|
|
|
if ($current === 'delivery_failed') {
|
|
|
|
|
array_push($options, 'dispatched', 'return_requested');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (in_array($current, self::RETURN_ELIGIBLE_FROM, true)) {
|
|
|
|
|
$options[] = 'return_requested';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($current === 'return_requested') {
|
|
|
|
|
$options[] = 'returned';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($current === 'returned') {
|
|
|
|
|
array_push($options, ...self::REFUND_OPTIONS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return collect($options)
|
|
|
|
|
->unique()
|
|
|
|
|
->mapWithKeys(fn (string $status) => [$status => $this->label($status)])
|
|
|
|
|
->all();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Whether the "mark paid" option should be offered right now —
|
|
|
|
|
* entirely independent of $order->status. True whenever this is a
|
|
|
|
|
* cash-on-delivery order and payment hasn't been recorded yet,
|
|
|
|
|
* regardless of fulfillment progress (before OR after completed).
|
|
|
|
|
*/
|
|
|
|
|
public function canMarkPaid(Order $order): bool
|
|
|
|
|
{
|
|
|
|
|
return ! $order->paid && $this->isCod($order);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-16 23:24:02 +03:00
|
|
|
/**
|
|
|
|
|
* Whether moving $order to $to is a valid transition from its CURRENT
|
|
|
|
|
* status — the single source of truth for "is this a legal next step,"
|
|
|
|
|
* so a caller reacting to an external event (a carrier tracking
|
|
|
|
|
* checkpoint, a staff action) doesn't need to hardcode its own "only
|
|
|
|
|
* fire from status X" guard duplicating what nextOptions() already
|
|
|
|
|
* knows. See e.g. Modules\Core\Order\Listeners\
|
|
|
|
|
* AdvanceFulfillmentOnCarrierCheckpoint, which used to compare
|
|
|
|
|
* $order->status to a literal 'ready_for_dispatch' inline instead of
|
|
|
|
|
* asking this class.
|
|
|
|
|
*/
|
|
|
|
|
public function isValidTransition(Order $order, string $to): bool
|
|
|
|
|
{
|
|
|
|
|
return array_key_exists($to, $this->nextOptions($order));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-14 00:03:06 +03:00
|
|
|
private function label(string $status): string
|
|
|
|
|
{
|
|
|
|
|
return (string) str($status)->replace('_', ' ')->title();
|
|
|
|
|
}
|
|
|
|
|
}
|