diff --git a/resources/views/order/notifications/pickup-ready.blade.php b/resources/views/order/notifications/pickup-ready.blade.php new file mode 100644 index 0000000..0fefc06 --- /dev/null +++ b/resources/views/order/notifications/pickup-ready.blade.php @@ -0,0 +1,3 @@ +

Hi,

+ +

Your order {{ $reference }} is ready for pickup in store.

diff --git a/src/Order/Listeners/CompleteOrderOnDelivered.php b/src/Order/Listeners/CompleteOrderOnDelivered.php new file mode 100644 index 0000000..e67dc97 --- /dev/null +++ b/src/Order/Listeners/CompleteOrderOnDelivered.php @@ -0,0 +1,36 @@ +order->status !== 'dispatched') { + return; + } + + $event->order->update(['status' => 'completed']); + } +} diff --git a/src/Order/Notifications/OrderPickupReadyNotification.php b/src/Order/Notifications/OrderPickupReadyNotification.php new file mode 100644 index 0000000..c68f42c --- /dev/null +++ b/src/Order/Notifications/OrderPickupReadyNotification.php @@ -0,0 +1,61 @@ +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, + ]); + } +} diff --git a/src/Order/Notifications/OrderStatusUpdatedNotification.php b/src/Order/Notifications/OrderStatusUpdatedNotification.php index c2259f8..099c0a0 100644 --- a/src/Order/Notifications/OrderStatusUpdatedNotification.php +++ b/src/Order/Notifications/OrderStatusUpdatedNotification.php @@ -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']; } diff --git a/src/Providers/OrderServiceProvider.php b/src/Providers/OrderServiceProvider.php index 7566e8c..86349d7 100644 --- a/src/Providers/OrderServiceProvider.php +++ b/src/Providers/OrderServiceProvider.php @@ -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); diff --git a/src/Providers/ShippingServiceProvider.php b/src/Providers/ShippingServiceProvider.php index 4ad386c..9e8d2ef 100644 --- a/src/Providers/ShippingServiceProvider.php +++ b/src/Providers/ShippingServiceProvider.php @@ -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']); } diff --git a/src/Shipping/Extensions/OrderViewExtension.php b/src/Shipping/Extensions/OrderViewExtension.php index 18929c8..eec8e28 100644 --- a/src/Shipping/Extensions/OrderViewExtension.php +++ b/src/Shipping/Extensions/OrderViewExtension.php @@ -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; diff --git a/src/Shipping/Extensions/ShippingMethodResourceExtension.php b/src/Shipping/Extensions/ShippingMethodResourceExtension.php index b8aa320..3de31fb 100644 --- a/src/Shipping/Extensions/ShippingMethodResourceExtension.php +++ b/src/Shipping/Extensions/ShippingMethodResourceExtension.php @@ -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.'); } /**