From 49ec4333afa5facf2078ad37b14b5ddf61601704 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Wed, 30 Sep 2026 17:28:47 +0300 Subject: [PATCH] Feat: Shipping functionalities redesign, elta courier integration, commenting out acs since no integration can happen, flow updates --- ...0_000003_add_source_to_shipments_table.php | 37 ++ .../order/notifications/dispatched.blade.php | 9 + .../activity/shipment-checkpoint.blade.php | 13 + .../shipping/carriers/elta/label-a4.blade.php | 3 +- .../shipping/carriers/elta/label-a6.blade.php | 37 +- .../elta/partials/_main-copy.blade.php | 24 +- .../elta/partials/_payment-stub.blade.php | 38 +- src/Checkout/Services/CheckoutService.php | 2 +- src/CorePlugin.php | 2 + .../Services/CustomerAccountService.php | 2 +- .../Seeders/StorefrontTranslationsSeeder.php | 20 + src/Order/Events/OrderDispatched.php | 16 +- src/Order/Events/OrderStatusUpdated.php | 1 + .../AdvanceFulfillmentOnCarrierCheckpoint.php | 5 +- .../DeriveOrderDeliveredFromShipment.php | 5 +- .../MarkDeliveryFailedOnCarrierCheckpoint.php | 5 +- .../OrderDispatchedNotification.php | 9 + .../OrderPickupReadyNotification.php | 8 +- .../OrderStatusUpdatedNotification.php | 29 +- src/Order/Observers/OrderObserver.php | 2 + .../Services/OrderFulfillmentService.php | 71 ++- src/Order/Services/OrderStatusWriter.php | 20 +- src/Order/Support/OrderStatus.php | 4 +- src/Providers/ShippingServiceProvider.php | 30 +- .../ActivityLog/ShipmentCheckpointRender.php | 45 ++ .../Carriers/Acs/AcsFulfillmentService.php | 128 ++++- src/Shipping/Carriers/Acs/AcsRateDriver.php | 3 +- .../BoxNow/BoxNowFulfillmentService.php | 115 ++++- src/Shipping/Carriers/Elta/EltaClient.php | 26 + .../Carriers/Elta/EltaFulfillmentService.php | 453 ++++++++++++++---- .../Carriers/Elta/EltaLabelRenderer.php | 198 ++++++-- src/Shipping/Carriers/Elta/EltaRateDriver.php | 3 +- .../Manual/ManualFulfillmentService.php | 52 ++ .../Carriers/Manual/ManualRateDriver.php | 74 +++ .../Contracts/IssuesVoucherOnPrint.php | 14 + .../Contracts/SupportsBatchLabels.php | 20 + .../Contracts/SupportsCashCollection.php | 8 +- .../Contracts/SupportsExtraServices.php | 21 + .../Contracts/SupportsVoucherListing.php | 19 + .../Contracts/SupportsVoucherLookup.php | 14 + src/Shipping/DTOs/CarrierVoucher.php | 32 ++ src/Shipping/DTOs/ShipmentRequest.php | 24 + src/Shipping/Enums/ExtraService.php | 47 ++ .../Extensions/OrderShipmentsExtension.php | 135 ++++-- .../Extensions/OrderViewExtension.php | 228 ++++++++- .../ShippingMethodResourceExtension.php | 40 +- .../Resources/CarrierVoucherResource.php | 351 ++++++++++++++ .../Pages/ListCarrierVouchers.php | 112 +++++ .../Pages/ViewCarrierVoucher.php | 32 ++ .../ShipmentsRelationManager.php | 7 +- .../Filament/Resources/ShipmentResource.php | 132 ++++- .../ShipmentResource/Pages/ListShipments.php | 29 +- .../DownloadShipmentLabelController.php | 12 +- .../DownloadShipmentLabelsController.php | 48 ++ src/Shipping/Jobs/PollShipmentTrackingJob.php | 39 +- src/Shipping/Jobs/SyncCarrierVouchersJob.php | 34 ++ .../LogShipmentCheckpointOnOrderTimeline.php | 20 + src/Shipping/Models/Shipment.php | 86 ++++ src/Shipping/Services/CarrierVoucherSync.php | 224 +++++++++ .../Services/ShipmentTrackingRecorder.php | 80 ++++ .../Support/ShipmentTimelineLogger.php | 60 +++ src/Shipping/Support/VoucherOrderMatcher.php | 129 +++++ src/Shipping/routes/web.php | 4 + 63 files changed, 3143 insertions(+), 347 deletions(-) create mode 100644 database/migrations/2026_09_30_000003_add_source_to_shipments_table.php create mode 100644 resources/views/shipping/activity/shipment-checkpoint.blade.php create mode 100644 src/Shipping/ActivityLog/ShipmentCheckpointRender.php create mode 100644 src/Shipping/Carriers/Manual/ManualFulfillmentService.php create mode 100644 src/Shipping/Carriers/Manual/ManualRateDriver.php create mode 100644 src/Shipping/Contracts/IssuesVoucherOnPrint.php create mode 100644 src/Shipping/Contracts/SupportsBatchLabels.php create mode 100644 src/Shipping/Contracts/SupportsExtraServices.php create mode 100644 src/Shipping/Contracts/SupportsVoucherListing.php create mode 100644 src/Shipping/Contracts/SupportsVoucherLookup.php create mode 100644 src/Shipping/DTOs/CarrierVoucher.php create mode 100644 src/Shipping/Enums/ExtraService.php create mode 100644 src/Shipping/Filament/Resources/CarrierVoucherResource.php create mode 100644 src/Shipping/Filament/Resources/CarrierVoucherResource/Pages/ListCarrierVouchers.php create mode 100644 src/Shipping/Filament/Resources/CarrierVoucherResource/Pages/ViewCarrierVoucher.php create mode 100644 src/Shipping/Http/Controllers/DownloadShipmentLabelsController.php create mode 100644 src/Shipping/Jobs/SyncCarrierVouchersJob.php create mode 100644 src/Shipping/Listeners/LogShipmentCheckpointOnOrderTimeline.php create mode 100644 src/Shipping/Services/CarrierVoucherSync.php create mode 100644 src/Shipping/Services/ShipmentTrackingRecorder.php create mode 100644 src/Shipping/Support/ShipmentTimelineLogger.php create mode 100644 src/Shipping/Support/VoucherOrderMatcher.php diff --git a/database/migrations/2026_09_30_000003_add_source_to_shipments_table.php b/database/migrations/2026_09_30_000003_add_source_to_shipments_table.php new file mode 100644 index 0000000..c2d949f --- /dev/null +++ b/database/migrations/2026_09_30_000003_add_source_to_shipments_table.php @@ -0,0 +1,37 @@ +string('tracking_reference')->nullable()->change(); + $table->unsignedBigInteger('order_id')->nullable()->change(); + $table->string('source')->default('created')->index()->after('carrier'); + }); + } + + public function down(): void + { + Schema::table('shipments', function (Blueprint $table) { + $table->dropColumn('source'); + $table->unsignedBigInteger('order_id')->nullable(false)->change(); + $table->string('tracking_reference')->nullable(false)->change(); + }); + } +}; diff --git a/resources/views/order/notifications/dispatched.blade.php b/resources/views/order/notifications/dispatched.blade.php index b89cf17..ce9fe0d 100644 --- a/resources/views/order/notifications/dispatched.blade.php +++ b/resources/views/order/notifications/dispatched.blade.php @@ -1,3 +1,12 @@

Hi,

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

+ +@foreach ($shipments ?? [] as $shipment) +

+ {{ $shipment->carrierLabel() }}: {{ $shipment->tracking_reference }} + @if ($url = $shipment->trackingUrl()) + — Track your parcel + @endif +

+@endforeach diff --git a/resources/views/shipping/activity/shipment-checkpoint.blade.php b/resources/views/shipping/activity/shipment-checkpoint.blade.php new file mode 100644 index 0000000..0d0b74c --- /dev/null +++ b/resources/views/shipping/activity/shipment-checkpoint.blade.php @@ -0,0 +1,13 @@ +
+
+ @svg('heroicon-m-truck', ['class' => 'w-4 text-gray-500']) + {{ $carrier }} {{ $trackingReference }}@if ($isReturn) (return)@endif + {{ $statusLabel }} +
+ + @if ($details || $occurredAt) +
+ {{ $details }}@if ($details && $occurredAt) · @endif{{ $occurredAt?->format('Y-m-d H:i') }} +
+ @endif +
diff --git a/resources/views/shipping/carriers/elta/label-a4.blade.php b/resources/views/shipping/carriers/elta/label-a4.blade.php index 185dbb0..db39d1a 100644 --- a/resources/views/shipping/carriers/elta/label-a4.blade.php +++ b/resources/views/shipping/carriers/elta/label-a4.blade.php @@ -117,7 +117,8 @@
ΜΗ ΣΗΜΕΙΩΝΕΤΕ ΚΑΤΩ ΑΠΟ ΑΥΤΗ ΤΗ ΓΡΑΜΜΗ
- + {{-- antik_ocr: ELTA's OCR line as issued (it already has its own > < markers) --}} + diff --git a/resources/views/shipping/carriers/elta/label-a6.blade.php b/resources/views/shipping/carriers/elta/label-a6.blade.php index 0bfb277..547c736 100644 --- a/resources/views/shipping/carriers/elta/label-a6.blade.php +++ b/resources/views/shipping/carriers/elta/label-a6.blade.php @@ -66,10 +66,10 @@ .a6-small-title { font-weight: bold; font-size: 5pt; } .a6-small-val { font-size: 6pt; } - .a6-legal { font-size: 5pt; text-align: center; line-height: 1.4; } + .a6-legal { font-size: 5pt; text-align: center; line-height: 1.15; } .a6-barcode { text-align: center; } - .a6-barcode img { height: 30px; } + .a6-barcode img { height: 24px; } .a6-voucher { text-align: center; font-weight: bold; font-size: 12pt; letter-spacing: 1px; } @@ -143,7 +143,7 @@
ΟΓΚΟΜΕΤΡΙΚΟ ΒΑΡΟΣ(Kgr)
-
{{ $volumetric_weight ?? '0.000' }}
+
{{ $volumetric_weight }}
Τεμάχια
@@ -177,10 +177,12 @@ 'lines' => $recipient_lines, ]) + {{-- antik_1..7 (textbox51/21/22/24/56/59/61): "ΑΝΤΙΚΑΤΑΒΟΛΗ 17.00", + "* ΑΝΑΛΥΣΗ *", "17.00 MΕΤΡΗΤΑ", one line every 0.37cm. --}}
- @if ($antik_1 ?? null) -
{{ $antik_1 }}
- @endif + @foreach ($antik_lines as $i => $line) +
{{ $line }}
+ @endforeach
{{-- REFERENCE / ΕΠΙΒΑΡΥΝΣΕΙΣ --}} @@ -188,22 +190,27 @@
REFERENCE
{{ $order_reference }}
-
+
* ΕΠΙΒΑΡΥΝΣΕΙΣ *
+ @foreach ([$sur_1 ?? null, $sur_2 ?? null, $sur_3 ?? null, $sur_4 ?? null] as $sur) + @if (!empty($sur)) +
{{ $sur }}
+ @endif + @endforeach
{{-- ΠΑΡΑΤΗΡΗΣΕΙΣ --}} -
+
* ΠΑΡΑΤΗΡΗΣΕΙΣ *
@if ($multiPiece) -
-
* ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ * {{ $package_label }}
+
+
{{ $polaplo }}
@endif -
+
@if ($antik_1 ?? null) -
-
{{ $antik_1 }}
+
+
{{ $antik_1 }}
@endif -
+
-
{{ $voucher_no }}
+
{{ $voucher_no }}
diff --git a/resources/views/shipping/carriers/elta/partials/_main-copy.blade.php b/resources/views/shipping/carriers/elta/partials/_main-copy.blade.php index 89a7c33..2a94e45 100644 --- a/resources/views/shipping/carriers/elta/partials/_main-copy.blade.php +++ b/resources/views/shipping/carriers/elta/partials/_main-copy.blade.php @@ -38,6 +38,15 @@
{{ $voucher_no }}
+{{-- The sender's copy carries the COD warning under the voucher + (sydetaE.rdlc textbox205: antik_minima), only for cash on delivery. + Its "Απόδειξη Είσπαξης" note (textbox211) sits at the top-left in the + RDLC, where our header block is — it goes in this copy's COD box + instead, under the breakdown. --}} +@if ($copyType === 'sender' && $antik_minima) +
{{ $antik_minima }}
+@endif + {{-- 5-cell deposit/destination/weight/pieces/volumetric row — a genuinely tabular single row of fixed-width cells, so it's built as a real rather than independently-bordered @@ -69,7 +78,7 @@
Ογκ/κο Βάρος
-
{{ $volumetric_weight ?? '0.000' }}
+
{{ $volumetric_weight }}
@@ -135,7 +144,10 @@ {{-- Right-hand signature / print-timestamp column, differs by copy type --}} @if ($copyType === 'delivery')
-
{{ $antik_1 ?? '' }}
+ {{-- antik_1..6 (textbox102/108/107/106/105/104), first line bold --}} + @foreach ($antik_lines as $i => $line) +
{{ $line }}
+ @endforeach
ΓΙΑ ΤΗΝ ΠΑΡΑΛΑΒΗ
@@ -144,7 +156,13 @@
@else
-
{{ $antik_1 ?? '' }}
+ {{-- antik_1..6 (textbox102/108/107/106/105/104), first line bold --}} + @foreach ($antik_lines as $i => $line) +
{{ $line }}
+ @endforeach + @if ($apodiksi) +
{{ $apodiksi }}
+ @endif
ΥΠΟΓΡΑΦΗ ΑΠΟΣΤΟΛΕΑ
diff --git a/resources/views/shipping/carriers/elta/partials/_payment-stub.blade.php b/resources/views/shipping/carriers/elta/partials/_payment-stub.blade.php index 05c554a..bdfff60 100644 --- a/resources/views/shipping/carriers/elta/partials/_payment-stub.blade.php +++ b/resources/views/shipping/carriers/elta/partials/_payment-stub.blade.php @@ -63,7 +63,8 @@
ΑΠΟΣΤΟΛΕΑΣ
- @foreach ($sender_lines as $i => $line) + {{-- The stub prints sender_1/sender_2 only (code + name) --}} + @foreach (array_slice($sender_lines, 0, 2) as $i => $line) @if (trim((string) $line) !== '')
{{ $line }}
@endif @@ -76,7 +77,8 @@
ΠΑΡΑΛΗΠΤΗΣ
- @foreach ($recipient_lines as $i => $line) + {{-- ...and rec_1..4: name, address, postcode — no phone --}} + @foreach (array_slice($recipient_lines, 0, 3) as $i => $line) @if (trim((string) $line) !== '')
{{ $line }}
@endif @@ -84,6 +86,12 @@
* ΕΠΙΒΑΡΥΝΣΕΙΣ *
+ {{-- sur_1..4 (textbox139-142) --}} + @foreach ([$sur_1, $sur_2, $sur_3, $sur_4] as $i => $sur) + @if (filled($sur)) +
{{ $sur }}
+ @endif + @endforeach
@@ -100,17 +108,10 @@
ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ ΠΑΡΑΛΗΠΤΗ
Ημ/νία:     Ώρα:
-{{-- rectangle44 --}} -
-
Π Ο Σ Ο
-
{{ number_format($cod_amount ?? 0, 2) }}
-
-{{-- rectangle45: small ELTA-branded legal box; the real RDLC fills it via - its own Image18 bitmap (baked "ΠΟΡΤΑ-ΠΟΡΤΑ ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ..." - legal text + logo graphic) rather than text fields — approximated - here with our own extracted logo + the same legal text as plain - HTML, since we can't extract that embedded bitmap. --}} -
+{{-- Image18: the ELTA "ΠΟΡΤΑ-ΠΟΡΤΑ" details bitmap, above ΠΟΣΟ — + approximated with our logo + the same text, since the bitmap can't + be extracted. --}} +
+{{-- rectangle44: the COD breakdown, antik_1..6 (textbox149-151,167-169) --}} +
+ @foreach ($antik_lines as $i => $line) +
{{ $line }}
+ @endforeach +
+{{-- rectangle45: Π Ο Σ Ο + antik_poso (textbox148/152) --}} +
+
Π Ο Σ Ο
+
{{ $antik_poso }}
+
diff --git a/src/Checkout/Services/CheckoutService.php b/src/Checkout/Services/CheckoutService.php index 48fa764..cb5bbc3 100644 --- a/src/Checkout/Services/CheckoutService.php +++ b/src/Checkout/Services/CheckoutService.php @@ -300,7 +300,7 @@ class CheckoutService if ($shippingMethod !== null && $method->driver === 'cash-on-delivery') { $shippingDriver = collect(Shipping::getSupportedDrivers())->get($shippingMethod->driver); - return $shippingDriver instanceof SupportsCashCollection && $shippingDriver->collectsCash(); + return $shippingDriver instanceof SupportsCashCollection && $shippingDriver->collectsCash($shippingMethod); } return true; diff --git a/src/CorePlugin.php b/src/CorePlugin.php index b119b41..5325a03 100644 --- a/src/CorePlugin.php +++ b/src/CorePlugin.php @@ -49,6 +49,7 @@ use Modules\Core\Shipping\Extensions\OrderViewExtension; use Modules\Core\Shipping\Extensions\ShippingMethodListExtension; use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension; use Modules\Core\Shipping\Filament\Resources\ManifestResource; +use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource; use Modules\Core\Shipping\Filament\Resources\ShipmentResource; use Modules\Core\Store\Filament\Pages\ManageStoreDetails; @@ -74,6 +75,7 @@ class CorePlugin implements Plugin CartResource::class, PaymentMethodResource::class, ShipmentResource::class, + CarrierVoucherResource::class, ManifestResource::class, ]) ->pages([ diff --git a/src/Customer/Services/CustomerAccountService.php b/src/Customer/Services/CustomerAccountService.php index d97dd4e..2bd8202 100644 --- a/src/Customer/Services/CustomerAccountService.php +++ b/src/Customer/Services/CustomerAccountService.php @@ -112,7 +112,7 @@ class CustomerAccountService $order = $customer ?->orders() ->whereNotNull('placed_at') - ->with(['lines', 'shippingAddress', 'billingAddress', 'transactions', 'shipments']) + ->with(['lines', 'shippingAddress', 'billingAddress', 'transactions', 'shipments.shipmentInfo']) ->find($orderId); if (! $order) { diff --git a/src/Localization/Database/Seeders/StorefrontTranslationsSeeder.php b/src/Localization/Database/Seeders/StorefrontTranslationsSeeder.php index cdf1b05..489a5e0 100644 --- a/src/Localization/Database/Seeders/StorefrontTranslationsSeeder.php +++ b/src/Localization/Database/Seeders/StorefrontTranslationsSeeder.php @@ -251,6 +251,26 @@ class StorefrontTranslationsSeeder extends Seeder 'orders.payment' => ['Payment method', 'Τρόπος πληρωμής'], 'orders.shipping_method' => ['Shipping method', 'Τρόπος αποστολής'], 'orders.tracking' => ['Tracking', 'Παρακολούθηση αποστολής'], + 'orders.shipment' => ['Shipment', 'Αποστολή'], + 'orders.voucher_number' => ['Tracking number', 'Αριθμός αποστολής'], + 'orders.shipment_status' => ['Shipment status', 'Κατάσταση αποστολής'], + 'orders.tracking_history' => ['Tracking history', 'Ιστορικό αποστολής'], + 'orders.tracking_empty' => [ + 'No updates from the courier yet.', + 'Δεν υπάρχουν ακόμα ενημερώσεις από την εταιρεία courier.', + ], + 'orders.track_on_carrier' => ['Track on the courier\'s website', 'Παρακολούθηση στη σελίδα του courier'], + + // ── Tracking statuses (Modules\Core\Shipping\Enums\TrackingStatus) ── + 'tracking_status.pending' => ['Awaiting pickup', 'Αναμονή παραλαβής'], + 'tracking_status.collected_from_sender' => ['Picked up by the courier', 'Παραλήφθηκε από το courier'], + 'tracking_status.in_transit' => ['In transit', 'Σε μεταφορά'], + 'tracking_status.out_for_delivery' => ['Out for delivery', 'Προς παράδοση'], + 'tracking_status.delivered' => ['Delivered', 'Παραδόθηκε'], + 'tracking_status.failed' => ['Delivery attempt failed', 'Αποτυχία παράδοσης'], + 'tracking_status.returned' => ['Returned to sender', 'Επιστράφηκε στον αποστολέα'], + 'tracking_status.cancelled' => ['Cancelled', 'Ακυρώθηκε'], + 'tracking_status.unknown' => ['Update', 'Ενημέρωση'], 'orders.items' => ['Items', 'Προϊόντα'], 'orders.subtotal' => ['Subtotal', 'Μερικό σύνολο'], 'orders.discount' => ['Discount', 'Έκπτωση'], diff --git a/src/Order/Events/OrderDispatched.php b/src/Order/Events/OrderDispatched.php index 116338a..add8120 100644 --- a/src/Order/Events/OrderDispatched.php +++ b/src/Order/Events/OrderDispatched.php @@ -7,16 +7,12 @@ use Lunar\Models\Order; use Modules\Core\Shipping\Models\ShipmentInfo; /** - * Dispatched by either of the two paths that move a carrier order's - * `status` to 'dispatched' — Modules\Core\Order\Listeners\ - * AdvanceFulfillmentOnCarrierCheckpoint (automatic, reacting to a real - * carrier checkpoint) or Modules\Core\Order\Services\ - * OrderFulfillmentService::createShipmentAndDispatch() (staff-driven, via - * the single "Update Status" action). $shipmentInfo is nullable - * specifically because of that second path — populated with the - * triggering checkpoint when it's real, null when staff drove it - * manually. Mirrors OrderDelivered's {order, shipmentInfo} shape, just - * with the nullability this one event additionally needs. + * Fired when the carrier picks the parcel up — Modules\Core\Order\ + * Listeners\AdvanceFulfillmentOnCarrierCheckpoint, on the first InTransit / + * CollectedFromSender checkpoint (synced from the carrier, or entered by + * hand for a manual carrier). Creating a shipment doesn't move the order to + * dispatched on its own. $shipmentInfo is that checkpoint; nullable for a + * caller without one. Mirrors OrderDelivered's {order, shipmentInfo} shape. */ class OrderDispatched { diff --git a/src/Order/Events/OrderStatusUpdated.php b/src/Order/Events/OrderStatusUpdated.php index cf1b809..0d67e6d 100644 --- a/src/Order/Events/OrderStatusUpdated.php +++ b/src/Order/Events/OrderStatusUpdated.php @@ -21,5 +21,6 @@ class OrderStatusUpdated public readonly Order $order, public readonly ?string $previousStatus, public readonly string $newStatus, + public readonly ?string $causeClass = null, ) {} } diff --git a/src/Order/Listeners/AdvanceFulfillmentOnCarrierCheckpoint.php b/src/Order/Listeners/AdvanceFulfillmentOnCarrierCheckpoint.php index 641a481..391675a 100644 --- a/src/Order/Listeners/AdvanceFulfillmentOnCarrierCheckpoint.php +++ b/src/Order/Listeners/AdvanceFulfillmentOnCarrierCheckpoint.php @@ -46,9 +46,10 @@ class AdvanceFulfillmentOnCarrierCheckpoint implements ShouldQueue return; } - $order = $event->shipmentInfo->shipment->order; + $shipment = $event->shipmentInfo->shipment; + $order = $shipment->order; - if (! $order || ! $this->flow->isValidTransition($order, 'dispatched')) { + if (! $order || ! $shipment->drivesOrderStatus() || ! $this->flow->isValidTransition($order, 'dispatched')) { return; } diff --git a/src/Order/Listeners/DeriveOrderDeliveredFromShipment.php b/src/Order/Listeners/DeriveOrderDeliveredFromShipment.php index d7d5672..d0290cd 100644 --- a/src/Order/Listeners/DeriveOrderDeliveredFromShipment.php +++ b/src/Order/Listeners/DeriveOrderDeliveredFromShipment.php @@ -26,9 +26,10 @@ class DeriveOrderDeliveredFromShipment implements ShouldQueue return; } - $order = $event->shipmentInfo->shipment->order; + $shipment = $event->shipmentInfo->shipment; + $order = $shipment->order; - if (! $order) { + if (! $order || ! $shipment->drivesOrderStatus()) { return; } diff --git a/src/Order/Listeners/MarkDeliveryFailedOnCarrierCheckpoint.php b/src/Order/Listeners/MarkDeliveryFailedOnCarrierCheckpoint.php index 460be03..36fc1d2 100644 --- a/src/Order/Listeners/MarkDeliveryFailedOnCarrierCheckpoint.php +++ b/src/Order/Listeners/MarkDeliveryFailedOnCarrierCheckpoint.php @@ -32,9 +32,10 @@ class MarkDeliveryFailedOnCarrierCheckpoint implements ShouldQueue return; } - $order = $event->shipmentInfo->shipment->order; + $shipment = $event->shipmentInfo->shipment; + $order = $shipment->order; - if (! $order || ! $this->flow->isValidTransition($order, 'delivery_failed')) { + if (! $order || ! $shipment->drivesOrderStatus() || ! $this->flow->isValidTransition($order, 'delivery_failed')) { return; } diff --git a/src/Order/Notifications/OrderDispatchedNotification.php b/src/Order/Notifications/OrderDispatchedNotification.php index fc58a8e..e8c5fa8 100644 --- a/src/Order/Notifications/OrderDispatchedNotification.php +++ b/src/Order/Notifications/OrderDispatchedNotification.php @@ -8,6 +8,8 @@ use Illuminate\Support\Facades\Notification as NotificationFacade; use Modules\Core\Notification\BaseNotification; use Modules\Core\Order\Events\OrderDispatched; use Modules\Core\Order\Support\OrderReferenceDisplay; +use Modules\Core\Shipping\Models\Shipment; + /** * Fills a real, previously-unfilled customer-communication gap — before @@ -53,6 +55,13 @@ class OrderDispatchedNotification extends BaseNotification ->view('core::order.notifications.dispatched', [ 'order' => $order, 'reference' => $reference, + // Active, numbered, non-return shipments: the voucher numbers + // to show, plus the courier's link for manual carriers + // (Shipment::trackingUrl()). Integrated carriers' history is + // on the customer's order page. + 'shipments' => $order->shipments()->get() + ->reject(fn (Shipment $shipment) => $shipment->isCancelled() || $shipment->isReturn() || blank($shipment->tracking_reference)) + ->values(), ]); } } diff --git a/src/Order/Notifications/OrderPickupReadyNotification.php b/src/Order/Notifications/OrderPickupReadyNotification.php index f5aed5e..3b914d4 100644 --- a/src/Order/Notifications/OrderPickupReadyNotification.php +++ b/src/Order/Notifications/OrderPickupReadyNotification.php @@ -13,11 +13,9 @@ use Modules\Core\Order\Support\OrderReferenceDisplay; * "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. + * branch only), not the generic OrderStatusUpdated. + * Modules\Core\Order\Notifications\OrderStatusUpdatedNotification + * suppresses itself for that same write (its OWN_EMAIL map). */ class OrderPickupReadyNotification extends BaseNotification { diff --git a/src/Order/Notifications/OrderStatusUpdatedNotification.php b/src/Order/Notifications/OrderStatusUpdatedNotification.php index 044e7e4..771f370 100644 --- a/src/Order/Notifications/OrderStatusUpdatedNotification.php +++ b/src/Order/Notifications/OrderStatusUpdatedNotification.php @@ -6,7 +6,12 @@ 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\Commands\CloseExpiredReturnWindows; use Modules\Core\Order\Events\OrderStatusUpdated; +use Modules\Core\Order\Listeners\AdvanceFulfillmentOnCarrierCheckpoint; +use Modules\Core\Order\Listeners\AdvanceFulfillmentOnDelivered; +use Modules\Core\Order\Listeners\CompleteOrderOnPickedUp; +use Modules\Core\Order\Services\OrderFulfillmentService; use Modules\Core\Order\Services\OrderStatusFlow; use Modules\Core\Order\Support\OrderReferenceDisplay; @@ -25,12 +30,22 @@ class OrderStatusUpdatedNotification extends BaseNotification } /** - * 'ready-for-pickup' has its own, richer notification - * (Modules\Core\Order\Notifications\OrderPickupReadyNotification) — - * both listen to the same OrderStatusUpdated event via - * NotificationRegistry, so without this the customer would get two - * emails for that one transition. Returning no channels is the - * standard Laravel way to suppress a notification outright. + * Writes that send their own, richer email for the new status — the + * generic one stays out of those, or the customer gets two. The same + * status written any other way (staff's "Update Status", which fires + * none of those events) still gets the generic email. + */ + private const OWN_EMAIL = [ + 'ready_for_pickup' => [OrderFulfillmentService::class.'::markReady'], // OrderPickupReadyNotification + 'dispatched' => [AdvanceFulfillmentOnCarrierCheckpoint::class], // OrderDispatchedNotification + 'delivered' => [AdvanceFulfillmentOnDelivered::class], // OrderDeliveredNotification + 'completed' => [CompleteOrderOnPickedUp::class, CloseExpiredReturnWindows::class], // OrderCompletedNotification + ]; + + /** + * Suppressed when a dedicated notification covers this write (see + * OWN_EMAIL). Returning no channels is the standard Laravel way to + * suppress a notification outright. * * Also suppressed for the order's very first transition off * 'awaiting_payment' — for every payment method except bank transfer, @@ -50,7 +65,7 @@ class OrderStatusUpdatedNotification extends BaseNotification */ public function via(object $notifiable): array { - if ($this->event->newStatus === 'ready-for-pickup') { + if (in_array($this->event->causeClass, self::OWN_EMAIL[$this->event->newStatus] ?? [], true)) { return []; } diff --git a/src/Order/Observers/OrderObserver.php b/src/Order/Observers/OrderObserver.php index ea7979e..a2e3803 100644 --- a/src/Order/Observers/OrderObserver.php +++ b/src/Order/Observers/OrderObserver.php @@ -4,6 +4,7 @@ namespace Modules\Core\Order\Observers; use Lunar\Models\Order; use Modules\Core\Order\Events\OrderStatusUpdated; +use Modules\Core\Order\Services\OrderStatusWriter; /** * Generically dispatches OrderStatusUpdated for ANY write to `status`, @@ -30,6 +31,7 @@ class OrderObserver $order, $order->getOriginal('status'), $order->status, + OrderStatusWriter::currentCause(), ); } } diff --git a/src/Order/Services/OrderFulfillmentService.php b/src/Order/Services/OrderFulfillmentService.php index 20768af..a3315b7 100644 --- a/src/Order/Services/OrderFulfillmentService.php +++ b/src/Order/Services/OrderFulfillmentService.php @@ -12,6 +12,8 @@ use Modules\Core\Payment\DTOs\PaymentResult; use Modules\Core\Payment\Enums\PaymentResultStatus; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\DTOs\ShipmentRequest; +use Modules\Core\Shipping\Enums\ExtraService; +use Modules\Core\Shipping\Models\Shipment; use Throwable; /** @@ -56,7 +58,15 @@ class OrderFulfillmentService return OrderFulfillmentResult::success('Order marked ready.'); } - public function createShipmentAndDispatch(Order $order, ShipmentRequest $request): OrderFulfillmentResult + /** + * Creates the shipment only — the order stays at ready_for_dispatch + * until the carrier actually picks the parcel up. That move (and the + * "on its way" email) happens in Modules\Core\Order\Listeners\ + * AdvanceFulfillmentOnCarrierCheckpoint, on the first InTransit / + * CollectedFromSender checkpoint — synced from the carrier, or entered + * by hand for a manual carrier. + */ + public function createShipment(Order $order, ShipmentRequest $request): OrderFulfillmentResult { if ($order->status !== 'ready_for_dispatch') { return OrderFulfillmentResult::failure('This order is not ready to be dispatched.'); @@ -76,9 +86,48 @@ class OrderFulfillmentService return OrderFulfillmentResult::failure('Failed to create shipment: '.$e->getMessage()); } - $this->writer->write($order, 'dispatched', self::class.'::createShipmentAndDispatch'); + return OrderFulfillmentResult::success('Shipment created. The order moves to Dispatched when the carrier picks it up.'); + } - return OrderFulfillmentResult::success('Shipment created and order dispatched.'); + /** + * Records an integrated carrier's voucher that wasn't created through + * our API — the carrier's system was down and a pre-numbered paper + * voucher was used, or the courier wrote his own at pickup. It's still + * that carrier's voucher, so PollShipmentTrackingJob picks up its + * history once the carrier's API has it. + * + * @param array $services + */ + public function addManualVoucher(Order $order, string $carrier, string $voucherNumber, array $services = []): OrderFulfillmentResult + { + if (! $this->canAddManualVoucher($order)) { + return OrderFulfillmentResult::failure('A voucher can only be added from Ready for Dispatch until the order is delivered.'); + } + + $voucherNumber = trim($voucherNumber); + + if (Shipment::where('tracking_reference', $voucherNumber)->exists()) { + return OrderFulfillmentResult::failure("Voucher {$voucherNumber} is already recorded."); + } + + Shipment::create([ + 'order_id' => $order->id, + 'carrier' => $carrier, + 'source' => Shipment::SOURCE_MANUAL_VOUCHER, + 'tracking_reference' => $voucherNumber, + 'meta' => [ + 'services' => array_map(fn (ExtraService $service) => $service->value, $services), + 'cod_amount' => $this->flow->isCod($order) ? $order->total->decimal : null, + ], + ]); + + return OrderFulfillmentResult::success("Voucher {$voucherNumber} added."); + } + + public function canAddManualVoucher(Order $order): bool + { + return in_array($order->status, ['ready_for_dispatch', 'dispatched', 'delivery_failed'], true) + && ! $order->isStorePickupOrder(); } public function markPickedUp(Order $order): OrderFulfillmentResult @@ -175,14 +224,28 @@ class OrderFulfillmentService return OrderFulfillmentResult::success('Order marked as paid.'); } + /** + * A cancelled shipment doesn't block creating a new one. + */ public function canCreateShipment(Order $order): bool { return $order->status === 'ready_for_dispatch' && ! $order->isStorePickupOrder() - && $order->shipments()->exists() === false + && $order->shipments()->whereNull('cancelled_at')->doesntExist() && $this->resolveFulfillmentService($order) !== null; } + /** + * The order's shipping method — manual carriers keep their name, + * tracking URL template and cash-on-delivery setting in its `data`. + */ + public function shippingMethodFor(Order $order): ?ShippingMethod + { + $code = $order->shippingAddress?->shipping_option; + + return $code ? ShippingMethod::where('code', $code)->first() : null; + } + /** * Public wrapper around resolveCarrier() — Modules\Core\Shipping\ * Extensions\OrderViewExtension needs to know which carrier an order diff --git a/src/Order/Services/OrderStatusWriter.php b/src/Order/Services/OrderStatusWriter.php index 8f9b588..334687f 100644 --- a/src/Order/Services/OrderStatusWriter.php +++ b/src/Order/Services/OrderStatusWriter.php @@ -29,6 +29,18 @@ use Modules\Core\Order\Events\OrderStatusChanged; */ class OrderStatusWriter { + /** + * The cause of the write in progress, so OrderObserver can put it on + * the generic OrderStatusUpdated event (null for writes that don't go + * through here, e.g. tinker). + */ + private static ?string $currentCause = null; + + public static function currentCause(): ?string + { + return self::$currentCause; + } + public function write(Order $order, string $to, string $causeClass): void { $from = $order->status; @@ -37,7 +49,13 @@ class OrderStatusWriter return; } - $order->update(['status' => $to]); + self::$currentCause = $causeClass; + + try { + $order->update(['status' => $to]); + } finally { + self::$currentCause = null; + } OrderStatusChanged::dispatch($order, $from, $to, $causeClass); } diff --git a/src/Order/Support/OrderStatus.php b/src/Order/Support/OrderStatus.php index 364aa6e..0dd0ba5 100644 --- a/src/Order/Support/OrderStatus.php +++ b/src/Order/Support/OrderStatus.php @@ -66,7 +66,9 @@ class OrderStatus */ public static function fulfillment(Order $order): FulfillmentStatus { - $shipments = $order->shipments->reject(fn ($shipment) => $shipment->cancelled_at !== null); + // Return vouchers linked to the order describe the parcel coming + // back, not its delivery — they never count towards fulfillment. + $shipments = $order->shipments->reject(fn ($shipment) => $shipment->isCancelled() || $shipment->isReturn()); if ($shipments->isEmpty()) { return FulfillmentStatus::Unfulfilled; diff --git a/src/Providers/ShippingServiceProvider.php b/src/Providers/ShippingServiceProvider.php index 3c78f7b..0de35ee 100644 --- a/src/Providers/ShippingServiceProvider.php +++ b/src/Providers/ShippingServiceProvider.php @@ -4,9 +4,13 @@ namespace Modules\Core\Providers; use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule; use Illuminate\Support\Facades\Event; +use Modules\Core\Shipping\ActivityLog\ShipmentCheckpointRender; +use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; +use Modules\Core\Shipping\Listeners\LogShipmentCheckpointOnOrderTimeline; use Illuminate\Support\ServiceProvider; use Livewire\Livewire; use Livewire\Mechanisms\ComponentRegistry; +use Lunar\Admin\Support\ActivityLog\Manifest as ActivityLogManifest; use Lunar\Models\Order; use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates; @@ -29,8 +33,11 @@ use Modules\Core\Shipping\Carriers\Elta\EltaFulfillmentService; use Modules\Core\Shipping\Carriers\Elta\EltaRateDriver; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; +use Modules\Core\Shipping\Carriers\Manual\ManualFulfillmentService; +use Modules\Core\Shipping\Carriers\Manual\ManualRateDriver; use Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver; use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob; +use Modules\Core\Shipping\Jobs\SyncCarrierVouchersJob; use Modules\Core\Shipping\Listeners\InvalidateShippingOptions; use Modules\Core\Shipping\Support\FulfillmentType; use Modules\Core\Shipping\Support\ShippingManager; @@ -53,6 +60,7 @@ class ShippingServiceProvider extends ServiceProvider 'acs' => $app->make(AcsFulfillmentService::class), 'box-now' => $app->make(BoxNowFulfillmentService::class), 'elta' => $app->make(EltaFulfillmentService::class), + 'manual' => $app->make(ManualFulfillmentService::class), default => null, }; }); @@ -70,6 +78,13 @@ class ShippingServiceProvider extends ServiceProvider public function boot(): void { + // Carrier checkpoints on the order page's Timeline. + Event::listen(ShipmentStatusUpdatedByCarrier::class, LogShipmentCheckpointOnOrderTimeline::class); + // Added when Lunar's manifest is built, not now: resolving it during + // boot builds it before the morph map exists, which keys every + // renderer (Lunar's own too) under a name the Timeline never looks up. + $this->app->afterResolving('lunar-activity-log', fn (ActivityLogManifest $manifest) => $manifest->addRender(Order::class, ShipmentCheckpointRender::class)); + $this->publishes([ __DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'), __DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'), @@ -141,18 +156,23 @@ class ShippingServiceProvider extends ServiceProvider $this->app->booted(function () { $this->app->bind(ShippingMethodManagerInterface::class, fn ($app) => $app->make(ShippingManager::class)); - Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class)); + // Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class)); Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class)); Shipping::extend('store-pickup', fn ($app) => $app->make(StorePickupRateDriver::class)); Shipping::extend('elta', fn ($app) => $app->make(EltaRateDriver::class)); + Shipping::extend('manual', fn ($app) => $app->make(ManualRateDriver::class)); - $this->app->make(ConsoleSchedule::class) - ->job(new WarmAcsAreaCacheJob) - ->dailyAt('06:00') - ->when(fn () => ShippingMethod::where('driver', 'acs')->exists()); + // $this->app->make(ConsoleSchedule::class) + // ->job(new WarmAcsAreaCacheJob) + // ->dailyAt('06:00') + // ->when(fn () => ShippingMethod::where('driver', 'acs')->exists()); $this->app->make(ConsoleSchedule::class) ->job(new PollShipmentTrackingJob) + ->everyFiveMinutes(); + + $this->app->make(ConsoleSchedule::class) + ->job(new SyncCarrierVouchersJob) ->everyThirtyMinutes(); $this->overrideRatesPageLivewireComponent(); diff --git a/src/Shipping/ActivityLog/ShipmentCheckpointRender.php b/src/Shipping/ActivityLog/ShipmentCheckpointRender.php new file mode 100644 index 0000000..9a54b19 --- /dev/null +++ b/src/Shipping/ActivityLog/ShipmentCheckpointRender.php @@ -0,0 +1,45 @@ +getExtraProperty('status'); + $occurredAt = $log->getExtraProperty('occurred_at'); + + return view('core::shipping.activity.shipment-checkpoint', [ + 'carrier' => $log->getExtraProperty('carrier'), + 'trackingReference' => $log->getExtraProperty('tracking_reference'), + 'isReturn' => (bool) $log->getExtraProperty('is_return'), + 'statusLabel' => (string) str($status)->replace('_', ' ')->title(), + 'statusColor' => match ($status) { + 'delivered' => 'success', + 'failed', 'returned', 'cancelled' => 'danger', + 'in_transit', 'out_for_delivery', 'collected_from_sender' => 'warning', + default => 'gray', + }, + 'details' => collect([ + $log->getExtraProperty('location'), + $log->getExtraProperty('message') ?: $log->getExtraProperty('carrier_status'), + ])->filter()->unique()->implode(' · '), + 'occurredAt' => $occurredAt ? Carbon::parse($occurredAt)->timezone(config('app.timezone')) : null, + ]); + } +} diff --git a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php index f2f67d3..8f1e4e0 100644 --- a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php +++ b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php @@ -3,22 +3,45 @@ namespace Modules\Core\Shipping\Carriers\Acs; use RuntimeException; +use Carbon\CarbonInterface; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Lunar\Models\Order; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; +use Modules\Core\Shipping\Contracts\SupportsExtraServices; +use Modules\Core\Shipping\Contracts\SupportsVoucherListing; +use Modules\Core\Shipping\Contracts\SupportsVoucherLookup; +use Modules\Core\Shipping\DTOs\CarrierVoucher; use Modules\Core\Shipping\Contracts\SupportsManifestBatching; use Modules\Core\Shipping\Contracts\SupportsTracking; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\DTOs\ManifestResult; use Modules\Core\Shipping\DTOs\ShipmentRequest; +use Modules\Core\Shipping\Enums\ExtraService; 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 +class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsExtraServices, SupportsManifestBatching, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup { + /** + * ACS's Acs_Delivery_Products codes. Timed delivery also sends + * Appointment_Until_Time; insurance sends Insurance_Ammount. + */ + public function extraServices(): array + { + return [ + ExtraService::SaturdayDelivery->value => 'SAT', + ExtraService::MorningDelivery->value => 'MDV', + ExtraService::TimedDelivery->value => 'TDD', + ExtraService::Insurance->value => 'INS', + ExtraService::ReturnDocuments->value => 'RDO', + ExtraService::RemoteArea->value => 'REM', + ExtraService::Protocol->value => 'PRO', + ]; + } + public function __construct( private readonly AcsClient $client, private readonly AreaResolver $areaResolver, @@ -44,12 +67,35 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani 'Charge_Type' => 2, 'Item_Quantity' => $request->packageCount, 'Weight' => $weight, + // Our order reference, so the vouchers ACS reports back can be + // matched to their order. + 'Reference_Key1' => (string) $order->reference, ]; - if ($request->paymentMode === 'cod') { - $params['Cod_Ammount'] = $request->amountToCollect ?? $order->total->decimal; + $codAmount = $request->paymentMode === 'cod' + ? (float) ($request->amountToCollect ?? $order->total->decimal) + : null; + + $products = collect($request->services) + ->map(fn (ExtraService $service) => $this->extraServices()[$service->value] ?? null) + ->filter(); + + if ($codAmount !== null) { + $params['Cod_Ammount'] = $codAmount; $params['Cod_Payment_Way'] = 0; // cash - $params['Acs_Delivery_Products'] = 'COD'; + $products->push('COD'); + } + + if ($products->isNotEmpty()) { + $params['Acs_Delivery_Products'] = $products->unique()->implode(','); + } + + if ($request->has(ExtraService::Insurance) && $request->insuranceAmount) { + $params['Insurance_Ammount'] = $request->insuranceAmount; + } + + if ($request->has(ExtraService::TimedDelivery) && $request->deliveryUntil) { + $params['Appointment_Until_Time'] = substr($request->deliveryUntil, 0, 5); } $response = $this->client->call('ACS_Create_Voucher', $params)->throwIfError(); @@ -64,6 +110,9 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani 'station_destination' => $destination->stationId, 'weight' => $weight, 'pickup_date' => now()->toDateString(), + 'cod_amount' => $codAmount, + 'services' => $request->serviceValues(), + 'insurance_amount' => $request->has(ExtraService::Insurance) ? $request->insuranceAmount : null, ], ]); @@ -167,6 +216,68 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani }); } + /** + * ACS can't list vouchers directly: each day's pickup lists + * (ACS_Get_Pickup_Lists) → their vouchers (ACS_Pickup_List_Display_ + * Voucher). Only finalized vouchers appear, with our Reference_Key1 but + * no recipient or status — tracking fills those in. + */ + public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable + { + for ($day = $from->copy()->startOfDay(); $day->lte($to); $day = $day->addDay()) { + $date = $day->toDateString(); + + $lists = $this->client->call('ACS_Get_Pickup_Lists', ['Pickup_Date' => $date]) + ->throwIfError()->tableOutput['Table_Data'] ?? []; + + foreach ($lists as $list) { + $vouchers = $this->client->call('ACS_Pickup_List_Display_Voucher', [ + 'PickupList_No' => $list['PickupList_No'], + 'Pickup_Date' => $date, + ])->throwIfError()->tableOutput['Table_Data'] ?? []; + + foreach ($vouchers as $row) { + yield new CarrierVoucher( + carrier: 'acs', + voucherNumber: (string) $row['Voucher_no'], + reference: filled($row['Reference_Key1'] ?? null) ? (string) $row['Reference_Key1'] : null, + date: $day->copy(), + raw: $row, + ); + } + } + } + } + + /** + * ACS_Trackingsummary: recipient and delivery/return flags — ACS doesn't + * return our reference here. + */ + public function lookupVoucher(string $voucherNumber): ?CarrierVoucher + { + $row = $this->client->call('ACS_Trackingsummary', ['Voucher_No' => trim($voucherNumber)]) + ->throwIfError()->tableOutput['Table_Data'][0] ?? null; + + if (! $row || blank($row['voucher_no'] ?? null)) { + return null; + } + + return new CarrierVoucher( + carrier: 'acs', + voucherNumber: (string) $row['voucher_no'], + recipientName: $row['recipient'] ?? ($row['consignee'] ?? null), + statusText: (int) ($row['delivery_flag'] ?? 0) === 1 ? 'Delivered' : ($row['delivery_info'] ?? null), + status: match (true) { + (int) ($row['returned_flag'] ?? 0) === 1 => TrackingStatus::Returned, + (int) ($row['delivery_flag'] ?? 0) === 1 => TrackingStatus::Delivered, + default => null, + }, + isReturn: (int) ($row['returned_flag'] ?? 0) === 1, + date: filled($row['pickup_date'] ?? null) ? Carbon::parse($row['pickup_date']) : null, + raw: $row, + ); + } + private function isDelivered(Shipment $shipment): bool { try { @@ -177,7 +288,14 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani return false; } - return (int) ($response->valueOutput['shipment_status'] ?? 0) === 4; + // The summary row is in ACSTableOutput.Table_Data, not + // ACSValueOutput — reading the latter always came back empty, so no + // ACS shipment was ever marked delivered. delivery_flag = 1 is + // ACS's documented "delivered"; shipment_status 4 means the same. + $summary = $response->tableOutput['Table_Data'][0] ?? []; + + return (int) ($summary['delivery_flag'] ?? 0) === 1 + || (int) ($summary['shipment_status'] ?? 0) === 4; } private function guessStatusFromAction(string $action): TrackingStatus diff --git a/src/Shipping/Carriers/Acs/AcsRateDriver.php b/src/Shipping/Carriers/Acs/AcsRateDriver.php index e290e14..caade60 100644 --- a/src/Shipping/Carriers/Acs/AcsRateDriver.php +++ b/src/Shipping/Carriers/Acs/AcsRateDriver.php @@ -6,6 +6,7 @@ use Lunar\DataTypes\Price; use Lunar\DataTypes\ShippingOption; use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\Interfaces\ShippingRateInterface; +use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingRate; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Concerns\CachesLivePricing; @@ -40,7 +41,7 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla return 'carrier'; } - public function collectsCash(): bool + public function collectsCash(ShippingMethod $method): bool { return true; } diff --git a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php index 7dbae9a..c269398 100644 --- a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php +++ b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php @@ -2,12 +2,16 @@ namespace Modules\Core\Shipping\Carriers\BoxNow; +use Carbon\CarbonInterface; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Lunar\Models\Order; use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\SupportsTracking; +use Modules\Core\Shipping\Contracts\SupportsVoucherListing; +use Modules\Core\Shipping\Contracts\SupportsVoucherLookup; +use Modules\Core\Shipping\DTOs\CarrierVoucher; use Modules\Core\Shipping\DTOs\ShipmentRequest; use Modules\Core\Shipping\DTOs\TrackingCheckpoint; use Modules\Core\Shipping\Enums\TrackingStatus; @@ -32,7 +36,7 @@ use Modules\Core\Shipping\Models\Shipment; * sends that many entries in a single delivery request rather than * several separate ones. */ -class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking +class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup { private const COMPARTMENT_SIZES = ['S' => 1, 'M' => 2, 'L' => 3]; @@ -53,8 +57,21 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT $isCod = $request->paymentMode === 'cod'; + // Box Now rejects an orderNumber it has seen before (P410), even + // for a cancelled request — so a re-created shipment after a cancel + // gets "-2", "-3", …; the first attempt keeps "{reference}-{id}". + $previousRequests = Shipment::where('order_id', $order->id) + ->where('carrier', 'box-now') + ->where('source', Shipment::SOURCE_CREATED) + ->get() + ->map(fn (Shipment $shipment) => $shipment->meta['delivery_request_id'] ?? $shipment->id) + ->unique() + ->count(); + + $orderNumber = $order->reference.'-'.$order->id.($previousRequests > 0 ? '-'.($previousRequests + 1) : ''); + $response = $this->client->request('post', '/delivery-requests', [ - 'orderNumber' => $order->reference.'-'.$order->id, + 'orderNumber' => $orderNumber, 'invoiceValue' => number_format($order->total->decimal, 2, '.', ''), 'paymentMode' => $isCod ? 'cod' : 'prepaid', 'amountToBeCollected' => $isCod @@ -92,13 +109,18 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT // 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([ + // The cash is collected once for the whole request, so only the + // first parcel carries cod_amount. + $codAmount = $isCod ? (float) ($request->amountToCollect ?? $order->total->decimal) : null; + + $shipments = $parcels->values()->map(fn (array $parcel, int $index) => Shipment::create([ 'order_id' => $order->id, 'carrier' => 'box-now', 'tracking_reference' => (string) $parcel['id'], 'meta' => [ 'delivery_request_id' => $response['id'] ?? null, 'locker_id' => $destinationLocationId, + 'cod_amount' => $index === 0 ? $codAmount : null, ], ])); @@ -186,6 +208,91 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT )); } + /** + * Every parcel on the account (GET /parcels, 100 per page). Box Now has + * no date filter, so pages are read until a whole page is older than + * $from — its order isn't documented, so one old parcel alone doesn't + * stop the scan. + */ + public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable + { + $pageToken = null; + + do { + $response = $this->client->request('get', '/parcels', array_filter([ + 'limit' => 100, + 'pageToken' => $pageToken, + ])); + + $parcels = $response['data'] ?? []; + $anyInRange = false; + + foreach ($parcels as $parcel) { + $created = Carbon::parse($parcel['createTime'] ?? 'now'); + + if ($created->lt($from)) { + continue; + } + + $anyInRange = true; + + if ($created->lte($to)) { + yield $this->voucherFromParcel($parcel); + } + } + + $pageToken = $this->nextPageToken($response['pagination']['next'] ?? null); + } while ($pageToken && $anyInRange && $parcels !== []); + } + + public function lookupVoucher(string $voucherNumber): ?CarrierVoucher + { + $parcel = $this->client->request('get', '/parcels', ['parcelId' => trim($voucherNumber)])['data'][0] ?? null; + + return $parcel ? $this->voucherFromParcel($parcel) : null; + } + + private function voucherFromParcel(array $parcel): CarrierVoucher + { + $request = $parcel['deliveryRequest'] ?? []; + $destination = $request['destination'] ?? []; + $state = $parcel['state'] ?? null; + + return new CarrierVoucher( + carrier: 'box-now', + voucherNumber: (string) $parcel['id'], + reference: $request['orderNumber'] ?? null, + recipientName: $destination['contactName'] ?? null, + postcode: $destination['postalCode'] ?? ($destination['address']['postalCode'] ?? null), + phone: $destination['contactNumber'] ?? null, + statusText: $state, + status: $state ? $this->mapState($state) : null, + codAmount: ($request['paymentMode'] ?? null) === 'cod' ? ((float) ($request['amountToBeCollected'] ?? 0) ?: null) : null, + isReturn: in_array($state, ['returned', 'expired-return', 'accepted-for-return'], true), + date: isset($parcel['createTime']) ? Carbon::parse($parcel['createTime']) : null, + raw: $parcel, + ); + } + + /** + * pagination.next is documented only by name — accept either the bare + * token or a URL carrying it as ?pageToken=. + */ + private function nextPageToken(mixed $next): ?string + { + if (blank($next) || ! is_string($next)) { + return null; + } + + if (str_contains($next, 'pageToken=')) { + parse_str((string) parse_url($next, PHP_URL_QUERY), $query); + + return $query['pageToken'] ?? null; + } + + return $next; + } + private function mapState(string $state): TrackingStatus { // TODO: confirm against a live BoxNow webhook payload whether a @@ -201,7 +308,7 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT 'in-final-destination', 'wait-for-load' => TrackingStatus::OutForDelivery, 'delivered' => TrackingStatus::Delivered, 'returned', 'accepted-for-return' => TrackingStatus::Returned, - 'cancelled' => TrackingStatus::Cancelled, + 'cancelled', 'canceled' => TrackingStatus::Cancelled, 'expired-return', 'missing' => TrackingStatus::Failed, default => TrackingStatus::Unknown, }; diff --git a/src/Shipping/Carriers/Elta/EltaClient.php b/src/Shipping/Carriers/Elta/EltaClient.php index 754c28e..027db9a 100644 --- a/src/Shipping/Carriers/Elta/EltaClient.php +++ b/src/Shipping/Carriers/Elta/EltaClient.php @@ -58,11 +58,37 @@ class EltaClient return $this->call('PELPARALVGNEW1', $params); } + /** + * Issues a pending voucher (created with pel_insert_flag "0"): assigns + * its voucher number, OCR line, destination station and service. This + * is the official client's "print" — after it, the voucher can no + * longer be cancelled. + */ + public function issueVoucher(array $params): EltaResponse + { + return $this->call('PELVG01NEW1', $params); + } + + /** + * Only pending (not yet issued) vouchers can be deleted. + */ public function deleteVoucher(array $params): EltaResponse { return $this->call('PELVGDEL', $params); } + /** + * The client's "Πολλαπλή Αναζήτηση": every issued voucher on the + * account between date_1 and date_2 (dd/mm/yyyy), with its latest + * status. status_flag: 0 all, 1 delivered, 2 undelivered, 3 to be + * returned. Paged 100 at a time via in_id (the last row's + * pel_col_id). + */ + public function searchVouchers(array $params): EltaResponse + { + return $this->call('PELMANIF2', $params); + } + public function getTracking(array $params): EltaResponse { return $this->call('PELTTNEW01', $params); diff --git a/src/Shipping/Carriers/Elta/EltaFulfillmentService.php b/src/Shipping/Carriers/Elta/EltaFulfillmentService.php index 0e73d9c..41dd15e 100644 --- a/src/Shipping/Carriers/Elta/EltaFulfillmentService.php +++ b/src/Shipping/Carriers/Elta/EltaFulfillmentService.php @@ -2,34 +2,66 @@ namespace Modules\Core\Shipping\Carriers\Elta; +use Carbon\CarbonInterface; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Lunar\Models\Order; use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; +use Modules\Core\Shipping\Contracts\IssuesVoucherOnPrint; +use Modules\Core\Shipping\Contracts\SupportsBatchLabels; +use Modules\Core\Shipping\Contracts\SupportsExtraServices; use Modules\Core\Shipping\Contracts\SupportsTracking; +use Modules\Core\Shipping\Contracts\SupportsVoucherListing; +use Modules\Core\Shipping\Contracts\SupportsVoucherLookup; +use Modules\Core\Shipping\DTOs\CarrierVoucher; use Modules\Core\Shipping\DTOs\ShipmentRequest; use Modules\Core\Shipping\DTOs\TrackingCheckpoint; +use Modules\Core\Shipping\Enums\ExtraService; use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Models\Shipment; /** - * Not SupportsManifestBatching — no manifest/pickup-list operation was - * found in either ELTA operation family, unlike ACS's - * ACS_Issue_Pickup_List. Same shape as Box Now, which has no manifest - * step either. + * ELTA vouchers are created **pending** (pel_insert_flag "0", live-verified): + * ELTA stores the voucher with an internal id but no voucher number, and it + * can still be cancelled. Printing issues it (PELVG01NEW1 — what ELTA's own + * client does from its group-editing screen): that assigns the voucher + * number, OCR line, destination station and service, and from then on ELTA + * refuses to delete it ("Δεν Επιτρέπεται! Εχει Γίνει Εκτύπωση"). So a + * shipment has no tracking_reference until its label is printed. + * + * Not SupportsManifestBatching — ELTA has no manifest/pickup-list step. */ -class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking +class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVoucherOnPrint, SupportsBatchLabels, SupportsExtraServices, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup { + /** PELMANIF2 / PELPARALVGNEW1 page through 100 rows at a time. */ + private const MAX_PAGES = 50; + public function __construct( private readonly EltaClient $client, private readonly EltaLabelRenderer $labelRenderer, ) {} + /** + * ELTA surcharge codes (pel_sur_1..3), from its own client's checkboxes. + * Insurance isn't a surcharge — it's the amount in pel_asf_poso. + */ + public function extraServices(): array + { + return [ + ExtraService::SaturdayDelivery->value => '004', + ExtraService::TimedDelivery->value => '003', + ExtraService::SpecialHandling->value => '002', + ExtraService::Insurance->value => 'pel_asf_poso', + ]; + } + public function createShipment(Order $order, ShipmentRequest $request): Shipment { $address = $order->shippingAddress; $weight = $request->weight ?? 0.5; + $surcharges = $this->surchargeCodes($request); $params = [ 'pel_apost_code' => config('elta.apost_code'), @@ -47,9 +79,11 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTra 'pel_z' => '', 'pel_temaxia' => (string) $request->packageCount, 'pel_paral_sxolia' => '', - 'pel_sur_1' => '', - 'pel_sur_2' => '', - 'pel_sur_3' => '', + 'pel_sur_1' => $surcharges[0] ?? '', + 'pel_sur_2' => $surcharges[1] ?? '', + 'pel_sur_3' => $surcharges[2] ?? '', + // pel_ant_poso is the cash-on-delivery amount; pel_ant_poso1..4 + // are cheques (with dates) in ELTA's client — never used here. 'pel_ant_poso' => '', 'pel_ant_poso1' => '', 'pel_ant_poso2' => '', @@ -59,10 +93,16 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTra 'pel_ant_date2' => '', 'pel_ant_date3' => '', 'pel_ant_date4' => '', - 'pel_asf_poso' => '', + 'pel_asf_poso' => $request->has(ExtraService::Insurance) && $request->insuranceAmount + ? number_format($request->insuranceAmount, 2, '.', '') + : '', 'pel_user' => config('elta.user_code'), - 'pel_ref_no' => (string) $order->id, - 'pel_insert_flag' => '', + // Our order reference — comes back in ELTA's lists and tracking, + // which is how we find the pending voucher's id below and match + // vouchers to orders on the Carrier Vouchers screen. + 'pel_ref_no' => (string) $order->reference, + // "0" = save without issuing (see this class's docblock). + 'pel_insert_flag' => '0', 'pel_paral_code' => '', 'pel_retur_code' => '', ]; @@ -74,122 +114,66 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTra $params['pel_ant_poso'] = number_format($codAmount, 2, '.', ''); } - $response = $this->client->createVoucher($params)->throwIfError(); + $this->client->createVoucher($params)->throwIfError(); - $voucherNo = (string) $response->data['vg_code']; - - $shipment = Shipment::create([ + // PELVGNEW doesn't return the new voucher's id, so find it in the + // pending list by our reference. Saved even if that lookup fails — + // the voucher exists at ELTA either way, and printing retries it. + return Shipment::create([ 'order_id' => $order->id, 'carrier' => 'elta', - 'tracking_reference' => $voucherNo, + 'source' => Shipment::SOURCE_CREATED, + 'tracking_reference' => null, 'meta' => [ + 'carrier_id' => $this->findPendingVoucherId((string) $order->reference), 'weight' => $weight, - 'ocr_line' => $response->data['ocr_line'] ?? null, - 'date_time' => $response->data['date_time'] ?? null, 'package_count' => $request->packageCount, 'cod_amount' => $codAmount, + 'services' => $request->serviceValues(), + 'insurance_amount' => $request->has(ExtraService::Insurance) ? $request->insuranceAmount : null, ], ]); - - // PELVGNEW's own response already carries every child voucher - // number for a multi-piece shipment in vg_child_no — no second - // request needed, same as the old CREATEAWB/vg_child shape. - foreach (array_filter((array) ($response->data['vg_child_no'] ?? [])) as $childVoucherNo) { - Shipment::create([ - 'order_id' => $order->id, - 'carrier' => 'elta', - 'tracking_reference' => $childVoucherNo, - 'parent_reference' => $voucherNo, - 'meta' => $shipment->meta?->toArray() ?? [], - ]); - } - - return $shipment; } - /** - * PELB64VG (the old, deprecated operation this used to call) fails - * with the same st_flag=3 as CREATEAWB — it's the same legacy family. - * There's no *NEW replacement: ELTA's own official client renders - * labels entirely locally from a bundled report template, never - * fetching one from the server at all (see EltaLabelRenderer's - * docblock). This reproduces that local rendering instead. - */ public function printLabel(Shipment $shipment): string { - $pdf = $this->labelRenderer->render($shipment); + $this->issue($shipment); + + $pdf = $this->labelRenderer->render($shipment->refresh()); $shipment->update(['label_printed_at' => now()]); return $pdf; } + public function printLabels(Collection $shipments): string + { + $shipments->each(fn (Shipment $shipment) => $this->issue($shipment)); + + $pdf = $this->labelRenderer->renderMany($shipments->map->refresh()); + + Shipment::whereIn('id', $shipments->pluck('id'))->update(['label_printed_at' => now()]); + + return $pdf; + } + /** - * Unlike the old, deprecated operation set (which genuinely has no - * delete operation), the *NEW family does support cancellation via - * PELVGDEL — but it identifies a voucher by its internal pel_vg_id, - * not the AWB/tracking number PELVGNEW returns (vg_code). Getting - * pel_vg_id requires first listing the account's vouchers via - * PELPARALVGNEW1 and matching the row whose pel_paral_vg equals our - * tracking_reference. + * Pending (not yet printed) vouchers are deleted at ELTA. Issued ones + * can't be — ELTA only allows that before printing. */ public function cancelShipment(Shipment $shipment): void { - $voucherId = $this->findVoucherId($shipment->tracking_reference); - - if ($voucherId === null) { + if (filled($shipment->tracking_reference)) { throw new EltaApiException( - 'Could not find voucher '.$shipment->tracking_reference.' in ELTA\'s voucher list — it may already be too old or cancelled to look up.', + "ELTA voucher {$shipment->tracking_reference} is already printed, and ELTA only allows cancelling vouchers that haven't been printed yet. Contact ELTA to cancel it.", ); } - $this->client->deleteVoucher(['pel_vg_id' => $voucherId])->throwIfError(); + $this->client->deleteVoucher(['pel_vg_id' => $this->pendingVoucherId($shipment)])->throwIfError(); $shipment->update(['cancelled_at' => now()]); } - private function findVoucherId(string $trackingReference): ?string - { - $inId = '0'; - - // PELPARALVGNEW1 paginates via in_id, same cursor pattern the - // official client uses — walk pages until the voucher is found - // or a page comes back empty. flag_1/flag_2 map to the real - // client's "Show All"/"All Users" checkboxes — live-confirmed - // both must be '1', otherwise even a voucher created moments ago - // is filtered out of the (default, unfiltered-looking) list. - for ($page = 0; $page < 50; $page++) { - $response = $this->client->listVouchers([ - 'pel_code' => config('elta.apost_code'), - 'pel_user_code' => config('elta.user_code'), - 'flag_1' => '1', - 'flag_2' => '1', - 'in_id' => $inId, - ])->throwIfError(); - - $rows = array_filter( - (array) ($response->data['vg_rec'] ?? []), - fn (array $row) => filled($row['pel_vg_id'] ?? null), - ); - - if ($rows === []) { - return null; - } - - $match = collect($rows)->first( - fn (array $row) => ($row['pel_paral_vg'] ?? null) === $trackingReference, - ); - - if ($match !== null) { - return $match['pel_vg_id']; - } - - $inId = (string) end($rows)['pel_vg_id']; - } - - return null; - } - public function trackShipment(Shipment $shipment): Collection { $response = $this->client->getTracking([ @@ -220,12 +204,269 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTra carrierStatus: $row['web_status_name'] ?? null, message: $row['web_sxolia'] ?: ($row['web_status_name'] ?? null), location: $row['web_station'] ?? null, - occurredAt: Carbon::createFromFormat('YmdHi', substr($dateTime, 0, 12)), + // ELTA reports Greek local time. + occurredAt: Carbon::createFromFormat('YmdHi', substr($dateTime, 0, 12), 'Europe/Athens')->setTimezone(config('app.timezone')), meta: $row, ); }); } + /** + * PELMANIF2 (the client's "Πολλαπλή Αναζήτηση"): every issued voucher + * in the date range with its latest status. A second pass with + * status_flag 3 ("Προς Επιστροφή") marks the ones heading back to us. + */ + public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable + { + $returning = collect($this->searchVouchers($from, $to, '3'))->pluck('pel_col_1')->flip(); + + foreach ($this->searchVouchers($from, $to, '0') as $row) { + $number = trim($row['pel_col_1']); + + yield new CarrierVoucher( + carrier: 'elta', + voucherNumber: $number, + reference: filled($row['pel_col_2'] ?? null) ? trim($row['pel_col_2']) : null, + recipientName: trim($row['pel_col_3'] ?? '') ?: null, + postcode: trim($row['pel_col_4'] ?? '') ?: null, + statusText: trim($row['pel_col_6'] ?? '') ?: null, + status: filled(trim($row['pel_col_6'] ?? '')) ? $this->guessStatusFromTitle(trim($row['pel_col_6'])) : null, + codAmount: (float) ($row['pel_col_8'] ?? 0) ?: null, + isReturn: $returning->has($row['pel_col_1']), + date: $this->dateFromListRow($row['pel_col_7'] ?? ''), + raw: $row, + ); + } + } + + /** + * PELTTNEW01 (tracking) also returns the voucher's details (pel_rec): + * recipient, postcode, our reference, cash on delivery. + */ + public function lookupVoucher(string $voucherNumber): ?CarrierVoucher + { + $response = $this->client->getTracking([ + 'web_vg' => trim($voucherNumber), + 'pel_code' => config('elta.apost_code'), + ]); + + $record = $response->data['pel_rec'] ?? []; + + if ($response->hasError || blank($record['a_vg_3'] ?? null)) { + return null; + } + + $statuses = array_values(array_filter( + (array) ($response->data['web_status'] ?? []), + fn (array $row) => filled($row['web_date_time'] ?? null), + )); + + return new CarrierVoucher( + carrier: 'elta', + voucherNumber: trim($record['a_vg_3']), + reference: trim($record['a_ref'] ?? '') ?: null, + recipientName: trim($record['a_rec_title'] ?? '') ?: null, + postcode: trim($record['a_rec_postal'] ?? '') ?: null, + phone: trim($record['a_rec_tel_1'] ?? '') ?: null, + statusText: filled($statuses) ? trim(end($statuses)['web_status_name'] ?? '') : null, + status: filled($statuses) ? $this->guessStatusFromTitle(trim(end($statuses)['web_status_name'] ?? '')) : null, + codAmount: (float) ($record['a_antik'] ?? 0) ?: null, + date: $this->dateFromListRow($record['a_sender_date'] ?? ''), + raw: $record, + ); + } + + /** + * Issues a pending voucher (no-op once it has a number): stores the + * voucher number and what ELTA returns for the label, plus one shipment + * per child voucher of a multi-piece send. + */ + private function issue(Shipment $shipment): void + { + if (filled($shipment->tracking_reference)) { + return; + } + + $response = $this->client->issueVoucher([ + 'pel_id' => $this->pendingVoucherId($shipment), + 'sender_station' => $this->senderStation(), + ])->throwIfError(); + + $data = $response->data; + $voucherNo = trim((string) ($data['vg_code'] ?? '')); + + if ($voucherNo === '') { + throw new EltaApiException('ELTA issued the voucher but returned no voucher number.', $data); + } + + $shipment->tracking_reference = $voucherNo; + $shipment->meta = array_merge($shipment->meta?->toArray() ?? [], [ + 'ocr_line' => $data['ocr_line'] ?? null, + 'date_time' => $data['date_time'] ?? null, + 'rec_station' => $data['rec_station'] ?? null, + 'rec_station_t' => $data['rec_station_t'] ?? null, + 'rec_srv' => $data['rec_srv'] ?? null, + 'rec_srv_t' => $data['rec_srv_t'] ?? null, + 'return_vg' => trim((string) ($data['return_vg'] ?? '')) ?: null, + 'epitagh_vg' => trim((string) ($data['epitagh_vg'] ?? '')) ?: null, + ]); + $shipment->save(); + + foreach (array_filter(array_map('trim', (array) ($data['vg_child_no'] ?? []))) as $childVoucherNo) { + Shipment::create([ + 'order_id' => $shipment->order_id, + 'carrier' => 'elta', + 'source' => Shipment::SOURCE_CREATED, + 'tracking_reference' => $childVoucherNo, + 'parent_reference' => $voucherNo, + 'label_printed_at' => now(), + 'meta' => $shipment->meta->toArray(), + ]); + } + } + + /** + * @return array + */ + private function surchargeCodes(ShipmentRequest $request): array + { + $codes = $this->extraServices(); + + return collect($request->services) + ->reject(fn (ExtraService $service) => $service === ExtraService::Insurance) + ->map(fn (ExtraService $service) => $codes[$service->value] ?? null) + ->filter() + ->values() + ->take(3) + ->all(); + } + + private function pendingVoucherId(Shipment $shipment): string + { + $id = $shipment->meta['carrier_id'] ?? null; + + if (blank($id) && $shipment->order) { + $id = $this->findPendingVoucherId((string) $shipment->order->reference); + + if ($id) { + $shipment->meta = array_merge($shipment->meta?->toArray() ?? [], ['carrier_id' => $id]); + $shipment->save(); + } + } + + if (blank($id)) { + throw new EltaApiException("Couldn't find this shipment's pending voucher at ELTA."); + } + + return $id; + } + + /** + * The newest not-yet-issued voucher in ELTA's list carrying our + * reference. flag_1/flag_2 are the client's "Show all" / "All users" + * checkboxes — both "1", or even a just-created voucher is filtered out. + */ + private function findPendingVoucherId(string $reference): ?string + { + $inId = '0'; + $match = null; + + for ($page = 0; $page < self::MAX_PAGES; $page++) { + $rows = array_values(array_filter( + (array) ($this->client->listVouchers([ + 'pel_code' => config('elta.apost_code'), + 'pel_user_code' => config('elta.user_code'), + 'flag_1' => '1', + 'flag_2' => '1', + 'in_id' => $inId, + ])->throwIfError()->data['vg_rec'] ?? []), + fn (array $row) => filled($row['pel_vg_id'] ?? null), + )); + + foreach ($rows as $row) { + if (trim($row['pel_ref_no'] ?? '') === $reference && blank(trim($row['pel_paral_vg'] ?? '')) + && ($match === null || $row['pel_vg_id'] > $match)) { + $match = $row['pel_vg_id']; + } + } + + if (count($rows) < 100) { + break; + } + + $inId = (string) end($rows)['pel_vg_id']; + } + + return $match; + } + + /** + * @return array> + */ + private function searchVouchers(CarbonInterface $from, CarbonInterface $to, string $statusFlag): array + { + $inId = '0'; + $all = []; + + for ($page = 0; $page < self::MAX_PAGES; $page++) { + $rows = array_values(array_filter( + (array) ($this->client->searchVouchers([ + 'pel_code' => config('elta.apost_code'), + 'date_1' => $from->format('d/m/Y'), + 'date_2' => $to->format('d/m/Y'), + 'paral_code' => '', + 'status_flag' => $statusFlag, + 'in_id' => $inId, + ])->throwIfError()->data['ag_pel_rec'] ?? []), + fn (array $row) => filled(trim($row['pel_col_1'] ?? '')), + )); + + array_push($all, ...$rows); + + if (count($rows) < 100) { + break; + } + + $inId = (string) end($rows)['pel_col_id']; + } + + return $all; + } + + /** + * ELTA dates in its lists look like "29/09/26 09:41 PEL CLIENT" or + * "29/09/2026 09:51". + */ + private function dateFromListRow(string $value): ?CarbonInterface + { + if (! preg_match('#(\d{2})/(\d{2})/(\d{2,4})#', $value, $m)) { + return null; + } + + $year = strlen($m[3]) === 2 ? '20'.$m[3] : $m[3]; + + return Carbon::createFromDate((int) $year, (int) $m[2], (int) $m[1])->startOfDay(); + } + + /** + * The station ELTA needs when issuing a voucher: configured + * (ELTA_ORIGIN_STATION_CODE), or read from the login response once — + * ELTA returns the account's user_station there even though our + * account's login password is rejected. + */ + private function senderStation(): string + { + if (filled(config('elta.origin_station_code'))) { + return (string) config('elta.origin_station_code'); + } + + return (string) Cache::rememberForever('elta.user_station', fn () => $this->client->login([ + 'pel_code' => config('elta.apost_code'), + 'user_code' => config('elta.user_code'), + 'user_pass' => config('elta.user_pass'), + ])->data['user_station'] ?? ''); + } + /** * web_status_name is free text with no structured status code — same * limitation Acs\AcsFulfillmentService::guessStatusFromAction() has. @@ -234,15 +475,23 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTra * it isn't yet in transit. Other statuses are best-guess substring * matches to refine as more real tracking text is observed. */ + /** + * ELTA's status texts come in unaccented capitals ("ΠΑΡΑΔΟΘΗΚΕ"), so + * they're lowercased and stripped of accents before matching. + */ private function guessStatusFromTitle(string $title): TrackingStatus { - $title = mb_strtolower($title); + $title = strtr(mb_strtolower($title), [ + 'ά' => 'α', 'έ' => 'ε', 'ή' => 'η', 'ί' => 'ι', 'ό' => 'ο', 'ύ' => 'υ', 'ώ' => 'ω', + 'ϊ' => 'ι', 'ϋ' => 'υ', 'ΐ' => 'ι', 'ΰ' => 'υ', + ]); return match (true) { - str_contains($title, 'παραδόθηκε') => TrackingStatus::Delivered, - str_contains($title, 'διανομή') => TrackingStatus::OutForDelivery, - str_contains($title, 'παραλαβή') => TrackingStatus::CollectedFromSender, - str_contains($title, 'μεταφορά') || str_contains($title, 'διαμετακόμιση') => TrackingStatus::InTransit, + str_contains($title, 'επιστροφ') => TrackingStatus::Returned, + str_contains($title, 'παραδοθηκε') => TrackingStatus::Delivered, + str_contains($title, 'διανομη') => TrackingStatus::OutForDelivery, + str_contains($title, 'παραλαβη') => TrackingStatus::CollectedFromSender, + str_contains($title, 'μεταφορα') || str_contains($title, 'διαμετακομιση') || str_contains($title, 'διακινηση') => TrackingStatus::InTransit, default => TrackingStatus::Pending, }; } diff --git a/src/Shipping/Carriers/Elta/EltaLabelRenderer.php b/src/Shipping/Carriers/Elta/EltaLabelRenderer.php index f34ebce..185229d 100644 --- a/src/Shipping/Carriers/Elta/EltaLabelRenderer.php +++ b/src/Shipping/Carriers/Elta/EltaLabelRenderer.php @@ -4,6 +4,8 @@ namespace Modules\Core\Shipping\Carriers\Elta; use Barryvdh\DomPDF\Facade\Pdf; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; use Modules\Core\Shipping\Models\Shipment; use Picqer\Barcode\BarcodeGeneratorPNG; @@ -28,15 +30,88 @@ use Picqer\Barcode\BarcodeGeneratorPNG; */ class EltaLabelRenderer { + /** + * ELTA's own wording for each surcharge, as its client prints them in + * the label's "Πρόσθετες Υπηρεσίες" box (Sydeta.cs::print_vg()). + */ + private const SERVICE_TEXT = [ + 'special_handling' => '002 ΕΙΔΙΚΗ ΔΙΑΧΕΙΡΗΣΗ', + 'timed_delivery' => '003 ΠΡΟΚΑΘΟΡΙΣΜΕΝΗ ΩΡΑ', + 'saturday_delivery' => '004 ΕΠΙΔΟΣΗ ΣΑΒΒΑΤΟΥ', + ]; + public function __construct(private readonly AreaResolver $areaResolver) {} public function render(Shipment $shipment): string + { + return $this->renderMany(collect([$shipment])); + } + + /** + * Several labels in one PDF (the Pending Vouchers screen's "Print + * selected"). Each label view is a full page — a single `.page` block + * inside — so the pages are stacked under the first view's + * (the styles are the same for every label) with a page break + * between them. + * + * @param Collection $shipments + */ + public function renderMany(Collection $shipments): string + { + $paperSize = config('elta.label_paper_size', 'a4'); + $view = $paperSize === 'a6' + ? 'core::shipping.carriers.elta.label-a6' + : 'core::shipping.carriers.elta.label-a4'; + + $head = null; + $pages = []; + + foreach ($shipments as $shipment) { + $html = view($view, $this->data($shipment))->render(); + + $head ??= Str::before($html, ''), ''); + } + + $html = $head + .'' + .''.implode('', $pages).''; + + $pdf = Pdf::loadHTML($html); + + // A6 = 104mm x 148mm, per RDLCPrinter.cs's own DeviceInfo + // override for printer_size==2 (1mm ≈ 2.8346pt) — note this is + // ELTA's own thermal-label size, not the ISO A6 (105x148mm). A + // 4-value paper array plus an orientation string together confuse + // dompdf into doubling the canvas and silently overflowing to a + // second blank page; the array alone is already portrait (height > + // width), so no orientation argument. + $paperSize === 'a6' + ? $pdf->setPaper([0, 0, 294.80, 419.53]) + : $pdf->setPaper('a4'); + + return $pdf->output(); + } + + /** + * @return array + */ + private function data(Shipment $shipment): array { $order = $shipment->order; $address = $order->shippingAddress; $meta = $shipment->meta?->toArray() ?? []; - $destinationStation = $this->areaResolver->resolve($address->postcode); + // Issuing a pending voucher (PELVG01NEW1) returns the real + // destination station and service; vouchers issued before that flow + // existed don't have them, so fall back to the postcode lookup. + $station = filled($meta['rec_station'] ?? null) + ? ['code' => $meta['rec_station'], 'name' => $meta['rec_station_t'] ?? ''] + : (fn ($s) => ['code' => $s->code, 'name' => $s->name])($this->areaResolver->resolve($address->postcode)); + + $services = (array) ($meta['services'] ?? []); + $surcharges = $this->surchargeSlots($services); + $cod = $this->codFields(isset($meta['cod_amount']) ? (float) $meta['cod_amount'] : null); $dateTime = (string) ($meta['date_time'] ?? ''); $occurredAt = strlen($dateTime) >= 12 @@ -49,7 +124,7 @@ class EltaLabelRenderer $generator = new BarcodeGeneratorPNG(); - $data = [ + return [ 'sender_name' => config('elta.sender_name'), 'sender_address' => config('elta.sender_address'), 'sender_postcode' => config('elta.sender_postcode'), @@ -72,6 +147,7 @@ class EltaLabelRenderer // report's 40-char wrap width, which our own data never hits // in practice for a name/address/contact triple). 'sender_lines' => [ + 'Κωδικός:'.config('elta.apost_code'), config('elta.sender_name'), config('elta.sender_address'), 'TK:'.config('elta.sender_postcode').' '.config('elta.sender_area').' ΤΗΛ:'.config('elta.sender_phone'), @@ -95,13 +171,14 @@ class EltaLabelRenderer ? sprintf('001/%03d', $packageCount) : '001', 'multiPiece' => $packageCount > 1, + 'polaplo' => $packageCount > 1 ? '* ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ *' : null, 'station_apo' => config('elta.origin_station_code'), - 'station_pros' => $destinationStation->code, - 'station_pros_title' => $destinationStation->name, + 'station_pros' => $station['code'], + 'station_pros_title' => $station['name'], - 'service_code' => '214', - 'service_name' => 'ΠΟΛΗ ΠΟΛΗ -ΠΘ- ΝΗΣΙ', + 'service_code' => $meta['rec_srv'] ?? '', + 'service_name' => $meta['rec_srv_t'] ?? '', 'xreosi' => 'ΧΡΕΩΣΗ ΑΠΟΣΤΟΛΕΑ ΠΙΣΤΩΣΗ', 'siimvasi' => '131775-9', @@ -114,46 +191,95 @@ class EltaLabelRenderer // yields reference "1426000981436" (13 chars after the '>'). 'ocr_reference' => strlen($ocrLine) >= 14 ? substr($ocrLine, 1, 13) : '', - 'order_reference' => (string) $order->id, + // The reference we send ELTA as pel_ref_no (order reference; + // older vouchers were sent the order id). + 'order_reference' => (string) $order->reference, - // sydetaE.rdlc's "Πρόσθετες Υπηρεσίες" (sur_1..4) and the - // ΑΝΤΙΚΑΤΑΒΟΛΗ banner (antik_1) are only populated by ELTA's - // own client when the shipper ticks special-service checkboxes - // (Sydeta.cs::print_vg()'s checkBox2-5) or a COD amount is set - // — neither is tracked on our Shipment model today, so these - // render as empty boxes, matching what a plain "credit" send - // with no extras or COD looks like on a real label. - 'sur_1' => null, - 'sur_2' => null, - 'sur_3' => null, + // sydetaE.rdlc's "Πρόσθετες Υπηρεσίες" (sur_1..4): the ticked + // extra services, in ELTA's own wording and slots. + 'sur_1' => $surcharges[1], + 'sur_2' => $surcharges[2], + 'sur_3' => $surcharges[3], 'sur_4' => null, - 'antik_1' => $meta['cod_amount'] ?? null ? '* ΠΡΟΣΟΧΗ ΑΝΤΙΚΑΤΑΒΟΛΗ *' : null, + ...$cod, + // ogos_2: "LxWxH = volume", only when dimensions were given — + // we never send any, so the box stays empty like the client's. 'volumetric_weight' => $meta['volumetric_weight'] ?? null, 'eltaLogo' => $this->logoDataUri(), ]; + } - $paperSize = config('elta.label_paper_size', 'a4'); - $view = $paperSize === 'a6' - ? 'core::shipping.carriers.elta.label-a6' - : 'core::shipping.carriers.elta.label-a4'; + /** + * The client's own sur_1..3 placement (Sydeta.cs::print_vg()): 002 is + * always sur_1; 003 is always sur_2; 004 is sur_3 after 003, else + * sur_2. Slots can stay empty (003 alone leaves sur_1 blank). + * + * @param array $services ExtraService values + * @return array{1: ?string, 2: ?string, 3: ?string} + */ + private function surchargeSlots(array $services): array + { + $has = fn (string $service) => in_array($service, $services, true); + $slots = [1 => null, 2 => null, 3 => null]; - $pdf = Pdf::loadView($view, $data); + if ($has('special_handling')) { + $slots[1] = self::SERVICE_TEXT['special_handling']; + } - // A6 = 104mm x 148mm, per RDLCPrinter.cs's own DeviceInfo - // override for printer_size==2 (1mm ≈ 2.8346pt) — note this is - // ELTA's own thermal-label size, not the ISO A6 (105x148mm) the - // old comment here named. A 4-value paper array plus an - // orientation string together confuse dompdf into doubling the - // canvas and silently overflowing to a second blank page - // (reproduced with a bare empty page); the array alone is - // already portrait (height > width), so the orientation - // argument is both redundant and the actual bug. - $paperSize === 'a6' - ? $pdf->setPaper([0, 0, 294.80, 419.53]) - : $pdf->setPaper('a4'); + if ($has('timed_delivery')) { + $slots[2] = self::SERVICE_TEXT['timed_delivery']; - return $pdf->output(); + if ($has('saturday_delivery')) { + $slots[3] = self::SERVICE_TEXT['saturday_delivery']; + } + } elseif ($has('saturday_delivery')) { + $slots[2] = self::SERVICE_TEXT['saturday_delivery']; + } + + return $slots; + } + + /** + * The cash-on-delivery texts exactly as the client prints them + * (Sydeta.cs::print_vg(), cash branch): antik_1 "ΑΝΤΙΚΑΤΑΒΟΛΗ 17.00" + * heads the COD column and the A6 bottom banner, then "* ΑΝΑΛΥΣΗ *" and + * the cash line ("17.00 MΕΤΡΗΤΑ" — the amount as sent in pel_ant_poso, + * the client's own Latin "M" kept). antik_minima / apodiksi / antik_poso + * are the A4 payment stub's. We only ever send cash (no cheques), so + * antik_4..7 stay empty. + * + * @return array + */ + private function codFields(?float $amount): array + { + if (! $amount) { + return [ + 'antik_1' => null, 'antik_2' => null, 'antik_3' => null, + 'antik_4' => null, 'antik_5' => null, 'antik_6' => null, 'antik_7' => null, + 'antik_lines' => [], + 'antik_minima' => null, + 'apodiksi' => null, + 'antik_poso' => '0.00', + ]; + } + + $sent = number_format($amount, 2, '.', ''); + + $lines = [ + 'antik_1' => 'ΑΝΤΙΚΑΤΑΒΟΛΗ '.$sent, + 'antik_2' => '* ΑΝΑΛΥΣΗ *', + 'antik_3' => $sent.' MΕΤΡΗΤΑ', + 'antik_4' => null, 'antik_5' => null, 'antik_6' => null, 'antik_7' => null, + ]; + + return [ + ...$lines, + 'antik_lines' => array_values(array_filter($lines)), + 'antik_minima' => '* ΠΡΟΣΟΧΗ ΑΝΤΙΚΑΤΑΒΟΛΗ *', + 'apodiksi' => '* Απόδειξη Είσπαξης *', + 'antik_poso' => $sent, + ]; } /** diff --git a/src/Shipping/Carriers/Elta/EltaRateDriver.php b/src/Shipping/Carriers/Elta/EltaRateDriver.php index 34b7389..812eef9 100644 --- a/src/Shipping/Carriers/Elta/EltaRateDriver.php +++ b/src/Shipping/Carriers/Elta/EltaRateDriver.php @@ -5,6 +5,7 @@ namespace Modules\Core\Shipping\Carriers\Elta; use Lunar\DataTypes\ShippingOption; use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\Interfaces\ShippingRateInterface; +use Lunar\Shipping\Models\ShippingMethod; use Lunar\Shipping\Models\ShippingRate; use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; @@ -38,7 +39,7 @@ class EltaRateDriver implements ShippingRateInterface, DeclaresFulfillmentType, return 'carrier'; } - public function collectsCash(): bool + public function collectsCash(ShippingMethod $method): bool { return true; } diff --git a/src/Shipping/Carriers/Manual/ManualFulfillmentService.php b/src/Shipping/Carriers/Manual/ManualFulfillmentService.php new file mode 100644 index 0000000..6228bf8 --- /dev/null +++ b/src/Shipping/Carriers/Manual/ManualFulfillmentService.php @@ -0,0 +1,52 @@ +shippingAddress?->shipping_option; + $method = $code ? ShippingMethod::where('code', $code)->first() : null; + + return Shipment::create([ + 'order_id' => $order->id, + 'carrier' => 'manual', + 'source' => Shipment::SOURCE_MANUAL, + 'tracking_reference' => filled($request->trackingReference) ? trim($request->trackingReference) : null, + 'meta' => [ + 'carrier_name' => $method?->data['carrier_name'] ?? null, + 'tracking_url' => $method?->data['tracking_url'] ?? null, + 'package_count' => $request->packageCount, + 'weight' => $request->weight, + 'cod_amount' => $request->paymentMode === 'cod' + ? (float) ($request->amountToCollect ?? $order->total->decimal) + : null, + ], + ]); + } + + public function printLabel(Shipment $shipment): string + { + throw new RuntimeException('Manual carriers have no printable label.'); + } + + public function cancelShipment(Shipment $shipment): void + { + $shipment->update(['cancelled_at' => now()]); + } +} diff --git a/src/Shipping/Carriers/Manual/ManualRateDriver.php b/src/Shipping/Carriers/Manual/ManualRateDriver.php new file mode 100644 index 0000000..b00da20 --- /dev/null +++ b/src/Shipping/Carriers/Manual/ManualRateDriver.php @@ -0,0 +1,74 @@ +data['collects_cash'] ?? false); + } + + public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption + { + if ($this->cartHasExcludedProducts($shippingOptionRequest->shippingRate, $shippingOptionRequest->cart)) { + return null; + } + + return $this->resolveFixedPrice( + $shippingOptionRequest->shippingRate, + $shippingOptionRequest->shippingRate->shippingMethod, + $shippingOptionRequest->cart, + ); + } + + public function on(ShippingRate $shippingRate): self + { + $this->shippingRate = $shippingRate; + + return $this; + } +} diff --git a/src/Shipping/Contracts/IssuesVoucherOnPrint.php b/src/Shipping/Contracts/IssuesVoucherOnPrint.php new file mode 100644 index 0000000..6d606e9 --- /dev/null +++ b/src/Shipping/Contracts/IssuesVoucherOnPrint.php @@ -0,0 +1,14 @@ + $shipments + */ + public function printLabels(Collection $shipments): string; +} diff --git a/src/Shipping/Contracts/SupportsCashCollection.php b/src/Shipping/Contracts/SupportsCashCollection.php index a3c961c..0603287 100644 --- a/src/Shipping/Contracts/SupportsCashCollection.php +++ b/src/Shipping/Contracts/SupportsCashCollection.php @@ -2,6 +2,8 @@ namespace Modules\Core\Shipping\Contracts; +use Lunar\Shipping\Models\ShippingMethod; + /** * A shipping rate driver implements this to say a person is physically * present at handoff to collect cash — true for a courier like @@ -14,6 +16,10 @@ namespace Modules\Core\Shipping\Contracts; * Contracts\DeclaresFulfillmentType, which only distinguishes carrier vs. * store_pickup and can't tell ACS and Box Now apart (both 'carrier'). * + * Receives the shipping method, since for some drivers it's a per-method + * setting — a manual carrier's "Courier collects cash on delivery" toggle + * (Modules\Core\Shipping\Carriers\Manual\ManualRateDriver). + * * Not implemented at all means "no" — a driver with no opinion here * (any of table-rate-shipping's own generic drivers, if one were ever * re-added) is treated as not supporting cash collection, the safer @@ -21,5 +27,5 @@ namespace Modules\Core\Shipping\Contracts; */ interface SupportsCashCollection { - public function collectsCash(): bool; + public function collectsCash(ShippingMethod $method): bool; } diff --git a/src/Shipping/Contracts/SupportsExtraServices.php b/src/Shipping/Contracts/SupportsExtraServices.php new file mode 100644 index 0000000..bed63c2 --- /dev/null +++ b/src/Shipping/Contracts/SupportsExtraServices.php @@ -0,0 +1,21 @@ + + */ + public function extraServices(): array; +} diff --git a/src/Shipping/Contracts/SupportsVoucherListing.php b/src/Shipping/Contracts/SupportsVoucherListing.php new file mode 100644 index 0000000..6071f0a --- /dev/null +++ b/src/Shipping/Contracts/SupportsVoucherListing.php @@ -0,0 +1,19 @@ + + */ + public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable; +} diff --git a/src/Shipping/Contracts/SupportsVoucherLookup.php b/src/Shipping/Contracts/SupportsVoucherLookup.php new file mode 100644 index 0000000..50a61f3 --- /dev/null +++ b/src/Shipping/Contracts/SupportsVoucherLookup.php @@ -0,0 +1,14 @@ + $services staff-picked extra + * services; a carrier only receives ones it declares via + * SupportsExtraServices. + * @param ?float $insuranceAmount when ExtraService::Insurance is picked. + * @param ?string $deliveryUntil "HH:MM", when ExtraService::TimedDelivery is picked. */ public function __construct( public readonly ?float $weight = null, @@ -27,5 +34,22 @@ class ShipmentRequest public readonly ?string $paymentMode = null, public readonly ?float $amountToCollect = null, public readonly array $boxes = [], + public readonly array $services = [], + public readonly ?float $insuranceAmount = null, + public readonly ?string $deliveryUntil = null, + public readonly ?string $trackingReference = null, ) {} + + public function has(ExtraService $service): bool + { + return in_array($service, $this->services, true); + } + + /** + * @return array + */ + public function serviceValues(): array + { + return array_map(fn (ExtraService $service) => $service->value, $this->services); + } } diff --git a/src/Shipping/Enums/ExtraService.php b/src/Shipping/Enums/ExtraService.php new file mode 100644 index 0000000..4ea5207 --- /dev/null +++ b/src/Shipping/Enums/ExtraService.php @@ -0,0 +1,47 @@ + 'Saturday delivery', + self::MorningDelivery => 'Morning delivery', + self::TimedDelivery => 'Timed delivery', + self::SameDayDelivery => 'Same-day delivery', + self::Insurance => 'Insurance', + self::SpecialHandling => 'Special handling', + self::ReturnDocuments => 'Return documents', + self::RemoteArea => 'Remote area', + self::Protocol => 'Protocol', + self::Refrigerated => 'Refrigerated', + self::ExchangePackage => 'Exchange package', + }; + } +} diff --git a/src/Shipping/Extensions/OrderShipmentsExtension.php b/src/Shipping/Extensions/OrderShipmentsExtension.php index a321642..1c74d5d 100644 --- a/src/Shipping/Extensions/OrderShipmentsExtension.php +++ b/src/Shipping/Extensions/OrderShipmentsExtension.php @@ -3,17 +3,22 @@ namespace Modules\Core\Shipping\Extensions; use Filament\Actions\Action; +use Filament\Forms\Components\DateTimePicker; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; use Filament\Infolists\Components\RepeatableEntry; +use Filament\Notifications\Notification; use Filament\Infolists\Components\TextEntry; use Illuminate\Support\Collection; use Modules\Core\Shipping\Models\ShipmentInfo; -use Filament\Notifications\Notification; use Filament\Schemas\Components\Section; use Illuminate\Support\Facades\URL; use Lunar\Admin\Support\Extending\ViewPageExtension; -use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; +use Modules\Core\Shipping\Enums\ExtraService; +use Modules\Core\Shipping\Enums\TrackingStatus; +use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; +use Modules\Core\Shipping\Filament\Resources\ShipmentResource; use Modules\Core\Shipping\Models\Shipment; -use Throwable; /** * Adds a "Shipments" section to the order page's main column — previously @@ -85,8 +90,9 @@ class OrderShipmentsExtension extends ViewPageExtension ->contained(true) ->schema([ TextEntry::make('tracking_reference') - ->label(fn (Shipment $record) => $this->carrierLabel($record)) + ->label(fn (Shipment $record) => $record->carrierLabel()) ->inlineLabel() + ->placeholder('Voucher not issued yet') ->copyable(), TextEntry::make('status') ->label('Status') @@ -104,14 +110,57 @@ class OrderShipmentsExtension extends ViewPageExtension now()->addMinutes(5), ['shipment' => $record->id], ), shouldOpenInNewTab: true) - ->visible(fn (Shipment $record) => ! $record->cancelled_at), + ->visible(fn (Shipment $record) => $record->hasCarrierLabel()), + Action::make('open_carrier_tracking') + ->label('Carrier tracking') + ->icon('heroicon-o-arrow-top-right-on-square') + ->url(fn (Shipment $record) => $record->trackingUrl(), shouldOpenInNewTab: true) + ->visible(fn (Shipment $record) => filled($record->trackingUrl())), + Action::make('edit_tracking_reference') + ->label('Edit number') + ->icon('heroicon-o-pencil-square') + ->fillForm(fn (Shipment $record) => ['tracking_reference' => $record->tracking_reference]) + ->schema(fn (Shipment $record) => [ + TextInput::make('tracking_reference') + ->label('Voucher / tracking number') + ->unique(Shipment::class, 'tracking_reference', ignorable: $record), + ]) + ->action(fn (Shipment $record, array $data) => $record->update([ + 'tracking_reference' => filled($data['tracking_reference']) ? trim($data['tracking_reference']) : null, + ])) + ->visible(fn (Shipment $record) => $record->source === Shipment::SOURCE_MANUAL && ! $record->isCancelled()), + Action::make('add_tracking_update') + ->label('Add tracking update') + ->icon('heroicon-o-plus-circle') + ->schema([ + Select::make('status') + ->label('Status') + ->options(collect(TrackingStatus::cases()) + ->reject(fn (TrackingStatus $status) => in_array($status, [TrackingStatus::Unknown, TrackingStatus::Pending], true)) + ->mapWithKeys(fn (TrackingStatus $status) => [$status->value => (string) str($status->value)->replace('_', ' ')->title()])) + ->native(false) + ->required(), + DateTimePicker::make('occurred_at') + ->label('When') + ->seconds(false) + ->default(now()) + ->required(), + TextInput::make('location')->label('Location'), + TextInput::make('message') + ->label('Message') + ->helperText('Shown to the customer on their order page.'), + ]) + ->action(fn (Shipment $record, array $data) => $this->addTrackingUpdate($record, $data)) + ->visible(fn (Shipment $record) => $record->source === Shipment::SOURCE_MANUAL && ! $record->isCancelled()), 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)) + ->modalDescription(fn (Shipment $record) => $record->cancelsLocallyOnly() + ? 'Marks this shipment as cancelled here only. Void the voucher with the courier as well.' + : 'Cancels this shipment with the carrier. This cannot be undone.') + ->action(fn (Shipment $record) => ShipmentResource::cancelShipment($record)) ->visible(fn (Shipment $record) => ! $record->cancelled_at), ]), RepeatableEntry::make('shipmentInfo') @@ -123,7 +172,11 @@ class OrderShipmentsExtension extends ViewPageExtension ->label(fn (ShipmentInfo $record) => $record->occurred_at->format('Y-m-d H:i')) ->inlineLabel() ->state(fn (ShipmentInfo $record) => (string) str($record->status->value)->replace('_', ' ')->title()) - ->helperText(fn (ShipmentInfo $record) => $record->location), + // Our status as the label, the carrier's own wording underneath. + ->helperText(fn (ShipmentInfo $record) => collect([ + $record->carrier_status !== 'manual' ? $record->carrier_status : null, + $record->location, + ])->filter()->unique()->implode(' · ') ?: null), ]), ]), ]); @@ -142,13 +195,29 @@ class OrderShipmentsExtension extends ViewPageExtension return $record->shipmentInfo->sortBy('occurred_at')->values(); } - private function carrierLabel(Shipment $record): string + + /** + * A manual carrier's checkpoint, entered by staff. Dispatches the same + * event as carrier polling, so the order's status and emails follow it + * exactly like an integrated carrier's. + */ + private function addTrackingUpdate(Shipment $record, array $data): void { - return match ($record->carrier) { - 'acs' => 'ACS', - 'box-now' => 'Box Now', - default => (string) str($record->carrier)->title(), - }; + $info = ShipmentInfo::create([ + 'shipment_id' => $record->id, + 'status' => TrackingStatus::from($data['status']), + 'carrier_status' => 'manual', + 'message' => $data['message'] ?? null, + 'location' => $data['location'] ?? null, + 'occurred_at' => $data['occurred_at'], + ]); + + ShipmentStatusUpdatedByCarrier::dispatch($info); + + Notification::make() + ->title('Tracking update added.') + ->success() + ->send(); } private function helperText(Shipment $record): string @@ -159,6 +228,22 @@ class OrderShipmentsExtension extends ViewPageExtension $parts[] = 'Locker '.$locker; } + if ($cod = $record->meta['cod_amount'] ?? null) { + $parts[] = 'Cash on delivery: €'.number_format((float) $cod, 2); + } + + $services = collect($record->meta['services'] ?? []) + ->map(fn (string $value) => ExtraService::tryFrom($value)?->label()) + ->filter(); + + if ($services->isNotEmpty()) { + $parts[] = $services->implode(', '); + } + + if ($record->source === Shipment::SOURCE_MANUAL_VOUCHER) { + $parts[] = 'Manual voucher'; + } + return implode(' · ', $parts); } @@ -187,26 +272,4 @@ class OrderShipmentsExtension extends ViewPageExtension }; } - 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 0685b26..7b46f1d 100644 --- a/src/Shipping/Extensions/OrderViewExtension.php +++ b/src/Shipping/Extensions/OrderViewExtension.php @@ -3,17 +3,30 @@ namespace Modules\Core\Shipping\Extensions; use Filament\Actions\Action; +use Filament\Forms\Components\CheckboxList; +use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\TimePicker; use Filament\Notifications\Notification; +use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; use Lunar\Admin\Support\Extending\ViewPageExtension; use Lunar\Models\Order; +use Lunar\Shipping\Facades\Shipping; use Modules\Core\Order\DTOs\OrderFulfillmentResult; use Modules\Core\Order\Services\OrderFulfillmentService; use Modules\Core\Order\Services\OrderStatusFlow; +use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; +use Modules\Core\Shipping\Contracts\SupportsExtraServices; +use Modules\Core\Shipping\Contracts\SupportsVoucherLookup; use Modules\Core\Shipping\DTOs\ShipmentRequest; +use Modules\Core\Shipping\Enums\ExtraService; +use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Support\WeightCalculator; +use Throwable; /** * Filament wiring only (labels, icons, visibility, form schema) for the @@ -35,11 +48,12 @@ use Modules\Core\Shipping\Support\WeightCalculator; * * "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 + * that talks to a real carrier API, so it needs its own weight/locker + * inputs specific to that one real-world action. It does NOT move the + * order to 'dispatched': that happens when the carrier reports picking the + * parcel up (AdvanceFulfillmentOnCarrierCheckpoint). Staff also pick the + * carrier's extra services here (Saturday delivery, insurance, …) from the + * normalized ExtraService list the carrier offers. 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 @@ -62,6 +76,13 @@ use Modules\Core\Shipping\Support\WeightCalculator; * customer's choice turns out to be unavailable) or fill it in manually for * an order placed before the checkout locker picker existed. * + * "Add manual voucher" records a voucher of an integrated carrier that + * wasn't created through our API — the carrier's system was down and staff + * filled a pre-numbered paper voucher, or the courier wrote his own at + * pickup. Tracking picks its history up like any other shipment; the + * carrier's lookup (when it has one) previews what it reports for the + * number before saving. + * * "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 @@ -86,6 +107,7 @@ class OrderViewExtension extends ViewPageExtension : true); $actions[] = $this->createShipmentAction(); + $actions[] = $this->addManualVoucherAction(); $actions[] = $this->updateStatusAction(); $actions[] = $this->markPaidAction(); @@ -99,11 +121,28 @@ class OrderViewExtension extends ViewPageExtension ->icon('heroicon-o-truck') ->modalSubmitActionLabel('Create Shipment') ->schema(function (Order $record) { - $isBoxNow = $this->service()->carrierFor($record) === 'box-now'; + $carrier = $this->service()->carrierFor($record); + $isBoxNow = $carrier === 'box-now'; $lockerId = $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null; - if (! $isBoxNow) { + if ($carrier === 'manual') { return [ + TextInput::make('tracking_reference') + ->label('Voucher / tracking number') + ->unique(Shipment::class, 'tracking_reference') + ->helperText('Optional — add it later from the shipment if the courier hasn\'t given one yet.'), + TextInput::make('package_count') + ->label('Number of packages') + ->numeric() + ->integer() + ->minValue(1) + ->default(1) + ->required(), + ]; + } + + if (! $isBoxNow) { + return [...[ TextInput::make('weight') ->label('Total weight (kg)') ->numeric() @@ -118,10 +157,10 @@ class OrderViewExtension extends ViewPageExtension ->default(1) ->required() ->helperText('More than 1 issues a main voucher plus a sub-voucher per extra package, all sharing the total weight above.'), - ]; + ], ...$this->extraServiceFields($record)]; } - return [ + return [...[ TextInput::make('destination_location_id') ->label('Box Now locker ID') ->default($lockerId) @@ -143,7 +182,7 @@ class OrderViewExtension extends ViewPageExtension ->addActionLabel('Add another box') ->minItems(1) ->helperText('One row per physical parcel — Box Now ships by compartment size, not weight.'), - ]; + ], ...$this->extraServiceFields($record)]; }) ->action(function (Order $record, array $data, Action $action) { // Derived from the order itself, never from staff input — @@ -161,7 +200,9 @@ class OrderViewExtension extends ViewPageExtension // full payment was already settled, and never collect it. $isCod = app(OrderStatusFlow::class)->isCod($record); - $result = $this->service()->createShipmentAndDispatch( + $services = array_map(fn (string $value) => ExtraService::from($value), $data['services'] ?? []); + + $result = $this->service()->createShipment( $record, new ShipmentRequest( weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null, @@ -170,6 +211,14 @@ class OrderViewExtension extends ViewPageExtension paymentMode: $isCod ? 'cod' : 'prepaid', amountToCollect: $isCod ? $record->total->decimal : null, boxes: collect($data['boxes'] ?? [])->pluck('size')->all(), + services: $services, + insuranceAmount: in_array(ExtraService::Insurance, $services, true) && filled($data['insurance_amount'] ?? null) + ? (float) $data['insurance_amount'] + : null, + deliveryUntil: in_array(ExtraService::TimedDelivery, $services, true) + ? ($data['delivery_until'] ?? null) + : null, + trackingReference: $data['tracking_reference'] ?? null, ), ); @@ -182,6 +231,163 @@ class OrderViewExtension extends ViewPageExtension ->visible(fn (Order $record) => $this->service()->canCreateShipment($record)); } + /** + * The extra services the order's carrier offers, plus the details some + * of them need. Nothing is shown for carriers that offer none. + */ + private function extraServiceFields(Order $record): array + { + $options = $this->extraServiceOptions($this->service()->carrierFor($record)); + + if ($options === []) { + return []; + } + + $ticked = fn (Get $get, ExtraService $service) => in_array($service->value, $get('services') ?? [], true); + + return [ + CheckboxList::make('services') + ->label('Extra services') + ->options($options) + ->columns(2) + ->live(), + TextInput::make('insurance_amount') + ->label('Insured value') + ->numeric() + ->minValue(0) + ->prefix('€') + ->default($record->total->decimal) + ->visible(fn (Get $get) => $ticked($get, ExtraService::Insurance)) + ->required(fn (Get $get) => $ticked($get, ExtraService::Insurance)), + TimePicker::make('delivery_until') + ->label('Deliver by') + ->seconds(false) + ->visible(fn (Get $get) => $ticked($get, ExtraService::TimedDelivery)) + ->required(fn (Get $get) => $ticked($get, ExtraService::TimedDelivery)), + ]; + } + + /** + * @return array ExtraService value => label + */ + private function extraServiceOptions(?string $carrier): array + { + $service = $carrier ? app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]) : null; + + if (! $service instanceof SupportsExtraServices) { + return []; + } + + return collect(array_keys($service->extraServices())) + ->mapWithKeys(fn (string $value) => [$value => ExtraService::from($value)->label()]) + ->all(); + } + + private function addManualVoucherAction(): Action + { + return Action::make('add_manual_voucher') + ->label('Add manual voucher') + ->icon('heroicon-o-pencil-square') + ->modalDescription("For a voucher that exists at the carrier but wasn't created here — e.g. the carrier's system was down, or the courier wrote his own voucher at pickup. Its tracking history is picked up from the carrier.") + ->modalSubmitActionLabel('Add voucher') + ->schema(fn (Order $record) => [ + Select::make('carrier') + ->label('Carrier') + ->options($this->integratedCarriers()) + ->default($this->service()->carrierFor($record)) + ->native(false) + ->required() + ->live() + ->afterStateUpdated(fn (Set $set) => $set('lookup', null)), + TextInput::make('voucher_number') + ->label('Voucher number') + ->required() + ->unique(Shipment::class, 'tracking_reference') + ->live(onBlur: true) + ->afterStateUpdated(fn (Set $set) => $set('lookup', null)) + ->suffixAction( + Action::make('lookup') + ->icon('heroicon-o-magnifying-glass') + ->tooltip('Look up at the carrier') + ->action(fn (Get $get, Set $set) => $set('lookup', $this->describeLookup($get('carrier'), $get('voucher_number')))), + ), + Hidden::make('lookup'), + Placeholder::make('lookup_preview') + ->label('Carrier reports') + ->content(fn (Get $get) => $get('lookup')) + ->visible(fn (Get $get) => filled($get('lookup'))), + CheckboxList::make('services') + ->label('Extra services') + ->options(fn (Get $get) => $this->extraServiceOptions($get('carrier'))) + ->columns(2) + ->visible(fn (Get $get) => $this->extraServiceOptions($get('carrier')) !== []), + ]) + ->action(function (Order $record, array $data, Action $action) { + $result = $this->service()->addManualVoucher( + $record, + $data['carrier'], + $data['voucher_number'], + array_map(fn (string $value) => ExtraService::from($value), $data['services'] ?? []), + ); + + $this->notify($result); + + if (! $result->success) { + $action->halt(); + } + }) + ->visible(fn (Order $record) => $this->service()->canAddManualVoucher($record)); + } + + /** + * Carriers with an API integration — manual carriers have no voucher + * of their own to look up or track. + * + * @return array + */ + private function integratedCarriers(): array + { + return collect(Shipping::getSupportedDrivers()) + ->filter(fn ($driver, string $carrier) => $carrier !== 'manual' + && app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]) !== null) + ->map(fn ($driver) => $driver->name()) + ->all(); + } + + private function describeLookup(?string $carrier, ?string $number): string + { + if (blank($carrier) || blank($number)) { + return 'Pick the carrier and enter the voucher number first.'; + } + + $service = app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]); + + if (! $service instanceof SupportsVoucherLookup) { + return "This carrier doesn't support looking vouchers up — you can still add it."; + } + + try { + $voucher = $service->lookupVoucher(trim($number)); + } catch (Throwable $e) { + report($e); + + return "Couldn't reach the carrier ({$e->getMessage()}) — you can still add it; its history is picked up once the carrier responds."; + } + + if (! $voucher) { + return 'The carrier has no record of this voucher yet — you can still add it.'; + } + + return collect([ + $voucher->recipientName, + $voucher->postcode, + $voucher->reference ? 'Ref. '.$voucher->reference : null, + $voucher->statusText, + $voucher->codAmount ? 'COD €'.number_format($voucher->codAmount, 2) : null, + $voucher->isReturn ? 'Return voucher' : null, + ])->filter()->implode(' · '); + } + private function updateStatusAction(): Action { return Action::make('update_status') diff --git a/src/Shipping/Extensions/ShippingMethodResourceExtension.php b/src/Shipping/Extensions/ShippingMethodResourceExtension.php index f9675a3..7e6acc6 100644 --- a/src/Shipping/Extensions/ShippingMethodResourceExtension.php +++ b/src/Shipping/Extensions/ShippingMethodResourceExtension.php @@ -8,6 +8,8 @@ use Filament\Schemas\Components\Concerns\HasChildComponents; use Filament\Schemas\Components\Utilities\Get; use InvalidArgumentException; use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Lunar\Admin\Support\Extending\ResourceExtension; @@ -110,15 +112,49 @@ class ShippingMethodResourceExtension extends ResourceExtension } if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) { - $component->schema( - $this->replaceChargeByField($component->getDefaultChildComponents()) + $children = $this->replaceChargeByField($component->getDefaultChildComponents()); + + // The manual carrier's settings sit next to charge_by, in + // the same `data` group. + $hasChargeBy = collect($children)->contains( + fn (Component $child) => method_exists($child, 'getName') && $child->getName() === 'charge_by' ); + + $component->schema($hasChargeBy ? [...$children, ...$this->manualCarrierFields()] : $children); } return $component; }, $components); } + /** + * Settings for a carrier without an integration (ManualRateDriver), + * stored in the method's `data`. Shown only for that driver. + * + * @return array + */ + private function manualCarrierFields(): array + { + $isManual = fn (Get $get) => $get('../driver') === 'manual'; + + return [ + TextInput::make('carrier_name') + ->label('Carrier name') + ->helperText('Shown to staff and customers on this carrier\'s shipments, e.g. "Geniki Taxydromiki".') + ->visible($isManual) + ->required($isManual), + TextInput::make('tracking_url') + ->label('Tracking URL') + ->helperText('Optional. The carrier\'s tracking page, with {number} where the voucher number goes, e.g. https://example.com/track?number={number}.') + ->rule('starts_with:http://,https://') + ->visible($isManual), + Toggle::make('collects_cash') + ->label('Courier collects cash on delivery') + ->helperText('Offers cash on delivery at checkout for this shipping method.') + ->visible($isManual), + ]; + } + private function chargeBySelect(): Select { return Select::make('charge_by') diff --git a/src/Shipping/Filament/Resources/CarrierVoucherResource.php b/src/Shipping/Filament/Resources/CarrierVoucherResource.php new file mode 100644 index 0000000..b61ff79 --- /dev/null +++ b/src/Shipping/Filament/Resources/CarrierVoucherResource.php @@ -0,0 +1,351 @@ + 'Created here', + Shipment::SOURCE_MANUAL_VOUCHER => 'Manual voucher', + Shipment::SOURCE_MANUAL => 'Manual carrier', + Shipment::SOURCE_SYNCED => 'From carrier', + ]; + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->whereNotNull('tracking_reference') + ->with(['order.shippingAddress', 'shipmentInfo']); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('created_at', 'desc') + ->columns([ + TextColumn::make('tracking_reference') + ->label('Tracking number') + ->copyable(), + TextColumn::make('status') + ->label('Status') + ->state(fn (Shipment $record) => self::status($record)) + ->placeholder('No updates yet') + ->badge() + ->color(fn (Shipment $record) => self::statusColor($record)), + TextColumn::make('order.reference') + ->label('Order') + ->placeholder('Not linked'), + TextColumn::make('recipient') + ->label('Recipient') + ->state(fn (Shipment $record) => self::recipient($record)) + ->placeholder('—') + // One search box for the voucher number, order reference, + // recipient and phone. + ->searchable(query: fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q + ->where('tracking_reference', 'ilike', "%{$search}%") + ->orWhere('meta->recipient_name', 'ilike', "%{$search}%") + ->orWhere('meta->phone', 'ilike', "%{$search}%") + ->orWhereHas('order', fn (Builder $order) => $order + ->where('reference', 'ilike', "%{$search}%") + ->orWhereHas('shippingAddress', fn (Builder $address) => $address + ->where('first_name', 'ilike', "%{$search}%") + ->orWhere('last_name', 'ilike', "%{$search}%") + ->orWhere('contact_phone', 'ilike', "%{$search}%"))))), + TextColumn::make('phone') + ->label('Phone') + ->state(fn (Shipment $record) => self::phone($record)) + ->placeholder('—'), + ]) + ->filters([ + SelectFilter::make('carrier') + ->options(fn () => collect(Shipping::getSupportedDrivers())->map(fn ($driver) => $driver->name())->all()), + SelectFilter::make('source')->options(self::SOURCES), + ]) + ->recordActions([ + ViewAction::make(), + ]); + } + + public static function infolist(Schema $schema): Schema + { + return $schema->components([ + Section::make('Voucher') + ->columns(3) + ->schema([ + TextEntry::make('carrier') + ->formatStateUsing(fn (Shipment $record) => $record->carrierLabel()), + TextEntry::make('tracking_reference')->label('Voucher')->copyable(), + TextEntry::make('source') + ->badge() + ->formatStateUsing(fn (string $state) => self::SOURCES[$state] ?? $state), + TextEntry::make('status') + ->state(fn (Shipment $record) => self::status($record)) + ->placeholder('No updates yet') + ->badge() + ->color(fn (Shipment $record) => self::statusColor($record)), + TextEntry::make('carrier_status') + ->label('Carrier status') + ->state(fn (Shipment $record) => self::carrierStatus($record)) + ->placeholder('—'), + TextEntry::make('meta.reference')->label('Reference sent to the carrier')->placeholder('—'), + TextEntry::make('meta.voucher_date')->label('Voucher date')->date()->placeholder('—'), + TextEntry::make('cod') + ->label('Cash on delivery') + ->state(fn (Shipment $record) => ($amount = $record->meta['cod_amount'] ?? null) + ? '€'.number_format((float) $amount, 2) + : null) + ->placeholder('—'), + TextEntry::make('services') + ->label('Extra services') + ->state(fn (Shipment $record) => collect($record->meta['services'] ?? []) + ->map(fn (string $value) => ExtraService::tryFrom($value)?->label()) + ->filter() + ->implode(', ') ?: null) + ->placeholder('—'), + TextEntry::make('created_at')->label('Recorded')->dateTime(), + ]), + Section::make('Recipient') + ->columns(3) + ->schema([ + TextEntry::make('recipient') + ->label('Name') + ->state(fn (Shipment $record) => self::recipient($record)) + ->placeholder('—'), + TextEntry::make('postcode') + ->state(fn (Shipment $record) => $record->meta['postcode'] ?? $record->order?->shippingAddress?->postcode) + ->placeholder('—'), + TextEntry::make('phone') + ->state(fn (Shipment $record) => self::phone($record)) + ->placeholder('—'), + ]), + Section::make('Order') + ->schema([ + TextEntry::make('order.reference') + ->label('Linked order') + ->placeholder('Not linked to an order') + ->url(fn (Shipment $record) => $record->order_id + ? OrderResource::getUrl('order', ['record' => $record->order_id]) + : null), + TextEntry::make('suggested') + ->label('Suggested orders') + ->state(fn (Shipment $record) => array_values(self::orderOptions(Order::with('shippingAddress') + ->whereKey($record->meta['suggested_order_ids'] ?? []) + ->get()))) + ->listWithLineBreaks() + ->helperText('The order its reference points to first, then orders with the same postcode and phone or name.') + ->visible(fn (Shipment $record) => ! $record->order_id && filled($record->meta['suggested_order_ids'] ?? [])), + ]), + Section::make('Return') + ->columns(2) + ->schema([ + TextEntry::make('return_kind') + ->label('Reported as') + ->state(fn (Shipment $record) => $record->isReturn() ? 'Return voucher' : 'Being returned to sender'), + TextEntry::make('meta.original_voucher')->label('Original voucher')->placeholder('—'), + ]) + ->visible(fn (Shipment $record) => self::isReturnLike($record)), + Section::make('Tracking history') + ->schema([ + TextEntry::make('no_history') + ->hiddenLabel() + ->state('No tracking updates yet.') + ->visible(fn (Shipment $record) => $record->shipmentInfo->isEmpty()), + RepeatableEntry::make('history') + ->hiddenLabel() + ->state(fn (Shipment $record) => $record->shipmentInfo->sortByDesc('occurred_at')->values()) + ->visible(fn (Shipment $record) => $record->shipmentInfo->isNotEmpty()) + ->schema([ + TextEntry::make('status') + ->label(fn (ShipmentInfo $record) => $record->occurred_at->format('Y-m-d H:i')) + ->inlineLabel() + ->state(fn (ShipmentInfo $record) => (string) str($record->status->value)->replace('_', ' ')->title()) + ->helperText(fn (ShipmentInfo $record) => collect([$record->carrier_status !== 'manual' ? $record->carrier_status : null, $record->location, $record->message])->filter()->unique()->implode(' · ') ?: null), + ]), + ]), + ]); + } + + /** + * Attaches an unlinked voucher to an order (suggestions first), from the + * view page. + */ + public static function linkOrderAction(): Action + { + return Action::make('link_order') + ->label('Link to order') + ->icon('heroicon-o-link') + ->schema(fn (Shipment $record) => [ + Select::make('order_id') + ->label('Order') + ->options(fn () => self::orderOptions(Order::with('shippingAddress') + ->whereKey($record->meta['suggested_order_ids'] ?? []) + ->get())) + ->default(($record->meta['suggested_order_ids'] ?? [])[0] ?? null) + ->searchable() + ->getSearchResultsUsing(fn (string $search) => self::orderOptions(Order::with('shippingAddress') + ->where('reference', 'ilike', "%{$search}%") + ->orWhereHas('shippingAddress', fn ($q) => $q + ->where('last_name', 'ilike', "%{$search}%") + ->orWhere('postcode', $search)) + ->latest('placed_at') + ->limit(20) + ->get())) + ->getOptionLabelUsing(fn ($value) => self::orderOptions(Order::with('shippingAddress')->whereKey($value)->get())[$value] ?? $value) + ->helperText('Suggestions: the order its reference points to, then orders with the same postcode and phone or name.') + ->required(), + ]) + ->action(function (Shipment $record, array $data) { + $record->update(['order_id' => $data['order_id']]); + + // Its history so far goes onto the order's Timeline. + app(ShipmentTimelineLogger::class)->backfill($record->refresh()); + + Notification::make()->title('Voucher linked to the order.')->success()->send(); + }) + ->visible(fn (Shipment $record) => $record->order_id === null); + } + + public static function unlinkOrderAction(): Action + { + return Action::make('unlink_order') + ->label('Unlink from order') + ->icon('heroicon-o-x-mark') + ->color('gray') + ->requiresConfirmation() + ->action(fn (Shipment $record) => $record->update(['order_id' => null])) + ->visible(fn (Shipment $record) => $record->order_id !== null && $record->source === Shipment::SOURCE_SYNCED); + } + + /** + * @param iterable $orders + * @return array + */ + private static function orderOptions(iterable $orders): array + { + return collect($orders)->mapWithKeys(fn (Order $order) => [$order->id => collect([ + $order->reference, + trim($order->shippingAddress?->first_name.' '.$order->shippingAddress?->last_name), + $order->shippingAddress?->postcode, + $order->placed_at?->format('Y-m-d'), + ])->filter()->implode(' · ')])->all(); + } + + private static function phone(Shipment $record): ?string + { + return $record->meta['phone'] ?? $record->order?->shippingAddress?->contact_phone; + } + + private static function recipient(Shipment $record): ?string + { + if ($name = $record->meta['recipient_name'] ?? null) { + return $name; + } + + $address = $record->order?->shippingAddress; + + return $address ? trim("{$address->first_name} {$address->last_name}") : null; + } + + /** + * Our status (TrackingStatus): cancelled here, else the latest + * checkpoint in the voucher's history. + */ + private static function trackingStatus(Shipment $record): ?TrackingStatus + { + if ($record->isCancelled()) { + return TrackingStatus::Cancelled; + } + + return $record->shipmentInfo->sortBy('occurred_at')->last()?->status; + } + + private static function status(Shipment $record): ?string + { + $status = self::trackingStatus($record); + + return $status ? (string) str($status->value)->replace('_', ' ')->title() : null; + } + + private static function statusColor(Shipment $record): string + { + return match (self::trackingStatus($record)) { + TrackingStatus::Delivered => 'success', + TrackingStatus::Failed, TrackingStatus::Returned, TrackingStatus::Cancelled => 'danger', + TrackingStatus::InTransit, TrackingStatus::OutForDelivery, TrackingStatus::CollectedFromSender => 'warning', + default => 'gray', + }; + } + + /** + * The carrier's own wording for the latest status, as it sent it. + */ + private static function carrierStatus(Shipment $record): ?string + { + $latest = $record->shipmentInfo->sortBy('occurred_at')->last(); + + return $latest?->carrier_status !== 'manual' ? $latest?->carrier_status : null; + } + + public static function isReturnLike(Shipment $record): bool + { + return $record->isReturn() || (bool) ($record->meta['reported_return'] ?? false); + } + + public static function getPages(): array + { + return [ + 'index' => ListCarrierVouchers::route('/'), + 'view' => ViewCarrierVoucher::route('/{record}'), + ]; + } +} diff --git a/src/Shipping/Filament/Resources/CarrierVoucherResource/Pages/ListCarrierVouchers.php b/src/Shipping/Filament/Resources/CarrierVoucherResource/Pages/ListCarrierVouchers.php new file mode 100644 index 0000000..bd5df29 --- /dev/null +++ b/src/Shipping/Filament/Resources/CarrierVoucherResource/Pages/ListCarrierVouchers.php @@ -0,0 +1,112 @@ + Tab::make('All'), + 'unlinked' => Tab::make('Unlinked') + ->modifyQueryUsing(fn (Builder $query) => $query->whereNull('order_id')) + ->badge(fn () => Shipment::whereNotNull('tracking_reference')->whereNull('order_id')->count() ?: null), + 'returns' => Tab::make('Returns') + ->modifyQueryUsing(fn (Builder $query) => $query->where(fn (Builder $q) => $q + ->where('meta->is_return', true) + ->orWhere('meta->reported_return', true))) + ->badge(fn () => Shipment::whereNotNull('tracking_reference') + ->where(fn (Builder $q) => $q->where('meta->is_return', true)->orWhere('meta->reported_return', true)) + ->count() ?: null), + ]; + } + + protected function getHeaderActions(): array + { + return [ + Action::make('lookup') + ->label('Look up voucher') + ->icon('heroicon-o-magnifying-glass') + ->modalDescription('Fetches a voucher from the carrier that hasn\'t synced yet and records it here.') + ->schema([ + Select::make('carrier') + ->label('Carrier') + ->options(fn () => collect(Shipping::getSupportedDrivers()) + ->filter(fn ($driver, string $carrier) => app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]) instanceof SupportsVoucherLookup) + ->map(fn ($driver) => $driver->name()) + ->all()) + ->native(false) + ->required(), + TextInput::make('voucher_number')->label('Voucher number')->required(), + ]) + ->action(fn (array $data) => $this->lookup($data['carrier'], $data['voucher_number'])), + Action::make('sync') + ->label('Sync now') + ->icon('heroicon-o-arrow-path') + ->action(fn () => $this->sync()), + ]; + } + + private function lookup(string $carrier, string $number): void + { + try { + $shipment = app(CarrierVoucherSync::class)->lookup($carrier, $number); + } catch (Throwable $e) { + report($e); + + Notification::make()->title('Lookup failed: '.$e->getMessage())->danger()->send(); + + return; + } + + if (! $shipment) { + Notification::make()->title("The carrier has no record of {$number}.")->warning()->send(); + + return; + } + + $body = match (true) { + ! $shipment->wasRecentlyCreated => 'Already recorded here.', + filled($shipment->meta['suggested_order_ids'] ?? []) => 'Recorded — it has a suggested order to link it to.', + default => 'Recorded, not linked to an order.', + }; + + Notification::make()->title("Voucher {$shipment->tracking_reference}")->body($body)->success()->send(); + } + + private function sync(): void + { + $results = app(CarrierVoucherSync::class)->syncAll(now()->subDays(self::SYNC_DAYS)->startOfDay(), now()); + + foreach ($results as $carrier => $stats) { + $name = Shipping::getSupportedDrivers()->get($carrier)?->name() ?? $carrier; + + isset($stats['error']) + ? Notification::make()->title("{$name}: sync failed")->body($stats['error'])->danger()->send() + : Notification::make() + ->title("{$name}: {$stats['created']} new, {$stats['updated']} updated, of {$stats['seen']} vouchers") + ->body($stats['suggested'] ? "{$stats['suggested']} new have a suggested order to link." : null) + ->success() + ->send(); + } + } +} diff --git a/src/Shipping/Filament/Resources/CarrierVoucherResource/Pages/ViewCarrierVoucher.php b/src/Shipping/Filament/Resources/CarrierVoucherResource/Pages/ViewCarrierVoucher.php new file mode 100644 index 0000000..bc151de --- /dev/null +++ b/src/Shipping/Filament/Resources/CarrierVoucherResource/Pages/ViewCarrierVoucher.php @@ -0,0 +1,32 @@ +getRecord()->tracking_reference; + } + + protected function getHeaderActions(): array + { + return [ + Action::make('open_order') + ->label('Open order') + ->icon('heroicon-o-arrow-top-right-on-square') + ->url(fn (Shipment $record) => OrderResource::getUrl('order', ['record' => $record->order_id])) + ->visible(fn (Shipment $record) => $record->order_id !== null), + CarrierVoucherResource::linkOrderAction(), + CarrierVoucherResource::unlinkOrderAction(), + ]; + } +} diff --git a/src/Shipping/Filament/Resources/ManifestResource/RelationManagers/ShipmentsRelationManager.php b/src/Shipping/Filament/Resources/ManifestResource/RelationManagers/ShipmentsRelationManager.php index 44b1eba..fbe2482 100644 --- a/src/Shipping/Filament/Resources/ManifestResource/RelationManagers/ShipmentsRelationManager.php +++ b/src/Shipping/Filament/Resources/ManifestResource/RelationManagers/ShipmentsRelationManager.php @@ -12,9 +12,8 @@ use Modules\Core\Shipping\Models\Shipment; /** * The shipments a given Manifest actually included — read-only (a * shipment's manifest membership is set once, at issueManifest() time, - * never edited here). Reuses ShipmentResource::printShipment() for the - * "Print" action rather than duplicating its try/catch-and-notify - * handling. + * never edited here). "Print" opens the same signed label URL as the + * order page (ShipmentResource::labelUrl()). */ class ShipmentsRelationManager extends RelationManager { @@ -33,7 +32,7 @@ class ShipmentsRelationManager extends RelationManager Action::make('print') ->label('Print') ->icon('heroicon-o-printer') - ->action(fn (Shipment $record) => ShipmentResource::printShipment($record)), + ->url(fn (Shipment $record) => ShipmentResource::labelUrl($record), shouldOpenInNewTab: true), ]); } } diff --git a/src/Shipping/Filament/Resources/ShipmentResource.php b/src/Shipping/Filament/Resources/ShipmentResource.php index 82835e9..bd67b48 100644 --- a/src/Shipping/Filament/Resources/ShipmentResource.php +++ b/src/Shipping/Filament/Resources/ShipmentResource.php @@ -10,7 +10,9 @@ use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\URL; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; +use Modules\Core\Shipping\Contracts\SupportsBatchLabels; use Modules\Core\Shipping\Contracts\SupportsManifestBatching; use Modules\Core\Shipping\Filament\Resources\ShipmentResource\Pages\ListShipments; use Modules\Core\Shipping\Models\Shipment; @@ -21,12 +23,17 @@ use Throwable; * ManagePickupManifests page — a bare Page has no access to Filament's * resource-level pill-tab UI (Filament\Resources\Concerns\HasTabs is * scoped to ListRecords), so carrier-by-carrier separation - * (ListShipments::getTabs(), one tab per SupportsManifestBatching - * implementer) needed a real Resource to attach to. + * (ListShipments::getTabs()) needed a real Resource to attach to. * - * Shows only shipments NOT yet on an issued manifest — see - * Modules\Core\Shipping\Filament\Resources\ManifestResource for - * shipments that already are. + * Shows only shipments created through a carrier's API that are NOT yet on + * an issued manifest — see Modules\Core\Shipping\Filament\Resources\ + * ManifestResource for shipments that already are. Manual carriers, manual + * vouchers and carrier-reported (synced) vouchers have no label to print + * or manifest to join, so they never show here. + * + * Print opens the label through the same signed URL as the order page; for + * carriers that issue the voucher on print (ELTA), that first open is what + * gives the shipment its voucher number. */ class ShipmentResource extends Resource { @@ -45,6 +52,8 @@ class ShipmentResource extends Resource public static function getEloquentQuery(): Builder { return parent::getEloquentQuery() + ->where('source', Shipment::SOURCE_CREATED) + ->whereNotNull('order_id') ->whereNull('manifest_id') ->whereNull('cancelled_at'); } @@ -52,53 +61,132 @@ class ShipmentResource extends Resource public static function table(Table $table): Table { return $table + ->defaultSort('created_at', 'desc') ->columns([ - TextColumn::make('carrier')->badge(), - TextColumn::make('tracking_reference')->label('Tracking #'), + TextColumn::make('carrier') + ->badge() + ->formatStateUsing(fn (Shipment $record) => $record->carrierLabel()), + TextColumn::make('tracking_reference') + ->label('Tracking #') + ->placeholder('Not issued yet') + ->copyable(), TextColumn::make('order.reference')->label('Order'), + TextColumn::make('created_at')->label('Created')->dateTime(), 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)), + ->url(fn (Shipment $record) => self::labelUrl($record), shouldOpenInNewTab: true), + Action::make('cancel') + ->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) => self::cancelShipment($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))), + ->deselectRecordsAfterCompletion() + ->action(fn (Collection $records, $livewire) => self::printSelected($records, $livewire)), BulkAction::make('issue_manifest') ->label('Issue Manifest') ->icon('heroicon-o-check-circle') + ->visible(fn ($livewire) => filled($livewire->activeTab ?? null) + && self::fulfillmentService($livewire->activeTab) instanceof SupportsManifestBatching) ->action(fn (Collection $records) => self::issueManifest($records)), ]); } - public static function printShipment(Shipment $shipment): void + public static function labelUrl(Shipment $shipment): string { + return URL::temporarySignedRoute('shipments.label', now()->addMinutes(5), ['shipment' => $shipment->id]); + } + + /** + * One combined PDF for carriers that support it (opened in a new tab, + * with a link in the notification in case the browser blocks the + * pop-up); anything else has to be printed row by row. + */ + public static function printSelected(Collection $shipments, $livewire): void + { + $carriers = $shipments->pluck('carrier')->unique(); + + if ($carriers->count() !== 1) { + Notification::make() + ->title('Select shipments of a single carrier to print them together.') + ->warning() + ->send(); + + return; + } + + $shipment = $shipments->first(); + + if (! self::fulfillmentService($shipment->carrier) instanceof SupportsBatchLabels) { + Notification::make() + ->title("{$shipment->carrierLabel()} labels can't be combined — print them individually.") + ->warning() + ->send(); + + return; + } + + $url = URL::temporarySignedRoute('shipments.labels', now()->addMinutes(5), [ + 'ids' => $shipments->pluck('id')->sort()->implode(','), + ]); + + $livewire->js('window.open('.json_encode($url).', "_blank")'); + + Notification::make() + ->title('Printing '.$shipments->count().' labels.') + ->success() + ->actions([ + Action::make('open')->label('Open PDF')->url($url, shouldOpenInNewTab: true), + ]) + ->send(); + } + + public static function cancelShipment(Shipment $shipment): void + { + if ($shipment->cancelsLocallyOnly()) { + $shipment->update(['cancelled_at' => now()]); + + Notification::make() + ->title('Shipment marked as cancelled. Void the voucher with the courier as well.') + ->success() + ->send(); + + return; + } + $service = self::fulfillmentService($shipment->carrier); - if (! $service) { + try { + if (! $service) { + throw new \RuntimeException("No fulfillment integration configured for {$shipment->carrierLabel()}."); + } + + $service->cancelShipment($shipment); + } catch (Throwable $e) { + report($e); + Notification::make() - ->title("No fulfillment integration configured for {$shipment->carrier}.") + ->title('Failed to cancel shipment: '.$e->getMessage()) ->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(); - } + Notification::make() + ->title('Shipment cancelled.') + ->success() + ->send(); } public static function issueManifest(Collection $shipments): void diff --git a/src/Shipping/Filament/Resources/ShipmentResource/Pages/ListShipments.php b/src/Shipping/Filament/Resources/ShipmentResource/Pages/ListShipments.php index 331ec9c..1fa8d2c 100644 --- a/src/Shipping/Filament/Resources/ShipmentResource/Pages/ListShipments.php +++ b/src/Shipping/Filament/Resources/ShipmentResource/Pages/ListShipments.php @@ -6,17 +6,17 @@ use Filament\Resources\Pages\ListRecords; use Filament\Schemas\Components\Tabs\Tab; use Illuminate\Database\Eloquent\Builder; use Lunar\Shipping\Facades\Shipping; +use Modules\Core\Shipping\Contracts\IssuesVoucherOnPrint; use Modules\Core\Shipping\Contracts\SupportsManifestBatching; use Modules\Core\Shipping\Filament\Resources\ShipmentResource; /** - * One tab per carrier that actually implements SupportsManifestBatching - * (ACS today) — a carrier with no manifest concept at all (Box Now, - * which books courier pickup at shipment-creation time, no separate - * batching step) never gets a tab here, since there is nothing to batch. - * Adding a new carrier (e.g. Speedex) that also implements the contract - * needs zero changes to this page — the tab list is derived from - * Shipping::getSupportedDrivers(), not hardcoded. + * One tab per carrier with a step between creating a shipment and handing + * it over: batching into a manifest (SupportsManifestBatching — ACS) or + * issuing the voucher when it's printed (IssuesVoucherOnPrint — ELTA). A + * carrier with neither (Box Now books the pickup at creation) never gets a + * tab. The tab list is derived from Shipping::getSupportedDrivers(), so a + * new carrier implementing either contract needs no change here. */ class ListShipments extends ListRecords { @@ -24,13 +24,14 @@ class ListShipments extends ListRecords public function getTabs(): array { - $carriers = collect(Shipping::getSupportedDrivers()) - ->keys() - ->filter(fn (string $carrier) => ShipmentResource::fulfillmentService($carrier) instanceof SupportsManifestBatching); + return collect(Shipping::getSupportedDrivers()) + ->filter(function ($driver, string $carrier) { + $service = ShipmentResource::fulfillmentService($carrier); - return $carriers->mapWithKeys(fn (string $carrier) => [ - $carrier => Tab::make(ucwords(str_replace('-', ' ', $carrier))) - ->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)), - ])->all(); + return $service instanceof SupportsManifestBatching || $service instanceof IssuesVoucherOnPrint; + }) + ->map(fn ($driver, string $carrier) => Tab::make($driver->name()) + ->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 index c2a1d5c..d0d5e8c 100644 --- a/src/Shipping/Http/Controllers/DownloadShipmentLabelController.php +++ b/src/Shipping/Http/Controllers/DownloadShipmentLabelController.php @@ -45,11 +45,21 @@ class DownloadShipmentLabelController extends Controller $service = app(CarrierFulfillmentInterface::class, ['carrier' => $shipment->carrier]); + // Manual carriers, typed-in vouchers and synced ones have no + // carrier label to print. + if (! $service || ! $shipment->hasCarrierLabel()) { + abort(404); + } + $bytes = $service->printLabel($shipment); + // Printing may have just issued the voucher number (ELTA pending + // vouchers), so name the file after the refreshed record. + $name = $shipment->refresh()->tracking_reference ?? $shipment->id; + return response($bytes, 200, [ 'Content-Type' => 'application/pdf', - 'Content-Disposition' => 'inline; filename="shipment-'.$shipment->tracking_reference.'.pdf"', + 'Content-Disposition' => 'inline; filename="shipment-'.$name.'.pdf"', ]); } } diff --git a/src/Shipping/Http/Controllers/DownloadShipmentLabelsController.php b/src/Shipping/Http/Controllers/DownloadShipmentLabelsController.php new file mode 100644 index 0000000..2f49d0b --- /dev/null +++ b/src/Shipping/Http/Controllers/DownloadShipmentLabelsController.php @@ -0,0 +1,48 @@ +hasValidSignature()) { + abort(401); + } + + $ids = array_filter(array_map('intval', explode(',', (string) $request->query('ids')))); + + $shipments = Shipment::whereIn('id', $ids)->orderBy('id')->get() + ->filter(fn (Shipment $shipment) => $shipment->hasCarrierLabel()); + + $carriers = $shipments->pluck('carrier')->unique(); + + if ($shipments->isEmpty() || $carriers->count() !== 1) { + abort(404); + } + + $service = app(CarrierFulfillmentInterface::class, ['carrier' => $carriers->first()]); + + if (! $service instanceof SupportsBatchLabels) { + abort(404); + } + + return response($service->printLabels($shipments), 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="labels-'.now()->format('Ymd-His').'.pdf"', + ]); + } +} diff --git a/src/Shipping/Jobs/PollShipmentTrackingJob.php b/src/Shipping/Jobs/PollShipmentTrackingJob.php index 7090aac..1b07964 100644 --- a/src/Shipping/Jobs/PollShipmentTrackingJob.php +++ b/src/Shipping/Jobs/PollShipmentTrackingJob.php @@ -11,9 +11,8 @@ use Lunar\Shipping\Facades\Shipping; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\SupportsTracking; use Modules\Core\Shipping\Enums\TrackingStatus; -use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; use Modules\Core\Shipping\Models\Shipment; -use Modules\Core\Shipping\Models\ShipmentInfo; +use Modules\Core\Shipping\Services\ShipmentTrackingRecorder; use Throwable; /** @@ -52,6 +51,8 @@ class PollShipmentTrackingJob implements ShouldQueue Shipment::query() ->whereIn('carrier', $trackableCarriers) ->whereNull('cancelled_at') + // Pending vouchers (issued on print) have no number to track yet. + ->whereNotNull('tracking_reference') ->whereDoesntHave('shipmentInfo', function ($query) { $query->whereIn('status', [ TrackingStatus::Delivered->value, @@ -68,49 +69,19 @@ class PollShipmentTrackingJob implements ShouldQueue private function pollCarrierShipments(string $carrier, $shipments): void { - $service = $this->fulfillmentService($carrier); - - if (! $service instanceof SupportsTracking) { + if (! $this->fulfillmentService($carrier) instanceof SupportsTracking) { return; } foreach ($shipments as $shipment) { try { - $this->recordNewCheckpoints($shipment, $service->trackShipment($shipment)); + app(ShipmentTrackingRecorder::class)->refresh($shipment); } catch (Throwable $e) { report($e); } } } - private function recordNewCheckpoints(Shipment $shipment, $checkpoints): void - { - $existing = $shipment->shipmentInfo() - ->get(['status', 'occurred_at']) - ->map(fn ($info) => $info->status->value.'|'.$info->occurred_at->toIso8601String()) - ->flip(); - - foreach ($checkpoints as $checkpoint) { - $fingerprint = $checkpoint->status->value.'|'.$checkpoint->occurredAt->toIso8601String(); - - if ($existing->has($fingerprint)) { - continue; - } - - $info = ShipmentInfo::create([ - 'shipment_id' => $shipment->id, - 'status' => $checkpoint->status, - 'carrier_status' => $checkpoint->carrierStatus, - 'message' => $checkpoint->message, - 'location' => $checkpoint->location, - 'occurred_at' => $checkpoint->occurredAt, - 'meta' => $checkpoint->meta, - ]); - - ShipmentStatusUpdatedByCarrier::dispatch($info); - } - } - private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface { return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]); diff --git a/src/Shipping/Jobs/SyncCarrierVouchersJob.php b/src/Shipping/Jobs/SyncCarrierVouchersJob.php new file mode 100644 index 0000000..fa6dec5 --- /dev/null +++ b/src/Shipping/Jobs/SyncCarrierVouchersJob.php @@ -0,0 +1,34 @@ +syncAll(now()->subDays($this->days)->startOfDay(), now()); + } +} diff --git a/src/Shipping/Listeners/LogShipmentCheckpointOnOrderTimeline.php b/src/Shipping/Listeners/LogShipmentCheckpointOnOrderTimeline.php new file mode 100644 index 0000000..c38ace6 --- /dev/null +++ b/src/Shipping/Listeners/LogShipmentCheckpointOnOrderTimeline.php @@ -0,0 +1,20 @@ +logger->log($event->shipmentInfo); + } +} diff --git a/src/Shipping/Models/Shipment.php b/src/Shipping/Models/Shipment.php index e760f30..d320eef 100644 --- a/src/Shipping/Models/Shipment.php +++ b/src/Shipping/Models/Shipment.php @@ -7,9 +7,22 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Lunar\Models\Order; +use Lunar\Shipping\Facades\Shipping; class Shipment extends Model { + /** Created from our admin through the carrier's API. */ + public const SOURCE_CREATED = 'created'; + + /** An integrated carrier's voucher whose number staff typed in (API down, or the courier's own voucher). */ + public const SOURCE_MANUAL_VOUCHER = 'manual_voucher'; + + /** A carrier with no integration (the `manual` shipping driver); tracked by hand. */ + public const SOURCE_MANUAL = 'manual'; + + /** Pulled from a carrier's own voucher list; may not be linked to an order yet. */ + public const SOURCE_SYNCED = 'synced'; + protected $guarded = []; protected $casts = [ @@ -37,4 +50,77 @@ class Shipment extends Model { return $this->shipmentInfo()->latest('occurred_at')->first(); } + + /** + * The carrier's display name: a manual carrier's own name (snapshotted + * from its shipping method), otherwise the shipping driver's name(). + */ + public function carrierLabel(): string + { + if (filled($this->meta['carrier_name'] ?? null)) { + return $this->meta['carrier_name']; + } + + $driver = collect(Shipping::getSupportedDrivers())->get($this->carrier); + + return $driver?->name() ?? ucwords(str_replace('-', ' ', $this->carrier)); + } + + /** + * A link to the carrier's own tracking page — manual carriers only. + * Integrated carriers' history is synced, so customers follow it on + * our own order page instead. + */ + public function trackingUrl(): ?string + { + $template = $this->meta['tracking_url'] ?? null; + + if ($this->source !== self::SOURCE_MANUAL || blank($template) || blank($this->tracking_reference)) { + return null; + } + + return str_replace('{number}', rawurlencode($this->tracking_reference), $template); + } + + public function isCancelled(): bool + { + return $this->cancelled_at !== null; + } + + /** + * A return voucher (coming back to us), from a carrier's list. Its + * checkpoints must never drive the order's own status. + */ + public function isReturn(): bool + { + return (bool) ($this->meta['is_return'] ?? false); + } + + /** + * Whether this shipment's carrier checkpoints should move its order's + * status (dispatched / delivered / delivery failed). + */ + public function drivesOrderStatus(): bool + { + return $this->order_id !== null && ! $this->isReturn() && ! $this->isCancelled(); + } + + /** + * Only shipments created through the carrier's API have a label to + * print — manual carriers, typed-in vouchers and synced ones don't. + */ + public function hasCarrierLabel(): bool + { + return $this->source === self::SOURCE_CREATED && ! $this->isCancelled(); + } + + /** + * Cancelling a manual carrier's shipment or a typed-in voucher is a + * local record change only — there's nothing to cancel at the carrier + * through the API. + */ + public function cancelsLocallyOnly(): bool + { + return in_array($this->source, [self::SOURCE_MANUAL, self::SOURCE_MANUAL_VOUCHER, self::SOURCE_SYNCED], true); + } } diff --git a/src/Shipping/Services/CarrierVoucherSync.php b/src/Shipping/Services/CarrierVoucherSync.php new file mode 100644 index 0000000..801930a --- /dev/null +++ b/src/Shipping/Services/CarrierVoucherSync.php @@ -0,0 +1,224 @@ + + */ + public function syncAll(CarbonInterface $from, CarbonInterface $to): array + { + return collect(Shipping::getSupportedDrivers())->keys() + ->filter(fn (string $carrier) => $this->service($carrier) instanceof SupportsVoucherListing) + ->mapWithKeys(function (string $carrier) use ($from, $to) { + try { + return [$carrier => $this->sync($carrier, $from, $to)]; + } catch (Throwable $e) { + report($e); + + return [$carrier => ['seen' => 0, 'created' => 0, 'suggested' => 0, 'updated' => 0, 'error' => $e->getMessage()]]; + } + }) + ->all(); + } + + /** + * @return array{seen: int, created: int, suggested: int, updated: int} + */ + public function sync(string $carrier, CarbonInterface $from, CarbonInterface $to): array + { + $service = $this->service($carrier); + $stats = ['seen' => 0, 'created' => 0, 'suggested' => 0, 'updated' => 0]; + + if (! $service instanceof SupportsVoucherListing) { + return $stats; + } + + foreach ($service->listVouchers($from, $to) as $voucher) { + $stats['seen']++; + + $this->checkpointsAdded = 0; + $shipment = $this->record($voucher); + + if ($shipment?->wasRecentlyCreated) { + $stats['created']++; + $stats['suggested'] += filled($shipment->meta['suggested_order_ids'] ?? []) ? 1 : 0; + } elseif ($this->checkpointsAdded > 0) { + $stats['updated']++; + } + } + + return $stats; + } + + /** + * A voucher that hasn't synced yet, looked up by number. Returns the + * shipment it's recorded as (existing or new), or null when the carrier + * doesn't know it. + */ + public function lookup(string $carrier, string $voucherNumber): ?Shipment + { + $service = $this->service($carrier); + + if (! $service instanceof SupportsVoucherLookup) { + return null; + } + + $voucher = $service->lookupVoucher($voucherNumber); + + return $voucher ? $this->record($voucher) : null; + } + + private function record(CarrierVoucher $voucher): ?Shipment + { + if (blank($voucher->voucherNumber)) { + return null; + } + + $existing = Shipment::where('tracking_reference', $voucher->voucherNumber)->first(); + + if ($existing) { + $this->refreshExisting($existing, $voucher); + + return $existing; + } + + $shipment = Shipment::create([ + 'carrier' => $voucher->carrier, + 'source' => Shipment::SOURCE_SYNCED, + 'tracking_reference' => $voucher->voucherNumber, + 'meta' => [ + 'reference' => $voucher->reference, + 'recipient_name' => $voucher->recipientName, + 'postcode' => $voucher->postcode, + 'phone' => $voucher->phone, + 'cod_amount' => $voucher->codAmount, + 'is_return' => $voucher->isReturn, + 'original_voucher' => $voucher->originalVoucher, + 'voucher_date' => $voucher->date?->toDateString(), + 'suggested_order_ids' => $this->matcher->suggestions($voucher), + ], + ]); + + $this->recordHistory($shipment, $voucher); + + return $shipment; + } + + /** + * A voucher we already have, seen again: its history is brought up to + * date (any source, unless cancelled here), and a voucher that came + * from the carrier also takes the carrier's latest details. The order + * link and our own shipments' details are never changed. + */ + private function refreshExisting(Shipment $shipment, CarrierVoucher $voucher): void + { + $meta = $shipment->meta?->toArray() ?? []; + + if ($voucher->isReturn) { + $meta['reported_return'] = true; + } + + if ($shipment->source === Shipment::SOURCE_SYNCED) { + // Only what the carrier reported this time — ACS's list, for + // one, has no recipient, which mustn't wipe what we have. + $meta = [...$meta, ...array_filter([ + 'recipient_name' => $voucher->recipientName, + 'postcode' => $voucher->postcode, + 'phone' => $voucher->phone, + 'cod_amount' => $voucher->codAmount, + 'original_voucher' => $voucher->originalVoucher, + ], fn ($value) => $value !== null)]; + + $meta['is_return'] = ($meta['is_return'] ?? false) || $voucher->isReturn; + + if ($shipment->order_id === null) { + $meta['suggested_order_ids'] = $this->matcher->suggestions($voucher); + } + } + + if ($meta !== ($shipment->meta?->toArray() ?? [])) { + $shipment->update(['meta' => $meta]); + } + + if (! $shipment->isCancelled()) { + $this->recordHistory($shipment, $voucher); + } + } + + /** + * What the carrier reports becomes the voucher's history, like any + * shipment's: new checkpoints from its tracking when the carrier has + * it; otherwise the status from the voucher list, when it differs from + * the latest one we have. + */ + private function recordHistory(Shipment $shipment, CarrierVoucher $voucher): void + { + try { + $this->checkpointsAdded += $this->tracking->refresh($shipment); + } catch (Throwable $e) { + report($e); + } + + if ($this->checkpointsAdded > 0 || ! $voucher->status) { + return; + } + + $latest = $shipment->shipmentInfo()->latest('occurred_at')->first(); + + if ($latest?->status === $voucher->status) { + return; + } + + $this->checkpointsAdded += $this->tracking->record($shipment, [new TrackingCheckpoint( + status: $voucher->status, + carrierStatus: $voucher->statusText, + message: $voucher->statusText, + location: null, + occurredAt: $latest ? now() : ($voucher->date ?? now()), + )]); + } + + private function service(string $carrier): ?CarrierFulfillmentInterface + { + return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]); + } +} diff --git a/src/Shipping/Services/ShipmentTrackingRecorder.php b/src/Shipping/Services/ShipmentTrackingRecorder.php new file mode 100644 index 0000000..61cef0e --- /dev/null +++ b/src/Shipping/Services/ShipmentTrackingRecorder.php @@ -0,0 +1,80 @@ + $shipment->carrier]); + + if (! $service instanceof SupportsTracking || blank($shipment->tracking_reference)) { + return 0; + } + + return $this->record($shipment, $service->trackShipment($shipment)); + } + + /** + * @param iterable $checkpoints + */ + public function record(Shipment $shipment, iterable $checkpoints): int + { + $existing = $shipment->shipmentInfo() + ->get(['status', 'occurred_at']) + ->map(fn (ShipmentInfo $info) => $this->fingerprint($info->status->value, $info->occurred_at->toIso8601String())) + ->flip(); + + $added = 0; + + foreach ($checkpoints as $checkpoint) { + $fingerprint = $this->fingerprint($checkpoint->status->value, $checkpoint->occurredAt->toIso8601String()); + + if ($existing->has($fingerprint)) { + continue; + } + + $existing->put($fingerprint, true); + + $info = ShipmentInfo::create([ + 'shipment_id' => $shipment->id, + 'status' => $checkpoint->status, + 'carrier_status' => $checkpoint->carrierStatus, + 'message' => $checkpoint->message, + 'location' => $checkpoint->location, + 'occurred_at' => $checkpoint->occurredAt, + 'meta' => $checkpoint->meta, + ]); + + ShipmentStatusUpdatedByCarrier::dispatch($info); + + $added++; + } + + return $added; + } + + private function fingerprint(string $status, string $occurredAt): string + { + return $status.'|'.$occurredAt; + } +} diff --git a/src/Shipping/Support/ShipmentTimelineLogger.php b/src/Shipping/Support/ShipmentTimelineLogger.php new file mode 100644 index 0000000..8ec49ad --- /dev/null +++ b/src/Shipping/Support/ShipmentTimelineLogger.php @@ -0,0 +1,60 @@ +shipment; + $order = $shipment?->order; + + if (! $order) { + return; + } + + activity() + ->useLog('lunarpanel') + ->performedOn($order) + ->event(self::EVENT) + ->withProperties([ + 'shipment_info_id' => $info->id, + 'shipment_id' => $shipment->id, + 'carrier' => $shipment->carrierLabel(), + 'tracking_reference' => $shipment->tracking_reference, + 'is_return' => $shipment->isReturn(), + 'status' => $info->status->value, + 'carrier_status' => $info->carrier_status, + 'message' => $info->message, + 'location' => $info->location, + 'occurred_at' => $info->occurred_at?->toIso8601String(), + ]) + ->log(self::EVENT); + } + + /** + * A voucher linked to an order after its history was recorded (Carrier + * Vouchers → Link to order): put that history on the order's Timeline, + * oldest first. + */ + public function backfill(Shipment $shipment): void + { + $shipment->loadMissing('shipmentInfo'); + + $shipment->shipmentInfo + ->sortBy('occurred_at') + ->each(fn (ShipmentInfo $info) => $this->log($info->setRelation('shipment', $shipment))); + } +} diff --git a/src/Shipping/Support/VoucherOrderMatcher.php b/src/Shipping/Support/VoucherOrderMatcher.php new file mode 100644 index 0000000..ee7cabf --- /dev/null +++ b/src/Shipping/Support/VoucherOrderMatcher.php @@ -0,0 +1,129 @@ + order ids, best first + */ + public function suggestions(CarrierVoucher $voucher): array + { + return collect([$this->referenceMatch($voucher), ...$this->suggestedOrderIds($voucher)]) + ->filter() + ->unique() + ->values() + ->all(); + } + + /** + * The order the voucher's own reference (or original voucher) points to. + */ + public function referenceMatch(CarrierVoucher $voucher): ?int + { + if ($voucher->originalVoucher + && $orderId = Shipment::where('tracking_reference', $voucher->originalVoucher)->value('order_id')) { + return $orderId; + } + + $reference = trim((string) $voucher->reference); + + if ($reference === '') { + return null; + } + + if ($id = Order::where('reference', $reference)->value('id')) { + return $id; + } + + // Box Now: "{reference}-{id}", or "{reference}-{id}-{attempt}" for + // a shipment re-created after a cancel. + foreach (['/^(.+)-(\d+)-\d+$/', '/^(.+)-(\d+)$/'] as $pattern) { + if (preg_match($pattern, $reference, $m) + && Order::whereKey((int) $m[2])->where('reference', $m[1])->exists()) { + return (int) $m[2]; + } + } + + if (ctype_digit($reference) && Order::whereKey((int) $reference)->exists()) { + return (int) $reference; + } + + return null; + } + + /** + * @return array order ids, best first + */ + public function suggestedOrderIds(CarrierVoucher $voucher): array + { + if (blank($voucher->postcode) || (blank($voucher->recipientName) && blank($voucher->phone))) { + return []; + } + + $since = ($voucher->date ?? now())->copy()->subDays(self::SUGGESTION_WINDOW_DAYS); + + return Order::query() + ->with('shippingAddress') + ->where('placed_at', '>=', $since) + ->whereHas('shippingAddress', fn ($q) => $q->where('postcode', trim($voucher->postcode))) + ->whereDoesntHave('shipments', fn ($q) => $q->where('carrier', $voucher->carrier)->whereNull('cancelled_at')) + ->latest('placed_at') + ->limit(50) + ->get() + ->filter(fn (Order $order) => $this->samePhone($voucher->phone, $order->shippingAddress?->contact_phone) + || $this->sameName($voucher->recipientName, trim($order->shippingAddress?->first_name.' '.$order->shippingAddress?->last_name))) + ->take(self::MAX_SUGGESTIONS) + ->pluck('id') + ->values() + ->all(); + } + + private function samePhone(?string $a, ?string $b): bool + { + $a = substr(preg_replace('/\D/', '', (string) $a), -10); + $b = substr(preg_replace('/\D/', '', (string) $b), -10); + + return strlen($a) === 10 && $a === $b; + } + + /** + * Carriers report names in capitals, often without accents or in the + * other script's word order, so compare the sets of name words. + */ + private function sameName(?string $a, ?string $b): bool + { + $words = fn (?string $name): Collection => collect(preg_split('/\s+/u', Str::upper(Str::ascii((string) $name)))) + ->filter() + ->sort() + ->values(); + + $a = $words($a); + + return $a->isNotEmpty() && $a->all() === $words($b)->all(); + } +} diff --git a/src/Shipping/routes/web.php b/src/Shipping/routes/web.php index ec0f91e..6c9f262 100644 --- a/src/Shipping/routes/web.php +++ b/src/Shipping/routes/web.php @@ -2,6 +2,10 @@ use Illuminate\Support\Facades\Route; use Modules\Core\Shipping\Http\Controllers\DownloadShipmentLabelController; +use Modules\Core\Shipping\Http\Controllers\DownloadShipmentLabelsController; + +Route::get('shipments/labels', DownloadShipmentLabelsController::class) + ->name('shipments.labels'); Route::get('shipments/{shipment}/label', DownloadShipmentLabelController::class) ->name('shipments.label');