Feature: Minor Updates to Order Shipping And Order Statuses
This commit is contained in:
@@ -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'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user