From a8fe13e336598660bd71775f33bb4845bd9ad825 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Wed, 30 Sep 2026 19:47:38 +0300 Subject: [PATCH] Feat: Updates to fullfilment services, shipping views, timezones, and elta labels --- config/core.php | 14 ++ .../components/activity-log-feed.blade.php | 113 ++++++++++++++ .../order/infolists/transaction.blade.php | 2 +- .../carriers/elta/label-a4-child.blade.php | 120 +++++++++++++++ .../shipping/carriers/elta/label-a6.blade.php | 4 + src/Providers/CoreServiceProvider.php | 16 ++ .../ActivityLog/ShipmentCheckpointRender.php | 2 +- .../Carriers/Acs/AcsFulfillmentService.php | 4 +- .../BoxNow/BoxNowFulfillmentService.php | 1 + .../Carriers/Elta/EltaFulfillmentService.php | 145 ++++++++++++++++-- .../Carriers/Elta/EltaLabelRenderer.php | 134 +++++++++++++--- .../Extensions/OrderShipmentsExtension.php | 8 +- .../Extensions/OrderViewExtension.php | 4 + .../Resources/CarrierVoucherResource.php | 56 ++++++- .../Filament/Resources/ManifestResource.php | 13 ++ .../Filament/Resources/ShipmentResource.php | 26 +++- src/Shipping/Models/Shipment.php | 36 +++++ 17 files changed, 653 insertions(+), 45 deletions(-) create mode 100644 resources/views/lunarpanel/livewire/components/activity-log-feed.blade.php create mode 100644 resources/views/shipping/carriers/elta/label-a4-child.blade.php diff --git a/config/core.php b/config/core.php index 83ca001..107d627 100644 --- a/config/core.php +++ b/config/core.php @@ -16,6 +16,20 @@ return [ 'auto_create_customer_for_user' => true, + /* + |-------------------------------------------------------------------------- + | Display Timezone + |-------------------------------------------------------------------------- + | + | Timestamps are stored in the app timezone (UTC). This is the timezone + | they're shown in — the admin (Filament's display timezone), the + | storefront, emails and the order Timeline. (Carriers that report times + | without an offset — ELTA, ACS — are read as Greek time regardless.) + | + */ + + 'display_timezone' => env('DISPLAY_TIMEZONE', 'Europe/Athens'), + /* |-------------------------------------------------------------------------- | Privacy / GDPR data-subject requests diff --git a/resources/views/lunarpanel/livewire/components/activity-log-feed.blade.php b/resources/views/lunarpanel/livewire/components/activity-log-feed.blade.php new file mode 100644 index 0000000..db09c12 --- /dev/null +++ b/resources/views/lunarpanel/livewire/components/activity-log-feed.blade.php @@ -0,0 +1,113 @@ +{{-- + Copy of lunarphp/lunar's resources/views/livewire/components/activity-log-feed.blade.php + (the order page's Timeline), overriding it via View::prependNamespace('lunarpanel') + in CoreServiceProvider. The only changes: the day heading and each entry's time are + shown in core.display_timezone instead of UTC (Lunar formats them by hand, so + Filament's display timezone doesn't reach them). Re-copy it if Lunar changes the + original. Lunar still groups entries by UTC day, so an entry just after local + midnight can sit under the previous day's heading. +--}} +
+
+
+
+ +
+
+ +
+ + {{ $this->form }} + +
+ {{ $this->addCommentAction }} +
+
+
+ +
+ + +
+
    + @foreach ($this->activityLog as $log) +
  • +

    + {{ $log['items']->first()['log']->created_at->inDisplayTimezone()->format('F jS, Y') }} +

    + +
      + @foreach ($log['items'] as $item) + @php + $logUserName = $item['log']->causer ? ($item['log']->causer->fullName ?: $item['log']->causer->name) : null; + @endphp + +
    • +
      $item['log']->causer, + '-left-[calc(0.5rem_-_1px)]' => !$item['log']->causer, + ])> + @if ($email = $item['log']->causer?->email) + {{ $logUserName }} + @else + description){ + 'created' => 'bg-sky-500 ring-sky-100 dark:ring-sky-800', + 'updated' => 'bg-teal-500 ring-teal-100 dark:ring-teal-800', + 'status-update' => 'bg-purple-500 ring-purple-100 dark:ring-purple-800', + default => 'bg-gray-300 ring-gray-200 dark:ring-gray-600', + }, + ])> + + @endif +
      + +
      $item['log']->causer, + 'pt-[1px]' => !$item['log']->causer, + ])> +
      +
      + @if (!$item['log']->causer) + {{ __('lunarpanel::components.activity-log.system') }} + @else + {{ $logUserName }} + @endif +
      + + @if (count($item['renderers'])) +
      + @foreach ($item['renderers'] as $class) + {{ $class->render($item['log']) }} + @endforeach +
      + @endif +
      + + +
      +
    • + @endforeach +
    +
  • + @endforeach +
+
+
+ +
+ {{ $this->activityLog->links('lunarpanel::components.activity-log.timeline-paginator.index', data: ['scrollTo' => '#lunar-panel-timeline']) }} +
+
diff --git a/resources/views/order/infolists/transaction.blade.php b/resources/views/order/infolists/transaction.blade.php index 0131675..d631d48 100644 --- a/resources/views/order/infolists/transaction.blade.php +++ b/resources/views/order/infolists/transaction.blade.php @@ -77,7 +77,7 @@ class="w-4" /> - {{ $transaction->created_at->format('jS F Y h:ia') }} + {{ $transaction->created_at->inDisplayTimezone()->format('jS F Y h:ia') }}
diff --git a/resources/views/shipping/carriers/elta/label-a4-child.blade.php b/resources/views/shipping/carriers/elta/label-a4-child.blade.php new file mode 100644 index 0000000..b3f4159 --- /dev/null +++ b/resources/views/shipping/carriers/elta/label-a4-child.blade.php @@ -0,0 +1,120 @@ + + + + + + + +
+ {{-- Image22 (logo) + image4 (ELTA details bitmap, reproduced as text) --}} + +
+
ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε.
+
Απελλού 1, 105 51, Αθήνα
+
Τ. 210-6073000 | www.eltacourier.gr
+
+
ΕΕΤΤ ΑΜ 99-150 Γενική Αδεια Ταχ/κων Υπηρεσιών
+ + {{-- rectangle17: title, barcode, this parcel's voucher --}} +
+
ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ
+
+ +
+
{{ $voucher_no }}
+
+ +
Ημερομηνία - Ωρα Εκτύπωσης :
+
{{ $date }} {{ $time }}
+ + {{-- rectangle18-21: deposit station, weight, volumetric, pieces --}} +
+
Γρ.Κατάθεσης
+
{{ $station_apo }}
+
+
+
Βάρος
+
{{ $weight }}
+
+
+
Ογκ/κο Βάρος
+
{{ $volumetric_weight }}
+
+
+
Τεμάχια
+
{{ $package_label }}
+
+ + {{-- rectangle1: service --}} +
+
ΥΠΗΡΕΣΙΑ :
+
{{ $service_code }}
+
{{ $service_name }}
+
+ + {{-- rectangle30: destination station --}} +
+
Γρ.Προορισμού
+
{{ $station_pros }}
+
{{ $station_pros_title }}
+
+ + {{-- rectangle52: multi-parcel panel — piece, COD warning, master voucher --}} +
+
** ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ **
+
{{ $piece_label }}
+ @if ($antik_1) +
{{ $antik_1 }}
+ @endif +
ΜASTER ΣΥΔΕΤΑ
+
{{ $copy }}
+
+ + {{-- rectangle24: sender (sender_1..5) --}} +
+
ΑΠΟΣΤΟΛΕΑΣ
+ @foreach ($sender_lines as $i => $line) +
{{ $line }}
+ @endforeach +
+ + {{-- rectangle27: recipient (rec_1..5) --}} +
+
ΠΑΡΑΛΗΠΤΗΣ
+ @foreach ($recipient_lines as $i => $line) +
{{ $line }}
+ @endforeach +
+
+ + diff --git a/resources/views/shipping/carriers/elta/label-a6.blade.php b/resources/views/shipping/carriers/elta/label-a6.blade.php index 547c736..6e84796 100644 --- a/resources/views/shipping/carriers/elta/label-a6.blade.php +++ b/resources/views/shipping/carriers/elta/label-a6.blade.php @@ -89,6 +89,10 @@
{{ $date }}
ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ
+ {{-- textbox15 (copy): "ΠΟΛΛΑΠΛH" on the extra parcels of a multi-parcel send --}} + @if ($copy) +
{{ $copy }}
+ @endif {{-- The RDLC's own barcode textbox here uses the "Free 3 of 9 Extended" barcode font — we don't have that font, so this would-be *{voucher}* fallback text is dropped in favor of the diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index e1c2610..2715ad1 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -2,7 +2,10 @@ namespace Modules\Core\Providers; +use Carbon\Carbon; +use Filament\Support\Facades\FilamentTimezone; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; use Modules\Core\Command\AnonymizeCommand; use Modules\Core\Command\BackfillMissingSkusCommand; @@ -26,6 +29,19 @@ class CoreServiceProvider extends ServiceProvider { $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core'); Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core'); + + // Stored in UTC, shown in the shop's timezone: Filament's display + // timezone covers every admin date column, entry and picker (pickers + // also convert back to UTC on save); inDisplayTimezone() is for + // dates formatted by hand (storefront, emails, custom labels). + $displayTimezone = config('core.display_timezone', config('app.timezone')); + FilamentTimezone::set($displayTimezone); + Carbon::macro('inDisplayTimezone', fn () => $this->copy()->setTimezone(config('core.display_timezone', config('app.timezone')))); + + // Lunar's order Timeline formats its times by hand (in UTC); this + // overrides just that one view with a copy that converts them — + // see resources/views/lunarpanel/. + View::prependNamespace('lunarpanel', __DIR__ . '/../../resources/views/lunarpanel'); $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); $this->loadTranslationsFrom(__DIR__ . '/../../lang', 'core'); diff --git a/src/Shipping/ActivityLog/ShipmentCheckpointRender.php b/src/Shipping/ActivityLog/ShipmentCheckpointRender.php index 9a54b19..0bccfd5 100644 --- a/src/Shipping/ActivityLog/ShipmentCheckpointRender.php +++ b/src/Shipping/ActivityLog/ShipmentCheckpointRender.php @@ -39,7 +39,7 @@ class ShipmentCheckpointRender extends AbstractRender $log->getExtraProperty('location'), $log->getExtraProperty('message') ?: $log->getExtraProperty('carrier_status'), ])->filter()->unique()->implode(' · '), - 'occurredAt' => $occurredAt ? Carbon::parse($occurredAt)->timezone(config('app.timezone')) : null, + 'occurredAt' => $occurredAt ? Carbon::parse($occurredAt)->inDisplayTimezone() : null, ]); } } diff --git a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php index 8f1e4e0..4a66325 100644 --- a/src/Shipping/Carriers/Acs/AcsFulfillmentService.php +++ b/src/Shipping/Carriers/Acs/AcsFulfillmentService.php @@ -108,6 +108,7 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsExtr 'tracking_reference' => $voucherNo, 'meta' => [ 'station_destination' => $destination->stationId, + 'reference' => (string) $order->reference, 'weight' => $weight, 'pickup_date' => now()->toDateString(), 'cod_amount' => $codAmount, @@ -210,7 +211,8 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsExtr carrierStatus: $row['checkpoint_action'] ?? null, message: $row['checkpoint_action'] ?? null, location: $row['checkpoint_location'] ?? null, - occurredAt: Carbon::parse($row['checkpoint_date_time']), + // ACS reports Greek time without an offset. + occurredAt: Carbon::parse($row['checkpoint_date_time'], 'Europe/Athens')->setTimezone(config('app.timezone')), meta: $row, ); }); diff --git a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php index c269398..3812f40 100644 --- a/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php +++ b/src/Shipping/Carriers/BoxNow/BoxNowFulfillmentService.php @@ -119,6 +119,7 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT 'tracking_reference' => (string) $parcel['id'], 'meta' => [ 'delivery_request_id' => $response['id'] ?? null, + 'reference' => $orderNumber, 'locker_id' => $destinationLocationId, 'cod_amount' => $index === 0 ? $codAmount : null, ], diff --git a/src/Shipping/Carriers/Elta/EltaFulfillmentService.php b/src/Shipping/Carriers/Elta/EltaFulfillmentService.php index 41dd15e..505956e 100644 --- a/src/Shipping/Carriers/Elta/EltaFulfillmentService.php +++ b/src/Shipping/Carriers/Elta/EltaFulfillmentService.php @@ -116,43 +116,100 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch $this->client->createVoucher($params)->throwIfError(); + $pieces = max(1, $request->packageCount); + // PELVGNEW doesn't return the new voucher's id, so find it in the // pending list by our reference. Saved even if that lookup fails — // the voucher exists at ELTA either way, and printing retries it. - return Shipment::create([ + $master = Shipment::create([ 'order_id' => $order->id, 'carrier' => 'elta', 'source' => Shipment::SOURCE_CREATED, 'tracking_reference' => null, 'meta' => [ 'carrier_id' => $this->findPendingVoucherId((string) $order->reference), + 'reference' => (string) $order->reference, 'weight' => $weight, - 'package_count' => $request->packageCount, + 'package_count' => $pieces, 'cod_amount' => $codAmount, 'services' => $request->serviceValues(), 'insurance_amount' => $request->has(ExtraService::Insurance) ? $request->insuranceAmount : null, + ...($pieces > 1 ? ['piece' => 1, 'pieces' => $pieces] : []), ], ]); + + // One row per extra parcel straight away, so the order shows every + // parcel; ELTA gives them their own voucher numbers only when the + // main voucher is issued (issue()). They belong to the main voucher: + // no COD or services of their own. + for ($piece = 2; $piece <= $pieces; $piece++) { + Shipment::create([ + 'order_id' => $order->id, + 'carrier' => 'elta', + 'source' => Shipment::SOURCE_CREATED, + 'tracking_reference' => null, + 'meta' => [ + 'master_shipment_id' => $master->id, + 'reference' => (string) $order->reference, + 'piece' => $piece, + 'pieces' => $pieces, + 'weight' => $weight, + 'package_count' => $pieces, + ], + ]); + } + + return $master; } + /** + * Like ELTA's own client: printing the main voucher of a multi-parcel + * send prints every parcel (main first, then each child); printing a + * child prints just that parcel. Either way the main voucher is issued + * first — that's what gives the children their numbers. + */ public function printLabel(Shipment $shipment): string { - $this->issue($shipment); + $master = $this->masterOf($shipment); - $pdf = $this->labelRenderer->render($shipment->refresh()); + $this->issue($master); - $shipment->update(['label_printed_at' => now()]); + $labels = $shipment->is($master) + ? collect([$master->refresh(), ...$this->childrenOf($master)]) + : collect([$shipment->refresh()]); + + $pdf = $this->labelRenderer->renderMany($labels); + + Shipment::whereIn('id', $labels->pluck('id'))->update(['label_printed_at' => now()]); return $pdf; } + /** + * Several labels in one PDF. A selected main voucher brings its + * children along (as printLabel() does); each parcel is printed once, + * main voucher before its children. + */ public function printLabels(Collection $shipments): string { - $shipments->each(fn (Shipment $shipment) => $this->issue($shipment)); + $labels = collect(); - $pdf = $this->labelRenderer->renderMany($shipments->map->refresh()); + foreach ($shipments as $shipment) { + $master = $this->masterOf($shipment); + $this->issue($master); - Shipment::whereIn('id', $shipments->pluck('id'))->update(['label_printed_at' => now()]); + $group = $shipment->is($master) + ? [$master->refresh(), ...$this->childrenOf($master)] + : [$shipment->refresh()]; + + foreach ($group as $label) { + $labels->put($label->id, $label); + } + } + + $pdf = $this->labelRenderer->renderMany($labels->values()); + + Shipment::whereIn('id', $labels->keys())->update(['label_printed_at' => now()]); return $pdf; } @@ -163,6 +220,16 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch */ public function cancelShipment(Shipment $shipment): void { + $master = $this->masterOf($shipment); + + if (! $shipment->is($master)) { + $piece = $shipment->meta['piece'] ?? null; + + throw new EltaApiException( + 'This is '.($piece ? "parcel {$piece}" : 'an extra parcel').' of a multi-parcel ELTA voucher — ELTA cancels the whole voucher, so cancel the main parcel instead.', + ); + } + if (filled($shipment->tracking_reference)) { throw new EltaApiException( "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.", @@ -172,6 +239,41 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch $this->client->deleteVoucher(['pel_vg_id' => $this->pendingVoucherId($shipment)])->throwIfError(); $shipment->update(['cancelled_at' => now()]); + $this->childrenOf($shipment)->each->update(['cancelled_at' => now()]); + } + + /** + * The main voucher's shipment for a child parcel, or the shipment + * itself. Children created by createShipment() point at it via + * meta.master_shipment_id; older ones only via parent_reference. + */ + private function masterOf(Shipment $shipment): Shipment + { + if ($id = $shipment->meta['master_shipment_id'] ?? null) { + return Shipment::find($id) ?? $shipment; + } + + if (filled($shipment->parent_reference)) { + return Shipment::where('tracking_reference', $shipment->parent_reference)->first() ?? $shipment; + } + + return $shipment; + } + + /** + * @return Collection the main voucher's child parcels, in piece order + */ + private function childrenOf(Shipment $master): Collection + { + return Shipment::query() + ->where('carrier', 'elta') + ->where(fn ($query) => $query + ->where('meta->master_shipment_id', $master->id) + ->when($master->tracking_reference, fn ($q) => $q->orWhere('parent_reference', $master->tracking_reference))) + ->orderBy('id') + ->get() + ->sortBy(fn (Shipment $child) => $child->meta['piece'] ?? PHP_INT_MAX) + ->values(); } public function trackShipment(Shipment $shipment): Collection @@ -312,16 +414,31 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch ]); $shipment->save(); - foreach (array_filter(array_map('trim', (array) ($data['vg_child_no'] ?? []))) as $childVoucherNo) { - Shipment::create([ + // The children's numbers, in ELTA's order (vg_child_no), go to the + // pending child rows in piece order; any parcel without a row (older + // shipments) gets one. + $pending = $this->childrenOf($shipment)->filter(fn (Shipment $child) => blank($child->tracking_reference))->values(); + $pieces = (int) ($shipment->meta['package_count'] ?? 1); + $childNumbers = array_values(array_filter(array_map('trim', (array) ($data['vg_child_no'] ?? [])))); + + foreach ($childNumbers as $i => $childVoucherNo) { + $child = $pending->get($i) ?? new Shipment([ '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(), + 'meta' => [ + 'master_shipment_id' => $shipment->id, + 'reference' => $shipment->meta['reference'] ?? null, + 'piece' => $i + 2, + 'pieces' => max($pieces, count($childNumbers) + 1), + 'weight' => $shipment->meta['weight'] ?? null, + 'package_count' => max($pieces, count($childNumbers) + 1), + ], ]); + + $child->tracking_reference = $childVoucherNo; + $child->parent_reference = $voucherNo; + $child->save(); } } diff --git a/src/Shipping/Carriers/Elta/EltaLabelRenderer.php b/src/Shipping/Carriers/Elta/EltaLabelRenderer.php index 185229d..dd7159b 100644 --- a/src/Shipping/Carriers/Elta/EltaLabelRenderer.php +++ b/src/Shipping/Carriers/Elta/EltaLabelRenderer.php @@ -59,22 +59,37 @@ class EltaLabelRenderer 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; + $styles = []; $pages = []; foreach ($shipments as $shipment) { - $html = view($view, $this->data($shipment))->render(); + $data = $this->data($shipment); - $head ??= Str::before($html, '. + $view = match (true) { + $paperSize === 'a6' => 'core::shipping.carriers.elta.label-a6', + $data['isChild'] => 'core::shipping.carriers.elta.label-a4-child', + default => 'core::shipping.carriers.elta.label-a4', + }; + + $html = view($view, $data)->render(); + $viewHead = Str::before($html, '.*?#s', $viewHead, $matches); + $styles[$view] = implode('', $matches[0]); $pages[] = Str::beforeLast(Str::after(Str::after($html, ''), ''); } - $html = $head + $html = Str::before($head, '' + .Str::afterLast($head, '') .''.implode('', $pages).''; $pdf = Pdf::loadHTML($html); @@ -100,7 +115,16 @@ class EltaLabelRenderer { $order = $shipment->order; $address = $order->shippingAddress; - $meta = $shipment->meta?->toArray() ?? []; + $paperSize = config('elta.label_paper_size', 'a4'); + $piece = $this->pieceOf($shipment); + $master = $piece['master'] ?? null; + + // A child parcel's label details (stations, service, issue time, + // weight) are the main voucher's. + $meta = array_merge( + $master?->meta?->toArray() ?? [], + array_filter($shipment->meta?->toArray() ?? [], fn ($value) => $value !== null), + ); // Issuing a pending voucher (PELVG01NEW1) returns the real // destination station and service; vouchers issued before that flow @@ -109,18 +133,24 @@ class EltaLabelRenderer ? ['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'] ?? []); + $isChild = $piece !== null && $piece['number'] > 1; + $masterVoucher = $isChild ? ($master?->tracking_reference ?? $shipment->parent_reference) : null; + + $services = $isChild ? [] : (array) ($meta['services'] ?? []); $surcharges = $this->surchargeSlots($services); - $cod = $this->codFields(isset($meta['cod_amount']) ? (float) $meta['cod_amount'] : null); + $cod = $isChild + ? $this->childCodFields(($master?->meta['cod_amount'] ?? null) > 0) + : $this->codFields(isset($meta['cod_amount']) ? (float) $meta['cod_amount'] : null); $dateTime = (string) ($meta['date_time'] ?? ''); $occurredAt = strlen($dateTime) >= 12 ? Carbon::createFromFormat('YmdHi', substr($dateTime, 0, 12)) - : now(); + : now()->inDisplayTimezone(); $voucherNo = $shipment->tracking_reference; - $ocrLine = (string) ($meta['ocr_line'] ?? ''); - $packageCount = (int) ($meta['package_count'] ?? 1); + $ocrLine = $isChild ? '' : (string) ($meta['ocr_line'] ?? ''); + $packageCount = (int) ($piece['of'] ?? $meta['package_count'] ?? 1); + $pieceLabel = sprintf('%03d/%03d', $piece['number'] ?? 1, $packageCount); $generator = new BarcodeGeneratorPNG(); @@ -167,11 +197,25 @@ class EltaLabelRenderer 'weight' => $meta['weight'] ?? null, 'package_count' => $packageCount, + // Τεμάχια: "001/002" on the main voucher, "002/002" on the second + // parcel (print_vg() / print_child()). The A4 child report shows + // the piece in its own big box (periexomeno) and just the total + // count under Τεμάχια. 'package_label' => $packageCount > 1 - ? sprintf('001/%03d', $packageCount) + ? ($isChild && $paperSize !== 'a6' ? (string) $packageCount : $pieceLabel) : '001', + 'piece_label' => $pieceLabel, 'multiPiece' => $packageCount > 1, - 'polaplo' => $packageCount > 1 ? '* ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ *' : null, + 'polaplo' => match (true) { + $isChild => 'ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ - MASTER : '.$masterVoucher, + $packageCount > 1 => '* ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ *', + default => null, + }, + 'isChild' => $isChild, + 'master_voucher' => $masterVoucher, + // The "copy" field: "ΠΟΛΛΑΠΛH" on an A6 child, the master's + // voucher (*NZ…GR*) under "ΜASTER ΣΥΔΕΤΑ" on an A4 child. + 'copy' => $isChild ? ($paperSize === 'a6' ? 'ΠΟΛΛΑΠΛH' : '*'.$masterVoucher.'*') : null, 'station_apo' => config('elta.origin_station_code'), 'station_pros' => $station['code'], @@ -180,8 +224,9 @@ class EltaLabelRenderer 'service_code' => $meta['rec_srv'] ?? '', 'service_name' => $meta['rec_srv_t'] ?? '', - 'xreosi' => 'ΧΡΕΩΣΗ ΑΠΟΣΤΟΛΕΑ ΠΙΣΤΩΣΗ', - 'siimvasi' => '131775-9', + // print_child() doesn't set these, so a child's cells stay empty. + 'xreosi' => $isChild ? null : 'ΧΡΕΩΣΗ ΑΠΟΣΤΟΛΕΑ ΠΙΣΤΩΣΗ', + 'siimvasi' => $isChild ? null : '131775-9', 'cod_amount' => $meta['cod_amount'] ?? null, 'ocr_line' => $ocrLine, @@ -189,11 +234,11 @@ class EltaLabelRenderer // from the OCR line, not the barcode's own voucher text — // confirmed against the real sample: ">14260009814363<..." // 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) : null, // The reference we send ELTA as pel_ref_no (order reference; // older vouchers were sent the order id). - 'order_reference' => (string) $order->reference, + 'order_reference' => $isChild ? null : (string) $order->reference, // sydetaE.rdlc's "Πρόσθετες Υπηρεσίες" (sur_1..4): the ticked // extra services, in ELTA's own wording and slots. @@ -282,6 +327,59 @@ class EltaLabelRenderer ]; } + /** + * A child parcel's COD text (print_child()): only the warning, never the + * amount — the cash is collected against the main voucher. + * + * @return array + */ + private function childCodFields(bool $cod): array + { + $warning = $cod ? '* ΠΡΟΣΟΧΗ ΑΝΤΙΚΑΤΑΒΟΛΗ *' : null; + + return [ + 'antik_1' => $warning, 'antik_2' => null, 'antik_3' => null, + 'antik_4' => null, 'antik_5' => null, 'antik_6' => null, 'antik_7' => null, + 'antik_lines' => array_filter([$warning]), + 'antik_minima' => $warning, + 'apodiksi' => null, + 'antik_poso' => '0.00', + ]; + } + + /** + * Which parcel of a multi-parcel send this is: ['number' => k, 'of' => + * n, 'master' => main voucher's Shipment|null], or null for a single + * parcel. Parcels created by createShipment() carry it in meta; + * children issued before that (only parent_reference set) are numbered + * by their order among their siblings, after the main voucher. + * + * @return array{number: int, of: int, master: ?Shipment}|null + */ + private function pieceOf(Shipment $shipment): ?array + { + $meta = $shipment->meta?->toArray() ?? []; + + if (isset($meta['piece'], $meta['pieces'])) { + $master = isset($meta['master_shipment_id']) ? Shipment::find($meta['master_shipment_id']) : null; + + return ['number' => (int) $meta['piece'], 'of' => (int) $meta['pieces'], 'master' => $master]; + } + + if (blank($shipment->parent_reference)) { + return null; + } + + $master = Shipment::where('tracking_reference', $shipment->parent_reference)->first(); + $siblings = Shipment::where('parent_reference', $shipment->parent_reference)->orderBy('id')->pluck('id'); + + return [ + 'number' => $siblings->search($shipment->id) + 2, + 'of' => (int) ($master?->meta['package_count'] ?? $siblings->count() + 1), + 'master' => $master, + ]; + } + /** * PNG over SVG — dompdf's inline support doesn't reliably * render the barcode generator's -based bars (confirmed: the diff --git a/src/Shipping/Extensions/OrderShipmentsExtension.php b/src/Shipping/Extensions/OrderShipmentsExtension.php index 328edde..46af6f5 100644 --- a/src/Shipping/Extensions/OrderShipmentsExtension.php +++ b/src/Shipping/Extensions/OrderShipmentsExtension.php @@ -170,7 +170,7 @@ class OrderShipmentsExtension extends ViewPageExtension ->hidden(fn (Shipment $record) => $record->shipmentInfo->isEmpty()) ->schema([ TextEntry::make('status') - ->label(fn (ShipmentInfo $record) => $record->occurred_at->format('Y-m-d H:i')) + ->label(fn (ShipmentInfo $record) => $record->occurred_at->inDisplayTimezone()->format('Y-m-d H:i')) ->inlineLabel() ->state(fn (ShipmentInfo $record) => (string) str($record->status->value)->replace('_', ' ')->title()) // Our status as the label, the carrier's own wording underneath. @@ -223,7 +223,11 @@ class OrderShipmentsExtension extends ViewPageExtension private function helperText(Shipment $record): string { - $parts = ['Created '.$record->created_at->format('Y-m-d H:i')]; + $parts = ['Created '.$record->created_at->inDisplayTimezone()->format('Y-m-d H:i')]; + + if ($parcel = $record->parcelDescription()) { + $parts[] = $parcel; + } if (BoxNowShipping::isDriver($record->carrier) && $locker = $record->meta['locker_id'] ?? null) { $parts[] = 'Locker '.$locker; diff --git a/src/Shipping/Extensions/OrderViewExtension.php b/src/Shipping/Extensions/OrderViewExtension.php index 1ce4451..c95cf84 100644 --- a/src/Shipping/Extensions/OrderViewExtension.php +++ b/src/Shipping/Extensions/OrderViewExtension.php @@ -224,6 +224,8 @@ class OrderViewExtension extends ViewPageExtension ); $this->notify($result); + // The page still holds the shipments loaded before this action. + $record->load('shipments.shipmentInfo'); if (! $result->success) { $action->halt(); @@ -332,6 +334,8 @@ class OrderViewExtension extends ViewPageExtension ); $this->notify($result); + // The page still holds the shipments loaded before this action. + $record->load('shipments.shipmentInfo'); if (! $result->success) { $action->halt(); diff --git a/src/Shipping/Filament/Resources/CarrierVoucherResource.php b/src/Shipping/Filament/Resources/CarrierVoucherResource.php index b61ff79..6ee42e5 100644 --- a/src/Shipping/Filament/Resources/CarrierVoucherResource.php +++ b/src/Shipping/Filament/Resources/CarrierVoucherResource.php @@ -24,6 +24,7 @@ use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource\Pages\ListCa 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\BoxNowShipping; use Modules\Core\Shipping\Support\ShipmentTimelineLogger; /** @@ -61,6 +62,18 @@ class CarrierVoucherResource extends Resource Shipment::SOURCE_SYNCED => 'From carrier', ]; + /** + * One color per source, the same on the list and the view page: + * ours (green), typed in by hand (orange), a manual carrier (grey), + * reported by the carrier (blue). + */ + private const SOURCE_COLORS = [ + Shipment::SOURCE_CREATED => 'success', + Shipment::SOURCE_MANUAL_VOUCHER => 'warning', + Shipment::SOURCE_MANUAL => 'gray', + Shipment::SOURCE_SYNCED => 'info', + ]; + public static function getEloquentQuery(): Builder { return parent::getEloquentQuery() @@ -75,7 +88,13 @@ class CarrierVoucherResource extends Resource ->columns([ TextColumn::make('tracking_reference') ->label('Tracking number') + ->description(fn (Shipment $record) => $record->parcelDescription()) ->copyable(), + TextColumn::make('source') + ->label('Source') + ->badge() + ->formatStateUsing(fn (string $state) => self::SOURCES[$state] ?? $state) + ->color(fn (string $state) => self::SOURCE_COLORS[$state] ?? 'gray'), TextColumn::make('status') ->label('Status') ->state(fn (Shipment $record) => self::status($record)) @@ -124,10 +143,14 @@ class CarrierVoucherResource extends Resource ->schema([ TextEntry::make('carrier') ->formatStateUsing(fn (Shipment $record) => $record->carrierLabel()), - TextEntry::make('tracking_reference')->label('Voucher')->copyable(), + TextEntry::make('tracking_reference') + ->label('Voucher') + ->helperText(fn (Shipment $record) => $record->parcelDescription()) + ->copyable(), TextEntry::make('source') ->badge() - ->formatStateUsing(fn (string $state) => self::SOURCES[$state] ?? $state), + ->formatStateUsing(fn (string $state) => self::SOURCES[$state] ?? $state) + ->color(fn (string $state) => self::SOURCE_COLORS[$state] ?? 'gray'), TextEntry::make('status') ->state(fn (Shipment $record) => self::status($record)) ->placeholder('No updates yet') @@ -137,7 +160,10 @@ class CarrierVoucherResource extends Resource ->label('Carrier status') ->state(fn (Shipment $record) => self::carrierStatus($record)) ->placeholder('—'), - TextEntry::make('meta.reference')->label('Reference sent to the carrier')->placeholder('—'), + TextEntry::make('sent_reference') + ->label('Reference sent to the carrier') + ->state(fn (Shipment $record) => self::sentReference($record)) + ->placeholder('—'), TextEntry::make('meta.voucher_date')->label('Voucher date')->date()->placeholder('—'), TextEntry::make('cod') ->label('Cash on delivery') @@ -206,7 +232,7 @@ class CarrierVoucherResource extends Resource ->visible(fn (Shipment $record) => $record->shipmentInfo->isNotEmpty()) ->schema([ TextEntry::make('status') - ->label(fn (ShipmentInfo $record) => $record->occurred_at->format('Y-m-d H:i')) + ->label(fn (ShipmentInfo $record) => $record->occurred_at->inDisplayTimezone()->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), @@ -280,6 +306,28 @@ class CarrierVoucherResource extends Resource ])->filter()->implode(' · ')])->all(); } + /** + * The reference the carrier has for this voucher: saved on the shipment + * (reported by the carrier, or sent by us since 0.29.2). For our own + * older vouchers it's what we sent them: the order reference, or + * "{reference}-{id}" for Box Now. Manual vouchers and manual carriers + * never sent one. + */ + private static function sentReference(Shipment $record): ?string + { + if (filled($record->meta['reference'] ?? null)) { + return $record->meta['reference']; + } + + if ($record->source !== Shipment::SOURCE_CREATED || ! $record->order) { + return null; + } + + return BoxNowShipping::isDriver($record->carrier) + ? $record->order->reference.'-'.$record->order->id + : $record->order->reference; + } + private static function phone(Shipment $record): ?string { return $record->meta['phone'] ?? $record->order?->shippingAddress?->contact_phone; diff --git a/src/Shipping/Filament/Resources/ManifestResource.php b/src/Shipping/Filament/Resources/ManifestResource.php index 91f18dc..a4c9f2d 100644 --- a/src/Shipping/Filament/Resources/ManifestResource.php +++ b/src/Shipping/Filament/Resources/ManifestResource.php @@ -6,6 +6,8 @@ use Filament\Actions\ViewAction; use Filament\Resources\Resource; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Lunar\Shipping\Facades\Shipping; +use Modules\Core\Shipping\Contracts\SupportsManifestBatching; use Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages\ListManifests; use Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages\ViewManifest; use Modules\Core\Shipping\Filament\Resources\ManifestResource\RelationManagers\ShipmentsRelationManager; @@ -30,6 +32,17 @@ class ManifestResource extends Resource protected static ?string $navigationLabel = 'Issued Manifests'; + /** + * Only carriers that batch into manifests (ACS) produce any — with none + * registered the menu entry would lead to an empty screen, so it's + * hidden (the page itself, and any past manifests, stay reachable). + */ + public static function shouldRegisterNavigation(): bool + { + return collect(Shipping::getSupportedDrivers())->keys() + ->contains(fn (string $carrier) => ShipmentResource::fulfillmentService($carrier) instanceof SupportsManifestBatching); + } + protected static ?string $modelLabel = 'Manifest'; protected static ?int $navigationSort = 101; diff --git a/src/Shipping/Filament/Resources/ShipmentResource.php b/src/Shipping/Filament/Resources/ShipmentResource.php index bd67b48..f5124c0 100644 --- a/src/Shipping/Filament/Resources/ShipmentResource.php +++ b/src/Shipping/Filament/Resources/ShipmentResource.php @@ -11,7 +11,9 @@ use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\URL; +use Lunar\Shipping\Facades\Shipping; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; +use Modules\Core\Shipping\Contracts\IssuesVoucherOnPrint; use Modules\Core\Shipping\Contracts\SupportsBatchLabels; use Modules\Core\Shipping\Contracts\SupportsManifestBatching; use Modules\Core\Shipping\Filament\Resources\ShipmentResource\Pages\ListShipments; @@ -25,9 +27,10 @@ use Throwable; * scoped to ListRecords), so carrier-by-carrier separation * (ListShipments::getTabs()) needed a real Resource to attach to. * - * Shows only shipments created through a carrier's API that are NOT yet on - * an issued manifest — see Modules\Core\Shipping\Filament\Resources\ - * ManifestResource for shipments that already are. Manual carriers, manual + * Shows only shipments created through a carrier's API that still need + * their hand-over step: not yet on an issued manifest (see Modules\Core\ + * Shipping\Filament\Resources\ManifestResource for those that are), or + * for carriers that issue vouchers on print, not yet printed. Manual carriers, manual * vouchers and carrier-reported (synced) vouchers have no label to print * or manifest to join, so they never show here. * @@ -49,13 +52,27 @@ class ShipmentResource extends Resource protected static ?int $navigationSort = 100; + /** + * A voucher leaves this queue once its carrier's hand-over step is + * done: on a manifest for carriers that batch (ACS), printed for + * carriers that issue the voucher on print (ELTA — every parcel of a + * multi-parcel send is printed together, so they leave together). + */ public static function getEloquentQuery(): Builder { + $issuedOnPrint = collect(Shipping::getSupportedDrivers())->keys() + ->filter(fn (string $carrier) => self::fulfillmentService($carrier) instanceof IssuesVoucherOnPrint) + ->values() + ->all(); + return parent::getEloquentQuery() ->where('source', Shipment::SOURCE_CREATED) ->whereNotNull('order_id') ->whereNull('manifest_id') - ->whereNull('cancelled_at'); + ->whereNull('cancelled_at') + ->where(fn (Builder $query) => $query + ->whereNotIn('carrier', $issuedOnPrint) + ->orWhereNull('label_printed_at')); } public static function table(Table $table): Table @@ -69,6 +86,7 @@ class ShipmentResource extends Resource TextColumn::make('tracking_reference') ->label('Tracking #') ->placeholder('Not issued yet') + ->description(fn (Shipment $record) => $record->parcelDescription()) ->copyable(), TextColumn::make('order.reference')->label('Order'), TextColumn::make('created_at')->label('Created')->dateTime(), diff --git a/src/Shipping/Models/Shipment.php b/src/Shipping/Models/Shipment.php index d320eef..ba63f03 100644 --- a/src/Shipping/Models/Shipment.php +++ b/src/Shipping/Models/Shipment.php @@ -123,4 +123,40 @@ class Shipment extends Model { return in_array($this->source, [self::SOURCE_MANUAL, self::SOURCE_MANUAL_VOUCHER, self::SOURCE_SYNCED], true); } + + /** + * This parcel's place in a multi-parcel send, for display — e.g. + * "Main voucher · parcel 1 of 3" or "Parcel 2 of 3 · of NZ000987399GR". + * Null for a single-parcel shipment. + * + * Read from what's already stored: the piece numbers in meta (sends + * created as multi-parcel), else parent_reference (the main voucher's + * number, set on issued extra parcels — also ACS multi-part vouchers). + */ + public function parcelDescription(): ?string + { + $piece = $this->meta['piece'] ?? null; + $pieces = $this->meta['pieces'] ?? null; + + if ($piece && $pieces > 1) { + if ((int) $piece === 1) { + return "Main voucher · parcel 1 of {$pieces}"; + } + + $main = $this->parent_reference ?? 'the main voucher (not issued yet)'; + + return "Parcel {$piece} of {$pieces} · of {$main}"; + } + + if (filled($this->parent_reference)) { + return "Extra parcel · of {$this->parent_reference}"; + } + + if (filled($this->tracking_reference) + && static::where('parent_reference', $this->tracking_reference)->exists()) { + return 'Main voucher'; + } + + return null; + } }