Feat: Shipping functionalities redesign, elta courier integration, commenting out acs since no integration can happen, flow updates
This commit is contained in:
@@ -7,16 +7,12 @@ use Lunar\Models\Order;
|
||||
use Modules\Core\Shipping\Models\ShipmentInfo;
|
||||
|
||||
/**
|
||||
* Dispatched by either of the two paths that move a carrier order's
|
||||
* `status` to 'dispatched' — Modules\Core\Order\Listeners\
|
||||
* AdvanceFulfillmentOnCarrierCheckpoint (automatic, reacting to a real
|
||||
* carrier checkpoint) or Modules\Core\Order\Services\
|
||||
* OrderFulfillmentService::createShipmentAndDispatch() (staff-driven, via
|
||||
* the single "Update Status" action). $shipmentInfo is nullable
|
||||
* specifically because of that second path — populated with the
|
||||
* triggering checkpoint when it's real, null when staff drove it
|
||||
* manually. Mirrors OrderDelivered's {order, shipmentInfo} shape, just
|
||||
* with the nullability this one event additionally needs.
|
||||
* Fired when the carrier picks the parcel up — Modules\Core\Order\
|
||||
* Listeners\AdvanceFulfillmentOnCarrierCheckpoint, on the first InTransit /
|
||||
* CollectedFromSender checkpoint (synced from the carrier, or entered by
|
||||
* hand for a manual carrier). Creating a shipment doesn't move the order to
|
||||
* dispatched on its own. $shipmentInfo is that checkpoint; nullable for a
|
||||
* caller without one. Mirrors OrderDelivered's {order, shipmentInfo} shape.
|
||||
*/
|
||||
class OrderDispatched
|
||||
{
|
||||
|
||||
@@ -21,5 +21,6 @@ class OrderStatusUpdated
|
||||
public readonly Order $order,
|
||||
public readonly ?string $previousStatus,
|
||||
public readonly string $newStatus,
|
||||
public readonly ?string $causeClass = null,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,10 @@ class AdvanceFulfillmentOnCarrierCheckpoint implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $event->shipmentInfo->shipment->order;
|
||||
$shipment = $event->shipmentInfo->shipment;
|
||||
$order = $shipment->order;
|
||||
|
||||
if (! $order || ! $this->flow->isValidTransition($order, 'dispatched')) {
|
||||
if (! $order || ! $shipment->drivesOrderStatus() || ! $this->flow->isValidTransition($order, 'dispatched')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,10 @@ class DeriveOrderDeliveredFromShipment implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $event->shipmentInfo->shipment->order;
|
||||
$shipment = $event->shipmentInfo->shipment;
|
||||
$order = $shipment->order;
|
||||
|
||||
if (! $order) {
|
||||
if (! $order || ! $shipment->drivesOrderStatus()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,9 +32,10 @@ class MarkDeliveryFailedOnCarrierCheckpoint implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $event->shipmentInfo->shipment->order;
|
||||
$shipment = $event->shipmentInfo->shipment;
|
||||
$order = $shipment->order;
|
||||
|
||||
if (! $order || ! $this->flow->isValidTransition($order, 'delivery_failed')) {
|
||||
if (! $order || ! $shipment->drivesOrderStatus() || ! $this->flow->isValidTransition($order, 'delivery_failed')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Modules\Core\Notification\BaseNotification;
|
||||
use Modules\Core\Order\Events\OrderDispatched;
|
||||
use Modules\Core\Order\Support\OrderReferenceDisplay;
|
||||
use Modules\Core\Shipping\Models\Shipment;
|
||||
|
||||
|
||||
/**
|
||||
* Fills a real, previously-unfilled customer-communication gap — before
|
||||
@@ -53,6 +55,13 @@ class OrderDispatchedNotification extends BaseNotification
|
||||
->view('core::order.notifications.dispatched', [
|
||||
'order' => $order,
|
||||
'reference' => $reference,
|
||||
// Active, numbered, non-return shipments: the voucher numbers
|
||||
// to show, plus the courier's link for manual carriers
|
||||
// (Shipment::trackingUrl()). Integrated carriers' history is
|
||||
// on the customer's order page.
|
||||
'shipments' => $order->shipments()->get()
|
||||
->reject(fn (Shipment $shipment) => $shipment->isCancelled() || $shipment->isReturn() || blank($shipment->tracking_reference))
|
||||
->values(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,9 @@ use Modules\Core\Order\Support\OrderReferenceDisplay;
|
||||
* "Your order is ready to collect" — listens to the specific
|
||||
* OrderReadyForPickup event (dispatched by Modules\Core\Shipping\
|
||||
* Extensions\OrderViewExtension's "Mark Ready" action, store-pickup
|
||||
* branch only), not the generic OrderStatusUpdated. Modules\Core\Order\
|
||||
* Notifications\OrderStatusUpdatedNotification still separately
|
||||
* suppresses itself for the legacy 'ready-for-pickup' status string, kept
|
||||
* defensively even though nothing writes that literal value to
|
||||
* Order::status anymore after this redesign.
|
||||
* branch only), not the generic OrderStatusUpdated.
|
||||
* Modules\Core\Order\Notifications\OrderStatusUpdatedNotification
|
||||
* suppresses itself for that same write (its OWN_EMAIL map).
|
||||
*/
|
||||
class OrderPickupReadyNotification extends BaseNotification
|
||||
{
|
||||
|
||||
@@ -6,7 +6,12 @@ use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Modules\Core\Notification\BaseNotification;
|
||||
use Modules\Core\Order\Commands\CloseExpiredReturnWindows;
|
||||
use Modules\Core\Order\Events\OrderStatusUpdated;
|
||||
use Modules\Core\Order\Listeners\AdvanceFulfillmentOnCarrierCheckpoint;
|
||||
use Modules\Core\Order\Listeners\AdvanceFulfillmentOnDelivered;
|
||||
use Modules\Core\Order\Listeners\CompleteOrderOnPickedUp;
|
||||
use Modules\Core\Order\Services\OrderFulfillmentService;
|
||||
use Modules\Core\Order\Services\OrderStatusFlow;
|
||||
use Modules\Core\Order\Support\OrderReferenceDisplay;
|
||||
|
||||
@@ -25,12 +30,22 @@ class OrderStatusUpdatedNotification extends BaseNotification
|
||||
}
|
||||
|
||||
/**
|
||||
* 'ready-for-pickup' has its own, richer notification
|
||||
* (Modules\Core\Order\Notifications\OrderPickupReadyNotification) —
|
||||
* both listen to the same OrderStatusUpdated event via
|
||||
* NotificationRegistry, so without this the customer would get two
|
||||
* emails for that one transition. Returning no channels is the
|
||||
* standard Laravel way to suppress a notification outright.
|
||||
* Writes that send their own, richer email for the new status — the
|
||||
* generic one stays out of those, or the customer gets two. The same
|
||||
* status written any other way (staff's "Update Status", which fires
|
||||
* none of those events) still gets the generic email.
|
||||
*/
|
||||
private const OWN_EMAIL = [
|
||||
'ready_for_pickup' => [OrderFulfillmentService::class.'::markReady'], // OrderPickupReadyNotification
|
||||
'dispatched' => [AdvanceFulfillmentOnCarrierCheckpoint::class], // OrderDispatchedNotification
|
||||
'delivered' => [AdvanceFulfillmentOnDelivered::class], // OrderDeliveredNotification
|
||||
'completed' => [CompleteOrderOnPickedUp::class, CloseExpiredReturnWindows::class], // OrderCompletedNotification
|
||||
];
|
||||
|
||||
/**
|
||||
* Suppressed when a dedicated notification covers this write (see
|
||||
* OWN_EMAIL). Returning no channels is the standard Laravel way to
|
||||
* suppress a notification outright.
|
||||
*
|
||||
* Also suppressed for the order's very first transition off
|
||||
* 'awaiting_payment' — for every payment method except bank transfer,
|
||||
@@ -50,7 +65,7 @@ class OrderStatusUpdatedNotification extends BaseNotification
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
if ($this->event->newStatus === 'ready-for-pickup') {
|
||||
if (in_array($this->event->causeClass, self::OWN_EMAIL[$this->event->newStatus] ?? [], true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Modules\Core\Order\Observers;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Events\OrderStatusUpdated;
|
||||
use Modules\Core\Order\Services\OrderStatusWriter;
|
||||
|
||||
/**
|
||||
* Generically dispatches OrderStatusUpdated for ANY write to `status`,
|
||||
@@ -30,6 +31,7 @@ class OrderObserver
|
||||
$order,
|
||||
$order->getOriginal('status'),
|
||||
$order->status,
|
||||
OrderStatusWriter::currentCause(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -56,7 +58,15 @@ class OrderFulfillmentService
|
||||
return OrderFulfillmentResult::success('Order marked ready.');
|
||||
}
|
||||
|
||||
public function createShipmentAndDispatch(Order $order, ShipmentRequest $request): OrderFulfillmentResult
|
||||
/**
|
||||
* 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.');
|
||||
@@ -76,9 +86,48 @@ class OrderFulfillmentService
|
||||
return OrderFulfillmentResult::failure('Failed to create shipment: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$this->writer->write($order, 'dispatched', self::class.'::createShipmentAndDispatch');
|
||||
return OrderFulfillmentResult::success('Shipment created. The order moves to Dispatched when the carrier picks it up.');
|
||||
}
|
||||
|
||||
return OrderFulfillmentResult::success('Shipment created and order dispatched.');
|
||||
/**
|
||||
* 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
|
||||
@@ -175,14 +224,28 @@ class OrderFulfillmentService
|
||||
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()->exists() === false
|
||||
&& $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
|
||||
|
||||
@@ -29,6 +29,18 @@ use Modules\Core\Order\Events\OrderStatusChanged;
|
||||
*/
|
||||
class OrderStatusWriter
|
||||
{
|
||||
/**
|
||||
* The cause of the write in progress, so OrderObserver can put it on
|
||||
* the generic OrderStatusUpdated event (null for writes that don't go
|
||||
* through here, e.g. tinker).
|
||||
*/
|
||||
private static ?string $currentCause = null;
|
||||
|
||||
public static function currentCause(): ?string
|
||||
{
|
||||
return self::$currentCause;
|
||||
}
|
||||
|
||||
public function write(Order $order, string $to, string $causeClass): void
|
||||
{
|
||||
$from = $order->status;
|
||||
@@ -37,7 +49,13 @@ class OrderStatusWriter
|
||||
return;
|
||||
}
|
||||
|
||||
$order->update(['status' => $to]);
|
||||
self::$currentCause = $causeClass;
|
||||
|
||||
try {
|
||||
$order->update(['status' => $to]);
|
||||
} finally {
|
||||
self::$currentCause = null;
|
||||
}
|
||||
|
||||
OrderStatusChanged::dispatch($order, $from, $to, $causeClass);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,9 @@ class OrderStatus
|
||||
*/
|
||||
public static function fulfillment(Order $order): FulfillmentStatus
|
||||
{
|
||||
$shipments = $order->shipments->reject(fn ($shipment) => $shipment->cancelled_at !== null);
|
||||
// Return vouchers linked to the order describe the parcel coming
|
||||
// back, not its delivery — they never count towards fulfillment.
|
||||
$shipments = $order->shipments->reject(fn ($shipment) => $shipment->isCancelled() || $shipment->isReturn());
|
||||
|
||||
if ($shipments->isEmpty()) {
|
||||
return FulfillmentStatus::Unfulfilled;
|
||||
|
||||
Reference in New Issue
Block a user