Compare commits

...
10 Commits
34 changed files with 1236 additions and 103 deletions
+140
View File
@@ -4,6 +4,146 @@ 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.30.0] - 2026-09-30
### Added
- Manual vouchers (an integrated carrier's voucher typed in by hand) can be
edited and tracked by hand from the order page: "Edit number" (the number
stays required) and "Add tracking update" now show for them, as they do
for manual carriers. An In Transit / Collected update dispatches the
order and sends the "on its way" email; Delivered marks it delivered and
stops tracking polls for it. The carrier's own checkpoints keep coming in
alongside.
- A shipping method's "Tracking URL" can now be set for every driver, not
only `manual`. For an integrated carrier it's copied onto vouchers added
with "Add manual voucher", so they get the "Carrier tracking" link in the
admin and a "Track your parcel" link in the dispatched email.
API-created shipments still send customers to the order page. Vouchers
added before this have no link.
- The customer's delivery instructions are sent to the carrier: ELTA's
remarks (ΠΑΡΑΤΗΡΗΣΕΙΣ, one line, 90 characters) and ACS's
`Delivery_Notes`. ELTA labels print them in the ΠΑΡΑΤΗΡΗΣΕΙΣ box (up to 3
lines on A4, 2 on A6 and the payment stub).
### Changed
- Tracking polls run one queued job per shipment: `PollShipmentTrackingJob`
now only finds the shipments to poll and dispatches a
`RefreshShipmentTrackingJob` for each as a batch (`allowFailures`). A
slow or failing carrier call only affects its own job, with its own 45s
timeout; a failed one is retried by the next poll, five minutes later.
Needs the `job_batches` table (Laravel's default jobs migration).
- `PollShipmentTrackingJob` is unique, so a scheduled run is dropped while
the previous one is still pending. `RefreshShipmentTrackingJob` is unique
per shipment, so the same shipment is never refreshed twice at once,
which could record a checkpoint twice and fire its emails twice.
### Fixed
- "Add manual voucher" and a manual carrier's "Create Shipment" failed
with an SQL error ("missing FROM-clause entry for table lunar_orders")
whenever a voucher number was entered: their unique check on the
shipments table also excluded the current order's id.
## [0.29.3] - 2026-09-30
### Fixed
- Queued shipping jobs did nothing: `PollShipmentTrackingJob` and
`SyncCarrierVouchersJob` ran every time but finished in milliseconds
without contacting any carrier. The queue worker clears facade instances
before each job, and the Shipping manager was bound with `bind()`, so the
facade then built a fresh manager with no drivers registered. It's now a
singleton. Tracking from the scheduler never worked on a deployed app
because of this (a "Sync now" click worked, since it doesn't go through
the queue).
- ELTA extra parcels are no longer tracked by their own number: ELTA only
tracks a multi-parcel send by its main voucher and rejects the extra
parcels' numbers ("Λάθος ΣΥΔΕΤΑ"), which logged an error for each of them
on every tracking run.
## [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.30.0",
"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.30.0",
"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
@@ -202,6 +206,10 @@
{{-- ΠΑΡΑΤΗΡΗΣΕΙΣ --}}
<div class="a6-box" style="top:10.60cm;left:0.10cm;width:5.82cm;height:0.80cm;">
<div class="a6-small-title">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div>
{{-- sxolia_1/2 (textbox49/50) --}}
@foreach (array_slice($sxolia, 0, 2) as $i => $line)
<div style="position:absolute;top:{{ 0.24 + $i * 0.26 }}cm;left:0.10cm;width:5.62cm;font-size:6pt;line-height:1;white-space:nowrap;overflow:hidden;">{{ $line }}</div>
@endforeach
</div>
@if ($multiPiece)
@@ -139,6 +139,10 @@
<div class="mc-box" style="top:4.97cm;left:6.60cm;width:4.37cm;height:1.31cm;border-top:none;">
<div style="position:absolute;top:0.03cm;left:0.08cm;font-size:6pt;">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div>
{{-- sxolia_1..3 (textbox92/95/94) --}}
@foreach ($sxolia as $i => $line)
<div style="position:absolute;top:{{ 0.30 + $i * 0.31 }}cm;left:0.10cm;width:4.18cm;font-size:7pt;line-height:1;white-space:nowrap;overflow:hidden;">{{ $line }}</div>
@endforeach
</div>
{{-- Right-hand signature / print-timestamp column, differs by copy type --}}
@@ -73,6 +73,11 @@
<div class="mc-box" style="top:2.45cm;left:6.06cm;width:4.71cm;height:1.17cm;border-bottom:none;">
<div style="position:absolute;top:0.02cm;left:0.16cm;font-size:6pt;">Συν.Χρέωσης (€):</div>
<div style="position:absolute;top:0.30cm;left:0.16cm;font-size:6pt;">Πρόσθετες Υπηρεσίες</div>
{{-- sxolia_1..3 (textbox91/132/133) sit under this heading in the RDLC;
this stub's boxes are shorter than the RDLC's, so only 2 lines fit. --}}
@foreach (array_slice($sxolia, 0, 2) as $i => $line)
<div style="position:absolute;top:{{ 0.50 + $i * 0.16 }}cm;left:0.16cm;width:4.47cm;font-size:5pt;line-height:1;white-space:nowrap;overflow:hidden;">{{ $line }}</div>
@endforeach
</div>
<div class="mc-box mc-partybox" style="top:3.35cm;left:0.00cm;width:5.97cm;height:1.63cm;overflow:hidden;border-top:none;">
+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'])) {
@@ -57,7 +57,7 @@ class OrderDispatchedNotification extends BaseNotification
'reference' => $reference,
// Active, numbered, non-return shipments: the voucher numbers
// to show, plus the courier's link for manual carriers
// (Shipment::trackingUrl()). Integrated carriers' history is
// and typed-in vouchers (Shipment::trackingUrl()). API-created shipments' history is
// on the customer's order page.
'shipments' => $order->shipments()->get()
->reject(fn (Shipment $shipment) => $shipment->isCancelled() || $shipment->isReturn() || blank($shipment->tracking_reference))
@@ -118,6 +118,12 @@ class OrderFulfillmentService
'meta' => [
'services' => array_map(fn (ExtraService $service) => $service->value, $services),
'cod_amount' => $this->flow->isCod($order) ? $order->total->decimal : null,
// Copied like ManualFulfillmentService does, so the link
// keeps working if the method is edited later.
'tracking_url' => ShippingMethod::where('driver', $carrier)->get()
->map(fn (ShippingMethod $method) => $method->data['tracking_url'] ?? null)
->filter()
->first(),
],
]);
+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');
+7 -1
View File
@@ -154,7 +154,13 @@ class ShippingServiceProvider extends ServiceProvider
// run before the Shipping::extend() calls below, which resolve —
// and the facade then CACHES — whatever's bound at that moment).
$this->app->booted(function () {
$this->app->bind(ShippingMethodManagerInterface::class, fn ($app) => $app->make(ShippingManager::class));
// A singleton, not bind(): the queue worker clears every
// facade's cached instance before each job
// (Facade::clearResolvedInstances()), so with bind() the Shipping
// facade then built a fresh, empty manager — without the drivers
// extended below — and every queued job (tracking, voucher sync)
// saw no carriers at all.
$this->app->singleton(ShippingMethodManagerInterface::class, fn ($app) => $app->make(ShippingManager::class));
// Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
@@ -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,
]);
}
}
@@ -72,6 +72,11 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsExtr
'Reference_Key1' => (string) $order->reference,
];
// The customer's delivery instructions, for the courier.
if ($instructions = trim(preg_replace('/\s+/u', ' ', (string) $address->delivery_instructions))) {
$params['Delivery_Notes'] = $instructions;
}
$codAmount = $request->paymentMode === 'cod'
? (float) ($request->amountToCollect ?? $order->total->decimal)
: null;
@@ -108,6 +113,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 +216,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,
],
@@ -63,6 +63,11 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch
$weight = $request->weight ?? 0.5;
$surcharges = $this->surchargeCodes($request);
// The customer's delivery instructions, as ELTA's remarks
// (ΠΑΡΑΤΗΡΗΣΕΙΣ): one line, at most 90 characters — the ELTA
// client's own field limit.
$remarks = mb_substr(trim(preg_replace('/\s+/u', ' ', (string) $address->delivery_instructions)), 0, 90);
$params = [
'pel_apost_code' => config('elta.apost_code'),
'pel_paral_name' => trim("{$address->first_name} {$address->last_name}"),
@@ -78,7 +83,7 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch
'pel_y' => '',
'pel_z' => '',
'pel_temaxia' => (string) $request->packageCount,
'pel_paral_sxolia' => '',
'pel_paral_sxolia' => $remarks,
'pel_sur_1' => $surcharges[0] ?? '',
'pel_sur_2' => $surcharges[1] ?? '',
'pel_sur_3' => $surcharges[2] ?? '',
@@ -116,43 +121,101 @@ 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,
'delivery_instructions' => $remarks ?: null,
'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 +226,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,10 +245,52 @@ 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
{
// ELTA tracks a multi-parcel send by its main voucher only — an
// extra parcel's own number is rejected ("Λάθος ΣΥΔΕΤΑ"). Its
// history is the main voucher's, which is tracked on its own row.
if (! $shipment->is($this->masterOf($shipment))) {
return collect();
}
$response = $this->client->getTracking([
'web_vg' => $shipment->tracking_reference,
'pel_code' => config('elta.apost_code'),
@@ -312,16 +427,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();
}
}
+121 -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,16 @@ 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,
// ΠΑΡΑΤΗΡΗΣΕΙΣ: the remarks we sent ELTA (the customer's delivery
// instructions), in the client's 27-character lines — up to 3
// on A4, the first 2 on A6. Extra parcels print them too.
'sxolia' => array_slice(mb_str_split((string) ($meta['delivery_instructions'] ?? ''), 27), 0, 3),
// sydetaE.rdlc's "Πρόσθετες Υπηρεσίες" (sur_1..4): the ticked
// extra services, in ELTA's own wording and slots.
@@ -282,6 +332,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
@@ -123,12 +124,13 @@ class OrderShipmentsExtension extends ViewPageExtension
->schema(fn (Shipment $record) => [
TextInput::make('tracking_reference')
->label('Voucher / tracking number')
->required($record->source === Shipment::SOURCE_MANUAL_VOUCHER)
->unique(Shipment::class, 'tracking_reference', ignorable: $record),
])
->action(fn (Shipment $record, array $data) => $record->update([
'tracking_reference' => filled($data['tracking_reference']) ? trim($data['tracking_reference']) : null,
]))
->visible(fn (Shipment $record) => $record->source === Shipment::SOURCE_MANUAL && ! $record->isCancelled()),
->visible(fn (Shipment $record) => in_array($record->source, [Shipment::SOURCE_MANUAL, Shipment::SOURCE_MANUAL_VOUCHER], true) && ! $record->isCancelled()),
Action::make('add_tracking_update')
->label('Add tracking update')
->icon('heroicon-o-plus-circle')
@@ -151,7 +153,7 @@ class OrderShipmentsExtension extends ViewPageExtension
->helperText('Shown to the customer on their order page.'),
])
->action(fn (Shipment $record, array $data) => $this->addTrackingUpdate($record, $data))
->visible(fn (Shipment $record) => $record->source === Shipment::SOURCE_MANUAL && ! $record->isCancelled()),
->visible(fn (Shipment $record) => in_array($record->source, [Shipment::SOURCE_MANUAL, Shipment::SOURCE_MANUAL_VOUCHER], true) && ! $record->isCancelled()),
Action::make('cancel_shipment')
->label('Cancel')
->icon('heroicon-o-x-circle')
@@ -169,7 +171,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 +224,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,14 +123,14 @@ 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') {
return [
TextInput::make('tracking_reference')
->label('Voucher / tracking number')
->unique(Shipment::class, 'tracking_reference')
->unique(Shipment::class, 'tracking_reference', ignoreRecord: false)
->helperText('Optional — add it later from the shipment if the courier hasn\'t given one yet.'),
TextInput::make('package_count')
->label('Number of packages')
@@ -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();
@@ -302,7 +305,7 @@ class OrderViewExtension extends ViewPageExtension
TextInput::make('voucher_number')
->label('Voucher number')
->required()
->unique(Shipment::class, 'tracking_reference')
->unique(Shipment::class, 'tracking_reference', ignoreRecord: false)
->live(onBlur: true)
->afterStateUpdated(fn (Set $set) => $set('lookup', null))
->suffixAction(
@@ -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();
@@ -145,9 +145,8 @@ class ShippingMethodResourceExtension extends ResourceExtension
->required($isManual),
TextInput::make('tracking_url')
->label('Tracking URL')
->helperText('Optional. The carrier\'s tracking page, with {number} where the voucher number goes, e.g. https://example.com/track?number={number}.')
->rule('starts_with:http://,https://')
->visible($isManual),
->helperText('Optional. The carrier\'s tracking page, with {number} where the voucher number goes, e.g. https://example.com/track?number={number}. For an integrated carrier it\'s only used on vouchers typed in by hand.')
->rule('starts_with:http://,https://'),
Toggle::make('collects_cash')
->label('Courier collects cash on delivery')
->helperText('Offers cash on delivery at checkout for this shipping method.')
@@ -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(),
+27 -33
View File
@@ -3,40 +3,43 @@
namespace Modules\Core\Shipping\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Bus;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Services\ShipmentTrackingRecorder;
use Throwable;
/**
* Carrier-agnostic: polls every Shipment not yet in a terminal state,
* skipping carriers whose fulfillment service doesn't implement
* SupportsTracking. New checkpoints are recorded in shipment_info and
* dispatch ShipmentStatusUpdatedByCarrier — one event per new checkpoint.
* Carrier-agnostic: finds every Shipment not yet in a terminal state on a
* carrier whose fulfillment service implements SupportsTracking, and
* dispatches one RefreshShipmentTrackingJob per shipment as a batch. The
* carrier calls happen in those jobs — each with its own timeout, and a
* failure in one (a carrier 500, a malformed parcel response) doesn't
* touch the others (allowFailures).
*
* Each shipment's trackShipment() call is individually try/caught in
* pollCarrierShipments() — one shipment's tracking lookup failing (a
* carrier 500, a malformed parcel response) must not stop the rest of that
* carrier's shipments in the same batch from being polled. The failure is
* reported and the loop continues.
* Unique: a scheduled run is dropped while the previous one is still
* queued or building its batch. The schedule's withoutOverlapping() alone
* can't do this — for a queued job it only covers the dispatch itself.
*/
class PollShipmentTrackingJob implements ShouldQueue
class PollShipmentTrackingJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
/** Only queries and dispatches — no carrier calls. */
public int $timeout = 60;
public int $backoff = 60;
public bool $failOnTimeout = true;
public int $tries = 1;
public int $uniqueFor = 300;
public function handle(): void
{
@@ -48,7 +51,7 @@ class PollShipmentTrackingJob implements ShouldQueue
return;
}
Shipment::query()
$jobs = Shipment::query()
->whereIn('carrier', $trackableCarriers)
->whereNull('cancelled_at')
// Pending vouchers (issued on print) have no number to track yet.
@@ -60,26 +63,17 @@ class PollShipmentTrackingJob implements ShouldQueue
TrackingStatus::Cancelled->value,
]);
})
->chunkById(50, function ($shipments) {
$shipments->groupBy('carrier')->each(
fn ($group, $carrier) => $this->pollCarrierShipments($carrier, $group)
);
});
}
->pluck('id')
->map(fn (int $id) => new RefreshShipmentTrackingJob($id));
private function pollCarrierShipments(string $carrier, $shipments): void
{
if (! $this->fulfillmentService($carrier) instanceof SupportsTracking) {
if ($jobs->isEmpty()) {
return;
}
foreach ($shipments as $shipment) {
try {
app(ShipmentTrackingRecorder::class)->refresh($shipment);
} catch (Throwable $e) {
report($e);
}
}
Bus::batch($jobs)
->name('Poll shipment tracking')
->allowFailures()
->dispatch();
}
private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
@@ -0,0 +1,63 @@
<?php
namespace Modules\Core\Shipping\Jobs;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Services\ShipmentTrackingRecorder;
/**
* Refreshes one shipment's tracking from its carrier — one job per
* shipment in PollShipmentTrackingJob's batch, so a slow or failing
* carrier call only costs its own job, not the rest of the run.
*
* Unique per shipment: while one is queued or running, another for the
* same shipment isn't queued, so two refreshes can't both record the same
* checkpoint (the recorder's dedup reads, then writes).
*/
class RefreshShipmentTrackingJob implements ShouldBeUnique, ShouldQueue
{
use Batchable;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
/** A few carrier calls at a 10–15s HTTP timeout each; under the queue's 90s retry_after. */
public int $timeout = 45;
public bool $failOnTimeout = true;
/** The next poll, five minutes later, is the retry. */
public int $tries = 1;
/** Releases the lock if a worker dies mid-job and never releases it. */
public int $uniqueFor = 300;
public function __construct(public readonly int $shipmentId) {}
public function uniqueId(): string
{
return (string) $this->shipmentId;
}
public function handle(ShipmentTrackingRecorder $recorder): void
{
if ($this->batch()?->cancelled()) {
return;
}
$shipment = Shipment::find($this->shipmentId);
// Cancelled (or deleted) since the batch was built.
if (! $shipment || $shipment->isCancelled()) {
return;
}
$recorder->refresh($shipment);
}
}
+40 -4
View File
@@ -67,15 +67,15 @@ class Shipment extends Model
}
/**
* A link to the carrier's own tracking page — manual carriers only.
* Integrated carriers' history is synced, so customers follow it on
* our own order page instead.
* A link to the carrier's own tracking page — manual carriers and
* typed-in vouchers only. API-created shipments' history is synced, so
* customers follow it on our own order page instead.
*/
public function trackingUrl(): ?string
{
$template = $this->meta['tracking_url'] ?? null;
if ($this->source !== self::SOURCE_MANUAL || blank($template) || blank($this->tracking_reference)) {
if (! in_array($this->source, [self::SOURCE_MANUAL, self::SOURCE_MANUAL_VOUCHER], true) || blank($template) || blank($this->tracking_reference)) {
return null;
}
@@ -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)