Feature: Minor Updates to Order Shipping And Order Statuses

This commit is contained in:
2026-09-10 22:50:40 +03:00
parent 99e55902ac
commit 78bbd8390a
8 changed files with 207 additions and 5 deletions
@@ -0,0 +1,3 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is ready for pickup in store.</p>
@@ -0,0 +1,36 @@
<?php
namespace Modules\Core\Order\Listeners;
use Modules\Core\Order\Events\OrderDelivered;
/**
* Writes Order.status to 'completed' once a carrier confirms delivery —
* the terminal status a carrier-fulfilled order reaches on its own,
* without a human picking it from the dropdown, mirroring the store-pickup
* order's own terminal transition (Modules\Core\Shipping\Extensions\
* OrderViewExtension::markPickedUpAction()).
*
* Kept separate from Modules\Core\Order\Listeners\
* DeriveOrderDeliveredFromShipment, which only ever dispatches
* OrderDelivered — see that event's own docblock ("Order.status itself is
* left untouched here") for why deriving "was this delivered" and acting
* on it by writing status are deliberately two different listeners, same
* separation Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus
* already has from the Payment* events it reacts to.
*
* Guarded to only fire from 'dispatched' — a checkpoint arriving out of
* order, or against an order some other status flow has already moved
* past, shouldn't silently force it to 'completed'.
*/
class CompleteOrderOnDelivered
{
public function handle(OrderDelivered $event): void
{
if ($event->order->status !== 'dispatched') {
return;
}
$event->order->update(['status' => 'completed']);
}
}
@@ -0,0 +1,61 @@
<?php
namespace Modules\Core\Order\Notifications;
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\Events\OrderStatusUpdated;
/**
* "Your order is ready to collect" — fires on the same OrderStatusUpdated
* event Modules\Core\Order\Notifications\OrderStatusUpdatedNotification
* listens to, but only for the 'ready-for-pickup' transition; that other
* notification suppresses itself for this same transition (see its own
* via()) so a customer gets this richer, pickup-specific email instead of
* the generic "order updated" one, not both.
*/
class OrderPickupReadyNotification extends BaseNotification
{
public function __construct(private readonly OrderStatusUpdated $event) {}
public static function getKey(): string
{
return 'order.pickup_ready.customer.mail';
}
public static function listensTo(): string
{
return OrderStatusUpdated::class;
}
public function via(object $notifiable): array
{
if ($this->event->newStatus !== 'ready-for-pickup') {
return [];
}
return ['mail'];
}
public function notifiable(): AnonymousNotifiable
{
$order = $this->event->order;
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
return NotificationFacade::route('mail', $email);
}
public function toMail(object $notifiable): MailMessage
{
$order = $this->event->order;
return (new MailMessage)
->subject(__('Your order :reference is ready for pickup', ['reference' => $order->reference]))
->view('core::order.notifications.pickup-ready', [
'reference' => $order->reference,
]);
}
}
@@ -22,8 +22,20 @@ class OrderStatusUpdatedNotification extends BaseNotification
return OrderStatusUpdated::class; return OrderStatusUpdated::class;
} }
/**
* '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.
*/
public function via(object $notifiable): array public function via(object $notifiable): array
{ {
if ($this->event->newStatus === 'ready-for-pickup') {
return [];
}
return ['mail']; return ['mail'];
} }
+3
View File
@@ -8,7 +8,9 @@ use Lunar\Models\Order;
use Lunar\Models\Transaction; use Lunar\Models\Transaction;
use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Checkout\Events\OrderPlaced;
use Modules\Core\Notification\NotificationRegistry; use Modules\Core\Notification\NotificationRegistry;
use Modules\Core\Order\Events\OrderDelivered;
use Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus; use Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus;
use Modules\Core\Order\Listeners\CompleteOrderOnDelivered;
use Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced; use Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced;
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment; use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
use Modules\Core\Order\Listeners\RecordPaymentTransaction; use Modules\Core\Order\Listeners\RecordPaymentTransaction;
@@ -37,6 +39,7 @@ class OrderServiceProvider extends ServiceProvider
Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($this)); Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($this));
Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class); Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class);
Event::listen(OrderDelivered::class, CompleteOrderOnDelivered::class);
Event::listen(PaymentCaptured::class, ApplyResolvedPaymentStatus::class); Event::listen(PaymentCaptured::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentAuthorized::class, ApplyResolvedPaymentStatus::class); Event::listen(PaymentAuthorized::class, ApplyResolvedPaymentStatus::class);
Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class); Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class);
+25
View File
@@ -69,6 +69,31 @@ class ShippingServiceProvider extends ServiceProvider
return $order->hasMany(Shipment::class); return $order->hasMany(Shipment::class);
}); });
// ShippingMethod.data['fulfillment_type'] — see
// ShippingMethodResourceExtension::fulfillmentTypeSelect() for
// where it's set. Defaults to 'carrier' (false here) for any row
// saved before this field existed.
ShippingMethod::macro('isStorePickup', function () {
/** @var ShippingMethod $this */
return ($this->data['fulfillment_type'] ?? 'carrier') === 'store_pickup';
});
// Order has no direct ShippingMethod relation — shippingAddress.
// shipping_option is only ever a code string (see
// Modules\Core\Shipping\Extensions\OrderViewExtension::
// resolveCarrier() for the same lookup pattern already used to
// resolve a carrier driver from it).
Order::macro('isStorePickupOrder', function () {
/** @var Order $this */
$code = $this->shippingAddress?->shipping_option;
if (! $code) {
return false;
}
return ShippingMethod::where('code', $code)->first()?->isStorePickup() ?? false;
});
foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) { foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) {
Event::listen($event, [InvalidateShippingOptions::class, 'handle']); Event::listen($event, [InvalidateShippingOptions::class, 'handle']);
} }
+33 -1
View File
@@ -21,6 +21,7 @@ class OrderViewExtension extends ViewPageExtension
public function headerActions(array $actions): array public function headerActions(array $actions): array
{ {
$actions[] = $this->createShipmentAction(); $actions[] = $this->createShipmentAction();
$actions[] = $this->markPickedUpAction();
return $actions; return $actions;
} }
@@ -93,10 +94,41 @@ class OrderViewExtension extends ViewPageExtension
->success() ->success()
->send(); ->send();
}) })
->visible(fn (Order $record) => $record->shipments()->exists() === false ->visible(fn (Order $record) => $record->status === 'ready-for-dispatch'
&& ! $record->isStorePickupOrder()
&& $record->shipments()->exists() === false
&& $this->resolveFulfillmentService($record) !== null); && $this->resolveFulfillmentService($record) !== null);
} }
/**
* The store-pickup mirror of createShipmentAction() — a store-pickup
* order never gets a Shipment record (no carrier is ever involved), so
* it needs its own way to close out of 'ready-for-pickup' once the
* customer has actually collected it. Sets status directly to
* 'completed', same terminal status DeriveOrderDeliveredFromShipment
* writes for a carrier order once tracking confirms delivery — see
* that listener's own docblock.
*/
private function markPickedUpAction(): Action
{
return Action::make('mark_picked_up')
->label('Mark Picked Up')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->modalDescription('Confirms the customer has collected this order in store.')
->action(function (Order $record) {
$record->update(['status' => 'completed']);
Notification::make()
->title('Order marked as picked up.')
->success()
->send();
})
->visible(fn (Order $record) => $record->status === 'ready-for-pickup'
&& $record->isStorePickupOrder());
}
private function resolveCarrier(Order $record): ?string private function resolveCarrier(Order $record): ?string
{ {
$code = $record->shippingAddress?->shipping_option; $code = $record->shippingAddress?->shipping_option;
@@ -18,11 +18,41 @@ class ShippingMethodResourceExtension extends ResourceExtension
{ {
public function extendForm(Schema $schema): Schema public function extendForm(Schema $schema): Schema
{ {
return $schema->components( return $schema->components([
$this->replaceChargeByField( ...$this->replaceChargeByField(
$this->replaceDriverField($schema->getComponents()) $this->replaceDriverField($schema->getComponents())
) ),
); $this->fulfillmentTypeSelect(),
]);
}
/**
* ShippingMethod.data['fulfillment_type'] — 'carrier' (default) or
* 'store_pickup'. Same free-form-`data`-column pattern as charge_by
* above, not a migrated column: ShippingMethod is a vendor
* (lunarphp/table-rate-shipping) table, and this codebase avoids
* forking vendor migrations for a merchant-configurable extra (see
* PaymentMethod.data.fee for the same convention on a different
* vendor-adjacent model).
*
* What this actually gates: Modules\Core\Shipping\Extensions\
* OrderViewExtension's "Create Shipment" action only makes sense for
* a 'carrier' method (it books a real carrier voucher) — a
* 'store_pickup' order instead moves through Order.status
* 'ready-for-pickup' -> a staff "Mark Picked Up" action, no shipment
* ever created. See docs/checkout.md for the full status-flow design.
*/
private function fulfillmentTypeSelect(): Select
{
return Select::make('data.fulfillment_type')
->label('Fulfillment type')
->options([
'carrier' => 'Carrier delivery',
'store_pickup' => 'Collect in store',
])
->default('carrier')
->required()
->helperText('Whether an order using this method is handed to a carrier, or collected by the customer in person.');
} }
/** /**