Feat: Shipping functionalities redesign, elta courier integration, commenting out acs since no integration can happen, flow updates

This commit is contained in:
2026-09-30 17:28:47 +03:00
parent f411dec1da
commit 49ec4333af
63 changed files with 3143 additions and 347 deletions
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* - tracking_reference becomes optional: some carriers only issue the
* voucher number when the label is printed (ELTA's pending vouchers).
* Still unique — Postgres allows any number of NULLs under a unique index.
* - order_id becomes optional: vouchers pulled from a carrier's own list
* can exist before they're linked to an order.
* - source records where the shipment came from: created (our admin, via
* the carrier's API), manual_voucher (an integrated carrier's voucher
* typed in by staff), manual (a carrier with no integration), synced
* (pulled from a carrier's voucher list).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->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();
});
}
};
@@ -1,3 +1,12 @@
<p>Hi,</p> <p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is on its way.</p> <p>Your order <strong>{{ $reference }}</strong> is on its way.</p>
@foreach ($shipments ?? [] as $shipment)
<p>
{{ $shipment->carrierLabel() }}: <strong>{{ $shipment->tracking_reference }}</strong>
@if ($url = $shipment->trackingUrl())
— <a href="{{ $url }}">Track your parcel</a>
@endif
</p>
@endforeach
@@ -0,0 +1,13 @@
<div class="flex flex-col gap-1">
<div class="flex flex-wrap items-center gap-2">
@svg('heroicon-m-truck', ['class' => 'w-4 text-gray-500'])
<span>{{ $carrier }} {{ $trackingReference }}@if ($isReturn) (return)@endif</span>
<x-filament::badge :color="$statusColor">{{ $statusLabel }}</x-filament::badge>
</div>
@if ($details || $occurredAt)
<div class="text-sm text-gray-500">
{{ $details }}@if ($details && $occurredAt) · @endif{{ $occurredAt?->format('Y-m-d H:i') }}
</div>
@endif
</div>
@@ -117,7 +117,8 @@
<div class="cut-line" style="top:26.85cm;"><span>ΜΗ ΣΗΜΕΙΩΝΕΤΕ ΚΑΤΩ ΑΠΟ ΑΥΤΗ ΤΗ ΓΡΑΜΜΗ</span></div> <div class="cut-line" style="top:26.85cm;"><span>ΜΗ ΣΗΜΕΙΩΝΕΤΕ ΚΑΤΩ ΑΠΟ ΑΥΤΗ ΤΗ ΓΡΑΜΜΗ</span></div>
<div class="footer-ocr">&gt;{{ $ocr_line }}&lt;</div> {{-- antik_ocr: ELTA's OCR line as issued (it already has its own > < markers) --}}
<div class="footer-ocr">{{ $ocr_line }}</div>
</div> </div>
</body> </body>
</html> </html>
@@ -66,10 +66,10 @@
.a6-small-title { font-weight: bold; font-size: 5pt; } .a6-small-title { font-weight: bold; font-size: 5pt; }
.a6-small-val { font-size: 6pt; } .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 { 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; } .a6-voucher { text-align: center; font-weight: bold; font-size: 12pt; letter-spacing: 1px; }
</style> </style>
</head> </head>
@@ -143,7 +143,7 @@
</td> </td>
<td class="a6-cell-td" style="height:0.97cm;"> <td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">ΟΓΚΟΜΕΤΡΙΚΟ ΒΑΡΟΣ(Kgr)</div> <div class="a6-cell-label">ΟΓΚΟΜΕΤΡΙΚΟ ΒΑΡΟΣ(Kgr)</div>
<div class="a6-cell-val" style="font-size:7pt;">{{ $volumetric_weight ?? '0.000' }}</div> <div class="a6-cell-val" style="font-size:7pt;">{{ $volumetric_weight }}</div>
</td> </td>
<td class="a6-cell-td" style="height:0.97cm;"> <td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">Τεμάχια</div> <div class="a6-cell-label">Τεμάχια</div>
@@ -177,10 +177,12 @@
'lines' => $recipient_lines, 'lines' => $recipient_lines,
]) ])
</div> </div>
{{-- antik_1..7 (textbox51/21/22/24/56/59/61): "ΑΝΤΙΚΑΤΑΒΟΛΗ 17.00",
"* ΑΝΑΛΥΣΗ *", "17.00 MΕΤΡΗΤΑ", one line every 0.37cm. --}}
<div class="a6-box" style="top:6.97cm;left:7.48cm;width:2.85cm;height:2.79cm;"> <div class="a6-box" style="top:6.97cm;left:7.48cm;width:2.85cm;height:2.79cm;">
@if ($antik_1 ?? null) @foreach ($antik_lines as $i => $line)
<div style="font-size:6pt;">{{ $antik_1 }}</div> <div style="position:absolute;top:{{ 0.16 + $i * 0.37 }}cm;left:0.10cm;width:2.62cm;font-size:6pt;">{{ $line }}</div>
@endif @endforeach
</div> </div>
{{-- REFERENCE / ΕΠΙΒΑΡΥΝΣΕΙΣ --}} {{-- REFERENCE / ΕΠΙΒΑΡΥΝΣΕΙΣ --}}
@@ -188,22 +190,27 @@
<div class="a6-small-title">REFERENCE</div> <div class="a6-small-title">REFERENCE</div>
<div class="a6-small-val" style="margin-top:3px; font-weight:bold;">{{ $order_reference }}</div> <div class="a6-small-val" style="margin-top:3px; font-weight:bold;">{{ $order_reference }}</div>
</div> </div>
<div class="a6-box" style="top:9.84cm;left:6.00cm;width:4.37cm;height:1.78cm;"> <div class="a6-box" style="top:9.84cm;left:6.00cm;width:4.37cm;height:1.56cm;">
<div class="a6-small-title">* ΕΠΙΒΑΡΥΝΣΕΙΣ *</div> <div class="a6-small-title">* ΕΠΙΒΑΡΥΝΣΕΙΣ *</div>
@foreach ([$sur_1 ?? null, $sur_2 ?? null, $sur_3 ?? null, $sur_4 ?? null] as $sur)
@if (!empty($sur))
<div style="font-size:6pt; margin-top:2px;">{{ $sur }}</div>
@endif
@endforeach
</div> </div>
{{-- ΠΑΡΑΤΗΡΗΣΕΙΣ --}} {{-- ΠΑΡΑΤΗΡΗΣΕΙΣ --}}
<div class="a6-box" style="top:10.60cm;left:0.10cm;width:5.82cm;height:1.01cm;"> <div class="a6-box" style="top:10.60cm;left:0.10cm;width:5.82cm;height:0.80cm;">
<div class="a6-small-title">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div> <div class="a6-small-title">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div>
</div> </div>
@if ($multiPiece) @if ($multiPiece)
<div class="a6-box" style="top:11.68cm;left:0.10cm;width:10.23cm;height:0.54cm;text-align:center;"> <div class="a6-box" style="top:11.58cm;left:0.10cm;width:10.23cm;height:0.30cm;text-align:center;">
<div style="font-weight:bold; font-size:9pt; margin-top:2px;">* ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ * {{ $package_label }}</div> <div style="font-weight:bold; font-size:8pt; line-height:1;">{{ $polaplo }}</div>
</div> </div>
@endif @endif
<div class="a6-box" style="top:12.20cm;left:0.11cm;width:10.23cm;height:0.57cm;"> <div class="a6-box" style="top:11.98cm;left:0.10cm;width:10.23cm;height:0.52cm;">
<div class="a6-legal"> <div class="a6-legal">
Ισχύουν οι Γενικοί Οροι Παραχής Υπηρεσιών οι οποίοι βρίσκονται αναρτημένοι στο www.elta-courier.gr<br> Ισχύουν οι Γενικοί Οροι Παραχής Υπηρεσιών οι οποίοι βρίσκονται αναρτημένοι στο www.elta-courier.gr<br>
και είναι διαθέσιμοι σε ολα τα καταστήματα της εταιρίας. και είναι διαθέσιμοι σε ολα τα καταστήματα της εταιρίας.
@@ -211,15 +218,15 @@
</div> </div>
@if ($antik_1 ?? null) @if ($antik_1 ?? null)
<div class="a6-box" style="top:12.70cm;left:0.10cm;width:10.23cm;height:0.51cm;text-align:center;"> <div class="a6-box" style="top:12.60cm;left:0.10cm;width:10.23cm;height:0.40cm;text-align:center;">
<div style="font-weight:bold; font-size:9pt; margin-top:4px;">{{ $antik_1 }}</div> <div style="font-weight:bold; font-size:9pt; line-height:1;">{{ $antik_1 }}</div>
</div> </div>
@endif @endif
<div class="a6-barcode" style="top:13.20cm;left:0.10cm;width:10.27cm;"> <div class="a6-barcode" style="top:13.18cm;left:0.10cm;width:10.27cm;">
<img src="{{ $barcode_voucher }}" alt=""> <img src="{{ $barcode_voucher }}" alt="">
</div> </div>
<div class="a6-voucher" style="top:13.95cm;left:0.10cm;width:10.30cm;">{{ $voucher_no }}</div> <div class="a6-voucher" style="top:14.02cm;left:0.10cm;width:10.30cm;">{{ $voucher_no }}</div>
</div> </div>
</body> </body>
</html> </html>
@@ -38,6 +38,15 @@
<div class="mc-voucher" style="top:1.29cm;left:0.24cm;width:6.64cm;">{{ $voucher_no }}</div> <div class="mc-voucher" style="top:1.29cm;left:0.24cm;width:6.64cm;">{{ $voucher_no }}</div>
</div> </div>
{{-- 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)
<div style="position:absolute;top:1.84cm;left:8.52cm;width:7.06cm;text-align:center;font-size:8pt;font-weight:bold;line-height:1;">{{ $antik_minima }}</div>
@endif
{{-- 5-cell deposit/destination/weight/pieces/volumetric row — a genuinely {{-- 5-cell deposit/destination/weight/pieces/volumetric row — a genuinely
tabular single row of fixed-width cells, so it's built as a real tabular single row of fixed-width cells, so it's built as a real
<table border-collapse:collapse> rather than independently-bordered <table border-collapse:collapse> rather than independently-bordered
@@ -69,7 +78,7 @@
</td> </td>
<td class="mc-cell-td"> <td class="mc-cell-td">
<div class="mc-cell-label">Ογκ/κο Βάρος</div> <div class="mc-cell-label">Ογκ/κο Βάρος</div>
<div class="mc-cell-val" style="font-size:7pt;">{{ $volumetric_weight ?? '0.000' }}</div> <div class="mc-cell-val" style="font-size:7pt;">{{ $volumetric_weight }}</div>
</td> </td>
</tr> </tr>
</table> </table>
@@ -135,7 +144,10 @@
{{-- Right-hand signature / print-timestamp column, differs by copy type --}} {{-- Right-hand signature / print-timestamp column, differs by copy type --}}
@if ($copyType === 'delivery') @if ($copyType === 'delivery')
<div class="mc-box" style="top:6.37cm;left:6.61cm;width:4.71cm;height:2.07cm;"> <div class="mc-box" style="top:6.37cm;left:6.61cm;width:4.71cm;height:2.07cm;">
<div style="position:absolute;top:0.05cm;left:0.08cm;font-size:6.5pt;">{{ $antik_1 ?? '' }}</div> {{-- antik_1..6 (textbox102/108/107/106/105/104), first line bold --}}
@foreach ($antik_lines as $i => $line)
<div style="position:absolute;top:{{ 0.12 + $i * 0.31 }}cm;left:0.08cm;width:4.55cm;font-size:7pt;{{ $i === 0 ? 'font-weight:bold;' : '' }}">{{ $line }}</div>
@endforeach
</div> </div>
<div class="mc-box" style="top:6.37cm;left:11.48cm;width:3.89cm;height:2.03cm;"> <div class="mc-box" style="top:6.37cm;left:11.48cm;width:3.89cm;height:2.03cm;">
<div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;">ΓΙΑ ΤΗΝ ΠΑΡΑΛΑΒΗ</div> <div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;">ΓΙΑ ΤΗΝ ΠΑΡΑΛΑΒΗ</div>
@@ -144,7 +156,13 @@
</div> </div>
@else @else
<div class="mc-box" style="top:6.37cm;left:6.61cm;width:4.71cm;height:2.07cm;"> <div class="mc-box" style="top:6.37cm;left:6.61cm;width:4.71cm;height:2.07cm;">
<div style="position:absolute;top:0.05cm;left:0.08cm;font-size:6.5pt;">{{ $antik_1 ?? '' }}</div> {{-- antik_1..6 (textbox102/108/107/106/105/104), first line bold --}}
@foreach ($antik_lines as $i => $line)
<div style="position:absolute;top:{{ 0.12 + $i * 0.31 }}cm;left:0.08cm;width:4.55cm;font-size:7pt;{{ $i === 0 ? 'font-weight:bold;' : '' }}">{{ $line }}</div>
@endforeach
@if ($apodiksi)
<div style="position:absolute;top:1.62cm;left:0.08cm;width:4.55cm;font-size:7pt;">{{ $apodiksi }}</div>
@endif
</div> </div>
<div class="mc-box" style="top:6.37cm;left:11.48cm;width:3.89cm;height:2.03cm;"> <div class="mc-box" style="top:6.37cm;left:11.48cm;width:3.89cm;height:2.03cm;">
<div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΥΠΟΓΡΑΦΗ ΑΠΟΣΤΟΛΕΑ</div> <div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΥΠΟΓΡΑΦΗ ΑΠΟΣΤΟΛΕΑ</div>
@@ -63,7 +63,8 @@
<div class="mc-box mc-partybox" style="top:2.44cm;left:0.00cm;width:5.98cm;height:0.91cm;overflow:hidden;"> <div class="mc-box mc-partybox" style="top:2.44cm;left:0.00cm;width:5.98cm;height:0.91cm;overflow:hidden;">
<div class="mc-party-title" style="top:0.03cm;left:0.10cm;font-size:6.5pt;">ΑΠΟΣΤΟΛΕΑΣ</div> <div class="mc-party-title" style="top:0.03cm;left:0.10cm;font-size:6.5pt;">ΑΠΟΣΤΟΛΕΑΣ</div>
@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) !== '') @if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.30 + $i * 0.19 }}cm;left:0.06cm;width:5.85cm;font-size:5.6pt;">{{ $line }}</div> <div class="mc-party-line" style="top:{{ 0.30 + $i * 0.19 }}cm;left:0.06cm;width:5.85cm;font-size:5.6pt;">{{ $line }}</div>
@endif @endif
@@ -76,7 +77,8 @@
<div class="mc-box mc-partybox" style="top:3.35cm;left:0.00cm;width:5.97cm;height:1.63cm;overflow:hidden;border-top:none;"> <div class="mc-box mc-partybox" style="top:3.35cm;left:0.00cm;width:5.97cm;height:1.63cm;overflow:hidden;border-top:none;">
<div class="mc-party-title" style="top:0.06cm;left:0.10cm;font-size:6.5pt;">ΠΑΡΑΛΗΠΤΗΣ</div> <div class="mc-party-title" style="top:0.06cm;left:0.10cm;font-size:6.5pt;">ΠΑΡΑΛΗΠΤΗΣ</div>
@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) !== '') @if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.34 + $i * 0.26 }}cm;left:0.10cm;width:5.79cm;font-size:5.6pt;">{{ $line }}</div> <div class="mc-party-line" style="top:{{ 0.34 + $i * 0.26 }}cm;left:0.10cm;width:5.79cm;font-size:5.6pt;">{{ $line }}</div>
@endif @endif
@@ -84,6 +86,12 @@
</div> </div>
<div class="mc-box" style="top:3.28cm;left:6.06cm;width:4.71cm;height:1.41cm;"> <div class="mc-box" style="top:3.28cm;left:6.06cm;width:4.71cm;height:1.41cm;">
<div style="position:absolute;top:0.05cm;left:0.14cm;font-size:6pt;">* ΕΠΙΒΑΡΥΝΣΕΙΣ *</div> <div style="position:absolute;top:0.05cm;left:0.14cm;font-size:6pt;">* ΕΠΙΒΑΡΥΝΣΕΙΣ *</div>
{{-- sur_1..4 (textbox139-142) --}}
@foreach ([$sur_1, $sur_2, $sur_3, $sur_4] as $i => $sur)
@if (filled($sur))
<div style="position:absolute;top:{{ 0.33 + $i * 0.265 }}cm;left:0.13cm;width:4.52cm;font-size:6pt;">{{ $sur }}</div>
@endif
@endforeach
</div> </div>
<div style="position:absolute;top:4.95cm;left:0.05cm;width:8.75cm;font-size:4.6pt;line-height:1.25;white-space:nowrap;"> <div style="position:absolute;top:4.95cm;left:0.05cm;width:8.75cm;font-size:4.6pt;line-height:1.25;white-space:nowrap;">
@@ -100,17 +108,10 @@
<div style="position:absolute;top:0.09cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ ΠΑΡΑΛΗΠΤΗ</div> <div style="position:absolute;top:0.09cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ ΠΑΡΑΛΗΠΤΗ</div>
<div style="position:absolute;top:1.35cm;left:0.08cm;font-size:6pt;">Ημ/νία: &nbsp;&nbsp;&nbsp; Ώρα:</div> <div style="position:absolute;top:1.35cm;left:0.08cm;font-size:6pt;">Ημ/νία: &nbsp;&nbsp;&nbsp; Ώρα:</div>
</div> </div>
{{-- rectangle44 --}} {{-- Image18: the ELTA "ΠΟΡΤΑ-ΠΟΡΤΑ" details bitmap, above ΠΟΣΟ —
<div class="mc-box" style="top:5.48cm;left:6.76cm;width:3.99cm;height:1.68cm;text-align:center;"> approximated with our logo + the same text, since the bitmap can't
<div style="position:absolute;top:0.04cm;left:0;width:100%;font-size:7pt;">Π Ο Σ Ο</div> be extracted. --}}
<div style="position:absolute;top:0.75cm;left:0;width:100%;font-size:13pt;font-weight:bold;">{{ number_format($cod_amount ?? 0, 2) }}</div> <div class="ps-brandbox-small" style="position:absolute;top:3.12cm;left:10.87cm;width:4.79cm;height:2.17cm;border:none;">
</div>
{{-- 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. --}}
<div class="mc-box ps-brandbox-small" style="top:5.55cm;left:10.84cm;width:2.72cm;height:1.61cm;">
<div class="ps-brand-logo-sm"><img src="{{ $eltaLogo }}" alt=""></div> <div class="ps-brand-logo-sm"><img src="{{ $eltaLogo }}" alt=""></div>
<div class="ps-legal-sm"> <div class="ps-legal-sm">
<strong>ΠΟΡΤΑ-ΠΟΡΤΑ</strong><br> <strong>ΠΟΡΤΑ-ΠΟΡΤΑ</strong><br>
@@ -118,3 +119,14 @@
ΕΕΤΤ ΑΜ: 99-150 ΕΕΤΤ ΑΜ: 99-150
</div> </div>
</div> </div>
{{-- rectangle44: the COD breakdown, antik_1..6 (textbox149-151,167-169) --}}
<div class="mc-box" style="top:5.48cm;left:6.76cm;width:3.99cm;height:1.68cm;">
@foreach ($antik_lines as $i => $line)
<div style="position:absolute;top:{{ 0.04 + $i * 0.265 }}cm;left:0.11cm;width:3.78cm;font-size:7pt;{{ $i === 0 ? 'font-weight:bold;' : '' }}">{{ $line }}</div>
@endforeach
</div>
{{-- rectangle45: Π Ο Σ Ο + antik_poso (textbox148/152) --}}
<div class="mc-box" style="top:5.55cm;left:10.84cm;width:2.72cm;height:1.61cm;text-align:center;">
<div style="position:absolute;top:0.15cm;left:0;width:100%;font-size:7pt;">Π Ο Σ Ο</div>
<div style="position:absolute;top:0.70cm;left:0;width:100%;font-size:13pt;font-weight:bold;">{{ $antik_poso }}</div>
</div>
+1 -1
View File
@@ -300,7 +300,7 @@ class CheckoutService
if ($shippingMethod !== null && $method->driver === 'cash-on-delivery') { if ($shippingMethod !== null && $method->driver === 'cash-on-delivery') {
$shippingDriver = collect(Shipping::getSupportedDrivers())->get($shippingMethod->driver); $shippingDriver = collect(Shipping::getSupportedDrivers())->get($shippingMethod->driver);
return $shippingDriver instanceof SupportsCashCollection && $shippingDriver->collectsCash(); return $shippingDriver instanceof SupportsCashCollection && $shippingDriver->collectsCash($shippingMethod);
} }
return true; return true;
+2
View File
@@ -49,6 +49,7 @@ use Modules\Core\Shipping\Extensions\OrderViewExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodListExtension; use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension; use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
use Modules\Core\Shipping\Filament\Resources\ManifestResource; use Modules\Core\Shipping\Filament\Resources\ManifestResource;
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource; use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
use Modules\Core\Store\Filament\Pages\ManageStoreDetails; use Modules\Core\Store\Filament\Pages\ManageStoreDetails;
@@ -74,6 +75,7 @@ class CorePlugin implements Plugin
CartResource::class, CartResource::class,
PaymentMethodResource::class, PaymentMethodResource::class,
ShipmentResource::class, ShipmentResource::class,
CarrierVoucherResource::class,
ManifestResource::class, ManifestResource::class,
]) ])
->pages([ ->pages([
@@ -112,7 +112,7 @@ class CustomerAccountService
$order = $customer $order = $customer
?->orders() ?->orders()
->whereNotNull('placed_at') ->whereNotNull('placed_at')
->with(['lines', 'shippingAddress', 'billingAddress', 'transactions', 'shipments']) ->with(['lines', 'shippingAddress', 'billingAddress', 'transactions', 'shipments.shipmentInfo'])
->find($orderId); ->find($orderId);
if (! $order) { if (! $order) {
@@ -251,6 +251,26 @@ class StorefrontTranslationsSeeder extends Seeder
'orders.payment' => ['Payment method', 'Τρόπος πληρωμής'], 'orders.payment' => ['Payment method', 'Τρόπος πληρωμής'],
'orders.shipping_method' => ['Shipping method', 'Τρόπος αποστολής'], 'orders.shipping_method' => ['Shipping method', 'Τρόπος αποστολής'],
'orders.tracking' => ['Tracking', 'Παρακολούθηση αποστολής'], '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.items' => ['Items', 'Προϊόντα'],
'orders.subtotal' => ['Subtotal', 'Μερικό σύνολο'], 'orders.subtotal' => ['Subtotal', 'Μερικό σύνολο'],
'orders.discount' => ['Discount', 'Έκπτωση'], 'orders.discount' => ['Discount', 'Έκπτωση'],
+6 -10
View File
@@ -7,16 +7,12 @@ use Lunar\Models\Order;
use Modules\Core\Shipping\Models\ShipmentInfo; use Modules\Core\Shipping\Models\ShipmentInfo;
/** /**
* Dispatched by either of the two paths that move a carrier order's * Fired when the carrier picks the parcel up — Modules\Core\Order\
* `status` to 'dispatched' — Modules\Core\Order\Listeners\ * Listeners\AdvanceFulfillmentOnCarrierCheckpoint, on the first InTransit /
* AdvanceFulfillmentOnCarrierCheckpoint (automatic, reacting to a real * CollectedFromSender checkpoint (synced from the carrier, or entered by
* carrier checkpoint) or Modules\Core\Order\Services\ * hand for a manual carrier). Creating a shipment doesn't move the order to
* OrderFulfillmentService::createShipmentAndDispatch() (staff-driven, via * dispatched on its own. $shipmentInfo is that checkpoint; nullable for a
* the single "Update Status" action). $shipmentInfo is nullable * caller without one. Mirrors OrderDelivered's {order, shipmentInfo} shape.
* 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.
*/ */
class OrderDispatched class OrderDispatched
{ {
+1
View File
@@ -21,5 +21,6 @@ class OrderStatusUpdated
public readonly Order $order, public readonly Order $order,
public readonly ?string $previousStatus, public readonly ?string $previousStatus,
public readonly string $newStatus, public readonly string $newStatus,
public readonly ?string $causeClass = null,
) {} ) {}
} }
@@ -46,9 +46,10 @@ class AdvanceFulfillmentOnCarrierCheckpoint implements ShouldQueue
return; 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; return;
} }
@@ -26,9 +26,10 @@ class DeriveOrderDeliveredFromShipment implements ShouldQueue
return; return;
} }
$order = $event->shipmentInfo->shipment->order; $shipment = $event->shipmentInfo->shipment;
$order = $shipment->order;
if (! $order) { if (! $order || ! $shipment->drivesOrderStatus()) {
return; return;
} }
@@ -32,9 +32,10 @@ class MarkDeliveryFailedOnCarrierCheckpoint implements ShouldQueue
return; 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; return;
} }
@@ -8,6 +8,8 @@ use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification; use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderDispatched; use Modules\Core\Order\Events\OrderDispatched;
use Modules\Core\Order\Support\OrderReferenceDisplay; use Modules\Core\Order\Support\OrderReferenceDisplay;
use Modules\Core\Shipping\Models\Shipment;
/** /**
* Fills a real, previously-unfilled customer-communication gap — before * Fills a real, previously-unfilled customer-communication gap — before
@@ -53,6 +55,13 @@ class OrderDispatchedNotification extends BaseNotification
->view('core::order.notifications.dispatched', [ ->view('core::order.notifications.dispatched', [
'order' => $order, 'order' => $order,
'reference' => $reference, '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(),
]); ]);
} }
} }
@@ -13,11 +13,9 @@ use Modules\Core\Order\Support\OrderReferenceDisplay;
* "Your order is ready to collect" — listens to the specific * "Your order is ready to collect" — listens to the specific
* OrderReadyForPickup event (dispatched by Modules\Core\Shipping\ * OrderReadyForPickup event (dispatched by Modules\Core\Shipping\
* Extensions\OrderViewExtension's "Mark Ready" action, store-pickup * Extensions\OrderViewExtension's "Mark Ready" action, store-pickup
* branch only), not the generic OrderStatusUpdated. Modules\Core\Order\ * branch only), not the generic OrderStatusUpdated.
* Notifications\OrderStatusUpdatedNotification still separately * Modules\Core\Order\Notifications\OrderStatusUpdatedNotification
* suppresses itself for the legacy 'ready-for-pickup' status string, kept * suppresses itself for that same write (its OWN_EMAIL map).
* defensively even though nothing writes that literal value to
* Order::status anymore after this redesign.
*/ */
class OrderPickupReadyNotification extends BaseNotification class OrderPickupReadyNotification extends BaseNotification
{ {
@@ -6,7 +6,12 @@ use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade; use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification; use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Commands\CloseExpiredReturnWindows;
use Modules\Core\Order\Events\OrderStatusUpdated; 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\Services\OrderStatusFlow;
use Modules\Core\Order\Support\OrderReferenceDisplay; use Modules\Core\Order\Support\OrderReferenceDisplay;
@@ -25,12 +30,22 @@ class OrderStatusUpdatedNotification extends BaseNotification
} }
/** /**
* 'ready-for-pickup' has its own, richer notification * Writes that send their own, richer email for the new status — the
* (Modules\Core\Order\Notifications\OrderPickupReadyNotification) — * generic one stays out of those, or the customer gets two. The same
* both listen to the same OrderStatusUpdated event via * status written any other way (staff's "Update Status", which fires
* NotificationRegistry, so without this the customer would get two * none of those events) still gets the generic email.
* emails for that one transition. Returning no channels is the */
* standard Laravel way to suppress a notification outright. 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 * Also suppressed for the order's very first transition off
* 'awaiting_payment' — for every payment method except bank transfer, * 'awaiting_payment' — for every payment method except bank transfer,
@@ -50,7 +65,7 @@ class OrderStatusUpdatedNotification extends BaseNotification
*/ */
public function via(object $notifiable): array 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 []; return [];
} }
+2
View File
@@ -4,6 +4,7 @@ namespace Modules\Core\Order\Observers;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Order\Events\OrderStatusUpdated; use Modules\Core\Order\Events\OrderStatusUpdated;
use Modules\Core\Order\Services\OrderStatusWriter;
/** /**
* Generically dispatches OrderStatusUpdated for ANY write to `status`, * Generically dispatches OrderStatusUpdated for ANY write to `status`,
@@ -30,6 +31,7 @@ class OrderObserver
$order, $order,
$order->getOriginal('status'), $order->getOriginal('status'),
$order->status, $order->status,
OrderStatusWriter::currentCause(),
); );
} }
} }
+67 -4
View File
@@ -12,6 +12,8 @@ use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus; use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\DTOs\ShipmentRequest; use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Models\Shipment;
use Throwable; use Throwable;
/** /**
@@ -56,7 +58,15 @@ class OrderFulfillmentService
return OrderFulfillmentResult::success('Order marked ready.'); 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') { if ($order->status !== 'ready_for_dispatch') {
return OrderFulfillmentResult::failure('This order is not ready to be dispatched.'); 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()); 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<int, ExtraService> $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 public function markPickedUp(Order $order): OrderFulfillmentResult
@@ -175,14 +224,28 @@ class OrderFulfillmentService
return OrderFulfillmentResult::success('Order marked as paid.'); return OrderFulfillmentResult::success('Order marked as paid.');
} }
/**
* A cancelled shipment doesn't block creating a new one.
*/
public function canCreateShipment(Order $order): bool public function canCreateShipment(Order $order): bool
{ {
return $order->status === 'ready_for_dispatch' return $order->status === 'ready_for_dispatch'
&& ! $order->isStorePickupOrder() && ! $order->isStorePickupOrder()
&& $order->shipments()->exists() === false && $order->shipments()->whereNull('cancelled_at')->doesntExist()
&& $this->resolveFulfillmentService($order) !== null; && $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\ * Public wrapper around resolveCarrier() — Modules\Core\Shipping\
* Extensions\OrderViewExtension needs to know which carrier an order * Extensions\OrderViewExtension needs to know which carrier an order
+19 -1
View File
@@ -29,6 +29,18 @@ use Modules\Core\Order\Events\OrderStatusChanged;
*/ */
class OrderStatusWriter 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 public function write(Order $order, string $to, string $causeClass): void
{ {
$from = $order->status; $from = $order->status;
@@ -37,7 +49,13 @@ class OrderStatusWriter
return; return;
} }
$order->update(['status' => $to]); self::$currentCause = $causeClass;
try {
$order->update(['status' => $to]);
} finally {
self::$currentCause = null;
}
OrderStatusChanged::dispatch($order, $from, $to, $causeClass); OrderStatusChanged::dispatch($order, $from, $to, $causeClass);
} }
+3 -1
View File
@@ -66,7 +66,9 @@ class OrderStatus
*/ */
public static function fulfillment(Order $order): FulfillmentStatus 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()) { if ($shipments->isEmpty()) {
return FulfillmentStatus::Unfulfilled; return FulfillmentStatus::Unfulfilled;
+25 -5
View File
@@ -4,9 +4,13 @@ namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule; use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule;
use Illuminate\Support\Facades\Event; 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 Illuminate\Support\ServiceProvider;
use Livewire\Livewire; use Livewire\Livewire;
use Livewire\Mechanisms\ComponentRegistry; use Livewire\Mechanisms\ComponentRegistry;
use Lunar\Admin\Support\ActivityLog\Manifest as ActivityLogManifest;
use Lunar\Models\Order; use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates; 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\Carriers\Elta\EltaRateDriver;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; 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\Carriers\StorePickup\StorePickupRateDriver;
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob; use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
use Modules\Core\Shipping\Jobs\SyncCarrierVouchersJob;
use Modules\Core\Shipping\Listeners\InvalidateShippingOptions; use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
use Modules\Core\Shipping\Support\FulfillmentType; use Modules\Core\Shipping\Support\FulfillmentType;
use Modules\Core\Shipping\Support\ShippingManager; use Modules\Core\Shipping\Support\ShippingManager;
@@ -53,6 +60,7 @@ class ShippingServiceProvider extends ServiceProvider
'acs' => $app->make(AcsFulfillmentService::class), 'acs' => $app->make(AcsFulfillmentService::class),
'box-now' => $app->make(BoxNowFulfillmentService::class), 'box-now' => $app->make(BoxNowFulfillmentService::class),
'elta' => $app->make(EltaFulfillmentService::class), 'elta' => $app->make(EltaFulfillmentService::class),
'manual' => $app->make(ManualFulfillmentService::class),
default => null, default => null,
}; };
}); });
@@ -70,6 +78,13 @@ class ShippingServiceProvider extends ServiceProvider
public function boot(): void 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([ $this->publishes([
__DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'), __DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'),
__DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.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->booted(function () {
$this->app->bind(ShippingMethodManagerInterface::class, fn ($app) => $app->make(ShippingManager::class)); $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('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
Shipping::extend('store-pickup', fn ($app) => $app->make(StorePickupRateDriver::class)); Shipping::extend('store-pickup', fn ($app) => $app->make(StorePickupRateDriver::class));
Shipping::extend('elta', fn ($app) => $app->make(EltaRateDriver::class)); Shipping::extend('elta', fn ($app) => $app->make(EltaRateDriver::class));
Shipping::extend('manual', fn ($app) => $app->make(ManualRateDriver::class));
$this->app->make(ConsoleSchedule::class) // $this->app->make(ConsoleSchedule::class)
->job(new WarmAcsAreaCacheJob) // ->job(new WarmAcsAreaCacheJob)
->dailyAt('06:00') // ->dailyAt('06:00')
->when(fn () => ShippingMethod::where('driver', 'acs')->exists()); // ->when(fn () => ShippingMethod::where('driver', 'acs')->exists());
$this->app->make(ConsoleSchedule::class) $this->app->make(ConsoleSchedule::class)
->job(new PollShipmentTrackingJob) ->job(new PollShipmentTrackingJob)
->everyFiveMinutes();
$this->app->make(ConsoleSchedule::class)
->job(new SyncCarrierVouchersJob)
->everyThirtyMinutes(); ->everyThirtyMinutes();
$this->overrideRatesPageLivewireComponent(); $this->overrideRatesPageLivewireComponent();
@@ -0,0 +1,45 @@
<?php
namespace Modules\Core\Shipping\ActivityLog;
use Illuminate\Support\Carbon;
use Lunar\Admin\Support\ActivityLog\AbstractRender;
use Modules\Core\Shipping\Support\ShipmentTimelineLogger;
use Spatie\Activitylog\Models\Activity;
/**
* Draws a shipment checkpoint on the order page's Timeline (logged by
* ShipmentTimelineLogger): carrier + voucher, the status badge, and the
* carrier's own location / text.
*/
class ShipmentCheckpointRender extends AbstractRender
{
public function getEvent(): string
{
return ShipmentTimelineLogger::EVENT;
}
public function render(Activity $log)
{
$status = (string) $log->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,
]);
}
}
@@ -3,22 +3,45 @@
namespace Modules\Core\Shipping\Carriers\Acs; namespace Modules\Core\Shipping\Carriers\Acs;
use RuntimeException; use RuntimeException;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; 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\SupportsManifestBatching;
use Modules\Core\Shipping\Contracts\SupportsTracking; use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\DTOs\ManifestResult; use Modules\Core\Shipping\DTOs\ManifestResult;
use Modules\Core\Shipping\DTOs\ShipmentRequest; use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint; use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Manifest; use Modules\Core\Shipping\Models\Manifest;
use Modules\Core\Shipping\Models\Shipment; 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( public function __construct(
private readonly AcsClient $client, private readonly AcsClient $client,
private readonly AreaResolver $areaResolver, private readonly AreaResolver $areaResolver,
@@ -44,12 +67,35 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
'Charge_Type' => 2, 'Charge_Type' => 2,
'Item_Quantity' => $request->packageCount, 'Item_Quantity' => $request->packageCount,
'Weight' => $weight, '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') { $codAmount = $request->paymentMode === 'cod'
$params['Cod_Ammount'] = $request->amountToCollect ?? $order->total->decimal; ? (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['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(); $response = $this->client->call('ACS_Create_Voucher', $params)->throwIfError();
@@ -64,6 +110,9 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
'station_destination' => $destination->stationId, 'station_destination' => $destination->stationId,
'weight' => $weight, 'weight' => $weight,
'pickup_date' => now()->toDateString(), '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 private function isDelivered(Shipment $shipment): bool
{ {
try { try {
@@ -177,7 +288,14 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
return false; 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 private function guessStatusFromAction(string $action): TrackingStatus
+2 -1
View File
@@ -6,6 +6,7 @@ use Lunar\DataTypes\Price;
use Lunar\DataTypes\ShippingOption; use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\CachesLivePricing; use Modules\Core\Shipping\Concerns\CachesLivePricing;
@@ -40,7 +41,7 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla
return 'carrier'; return 'carrier';
} }
public function collectsCash(): bool public function collectsCash(ShippingMethod $method): bool
{ {
return true; return true;
} }
@@ -2,12 +2,16 @@
namespace Modules\Core\Shipping\Carriers\BoxNow; namespace Modules\Core\Shipping\Carriers\BoxNow;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException; use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking; 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\ShipmentRequest;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint; use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus; 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 * sends that many entries in a single delivery request rather than
* several separate ones. * 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]; private const COMPARTMENT_SIZES = ['S' => 1, 'M' => 2, 'L' => 3];
@@ -53,8 +57,21 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
$isCod = $request->paymentMode === 'cod'; $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', [ $response = $this->client->request('post', '/delivery-requests', [
'orderNumber' => $order->reference.'-'.$order->id, 'orderNumber' => $orderNumber,
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''), 'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
'paymentMode' => $isCod ? 'cod' : 'prepaid', 'paymentMode' => $isCod ? 'cod' : 'prepaid',
'amountToBeCollected' => $isCod 'amountToBeCollected' => $isCod
@@ -92,13 +109,18 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
// operate per-Shipment), even though all boxes were submitted in // operate per-Shipment), even though all boxes were submitted in
// one delivery request. Siblings are linked via the shared // one delivery request. Siblings are linked via the shared
// delivery_request_id in meta. // 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, 'order_id' => $order->id,
'carrier' => 'box-now', 'carrier' => 'box-now',
'tracking_reference' => (string) $parcel['id'], 'tracking_reference' => (string) $parcel['id'],
'meta' => [ 'meta' => [
'delivery_request_id' => $response['id'] ?? null, 'delivery_request_id' => $response['id'] ?? null,
'locker_id' => $destinationLocationId, '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 private function mapState(string $state): TrackingStatus
{ {
// TODO: confirm against a live BoxNow webhook payload whether a // 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, 'in-final-destination', 'wait-for-load' => TrackingStatus::OutForDelivery,
'delivered' => TrackingStatus::Delivered, 'delivered' => TrackingStatus::Delivered,
'returned', 'accepted-for-return' => TrackingStatus::Returned, 'returned', 'accepted-for-return' => TrackingStatus::Returned,
'cancelled' => TrackingStatus::Cancelled, 'cancelled', 'canceled' => TrackingStatus::Cancelled,
'expired-return', 'missing' => TrackingStatus::Failed, 'expired-return', 'missing' => TrackingStatus::Failed,
default => TrackingStatus::Unknown, default => TrackingStatus::Unknown,
}; };
+26
View File
@@ -58,11 +58,37 @@ class EltaClient
return $this->call('PELPARALVGNEW1', $params); 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 public function deleteVoucher(array $params): EltaResponse
{ {
return $this->call('PELVGDEL', $params); 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 public function getTracking(array $params): EltaResponse
{ {
return $this->call('PELTTNEW01', $params); return $this->call('PELTTNEW01', $params);
@@ -2,34 +2,66 @@
namespace Modules\Core\Shipping\Carriers\Elta; namespace Modules\Core\Shipping\Carriers\Elta;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException; use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; 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\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\ShipmentRequest;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint; use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
/** /**
* Not SupportsManifestBatching — no manifest/pickup-list operation was * ELTA vouchers are created **pending** (pel_insert_flag "0", live-verified):
* found in either ELTA operation family, unlike ACS's * ELTA stores the voucher with an internal id but no voucher number, and it
* ACS_Issue_Pickup_List. Same shape as Box Now, which has no manifest * can still be cancelled. Printing issues it (PELVG01NEW1 — what ELTA's own
* step either. * 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( public function __construct(
private readonly EltaClient $client, private readonly EltaClient $client,
private readonly EltaLabelRenderer $labelRenderer, 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 public function createShipment(Order $order, ShipmentRequest $request): Shipment
{ {
$address = $order->shippingAddress; $address = $order->shippingAddress;
$weight = $request->weight ?? 0.5; $weight = $request->weight ?? 0.5;
$surcharges = $this->surchargeCodes($request);
$params = [ $params = [
'pel_apost_code' => config('elta.apost_code'), 'pel_apost_code' => config('elta.apost_code'),
@@ -47,9 +79,11 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTra
'pel_z' => '', 'pel_z' => '',
'pel_temaxia' => (string) $request->packageCount, 'pel_temaxia' => (string) $request->packageCount,
'pel_paral_sxolia' => '', 'pel_paral_sxolia' => '',
'pel_sur_1' => '', 'pel_sur_1' => $surcharges[0] ?? '',
'pel_sur_2' => '', 'pel_sur_2' => $surcharges[1] ?? '',
'pel_sur_3' => '', '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_poso' => '',
'pel_ant_poso1' => '', 'pel_ant_poso1' => '',
'pel_ant_poso2' => '', 'pel_ant_poso2' => '',
@@ -59,10 +93,16 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTra
'pel_ant_date2' => '', 'pel_ant_date2' => '',
'pel_ant_date3' => '', 'pel_ant_date3' => '',
'pel_ant_date4' => '', '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_user' => config('elta.user_code'),
'pel_ref_no' => (string) $order->id, // Our order reference — comes back in ELTA's lists and tracking,
'pel_insert_flag' => '', // 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_paral_code' => '',
'pel_retur_code' => '', 'pel_retur_code' => '',
]; ];
@@ -74,122 +114,66 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTra
$params['pel_ant_poso'] = number_format($codAmount, 2, '.', ''); $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']; // 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 —
$shipment = Shipment::create([ // the voucher exists at ELTA either way, and printing retries it.
return Shipment::create([
'order_id' => $order->id, 'order_id' => $order->id,
'carrier' => 'elta', 'carrier' => 'elta',
'tracking_reference' => $voucherNo, 'source' => Shipment::SOURCE_CREATED,
'tracking_reference' => null,
'meta' => [ 'meta' => [
'carrier_id' => $this->findPendingVoucherId((string) $order->reference),
'weight' => $weight, 'weight' => $weight,
'ocr_line' => $response->data['ocr_line'] ?? null,
'date_time' => $response->data['date_time'] ?? null,
'package_count' => $request->packageCount, 'package_count' => $request->packageCount,
'cod_amount' => $codAmount, '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 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()]); $shipment->update(['label_printed_at' => now()]);
return $pdf; 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 * Pending (not yet printed) vouchers are deleted at ELTA. Issued ones
* delete operation), the *NEW family does support cancellation via * can't be — ELTA only allows that before printing.
* 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.
*/ */
public function cancelShipment(Shipment $shipment): void public function cancelShipment(Shipment $shipment): void
{ {
$voucherId = $this->findVoucherId($shipment->tracking_reference); if (filled($shipment->tracking_reference)) {
if ($voucherId === null) {
throw new EltaApiException( 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()]); $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 public function trackShipment(Shipment $shipment): Collection
{ {
$response = $this->client->getTracking([ $response = $this->client->getTracking([
@@ -220,12 +204,269 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, SupportsTra
carrierStatus: $row['web_status_name'] ?? null, carrierStatus: $row['web_status_name'] ?? null,
message: $row['web_sxolia'] ?: ($row['web_status_name'] ?? null), message: $row['web_sxolia'] ?: ($row['web_status_name'] ?? null),
location: $row['web_station'] ?? 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, 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<int, string>
*/
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<int, array<string, string>>
*/
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 * web_status_name is free text with no structured status code — same
* limitation Acs\AcsFulfillmentService::guessStatusFromAction() has. * 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 * it isn't yet in transit. Other statuses are best-guess substring
* matches to refine as more real tracking text is observed. * 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 private function guessStatusFromTitle(string $title): TrackingStatus
{ {
$title = mb_strtolower($title); $title = strtr(mb_strtolower($title), [
'ά' => 'α', 'έ' => 'ε', 'ή' => 'η', 'ί' => 'ι', 'ό' => 'ο', 'ύ' => 'υ', 'ώ' => 'ω',
'ϊ' => 'ι', 'ϋ' => 'υ', 'ΐ' => 'ι', 'ΰ' => 'υ',
]);
return match (true) { return match (true) {
str_contains($title, 'παραδόθηκε') => TrackingStatus::Delivered, str_contains($title, 'επιστροφ') => TrackingStatus::Returned,
str_contains($title, 'διανομή') => TrackingStatus::OutForDelivery, str_contains($title, 'παραδοθηκε') => TrackingStatus::Delivered,
str_contains($title, 'παραλαβή') => TrackingStatus::CollectedFromSender, str_contains($title, 'διανομη') => TrackingStatus::OutForDelivery,
str_contains($title, 'μεταφορά') || str_contains($title, 'διαμετακόμιση') => TrackingStatus::InTransit, str_contains($title, 'παραλαβη') => TrackingStatus::CollectedFromSender,
str_contains($title, 'μεταφορα') || str_contains($title, 'διαμετακομιση') || str_contains($title, 'διακινηση') => TrackingStatus::InTransit,
default => TrackingStatus::Pending, default => TrackingStatus::Pending,
}; };
} }
+162 -36
View File
@@ -4,6 +4,8 @@ namespace Modules\Core\Shipping\Carriers\Elta;
use Barryvdh\DomPDF\Facade\Pdf; use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
use Picqer\Barcode\BarcodeGeneratorPNG; use Picqer\Barcode\BarcodeGeneratorPNG;
@@ -28,15 +30,88 @@ use Picqer\Barcode\BarcodeGeneratorPNG;
*/ */
class EltaLabelRenderer 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 __construct(private readonly AreaResolver $areaResolver) {}
public function render(Shipment $shipment): string 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 <body> — so the pages are stacked under the first view's
* <head> (the styles are the same for every label) with a page break
* between them.
*
* @param Collection<int, Shipment> $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, '<body');
$pages[] = Str::beforeLast(Str::after(Str::after($html, '<body'), '>'), '</body>');
}
$html = $head
.'<style>.page { page-break-after: always; } .page:last-child { page-break-after: auto; }</style>'
.'<body>'.implode('', $pages).'</body></html>';
$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<string, mixed>
*/
private function data(Shipment $shipment): array
{ {
$order = $shipment->order; $order = $shipment->order;
$address = $order->shippingAddress; $address = $order->shippingAddress;
$meta = $shipment->meta?->toArray() ?? []; $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'] ?? ''); $dateTime = (string) ($meta['date_time'] ?? '');
$occurredAt = strlen($dateTime) >= 12 $occurredAt = strlen($dateTime) >= 12
@@ -49,7 +124,7 @@ class EltaLabelRenderer
$generator = new BarcodeGeneratorPNG(); $generator = new BarcodeGeneratorPNG();
$data = [ return [
'sender_name' => config('elta.sender_name'), 'sender_name' => config('elta.sender_name'),
'sender_address' => config('elta.sender_address'), 'sender_address' => config('elta.sender_address'),
'sender_postcode' => config('elta.sender_postcode'), 'sender_postcode' => config('elta.sender_postcode'),
@@ -72,6 +147,7 @@ class EltaLabelRenderer
// report's 40-char wrap width, which our own data never hits // report's 40-char wrap width, which our own data never hits
// in practice for a name/address/contact triple). // in practice for a name/address/contact triple).
'sender_lines' => [ 'sender_lines' => [
'Κωδικός:'.config('elta.apost_code'),
config('elta.sender_name'), config('elta.sender_name'),
config('elta.sender_address'), config('elta.sender_address'),
'TK:'.config('elta.sender_postcode').' '.config('elta.sender_area').' ΤΗΛ:'.config('elta.sender_phone'), 'TK:'.config('elta.sender_postcode').' '.config('elta.sender_area').' ΤΗΛ:'.config('elta.sender_phone'),
@@ -95,13 +171,14 @@ class EltaLabelRenderer
? sprintf('001/%03d', $packageCount) ? sprintf('001/%03d', $packageCount)
: '001', : '001',
'multiPiece' => $packageCount > 1, 'multiPiece' => $packageCount > 1,
'polaplo' => $packageCount > 1 ? '* ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ *' : null,
'station_apo' => config('elta.origin_station_code'), 'station_apo' => config('elta.origin_station_code'),
'station_pros' => $destinationStation->code, 'station_pros' => $station['code'],
'station_pros_title' => $destinationStation->name, 'station_pros_title' => $station['name'],
'service_code' => '214', 'service_code' => $meta['rec_srv'] ?? '',
'service_name' => 'ΠΟΛΗ ΠΟΛΗ -ΠΘ- ΝΗΣΙ', 'service_name' => $meta['rec_srv_t'] ?? '',
'xreosi' => 'ΧΡΕΩΣΗ ΑΠΟΣΤΟΛΕΑ ΠΙΣΤΩΣΗ', 'xreosi' => 'ΧΡΕΩΣΗ ΑΠΟΣΤΟΛΕΑ ΠΙΣΤΩΣΗ',
'siimvasi' => '131775-9', 'siimvasi' => '131775-9',
@@ -114,46 +191,95 @@ class EltaLabelRenderer
// yields reference "1426000981436" (13 chars after the '>'). // yields reference "1426000981436" (13 chars after the '>').
'ocr_reference' => strlen($ocrLine) >= 14 ? substr($ocrLine, 1, 13) : '', '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 // sydetaE.rdlc's "Πρόσθετες Υπηρεσίες" (sur_1..4): the ticked
// ΑΝΤΙΚΑΤΑΒΟΛΗ banner (antik_1) are only populated by ELTA's // extra services, in ELTA's own wording and slots.
// own client when the shipper ticks special-service checkboxes 'sur_1' => $surcharges[1],
// (Sydeta.cs::print_vg()'s checkBox2-5) or a COD amount is set 'sur_2' => $surcharges[2],
// — neither is tracked on our Shipment model today, so these 'sur_3' => $surcharges[3],
// 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,
'sur_4' => null, '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, 'volumetric_weight' => $meta['volumetric_weight'] ?? null,
'eltaLogo' => $this->logoDataUri(), 'eltaLogo' => $this->logoDataUri(),
]; ];
}
$paperSize = config('elta.label_paper_size', 'a4'); /**
$view = $paperSize === 'a6' * The client's own sur_1..3 placement (Sydeta.cs::print_vg()): 002 is
? 'core::shipping.carriers.elta.label-a6' * always sur_1; 003 is always sur_2; 004 is sur_3 after 003, else
: 'core::shipping.carriers.elta.label-a4'; * sur_2. Slots can stay empty (003 alone leaves sur_1 blank).
*
* @param array<int, string> $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 if ($has('timed_delivery')) {
// override for printer_size==2 (1mm ≈ 2.8346pt) — note this is $slots[2] = self::SERVICE_TEXT['timed_delivery'];
// 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');
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<string, mixed>
*/
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,
];
} }
/** /**
@@ -5,6 +5,7 @@ namespace Modules\Core\Shipping\Carriers\Elta;
use Lunar\DataTypes\ShippingOption; use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts; use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing; use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
@@ -38,7 +39,7 @@ class EltaRateDriver implements ShippingRateInterface, DeclaresFulfillmentType,
return 'carrier'; return 'carrier';
} }
public function collectsCash(): bool public function collectsCash(ShippingMethod $method): bool
{ {
return true; return true;
} }
@@ -0,0 +1,52 @@
<?php
namespace Modules\Core\Shipping\Carriers\Manual;
use Lunar\Models\Order;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Models\Shipment;
use RuntimeException;
/**
* Shipments of a manual carrier (ManualRateDriver): recorded here only,
* with no carrier API. The carrier's name and tracking URL template are
* copied from the shipping method into the shipment's meta, so links keep
* working if the method is edited later. Tracking updates are added by
* staff from the order page.
*/
class ManualFulfillmentService implements CarrierFulfillmentInterface
{
public function createShipment(Order $order, ShipmentRequest $request): Shipment
{
$code = $order->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()]);
}
}
@@ -0,0 +1,74 @@
<?php
namespace Modules\Core\Shipping\Carriers\Manual;
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;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsCashCollection;
/**
* A carrier we have no integration with (e.g. Geniki), added by the
* merchant as a shipping method with this driver. The method's `data`
* holds the carrier's details, set in the admin
* (ShippingMethodResourceExtension):
*
* - carrier_name: shown to staff and customers on its shipments;
* - tracking_url: optional link template, `{number}` = the voucher number;
* - collects_cash: whether its courier takes cash on delivery.
*
* Priced like Store Pickup / Box Now (charge_by + price breaks); its
* shipments are created and tracked by hand (ManualFulfillmentService).
*/
class ManualRateDriver implements ShippingRateInterface, DeclaresFulfillmentType, SupportsCashCollection
{
use ResolvesFixedPricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate;
public function name(): string
{
return 'Manual carrier';
}
public function description(): string
{
return 'A carrier without an integration — shipments are entered and tracked by hand.';
}
public function fulfillmentType(): string
{
return 'carrier';
}
public function collectsCash(ShippingMethod $method): bool
{
return (bool) ($method->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;
}
}
@@ -0,0 +1,14 @@
<?php
namespace Modules\Core\Shipping\Contracts;
/**
* A carrier whose shipments are created as pending records and only get a
* voucher number when the label is printed (ELTA). Until then the shipment
* has no tracking_reference and can still be cancelled; printLabel()
* issues the voucher. Its shipments get a tab on the Pending Vouchers
* screen, like carriers with a manifest step.
*/
interface IssuesVoucherOnPrint
{
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Illuminate\Support\Collection;
use Modules\Core\Shipping\Models\Shipment;
/**
* A carrier that can print several shipments' labels as one PDF — behind
* the Pending Vouchers screen's "Print selected". Same side effects as
* printLabel() per shipment (issuing pending vouchers, setting
* label_printed_at).
*/
interface SupportsBatchLabels
{
/**
* @param Collection<int, Shipment> $shipments
*/
public function printLabels(Collection $shipments): string;
}
@@ -2,6 +2,8 @@
namespace Modules\Core\Shipping\Contracts; namespace Modules\Core\Shipping\Contracts;
use Lunar\Shipping\Models\ShippingMethod;
/** /**
* A shipping rate driver implements this to say a person is physically * A shipping rate driver implements this to say a person is physically
* present at handoff to collect cash — true for a courier like * 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. * Contracts\DeclaresFulfillmentType, which only distinguishes carrier vs.
* store_pickup and can't tell ACS and Box Now apart (both 'carrier'). * 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 * 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 * (any of table-rate-shipping's own generic drivers, if one were ever
* re-added) is treated as not supporting cash collection, the safer * re-added) is treated as not supporting cash collection, the safer
@@ -21,5 +27,5 @@ namespace Modules\Core\Shipping\Contracts;
*/ */
interface SupportsCashCollection interface SupportsCashCollection
{ {
public function collectsCash(): bool; public function collectsCash(ShippingMethod $method): bool;
} }
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Modules\Core\Shipping\Enums\ExtraService;
/**
* A carrier that offers extra services (Saturday delivery, insurance, …).
* Only the carrier knows its own codes — the rest of the app deals in the
* normalized ExtraService cases.
*/
interface SupportsExtraServices
{
/**
* The services this carrier offers, keyed by ExtraService value, each
* mapped to the carrier's own code.
*
* @return array<string, string>
*/
public function extraServices(): array;
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Carbon\CarbonInterface;
use Modules\Core\Shipping\DTOs\CarrierVoucher;
/**
* A carrier that can list the vouchers on our account for a date range —
* feeds the Carrier Vouchers screen (returns, and vouchers not linked to
* any order yet).
*/
interface SupportsVoucherListing
{
/**
* @return iterable<CarrierVoucher>
*/
public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable;
}
@@ -0,0 +1,14 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Modules\Core\Shipping\DTOs\CarrierVoucher;
/**
* A carrier that can look up a single voucher by number — so a voucher can
* be found and linked to an order before the next list sync.
*/
interface SupportsVoucherLookup
{
public function lookupVoucher(string $voucherNumber): ?CarrierVoucher;
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Modules\Core\Shipping\DTOs;
use Carbon\CarbonInterface;
use Modules\Core\Shipping\Enums\TrackingStatus;
/**
* One voucher as a carrier's own list or lookup reports it — including ones
* we didn't create (made in the carrier's portal, typed in by a courier)
* and return vouchers. Stored as a `synced` Shipment by
* Modules\Core\Shipping\Services\CarrierVoucherSync.
*/
class CarrierVoucher
{
public function __construct(
public readonly string $carrier,
public readonly string $voucherNumber,
public readonly ?string $reference = null,
public readonly ?string $recipientName = null,
public readonly ?string $postcode = null,
public readonly ?string $phone = null,
// The carrier's own words; $status is the same mapped to ours.
public readonly ?string $statusText = null,
public readonly ?TrackingStatus $status = null,
public readonly ?float $codAmount = null,
public readonly bool $isReturn = false,
public readonly ?string $originalVoucher = null,
public readonly ?CarbonInterface $date = null,
public readonly array $raw = [],
) {}
}
+24
View File
@@ -2,6 +2,8 @@
namespace Modules\Core\Shipping\DTOs; namespace Modules\Core\Shipping\DTOs;
use Modules\Core\Shipping\Enums\ExtraService;
/** /**
* Carrier-agnostic input for CarrierFulfillmentInterface::createShipment(). * Carrier-agnostic input for CarrierFulfillmentInterface::createShipment().
* Every field is optional — a carrier reads only what it needs and ignores * Every field is optional — a carrier reads only what it needs and ignores
@@ -19,6 +21,11 @@ class ShipmentRequest
* an order that doesn't fit one compartment. Empty for every other * an order that doesn't fit one compartment. Empty for every other
* carrier, which ships as a single package described by $weight * carrier, which ships as a single package described by $weight
* instead. * instead.
* @param array<int, ExtraService> $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 function __construct(
public readonly ?float $weight = null, public readonly ?float $weight = null,
@@ -27,5 +34,22 @@ class ShipmentRequest
public readonly ?string $paymentMode = null, public readonly ?string $paymentMode = null,
public readonly ?float $amountToCollect = null, public readonly ?float $amountToCollect = null,
public readonly array $boxes = [], 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<int, string>
*/
public function serviceValues(): array
{
return array_map(fn (ExtraService $service) => $service->value, $this->services);
}
} }
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace Modules\Core\Shipping\Enums;
/**
* Extra carrier services, normalized: "Saturday delivery" is the same case
* whatever code each carrier uses for it. A carrier declares which ones it
* offers and maps them to its own codes via
* Modules\Core\Shipping\Contracts\SupportsExtraServices. Staff pick them
* per shipment; customers never see them.
*/
enum ExtraService: string
{
case SaturdayDelivery = 'saturday_delivery';
case MorningDelivery = 'morning_delivery';
/** Delivery by a set time / within a time slot. */
case TimedDelivery = 'timed_delivery';
case SameDayDelivery = 'same_day_delivery';
case Insurance = 'insurance';
case SpecialHandling = 'special_handling';
case ReturnDocuments = 'return_documents';
case RemoteArea = 'remote_area';
case Protocol = 'protocol';
case Refrigerated = 'refrigerated';
case ExchangePackage = 'exchange_package';
/**
* Admin-facing only (staff pick these), so plain English like the rest
* of the admin UI.
*/
public function label(): string
{
return match ($this) {
self::SaturdayDelivery => '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',
};
}
}
@@ -3,17 +3,22 @@
namespace Modules\Core\Shipping\Extensions; namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\Action; 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\Infolists\Components\RepeatableEntry;
use Filament\Notifications\Notification;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Modules\Core\Shipping\Models\ShipmentInfo; use Modules\Core\Shipping\Models\ShipmentInfo;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Lunar\Admin\Support\Extending\ViewPageExtension; 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 Modules\Core\Shipping\Models\Shipment;
use Throwable;
/** /**
* Adds a "Shipments" section to the order page's main column — previously * Adds a "Shipments" section to the order page's main column — previously
@@ -85,8 +90,9 @@ class OrderShipmentsExtension extends ViewPageExtension
->contained(true) ->contained(true)
->schema([ ->schema([
TextEntry::make('tracking_reference') TextEntry::make('tracking_reference')
->label(fn (Shipment $record) => $this->carrierLabel($record)) ->label(fn (Shipment $record) => $record->carrierLabel())
->inlineLabel() ->inlineLabel()
->placeholder('Voucher not issued yet')
->copyable(), ->copyable(),
TextEntry::make('status') TextEntry::make('status')
->label('Status') ->label('Status')
@@ -104,14 +110,57 @@ class OrderShipmentsExtension extends ViewPageExtension
now()->addMinutes(5), now()->addMinutes(5),
['shipment' => $record->id], ['shipment' => $record->id],
), shouldOpenInNewTab: true) ), 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') Action::make('cancel_shipment')
->label('Cancel') ->label('Cancel')
->icon('heroicon-o-x-circle') ->icon('heroicon-o-x-circle')
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->modalDescription('Cancels this shipment with the carrier. This cannot be undone.') ->modalDescription(fn (Shipment $record) => $record->cancelsLocallyOnly()
->action(fn (Shipment $record) => $this->cancel($record)) ? '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), ->visible(fn (Shipment $record) => ! $record->cancelled_at),
]), ]),
RepeatableEntry::make('shipmentInfo') RepeatableEntry::make('shipmentInfo')
@@ -123,7 +172,11 @@ class OrderShipmentsExtension extends ViewPageExtension
->label(fn (ShipmentInfo $record) => $record->occurred_at->format('Y-m-d H:i')) ->label(fn (ShipmentInfo $record) => $record->occurred_at->format('Y-m-d H:i'))
->inlineLabel() ->inlineLabel()
->state(fn (ShipmentInfo $record) => (string) str($record->status->value)->replace('_', ' ')->title()) ->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(); 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) { $info = ShipmentInfo::create([
'acs' => 'ACS', 'shipment_id' => $record->id,
'box-now' => 'Box Now', 'status' => TrackingStatus::from($data['status']),
default => (string) str($record->carrier)->title(), '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 private function helperText(Shipment $record): string
@@ -159,6 +228,22 @@ class OrderShipmentsExtension extends ViewPageExtension
$parts[] = 'Locker '.$locker; $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); 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();
}
} }
+217 -11
View File
@@ -3,17 +3,30 @@
namespace Modules\Core\Shipping\Extensions; namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\Action; 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\Repeater;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\TimePicker;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Lunar\Admin\Support\Extending\ViewPageExtension; use Lunar\Admin\Support\Extending\ViewPageExtension;
use Lunar\Models\Order; use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Order\DTOs\OrderFulfillmentResult; use Modules\Core\Order\DTOs\OrderFulfillmentResult;
use Modules\Core\Order\Services\OrderFulfillmentService; use Modules\Core\Order\Services\OrderFulfillmentService;
use Modules\Core\Order\Services\OrderStatusFlow; 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\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Support\WeightCalculator; use Modules\Core\Shipping\Support\WeightCalculator;
use Throwable;
/** /**
* Filament wiring only (labels, icons, visibility, form schema) for the * 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 * "Create Shipment" is its own separate header action, visible only for a
* carrier order sitting at 'ready_for_dispatch' — this is the one action * carrier order sitting at 'ready_for_dispatch' — this is the one action
* that talks to a real carrier API and writes Order::status to * that talks to a real carrier API, so it needs its own weight/locker
* 'dispatched' as a side effect of that succeeding, so it needs its own * inputs specific to that one real-world action. It does NOT move the
* weight/locker inputs specific to that one real-world action, not * order to 'dispatched': that happens when the carrier reports picking the
* bundled into the general-purpose status select where they'd appear for * parcel up (AdvanceFulfillmentOnCarrierCheckpoint). Staff also pick the
* every revert/manual-override use of 'dispatched' too. The form branches * 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 * on which carrier the order actually uses
* (OrderFulfillmentService::carrierFor()): a weight-billed carrier (ACS) * (OrderFulfillmentService::carrierFor()): a weight-billed carrier (ACS)
* gets a single TOTAL weight field for the whole shipment (ACS has no * 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 * customer's choice turns out to be unavailable) or fill it in manually for
* an order placed before the checkout locker picker existed. * 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 * "Mark Paid" is a third, separate header action — Order::paid is
* independent of `status` (see OrderStatusFlow's own docblock), so it * independent of `status` (see OrderStatusFlow's own docblock), so it
* doesn't belong bundled into the status select either. Visible only when * doesn't belong bundled into the status select either. Visible only when
@@ -86,6 +107,7 @@ class OrderViewExtension extends ViewPageExtension
: true); : true);
$actions[] = $this->createShipmentAction(); $actions[] = $this->createShipmentAction();
$actions[] = $this->addManualVoucherAction();
$actions[] = $this->updateStatusAction(); $actions[] = $this->updateStatusAction();
$actions[] = $this->markPaidAction(); $actions[] = $this->markPaidAction();
@@ -99,11 +121,28 @@ class OrderViewExtension extends ViewPageExtension
->icon('heroicon-o-truck') ->icon('heroicon-o-truck')
->modalSubmitActionLabel('Create Shipment') ->modalSubmitActionLabel('Create Shipment')
->schema(function (Order $record) { ->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; $lockerId = $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null;
if (! $isBoxNow) { if ($carrier === 'manual') {
return [ 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') TextInput::make('weight')
->label('Total weight (kg)') ->label('Total weight (kg)')
->numeric() ->numeric()
@@ -118,10 +157,10 @@ class OrderViewExtension extends ViewPageExtension
->default(1) ->default(1)
->required() ->required()
->helperText('More than 1 issues a main voucher plus a sub-voucher per extra package, all sharing the total weight above.'), ->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') TextInput::make('destination_location_id')
->label('Box Now locker ID') ->label('Box Now locker ID')
->default($lockerId) ->default($lockerId)
@@ -143,7 +182,7 @@ class OrderViewExtension extends ViewPageExtension
->addActionLabel('Add another box') ->addActionLabel('Add another box')
->minItems(1) ->minItems(1)
->helperText('One row per physical parcel — Box Now ships by compartment size, not weight.'), ->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) { ->action(function (Order $record, array $data, Action $action) {
// Derived from the order itself, never from staff input — // 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. // full payment was already settled, and never collect it.
$isCod = app(OrderStatusFlow::class)->isCod($record); $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, $record,
new ShipmentRequest( new ShipmentRequest(
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null, weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
@@ -170,6 +211,14 @@ class OrderViewExtension extends ViewPageExtension
paymentMode: $isCod ? 'cod' : 'prepaid', paymentMode: $isCod ? 'cod' : 'prepaid',
amountToCollect: $isCod ? $record->total->decimal : null, amountToCollect: $isCod ? $record->total->decimal : null,
boxes: collect($data['boxes'] ?? [])->pluck('size')->all(), 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)); ->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<string, string> 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<string, string>
*/
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 private function updateStatusAction(): Action
{ {
return Action::make('update_status') return Action::make('update_status')
@@ -8,6 +8,8 @@ use Filament\Schemas\Components\Concerns\HasChildComponents;
use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Get;
use InvalidArgumentException; use InvalidArgumentException;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\ResourceExtension; use Lunar\Admin\Support\Extending\ResourceExtension;
@@ -110,15 +112,49 @@ class ShippingMethodResourceExtension extends ResourceExtension
} }
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) { if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema( $children = $this->replaceChargeByField($component->getDefaultChildComponents());
$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; return $component;
}, $components); }, $components);
} }
/**
* Settings for a carrier without an integration (ManualRateDriver),
* stored in the method's `data`. Shown only for that driver.
*
* @return array<Component>
*/
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 private function chargeBySelect(): Select
{ {
return Select::make('charge_by') return Select::make('charge_by')
@@ -0,0 +1,351 @@
<?php
namespace Modules\Core\Shipping\Filament\Resources;
use Filament\Actions\Action;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Lunar\Admin\Filament\Resources\OrderResource;
use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource\Pages\ListCarrierVouchers;
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource\Pages\ViewCarrierVoucher;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Models\ShipmentInfo;
use Modules\Core\Shipping\Support\ShipmentTimelineLogger;
/**
* Every voucher with a number — ours and the ones carriers report
* (CarrierVoucherSync) — to follow returns and reconcile vouchers that
* never made it onto an order. Tabs: All / Unlinked / Returns. The list
* shows the tracking number, our status, order, recipient and phone; the view page has the
* details (carrier, voucher, carrier status, COD, services, return info,
* suggested orders, tracking history). Vouchers are never linked
* automatically: each carries suggested orders (its reference match
* first, then same postcode + phone or name), and
* "Link to order" attaches it, after which it counts towards that order
* like any other shipment.
*/
class CarrierVoucherResource extends Resource
{
protected static ?string $model = Shipment::class;
protected static ?string $slug = 'carrier-vouchers';
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-queue-list';
protected static string | \UnitEnum | null $navigationGroup = 'Sales';
protected static ?string $navigationLabel = 'Carrier Vouchers';
protected static ?string $modelLabel = 'Carrier voucher';
protected static ?int $navigationSort = 101;
private const SOURCES = [
Shipment::SOURCE_CREATED => '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<Order> $orders
* @return array<int, string>
*/
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}'),
];
}
}
@@ -0,0 +1,112 @@
<?php
namespace Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource\Pages;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
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\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Services\CarrierVoucherSync;
use Throwable;
class ListCarrierVouchers extends ListRecords
{
protected static string $resource = CarrierVoucherResource::class;
private const SYNC_DAYS = 14;
public function getTabs(): array
{
return [
'all' => 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();
}
}
}
@@ -0,0 +1,32 @@
<?php
namespace Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource\Pages;
use Filament\Actions\Action;
use Filament\Resources\Pages\ViewRecord;
use Lunar\Admin\Filament\Resources\OrderResource;
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource;
use Modules\Core\Shipping\Models\Shipment;
class ViewCarrierVoucher extends ViewRecord
{
protected static string $resource = CarrierVoucherResource::class;
public function getTitle(): string
{
return 'Voucher '.$this->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(),
];
}
}
@@ -12,9 +12,8 @@ use Modules\Core\Shipping\Models\Shipment;
/** /**
* The shipments a given Manifest actually included — read-only (a * The shipments a given Manifest actually included — read-only (a
* shipment's manifest membership is set once, at issueManifest() time, * shipment's manifest membership is set once, at issueManifest() time,
* never edited here). Reuses ShipmentResource::printShipment() for the * never edited here). "Print" opens the same signed label URL as the
* "Print" action rather than duplicating its try/catch-and-notify * order page (ShipmentResource::labelUrl()).
* handling.
*/ */
class ShipmentsRelationManager extends RelationManager class ShipmentsRelationManager extends RelationManager
{ {
@@ -33,7 +32,7 @@ class ShipmentsRelationManager extends RelationManager
Action::make('print') Action::make('print')
->label('Print') ->label('Print')
->icon('heroicon-o-printer') ->icon('heroicon-o-printer')
->action(fn (Shipment $record) => ShipmentResource::printShipment($record)), ->url(fn (Shipment $record) => ShipmentResource::labelUrl($record), shouldOpenInNewTab: true),
]); ]);
} }
} }
@@ -10,7 +10,9 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\URL;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsBatchLabels;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching; use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource\Pages\ListShipments; use Modules\Core\Shipping\Filament\Resources\ShipmentResource\Pages\ListShipments;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
@@ -21,12 +23,17 @@ use Throwable;
* ManagePickupManifests page — a bare Page has no access to Filament's * ManagePickupManifests page — a bare Page has no access to Filament's
* resource-level pill-tab UI (Filament\Resources\Concerns\HasTabs is * resource-level pill-tab UI (Filament\Resources\Concerns\HasTabs is
* scoped to ListRecords), so carrier-by-carrier separation * scoped to ListRecords), so carrier-by-carrier separation
* (ListShipments::getTabs(), one tab per SupportsManifestBatching * (ListShipments::getTabs()) needed a real Resource to attach to.
* implementer) needed a real Resource to attach to.
* *
* Shows only shipments NOT yet on an issued manifest — see * Shows only shipments created through a carrier's API that are NOT yet on
* Modules\Core\Shipping\Filament\Resources\ManifestResource for * an issued manifest — see Modules\Core\Shipping\Filament\Resources\
* shipments that already are. * 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 class ShipmentResource extends Resource
{ {
@@ -45,6 +52,8 @@ class ShipmentResource extends Resource
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
return parent::getEloquentQuery() return parent::getEloquentQuery()
->where('source', Shipment::SOURCE_CREATED)
->whereNotNull('order_id')
->whereNull('manifest_id') ->whereNull('manifest_id')
->whereNull('cancelled_at'); ->whereNull('cancelled_at');
} }
@@ -52,53 +61,132 @@ class ShipmentResource extends Resource
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
->defaultSort('created_at', 'desc')
->columns([ ->columns([
TextColumn::make('carrier')->badge(), TextColumn::make('carrier')
TextColumn::make('tracking_reference')->label('Tracking #'), ->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('order.reference')->label('Order'),
TextColumn::make('created_at')->label('Created')->dateTime(),
TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'), TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
]) ])
->recordActions([ ->recordActions([
Action::make('print') Action::make('print')
->label('Print') ->label('Print')
->icon('heroicon-o-printer') ->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([ ->toolbarActions([
BulkAction::make('print_selected') BulkAction::make('print_selected')
->label('Print selected') ->label('Print selected')
->icon('heroicon-o-printer') ->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') BulkAction::make('issue_manifest')
->label('Issue Manifest') ->label('Issue Manifest')
->icon('heroicon-o-check-circle') ->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)), ->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); $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() Notification::make()
->title("No fulfillment integration configured for {$shipment->carrier}.") ->title('Failed to cancel shipment: '.$e->getMessage())
->danger() ->danger()
->send(); ->send();
return; return;
} }
try { Notification::make()
$service->printLabel($shipment); ->title('Shipment cancelled.')
} catch (Throwable $e) { ->success()
report($e); ->send();
Notification::make()
->title("Failed to print label for {$shipment->tracking_reference}: {$e->getMessage()}")
->danger()
->send();
}
} }
public static function issueManifest(Collection $shipments): void public static function issueManifest(Collection $shipments): void
@@ -6,17 +6,17 @@ use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Tabs\Tab;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\IssuesVoucherOnPrint;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching; use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource; use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
/** /**
* One tab per carrier that actually implements SupportsManifestBatching * One tab per carrier with a step between creating a shipment and handing
* (ACS today) — a carrier with no manifest concept at all (Box Now, * it over: batching into a manifest (SupportsManifestBatching — ACS) or
* which books courier pickup at shipment-creation time, no separate * issuing the voucher when it's printed (IssuesVoucherOnPrint — ELTA). A
* batching step) never gets a tab here, since there is nothing to batch. * carrier with neither (Box Now books the pickup at creation) never gets a
* Adding a new carrier (e.g. Speedex) that also implements the contract * tab. The tab list is derived from Shipping::getSupportedDrivers(), so a
* needs zero changes to this page — the tab list is derived from * new carrier implementing either contract needs no change here.
* Shipping::getSupportedDrivers(), not hardcoded.
*/ */
class ListShipments extends ListRecords class ListShipments extends ListRecords
{ {
@@ -24,13 +24,14 @@ class ListShipments extends ListRecords
public function getTabs(): array public function getTabs(): array
{ {
$carriers = collect(Shipping::getSupportedDrivers()) return collect(Shipping::getSupportedDrivers())
->keys() ->filter(function ($driver, string $carrier) {
->filter(fn (string $carrier) => ShipmentResource::fulfillmentService($carrier) instanceof SupportsManifestBatching); $service = ShipmentResource::fulfillmentService($carrier);
return $carriers->mapWithKeys(fn (string $carrier) => [ return $service instanceof SupportsManifestBatching || $service instanceof IssuesVoucherOnPrint;
$carrier => Tab::make(ucwords(str_replace('-', ' ', $carrier))) })
->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)), ->map(fn ($driver, string $carrier) => Tab::make($driver->name())
])->all(); ->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)))
->all();
} }
} }
@@ -45,11 +45,21 @@ class DownloadShipmentLabelController extends Controller
$service = app(CarrierFulfillmentInterface::class, ['carrier' => $shipment->carrier]); $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); $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, [ return response($bytes, 200, [
'Content-Type' => 'application/pdf', 'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="shipment-'.$shipment->tracking_reference.'.pdf"', 'Content-Disposition' => 'inline; filename="shipment-'.$name.'.pdf"',
]); ]);
} }
} }
@@ -0,0 +1,48 @@
<?php
namespace Modules\Core\Shipping\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsBatchLabels;
use Modules\Core\Shipping\Models\Shipment;
/**
* The Pending Vouchers screen's "Print selected": several shipments' labels
* as one PDF. Same signed-URL auth as DownloadShipmentLabelController —
* the ids are part of the signed query string, so they can't be changed.
* All shipments must be one carrier's (the screen's tabs are per carrier)
* and that carrier must implement SupportsBatchLabels.
*/
class DownloadShipmentLabelsController extends Controller
{
public function __invoke(Request $request)
{
if (! $request->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"',
]);
}
}
+5 -34
View File
@@ -11,9 +11,8 @@ use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking; use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Models\ShipmentInfo; use Modules\Core\Shipping\Services\ShipmentTrackingRecorder;
use Throwable; use Throwable;
/** /**
@@ -52,6 +51,8 @@ class PollShipmentTrackingJob implements ShouldQueue
Shipment::query() Shipment::query()
->whereIn('carrier', $trackableCarriers) ->whereIn('carrier', $trackableCarriers)
->whereNull('cancelled_at') ->whereNull('cancelled_at')
// Pending vouchers (issued on print) have no number to track yet.
->whereNotNull('tracking_reference')
->whereDoesntHave('shipmentInfo', function ($query) { ->whereDoesntHave('shipmentInfo', function ($query) {
$query->whereIn('status', [ $query->whereIn('status', [
TrackingStatus::Delivered->value, TrackingStatus::Delivered->value,
@@ -68,49 +69,19 @@ class PollShipmentTrackingJob implements ShouldQueue
private function pollCarrierShipments(string $carrier, $shipments): void private function pollCarrierShipments(string $carrier, $shipments): void
{ {
$service = $this->fulfillmentService($carrier); if (! $this->fulfillmentService($carrier) instanceof SupportsTracking) {
if (! $service instanceof SupportsTracking) {
return; return;
} }
foreach ($shipments as $shipment) { foreach ($shipments as $shipment) {
try { try {
$this->recordNewCheckpoints($shipment, $service->trackShipment($shipment)); app(ShipmentTrackingRecorder::class)->refresh($shipment);
} catch (Throwable $e) { } catch (Throwable $e) {
report($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 private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
{ {
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]); return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
@@ -0,0 +1,34 @@
<?php
namespace Modules\Core\Shipping\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Modules\Core\Shipping\Services\CarrierVoucherSync;
/**
* Daily pull of every carrier's reported vouchers for the last $days days
* into the Carrier Vouchers screen (CarrierVoucherSync). Also run from
* that screen's "Sync now".
*/
class SyncCarrierVouchersJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 2;
public int $backoff = 300;
public function __construct(public readonly int $days = 14) {}
public function handle(CarrierVoucherSync $sync): void
{
$sync->syncAll(now()->subDays($this->days)->startOfDay(), now());
}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Shipping\Listeners;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Support\ShipmentTimelineLogger;
/**
* Every new checkpoint (carrier polling or a manual tracking update) goes
* onto its order's Timeline as it's recorded.
*/
class LogShipmentCheckpointOnOrderTimeline
{
public function __construct(private readonly ShipmentTimelineLogger $logger) {}
public function handle(ShipmentStatusUpdatedByCarrier $event): void
{
$this->logger->log($event->shipmentInfo);
}
}
+86
View File
@@ -7,9 +7,22 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Lunar\Models\Order; use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
class Shipment extends Model 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 $guarded = [];
protected $casts = [ protected $casts = [
@@ -37,4 +50,77 @@ class Shipment extends Model
{ {
return $this->shipmentInfo()->latest('occurred_at')->first(); 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);
}
} }
@@ -0,0 +1,224 @@
<?php
namespace Modules\Core\Shipping\Services;
use Carbon\CarbonInterface;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsVoucherListing;
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
use Modules\Core\Shipping\DTOs\CarrierVoucher;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Support\VoucherOrderMatcher;
use Throwable;
/**
* Brings the vouchers each carrier reports into `shipments` (source
* `synced`), for the Carrier Vouchers screen: returns, and vouchers that
* never made it onto an order.
*
* A voucher we already have (any source) is never duplicated: its history
* is brought up to date from the carrier's tracking, it's flagged when the
* carrier lists it as a return (`meta.reported_return`), and one that came
* from the carrier also takes the carrier's latest details. The order link
* and our own shipments' details are never changed.
*
* Nothing is ever linked to an order here: vouchers get suggested orders
* (VoucherOrderMatcher, the reference match first), and staff link them.
* An unlinked voucher's suggestions are refreshed on every sync.
*/
class CarrierVoucherSync
{
/** Checkpoints added for the voucher being recorded (sync stats). */
private int $checkpointsAdded = 0;
public function __construct(
private readonly VoucherOrderMatcher $matcher,
private readonly ShipmentTrackingRecorder $tracking,
) {}
/**
* One carrier failing (API down, not configured) doesn't stop the
* others; its entry carries the error instead.
*
* @return array<string, array{seen: int, created: int, suggested: int, updated: int, error?: string}>
*/
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]);
}
}
@@ -0,0 +1,80 @@
<?php
namespace Modules\Core\Shipping\Services;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Models\ShipmentInfo;
/**
* The one place carrier checkpoints become ShipmentInfo rows (the carrier
* info every screen reads): new checkpoints only — matched on status +
* time — each dispatching ShipmentStatusUpdatedByCarrier, so order status,
* emails and the order Timeline follow. Used by PollShipmentTrackingJob,
* the voucher sync and the "Refresh tracking" actions.
*/
class ShipmentTrackingRecorder
{
/**
* Fetches the shipment's history from its carrier and records what's
* new. Returns how many checkpoints were added (0 when the carrier has
* no tracking API).
*/
public function refresh(Shipment $shipment): int
{
$service = app(CarrierFulfillmentInterface::class, ['carrier' => $shipment->carrier]);
if (! $service instanceof SupportsTracking || blank($shipment->tracking_reference)) {
return 0;
}
return $this->record($shipment, $service->trackShipment($shipment));
}
/**
* @param iterable<TrackingCheckpoint> $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;
}
}
@@ -0,0 +1,60 @@
<?php
namespace Modules\Core\Shipping\Support;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Models\ShipmentInfo;
/**
* Writes a shipment's tracking checkpoints to its order's activity log, so
* they show on the order page's Timeline next to status changes and
* comments (rendered by ActivityLog\ShipmentCheckpointRender). One entry
* per checkpoint, logged as it's recorded; the checkpoint's own time is in
* the properties.
*/
class ShipmentTimelineLogger
{
public const EVENT = 'shipment-checkpoint';
public function log(ShipmentInfo $info): void
{
$shipment = $info->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)));
}
}
@@ -0,0 +1,129 @@
<?php
namespace Modules\Core\Shipping\Support;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Lunar\Models\Order;
use Modules\Core\Shipping\DTOs\CarrierVoucher;
use Modules\Core\Shipping\Models\Shipment;
/**
* Suggests the orders a carrier-reported voucher may belong to. It never
* links anything — staff confirm a suggestion with "Link to order".
*
* Best first:
* - the order the reference we sent the carrier points to: the order
* reference (ELTA pel_ref_no, ACS Reference_Key1), Box Now's
* "{reference}-{id}[-{attempt}]" orderNumber, or the bare order id older
* ELTA vouchers were sent;
* - for a return voucher that names its original voucher, that shipment's
* order;
* - then orders with the same postcode and the same phone or recipient
* name, among recent orders with no active shipment with this carrier.
*/
class VoucherOrderMatcher
{
private const SUGGESTION_WINDOW_DAYS = 60;
private const MAX_SUGGESTIONS = 5;
/**
* @return array<int, int> 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<int, int> 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();
}
}
+4
View File
@@ -2,6 +2,10 @@
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Modules\Core\Shipping\Http\Controllers\DownloadShipmentLabelController; 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) Route::get('shipments/{shipment}/label', DownloadShipmentLabelController::class)
->name('shipments.label'); ->name('shipments.label');