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.
+--}}
+
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;
+ }
}