Feature: Minor Updates to Order Shipping And Order Statuses
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* '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
|
||||
{
|
||||
if ($this->event->newStatus === 'ready-for-pickup') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ use Lunar\Models\Order;
|
||||
use Lunar\Models\Transaction;
|
||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||
use Modules\Core\Notification\NotificationRegistry;
|
||||
use Modules\Core\Order\Events\OrderDelivered;
|
||||
use Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus;
|
||||
use Modules\Core\Order\Listeners\CompleteOrderOnDelivered;
|
||||
use Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced;
|
||||
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
|
||||
use Modules\Core\Order\Listeners\RecordPaymentTransaction;
|
||||
@@ -37,6 +39,7 @@ class OrderServiceProvider extends ServiceProvider
|
||||
Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($this));
|
||||
|
||||
Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class);
|
||||
Event::listen(OrderDelivered::class, CompleteOrderOnDelivered::class);
|
||||
Event::listen(PaymentCaptured::class, ApplyResolvedPaymentStatus::class);
|
||||
Event::listen(PaymentAuthorized::class, ApplyResolvedPaymentStatus::class);
|
||||
Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class);
|
||||
|
||||
@@ -69,6 +69,31 @@ class ShippingServiceProvider extends ServiceProvider
|
||||
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) {
|
||||
Event::listen($event, [InvalidateShippingOptions::class, 'handle']);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class OrderViewExtension extends ViewPageExtension
|
||||
public function headerActions(array $actions): array
|
||||
{
|
||||
$actions[] = $this->createShipmentAction();
|
||||
$actions[] = $this->markPickedUpAction();
|
||||
|
||||
return $actions;
|
||||
}
|
||||
@@ -93,10 +94,41 @@ class OrderViewExtension extends ViewPageExtension
|
||||
->success()
|
||||
->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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$code = $record->shippingAddress?->shipping_option;
|
||||
|
||||
@@ -18,11 +18,41 @@ class ShippingMethodResourceExtension extends ResourceExtension
|
||||
{
|
||||
public function extendForm(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components(
|
||||
$this->replaceChargeByField(
|
||||
return $schema->components([
|
||||
...$this->replaceChargeByField(
|
||||
$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.');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user