Compare commits

...
26 changed files with 1031 additions and 56 deletions
+87
View File
@@ -4,6 +4,93 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.29.2] - 2026-09-30
### Added
- `core.display_timezone` (env `DISPLAY_TIMEZONE`, default `Europe/Athens`):
the timezone times are shown in, while they stay stored in the app
timezone (UTC). Set as Filament's display timezone, so every admin date
column, entry and picker shows local time (and pickers save UTC), and
available on dates as `->inDisplayTimezone()` for hand-formatted ones —
storefront pages and emails should use it (3dealer's My orders, tracking
timeline and order-placed email do).
### Fixed
- Every time in the admin was shown in UTC — 3 hours behind Greek time in
summer, 2 in winter — including Carrier Vouchers, Pending Vouchers, the
order page's shipments and the order Timeline (whose view core now
overrides, `resources/views/lunarpanel`, since Lunar formats its times by
hand). Manual tracking updates entered in local time were saved as if
they were UTC; they're now converted.
- ACS tracking times (Greek time, no offset) were stored as if they were
UTC; they're now read as Greek time, like ELTA's.
- ELTA multi-parcel sends: Create Shipment with several packages now
creates one shipment per parcel straight away (the extra parcels get
their voucher numbers when the main voucher is issued, on first print),
so the order shows every parcel from the start. Labels follow ELTA's own
client: printing the main voucher prints every parcel in one PDF, each
parcel is numbered `00k/00n` (previously every label said `001/00n`),
the extra parcels show "ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ - MASTER : <main voucher>" and
only the COD warning (the amount is collected on the main voucher), and
on A4 they use ELTA's separate single-parcel layout (`label-a4-child`).
An extra parcel can't be cancelled on its own — ELTA cancels the whole
voucher, so cancelling the main parcel cancels them all.
- The order page's shipments list now updates right after Create Shipment
and Add manual voucher, instead of only on the next page load.
- Pending Vouchers kept ELTA vouchers forever (ELTA has no manifest step,
which is what took ACS vouchers off the list). A voucher now leaves once
its carrier's hand-over step is done: printed for carriers that issue on
print (ELTA), on a manifest for carriers that batch (ACS).
- "Issued Manifests" is hidden from the menu while no registered carrier
uses manifests (ACS is currently off); the page and past manifests stay
reachable.
- Carrier Vouchers: the list shows each voucher's **Source** again, colored
per source (created here green, manual voucher orange, manual carrier
grey, from carrier blue), same on the view page. "Reference sent to the
carrier" was only filled for vouchers the carrier reported; shipments now
save the reference they send (ELTA `pel_ref_no`, ACS `Reference_Key1`,
Box Now `orderNumber`), and older ones show what was sent (the order
reference, or `{reference}-{id}` for Box Now).
- Multi-parcel sends show which voucher is the main one and which parcels
belong to it — "Main voucher · parcel 1 of 3", "Parcel 2 of 3 · of
NZ…GR" — on the order page, Pending Vouchers and Carrier Vouchers (list
and view), via `Shipment::parcelDescription()`.
- Shipping options are now resolved by **place, from the inside out**:
postcode → state → country → unrestricted, and the first place with
usable rates is the whole answer — outer places are not considered. So a
postcode zone offering only Box Now at €4 means customers in that
postcode get only Box Now at €4, even if an unrestricted zone offers Box
Now and ACS at €8. Postcode zones go from the most specific match to the
least (an exact postcode beats a `45*` prefix). Previously Lunar mixed
every matching zone together and kept whichever rate per method it met
first, so the price depended on the order zones were created in.
A place whose rates are all unusable (disabled, outside the customer
group or schedule, stock, weight limits, or an unregistered driver) falls
through to the next one. It finds every zone containing the address in
one query and checks customer groups once for all methods, so it runs a
fixed ~10 queries — no more than Lunar's own resolver, which grew with
each rate. `Modules\Core\Shipping\Support\PlaceShippingRateResolver`
replaces 0.29.1's `RegisteredDriverRateResolver` (its unregistered-driver
check is part of it).
Exclusion lists don't make a place fall through: an excluded product
means "can't be shipped there", so a cart holding one gets that place's
remaining options — or none — never an outer place's rule.
## [0.29.1] - 2026-09-30
### Fixed
- Checkout crashed ("Driver [acs] not supported", a 500) when a shipping
method pointed at a driver that is no longer registered — e.g. a carrier
commented out while its shipping method still exists in the admin. Such
methods are now left out of the options offered at checkout
(`Modules\Core\Shipping\Support\RegisteredDriverRateResolver`); the admin
is unchanged.
- The Box Now locker map at checkout only appeared when the shipping
method's **code** was exactly `box-now`; a method with any other code
(e.g. `boxnow`) never showed it, and switching options cleared the chosen
locker. Box Now is now recognized by its driver being a `BoxNowRateDriver`
(`Modules\Core\Shipping\Support\BoxNowShipping`), not by any name. The
admin order page's Create Shipment (locker + boxes form) and the
shipment's locker line use the same check.
## [0.29.0] - 2026-09-30
### Added
- **ELTA Courier** carrier (`elta` shipping driver): rates, shipment creation,
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.29.0",
"version": "0.29.2",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
+14
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@boboko/core",
"version": "0.29.0",
"version": "0.29.2",
"private": true,
"type": "module",
"description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.",
@@ -123,7 +123,7 @@ export default class extends Controller {
}
async selectShipping(event) {
this.toggleBoxNowLocker(event.target.value)
this.toggleBoxNowLocker(event.target.dataset.boxNow === 'true')
// Tracked so flush() can await it — nothing else stops "place order"
// (a separate, unrelated click) from racing ahead of this request.
@@ -136,15 +136,17 @@ export default class extends Controller {
// re-renders wholesale on every shipping-option change — so its own
// Stimulus controller reconnects fresh each time and has no memory of
// which option was previously selected. This is the one place that
// knows the newly-chosen option's identifier, so it also owns
// showing/hiding the picker.
toggleBoxNowLocker(identifier) {
// knows the newly-chosen option, so it also owns showing/hiding the
// picker. Whether an option is Box Now comes from the server
// (data-box-now, by the method's driver) — the option's value is the
// merchant-typed method code, which needn't be "box-now".
toggleBoxNowLocker(isBoxNow) {
const picker = this.shippingOptionsTarget.querySelector('#bbk-box-now-locker')
if (!picker) return
const controller = this.application.getControllerForElementAndIdentifier(picker, 'bbk-box-now-locker')
if (identifier === 'box-now') {
if (isBoxNow) {
controller?.show()
} else {
controller?.hide()
@@ -8,6 +8,8 @@
$shippingAddress, $shippingOptions come from the controller / page scope.
--}}
@php($selected = $shippingAddress?->shipping_option)
{{-- Box Now by the method's driver, not its (merchant-typed) code --}}
@php($boxNowCodes = \Modules\Core\Shipping\Support\BoxNowShipping::codes())
{{-- Rate resolution needs country (always Greece here) + postcode; until a
postcode is saved there's nothing to quote against yet. --}}
@@ -34,6 +36,7 @@
type="radio"
name="shipping_option"
value="{{ $option->identifier }}"
data-box-now="{{ in_array($option->identifier, $boxNowCodes, true) ? 'true' : 'false' }}"
@checked($selected === $option->identifier)
data-action="change->bbk-checkout-form#selectShipping"
>
@@ -73,7 +76,7 @@
data-bbk-box-now-locker-select-label-value="{{ __('checkout.page.box_now_locker_select') }}"
data-bbk-box-now-locker-selected-label-value="{{ __('checkout.page.box_now_locker_selected') }}"
data-bbk-box-now-locker-no-results-value="{{ __('checkout.page.box_now_locker_no_results') }}"
@if ($selected !== 'box-now') hidden @endif
@if (! in_array($selected, $boxNowCodes, true)) hidden @endif
>
<p class="bbk-checkout-box-now-locker-label">
{{ __('checkout.page.box_now_locker_label') }}
@@ -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.
--}}
<div class="px-2 pb-4 scroll-mt-32" id="lunar-panel-timeline">
<div class="relative flex items-end gap-4 mt-4">
<div class="shrink-0">
<div>
<img src="{{ $this->userAvatar }}"
class="inline-block w-8 h-8 rounded-full" />
</div>
</div>
<form class="w-full"
wire:submit.prevent="addComment">
{{ $this->form }}
<div class="absolute right-0 mt-2">
{{ $this->addCommentAction }}
</div>
</form>
</div>
<div class="relative pt-8 -ml-[5px] z-10 pointer-events-none">
<span class="absolute inset-y-0 left-5 w-[2px] bg-gray-200 dark:bg-gray-600 rounded-full"></span>
<div class="flow-root">
<ul class="-my-8 divide-y-2 divide-gray-200 dark:divide-gray-600"
role="list">
@foreach ($this->activityLog as $log)
<li class="relative py-8 ml-5">
<p class="ml-8 font-bold text-gray-950 dark:text-gray-300">
{{ $log['items']->first()['log']->created_at->inDisplayTimezone()->format('F jS, Y') }}
</p>
<ul class="mt-4 space-y-6 pointer-events-auto">
@foreach ($log['items'] as $item)
@php
$logUserName = $item['log']->causer ? ($item['log']->causer->fullName ?: $item['log']->causer->name) : null;
@endphp
<li class="relative pl-8">
<div @class([
'absolute top-[2px]',
'-left-[calc(0.75rem_-_1px)]' => $item['log']->causer,
'-left-[calc(0.5rem_-_1px)]' => !$item['log']->causer,
])>
@if ($email = $item['log']->causer?->email)
<img
src="{{ $this->getAvatarUrl($email) }}"
class="w-6 h-6 rounded-full ring-4 ring-gray-200 dark:ring-gray-600"
alt="{{ $logUserName }}"
/>
@else
<span @class([
'absolute w-4 h-4 rounded-full ring-4',
match($item['log']->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',
},
])>
</span>
@endif
</div>
<div @class([
'flex justify-between',
'pt-[5px]' => $item['log']->causer,
'pt-[1px]' => !$item['log']->causer,
])>
<div>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
@if (!$item['log']->causer)
{{ __('lunarpanel::components.activity-log.system') }}
@else
{{ $logUserName }}
@endif
</div>
@if (count($item['renderers']))
<div class="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">
@foreach ($item['renderers'] as $class)
{{ $class->render($item['log']) }}
@endforeach
</div>
@endif
</div>
<time class="flex-shrink-0 ml-4 text-xs mt-0.5 text-gray-500 dark:text-gray-400 font-medium">
{{ $item['log']->created_at->inDisplayTimezone()->format('h:ia') }}
</time>
</div>
</li>
@endforeach
</ul>
</li>
@endforeach
</ul>
</div>
</div>
<div class="pt-4">
{{ $this->activityLog->links('lunarpanel::components.activity-log.timeline-paginator.index', data: ['scrollTo' => '#lunar-panel-timeline']) }}
</div>
</div>
@@ -77,7 +77,7 @@
class="w-4"
/>
</div>
<span>{{ $transaction->created_at->format('jS F Y h:ia') }}</span>
<span>{{ $transaction->created_at->inDisplayTimezone()->format('jS F Y h:ia') }}</span>
</div>
<div class="flex space-x-2">
@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="el">
<head>
<meta charset="utf-8">
<style>
{{--
An extra parcel of a multi-parcel send on A4 — ELTA's own client
prints these with a separate, smaller report (ELTA_PEL.sydetaE1.rdlc,
via epexergasia.cs::print_child()) instead of the full 3-copy
voucher: one block with the parcel's own voucher/barcode, its
piece ("002/002"), the master voucher and, for cash on delivery,
only the warning (the amount is collected on the master). Every
position below is that report's own Top/Left in cm, on the page.
Classes are ch-* so they don't collide with label-a4's when a
batch PDF holds both.
--}}
@page { margin: 0; size: 21cm 29.7cm; }
html, body { margin: 0; padding: 0; }
body { font-family: 'DejaVu Sans', sans-serif; font-size: 6.5pt; color: #000; }
.ch-page { position: relative; width: 21cm; height: 29.7cm; }
.ch-page > * { position: absolute; box-sizing: border-box; }
.ch-box { border: 1px solid #000; }
.ch-box > * { position: absolute; }
.ch-label { font-size: 6pt; }
.ch-val { font-size: 8pt; font-weight: bold; }
.ch-line { font-size: 9pt; white-space: nowrap; overflow: hidden; }
.ch-center { text-align: center; left: 0; width: 100%; }
.ch-logo img { width: 100%; }
.ch-company { font-size: 5pt; line-height: 1.25; white-space: nowrap; }
.ch-company-name { font-weight: bold; font-size: 5.5pt; }
.ch-barcode img { width: 100%; height: 0.62cm; }
</style>
</head>
<body>
<div class="page ch-page">
{{-- Image22 (logo) + image4 (ELTA details bitmap, reproduced as text) --}}
<div class="ch-logo" style="top:9.07cm;left:0.41cm;width:1.33cm;height:1.28cm;">
<img src="{{ $eltaLogo }}" alt="ELTA Courier">
</div>
<div class="ch-company" style="top:9.07cm;left:1.95cm;width:3.38cm;">
<div class="ch-company-name">ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε.</div>
<div>Απελλού 1, 105 51, Αθήνα</div>
<div>Τ. 210-6073000 | www.eltacourier.gr</div>
</div>
<div style="top:10.38cm;left:0.41cm;width:4.99cm;font-size:6pt;">ΕΕΤΤ ΑΜ 99-150 Γενική Αδεια Ταχ/κων Υπηρεσιών</div>
{{-- rectangle17: title, barcode, this parcel's voucher --}}
<div class="ch-box" style="top:9.02cm;left:5.44cm;width:8.07cm;height:1.76cm;">
<div class="ch-center" style="top:0.13cm;font-size:9pt;font-weight:bold;">ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ</div>
<div class="ch-barcode" style="top:0.57cm;left:0.19cm;width:7.65cm;">
<img src="{{ $barcode_voucher }}" alt="">
</div>
<div class="ch-center" style="top:1.30cm;font-size:11pt;font-weight:bold;">{{ $voucher_no }}</div>
</div>
<div style="top:11.02cm;left:5.10cm;width:4.55cm;font-size:8pt;">Ημερομηνία - Ωρα Εκτύπωσης :</div>
<div style="top:11.02cm;left:9.70cm;width:3.70cm;font-size:8pt;">{{ $date }} {{ $time }}</div>
{{-- rectangle18-21: deposit station, weight, volumetric, pieces --}}
<div class="ch-box" style="top:11.45cm;left:0.11cm;width:1.51cm;height:0.82cm;">
<div class="ch-label" style="top:0.11cm;left:0.05cm;">Γρ.Κατάθεσης</div>
<div style="top:0.42cm;left:0.05cm;font-size:9pt;font-weight:bold;">{{ $station_apo }}</div>
</div>
<div class="ch-box" style="top:11.45cm;left:1.81cm;width:1.35cm;height:0.82cm;">
<div class="ch-label" style="top:0.11cm;left:0.05cm;">Βάρος</div>
<div class="ch-val" style="top:0.42cm;left:0.05cm;">{{ $weight }}</div>
</div>
<div class="ch-box" style="top:11.45cm;left:3.29cm;width:2.83cm;height:0.82cm;">
<div class="ch-label" style="top:0.11cm;left:0.05cm;">Ογκ/κο Βάρος</div>
<div class="ch-val" style="top:0.42cm;left:0.05cm;">{{ $volumetric_weight }}</div>
</div>
<div class="ch-box" style="top:11.45cm;left:6.28cm;width:1.22cm;height:0.82cm;">
<div class="ch-label" style="top:0.11cm;left:0.05cm;">Τεμάχια</div>
<div class="ch-val" style="top:0.42cm;left:0.05cm;">{{ $package_label }}</div>
</div>
{{-- rectangle1: service --}}
<div class="ch-box" style="top:11.48cm;left:13.49cm;width:6.20cm;height:0.86cm;">
<div style="top:0.06cm;left:0.13cm;font-size:8pt;font-weight:bold;">ΥΠΗΡΕΣΙΑ :</div>
<div style="top:0.04cm;left:3.19cm;font-size:9pt;font-weight:bold;">{{ $service_code }}</div>
<div style="top:0.43cm;left:0.06cm;width:5.82cm;font-size:8pt;font-weight:bold;">{{ $service_name }}</div>
</div>
{{-- rectangle30: destination station --}}
<div class="ch-box" style="top:12.38cm;left:0.11cm;width:12.59cm;height:0.61cm;">
<div class="ch-label" style="top:0.18cm;left:0.08cm;">Γρ.Προορισμού</div>
<div class="ch-val" style="top:0.13cm;left:1.79cm;">{{ $station_pros }}</div>
<div class="ch-val" style="top:0.14cm;left:3.65cm;width:8.73cm;">{{ $station_pros_title }}</div>
</div>
{{-- rectangle52: multi-parcel panel — piece, COD warning, master voucher --}}
<div class="ch-box" style="top:12.61cm;left:13.50cm;width:6.27cm;height:6.24cm;">
<div class="ch-center" style="top:0.26cm;font-size:10pt;font-weight:bold;">** ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ **</div>
<div class="ch-center" style="top:1.89cm;font-size:20pt;font-weight:bold;">{{ $piece_label }}</div>
@if ($antik_1)
<div class="ch-center" style="top:3.66cm;font-size:10pt;font-weight:bold;">{{ $antik_1 }}</div>
@endif
<div class="ch-center" style="top:4.77cm;font-size:10pt;font-weight:bold;">ΜASTER ΣΥΔΕΤΑ</div>
<div class="ch-center" style="top:5.31cm;font-size:11pt;font-weight:bold;">{{ $copy }}</div>
</div>
{{-- rectangle24: sender (sender_1..5) --}}
<div class="ch-box" style="top:13.06cm;left:0.11cm;width:12.57cm;height:2.74cm;">
<div style="top:0.08cm;left:0.05cm;font-size:9pt;font-weight:bold;">ΑΠΟΣΤΟΛΕΑΣ</div>
@foreach ($sender_lines as $i => $line)
<div class="ch-line" style="top:{{ 0.53 + $i * 0.43 }}cm;left:0.05cm;width:11.96cm;">{{ $line }}</div>
@endforeach
</div>
{{-- rectangle27: recipient (rec_1..5) --}}
<div class="ch-box" style="top:15.94cm;left:0.11cm;width:12.57cm;height:2.90cm;">
<div style="top:0.05cm;left:0.05cm;font-size:9pt;font-weight:bold;">ΠΑΡΑΛΗΠΤΗΣ</div>
@foreach ($recipient_lines as $i => $line)
<div class="ch-line" style="top:{{ 0.49 + $i * 0.44 }}cm;left:0.05cm;width:12.12cm;font-weight:bold;">{{ $line }}</div>
@endforeach
</div>
</div>
</body>
</html>
@@ -89,6 +89,10 @@
<div class="a6-datetime" style="top:1.68cm;left:0.13cm;width:1.68cm;">{{ $date }}</div>
<div class="a6-title" style="top:1.79cm;left:1.93cm;width:5.89cm;">ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ</div>
{{-- textbox15 (copy): "ΠΟΛΛΑΠΛH" on the extra parcels of a multi-parcel send --}}
@if ($copy)
<div class="a6-title" style="top:1.84cm;left:7.95cm;width:2.21cm;">{{ $copy }}</div>
@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
+2 -1
View File
@@ -29,6 +29,7 @@ use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache;
use Modules\Core\Shipping\Contracts\SupportsCashCollection;
use Modules\Core\Shipping\Support\BoxNowShipping;
use Modules\Core\Shipping\Support\FulfillmentType;
/**
@@ -181,7 +182,7 @@ class CheckoutService
// switches BACK to Box Now and it resurfaces as if still chosen,
// possibly for a locker that no longer exists/fits. Cleared here,
// the one place that knows the method just changed.
if ($identifier !== 'box-now') {
if (! BoxNowShipping::isOption($identifier)) {
$address = $cart->shippingAddress;
if ($address && isset($address->meta['box_now_locker'])) {
+16
View File
@@ -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');
@@ -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,
]);
}
}
@@ -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,
);
});
@@ -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,
],
@@ -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<int, Shipment> 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();
}
}
+116 -18
View File
@@ -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, '<body');
// A6 prints every parcel on the same label; on A4 the extra
// parcels of a multi-parcel send use ELTA's own separate, smaller
// report (sydetaE1.rdlc) — so one PDF can mix two layouts, and
// each layout's styles are carried into the shared <head>.
$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, '<body');
$head ??= $viewHead;
preg_match_all('#<style>.*?</style>#s', $viewHead, $matches);
$styles[$view] = implode('', $matches[0]);
$pages[] = Str::beforeLast(Str::after(Str::after($html, '<body'), '>'), '</body>');
}
$html = $head
$html = Str::before($head, '<style>')
.implode('', $styles)
.'<style>.page { page-break-after: always; } .page:last-child { page-break-after: auto; }</style>'
.Str::afterLast($head, '</style>')
.'<body>'.implode('', $pages).'</body></html>';
$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<string, mixed>
*/
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 <svg> support doesn't reliably
* render the barcode generator's <rect>-based bars (confirmed: the
@@ -19,6 +19,7 @@ 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\Support\BoxNowShipping;
/**
* Adds a "Shipments" section to the order page's main column — previously
@@ -169,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.
@@ -222,9 +223,13 @@ 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 ($record->carrier === 'box-now' && $locker = $record->meta['locker_id'] ?? null) {
if ($parcel = $record->parcelDescription()) {
$parts[] = $parcel;
}
if (BoxNowShipping::isDriver($record->carrier) && $locker = $record->meta['locker_id'] ?? null) {
$parts[] = 'Locker '.$locker;
}
@@ -25,6 +25,7 @@ use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Support\BoxNowShipping;
use Modules\Core\Shipping\Support\WeightCalculator;
use Throwable;
@@ -122,7 +123,7 @@ class OrderViewExtension extends ViewPageExtension
->modalSubmitActionLabel('Create Shipment')
->schema(function (Order $record) {
$carrier = $this->service()->carrierFor($record);
$isBoxNow = $carrier === 'box-now';
$isBoxNow = BoxNowShipping::isDriver($carrier);
$lockerId = $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null;
if ($carrier === 'manual') {
@@ -223,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();
@@ -331,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();
@@ -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;
@@ -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;
@@ -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(),
+36
View File
@@ -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;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace Modules\Core\Shipping\Support;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver;
/**
* Whether something is Box Now — a checkout option, a shipping method, a
* driver key — by checking that the driver is a BoxNowRateDriver, never by
* comparing names: an option's identifier is the merchant-typed method
* code (may well not be "box-now"), and the driver may be registered under
* any key.
*/
class BoxNowShipping
{
public static function isOption(?string $identifier): bool
{
return $identifier !== null
&& self::isMethod(ShippingMethod::where('code', $identifier)->first());
}
public static function isMethod(?ShippingMethod $method): bool
{
return self::isDriver($method?->driver);
}
/**
* A shipping driver key (a method's `driver`, a shipment's `carrier`)
* that resolves to Box Now. getSupportedDrivers() rather than
* Shipping::driver(), which throws for a key no longer registered.
*/
public static function isDriver(?string $driver): bool
{
return $driver !== null
&& Shipping::getSupportedDrivers()->get($driver) instanceof BoxNowRateDriver;
}
/**
* Codes of every Box Now shipping method — for marking options in bulk.
*
* @return array<int, string>
*/
public static function codes(): array
{
return ShippingMethod::all()
->filter(fn (ShippingMethod $method) => self::isMethod($method))
->pluck('code')
->values()
->all();
}
}
@@ -0,0 +1,208 @@
<?php
namespace Modules\Core\Shipping\Support;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Lunar\Models\State;
use Lunar\Shipping\DataTransferObjects\PostcodeLookup;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate;
use Lunar\Shipping\Models\ShippingZone;
use Lunar\Shipping\Resolvers\ShippingRateResolver;
/**
* Resolves shipping by place, from the innermost place outwards: postcode →
* state → country → unrestricted. The first place that has shipping
* zones with usable rates is the whole answer — outer places are not
* consulted at all, so a cheaper Athens postcode rule replaces the
* Greece-wide one for those postcodes instead of competing with it.
*
* Lunar's own resolver gathers every matching zone of every type at once
* and keeps the first rate per shipping method, so which price a customer
* saw depended on the order the zones were inserted.
*
* Postcode zones are tried from the most specific match to the least, in
* the order Lunar's postcode resolver lists the parts ("12345", then
* shorter prefixes and their "*" wildcards), so an exact postcode beats a
* prefix. Several zones matching at the same place are combined; if two of
* them carry the same shipping method, the older zone's rate is used.
*
* "Usable" means passing the same checks as Lunar's resolver (rate
* enabled, method in the customer's group and in schedule, stock, weight
* limits) plus one of ours: the method's driver is still registered —
* otherwise Lunar calls a missing driver to price it and the checkout
* fails with "Driver [x] not supported". So a place whose rates all fail
* those checks (e.g. the cart is too heavy for its only method) falls
* through to the next place out.
*
* Exclusion lists deliberately do NOT make a place fall through. An
* exclusion list on a zone means "these products can't be shipped there",
* so the place is still the answer; each driver then drops that zone's
* options for a cart holding an excluded product
* (ExcludesRestrictedProducts). If nothing is left, the customer gets no
* shipping options for that address rather than an outer place's rule.
*
* Queries stay fixed whatever the number of places or zones: every zone
* containing the address comes back in one query (with its matching
* postcodes, rates and methods eager-loaded), the customer-group check is
* one query over all their methods, and the layering happens in PHP.
*/
class PlaceShippingRateResolver extends ShippingRateResolver
{
public function get(): Collection
{
if (! $this->postcode || ! $this->country) {
return collect();
}
$parts = (new PostcodeLookup(country: $this->country, postcode: $this->postcode))->getParts()->values();
$state = $this->state ? State::whereName($this->state)->first() : null;
$zones = $this->candidateZones($parts, $state);
$visibleMethodIds = $this->methodsVisibleToCustomer($zones);
foreach ($this->placesInsideOut($zones, $parts) as $placeZones) {
$rates = $this->usableRates($placeZones, $visibleMethodIds);
if ($rates->isNotEmpty()) {
return $rates;
}
}
return collect();
}
/**
* Every zone containing the address, of every place, oldest first.
*
* @param Collection<int, string> $parts
* @return Collection<int, ShippingZone>
*/
private function candidateZones(Collection $parts, ?State $state): Collection
{
$inCountry = fn (Builder $countries) => $countries->where('country_id', $this->country->id);
return ShippingZone::query()
->where(function (Builder $query) use ($parts, $state, $inCountry) {
$query
->where(fn (Builder $zone) => $zone->where('type', 'postcodes')
->whereHas('postcodes', fn (Builder $postcodes) => $postcodes->whereIn('postcode', $parts))
->whereHas('countries', $inCountry))
->when($state, fn (Builder $q) => $q->orWhere(fn (Builder $zone) => $zone->where('type', 'states')
->whereHas('states', fn (Builder $states) => $states->where('state_id', $state->id))))
->orWhere(fn (Builder $zone) => $zone->where('type', 'countries')
->whereHas('countries', $inCountry))
->orWhere('type', 'unrestricted');
})
->with([
'postcodes' => fn ($postcodes) => $postcodes->whereIn('postcode', $parts),
'rates' => fn ($rates) => $rates->orderBy('id'),
'rates.shippingMethod',
])
->orderBy('id')
->get();
}
/**
* The candidate zones grouped by place, innermost first. A postcode
* zone belongs to the most specific part it matches.
*
* @param Collection<int, ShippingZone> $zones
* @param Collection<int, string> $parts
* @return iterable<int, Collection<int, ShippingZone>>
*/
private function placesInsideOut(Collection $zones, Collection $parts): iterable
{
$specificity = $parts->flip();
$postcodeZones = $zones
->where('type', 'postcodes')
->groupBy(fn (ShippingZone $zone) => $zone->postcodes
->map(fn ($postcode) => $specificity[$postcode->postcode] ?? PHP_INT_MAX)
->min());
foreach ($postcodeZones->sortKeys() as $placeZones) {
yield $placeZones;
}
foreach (['states', 'countries', 'unrestricted'] as $type) {
yield $zones->where('type', $type);
}
}
/**
* Ids of the candidate zones' shipping methods that the customer's
* group may use — Lunar's own customerGroup() check, once for all of
* them instead of once per rate.
*
* @param Collection<int, ShippingZone> $zones
* @return Collection<int, bool> keyed by method id
*/
private function methodsVisibleToCustomer(Collection $zones): Collection
{
$methodIds = $zones->flatMap->rates->pluck('shipping_method_id')->filter()->unique()->values();
if ($methodIds->isEmpty()) {
return collect();
}
return ShippingMethod::query()
->whereIn('id', $methodIds)
->customerGroup($this->customerGroups)
->pluck('id')
->flip()
->map(fn () => true);
}
/**
* The zones' rates that pass Lunar's own checks and whose driver is
* registered — one per shipping method.
*
* @param Collection<int, ShippingZone> $zones
* @param Collection<int, bool> $visibleMethodIds
* @return Collection<int, ShippingRate>
*/
private function usableRates(Collection $zones, Collection $visibleMethodIds): Collection
{
$registered = Shipping::getSupportedDrivers();
return $zones
->flatMap(fn (ShippingZone $zone) => $zone->rates)
->filter(function (ShippingRate $rate) use ($registered, $visibleMethodIds) {
$method = $rate->shippingMethod;
if (! $rate->enabled || ! $method || ! $visibleMethodIds->has($method->id)) {
return false;
}
if (! $method->isAvailable() || ! $registered->has($method->driver)) {
return false;
}
if (! $this->allCartItemsAreInStock && ($method->stock_available ?? false)) {
return false;
}
return $this->withinWeightLimits($method);
})
->unique(fn (ShippingRate $rate) => $rate->shippingMethod->code)
->values();
}
private function withinWeightLimits(ShippingMethod $method): bool
{
$minWeight = $method->min_weight;
$maxWeight = $method->max_weight;
if ($minWeight === null && $maxWeight === null) {
return true;
}
$cartWeight = $this->cartWeightIn($method->weight_unit ?? 'kg');
return ! ($minWeight !== null && $cartWeight < (float) $minWeight)
&& ! ($maxWeight !== null && $cartWeight > (float) $maxWeight);
}
}
+11
View File
@@ -2,7 +2,9 @@
namespace Modules\Core\Shipping\Support;
use Lunar\Models\Contracts\Cart as CartContract;
use Lunar\Shipping\Managers\ShippingManager as VendorShippingManager;
use Lunar\Shipping\Resolvers\ShippingRateResolver;
/**
* Drops lunarphp/table-rate-shipping's own generic drivers (free-shipping,
@@ -28,6 +30,15 @@ use Lunar\Shipping\Managers\ShippingManager as VendorShippingManager;
*/
class ShippingManager extends VendorShippingManager
{
/**
* Rates for a cart, resolved by place from the inside out (see
* PlaceShippingRateResolver).
*/
public function shippingRates(?CartContract $cart = null): ShippingRateResolver
{
return new PlaceShippingRateResolver($cart);
}
public function getSupportedDrivers(): \Illuminate\Support\Collection
{
return collect($this->customCreators)