From 44c6b7defd7bf67923af02deb26d9eb86b0aafd0 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Mon, 14 Sep 2026 00:03:06 +0300 Subject: [PATCH] Feature: Order Updates, Events, Order Flows, Shipment And COD support --- config/core.php | 17 ++ ...000001_add_status_axes_to_orders_table.php | 43 +++ ..._create_order_status_transitions_table.php | 42 +++ ...9_11_000003_backfill_order_status_axes.php | 79 +++++ ...tus_columns_from_payment_methods_table.php | 36 +++ ..._09_12_000001_add_paid_to_orders_table.php | 30 ++ ..._backfill_single_order_status_and_paid.php | 116 +++++++ ...003_drop_status_axes_from_orders_table.php | 34 +++ ...is_from_order_status_transitions_table.php | 33 ++ ...cash_on_delivery_payment_method_driver.php | 27 ++ ...return_window_open_status_to_delivered.php | 30 ++ ...26_09_13_000002_create_manifests_table.php | 43 +++ ...003_add_manifest_id_to_shipments_table.php | 82 +++++ .../order/notifications/completed.blade.php | 3 + .../order/notifications/dispatched.blade.php | 3 + .../pages/manage-pickup-manifests.blade.php | 3 - src/Checkout/Services/CheckoutService.php | 1 + src/Command/InstallLunarCommand.php | 3 +- src/CorePlugin.php | 12 +- .../Commands/CloseExpiredReturnWindows.php | 68 +++++ src/Order/DTOs/OrderFulfillmentResult.php | 30 ++ src/Order/Events/OrderCompleted.php | 25 ++ src/Order/Events/OrderDispatched.php | 29 ++ src/Order/Events/OrderPaidChanged.php | 25 ++ src/Order/Events/OrderPickedUp.php | 25 ++ src/Order/Events/OrderReadyForDispatch.php | 23 ++ src/Order/Events/OrderReadyForPickup.php | 22 ++ src/Order/Events/OrderStatusChanged.php | 30 ++ .../OrderPaymentMethodSummaryExtension.php | 47 +++ .../AdvanceFulfillmentOnCarrierCheckpoint.php | 49 +++ .../AdvanceFulfillmentOnDelivered.php | 43 +++ .../Listeners/ApplyResolvedPaymentStatus.php | 110 +++---- .../Listeners/CompleteOrderOnDelivered.php | 36 --- .../Listeners/CompleteOrderOnPickedUp.php | 39 +++ .../MarkDeliveryFailedOnCarrierCheckpoint.php | 35 +++ .../Listeners/RecordStatusTransition.php | 33 ++ src/Order/Models/OrderStatusTransition.php | 29 ++ .../OrderCompletedNotification.php | 49 +++ .../OrderDispatchedNotification.php | 54 ++++ .../OrderPickupReadyNotification.php | 24 +- src/Order/Observers/OrderObserver.php | 30 +- .../Services/OrderFulfillmentService.php | 169 +++++++++++ src/Order/Services/OrderStatusFlow.php | 140 +++++++++ .../OrderStatusTransitionRecorder.php | 27 ++ src/Order/Services/OrderStatusWriter.php | 55 ++++ .../Drivers/CashOnDeliveryPaymentDriver.php | 50 +++ src/Payment/Drivers/OfflinePaymentDriver.php | 9 +- .../Resources/PaymentMethodResource.php | 33 +- src/Payment/Models/PaymentMethod.php | 10 - src/Payment/Services/PaymentMethodService.php | 3 +- src/Providers/OrderServiceProvider.php | 51 +++- src/Providers/PaymentServiceProvider.php | 2 + src/Providers/ShippingServiceProvider.php | 37 ++- .../Carriers/Acs/AcsFulfillmentService.php | 21 +- src/Shipping/Carriers/Acs/AcsRateDriver.php | 23 +- .../BoxNow/BoxNowFulfillmentService.php | 62 ++-- src/Shipping/DTOs/ManifestResult.php | 8 +- src/Shipping/DTOs/ShipmentRequest.php | 15 +- src/Shipping/Enums/TrackingStatus.php | 19 ++ .../Extensions/OrderShipmentsExtension.php | 186 ++++++++++++ .../Extensions/OrderViewExtension.php | 286 +++++++++++------- .../Filament/Pages/ManagePickupManifests.php | 123 -------- .../Filament/Resources/ManifestResource.php | 66 ++++ .../ManifestResource/Pages/ListManifests.php | 28 ++ .../ManifestResource/Pages/ViewManifest.php | 33 ++ .../ShipmentsRelationManager.php | 39 +++ .../Filament/Resources/ShipmentResource.php | 153 ++++++++++ .../ShipmentResource/Pages/ListShipments.php | 36 +++ .../DownloadShipmentLabelController.php | 55 ++++ src/Shipping/Models/Manifest.php | 28 ++ src/Shipping/Models/Shipment.php | 5 + src/Shipping/Support/WeightCalculator.php | 47 +++ src/Shipping/routes/web.php | 7 + 73 files changed, 2854 insertions(+), 464 deletions(-) create mode 100644 database/migrations/2026_09_11_000001_add_status_axes_to_orders_table.php create mode 100644 database/migrations/2026_09_11_000002_create_order_status_transitions_table.php create mode 100644 database/migrations/2026_09_11_000003_backfill_order_status_axes.php create mode 100644 database/migrations/2026_09_11_000004_drop_status_columns_from_payment_methods_table.php create mode 100644 database/migrations/2026_09_12_000001_add_paid_to_orders_table.php create mode 100644 database/migrations/2026_09_12_000002_backfill_single_order_status_and_paid.php create mode 100644 database/migrations/2026_09_12_000003_drop_status_axes_from_orders_table.php create mode 100644 database/migrations/2026_09_12_000004_drop_axis_from_order_status_transitions_table.php create mode 100644 database/migrations/2026_09_12_000005_correct_cash_on_delivery_payment_method_driver.php create mode 100644 database/migrations/2026_09_13_000001_rename_return_window_open_status_to_delivered.php create mode 100644 database/migrations/2026_09_13_000002_create_manifests_table.php create mode 100644 database/migrations/2026_09_13_000003_add_manifest_id_to_shipments_table.php create mode 100644 resources/views/order/notifications/completed.blade.php create mode 100644 resources/views/order/notifications/dispatched.blade.php delete mode 100644 resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php create mode 100644 src/Order/Commands/CloseExpiredReturnWindows.php create mode 100644 src/Order/DTOs/OrderFulfillmentResult.php create mode 100644 src/Order/Events/OrderCompleted.php create mode 100644 src/Order/Events/OrderDispatched.php create mode 100644 src/Order/Events/OrderPaidChanged.php create mode 100644 src/Order/Events/OrderPickedUp.php create mode 100644 src/Order/Events/OrderReadyForDispatch.php create mode 100644 src/Order/Events/OrderReadyForPickup.php create mode 100644 src/Order/Events/OrderStatusChanged.php create mode 100644 src/Order/Filament/Extensions/OrderPaymentMethodSummaryExtension.php create mode 100644 src/Order/Listeners/AdvanceFulfillmentOnCarrierCheckpoint.php create mode 100644 src/Order/Listeners/AdvanceFulfillmentOnDelivered.php delete mode 100644 src/Order/Listeners/CompleteOrderOnDelivered.php create mode 100644 src/Order/Listeners/CompleteOrderOnPickedUp.php create mode 100644 src/Order/Listeners/MarkDeliveryFailedOnCarrierCheckpoint.php create mode 100644 src/Order/Listeners/RecordStatusTransition.php create mode 100644 src/Order/Models/OrderStatusTransition.php create mode 100644 src/Order/Notifications/OrderCompletedNotification.php create mode 100644 src/Order/Notifications/OrderDispatchedNotification.php create mode 100644 src/Order/Services/OrderFulfillmentService.php create mode 100644 src/Order/Services/OrderStatusFlow.php create mode 100644 src/Order/Services/OrderStatusTransitionRecorder.php create mode 100644 src/Order/Services/OrderStatusWriter.php create mode 100644 src/Payment/Drivers/CashOnDeliveryPaymentDriver.php create mode 100644 src/Shipping/Extensions/OrderShipmentsExtension.php delete mode 100644 src/Shipping/Filament/Pages/ManagePickupManifests.php create mode 100644 src/Shipping/Filament/Resources/ManifestResource.php create mode 100644 src/Shipping/Filament/Resources/ManifestResource/Pages/ListManifests.php create mode 100644 src/Shipping/Filament/Resources/ManifestResource/Pages/ViewManifest.php create mode 100644 src/Shipping/Filament/Resources/ManifestResource/RelationManagers/ShipmentsRelationManager.php create mode 100644 src/Shipping/Filament/Resources/ShipmentResource.php create mode 100644 src/Shipping/Filament/Resources/ShipmentResource/Pages/ListShipments.php create mode 100644 src/Shipping/Http/Controllers/DownloadShipmentLabelController.php create mode 100644 src/Shipping/Models/Manifest.php create mode 100644 src/Shipping/Support/WeightCalculator.php create mode 100644 src/Shipping/routes/web.php diff --git a/config/core.php b/config/core.php index 4f09573..49d34f6 100644 --- a/config/core.php +++ b/config/core.php @@ -48,4 +48,21 @@ return [ 'unrecoverable_after' => '90 days', ], + /* + |-------------------------------------------------------------------------- + | Order Return Window + |-------------------------------------------------------------------------- + | + | How many days after a carrier order is delivered (Order::fulfillment_status + | becomes 'return_window_open') before Modules\Core\Order\Commands\ + | CloseExpiredReturnWindows auto-completes it, if no return was requested. + | Store-pickup orders have no return-window step and are unaffected by + | this value (see Modules\Core\Order\Listeners\CompleteOrderOnPickedUp). + | + */ + + 'order' => [ + 'return_window_days' => 14, + ], + ]; diff --git a/database/migrations/2026_09_11_000001_add_status_axes_to_orders_table.php b/database/migrations/2026_09_11_000001_add_status_axes_to_orders_table.php new file mode 100644 index 0000000..001192a --- /dev/null +++ b/database/migrations/2026_09_11_000001_add_status_axes_to_orders_table.php @@ -0,0 +1,43 @@ +string('payment_status')->default('awaiting_payment')->after('status')->index(); + $table->string('fulfillment_status')->default('unfulfilled')->after('payment_status')->index(); + $table->string('return_status')->default('none')->after('fulfillment_status')->index(); + }); + } + + public function down(): void + { + Schema::table('lunar_orders', function (Blueprint $table) { + $table->dropColumn(['payment_status', 'fulfillment_status', 'return_status']); + }); + } +}; diff --git a/database/migrations/2026_09_11_000002_create_order_status_transitions_table.php b/database/migrations/2026_09_11_000002_create_order_status_transitions_table.php new file mode 100644 index 0000000..38b9a29 --- /dev/null +++ b/database/migrations/2026_09_11_000002_create_order_status_transitions_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('order_id')->constrained('lunar_orders')->cascadeOnDelete(); + $table->string('axis'); + $table->string('from_status')->nullable(); + $table->string('to_status'); + $table->string('event_class'); + $table->timestamp('created_at')->useCurrent(); + $table->index(['order_id', 'axis']); + }); + } + + public function down(): void + { + Schema::dropIfExists('order_status_transitions'); + } +}; diff --git a/database/migrations/2026_09_11_000003_backfill_order_status_axes.php b/database/migrations/2026_09_11_000003_backfill_order_status_axes.php new file mode 100644 index 0000000..63a53d9 --- /dev/null +++ b/database/migrations/2026_09_11_000003_backfill_order_status_axes.php @@ -0,0 +1,79 @@ + ['payment_status' => 'awaiting_payment', 'fulfillment_status' => 'unfulfilled'], + 'payment-offline' => ['payment_status' => 'awaiting_payment', 'fulfillment_status' => 'unfulfilled'], + 'payment-received' => ['payment_status' => 'paid', 'fulfillment_status' => 'unfulfilled'], + 'ready-for-dispatch' => ['payment_status' => 'paid', 'fulfillment_status' => 'ready'], + 'ready-for-pickup' => ['payment_status' => 'paid', 'fulfillment_status' => 'ready'], + 'dispatched' => ['payment_status' => 'paid', 'fulfillment_status' => 'in_transit'], + 'completed' => ['payment_status' => 'paid', 'fulfillment_status' => 'completed'], + ]; + + public function up(): void + { + Order::query()->with('transactions')->chunkById(200, function ($orders) { + foreach ($orders as $order) { + $mapped = self::MAP[$order->status] ?? null; + + if ($mapped === null) { + Log::warning('Order status axis backfill: unmapped status, leaving column defaults', [ + 'order_id' => $order->id, + 'status' => $order->status, + ]); + + continue; + } + + $paymentStatus = $mapped['payment_status']; + + $derived = OrderStatus::payment($order); + + if ($derived === PaymentStatus::Refunded) { + $paymentStatus = 'refunded'; + } elseif ($derived === PaymentStatus::PartialRefund) { + $paymentStatus = 'partially_refunded'; + } + + DB::table('lunar_orders')->where('id', $order->id)->update([ + 'payment_status' => $paymentStatus, + 'fulfillment_status' => $mapped['fulfillment_status'], + 'return_status' => 'none', + ]); + } + }); + } + + public function down(): void + { + // Column defaults (set in the schema migration) are the correct + // "undo" — no need to reverse-map back to the flat status, since + // `status` itself was never touched by this migration. + } +}; diff --git a/database/migrations/2026_09_11_000004_drop_status_columns_from_payment_methods_table.php b/database/migrations/2026_09_11_000004_drop_status_columns_from_payment_methods_table.php new file mode 100644 index 0000000..6e24878 --- /dev/null +++ b/database/migrations/2026_09_11_000004_drop_status_columns_from_payment_methods_table.php @@ -0,0 +1,36 @@ +dropColumn(['captured_status', 'authorized_status', 'refunded_status']); + }); + } + + public function down(): void + { + Schema::table('payment_methods', function (Blueprint $table) { + $table->string('captured_status')->nullable(); + $table->string('authorized_status')->nullable(); + $table->string('refunded_status')->nullable(); + }); + } +}; diff --git a/database/migrations/2026_09_12_000001_add_paid_to_orders_table.php b/database/migrations/2026_09_12_000001_add_paid_to_orders_table.php new file mode 100644 index 0000000..e012efb --- /dev/null +++ b/database/migrations/2026_09_12_000001_add_paid_to_orders_table.php @@ -0,0 +1,30 @@ +boolean('paid')->default(false)->after('status')->index(); + $table->timestamp('paid_at')->nullable()->after('paid'); + }); + } + + public function down(): void + { + Schema::table('lunar_orders', function (Blueprint $table) { + $table->dropColumn(['paid', 'paid_at']); + }); + } +}; diff --git a/database/migrations/2026_09_12_000002_backfill_single_order_status_and_paid.php b/database/migrations/2026_09_12_000002_backfill_single_order_status_and_paid.php new file mode 100644 index 0000000..d9a9279 --- /dev/null +++ b/database/migrations/2026_09_12_000002_backfill_single_order_status_and_paid.php @@ -0,0 +1,116 @@ + 'awaiting_payment', + 'payment-offline' => 'awaiting_payment', + 'payment-received' => 'processing', + 'ready-for-dispatch' => 'ready_for_dispatch', + 'ready-for-pickup' => 'ready_for_pickup', + 'dispatched' => 'dispatched', + 'completed' => 'completed', + ]; + + /** + * Axis fulfillment_status -> new single status, given branch. Axis + * 'delivered' folds into 'return_window_open' (same combined-value + * decision the going-forward design makes). Axis payment_status is + * used only to decide whether a fully-unfulfilled order should read + * as 'awaiting_payment' or 'processing'. + */ + private function mapFromAxes(string $payment, string $fulfillment, string $return, bool $isPickup): ?string + { + if ($return === 'returned') { + return 'returned'; + } + if ($return === 'requested') { + return 'return_requested'; + } + + return match ($fulfillment) { + 'unfulfilled' => $payment === 'paid' ? 'processing' : 'awaiting_payment', + 'processing' => 'processing', + 'ready' => $isPickup ? 'ready_for_pickup' : 'ready_for_dispatch', + 'in_transit' => 'dispatched', + 'delivered', 'return_window_open' => 'return_window_open', + 'picked_up' => 'picked_up', + 'completed' => 'completed', + default => null, + }; + } + + public function up(): void + { + Order::query()->with('transactions')->chunkById(200, function ($orders) { + foreach ($orders as $order) { + $isPickup = $order->isStorePickupOrder(); + + $axisIsDefault = $order->payment_status === 'awaiting_payment' + && $order->fulfillment_status === 'unfulfilled' + && $order->return_status === 'none'; + + $status = $axisIsDefault + ? (self::LEGACY_MAP[$order->status] ?? null) + : $this->mapFromAxes($order->payment_status, $order->fulfillment_status, $order->return_status, $isPickup); + + if ($status === null) { + Log::warning('Single-status backfill: unmapped order, defaulting to awaiting_payment', [ + 'order_id' => $order->id, + 'status' => $order->status, + 'payment_status' => $order->payment_status, + 'fulfillment_status' => $order->fulfillment_status, + 'return_status' => $order->return_status, + ]); + $status = 'awaiting_payment'; + } + + $derived = OrderStatus::payment($order); + $paid = $order->payment_status === 'paid' + || in_array($derived, [PaymentStatus::Captured, PaymentStatus::Refunded, PaymentStatus::PartialRefund], true); + + // A refund implies the order concluded via a return — + // even one backfilled to an early status (e.g. an order + // refunded before fulfillment ever started) is corrected + // to refunded/partially_refunded here, not left stuck + // pre-fulfillment with no sign a refund ever happened. + if ($derived === PaymentStatus::Refunded) { + $status = 'refunded'; + } elseif ($derived === PaymentStatus::PartialRefund) { + $status = 'partially_refunded'; + } + + DB::table('lunar_orders')->where('id', $order->id)->update([ + 'status' => $status, + 'paid' => $paid, + 'paid_at' => $paid ? ($order->placed_at ?? now()) : null, + ]); + } + }); + } + + public function down(): void + { + // No reverse mapping — column defaults (post-rollback of the + // schema migrations) are the correct "undo". + } +}; diff --git a/database/migrations/2026_09_12_000003_drop_status_axes_from_orders_table.php b/database/migrations/2026_09_12_000003_drop_status_axes_from_orders_table.php new file mode 100644 index 0000000..8644e00 --- /dev/null +++ b/database/migrations/2026_09_12_000003_drop_status_axes_from_orders_table.php @@ -0,0 +1,34 @@ +dropColumn(['payment_status', 'fulfillment_status', 'return_status']); + }); + } + + public function down(): void + { + // Mirrors 2026_09_11_000001's own down() — restores columns + // empty/defaulted, does not attempt to resurrect real per-order + // values. + Schema::table('lunar_orders', function (Blueprint $table) { + $table->string('payment_status')->default('awaiting_payment')->after('paid_at')->index(); + $table->string('fulfillment_status')->default('unfulfilled')->after('payment_status')->index(); + $table->string('return_status')->default('none')->after('fulfillment_status')->index(); + }); + } +}; diff --git a/database/migrations/2026_09_12_000004_drop_axis_from_order_status_transitions_table.php b/database/migrations/2026_09_12_000004_drop_axis_from_order_status_transitions_table.php new file mode 100644 index 0000000..8ae0e36 --- /dev/null +++ b/database/migrations/2026_09_12_000004_drop_axis_from_order_status_transitions_table.php @@ -0,0 +1,33 @@ +dropIndex(['order_id', 'axis']); + $table->dropColumn('axis'); + $table->index('order_id'); + }); + } + + public function down(): void + { + Schema::table('order_status_transitions', function (Blueprint $table) { + $table->dropIndex(['order_id']); + $table->string('axis')->default('status')->after('order_id'); + $table->index(['order_id', 'axis']); + }); + } +}; diff --git a/database/migrations/2026_09_12_000005_correct_cash_on_delivery_payment_method_driver.php b/database/migrations/2026_09_12_000005_correct_cash_on_delivery_payment_method_driver.php new file mode 100644 index 0000000..e72c810 --- /dev/null +++ b/database/migrations/2026_09_12_000005_correct_cash_on_delivery_payment_method_driver.php @@ -0,0 +1,27 @@ + 'offline' — the same immediate-capture driver as + * cash-in-hand. That's the bug that made COD "pay immediately" instead of + * waiting for staff to confirm cash was actually received. Repoints + * already-seeded environments to the new dedicated + * Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver; the seeder + * itself is fixed separately for fresh installs. + */ +return new class extends Migration +{ + public function up(): void + { + DB::table('payment_methods')->where('type', 'cash-on-delivery')->update(['driver' => 'cash-on-delivery']); + } + + public function down(): void + { + DB::table('payment_methods')->where('type', 'cash-on-delivery')->update(['driver' => 'offline']); + } +}; diff --git a/database/migrations/2026_09_13_000001_rename_return_window_open_status_to_delivered.php b/database/migrations/2026_09_13_000001_rename_return_window_open_status_to_delivered.php new file mode 100644 index 0000000..001b528 --- /dev/null +++ b/database/migrations/2026_09_13_000001_rename_return_window_open_status_to_delivered.php @@ -0,0 +1,30 @@ +where('status', 'return_window_open')->update(['status' => 'delivered']); + DB::table('order_status_transitions')->where('from_status', 'return_window_open')->update(['from_status' => 'delivered']); + DB::table('order_status_transitions')->where('to_status', 'return_window_open')->update(['to_status' => 'delivered']); + } + + public function down(): void + { + DB::table('lunar_orders')->where('status', 'delivered')->update(['status' => 'return_window_open']); + DB::table('order_status_transitions')->where('from_status', 'delivered')->update(['from_status' => 'return_window_open']); + DB::table('order_status_transitions')->where('to_status', 'delivered')->update(['to_status' => 'return_window_open']); + } +}; diff --git a/database/migrations/2026_09_13_000002_create_manifests_table.php b/database/migrations/2026_09_13_000002_create_manifests_table.php new file mode 100644 index 0000000..1e9abd4 --- /dev/null +++ b/database/migrations/2026_09_13_000002_create_manifests_table.php @@ -0,0 +1,43 @@ +count()) purely so the manifests list can + * render without an extra query per row. + * + * carrier-agnostic by design — see Modules\Core\Shipping\Contracts\ + * SupportsManifestBatching, the same contract any future carrier + * (Speedex, etc.) implements to get manifest batching at all; this table + * has no ACS-specific columns. + */ +return new class extends Migration +{ + public function up(): void + { + Schema::create('manifests', function (Blueprint $table) { + $table->id(); + $table->string('carrier'); + $table->string('reference'); + $table->unsignedInteger('shipment_count')->default(0); + $table->timestamp('issued_at'); + $table->timestamps(); + + $table->unique(['carrier', 'reference']); + }); + } + + public function down(): void + { + Schema::dropIfExists('manifests'); + } +}; diff --git a/database/migrations/2026_09_13_000003_add_manifest_id_to_shipments_table.php b/database/migrations/2026_09_13_000003_add_manifest_id_to_shipments_table.php new file mode 100644 index 0000000..4b3de28 --- /dev/null +++ b/database/migrations/2026_09_13_000003_add_manifest_id_to_shipments_table.php @@ -0,0 +1,82 @@ +foreignId('manifest_id')->nullable()->after('manifest_reference')->constrained()->nullOnDelete(); + }); + + $groups = DB::table('shipments') + ->select('carrier', 'manifest_reference') + ->whereNotNull('manifest_reference') + ->distinct() + ->get(); + + foreach ($groups as $group) { + $shipments = DB::table('shipments') + ->where('carrier', $group->carrier) + ->where('manifest_reference', $group->manifest_reference) + ->get(); + + $issuedAt = $shipments->pluck('label_printed_at')->filter()->min() + ?? $shipments->pluck('updated_at')->min(); + + $manifestId = DB::table('manifests')->insertGetId([ + 'carrier' => $group->carrier, + 'reference' => $group->manifest_reference, + 'shipment_count' => $shipments->count(), + 'issued_at' => $issuedAt, + 'created_at' => $issuedAt, + 'updated_at' => $issuedAt, + ]); + + DB::table('shipments') + ->where('carrier', $group->carrier) + ->where('manifest_reference', $group->manifest_reference) + ->update(['manifest_id' => $manifestId]); + } + + Schema::table('shipments', function (Blueprint $table) { + $table->dropColumn('manifest_reference'); + }); + } + + public function down(): void + { + Schema::table('shipments', function (Blueprint $table) { + $table->string('manifest_reference')->nullable()->after('parent_reference'); + }); + + DB::table('shipments') + ->whereNotNull('manifest_id') + ->orderBy('id') + ->each(function ($shipment) { + $manifest = DB::table('manifests')->find($shipment->manifest_id); + + if ($manifest) { + DB::table('shipments')->where('id', $shipment->id)->update([ + 'manifest_reference' => $manifest->reference, + ]); + } + }); + + Schema::table('shipments', function (Blueprint $table) { + $table->dropConstrainedForeignId('manifest_id'); + }); + } +}; diff --git a/resources/views/order/notifications/completed.blade.php b/resources/views/order/notifications/completed.blade.php new file mode 100644 index 0000000..d5df044 --- /dev/null +++ b/resources/views/order/notifications/completed.blade.php @@ -0,0 +1,3 @@ +

Hi,

+ +

Your order {{ $reference }} is complete. Thanks for shopping with us!

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

Hi,

+ +

Your order {{ $reference }} is on its way.

diff --git a/resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php b/resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php deleted file mode 100644 index ce096a2..0000000 --- a/resources/views/shipping/filament/pages/manage-pickup-manifests.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - - {{ $this->table }} - diff --git a/src/Checkout/Services/CheckoutService.php b/src/Checkout/Services/CheckoutService.php index 0991aba..aad91d5 100644 --- a/src/Checkout/Services/CheckoutService.php +++ b/src/Checkout/Services/CheckoutService.php @@ -297,6 +297,7 @@ class CheckoutService $order->meta = [ ...($order->meta?->toArray() ?? []), + 'payment_method' => $type, 'terms_accepted' => true, 'terms_accepted_at' => now()->toIso8601String(), 'terms_accepted_policy_version' => $policyVersion, diff --git a/src/Command/InstallLunarCommand.php b/src/Command/InstallLunarCommand.php index 0475e72..1c0c040 100644 --- a/src/Command/InstallLunarCommand.php +++ b/src/Command/InstallLunarCommand.php @@ -311,9 +311,8 @@ class InstallLunarCommand extends Command PaymentMethod::create([ 'type' => 'cash-on-delivery', 'name' => 'Cash on Delivery', - 'driver' => 'offline', + 'driver' => 'cash-on-delivery', 'capture_mode' => 'pay', - 'captured_status' => 'payment-offline', 'position' => 0, 'enabled' => false, 'data' => [], diff --git a/src/CorePlugin.php b/src/CorePlugin.php index 583e79d..31efae4 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -27,15 +27,18 @@ use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension; use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension; use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Order\Filament\Extensions\OrderItemsTableExtension; +use Modules\Core\Order\Filament\Extensions\OrderPaymentMethodSummaryExtension; use Modules\Core\Order\Filament\Extensions\OrderRefundActionsExtension; use Modules\Core\Order\Filament\Extensions\OrderTransactionsExtension; use Modules\Core\Payment\Filament\Resources\PaymentMethodResource; use Modules\Core\Review\Filament\Extensions\ProductResourceExtension; use Modules\Core\Review\Models\ProductReview; +use Modules\Core\Shipping\Extensions\OrderShipmentsExtension; use Modules\Core\Shipping\Extensions\OrderViewExtension; use Modules\Core\Shipping\Extensions\ShippingMethodListExtension; use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension; -use Modules\Core\Shipping\Filament\Pages\ManagePickupManifests; +use Modules\Core\Shipping\Filament\Resources\ManifestResource; +use Modules\Core\Shipping\Filament\Resources\ShipmentResource; class CorePlugin implements Plugin { @@ -55,9 +58,10 @@ class CorePlugin implements Plugin LanguageLineResource::class, CartResource::class, PaymentMethodResource::class, + ShipmentResource::class, + ManifestResource::class, ]) - ->plugin(ShippingPlugin::make()) - ->pages([ManagePickupManifests::class]); + ->plugin(ShippingPlugin::make()); LunarPanel::extensions([ StaffResource::class => StaffResourceExtension::class, @@ -66,7 +70,7 @@ class CorePlugin implements Plugin ValuesRelationManager::class => ValuesRelationManagerExtension::class, ShippingMethodResource::class => ShippingMethodResourceExtension::class, ListShippingMethod::class => ShippingMethodListExtension::class, - ManageOrder::class => [OrderViewExtension::class, OrderRefundActionsExtension::class, OrderTransactionsExtension::class], + ManageOrder::class => [OrderViewExtension::class, OrderRefundActionsExtension::class, OrderTransactionsExtension::class, OrderPaymentMethodSummaryExtension::class, OrderShipmentsExtension::class], OrderItemsTable::class => OrderItemsTableExtension::class, ]); diff --git a/src/Order/Commands/CloseExpiredReturnWindows.php b/src/Order/Commands/CloseExpiredReturnWindows.php new file mode 100644 index 0000000..2f65408 --- /dev/null +++ b/src/Order/Commands/CloseExpiredReturnWindows.php @@ -0,0 +1,68 @@ +subDays(config('core.order.return_window_days', 14)); + + $orderIds = Order::query() + ->where('status', 'delivered') + ->whereHas('statusTransitions', function ($query) use ($cutoff) { + $query->where('to_status', 'delivered') + ->where('created_at', '<=', $cutoff); + }) + ->pluck('id'); + + $completed = 0; + + foreach ($orderIds as $orderId) { + $order = Order::find($orderId); + + if (! $order || $order->status !== 'delivered') { + continue; // idempotent no-op — moved on since the query ran + } + + $writer->write($order, 'completed', self::class); + + OrderCompleted::dispatch($order); + + $completed++; + } + + $this->components->info("Completed {$completed} order(s) past their return window."); + } +} diff --git a/src/Order/DTOs/OrderFulfillmentResult.php b/src/Order/DTOs/OrderFulfillmentResult.php new file mode 100644 index 0000000..32ca635 --- /dev/null +++ b/src/Order/DTOs/OrderFulfillmentResult.php @@ -0,0 +1,30 @@ +label('Payment method') + ->state(fn (Order $record) => $this->resolveLabel($record)) + ->placeholder('—') + ->alignEnd(); + + return $schema; + } + + private function resolveLabel(Order $record): ?string + { + $type = $record->meta['payment_method'] ?? $record->transactions()->latest('id')->value('driver'); + + if ($type === null) { + return null; + } + + return PaymentMethod::where('type', $type)->value('name') ?? $type; + } +} diff --git a/src/Order/Listeners/AdvanceFulfillmentOnCarrierCheckpoint.php b/src/Order/Listeners/AdvanceFulfillmentOnCarrierCheckpoint.php new file mode 100644 index 0000000..e5451bc --- /dev/null +++ b/src/Order/Listeners/AdvanceFulfillmentOnCarrierCheckpoint.php @@ -0,0 +1,49 @@ +shipmentInfo->status !== TrackingStatus::InTransit + && $event->shipmentInfo->status !== TrackingStatus::CollectedFromSender) { + return; + } + + $order = $event->shipmentInfo->shipment->order; + + if (! $order || $order->status !== 'ready_for_dispatch') { + return; + } + + $this->writer->write($order, 'dispatched', self::class); + + OrderDispatched::dispatch($order, $event->shipmentInfo); + } +} diff --git a/src/Order/Listeners/AdvanceFulfillmentOnDelivered.php b/src/Order/Listeners/AdvanceFulfillmentOnDelivered.php new file mode 100644 index 0000000..1ca6e9b --- /dev/null +++ b/src/Order/Listeners/AdvanceFulfillmentOnDelivered.php @@ -0,0 +1,43 @@ +order; + + if ($order->status !== 'dispatched') { + return; + } + + $this->writer->write($order, 'delivered', self::class); + } +} diff --git a/src/Order/Listeners/ApplyResolvedPaymentStatus.php b/src/Order/Listeners/ApplyResolvedPaymentStatus.php index 12869f1..2df32a2 100644 --- a/src/Order/Listeners/ApplyResolvedPaymentStatus.php +++ b/src/Order/Listeners/ApplyResolvedPaymentStatus.php @@ -5,43 +5,45 @@ namespace Modules\Core\Order\Listeners; use Illuminate\Support\Facades\Event; use Lunar\Models\Order; use Modules\Core\Checkout\Events\OrderPlaced; +use Modules\Core\Order\Enums\PaymentStatus; +use Modules\Core\Order\Services\OrderStatusWriter; +use Modules\Core\Order\Support\OrderStatus; use Modules\Core\Payment\Events\PaymentAuthorized; use Modules\Core\Payment\Events\PaymentCaptured; use Modules\Core\Payment\Events\PaymentRefunded; -use Modules\Core\Payment\Models\PaymentMethod; -use Modules\Core\Payment\Services\PaymentMethodCache; /** - * The only place an Order's status column is written in reaction to a - * payment outcome. Registered against PaymentCaptured, PaymentAuthorized, - * AND PaymentRefunded (see OrderServiceProvider) — same handler for all - * three, differing only in which PaymentMethod column decides the - * resulting status and, for a refund, which PaymentMethod row that even - * is (see resolvePaymentMethod()). + * Registered against PaymentCaptured, PaymentAuthorized, AND + * PaymentRefunded (see OrderServiceProvider). + * + * A capture/authorization only ever writes Order::paid/paid_at (via + * OrderStatusWriter::markPaid()) — never `status`. Confirmed with the + * user: status leaving 'awaiting_payment' is always a staff-driven + * "Update Status" click, regardless of payment method — no special-casing + * prepaid vs. cash-on-delivery. A prepaid order briefly sitting at + * 'awaiting_payment' with paid = true (until staff notice and advance it) + * is expected, not a bug. + * + * A refund still moves `status` (returned -> refunded/partially_refunded) + * — refunds are a normal step in Modules\Core\Order\Services\ + * OrderStatusFlow's own sequence, unlike captures. Derives + * Refunded/PartialRefund from Modules\Core\Order\Support\OrderStatus:: + * payment() — the existing, unchanged derived-enum logic, reused rather + * than reimplemented. * * Reads $event->context['order_id'] to find which Order this outcome - * belongs to — Payment has no concept of an Order, so this is the one - * place that context key gets consumed on the Order side (Payment's own - * StripePaymentDriver reads $context['order_id'] independently, for its - * own unrelated correlation need — see that class's rememberIntent()). + * belongs to — Payment has no concept of an Order. * - * Loads and saves the model (not a bulk ::whereKey()->update()) so - * Order::observe()'s updated() hook fires and OrderStatusUpdated goes out - * the same as any other status write — see that event's own docblock for - * why it's meant to fire "regardless of what wrote it." + * Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set. + * Never fires from the PaymentRefunded path — a refund can only ever + * happen after an order was already placed. * - * Dispatches Checkout\Events\OrderPlaced itself, once placed_at is set — - * see that event's own docblock for why this, not CheckoutService, is now - * the dispatch point. Never fires from the PaymentRefunded path — a - * refund can only ever happen after an order was already placed. - * - * Deliberately does NOT react to PaymentVoided — see PaymentMethod's own - * docblock for why there's no void_status column at all yet. + * Deliberately does NOT react to PaymentVoided. */ class ApplyResolvedPaymentStatus { public function __construct( - private readonly PaymentMethodCache $paymentMethods, + private readonly OrderStatusWriter $writer, ) {} public function handle(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event): void @@ -54,59 +56,41 @@ class ApplyResolvedPaymentStatus $order = Order::findOrFail($orderId); - $method = $this->resolvePaymentMethod($event, $order); - $column = match (true) { - $event instanceof PaymentCaptured => 'captured_status', - $event instanceof PaymentAuthorized => 'authorized_status', - $event instanceof PaymentRefunded => 'refunded_status', - }; - $status = $method?->{$column}; + if ($event instanceof PaymentRefunded) { + $this->applyRefund($order, $event); - if ($status === null) { return; } $wasPlaced = ! blank($order->placed_at); - $order->update([ - 'status' => $status, - 'placed_at' => $order->placed_at ?? now(), - ]); + $this->writer->markPaid($order, $event::class); - if (! $wasPlaced && ! $event instanceof PaymentRefunded) { + if (! $wasPlaced) { + $order->update(['placed_at' => $order->placed_at ?? now()]); Event::dispatch(new OrderPlaced($order)); } } /** - * PaymentCaptured/PaymentAuthorized carry $event->type as the - * PaymentMethod.type that was actually charged — a direct lookup. - * - * PaymentRefunded's $event->type is the REFUND driver's own registry - * key (e.g. 'bank-transfer' — see BankTransferPaymentDriver::refund()), - * which may not correspond to any PaymentMethod row at all when the - * admin refunded through a different driver than the one that took - * the original payment (Payment\Support\TransactionDriverAdapter:: - * refundVia()). refunded_status is a business decision about the - * ORIGINAL payment method, not the refund mechanism, so this instead - * finds the order's earliest successful capture/intent transaction — - * the actual payment the refund is reversing — and resolves that - * transaction's own driver (a real PaymentMethod.type) instead. + * Requires the refund Transaction row to already exist (Modules\Core\ + * Order\Listeners\RecordPaymentTransaction must run first — see + * OrderServiceProvider's listener registration order for + * PaymentRefunded), so the relation is refreshed here rather than + * trusted from a possibly-stale $order instance. */ - private function resolvePaymentMethod(PaymentCaptured|PaymentAuthorized|PaymentRefunded $event, Order $order): ?PaymentMethod + private function applyRefund(Order $order, PaymentRefunded $event): void { - if (! $event instanceof PaymentRefunded) { - return $this->paymentMethods->all()->firstWhere('type', $event->type); + $order->load('transactions'); + + $target = match (OrderStatus::payment($order)) { + PaymentStatus::Refunded => 'refunded', + PaymentStatus::PartialRefund => 'partially_refunded', + default => null, + }; + + if ($target !== null && $order->status !== $target) { + $this->writer->write($order, $target, $event::class); } - - $originalType = $order->transactions() - ->whereIn('type', ['capture', 'intent']) - ->where('success', true) - ->oldest('created_at') - ->value('driver'); - - return $originalType !== null - ? $this->paymentMethods->all()->firstWhere('type', $originalType) - : null; } } diff --git a/src/Order/Listeners/CompleteOrderOnDelivered.php b/src/Order/Listeners/CompleteOrderOnDelivered.php deleted file mode 100644 index e67dc97..0000000 --- a/src/Order/Listeners/CompleteOrderOnDelivered.php +++ /dev/null @@ -1,36 +0,0 @@ -order->status !== 'dispatched') { - return; - } - - $event->order->update(['status' => 'completed']); - } -} diff --git a/src/Order/Listeners/CompleteOrderOnPickedUp.php b/src/Order/Listeners/CompleteOrderOnPickedUp.php new file mode 100644 index 0000000..581a9e3 --- /dev/null +++ b/src/Order/Listeners/CompleteOrderOnPickedUp.php @@ -0,0 +1,39 @@ +order; + + if ($order->status !== 'picked_up') { + return; + } + + $this->writer->write($order, 'completed', self::class); + + OrderCompleted::dispatch($order); + } +} diff --git a/src/Order/Listeners/MarkDeliveryFailedOnCarrierCheckpoint.php b/src/Order/Listeners/MarkDeliveryFailedOnCarrierCheckpoint.php new file mode 100644 index 0000000..96cab9f --- /dev/null +++ b/src/Order/Listeners/MarkDeliveryFailedOnCarrierCheckpoint.php @@ -0,0 +1,35 @@ +shipmentInfo->status !== TrackingStatus::Failed) { + return; + } + + $order = $event->shipmentInfo->shipment->order; + + if (! $order || $order->status !== 'dispatched') { + return; + } + + $this->writer->write($order, 'delivery_failed', self::class); + } +} diff --git a/src/Order/Listeners/RecordStatusTransition.php b/src/Order/Listeners/RecordStatusTransition.php new file mode 100644 index 0000000..bb70d21 --- /dev/null +++ b/src/Order/Listeners/RecordStatusTransition.php @@ -0,0 +1,33 @@ +recorder->record($event->order, $event->previousStatus, $event->newStatus, $event->causeClass); + } + + public function handlePaidChanged(OrderPaidChanged $event): void + { + $this->recorder->record($event->order, null, 'paid', $event->causeClass); + } +} diff --git a/src/Order/Models/OrderStatusTransition.php b/src/Order/Models/OrderStatusTransition.php new file mode 100644 index 0000000..b16f28c --- /dev/null +++ b/src/Order/Models/OrderStatusTransition.php @@ -0,0 +1,29 @@ +belongsTo(Order::class); + } +} diff --git a/src/Order/Notifications/OrderCompletedNotification.php b/src/Order/Notifications/OrderCompletedNotification.php new file mode 100644 index 0000000..14ef7cd --- /dev/null +++ b/src/Order/Notifications/OrderCompletedNotification.php @@ -0,0 +1,49 @@ +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 complete', ['reference' => $order->reference])) + ->view('core::order.notifications.completed', [ + 'reference' => $order->reference, + ]); + } +} diff --git a/src/Order/Notifications/OrderDispatchedNotification.php b/src/Order/Notifications/OrderDispatchedNotification.php new file mode 100644 index 0000000..46bc43c --- /dev/null +++ b/src/Order/Notifications/OrderDispatchedNotification.php @@ -0,0 +1,54 @@ +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 on its way', ['reference' => $order->reference])) + ->view('core::order.notifications.dispatched', [ + 'reference' => $order->reference, + ]); + } +} diff --git a/src/Order/Notifications/OrderPickupReadyNotification.php b/src/Order/Notifications/OrderPickupReadyNotification.php index c68f42c..49c5844 100644 --- a/src/Order/Notifications/OrderPickupReadyNotification.php +++ b/src/Order/Notifications/OrderPickupReadyNotification.php @@ -6,19 +6,21 @@ 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; +use Modules\Core\Order\Events\OrderReadyForPickup; /** - * "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. + * "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. */ class OrderPickupReadyNotification extends BaseNotification { - public function __construct(private readonly OrderStatusUpdated $event) {} + public function __construct(private readonly OrderReadyForPickup $event) {} public static function getKey(): string { @@ -27,15 +29,11 @@ class OrderPickupReadyNotification extends BaseNotification public static function listensTo(): string { - return OrderStatusUpdated::class; + return OrderReadyForPickup::class; } public function via(object $notifiable): array { - if ($this->event->newStatus !== 'ready-for-pickup') { - return []; - } - return ['mail']; } diff --git a/src/Order/Observers/OrderObserver.php b/src/Order/Observers/OrderObserver.php index 9cfbef8..ea7979e 100644 --- a/src/Order/Observers/OrderObserver.php +++ b/src/Order/Observers/OrderObserver.php @@ -5,18 +5,32 @@ namespace Modules\Core\Order\Observers; use Lunar\Models\Order; use Modules\Core\Order\Events\OrderStatusUpdated; +/** + * Generically dispatches OrderStatusUpdated for ANY write to `status`, + * regardless of what wrote it (Modules\Core\Order\Services\ + * OrderStatusWriter, artisan tinker, a future API) — the general-purpose + * hook notifications listen to. OrderStatusWriter separately dispatches + * its own OrderStatusChanged (carrying $causeClass, which this event does + * not) for the audit trail — see Modules\Core\Order\Listeners\ + * RecordStatusTransition. + * + * Does NOT try to generically watch Order::paid — an earlier design had + * this observer thread a "what caused this" value through a runtime + * $order->statusTransitionCause property, abandoned because + * Lunar\Models\Order's $guarded = [] means Eloquent tries to persist any + * property set that way as a real column. OrderStatusWriter::markPaid() + * dispatches OrderPaidChanged directly instead. + */ class OrderObserver { public function updated(Order $order): void { - if (! $order->wasChanged('status')) { - return; + if ($order->wasChanged('status')) { + OrderStatusUpdated::dispatch( + $order, + $order->getOriginal('status'), + $order->status, + ); } - - OrderStatusUpdated::dispatch( - $order, - $order->getOriginal('status'), - $order->status, - ); } } diff --git a/src/Order/Services/OrderFulfillmentService.php b/src/Order/Services/OrderFulfillmentService.php new file mode 100644 index 0000000..1874883 --- /dev/null +++ b/src/Order/Services/OrderFulfillmentService.php @@ -0,0 +1,169 @@ +status !== 'processing') { + return OrderFulfillmentResult::failure('This order must be in Processing before it can be marked ready.'); + } + + $target = $order->isStorePickupOrder() ? 'ready_for_pickup' : 'ready_for_dispatch'; + + $this->writer->write($order, $target, self::class.'::markReady'); + + if ($order->isStorePickupOrder()) { + OrderReadyForPickup::dispatch($order); + } else { + OrderReadyForDispatch::dispatch($order); + } + + return OrderFulfillmentResult::success('Order marked ready.'); + } + + public function createShipmentAndDispatch(Order $order, ShipmentRequest $request): OrderFulfillmentResult + { + if ($order->status !== 'ready_for_dispatch') { + return OrderFulfillmentResult::failure('This order is not ready to be dispatched.'); + } + + $service = $this->resolveFulfillmentService($order); + + if (! $service) { + return OrderFulfillmentResult::failure('No carrier fulfillment integration is configured for this order.'); + } + + try { + $service->createShipment($order, $request); + } catch (Throwable $e) { + report($e); + + return OrderFulfillmentResult::failure('Failed to create shipment: '.$e->getMessage()); + } + + $this->writer->write($order, 'dispatched', self::class.'::createShipmentAndDispatch'); + + return OrderFulfillmentResult::success('Shipment created and order dispatched.'); + } + + public function markPickedUp(Order $order): OrderFulfillmentResult + { + if ($order->status !== 'ready_for_pickup') { + return OrderFulfillmentResult::failure('This order is not ready for pickup.'); + } + + $this->writer->write($order, 'picked_up', self::class.'::markPickedUp'); + + OrderPickedUp::dispatch($order); + + return OrderFulfillmentResult::success('Order marked as picked up.'); + } + + /** + * The general-purpose entry point for any transition with no special + * side effect — a manual override, not restricted to the guided next + * step(s), so staff can revert to an earlier status in the order's + * own branch. Validates $to is actually a member of + * OrderStatusFlow::allOptions() before writing (server-side + * re-validation of whatever the Select offered) — still refuses a + * status from the WRONG branch or an unknown value. + */ + public function transitionTo(Order $order, string $to): OrderFulfillmentResult + { + if (! array_key_exists($to, $this->flow->allOptions($order))) { + return OrderFulfillmentResult::failure('That status is not valid for this order.'); + } + + $this->writer->write($order, $to, self::class.'::transitionTo'); + + return OrderFulfillmentResult::success('Order status updated.'); + } + + /** + * Independent of `status` entirely — offered by the single "Update + * Status" action regardless of current status (see + * OrderStatusFlow::canMarkPaid()). + */ + public function markPaid(Order $order): OrderFulfillmentResult + { + if (! $this->flow->canMarkPaid($order)) { + return OrderFulfillmentResult::failure('This order cannot be marked paid right now.'); + } + + $this->writer->markPaid($order, self::class.'::markPaid'); + + return OrderFulfillmentResult::success('Order marked as paid.'); + } + + public function canCreateShipment(Order $order): bool + { + return $order->status === 'ready_for_dispatch' + && ! $order->isStorePickupOrder() + && $order->shipments()->exists() === false + && $this->resolveFulfillmentService($order) !== null; + } + + /** + * Public wrapper around resolveCarrier() — Modules\Core\Shipping\ + * Extensions\OrderViewExtension needs to know which carrier an order + * uses to branch the "Create Shipment" form (Box Now's box-size + * repeater vs. every other carrier's plain weight field). + */ + public function carrierFor(Order $order): ?string + { + return $this->resolveCarrier($order); + } + + private function resolveCarrier(Order $order): ?string + { + $code = $order->shippingAddress?->shipping_option; + + if (! $code) { + return null; + } + + return ShippingMethod::where('code', $code)->value('driver'); + } + + private function resolveFulfillmentService(Order $order): ?CarrierFulfillmentInterface + { + $carrier = $this->resolveCarrier($order); + + if (! $carrier) { + return null; + } + + return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]); + } +} diff --git a/src/Order/Services/OrderStatusFlow.php b/src/Order/Services/OrderStatusFlow.php new file mode 100644 index 0000000..daa4ed5 --- /dev/null +++ b/src/Order/Services/OrderStatusFlow.php @@ -0,0 +1,140 @@ +isStorePickupOrder() ? self::FLOW_PICKUP : self::FLOW_CARRIER; + } + + /** + * Order.meta['payment_method'] (written by + * Modules\Core\Checkout\Services\CheckoutService::initiatePayment()) + * is the durable source of truth. Falls back to the most recent + * Transaction.driver (a payment TYPE slug) only if meta is missing — + * e.g. an order placed before this field existed. + */ + public function isCod(Order $order): bool + { + $type = $order->meta['payment_method'] ?? $order->transactions()->latest('id')->value('driver'); + + if ($type === null) { + return false; + } + + return PaymentMethod::where('type', $type)->value('driver') === 'cash-on-delivery'; + } + + /** + * @return array value => label — every status in the + * order's own branch (carrier or pickup), plus the refund options, + * for a manual-override "New status" select. Deliberately not + * filtered to nextOptions()'s guided next-step(s) — staff can jump + * to any status in their branch, including reverting to an earlier + * one (e.g. undoing a mistaken click). transitionTo() still + * validates $to is actually a member of this set server-side. + */ + public function allOptions(Order $order): array + { + $statuses = [...$this->resolveFlow($order), ...self::REFUND_OPTIONS, 'delivery_failed']; + + return collect($statuses) + ->unique() + ->mapWithKeys(fn (string $status) => [$status => $this->label($status)]) + ->all(); + } + + /** + * @return array value => label — status-sequence + * transitions offered as the guided next step(s). Does not include + * the "mark paid" pseudo-option — see canMarkPaid(). + */ + public function nextOptions(Order $order): array + { + $flow = $this->resolveFlow($order); + $current = $order->status; + $position = array_search($current, $flow, true); + + $options = []; + + if ($position !== false && isset($flow[$position + 1])) { + $options[] = $flow[$position + 1]; + } + + // delivery_failed — a possible outcome of any delivery attempt, + // carrier flow only, checked on $current directly (not on the + // flow's literal next value) since it's a branch on the attempt + // itself, not on sequence position. + if ($current === 'dispatched') { + $options[] = 'delivery_failed'; + } + + // From delivery_failed: retry dispatch, or give up and treat as + // a return. + if ($current === 'delivery_failed') { + array_push($options, 'dispatched', 'return_requested'); + } + + if (in_array($current, self::RETURN_ELIGIBLE_FROM, true)) { + $options[] = 'return_requested'; + } + + if ($current === 'return_requested') { + $options[] = 'returned'; + } + + if ($current === 'returned') { + array_push($options, ...self::REFUND_OPTIONS); + } + + return collect($options) + ->unique() + ->mapWithKeys(fn (string $status) => [$status => $this->label($status)]) + ->all(); + } + + /** + * Whether the "mark paid" option should be offered right now — + * entirely independent of $order->status. True whenever this is a + * cash-on-delivery order and payment hasn't been recorded yet, + * regardless of fulfillment progress (before OR after completed). + */ + public function canMarkPaid(Order $order): bool + { + return ! $order->paid && $this->isCod($order); + } + + private function label(string $status): string + { + return (string) str($status)->replace('_', ' ')->title(); + } +} diff --git a/src/Order/Services/OrderStatusTransitionRecorder.php b/src/Order/Services/OrderStatusTransitionRecorder.php new file mode 100644 index 0000000..a6e24af --- /dev/null +++ b/src/Order/Services/OrderStatusTransitionRecorder.php @@ -0,0 +1,27 @@ + $order->id, + 'from_status' => $from, + 'to_status' => $to, + 'event_class' => $eventClass, + ]); + } +} diff --git a/src/Order/Services/OrderStatusWriter.php b/src/Order/Services/OrderStatusWriter.php new file mode 100644 index 0000000..8f9b588 --- /dev/null +++ b/src/Order/Services/OrderStatusWriter.php @@ -0,0 +1,55 @@ +statusTransitionCause = ... broke immediately with an + * "undefined column" error the moment ->update() ran. + */ +class OrderStatusWriter +{ + public function write(Order $order, string $to, string $causeClass): void + { + $from = $order->status; + + if ($from === $to) { + return; + } + + $order->update(['status' => $to]); + + OrderStatusChanged::dispatch($order, $from, $to, $causeClass); + } + + public function markPaid(Order $order, string $causeClass): void + { + if ($order->paid) { + return; + } + + $order->update(['paid' => true, 'paid_at' => now()]); + + OrderPaidChanged::dispatch($order, $causeClass); + } +} diff --git a/src/Payment/Drivers/CashOnDeliveryPaymentDriver.php b/src/Payment/Drivers/CashOnDeliveryPaymentDriver.php new file mode 100644 index 0000000..866167b --- /dev/null +++ b/src/Payment/Drivers/CashOnDeliveryPaymentDriver.php @@ -0,0 +1,50 @@ +default('pay') ->live() ->required(), - static::getOrderStatusSelect('captured_status', 'Order status once paid') - ->helperText('Applied the moment a payment is fully charged.'), - static::getOrderStatusSelect('authorized_status', 'Order status once held') - ->helperText('Applied the moment a hold is placed, before it\'s charged.') - ->visible(fn (Get $get) => $get('capture_mode') === 'authorize'), - static::getOrderStatusSelect('refunded_status', 'Order status once refunded') - ->helperText('Applied when a payment taken through this method is refunded — even if the refund itself is processed through a different method.'), ]; } @@ -169,24 +156,6 @@ class PaymentMethodResource extends Resource ->required(); } - /** - * Lunar's own Order::status is a plain, admin-extensible string - * (config('lunar.orders.statuses')) rather than a fixed enum — - * deliberately so a store can add its own custom status without a - * code change (see docs/payments.md). This Select still reads from - * that same open-ended list, just so an admin picks a real status - * instead of typing a slug from memory. - */ - private static function getOrderStatusSelect(string $name, string $label): Select - { - return Select::make($name) - ->label($label) - ->options(collect(config('lunar.orders.statuses', [])) - ->map(fn (array $status) => $status['label'] ?? $status) - ->all()) - ->native(false); - } - public static function getPages(): array { return [ @@ -211,7 +180,7 @@ class PaymentMethodResource extends Resource ->icon('heroicon-o-pencil-square') ->schema(static::getFormComponents()) ->fillForm(fn (PaymentMethod $record) => $record->only([ - 'name', 'type', 'driver', 'capture_mode', 'captured_status', 'authorized_status', 'refunded_status', + 'name', 'type', 'driver', 'capture_mode', ])) ->action(fn (PaymentMethod $record, array $data) => app(PaymentMethodService::class)->update($record, $data)); } diff --git a/src/Payment/Models/PaymentMethod.php b/src/Payment/Models/PaymentMethod.php index 25d50dc..39a6016 100644 --- a/src/Payment/Models/PaymentMethod.php +++ b/src/Payment/Models/PaymentMethod.php @@ -18,16 +18,6 @@ use Illuminate\Database\Eloquent\Model; * - capture_mode: 'pay' or 'authorize' — which SupportsPay/ * SupportsAuthorization method CheckoutService::initiatePayment() * calls for this row. - * - captured_status / authorized_status / refunded_status: the - * Order::status value Modules\Core\Order\Listeners\ - * ApplyResolvedPaymentStatus applies on a PaymentCaptured/ - * PaymentAuthorized/PaymentRefunded event. For a refund, this is - * always the ORIGINAL payment method's row (the one the customer - * actually paid with), never the driver the refund itself was routed - * through (Payment\Support\TransactionDriverAdapter::refundVia() may - * use a different one entirely — e.g. a cash-on-delivery order - * refunded via a Bank Transfer driver with no PaymentMethod row of - * its own) — see that listener's own docblock. * - position: admin-controlled display/checkout order. * - driver_missing_at: set by `payment:sync-drivers` when `driver` no * longer resolves via the registry — separate from `enabled`, so a diff --git a/src/Payment/Services/PaymentMethodService.php b/src/Payment/Services/PaymentMethodService.php index c6c5853..737d277 100644 --- a/src/Payment/Services/PaymentMethodService.php +++ b/src/Payment/Services/PaymentMethodService.php @@ -70,8 +70,7 @@ class PaymentMethodService public function delete(PaymentMethod $method): void { $snapshot = $method->only([ - 'id', 'type', 'name', 'driver', 'capture_mode', - 'captured_status', 'authorized_status', 'position', 'enabled', + 'id', 'type', 'name', 'driver', 'capture_mode', 'position', 'enabled', ]); $method->delete(); diff --git a/src/Providers/OrderServiceProvider.php b/src/Providers/OrderServiceProvider.php index 86349d7..b5b57e5 100644 --- a/src/Providers/OrderServiceProvider.php +++ b/src/Providers/OrderServiceProvider.php @@ -2,20 +2,33 @@ namespace Modules\Core\Providers; +use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Lunar\Models\Order; use Lunar\Models\Transaction; use Modules\Core\Checkout\Events\OrderPlaced; use Modules\Core\Notification\NotificationRegistry; +use Modules\Core\Order\Commands\CloseExpiredReturnWindows; use Modules\Core\Order\Events\OrderDelivered; +use Modules\Core\Order\Events\OrderPaidChanged; +use Modules\Core\Order\Events\OrderPickedUp; +use Modules\Core\Order\Events\OrderStatusChanged; +use Modules\Core\Order\Listeners\AdvanceFulfillmentOnCarrierCheckpoint; +use Modules\Core\Order\Listeners\AdvanceFulfillmentOnDelivered; use Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus; -use Modules\Core\Order\Listeners\CompleteOrderOnDelivered; +use Modules\Core\Order\Listeners\CompleteOrderOnPickedUp; use Modules\Core\Order\Listeners\DecrementStockOnOrderPlaced; use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment; +use Modules\Core\Order\Listeners\MarkDeliveryFailedOnCarrierCheckpoint; use Modules\Core\Order\Listeners\RecordPaymentTransaction; +use Modules\Core\Order\Listeners\RecordStatusTransition; +use Modules\Core\Order\Models\OrderStatusTransition; use Modules\Core\Order\Notifications\OrderCapturedNotification; +use Modules\Core\Order\Notifications\OrderCompletedNotification; use Modules\Core\Order\Notifications\OrderDeliveredNotification; +use Modules\Core\Order\Notifications\OrderDispatchedNotification; +use Modules\Core\Order\Notifications\OrderPickupReadyNotification; use Modules\Core\Order\Notifications\OrderPlacedNotification; use Modules\Core\Order\Notifications\OrderRefundedNotification; use Modules\Core\Order\Notifications\OrderStatusUpdatedNotification; @@ -38,23 +51,43 @@ class OrderServiceProvider extends ServiceProvider Order::macro('paymentStatus', fn () => OrderStatus::payment($this)); Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($this)); + Order::resolveRelationUsing('statusTransitions', function ($order) { + return $order->hasMany(OrderStatusTransition::class); + }); + Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class); - Event::listen(OrderDelivered::class, CompleteOrderOnDelivered::class); + Event::listen(ShipmentStatusUpdatedByCarrier::class, AdvanceFulfillmentOnCarrierCheckpoint::class); + Event::listen(ShipmentStatusUpdatedByCarrier::class, MarkDeliveryFailedOnCarrierCheckpoint::class); + Event::listen(OrderDelivered::class, AdvanceFulfillmentOnDelivered::class); + Event::listen(OrderPickedUp::class, CompleteOrderOnPickedUp::class); + Event::listen(PaymentCaptured::class, ApplyResolvedPaymentStatus::class); Event::listen(PaymentAuthorized::class, ApplyResolvedPaymentStatus::class); - Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class); Event::listen(PaymentCaptured::class, RecordPaymentTransaction::class); Event::listen(PaymentAuthorized::class, RecordPaymentTransaction::class); Event::listen(PaymentVoided::class, RecordPaymentTransaction::class); + // RecordPaymentTransaction must run before ApplyResolvedPaymentStatus + // for PaymentRefunded specifically — the latter now reads + // $order->transactions (via OrderStatus::payment()) to resolve + // Refunded vs PartiallyRefunded, which requires the refund + // Transaction row to already exist. Event::listen(PaymentRefunded::class, RecordPaymentTransaction::class); + Event::listen(PaymentRefunded::class, ApplyResolvedPaymentStatus::class); + Event::listen(OrderPlaced::class, DecrementStockOnOrderPlaced::class); + Event::listen(OrderStatusChanged::class, [RecordStatusTransition::class, 'handleStatusChanged']); + Event::listen(OrderPaidChanged::class, [RecordStatusTransition::class, 'handlePaidChanged']); + NotificationRegistry::get()->register([ OrderDeliveredNotification::class, OrderStatusUpdatedNotification::class, OrderRefundedNotification::class, OrderCapturedNotification::class, OrderPlacedNotification::class, + OrderPickupReadyNotification::class, + OrderDispatchedNotification::class, + OrderCompletedNotification::class, ]); // Lets the consuming app override copy/markup without forking core @@ -64,5 +97,17 @@ class OrderServiceProvider extends ServiceProvider $this->publishes([ __DIR__ . '/../../resources/views/order/notifications' => resource_path('views/vendor/core/order/notifications'), ], 'core-views'); + + if ($this->app->runningInConsole()) { + $this->commands([CloseExpiredReturnWindows::class]); + } + + // Exact midnight per an explicit compliance requirement — not a + // loose dailyAt() offset or plain daily(). + $this->app->booted(function () { + $this->app->make(ConsoleSchedule::class) + ->command(CloseExpiredReturnWindows::class) + ->dailyAt('00:00'); + }); } } diff --git a/src/Providers/PaymentServiceProvider.php b/src/Providers/PaymentServiceProvider.php index bf36462..dc31e43 100644 --- a/src/Providers/PaymentServiceProvider.php +++ b/src/Providers/PaymentServiceProvider.php @@ -9,6 +9,7 @@ use Lunar\Models\Contracts\Transaction as TransactionContract; use Lunar\Pipelines\Cart\ApplyShipping; use Modules\Core\Command\SyncPaymentDriversCommand; use Modules\Core\Payment\Drivers\BankTransferPaymentDriver; +use Modules\Core\Payment\Drivers\CashOnDeliveryPaymentDriver; use Modules\Core\Payment\Drivers\OfflinePaymentDriver; use Modules\Core\Payment\Drivers\StripePaymentDriver; use Modules\Core\Payment\Events\PaymentMethodCreated; @@ -33,6 +34,7 @@ class PaymentServiceProvider extends ServiceProvider $registry->register('offline', OfflinePaymentDriver::class, 'Offline / Manual'); $registry->register('stripe', StripePaymentDriver::class, 'Stripe'); $registry->register('bank-transfer', BankTransferPaymentDriver::class, 'Bank Transfer'); + $registry->register('cash-on-delivery', CashOnDeliveryPaymentDriver::class, 'Cash on Delivery'); $cartPipeline = config('lunar.cart.pipelines.cart', []); $insertAfter = array_search(ApplyShipping::class, $cartPipeline, true); diff --git a/src/Providers/ShippingServiceProvider.php b/src/Providers/ShippingServiceProvider.php index 9e8d2ef..7e23709 100644 --- a/src/Providers/ShippingServiceProvider.php +++ b/src/Providers/ShippingServiceProvider.php @@ -65,24 +65,34 @@ class ShippingServiceProvider extends ServiceProvider __DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'), ], 'core-config'); + // Signed-URL auth only, same model as Lunar's own vendor + // lunar.pdf.download route — see Modules\Core\Shipping\Http\ + // Controllers\DownloadShipmentLabelController's own docblock. + $this->loadRoutesFrom(__DIR__ . '/../Shipping/routes/web.php'); + Order::resolveRelationUsing('shipments', function ($order) { 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). + // + // Reads ShippingMethod.data['fulfillment_type'] directly rather + // than through a ShippingMethod::macro('isStorePickup', ...) — + // Lunar\Base\Traits\HasModelExtending::__callStatic() (used by + // Lunar\Shipping\Models\ShippingMethod via Lunar\Base\BaseModel) + // intercepts EVERY unmatched static call, including macro() + // itself, and dispatches it as (new static)->macro(...) instead + // of forwarding to Macroable — so a macro registered this way + // silently never gets stored, and hasMacro() always returns + // false. (Lunar\Models\Order is unaffected because it declares + // its own macro() method directly, bypassing __callStatic + // entirely — that's why Order::macro('isStorePickupOrder', ...) + // below still works.) Defaults to 'carrier' (false) for any row + // saved before this field existed. Order::macro('isStorePickupOrder', function () { /** @var Order $this */ $code = $this->shippingAddress?->shipping_option; @@ -91,7 +101,14 @@ class ShippingServiceProvider extends ServiceProvider return false; } - return ShippingMethod::where('code', $code)->first()?->isStorePickup() ?? false; + // ->value('data') is deliberately avoided here — on Postgres, + // ->value()/->pluck() on a JSON column silently return the + // wrong result (works fine in ->where(), not in a column + // projection); loading the model and reading its cast + // attribute avoids that entirely. + $method = ShippingMethod::where('code', $code)->first(); + + return ($method?->data['fulfillment_type'] ?? 'carrier') === 'store_pickup'; }); foreach ([CartLineAdded::class, CartLineUpdated::class, CartLineRemoved::class, CartCleared::class, ShippingAddressSet::class] as $event) { diff --git a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php index aac4d37..f2f67d3 100644 --- a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php +++ b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php @@ -14,6 +14,7 @@ use Modules\Core\Shipping\DTOs\ManifestResult; use Modules\Core\Shipping\DTOs\ShipmentRequest; use Modules\Core\Shipping\DTOs\TrackingCheckpoint; use Modules\Core\Shipping\Enums\TrackingStatus; +use Modules\Core\Shipping\Models\Manifest; use Modules\Core\Shipping\Models\Shipment; class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking @@ -88,7 +89,7 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani public function cancelShipment(Shipment $shipment): void { - if ($shipment->manifest_reference) { + if ($shipment->manifest_id) { throw new RuntimeException('Cannot cancel a shipment already included in an issued manifest.'); } @@ -103,7 +104,7 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani { return Shipment::query() ->where('carrier', 'acs') - ->whereNull('manifest_reference') + ->whereNull('manifest_id') ->whereNull('cancelled_at') ->get(); } @@ -123,11 +124,18 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani $pickupListNo = (string) $response->valueOutput['PickupList_No']; + $manifest = Manifest::create([ + 'carrier' => 'acs', + 'reference' => $pickupListNo, + 'shipment_count' => $shipments->count(), + 'issued_at' => now(), + ]); + $shipments->each(fn (Shipment $shipment) => $shipment->update([ - 'manifest_reference' => $pickupListNo, + 'manifest_id' => $manifest->id, ])); - return ManifestResult::success($pickupListNo, $shipments); + return ManifestResult::success($manifest, $shipments); } public function trackShipment(Shipment $shipment): Collection @@ -176,6 +184,11 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani { $action = strtolower($action); + // TODO: no live ACS payload sample yet showing a distinct + // collection checkpoint separate from transit ("arrival"/ + // "departure" already map to InTransit) — add a str_contains() + // arm mapping to TrackingStatus::CollectedFromSender here once + // one is confirmed. return match (true) { str_contains($action, 'delivery to consignee') => TrackingStatus::Delivered, str_contains($action, 'on delivery') => TrackingStatus::OutForDelivery, diff --git a/src/Shipping/Carriers/Acs/AcsRateDriver.php b/src/Shipping/Carriers/Acs/AcsRateDriver.php index 0daea90..74ce760 100644 --- a/src/Shipping/Carriers/Acs/AcsRateDriver.php +++ b/src/Shipping/Carriers/Acs/AcsRateDriver.php @@ -11,6 +11,7 @@ use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Concerns\CachesLivePricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Contracts\SupportsLivePricing; +use Modules\Core\Shipping\Support\WeightCalculator; class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing { @@ -103,26 +104,6 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing private function totalWeightInKg($cart): float { - $weight = 0.0; - - foreach ($cart->lines->load('purchasable') as $line) { - $variant = $line->purchasable; - - if (! $variant || ! $variant->weight_value) { - continue; - } - - $unit = $variant->weight_unit ?? 'kg'; - $value = (float) $variant->weight_value; - - $weight += match ($unit) { - 'g' => $value / 1000, - 'lb' => $value * 0.45359237, - 'oz' => $value * 0.0283495231, - default => $value, // kg - } * $line->quantity; - } - - return max($weight, 0.5); // ACS minimum billable weight + return max(WeightCalculator::totalKg($cart->lines), 0.5); // ACS minimum billable weight } } diff --git a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php index 4b8b643..971df90 100644 --- a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php +++ b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php @@ -21,10 +21,21 @@ use Modules\Core\Shipping\Models\Shipment; * Box Now delivers to lockers, not addresses. The storefront locker-picker * is out of scope for this pass — createShipment() requires the chosen * locker's Box Now locationId via ShipmentRequest::$destinationLocationId - * (e.g. set manually by admin staff until checkout UI exists). + * (e.g. set manually by admin staff until checkout UI exists — see + * Modules\Core\Shipping\Extensions\OrderViewExtension, which locks the + * field instead once the shopper's own checkout selection is present in + * $order->shippingAddress->meta['box_now_locker']). + * + * Box Now ships by compartment size, not weight — unlike ACS, which bills + * by kg. One 'items' entry per box in ShipmentRequest::$boxes, so an order + * needing more than one physical parcel (doesn't fit one compartment) + * sends that many entries in a single delivery request rather than + * several separate ones. */ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking { + private const COMPARTMENT_SIZES = ['S' => 1, 'M' => 2, 'L' => 3]; + public function __construct(private readonly BoxNowClient $client) {} public function createShipment(Order $order, ShipmentRequest $request): Shipment @@ -36,6 +47,10 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT throw new BoxNowApiException('No Box Now locker (locationId) was provided for this shipment.'); } + if (empty($request->boxes)) { + throw new BoxNowApiException('At least one box (compartment size) is required for a Box Now shipment.'); + } + $isCod = $request->paymentMode === 'cod'; $response = $this->client->request('post', '/delivery-requests', [ @@ -57,31 +72,37 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT 'contactName' => trim("{$address->first_name} {$address->last_name}"), 'locationId' => $destinationLocationId, ], - 'items' => [ - [ - 'id' => (string) $order->id, - 'name' => 'Order '.$order->reference, - 'value' => '0.00', - 'compartmentSize' => 1, - 'weight' => $request->weight ?? 0, - ], - ], + 'items' => collect($request->boxes)->values()->map(fn (string $size, int $index) => [ + 'id' => $order->id.'-'.($index + 1), + 'name' => 'Order '.$order->reference.' (box '.($index + 1).')', + 'value' => '0.00', + 'compartmentSize' => self::COMPARTMENT_SIZES[$size] ?? self::COMPARTMENT_SIZES['S'], + ])->all(), ]); - $parcelId = (string) ($response['parcels'][0]['id'] ?? throw new BoxNowApiException( - 'Box Now delivery request succeeded but returned no parcel id.', - $response, - )); + $parcels = collect($response['parcels'] ?? []); - return Shipment::create([ + if ($parcels->isEmpty()) { + throw new BoxNowApiException('Box Now delivery request succeeded but returned no parcel ids.', $response); + } + + // One Shipment row per box/parcel — each is independently + // trackable/printable/cancellable via its own tracking_reference + // (printLabel()/cancelShipment()/trackShipment() below already + // operate per-Shipment), even though all boxes were submitted in + // one delivery request. Siblings are linked via the shared + // delivery_request_id in meta. + $shipments = $parcels->map(fn (array $parcel) => Shipment::create([ 'order_id' => $order->id, 'carrier' => 'box-now', - 'tracking_reference' => $parcelId, + 'tracking_reference' => (string) $parcel['id'], 'meta' => [ 'delivery_request_id' => $response['id'] ?? null, 'locker_id' => $destinationLocationId, ], - ]); + ])); + + return $shipments->first(); } public function printLabel(Shipment $shipment): string @@ -136,6 +157,13 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT private function mapState(string $state): TrackingStatus { + // TODO: confirm against a live BoxNow webhook payload whether a + // distinct collected-from-sender state exists (e.g. between 'new' + // and 'in-transit') before mapping it to + // TrackingStatus::CollectedFromSender — BoxNow's own model is + // locker-drop-off-based, so it may not have one. No guessed match + // arm added; 'new' still falls through to Pending, InTransit + // remains the earliest recognized checkpoint. return match ($state) { 'new' => TrackingStatus::Pending, 'in-transit', 'in-depot' => TrackingStatus::InTransit, diff --git a/src/Shipping/DTOs/ManifestResult.php b/src/Shipping/DTOs/ManifestResult.php index 682b79e..9342298 100644 --- a/src/Shipping/DTOs/ManifestResult.php +++ b/src/Shipping/DTOs/ManifestResult.php @@ -3,24 +3,26 @@ namespace Modules\Core\Shipping\DTOs; use Illuminate\Support\Collection; +use Modules\Core\Shipping\Models\Manifest; class ManifestResult { private function __construct( public readonly bool $success, public readonly ?string $reference, + public readonly ?Manifest $manifest, public readonly Collection $includedShipments, public readonly Collection $blockedShipments, public readonly ?string $reason, ) {} - public static function success(string $reference, Collection $includedShipments): self + public static function success(Manifest $manifest, Collection $includedShipments): self { - return new self(true, $reference, $includedShipments, collect(), null); + return new self(true, $manifest->reference, $manifest, $includedShipments, collect(), null); } public static function blocked(Collection $blockedShipments, string $reason): self { - return new self(false, null, collect(), $blockedShipments, $reason); + return new self(false, null, null, collect(), $blockedShipments, $reason); } } diff --git a/src/Shipping/DTOs/ShipmentRequest.php b/src/Shipping/DTOs/ShipmentRequest.php index 35cb380..4665eff 100644 --- a/src/Shipping/DTOs/ShipmentRequest.php +++ b/src/Shipping/DTOs/ShipmentRequest.php @@ -5,16 +5,27 @@ namespace Modules\Core\Shipping\DTOs; /** * Carrier-agnostic input for CarrierFulfillmentInterface::createShipment(). * Every field is optional — a carrier reads only what it needs and ignores - * the rest (e.g. destinationLocationId only matters to locker-delivery - * carriers like Box Now; ACS has no use for it). + * the rest (e.g. destinationLocationId/boxes only matter to locker-delivery + * carriers like Box Now; ACS has no use for either — it ships by weight, + * not by box/compartment size). */ class ShipmentRequest { + /** + * @param array $boxes Box Now only — one entry per + * physical parcel, each a compartment size ('S'|'M'|'L'). A single + * shipment can be split across several lockers of the same + * destinationLocationId's collection point, e.g. two Large boxes for + * an order that doesn't fit one compartment. Empty for every other + * carrier, which ships as a single package described by $weight + * instead. + */ public function __construct( public readonly ?float $weight = null, public readonly int $packageCount = 1, public readonly ?string $destinationLocationId = null, public readonly ?string $paymentMode = null, public readonly ?float $amountToCollect = null, + public readonly array $boxes = [], ) {} } diff --git a/src/Shipping/Enums/TrackingStatus.php b/src/Shipping/Enums/TrackingStatus.php index 8c66b38..5f0b9fb 100644 --- a/src/Shipping/Enums/TrackingStatus.php +++ b/src/Shipping/Enums/TrackingStatus.php @@ -11,6 +11,25 @@ namespace Modules\Core\Shipping\Enums; enum TrackingStatus: string { case Pending = 'pending'; + + /** + * The carrier collected the parcel from the merchant — deliberately + * NOT named "PickedUp" to avoid colliding with the unrelated + * order status 'picked_up' (Modules\Core\Order\Services\ + * OrderStatusFlow), which means the opposite end of a different flow + * (a CUSTOMER collecting a store-pickup order). Consumed by + * Modules\Core\Order\Listeners\AdvanceFulfillmentOnCarrierCheckpoint. + * + * Not yet mapped from either carrier driver's own checkpoint data — + * see Carriers\BoxNow\BoxNowFulfillmentService::mapState() and + * Carriers\Acs\AcsFulfillmentService::guessStatusFromAction() for + * TODOs on confirming against real payload samples before adding a + * mapping. Until then this case exists but nothing produces it, and + * InTransit remains the effective first real checkpoint for both + * carriers. + */ + case CollectedFromSender = 'collected_from_sender'; + case InTransit = 'in_transit'; case OutForDelivery = 'out_for_delivery'; case Delivered = 'delivered'; diff --git a/src/Shipping/Extensions/OrderShipmentsExtension.php b/src/Shipping/Extensions/OrderShipmentsExtension.php new file mode 100644 index 0000000..0298812 --- /dev/null +++ b/src/Shipping/Extensions/OrderShipmentsExtension.php @@ -0,0 +1,186 @@ +shipmentsSection()]); + + return $schema; + } + + private function shipmentsSection(): Section + { + return Section::make('shipments') + ->heading('Shipments') + ->compact() + ->collapsed(fn ($record) => $record->shipments->isEmpty()) + ->collapsible(fn ($record) => $record->shipments->isNotEmpty()) + ->schema([ + RepeatableEntry::make('shipments') + ->hiddenLabel() + ->placeholder('No shipments have been created for this order yet.') + ->contained(true) + ->schema([ + TextEntry::make('tracking_reference') + ->label(fn (Shipment $record) => $this->carrierLabel($record)) + ->inlineLabel() + ->copyable(), + TextEntry::make('status') + ->label('Status') + ->inlineLabel() + ->state(fn (Shipment $record) => $this->statusLabel($record)) + ->badge() + ->color(fn (Shipment $record) => $this->statusColor($record)) + ->helperText(fn (Shipment $record) => $this->helperText($record)) + ->suffixActions([ + Action::make('print_label') + ->label('Print Label') + ->icon('heroicon-o-printer') + ->url(fn (Shipment $record) => URL::temporarySignedRoute( + 'shipments.label', + now()->addMinutes(5), + ['shipment' => $record->id], + ), shouldOpenInNewTab: true) + ->visible(fn (Shipment $record) => ! $record->cancelled_at), + Action::make('cancel_shipment') + ->label('Cancel') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->modalDescription('Cancels this shipment with the carrier. This cannot be undone.') + ->action(fn (Shipment $record) => $this->cancel($record)) + ->visible(fn (Shipment $record) => ! $record->cancelled_at), + ]), + ]), + ]); + } + + private function carrierLabel(Shipment $record): string + { + return match ($record->carrier) { + 'acs' => 'ACS', + 'box-now' => 'Box Now', + default => (string) str($record->carrier)->title(), + }; + } + + private function helperText(Shipment $record): string + { + $parts = ['Created '.$record->created_at->format('Y-m-d H:i')]; + + if ($record->carrier === 'box-now' && $locker = $record->meta['locker_id'] ?? null) { + $parts[] = 'Locker '.$locker; + } + + return implode(' · ', $parts); + } + + private function statusLabel(Shipment $record): string + { + if ($record->cancelled_at) { + return 'Cancelled'; + } + + $latest = $record->latestShipmentInfo(); + + return $latest ? (string) str($latest->status->value)->replace('_', ' ')->title() : 'Pending'; + } + + private function statusColor(Shipment $record): string + { + if ($record->cancelled_at) { + return 'danger'; + } + + return match ($record->latestShipmentInfo()?->status?->value) { + 'delivered' => 'success', + 'failed', 'returned' => 'danger', + 'in_transit', 'out_for_delivery', 'collected_from_sender' => 'warning', + default => 'gray', + }; + } + + private function cancel(Shipment $record): void + { + $service = app(CarrierFulfillmentInterface::class, ['carrier' => $record->carrier]); + + try { + $service->cancelShipment($record); + } catch (Throwable $e) { + report($e); + + Notification::make() + ->title('Failed to cancel shipment: '.$e->getMessage()) + ->color('danger') + ->send(); + + return; + } + + Notification::make() + ->title('Shipment cancelled.') + ->color('success') + ->send(); + } +} diff --git a/src/Shipping/Extensions/OrderViewExtension.php b/src/Shipping/Extensions/OrderViewExtension.php index eec8e28..f792860 100644 --- a/src/Shipping/Extensions/OrderViewExtension.php +++ b/src/Shipping/Extensions/OrderViewExtension.php @@ -3,25 +3,90 @@ namespace Modules\Core\Shipping\Extensions; use Filament\Actions\Action; +use Filament\Forms\Components\Repeater; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; -use Filament\Forms\Components\Toggle; -use Closure; -use Throwable; -use Filament\Actions; -use Filament\Forms; use Filament\Notifications\Notification; use Lunar\Admin\Support\Extending\ViewPageExtension; use Lunar\Models\Order; -use Lunar\Shipping\Models\ShippingMethod; -use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; +use Modules\Core\Order\DTOs\OrderFulfillmentResult; +use Modules\Core\Order\Services\OrderFulfillmentService; +use Modules\Core\Order\Services\OrderStatusFlow; use Modules\Core\Shipping\DTOs\ShipmentRequest; +use Modules\Core\Shipping\Support\WeightCalculator; +/** + * Filament wiring only (labels, icons, visibility, form schema) for the + * staff-facing status workflow — every guard check, status write, and + * event dispatch lives in Modules\Core\Order\Services\ + * OrderFulfillmentService/OrderStatusFlow, resolved via app() (a + * ViewPageExtension is instantiated by Lunar's own extension mechanism, + * not the container, so there's no constructor-injection seam here). + * + * Strips Lunar's own "Update Status" header action (registered by vendor + * ManageOrder as Action::make('update_status')) and replaces it with our + * own action of the same name — vendor's writes $record->status directly + * with no audit trail, no branch validation, and no side effects. Our + * replacement offers every status in the order's own branch (carrier or + * pickup — see OrderStatusFlow::allOptions()), not just the guided next + * step, so staff can freely revert to an earlier status too. It is a + * PLAIN status write — picking 'dispatched' here does not create a real + * shipment (see "Create Shipment" below for that). + * + * "Create Shipment" is its own separate header action, visible only for a + * carrier order sitting at 'ready_for_dispatch' — this is the one action + * that talks to a real carrier API and writes Order::status to + * 'dispatched' as a side effect of that succeeding, so it needs its own + * weight/locker inputs specific to that one real-world action, not + * bundled into the general-purpose status select where they'd appear for + * every revert/manual-override use of 'dispatched' too. The form branches + * on which carrier the order actually uses + * (OrderFulfillmentService::carrierFor()): a weight-billed carrier (ACS) + * gets a single TOTAL weight field for the whole shipment (ACS has no + * per-package weight concept — one Weight value is sent alongside + * Item_Quantity in the same ACS_Create_Voucher call, see + * AcsFulfillmentService::createShipment()), pre-filled from the order's + * own line weights (Modules\Core\Shipping\Support\WeightCalculator) but + * still staff-editable, plus a package count (ShipmentRequest:: + * $packageCount) — more than 1 issues a main voucher plus a + * multi-part sub-voucher per extra package (persistMultipartVouchers()), + * each recorded as its own Shipment row sharing the same total weight in + * meta. Box Now, which bills by compartment size rather than + * weight, gets a repeatable list of boxes (one row per physical parcel, + * each with its own S/M/L size) instead — see + * Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService for how + * multiple boxes become multiple Shipment rows from one delivery request. + * Box Now's locker is locked to read-only once the shopper's own checkout + * selection ($order->shippingAddress->meta['box_now_locker']) is present + * — staff can only fill it in manually for the (current, checkout-UI-less) + * case where nothing set it yet. + * + * "Mark Paid" is a third, separate header action — Order::paid is + * independent of `status` (see OrderStatusFlow's own docblock), so it + * doesn't belong bundled into the status select either. Visible only when + * the order's payment method doesn't auto-capture at checkout (currently + * only cash-on-delivery — see OrderStatusFlow::canMarkPaid()); a + * processor-managed method (Stripe) or an immediate-capture offline + * method (cash-in-hand, bank-transfer) sets Order::paid automatically via + * Modules\Core\Order\Listeners\ApplyResolvedPaymentStatus, so this button + * never appears for those. + * + * Also strips Lunar's own "Download PDF" header action (getDefaultHeaderActions() + * in vendor ManageOrder) — its lunarpanel::pdf.order template does not meet + * Greek AADE e-invoicing requirements, so it must not be offered as a + * downloadable document until a compliant invoice generator exists. + */ class OrderViewExtension extends ViewPageExtension { public function headerActions(array $actions): array { + $actions = array_filter($actions, fn ($action) => method_exists($action, 'getName') + ? ! in_array($action->getName(), ['download_pdf', 'update_status'], true) + : true); + $actions[] = $this->createShipmentAction(); - $actions[] = $this->markPickedUpAction(); + $actions[] = $this->updateStatusAction(); + $actions[] = $this->markPaidAction(); return $actions; } @@ -32,122 +97,133 @@ class OrderViewExtension extends ViewPageExtension ->label('Create Shipment') ->icon('heroicon-o-truck') ->modalSubmitActionLabel('Create Shipment') - ->schema([ - TextInput::make('weight') - ->label('Package weight (kg)') - ->numeric() - ->minValue(0) - ->helperText('Leave blank to use the carrier\'s default.'), - TextInput::make('destination_location_id') - ->label('Box Now locker ID') - ->helperText('Only required for Box Now shipments.') - ->default(fn (Order $record) => $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null), - Toggle::make('confirm') - ->label('Confirm') - ->helperText('This will create a real shipment with the carrier.') - ->rules([ - function () { - return function (string $attribute, $value, Closure $fail) { - if ($value !== true) { - $fail('Please confirm before creating the shipment.'); - } - }; - }, - ]), - ]) - ->action(function (Order $record, array $data, Action $action) { - $service = $this->resolveFulfillmentService($record); + ->schema(function (Order $record) { + $isBoxNow = $this->service()->carrierFor($record) === 'box-now'; + $lockerId = $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null; - if (! $service) { - Notification::make() - ->title('No carrier fulfillment integration is configured for this order.') - ->danger() - ->send(); - - $action->halt(); - - return; + if (! $isBoxNow) { + return [ + TextInput::make('weight') + ->label('Total weight (kg)') + ->numeric() + ->minValue(0) + ->default(fn () => round(WeightCalculator::totalKg($record->lines), 2) ?: null) + ->helperText("Calculated from the order's line weights — adjust if needed, or leave blank to use the carrier's default. One figure for the whole shipment, not per package."), + TextInput::make('package_count') + ->label('Number of packages') + ->numeric() + ->integer() + ->minValue(1) + ->default(1) + ->required() + ->helperText('More than 1 issues a main voucher plus a sub-voucher per extra package, all sharing the total weight above.'), + ]; } - $request = new ShipmentRequest( - weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null, - destinationLocationId: $data['destination_location_id'] ?? null, + return [ + TextInput::make('destination_location_id') + ->label('Box Now locker ID') + ->default($lockerId) + // Locked once the shopper's own checkout selection is + // known — staff should not be able to redirect a + // parcel to a different locker than the one the + // customer picked. Only editable for the (current, + // checkout-UI-less) case where nothing set it yet. + ->disabled(filled($lockerId)) + ->dehydrated() + ->required() + ->helperText($lockerId + ? 'Set by the customer at checkout.' + : 'No locker was selected at checkout — enter it manually.'), + Repeater::make('boxes') + ->label('Boxes') + ->schema([ + Select::make('size') + ->label('Size') + ->options(['S' => 'Small', 'M' => 'Medium', 'L' => 'Large']) + ->default('S') + ->native(false) + ->required(), + ]) + ->defaultItems(1) + ->addActionLabel('Add another box') + ->minItems(1) + ->helperText('One row per physical parcel — Box Now ships by compartment size, not weight.'), + ]; + }) + ->action(function (Order $record, array $data, Action $action) { + $result = $this->service()->createShipmentAndDispatch( + $record, + new ShipmentRequest( + weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null, + packageCount: (int) ($data['package_count'] ?? 1), + destinationLocationId: $data['destination_location_id'] ?? null, + boxes: collect($data['boxes'] ?? [])->pluck('size')->all(), + ), ); - try { - $service->createShipment($record, $request); - } catch (Throwable $e) { - report($e); - - Notification::make() - ->title('Failed to create shipment: '.$e->getMessage()) - ->danger() - ->send(); + $this->notify($result); + if (! $result->success) { $action->halt(); - - return; } - - Notification::make() - ->title('Shipment created.') - ->success() - ->send(); }) - ->visible(fn (Order $record) => $record->status === 'ready-for-dispatch' - && ! $record->isStorePickupOrder() - && $record->shipments()->exists() === false - && $this->resolveFulfillmentService($record) !== null); + ->visible(fn (Order $record) => $this->service()->canCreateShipment($record)); } - /** - * 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 + private function updateStatusAction(): Action { - return Action::make('mark_picked_up') - ->label('Mark Picked Up') - ->icon('heroicon-o-check-circle') + return Action::make('update_status') + ->label('Update Status') + ->icon('heroicon-o-adjustments-horizontal') + ->schema(fn (Order $record) => [ + Select::make('to_status') + ->label('New status') + ->options(app(OrderStatusFlow::class)->allOptions($record)) + ->default($record->status) + ->native(false) + ->required(), + ]) + ->action(function (Order $record, array $data, Action $action) { + $to = $data['to_status']; + $service = $this->service(); + + $result = match (true) { + ($to === 'ready_for_dispatch' || $to === 'ready_for_pickup') && $record->status === 'processing' => $service->markReady($record), + $to === 'picked_up' && $record->status === 'ready_for_pickup' => $service->markPickedUp($record), + default => $service->transitionTo($record, $to), + }; + + $this->notify($result); + + if (! $result->success) { + $action->halt(); + } + }); + } + + private function markPaidAction(): Action + { + return Action::make('mark_paid') + ->label('Mark Paid') + ->icon('heroicon-o-banknotes') ->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()); + ->modalDescription('Confirms payment for this order has been received outside the system.') + ->visible(fn (Order $record) => app(OrderStatusFlow::class)->canMarkPaid($record)) + ->action(fn (Order $record) => $this->notify($this->service()->markPaid($record))); } - private function resolveCarrier(Order $record): ?string + private function service(): OrderFulfillmentService { - $code = $record->shippingAddress?->shipping_option; - - if (! $code) { - return null; - } - - return ShippingMethod::where('code', $code)->value('driver'); + return app(OrderFulfillmentService::class); } - private function resolveFulfillmentService(Order $record): ?CarrierFulfillmentInterface + private function notify(OrderFulfillmentResult $result): void { - $carrier = $this->resolveCarrier($record); - - if (! $carrier) { - return null; - } - - return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]); + Notification::make() + ->title($result->message) + ->color($result->success ? 'success' : 'danger') + ->send(); } } diff --git a/src/Shipping/Filament/Pages/ManagePickupManifests.php b/src/Shipping/Filament/Pages/ManagePickupManifests.php deleted file mode 100644 index 2b6617b..0000000 --- a/src/Shipping/Filament/Pages/ManagePickupManifests.php +++ /dev/null @@ -1,123 +0,0 @@ -query($this->pendingQuery()) - ->columns([ - TextColumn::make('carrier')->badge(), - TextColumn::make('tracking_reference')->label('Tracking #'), - TextColumn::make('order.reference')->label('Order'), - TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'), - ]) - ->recordActions([ - Action::make('print') - ->label('Print') - ->icon('heroicon-o-printer') - ->action(fn (Shipment $record) => $this->printShipment($record)), - ]) - ->toolbarActions([ - BulkAction::make('print_selected') - ->label('Print selected') - ->icon('heroicon-o-printer') - ->action(fn (Collection $records) => $records->each(fn (Shipment $shipment) => $this->printShipment($shipment))), - BulkAction::make('issue_manifest') - ->label('Issue Manifest') - ->icon('heroicon-o-check-circle') - ->action(fn (Collection $records) => $this->issueManifest($records)), - ]); - } - - private function pendingQuery(): Builder - { - $carriers = collect(Shipping::getSupportedDrivers())->keys()->filter( - fn (string $carrier) => $this->fulfillmentService($carrier) instanceof SupportsManifestBatching - ); - - return Shipment::query() - ->whereIn('carrier', $carriers) - ->whereNull('manifest_reference') - ->whereNull('cancelled_at'); - } - - private function printShipment(Shipment $shipment): void - { - $this->fulfillmentService($shipment->carrier)?->printLabel($shipment); - } - - private function issueManifest(Collection $shipments): void - { - $shipments->groupBy('carrier')->each(function (Collection $group, string $carrier) { - $service = $this->fulfillmentService($carrier); - - if (! $service instanceof SupportsManifestBatching) { - return; - } - - $result = $service->issueManifest($group); - - if (! $result->success) { - Notification::make() - ->title("Manifest blocked for {$carrier}: {$result->reason}") - ->danger() - ->send(); - - return; - } - - Notification::make() - ->title("Manifest issued for {$carrier}: {$result->reference}") - ->success() - ->send(); - }); - } - - private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface - { - return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]); - } -} diff --git a/src/Shipping/Filament/Resources/ManifestResource.php b/src/Shipping/Filament/Resources/ManifestResource.php new file mode 100644 index 0000000..91f18dc --- /dev/null +++ b/src/Shipping/Filament/Resources/ManifestResource.php @@ -0,0 +1,66 @@ +columns([ + TextColumn::make('carrier')->badge(), + TextColumn::make('reference')->label('Reference')->copyable(), + TextColumn::make('shipment_count')->label('Shipments'), + TextColumn::make('issued_at')->label('Issued')->dateTime(), + ]) + ->recordActions([ + ViewAction::make(), + ]) + ->defaultSort('issued_at', 'desc'); + } + + public static function getRelations(): array + { + return [ + ShipmentsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListManifests::route('/'), + 'view' => ViewManifest::route('/{record}'), + ]; + } +} diff --git a/src/Shipping/Filament/Resources/ManifestResource/Pages/ListManifests.php b/src/Shipping/Filament/Resources/ManifestResource/Pages/ListManifests.php new file mode 100644 index 0000000..36c9eec --- /dev/null +++ b/src/Shipping/Filament/Resources/ManifestResource/Pages/ListManifests.php @@ -0,0 +1,28 @@ +keys() + ->filter(fn (string $carrier) => ShipmentResource::fulfillmentService($carrier) instanceof SupportsManifestBatching); + + return $carriers->mapWithKeys(fn (string $carrier) => [ + $carrier => Tab::make(ucwords(str_replace('-', ' ', $carrier))) + ->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)), + ])->all(); + } +} diff --git a/src/Shipping/Filament/Resources/ManifestResource/Pages/ViewManifest.php b/src/Shipping/Filament/Resources/ManifestResource/Pages/ViewManifest.php new file mode 100644 index 0000000..7a316f6 --- /dev/null +++ b/src/Shipping/Filament/Resources/ManifestResource/Pages/ViewManifest.php @@ -0,0 +1,33 @@ +components([ + TextEntry::make('carrier')->badge(), + TextEntry::make('reference')->label('Reference')->copyable(), + TextEntry::make('shipment_count')->label('Shipments'), + TextEntry::make('issued_at')->label('Issued')->dateTime(), + ]); + } +} diff --git a/src/Shipping/Filament/Resources/ManifestResource/RelationManagers/ShipmentsRelationManager.php b/src/Shipping/Filament/Resources/ManifestResource/RelationManagers/ShipmentsRelationManager.php new file mode 100644 index 0000000..44b1eba --- /dev/null +++ b/src/Shipping/Filament/Resources/ManifestResource/RelationManagers/ShipmentsRelationManager.php @@ -0,0 +1,39 @@ +columns([ + TextColumn::make('tracking_reference')->label('Tracking #'), + TextColumn::make('order.reference')->label('Order'), + TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'), + TextColumn::make('cancelled_at')->label('Cancelled')->dateTime()->placeholder('—'), + ]) + ->recordActions([ + Action::make('print') + ->label('Print') + ->icon('heroicon-o-printer') + ->action(fn (Shipment $record) => ShipmentResource::printShipment($record)), + ]); + } +} diff --git a/src/Shipping/Filament/Resources/ShipmentResource.php b/src/Shipping/Filament/Resources/ShipmentResource.php new file mode 100644 index 0000000..82835e9 --- /dev/null +++ b/src/Shipping/Filament/Resources/ShipmentResource.php @@ -0,0 +1,153 @@ +whereNull('manifest_id') + ->whereNull('cancelled_at'); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('carrier')->badge(), + TextColumn::make('tracking_reference')->label('Tracking #'), + TextColumn::make('order.reference')->label('Order'), + TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'), + ]) + ->recordActions([ + Action::make('print') + ->label('Print') + ->icon('heroicon-o-printer') + ->action(fn (Shipment $record) => self::printShipment($record)), + ]) + ->toolbarActions([ + BulkAction::make('print_selected') + ->label('Print selected') + ->icon('heroicon-o-printer') + ->action(fn (Collection $records) => $records->each(fn (Shipment $shipment) => self::printShipment($shipment))), + BulkAction::make('issue_manifest') + ->label('Issue Manifest') + ->icon('heroicon-o-check-circle') + ->action(fn (Collection $records) => self::issueManifest($records)), + ]); + } + + public static function printShipment(Shipment $shipment): void + { + $service = self::fulfillmentService($shipment->carrier); + + if (! $service) { + Notification::make() + ->title("No fulfillment integration configured for {$shipment->carrier}.") + ->danger() + ->send(); + + return; + } + + try { + $service->printLabel($shipment); + } catch (Throwable $e) { + report($e); + + Notification::make() + ->title("Failed to print label for {$shipment->tracking_reference}: {$e->getMessage()}") + ->danger() + ->send(); + } + } + + public static function issueManifest(Collection $shipments): void + { + $shipments->groupBy('carrier')->each(function (Collection $group, string $carrier) { + $service = self::fulfillmentService($carrier); + + if (! $service instanceof SupportsManifestBatching) { + return; + } + + try { + $result = $service->issueManifest($group); + } catch (Throwable $e) { + report($e); + + Notification::make() + ->title("Failed to issue manifest for {$carrier}: {$e->getMessage()}") + ->danger() + ->send(); + + return; + } + + if (! $result->success) { + Notification::make() + ->title("Manifest blocked for {$carrier}: {$result->reason}") + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title("Manifest issued for {$carrier}: {$result->reference}") + ->success() + ->send(); + }); + } + + public static function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface + { + return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]); + } + + public static function getPages(): array + { + return [ + 'index' => ListShipments::route('/'), + ]; + } +} diff --git a/src/Shipping/Filament/Resources/ShipmentResource/Pages/ListShipments.php b/src/Shipping/Filament/Resources/ShipmentResource/Pages/ListShipments.php new file mode 100644 index 0000000..331ec9c --- /dev/null +++ b/src/Shipping/Filament/Resources/ShipmentResource/Pages/ListShipments.php @@ -0,0 +1,36 @@ +keys() + ->filter(fn (string $carrier) => ShipmentResource::fulfillmentService($carrier) instanceof SupportsManifestBatching); + + return $carriers->mapWithKeys(fn (string $carrier) => [ + $carrier => Tab::make(ucwords(str_replace('-', ' ', $carrier))) + ->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)), + ])->all(); + } +} diff --git a/src/Shipping/Http/Controllers/DownloadShipmentLabelController.php b/src/Shipping/Http/Controllers/DownloadShipmentLabelController.php new file mode 100644 index 0000000..c2a1d5c --- /dev/null +++ b/src/Shipping/Http/Controllers/DownloadShipmentLabelController.php @@ -0,0 +1,55 @@ +hasValidSignature()) { + abort(401); + } + + $shipment = Shipment::findOrFail($shipment); + + $service = app(CarrierFulfillmentInterface::class, ['carrier' => $shipment->carrier]); + + $bytes = $service->printLabel($shipment); + + return response($bytes, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="shipment-'.$shipment->tracking_reference.'.pdf"', + ]); + } +} diff --git a/src/Shipping/Models/Manifest.php b/src/Shipping/Models/Manifest.php new file mode 100644 index 0000000..a79d2de --- /dev/null +++ b/src/Shipping/Models/Manifest.php @@ -0,0 +1,28 @@ + 'datetime', + ]; + + public function shipments(): HasMany + { + return $this->hasMany(Shipment::class); + } +} diff --git a/src/Shipping/Models/Shipment.php b/src/Shipping/Models/Shipment.php index c278f4c..e760f30 100644 --- a/src/Shipping/Models/Shipment.php +++ b/src/Shipping/Models/Shipment.php @@ -23,6 +23,11 @@ class Shipment extends Model return $this->belongsTo(Order::class); } + public function manifest(): BelongsTo + { + return $this->belongsTo(Manifest::class); + } + public function shipmentInfo(): HasMany { return $this->hasMany(ShipmentInfo::class); diff --git a/src/Shipping/Support/WeightCalculator.php b/src/Shipping/Support/WeightCalculator.php new file mode 100644 index 0000000..4ca4ea0 --- /dev/null +++ b/src/Shipping/Support/WeightCalculator.php @@ -0,0 +1,47 @@ +load('purchasable') + * is called here if not already eager-loaded). + */ + public static function totalKg(Collection $lines): float + { + $weight = 0.0; + + foreach ($lines->load('purchasable') as $line) { + $variant = $line->purchasable; + + if (! $variant || ! $variant->weight_value) { + continue; + } + + $unit = $variant->weight_unit ?? 'kg'; + $value = (float) $variant->weight_value; + + $weight += match ($unit) { + 'g' => $value / 1000, + 'lb' => $value * 0.45359237, + 'oz' => $value * 0.0283495231, + default => $value, // kg + } * $line->quantity; + } + + return $weight; + } +} diff --git a/src/Shipping/routes/web.php b/src/Shipping/routes/web.php new file mode 100644 index 0000000..ec0f91e --- /dev/null +++ b/src/Shipping/routes/web.php @@ -0,0 +1,7 @@ +name('shipments.label');