Feature: Order Updates, Events, Order Flows, Shipment And COD support
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
<?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);
|
||||
}
|
||||
|
||||
private function label(string $status): string
|
||||
{
|
||||
return (string) str($status)->replace('_', ' ')->title();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user