Compare commits

..
12 Commits
49 changed files with 3512 additions and 440 deletions
+266
View File
@@ -4,6 +4,272 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.31.0] - 2026-10-01
### Added
- A "Create voucher" button on Carrier Vouchers, for a parcel that isn't a
shop order. The form follows the carrier:
- **ELTA:** recipient address and phones, envelope or parcel, packages,
weight, dimensions (sent as volumetric weight), cash on delivery,
extra services, remarks and our reference.
- **Box Now:** the locker, one size per box, the recipient's mobile and
email, cash on delivery, parcel value and reference.
The voucher has no order. An ELTA one waits on Pending Vouchers, which
now lists vouchers without an order, until it's printed. It can be
linked to an order later. Carriers opt in with the new
`CreatesStandaloneVouchers` contract.
- Carrier Vouchers:
- a **Carrier** column;
- a **Print** button, in the list and on a voucher's page, for vouchers
created here (from an order or with "Create voucher");
- vouchers created here that are still waiting for their number (ELTA
issues it on print) are listed too, as "Not issued yet".
Empty Status and Order cells now read "None yet" and "None".
- An order's "Create Shipment" for ELTA and Box Now opens the same voucher
form (`VoucherForm`), prefilled from the order:
- recipient, phones, email and delivery instructions from the shipping
address;
- weight from the order lines, and Box Now's locker from the checkout;
- insurance defaulting to the order total;
- cash on delivery shown read-only, from the payment method.
Staff can correct the recipient before it's sent. The correction goes
to the carrier and is saved on the voucher (`ShipmentRequest::$recipient`),
while the order keeps what the customer entered. Envelope and dimensions
are now available for orders' ELTA vouchers too. Manual carriers and ACS
keep their forms.
- Vouchers save their recipient when they're created (`meta.recipient`,
the new `VoucherRecipient` DTO), orders' vouchers included.
`Shipment::recipient()` and `Shipment::reference()` return them. ELTA
labels are rendered from these alone, not from the order. Vouchers
created before this fall back to their order's shipping address.
- ELTA vouchers can be envelopes and can carry dimensions: the parcel
flag becomes `1`, and `pel_x`/`pel_y`/`pel_z` plus the volumetric
weight are sent and printed. So far only through "Create voucher".
- ELTA return vouchers ("RETOUR"). With special handling, the voucher is
sent with us as the return-to party, as ELTA's client does. ELTA then
issues a return voucher with it. Printing prints it after the voucher
and its parcels: parties swapped, service 461/462, charged to the
recipient. Its number shows on the order page and on the Carrier
Vouchers view.
- ELTA PostBox delivery, as a new "PostBox delivery" extra service. The
postcode goes through ELTA's PostBox lookup (`PELTKBOX`), and the
voucher carries surcharge `917`. The label prints ELTA's
"* ΠΡΟΣΟΧΗ PostBox *" warnings. ELTA's rules are checked when the
shipment is created:
- no special handling;
- no cash on delivery;
- a 69… mobile;
- a postcode that has a PostBox.
- ELTA tracking now stores who received the parcel and when, and the COD
amount ELTA paid back to us. These show on the order page and in a new
Delivery section on the Carrier Vouchers view.
### Changed
- ELTA's default endpoint is now `https://clients.elta-courier.gr`. The old
`http://212.205.47.226:9003` only speaks plain HTTP. Apps that set
`ELTA_SERVICE_LOCATION` should update it. Calls are retried twice on a
connection error or a 5xx.
- ELTA vouchers are sent with the service and locality that ELTA's
`PELTKNEW` gives for the postcode (sender station included): 101/201 for
the city, 211 for islands, 231 for hard-to-reach localities. When
several localities share a postcode, the one matching the address's city
is used. When none matches, ELTA picks the service and the order page
says so. Before this the service was blank and the area was the city
text.
- ELTA extra services go in ELTA's fixed slots: special handling / PostBox
in `pel_sur_1`, timed delivery in `pel_sur_2`, Saturday delivery in
`pel_sur_3`.
- ELTA remarks carry the parcel flag, as ELTA's own client sends them:
the remarks are right-aligned in 99 characters, followed by `2`.
- ELTA labels print ELTA's current company details (Λεωφ. Ιωνίας 200,
Γ.Ε.ΜΗ) on the A4 copies, the payment stub, the A4 child label and the
A6. The payment stub's ΠΟΡΤΑ-ΠΟΡΤΑ block is gone.
### Fixed
- Creating an ELTA shipment now fails with a clear message:
- with no recipient phone;
- with more than 20 pieces;
- with a postcode ELTA doesn't serve.
Before, ELTA accepted the pending voucher and it failed later.
- Phone numbers sent to ELTA drop the `30` / `0030` prefix. Mobiles
(`69…`) go in the mobile field.
- "Απόδειξη Είσπραξης" is now spelled correctly on the ELTA sender's
copy.
- ELTA labels left the deposit station (Γρ.Κατάθεσης) blank when
`ELTA_ORIGIN_STATION_CODE` isn't set. They now use the station ELTA's
login reports, as issuing already did.
- The ELTA A4 voucher no longer has overlapping parts:
- **Payment stub:** laid out again on a clean grid, with the same
fields. It now carries ELTA's "Hellenic Post" logo and "Αποστολή μας
εσείς!" (`resources/logos/elta-logo.png`), as ELTA's own stub does.
The remarks fit on one line.
- **Header box:** the title no longer runs into the barcode, the
barcode fits inside the box, and the voucher number sits within its
border.
- **Copy 1:** the tear-strip line runs beside the boxes, not through
them.
- **Copy 2:** it starts clear of the cut line, and the print date stays
inside its box.
- **REFERENCE / ΠΑΡΑΤΗΡΗΣΕΙΣ:** the boxes have no more gaps in their
borders.
- On the A6, "ΧΡΕΩΣΗ … ΠΙΣΤΩΣΗ" no longer wraps out of its cell.
- A Box Now shipment with several boxes got only one voucher. Box Now
merges the items of one delivery request into a single parcel at the
largest size (verified on staging, with our payload and with the
official plugin's), although its manual says each item becomes a
parcel. Each box is now its own delivery request, so each gets its own
voucher, label and compartment:
- order numbers `…-b1`, `…-b2`;
- "Box 1 of 2" on the order page;
- the order's value and any cash on delivery on box 1;
- if a later box fails, the boxes already created are cancelled.
- Creating a Box Now shipment without the recipient's mobile or email now
fails with a clear message, instead of sending Box Now an empty
contact. The locker pickup code goes to both.
## [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 ## [0.29.0] - 2026-09-30
### Added ### Added
- **ELTA Courier** carrier (`elta` shipping driver): rates, shipment creation, - **ELTA Courier** carrier (`elta` shipping driver): rates, shipment creation,
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.29.0", "version": "0.31.0",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
+14
View File
@@ -16,6 +16,20 @@ return [
'auto_create_customer_for_user' => true, '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 | Privacy / GDPR data-subject requests
+3 -1
View File
@@ -48,7 +48,9 @@
return [ return [
'service_location' => env('ELTA_SERVICE_LOCATION', 'http://212.205.47.226:9003'), // ELTA's current host, over HTTPS. The old 212.205.47.226:9003 still
// answers, but plain HTTP only (see docs/elta.md, A1).
'service_location' => env('ELTA_SERVICE_LOCATION', 'https://clients.elta-courier.gr'),
'user_code' => env('ELTA_USER_CODE'), 'user_code' => env('ELTA_USER_CODE'),
'user_pass' => env('ELTA_USER_PASS'), 'user_pass' => env('ELTA_USER_PASS'),
+709
View File
@@ -0,0 +1,709 @@
# ELTA Courier — API and printing reference
A reference for designing the ELTA Courier integration, reconstructed from ELTA's own
desktop client ("ELTA Customer Client", `ELTA_PEL.exe`, client version `0032`), decompiled
from ELTA's official installer (`https://www.elta-courier.gr/app/ELTA_COURIER_SETUP.msi`).
The decompiled source lives next to this repo in `../elta-courier-decompiled/` (see its
README); file and line references below point there. Where something was confirmed by
calling ELTA's test account from our integration, it says so.
Two axes:
- **Part A — the API**: every SOAP operation, its inputs and outputs, the values the
client sends, and the business rules it enforces before calling.
- **Part B — printing**: what the client prints, when, from which data, with every
fixed text, every image and every formatting rule.
Part C lists where our current integration (`src/Shipping/Carriers/Elta/`) differs.
---
# Part A — The API
## A1. Transport
| | |
|---|---|
| Host | **`https://clients.elta-courier.gr`** (port 443). Verified 2026-09-30: the hostname now resolves to `18.156.16.255` (AWS Frankfurt) and serves HTTPS with a valid certificate; answers are identical to the old host. |
| Old host | `212.205.47.226:9003` (the installer's `hosts-entry.txt`) — **plain HTTP only**: HTTPS there fails the TLS handshake (`SSL_ERROR_SYSCALL`), and `https://clients.elta-courier.gr:9003` fails with `wrong version number` (that port speaks HTTP). |
| Scheme | The client builds its URL as `"https://" + <first field of ip.txt>` (`Form1.cs:213`), i.e. HTTPS on the hostname. |
| Reliability | The HTTPS host was slower (~0.4–0.8 s vs ~0.2 s) and returned one transient HTTP 500 and one 15 s timeout during testing; retry on network errors / 5xx. |
| Protocol | SOAP 1.1, document/literal. One endpoint URL for every operation. |
| Namespace | Each operation has its own: `/<OPERATION>` (e.g. `/PELVGNEW`). |
| Body | `<READ>` element in that namespace, with each parameter as a child element. |
| SOAPAction | We send `Get` (works); the proxies' `SoapDocumentMethod` arguments weren't decodable. |
| Authentication | None at the HTTP/SOAP level. Account codes travel as ordinary body parameters. `PELLOGINNEW` checks the password; other operations take the account/user code without a password. |
| Proxy | Optional corporate HTTP proxy from `proxy.txt` (`ip;port;user;pass`), with `Expect100Continue = false`. Local-network only; nothing to do with ELTA auth. |
| WSDL | None published for these operations; the shapes come from the client's generated proxies (`ELTA_PEL.pel_*/`). |
**Every response** carries:
- `st_flag` — the return value of `READ` (declared `integer`). **`"0"` = success**; anything
else is an error.
- `st_title` — the message (Greek) to show on error, e.g. `Λάθος Τ.Κ.` (wrong postcode),
`Λάθος ΣΥΔΕΤΑ` (wrong voucher), `Δεν Επιτρέπεται! Εχει Γίνει Εκτύπωση` (not allowed,
already printed).
**Paging convention** (every list operation): a page is at most **100 rows**. If a page
comes back with exactly 100 rows, call again passing the last row's id as the cursor
(`in_id`, `pel_sub_code` or `a_paral_code`, depending on the operation); the client stops
when a page has fewer than 100 (or the 100th row is empty).
**Number formats**: amounts come back right-aligned in 9 characters (`" 0.00"`);
dates go in as `dd/MM/yyyy`; timestamps come back as `yyyyMMddHHmm[ss]` in Greek local time.
## A2. Operations at a glance
| Operation | Purpose | Client screen |
|---|---|---|
| `PELLOGINNEW` | Log in, get account details and the user's station | Login |
| `PELVERNEW` | Latest client version (triggers FTP self-update) | Login |
| `PELLISTNEW` | Sub-senders of a multi-sender account | Login |
| `PELSRV` | Service catalogue | Login |
| `PELSUR` | Surcharge catalogue | **Never called** (surcharges are hardcoded) |
| `PELPARALLIST1` | Saved recipients (address book) | Login |
| `PELTKNEW` | Postcode → station, default service, localities | Voucher entry |
| `PELTK` | Postcode → station, service (older/simpler) | Voucher entry, recipient pick |
| `PELTKBOX` | Postcode → PostBox station and service | Voucher entry (PostBox) |
| `PELTK1` | Is this postcode valid? | File/Excel import |
| `PELVGNEW` | **Create a voucher** (pending or issued) | Voucher entry |
| `PELVGUP` | Edit a pending voucher | Voucher entry (edit mode) |
| `PELVGINNEW` | Create a pending voucher, simplified (bulk) | File/Excel import |
| `PELVG01NEW1` | **Issue** a pending voucher (and re-print an issued one) | Batch processing |
| `PELVGDEL` | Delete a pending voucher | Batch processing |
| `PELPARALVGNEW1` | List pending (and optionally issued) vouchers | Batch processing |
| `PELTTNEW01` | Track one voucher (by voucher number **or reference**) | Tracking |
| `PELMANIF2` | Vouchers in a date range with latest status, by status | Multiple search |
| `PELMANIFNEW` | Index / manifest of vouchers in a date range, with totals | Index report |
| `PELPARALIN` / `PELPARALUP` / `PELPARALSE` | Address book: insert / update / fetch one | Recipients |
| `PELPARALDE` / `PELPARALDE1` | Address book: delete one / delete a code range | Recipients |
| `PELPARALCE` | Address book: check a code and postcode before import | Recipient Excel import |
## A3. Account and reference data
### `PELLOGINNEW` — log in
| In | |
|---|---|
| `pel_code` | Customer (account) code, e.g. test account `999999999` |
| `user_code` | User code (digits only in the client) |
| `user_pass` | Password |
| Out | |
|---|---|
| `st_title` | Error message |
| `pel_flag` | `"1"` = multi-sender account (has sub-senders, see `PELLISTNEW`); `"0"` = single sender |
| `pel_titles`, `pel_addres`, `pel_area`, `pel_tk`, `pel_tel_1`, `pel_tel_2` | Account name/address/area/postcode/phones — printed as the **sender** on single-sender accounts |
| `user_station` | The user's ELTA station code — sent as `sender_station` to the postcode lookups and to `PELVG01NEW1`, and printed as the deposit station (`Γρ.Κατάθεσης`) |
| `user_pel_code` | The account code to use in every later call (`pel_code` / `pel_apost_code`) |
After a successful login the client calls `PELVERNEW` (if an FTP address is configured);
if `ag_wupdate_version` > its own version (`0032`) it downloads an update over FTP and
stops. Then it loads the reference lists below.
### `PELVERNEW` — client version
No inputs. Out: `st_title`, `ag_wupdate_version`.
### `PELLISTNEW` — sub-senders (multi-sender accounts only)
In: `pel_code` (= `user_pel_code`), `pel_sub_code` (cursor: last code of the previous page,
`""` first). Out: `st_title`, `ag_rec_rec[]` (100/page), `ag_rec_counter`.
Row: `ag_code`, `ag_title`, `ag_adress`, `ag_area`, `ag_tk`, `ag_tel_1`, `ag_tel_2`,
`ag_st` (station), `ag_st_t` (station title).
Used to pick the **sender** of a voucher and the **return-to** party (see special handling).
### `PELSRV` — service catalogue
No inputs. Out: `st_title`, `ag_srv_rec[]` (up to 150: `ag_code`, `ag_title`), `ag_srv_counter`.
The service picker only offers services whose code starts with the **same two digits** as
the one the postcode lookup returned (same family).
### `PELSUR` — surcharge catalogue
No inputs. Out: `ag_sur_rec[]` (`ag_code`, `ag_title`). **The client never calls it**; its
surcharges are hardcoded (see A5).
### `PELPARALLIST1` — saved recipients
In: `pel_code`, `a_paral_code` (cursor). Out: `ag_rec_rec[]` (100/page: `ag_code`,
`ag_title`, `ag_adress`, `ag_area`, `ag_tk`, `ag_tel_1`, `ag_tel_2`), `ag_rec_counter`.
## A4. Postcode, station and service lookups
### `PELTKNEW` — the main lookup
In: `pel_tk` (postcode), `sender_station` (= `user_station`).
| Out | |
|---|---|
| `pel_fields.rec_station`, `rec_station_t` | Destination station code and title (`Γρ. Επίδοσης`) |
| `pel_fields.pel_service`, `pel_service_t` | Default service for that postcode |
| `pel_fields.pel_service_t201`, `pel_service_t231` | Titles of services 201 and 231 (see localities) |
| `a_lview_data[]` | Up to 300 **localities** within the postcode |
| `locations_counter` | Number of localities; `"0"` = none, use `pel_service` as is |
Locality row: `wloc_code`, `wloc_title`, `wloc_county`, `wloc_countyt`, `wloc_station`,
`wloc_stationt`, `wloc_distance` (decimal), `wloc_postal_code`, `wloc_coordinate_x`,
`wloc_coordinate_y`, `wloc_disp_flag`, `wloc_comments`.
**When a postcode has localities**, the user must pick one (`Sydeta.cs:3320`):
- the recipient **area** becomes `"<wloc_code>/<wloc_title>"`;
- `wloc_disp_flag = "1"` (hard to reach, `ΔΥΣΠΡΟΣΙΤΟ`) → service **`231`**
`ΠΟΛΗ ΠΟΛΗ -ΠΠ- ΔΥΣΠΡΟΣΙΤΟ`; otherwise service **`201`** `ΕΝΤΟΣ ΠΟΛΗΣ -ΠΠ- ΕΠΟΜΕΝΗ`.
### `PELTK` — simple lookup
In: `pel_tk`, `sender_station`. Out: `st_title`, `rec_station`, `rec_station_t`,
`pel_service`, `pel_service_t`. Used when a saved recipient is picked.
### `PELTKBOX` — PostBox lookup
In: `pel_tk`, `sender_station`. Out: `st_title`, `pel_fields` (`rec_station`,
`rec_station_t`, `pel_service`, `pel_service_t`). Used instead of `PELTKNEW` when
"ΕΠΙΔΟΣΗ POST BOX" is ticked; on error the PostBox option is unticked.
### `PELTK1` — validate a postcode
In: `pel_tk`. Out: `st_title` (`st_flag` ≠ 0 = invalid). Used by the bulk imports.
## A5. Vouchers (ΣΥ.ΔΕ.ΤΑ.)
### Lifecycle
```
PELVGNEW (pel_insert_flag "0") ─► pending: has pel_vg_id, no voucher number
│ │ editable (PELVGUP), deletable (PELVGDEL)
│ ▼
│ PELVG01NEW1 (issue) ─► issued: voucher number, OCR line,
│ stations, service; child numbers
└── (pel_insert_flag "1") ─► issued immediately, same outputs as PELVGNEW
```
- **Only pending vouchers can be deleted.** An issued one is refused (`Δεν Επιτρέπεται!
Εχει Γίνει Εκτύπωση`). Confirmed on the test account.
- In the client, **issuing = printing**: "Print" in batch processing calls `PELVG01NEW1`
and prints the result. It calls `PELVG01NEW1` for already-issued rows too, so it also
serves to **re-print** (ELTA returns the existing voucher's data).
- A **multi-parcel** send is **one** voucher record (`pel_temaxia` > 1). The extra parcels
get their own numbers (`vg_child_no[]`) only when the voucher is issued. They are not
separately listed, deletable or trackable (tracking an extra parcel's number returns
`Λάθος ΣΥΔΕΤΑ` — confirmed).
### `PELVGNEW` — create a voucher
The client's call (`Sydeta.cs:1550`), in order:
| # | Parameter | Client value | Notes / rules |
|---|---|---|---|
| 1 | `pel_apost_code` | Sender code: `user_pel_code` (single-sender), or the chosen sub-sender | Required |
| 2 | `pel_paral_name` | Recipient name | Required; up to 150 chars in the form |
| 3 | `pel_paral_address` | Recipient address | Required; up to 150 |
| 4 | `pel_paral_area` | Recipient area (or `"<wloc_code>/<wloc_title>"`) | Up to 40 |
| 5 | `pel_paral_tk` | Postcode | Required; 5 digits |
| 6 | `pel_paral_thl_1` | Phone | Digits only, up to 10. **At least one of the two phones is required** |
| 7 | `pel_paral_thl_2` | Mobile | Up to 10. PostBox: required, 10 digits, starting `69` |
| 8 | `pel_service` | Service code from the postcode lookup / picker | **Required** by the client (`Δεν Εχει Οριστεί Υπηρεσία`). We send `""` and ELTA assigns one on issue — works on the test account |
| 9 | `pel_baros` | Weight in kg, `.` decimal | **Required, > 0** |
| 10 | `pel_baros_xyz` | Volumetric weight: **X × Y × Z (cm) ÷ 5000**, 3 decimals | Only when dimensions are given |
| 11–13 | `pel_x`, `pel_y`, `pel_z` | Dimensions in cm, integers up to 3 digits | All three > 0 or none (`Λάθος Διαστάσεις`) |
| 14 | `pel_temaxia` | Number of pieces | **Required, 1–20** (`Μέγιστος Επιτρεπόμενος Αριθμός Τεμαχίων 20.`) |
| 15 | `pel_paral_sxolia` | Remarks **+ envelope/parcel flag**, see below | Remarks up to **90** chars |
| 16 | `pel_sur_1` | `002` special handling, or `917` PostBox | Fixed slot, see below |
| 17 | `pel_sur_2` | `003` set delivery time | Fixed slot |
| 18 | `pel_sur_3` | `004` Saturday delivery | Fixed slot |
| 19 | `pel_ant_poso` | **COD cash** amount | `.` decimal |
| 20–23 | `pel_ant_poso1`…`4` | COD **cheque** amounts (up to 4 cheques) | |
| 24–27 | `pel_ant_date1`…`4` | Each cheque's due date, `dd/MM/yyyy` | Required per cheque; **not in the past** |
| 28 | `pel_asf_poso` | Insured value | **The client always sends a single space `" "`** — it has no insurance input |
| 29 | `pel_user` | `user_code` | |
| 30 | `pel_ref_no` | "Reference No" | Up to 30 chars; printed on the voucher, searchable, trackable |
| 31 | `pel_insert_flag` | `"1"` = issue now ("Εκτύπωση ΣΥ.ΔΕ.ΤΑ." ticked), `"0"` = pending | We send `"0"` |
| 32 | `pel_paral_code` | Saved recipient's code, if picked from the address book | |
| 33 | `pel_retur_code` | Return-to party's code (special handling only) | See below |
**Envelope or parcel (mandatory).** The client makes the user choose `Φάκελος` (envelope)
or `Δέμα` (parcel) (`Δεν Εχει Επιλεγεί Είδος`), and encodes it **inside
`pel_paral_sxolia`**: the remarks right-aligned in 99 characters, then `"1"` (envelope) or
`"2"` (parcel) — 100 characters total (`Sydeta.cs:1530`):
```csharp
pel_paral_sxolia = $"{remarks.Trim(),99}" + "2"; // parcel
```
(Only `PELVGNEW` does this; `PELVGUP` and `PELVGINNEW` send the remarks plain.)
**Surcharges go in fixed slots**, not in order: special handling is always `pel_sur_1`, set
time always `pel_sur_2`, Saturday always `pel_sur_3`; PostBox (`917`) takes `pel_sur_1`.
The client's surcharges:
| Code | Surcharge | Slot |
|---|---|---|
| `002` | ΕΙΔΙΚΗ ΔΙΑΧΕΙΡΙΣΗ (special handling) | `pel_sur_1` |
| `003` | ΠΡΟΚΑΘΟΡΙΣΜΕΝΗ ΩΡΑ (set delivery time) | `pel_sur_2` |
| `004` | ΕΠΙΔΟΣΗ ΣΑΒΒΑΤΟΥ (Saturday delivery) | `pel_sur_3` |
| `917` | ΕΠΙΔΟΣΗ POST BOX (PostBox delivery) | `pel_sur_1` |
**Special handling (`002`) and the return-to party.** Ticking special handling sets a
return-to party (`pel_retur_code`), defaulting to the sender; multi-sender accounts can
pick another sub-sender. When ELTA then returns a **return voucher** (`return_vg`), the
client prints it (B6).
**Business rules the client enforces** (all before calling ELTA):
- name, address, postcode, service required; at least one phone; pieces 1–20; weight > 0;
- COD is **cash or cheques, never both** (`Δεν Επιτρέπεται Αντικαταβολή Μετρητά Και Επιταγή`);
- each cheque needs a due date, not in the past;
- **PostBox**: not with special handling (`Δεν Επιτρέπεται Post Box Με ειδική Διαχείρηση`),
not with COD (`Δεν Επιτρέπεται Post Box Με Αντικαταβολή`), and needs a 10-digit mobile
starting `69` (`Δεν Επιτρέπεται Post Box Χωρίς Κινητό Τηλέφωνο` / `...Λάθος Κινητό Τηλέφωνο`);
- envelope or parcel must be chosen.
**Outputs** (same set from `PELVG01NEW1`, plus stations/service there):
| Out | |
|---|---|
| `vg_code` | The voucher number, e.g. `NZ000987368GR` (empty when pending) |
| `vg_child_no[]` | One number per extra parcel of a multi-parcel send (up to 149) |
| `ocr_line` | OCR line, e.g. `>14260009873682< 17004> 1317759< 25>` — printed at the foot of the A4 payment stub; characters 2–14 are the 13-digit reference printed on the voucher |
| `return_vg`, `r_ocr_line` | Return voucher number and its OCR line (special handling) |
| `epitagh_vg`, `e_ocr_line` | Cheque-return voucher number and its OCR line (COD by cheque) |
| `date_time` | Issue time, `yyyyMMddHHmm` (Greek time). The client picks the report by it (≥ `202410` → current reports) |
`PELVGNEW` does **not** return the pending voucher's id (`pel_vg_id`); find it with
`PELPARALVGNEW1` (match `pel_ref_no`, empty `pel_paral_vg`, highest id).
### `PELVG01NEW1` — issue (and re-print)
In: `pel_id` (= `pel_vg_id`, declared `integer`), `sender_station` (= `user_station`).
Out: `st_title`, `return_vg`, `epitagh_vg`, `vg_code`, `vg_child_no[]`, `ocr_line`,
`r_ocr_line`, `e_ocr_line`, `date_time`, `rec_station`, `rec_station_t` (destination
station), `rec_srv`, `rec_srv_t` (the service ELTA settled on, e.g. `201` `ΠΟΛΗ ΠΟΛΗ - ΠΠ`).
### `PELVGUP` — edit a pending voucher
In: `pel_id`, then the same fields as `PELVGNEW` 1–30 and `pel_paral_code` (no
`pel_insert_flag`, no `pel_retur_code`; remarks sent plain). Out: `st_title`.
### `PELVGINNEW` — bulk insert (pending)
Used by the file/Excel imports. In: `pel_code` (sender), `user_code`, `paral_title`,
`paral_adress`, `paral_area`, `paral_tk`, `paral_thl1`, `paral_thl2`, `paral_tem`
(pieces), `paral_baros`, `paral_sxolia`, `paral_ant_m` (COD cash), `paral_ant_e` (COD
cheque), `paral_ant_date` (cheque due date), `paral_da` (always `""`), `paral_ref`,
`paral_sur_1`…`3`, `retour_code`. Out: `st_title`. No service, no envelope/parcel flag,
no dimensions — ELTA works them out.
### `PELVGDEL` — delete a pending voucher
In: `pel_vg_id` (the pending id, not the voucher number). Out: `st_title`. The client
only offers it for rows without a voucher number.
### `PELPARALVGNEW1` — list pending vouchers
In: `pel_code`, `pel_user_code`, `flag_1` (`"1"` = "Εμφάνιση Όλων": include issued ones),
`flag_2` (`"1"` = "Όλοι Οι Χρήστες": all the account's users), `in_id` (cursor: last
`pel_vg_id`, `"0"` first). Out: `st_title`, `vg_rec[]` (100/page), `vg_rec_counter`.
Row fields: `pel_vg_id`, `pel_apost_code`, `pel_paral_vg` (voucher number; empty =
pending), `pel_paral_code`, `pel_paral_name`, `pel_paral_address`, `pel_paral_area`,
`pel_paral_tk`, `pel_paral_thl_1`, `pel_paral_thl_2`, `pel_service`, `pel_baros`,
`pel_baros_xyz`, `pel_x`, `pel_y`, `pel_z`, `pel_temaxia`, `pel_paral_sxolia`,
`pel_ant_poso`, `pel_ant_poso1`…`4`, `pel_ant_date1`…`4`, `pel_asf_poso`, `pel_ref_no`,
`pel_sur_1`…`3`, `pel_retour`.
## A6. Tracking and search
### `PELTTNEW01` — track one voucher
In: `web_vg` — **a voucher number or our reference number** (the client's field is labelled
`ΣΥ.Δ.ΕΛ.ΤΑ./Reference No`), `pel_code`.
Out: `st_title`, `web_status[]` (up to 50 checkpoints), `web_status_counter`, `pel_rec`.
Checkpoint: `web_date_time` (`yyyyMMddHHmmss`, Greek time), `web_station`,
`web_status_name` (e.g. `ΔΗΜΙΟΥΡΓΙΑ ΣΥ.ΔΕ.ΤΑ. ΑΠΟ ΠΕΛΑΤΗ`, in unaccented capitals),
`web_sxolia` (e.g. `PEL CLIENT`). Empty rows pad the array; skip rows without a date.
`pel_rec` (the voucher record):
| Field | Meaning |
|---|---|
| `a_vg_3` | The voucher number |
| `a_vg_2` | Its **return** voucher — verified: a special-handling voucher's `return_vg` came back here |
| `a_vg_1` | Its **cheque** voucher (by elimination; the client's field labels suggested the opposite) |
| `a_ref` | Our reference |
| `a_rec_title`, `a_rec_address`, `a_rec_area`, `a_rec_postal`, `a_rec_tel_1`, `a_rec_tel_2` | Recipient |
| `a_rec_station`, `a_rec_station_t` | Destination station |
| `a_services` | Service (code/text) |
| `a_weight` (decimal), `a_no_of_packages` | Weight, pieces |
| `a_antik` (decimal), `a_antik_text` | COD amount and text |
| `a_antik1` (decimal), `a_antik_text1` | **Remittance** ("Απόδοση") — the COD paid back to the merchant |
| `a_sender_date` | Date sent |
| `a_rec_name` | **Delivered to** — the name of whoever received it |
| `a_rec_date_time` | **Delivered at** — blank until delivered |
| `a_apost`, `a_sxolia` | Sender, remarks |
### `PELMANIF2` — vouchers in a date range, by status ("Πολλαπλή Αναζήτηση")
In: `pel_code`, `date_1`, `date_2` (`dd/MM/yyyy`), `paral_code` (optional recipient),
`status_flag`, `in_id` (cursor: last `pel_col_id`).
| `status_flag` | |
|---|---|
| `0` | All (`Ολα`) |
| `1` | Delivered (`Παραδομένα`) |
| `2` | Undelivered (`Απαραδοτα`) |
| `3` | To be returned (`Προς Επιστροφή`) |
Row: `pel_col_1` voucher · `pel_col_2` reference · `pel_col_3` recipient · `pel_col_4`
postcode · `pel_col_5` station · `pel_col_6` last status · `pel_col_7` remarks/date ·
`pel_col_8` COD · `pel_col_id` cursor. Only main vouchers are listed.
### `PELMANIFNEW` — index / manifest ("Ευρετήριο")
In: `pel_code`, `date_1`, `date_2`, `paral_code`, `in_id` (cursor: last `pel_col_15`).
Out: `ag_pel_rec[]`, `ag_pel_counter`, **`spel_baros`** (total weight of the page, decimal),
**`spel_temaxia`** (total pieces of the page).
Row: `pel_col_1` voucher · `2` weight · `3` pieces · `4` recipient · `5` address ·
`6` postcode · `7` station · `8` sender · `9` reference · `10` COD · `11` **cheque
voucher** · `12` **return voucher** · `13` + `14` the two phones · `15` cursor.
## A7. Address book
| Operation | In | Out |
|---|---|---|
| `PELPARALIN` | `pel_code`, `paral_code`, `paral_title`, `paral_adress`, `paral_area`, `paral_tk`, `paral_thl1`, `paral_thl2` | `st_title` |
| `PELPARALUP` | same as `PELPARALIN` | `st_title` |
| `PELPARALSE` | `pel_code`, `paral_code` | the recipient's fields |
| `PELPARALDE` | `pel_code`, `paral_code` | `st_title` |
| `PELPARALDE1` | `pel_code`, `paral_code_1`, `paral_code_2` (a code range) | `st_title` |
| `PELPARALCE` | `pel_code`, `paral_code`, `paral_tk`, `tk_flag`, `pel_flag` | `st_tk` (`"1"` = bad postcode), `st_code` (`"1"` = code already exists) |
## A8. Client-side configuration files (for reference)
| File | Content |
|---|---|
| `ip.txt` | `<host:port>;<ftp host>` — web-service host (prefixed with `https://`) and update FTP host |
| `proxy.txt` | `ip;port;user;pass` |
| `printer.txt` | `print_on_create(0/1);printer name;paper size` — `1` = A4, `2` = label (A6) |
| `paral.txt`, `awb.txt` | Excel column letters for the recipient (7) and voucher (16) imports |
## A9. Excel import (voucher columns)
Recipient name, address, area, postcode, phone, mobile, pieces, weight, remarks, COD cash,
COD cheque, cheque due date, reference, surcharge 1–3 — each mapped to a column letter
(defaults `A`…`P`). Rows are validated (postcode via `PELTK1`, PostBox via `PELTKBOX`,
pieces ≤ 20, …), invalid ones shown red and skipped; valid ones go through `PELVGINNEW`
as pending vouchers, issued later from batch processing.
---
# Part B — Printing
## B1. When the client prints
- **On creation**, if "Εκτύπωση ΣΥ.ΔΕ.ΤΑ." is ticked (default from `printer.txt`): it sends
`pel_insert_flag = "1"` and prints straight away.
- **From batch processing** ("Εκτύπωση Επιλεγμένων" / "Εκτύπωση Όλων"): issues each voucher
with `PELVG01NEW1` and prints it.
Each print job prints, in order (`Sydeta.cs:1646`, `epexergasia.cs`):
1. the **main voucher** (`print_vg`);
2. one label per **extra parcel**, `002/00n`, `003/00n`, … (`print_child`) — printing the
main voucher always prints every parcel; there is no way to print one extra parcel alone;
3. the **return voucher**, if ELTA returned `return_vg` (B6);
4. the **cheque-return voucher**, if ELTA returned `epitagh_vg` (B7).
## B2. Paper and report selection
| Paper (`printer.txt`) | Page (render DeviceInfo) | Main voucher | Extra parcel |
|---|---|---|---|
| `1` A4 | 21 × 29.7 cm, no margins | `sydetaE.rdlc` | `sydetaE1.rdlc` (separate, smaller report) |
| `2` label | **10.4 × 14.8 cm** (not ISO A6's 10.5), no margins | `SydetaLabelE.rdlc` | `SydetaLabelE.rdlc` (same report) |
Vouchers issued before October 2024 (`date_time` < `202410`) use the older reports
(`sydeta`, `sydeta1`, `SydetaLabel1`). `sydeta_R`, `sydeta1_R`, `SydetaLabel2` are only
on-screen previews. Rendering is EMF at the page size, drawn to the whole page, sent to the
configured printer without a dialog (a print dialog only if no printer is configured).
## B3. The print data — every field
The reports read one dataset row (`DataSet1.print_fields`). How the client fills it for a
**main voucher** (`Sydeta.cs::print_vg`, `epexergasia.cs::print_vg`):
**Sender (`sender_1`…`5`)**
| | |
|---|---|
| `sender_1` | `"Κωδικός:" + sender code` |
| `sender_2`, `sender_3` | Sender name, split at 40 characters (`sender_3` = the next 40, else `""`) |
| `sender_4` | Sender address (first 40 characters) |
| `sender_5` | `"TK:" + postcode + " " + area + " ΤΗΛ:" + phone`, cut at 40 |
Sender = the account (single-sender) or the chosen sub-sender.
**Recipient (`rec_1`…`5`)**
| | |
|---|---|
| `rec_1`, `rec_2` | Recipient name, split at 40 (`rec_2` = the rest, else `""`) |
| `rec_3` | Address (first 40) |
| `rec_4` | `"TK:" + postcode + " " + area` |
| `rec_5` | `"ΤΗΛ: " + phone + " " + mobile` |
**Voucher and routing**
| Field | Value |
|---|---|
| `sydeta` | Voucher number, e.g. `NZ000987368GR` |
| `barcode` | `"*" + voucher + "*"` — printed in a **Code 39 barcode font** (the asterisks are Code 39 start/stop) |
| `barcode_2` | `ocr_line` characters 2–14 (13 digits), e.g. `1426000987368` |
| `date` | A4: `dd/MM/yyyy HH:mm`; label: `dd/MM/yyyy` |
| `time` | Label only: `HH:mm` |
| `service`, `service_title` | Service code and title, e.g. `201` / `ΠΟΛΗ ΠΟΛΗ - ΠΠ` |
| `station_apo` | Deposit station = the user's station (`Γρ.Κατάθεσης`) |
| `station_pros`, `station_pros_title` | Destination station (`Γρ. Επίδοσης`), e.g. `94580` / `ΙΩΑΝΝΙΝΑ - ΠΡΑΚΤΟΡΕΙΟ` |
| `xreosi` | `ΧΡΕΩΣΗ ΑΠΟΣΤΟΛΕΑ ΠΙΣΤΩΣΗ` (charged to sender, on credit) |
| `siimvasi` | `131775-9` (fixed; the ELTA "Ταχυπληρωμή" account number) |
| `copy` | Empty on a main voucher (see extra parcels / return voucher) |
**Parcel**
| Field | Value |
|---|---|
| `baros` | Weight as entered |
| `ogos_baros` | Volumetric weight (A4) |
| `ogos_1` | `"XxYxZ"` (A4) |
| `ogos_2` | `"XxYxZ = <volumetric>"` (label) |
| `ogos_3` | `* ΠΡΟΣΟΧΗ PostBox *` for PostBox, else empty |
| `temaxia` | 1 piece: the count as entered (`1`); several: `"001/" + count` (e.g. `001/003`) |
| `polaplo` | `* ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ *` when more than one piece |
| `periexomeno` | The Reference No (`pel_ref_no`) — printed in the REFERENCE box |
| `sxolia_1`…`3` | Remarks in **27-character** lines (1st 27, next 27, the rest) — the plain remarks, not the padded/flagged value |
**Cash on delivery** (`num6` = cash + all cheques)
| Field | When | Value |
|---|---|---|
| `antik_minima` | COD | `* ΠΡΟΣΟΧΗ ΑΝΤΙΚΑΤΑΒΟΛΗ *` |
| `apodiksi` | COD | `* Απόδειξη Είσπραξης *` (the entry screen misspells it `Είσπαξης`; batch printing spells it right) |
| `antik_1` | COD | `ΑΝΤΙΚΑΤΑΒΟΛΗ <total, 2 decimals>`; PostBox: `* ΠΡΟΣΟΧΗ PostBox *` |
| `antik_2` | COD | `* ΑΝΑΛΥΣΗ *` |
| `antik_3` | cash | `<cash> MΕΤΡΗΤΑ` (the "M" is a Latin M) |
| `antik_3`…`6` | cheques, A4 | `<amount> € ΕΠΙΤΑΓΗ <due date>` per cheque |
| `antik_3`…`6` | cheques, label | `<amount> ΕΠ/ΓΗ <due date>` per cheque |
| `antik_poso` | always | the total if paid in cash, else `0.00` (cheques and no COD) |
| `antik_ocr` | always | `ocr_line` — printed at the foot of the A4 payment stub |
**Surcharges (`sur_1`…`3`)** — from the codes, in this layout:
| Codes | `sur_1` | `sur_2` | `sur_3` |
|---|---|---|---|
| 002 | 002 ΕΙΔΙΚΗ ΔΙΑΧΕΙΡΗΣΗ | | |
| 002 + 003 | 002 … | 003 ΠΡΟΚΑΘΟΡΙΣΜΕΝΗ ΩΡΑ | |
| 002 + 003 + 004 | 002 … | 003 … | 004 ΕΠΙΔΟΣΗ ΣΑΒΒΑΤΟΥ |
| 002 + 004 | 002 … | 004 ΕΠΙΔΟΣΗ ΣΑΒΒΑΤΟΥ | |
| 003 | | 003 … | |
| 003 + 004 | | 003 … | 004 … (entry screen) / `sur_1` = 004 (batch) |
| 004 | | 004 … (entry screen) / `sur_1` = 004 (batch) | |
| 917 (PostBox) | `* ΠΡΟΣΟΧΗ PostBox *` | (003/004 as above) | |
(The entry screen and batch printing place 004 differently when 002 is absent — a client
inconsistency; `sur_4` exists in the A4 report but is never filled.)
Fields in the dataset that no code fills: `service_thirida`, `axia`, `sur_4`, `antik_7`.
## B4. Extra parcel labels (`print_child`)
For each `vg_child_no` (k = 2, 3, …; n = pieces):
| Field | Value |
|---|---|
| `sydeta`, `barcode` | The **parcel's own** number / `*number*` |
| `temaxia` | Label: `"00k/00n"` (e.g. `002/003`); A4: `n` |
| `periexomeno` | A4 only: `"00k/00n"` — printed large in the child report |
| `polaplo` | Label: `ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ - MASTER : <main voucher>` |
| `copy` | Label: `ΠΟΛΛΑΠΛH`; A4: `"*" + main voucher + "*"` (under `ΜASTER ΣΥΔΕΤΑ`) |
| `antik_minima`, `antik_1` | COD: `* ΠΡΟΣΟΧΗ ΑΝΤΙΚΑΤΑΒΟΛΗ *` — **no amount** (collected on the main voucher) |
| sender, recipient, `date`, `time`, `service`, `service_title`, `baros`, stations, `sxolia_1`…`3` | As on the main voucher |
| Not set | `barcode_2`, `xreosi`, `siimvasi`, `sur_*`, `ogos_*`, other `antik_*`, reference (label) |
## B5. What each report prints
### A4 main voucher — `sydetaE.rdlc`
One A4 page, three bands separated by cut lines, each with a side strip naming the copy:
| Band | Copies (side strips) | Contents |
|---|---|---|
| 1 (top) | `1. ΑΠΟΣΤΟΛΕΑΣ / SHIPPER`, `3. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΚΑΤΑΘΕΣΗΣ` | Full voucher: logo, ELTA details, title, barcode, voucher, deposit/destination stations, weight, volumetric, pieces, service, sender, recipient, charge line, surcharges, reference, remarks, COD block, signature boxes, terms |
| 2 | `6. ΠΑΡΑΛΗΠΤΗΣ / CONSIGNEE`, `5. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΕΠΙΔΟΣΗΣ` | Same voucher again, with the sender's signature box, print time, `copy`, `antik_minima`, `apodiksi` |
| 3 (payment stub) | `2. - 4. ΛΟΓΙΣΤΗΡΙΟ` | "ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ / ΜΕΤΑΒΙΒΑΣΗ": voucher + barcode, stations, weight, pieces, service, charge, sender (code + name), recipient (name, address, postcode), surcharges, remarks, signature boxes, COD breakdown, **ΠΟΣΟ** (`antik_poso`), date stamp, fees, "Η-αριθμός", "Αρ. Λογ/κής Απόδοσης", and the OCR line below "ΜΗ ΣΗΜΕΙΩΝΕΤΕ ΚΑΤΩ ΑΠΟ ΑΥΤΗ ΤΗ ΓΡΑΜΜΗ" |
Fields used: `antik_1`–`7`, `antik_minima`, `antik_ocr`, `antik_poso`, `apodiksi`, `barcode`,
`baros`, `copy`, `date`, `ogos_1`, `ogos_3`, `ogos_baros`, `periexomeno`, `rec_1`–`5`,
`sender_1`–`5`, `service`, `service_title`, `station_apo`, `station_pros`,
`station_pros_title`, `sur_1`–`4`, `sxolia_1`–`3`, `sydeta`, `temaxia`.
Fixed texts (verbatim, typos included):
- `ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ`
- `ΥΠΗΡΕΣΙΑ :`, `Ογκ/κο Βάρος`
- `ΧΡΕΩΣΗ ΑΠΟΣΤΟΛΕΑ ΤΡ.ΠΛΗΡ: ΠΙΣΤΩΣΗ` (copies), `ΧΡ. ΑΠΟΣΤ: ΠΙΣΤΩΣΗ` (stub)
- `Συν.Χρέωσης (€):` / `Συν.Χρέωσης. (€):` / `Συν.Χρ. (€):`
- `Πρόσθετες Υπηρεσίες`, `* ΕΠΙΒΑΡΥΝΣΕΙΣ *`
- `* REFERENCE No *` / `* REFERENCE No*`
- `* ΠΑΡΑΤΗΡΗΣΕΙΣ *`
- `ΓΙΑ ΤΗΝ ΠΑΡΑΛΑΒΗ`, `ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ`
- `ΥΠΟΓΡΑΦΗ ΑΠΟΣΤΟΛΕΑ`, `ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ ΠΑΡΑΛΗΠΤΗ`
- `Ημερομηνία - Ωρα Εκτύπωσης :`, `Ημ/νία:`, `Ώρα:`
- `Π Ο Σ Ο`
- `Έλαβα γνώση των όρων που αναγράφονται στο αντίγραφο 1 και 6 και τους αποδέχομαι
ανεπιφύλακτα` (and a variant ending `...αντίγραφο 1 και τους αποδέχομαι ανεπιφύλακτα`)
- `ΕΕΤΤ ΑΜ 99-150 Γενική Αδεια Ταχ/κων Υπηρεσιών`
Images used, with what they contain:
| Image | Where | Content |
|---|---|---|
| `elta`, `elta1` | Band 1/2 top-left | ELTA Courier logo |
| `Πλήρηστοιχεία_red1`, `_red2`, `_black` | Next to the logo; stub | **ELTA company details** (see B8) |
| `Point2_EETTAM_August2024` | Band 1 | `ΕΕΤΤ ΑΜ: 99-150 Γενική Άδεια Ταχ/κων Υπηρεσιών` |
| `Point4_CompanynameEETTAM_August2024` | Band 2 | `ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε. ΕΕΤΤ ΑΜ: 99-150 Γενική Άδεια Ταχ/κων Υπηρεσιών` |
| `oroi` | Band 1, vertical | `Ισχύουν οι Γενικοί Οροι Παραχής Υπηρεσιών οι οποίοι βρίσκονται αναρτημένοι στο www.elta-courier.gr και είναι διαθέσιμοι σε ολα τα καταστήματα της εταιρίας.` ("Παραχής", "ολα": ELTA's own spelling, same as the label) |
| `ID1__shipper_ec` | Band 1 side | `1.ΑΠΟΣΤΟΛΕΑΣ / SHIPPER` |
| `ID3__file_ec` | Band 1 side | `3. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΚΑΤΑΘΕΣΗΣ` |
| `ID6__consignee_ec` | Band 2 side | `6. ΠΑΡΑΛΗΠΤΗΣ / CONSIGNEE` + `ΕΕΤΤ ΑΜ:01-200 Γενική Άδεια Ταχ/κών Υπηρεσιών` |
| `ID5__file_store_elta_courier` | Band 2 side | `5. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΕΠΙΔΟΣΗΣ` |
| `ID2_4_logistirio` | Stub side | `2. - 4. ΛΟΓΙΣΤΗΡΙΟ` |
| `elta_taxyplirwmi_eispraksi` | Stub header | `ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ / ΜΕΤΑΒΙΒΑΣΗ Ο. Αριθμός Λογ/μού Ταχυπληρωμής 131775-9` |
| `elta_chromologiko_simantro` | Stub | ELTA Ταχυπληρωμή logo + `Χρονολογικό Σήμαντρο` (date-stamp box) |
| `elta_teli` | Stub | `Τέλη` (fees) |
| `elta_h_arithmos` | Stub | `Η - αριθμός` |
| `elta_ar__logiostikis_apodosis` | Stub | `Αρ. Λογ/κής Απόδοσης` |
| `Point5_ELTACourier_ΑποστολήμαςΕσείς_September2024` | Stub, vertical | ELTA "Hellenic Post" logo + `Αποστολή μας εσείς!` |
| `mhn_shmeivnete` | Foot | `ΜΗ ΣΗΜΕΙΩΝΕΤΕ ΚΑΤΩ ΑΠΟ ΑΥΤΗ ΤΗ ΓΡΑΜΜΗ` |
### A4 extra parcel — `sydetaE1.rdlc`
A single block: logo (`eltared`) + company details (`Πλήρηστοιχεία_red1`), `ΕΕΤΤ ΑΜ 99-150
Γενική Αδεια Ταχ/κων Υπηρεσιών`, `ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ`, barcode + parcel number,
`Ημερομηνία - Ωρα Εκτύπωσης :` + date, deposit station / weight / `Ογκ/κο Βάρος` / pieces,
`ΥΠΗΡΕΣΙΑ :` + service, destination station, sender (5 lines), recipient (5 lines), and a
panel with `** ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ **`, the piece (`002/003`, large), the COD warning,
`ΜASTER ΣΥΔΕΤΑ` and the master voucher. No surcharges, reference, remarks, charge line,
COD amounts or stub.
### Label (10.4 × 14.8 cm) — `SydetaLabelE.rdlc`
Top: logo (`elta_logoblack`), company details (`Πλήρηστοιχεία_black1`), a `ΥΠΗΡΕΣΙΑ:` box
(service + title), the EETT box (`Point2_EETTAM_August2024`), date and time,
`ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ`, `copy`. Then `Γρ. Επίδοσης :` + destination station;
a row of charge (`xreosi`) / OCR reference (`barcode_2`) / `siimvasi`; `Γρ.Κατάθεσης:`,
`ΒΑΡΟΣ(Kgr)`, `ΟΓΚΟΜΕΤΡΙΚΟ ΒΑΡΟΣ(Kgr)` (`ogos_2`), `Τεμάχια`; the sender (`ΑΠΟΣΤΟΛΕΑΣ`,
5 lines) next to **`ΑΙΤΙΑ ΕΠΙΣΤΡΟΦΗΣ`** (return reasons with tick boxes: `Άγνωστος`,
`Ελλειπή Δ/νση`, `Απαράδεκτο`, `Άλλαξε Δ/νση`, `Αζήτητο`); the recipient (`Π Α Ρ Α Λ Η Π Τ Η Σ`,
5 lines) next to the COD column (`antik_1`…`7`); `REFERENCE` (`periexomeno`) and
`* ΠΑΡΑΤΗΡΗΣΕΙΣ *` (`sxolia_1`, `sxolia_2` — only two lines on the label) next to
`* ΕΠΙΒΑΡΥΝΣΕΙΣ *` (`sur_1`…`3`); the `polaplo` line; the terms (`Ισχύουν οι Γενικοί Οροι
Παραχής Υπηρεσιών οι οποίοι βρίσκονται αναρτημένοι στο www.elta-courier.gr` / `και είναι
διαθέσιμοι σε ολα τα καταστήματα της εταιρίας.` — "Παραχής" is ELTA's typo); the
`antik_1` banner; the barcode; the voucher number (`sydeta`).
## B6. Return voucher ("RETOUR")
Printed when `return_vg` is not empty (special handling with a return-to party):
| Field | Value |
|---|---|
| `copy` | `RETOUR` |
| `rec_1`…`5` (**recipient = return-to party**) | `"Κωδικός:" + code`, name (split at 40), address, `"TK:… … ΤΗΛ:…"` |
| `sender_1`…`5` (**sender = our recipient**) | name (split at 40), address, `"TK:" + postcode + " " + area`, `"ΤΗΛ: " + phones` |
| `sydeta`, `barcode` | `return_vg` |
| `barcode_2` | `r_ocr_line` characters 2–14 |
| `service`, `service_title` | `461` `RETOUR ΕΝΤΟΣ ΠΟΛΗΣ` if the original service starts with `1`, else `462` `RETOUR ΠΟΛΗ ΠΟΛΗ` |
| `xreosi` | `ΧΡΕΩΣΗ ΠΑΡΑΛΗΠΤΗ ΠΙΣΤΩΣΗ` (charged to the recipient) |
| `siimvasi` | `131775-9` |
| `baros`, `temaxia` | `0.100`, `1` |
| `station_apo` / `station_pros` | the original destination station / the return-to party's station |
| `sxolia_1`…`3` | `"ΣΥΔΕΤΑ <main voucher> Χρέωση Πελάτη <sender code>"` in 27-char lines |
| `antik_poso` / `antik_ocr` | `0.00` / `r_ocr_line` |
## B7. Cheque-return voucher
Printed when `epitagh_vg` is not empty (COD by cheque) — carries the cheques back:
| Field | Value |
|---|---|
| `copy` | empty |
| `rec_*` (**recipient = our sender**) | `"Κωδικός:" + sender code`, sender name, address, TK/area/phone line |
| `sender_*` (**sender = our recipient**) | as in B6 |
| `sydeta`, `barcode`, `barcode_2` | `epitagh_vg`, `*epitagh_vg*`, `e_ocr_line` characters 2–14 |
| `service`, `service_title` | `350` `ΕΠΙΣΤΡΟΦΗ ΑΞΙΟΓΡΑΦΩΝ` |
| `xreosi` | `ΧΡΕΩΣΗ ΠΑΡΑΛΗΠΤΗ ΠΙΣΤΩΣΗ` |
| `siimvasi`, `baros`, `temaxia` | `131775-9`, `0.100`, `1` |
| `station_apo` / `station_pros` | the original destination station / the user's station |
| `sxolia_*` | `"ΣΥΔΕΤΑ <main voucher> Χρέωση Πελάτη <sender code>"` |
| `antik_poso` / `antik_ocr` | `0.00` / `e_ocr_line` |
## B8. ELTA company details (as printed, from ELTA's current images)
```
ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε.
Έδρα: Λεωφ. Ιωνίας 200 & Ιακωβάτων 61,
111 44 Αθήνα
Α.Φ.Μ.: 094026421 | Δ.Ο.Υ.: ΚΕ.ΦΟ.Δ.Ε. ΑΤΤΙΚΗΣ
Τ. 210-6073000 | Ε. info@elta-courier.gr
www.elta-courier.gr
Γ.Ε.ΜΗ: 001092101000
```
EETT licence: `ΕΕΤΤ ΑΜ: 99-150 Γενική Άδεια Ταχ/κων Υπηρεσιών` (the older `01-200` still
appears on the consignee copy strip and the legacy reports, whose company block reads
`ΤΑΧΥΜΕΤΑΦΟΡΕΣ ΕΛΤΑ Α.Ε., Λ.Μεσογείων 395, Αγία Παρασκευή, 153 43, ΤΗΛ:210 6073000
FAX:2106073100, ΑΦΜ: 099759170 ΔΟΥ ΦΑΕ ΑΘΗΝΩΝ, Ε.Ε.Τ.Τ. ΑΜ 01-200` — not current).
## B9. Other printouts (reports lists)
| Report | Screen | Columns |
|---|---|---|
| `Report1` (landscape) | Index, simple | ΣΥ.ΔΕ.ΤΑ., ΒΑΡΟΣ, ΤΕΜΑΧΙΑ, ΠΑΡΑΛΗΠΤΗΣ, ΔΙΕΥΘΥΝΣΗ, Τ.Κ., ΣΤΑΘΜΟΣ, ΑΠΟΣΤΟΛΕΑΣ, REFERENCE No; title `ΚΑΤΑΣΤΑΣΗ ΣΥ.ΔΕ.ΤΑ.` + date range; totals row `ΣΥΝΟΛΑ` (weight, pieces) |
| `Report3` (landscape) | Index, extended | the above + ΤΗΛΕΦΩΝΑ, ΑΝΤ/ΛΗ, ΣΥΔΕΤΑ ΕΠΙΤΑΓΗΣ, ΣΥΔΕΤΑ ΕΠΙΣΤΡΟΦΙΚΟΥ |
| `Report2` (landscape) | Multiple search | ΣΥ.ΔΕ.ΤΑ., REFERENCE, ΠΑΡΑΛΗΠΤΗΣ, Τ.Κ., ΣΤΑΘΜΟΣ, ΑΝΤ/ΒΟΛΗ, ΤΕΛΕΥΤΑΙΟ STATUS, ΣΧΟΛΙΑ |
---
# Part C — Where our integration differs (as of 0.29.3)
Rows marked **Fixed** are handled since 0.31.0 (see CHANGELOG); the "Ours" column describes the fixed behaviour.
## Verified against ELTA's test account (2026-09-30)
All with generic test data (`TEST RECIPIENT`, `TEST ADDRESS 1`, `10431`, `2100000000`,
references `TEST-…`); pending vouchers were deleted afterwards. One issued voucher remains
on the shared account: `NZ000987460GR` (+ return voucher `NZ000987473GR`).
| Check | Result |
|---|---|
| HTTPS | Works on `https://clients.elta-courier.gr` (443); fails on the old IP and on port 9003 (see A1) |
| Envelope/parcel flag | Accepted. ELTA trims the padding and **keeps the flag as the last character of the remarks** (`TEST REMARK2`, also in tracking `a_sxolia`). Whether ELTA also reads it as the parcel type isn't visible through the API |
| Surcharge slots | Stored exactly in the slot sent (`004` in slot 1 stays `004\|000\|000`); ELTA does not normalize. Tracking (`a_services`) shows only the service, never surcharges, so their effect can't be read back. ELTA's own client only reads Saturday from slot 3 (B3), so `004` in slot 1 would not print as Saturday there |
| Blank service | Accepted, but ELTA stores **`201` ΠΟΛΗ ΠΟΛΗ** for 10431, where its own lookup (`PELTKNEW`) gives **`101` ΕΝΤΟΣ ΠΟΛΗΣ** for the test station — a blank service can land on a different service than the client would send. Explicit `101`/`201` are honoured |
| Localities | Common: 45500 Ιωάννινα has 190 localities (170 hard-to-reach → `231`), 20100 Κόρινθος 41 (16), 72200 Χίος 56 (31); islands get `211` ΠΟΛΗ ΠΟΛΗ -ΠΠ- ΝΗΣΙ |
| Insurance | `pel_asf_poso = 50.00` is stored; effect not visible |
| No phones / 21 pieces | Both accepted at creation (the limits are the client's); not tested at issue |
| Remarks > 90 | 95 characters accepted and stored in full (90 is the client's limit) |
| Special handling + return-to | Issuing returned a **return voucher** (`return_vg` + `r_ocr_line`); tracking lists it in `a_vg_2` |
| Tracking by reference | `PELTTNEW01` with our reference (`00000007`) returns the voucher |
| Delivery / remittance fields | Present in `pel_rec` (`a_rec_name`, `a_rec_date_time`, `a_antik1`, `a_antik_text1`); empty until delivery |
| PostBox lookup (`PELTKBOX`) | `10431` → station `10200` ΑΙΟΛΟΥ 100 (Κ.Κ.), service `101`; `45500`, `84100`, `00000` → `st_flag 1` "Το Τ.Κ. Δεν Εχει PostBox" |
## The differences
| # | Topic | ELTA client | Ours | Impact |
|---|---|---|---|---|
| 1 | Envelope/parcel | Mandatory; remarks right-aligned in 99 chars + `1`/`2` in `pel_paral_sxolia` | Remarks right-aligned in 99 characters (padded by characters) + `2` | **Fixed** |
| 2 | Surcharge slots | Fixed: `002`→1, `003`→2, `004`→3, `917`→1 | Fixed slots, as the client | **Fixed** |
| 3 | Service | Always resolved first (`PELTKNEW`) and sent | Resolved with `PELTKNEW` (sender station) and sent | **Fixed** (was blank; ELTA then gave `201` where its lookup gives `101`) |
| 4 | Localities | Area = `code/title` of the picked locality; hard-to-reach → service `231` | Locality matched by the address's city (accents/case ignored) → `code/title` + its service; no match → blank service, noted on the shipment | **Fixed** |
| 5 | Phones | At least one required; PostBox needs `69…` mobile | At least one required; digits only, `30`/`0030` dropped, `69…` → mobile field | **Fixed** (PostBox not offered) |
| 6 | Pieces | 1–20 | Refused above 20 | **Fixed** |
| 7 | Insurance | Never sent (`" "`) | We send `pel_asf_poso` | Unverified whether ELTA honours it |
| 8 | PostBox (`917`) | Supported, with rules (no COD, no special handling, mobile) | "PostBox delivery" extra service: `PELTKBOX` routing, `917` in `pel_sur_1`, the client's rules checked, PostBox texts on the label | **Fixed** |
| 9 | Cheque COD | Up to 4 cheques + dates; cheque-return voucher printed | Cash only | Fine for a shop; noted for completeness |
| 10 | Special handling + return | Return-to party (`pel_retur_code`); RETOUR voucher printed | Return-to = our sender code; `return_vg` + `r_ocr_line` stored; RETOUR page printed after the parcels (B6) | **Fixed** |
| 11 | Remarks length | 90 chars | 90 | — |
| 12 | Company details on labels | B8 (Ιωνίας 200, Γ.Ε.ΜΗ) | B8 details on the A4 copies, stub, A4 child and A6 | **Fixed** |
| 13 | `Απόδειξη Είσπραξης` | Correct in batch printing | Correct | **Fixed** |
| 14 | Side copy strips, stub strips, "Αποστολή μας εσείς" | Images (B5) | Approximated / missing | Cosmetic |
| 15 | Tracking by reference | `PELTTNEW01` accepts our reference | We track by voucher only | **Verified** working — could track a voucher whose number we don't have |
| 16 | Delivery details | `a_rec_name`, `a_rec_date_time` | Stored from each tracking poll (`meta.delivered_to` / `delivered_at`); shown on the order page and Carrier Vouchers view | **Fixed** |
| 17 | COD remittance | `a_antik1`, `a_antik_text1` | Stored (`meta.cod_remitted` / `cod_remittance`); shown as "COD paid back" | **Fixed** |
| 18 | Scheme | `https://clients.elta-courier.gr` | `https://clients.elta-courier.gr`, 2 retries on connection errors / 5xx | **Fixed** |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@boboko/core", "name": "@boboko/core",
"version": "0.29.0", "version": "0.31.0",
"private": true, "private": true,
"type": "module", "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.", "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) { 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" // Tracked so flush() can await it — nothing else stops "place order"
// (a separate, unrelated click) from racing ahead of this request. // (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 // re-renders wholesale on every shipping-option change — so its own
// Stimulus controller reconnects fresh each time and has no memory of // Stimulus controller reconnects fresh each time and has no memory of
// which option was previously selected. This is the one place that // which option was previously selected. This is the one place that
// knows the newly-chosen option's identifier, so it also owns // knows the newly-chosen option, so it also owns showing/hiding the
// showing/hiding the picker. // picker. Whether an option is Box Now comes from the server
toggleBoxNowLocker(identifier) { // (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') const picker = this.shippingOptionsTarget.querySelector('#bbk-box-now-locker')
if (!picker) return if (!picker) return
const controller = this.application.getControllerForElementAndIdentifier(picker, 'bbk-box-now-locker') const controller = this.application.getControllerForElementAndIdentifier(picker, 'bbk-box-now-locker')
if (identifier === 'box-now') { if (isBoxNow) {
controller?.show() controller?.show()
} else { } else {
controller?.hide() controller?.hide()
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

@@ -8,6 +8,8 @@
$shippingAddress, $shippingOptions come from the controller / page scope. $shippingAddress, $shippingOptions come from the controller / page scope.
--}} --}}
@php($selected = $shippingAddress?->shipping_option) @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 {{-- Rate resolution needs country (always Greece here) + postcode; until a
postcode is saved there's nothing to quote against yet. --}} postcode is saved there's nothing to quote against yet. --}}
@@ -34,6 +36,7 @@
type="radio" type="radio"
name="shipping_option" name="shipping_option"
value="{{ $option->identifier }}" value="{{ $option->identifier }}"
data-box-now="{{ in_array($option->identifier, $boxNowCodes, true) ? 'true' : 'false' }}"
@checked($selected === $option->identifier) @checked($selected === $option->identifier)
data-action="change->bbk-checkout-form#selectShipping" 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-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-selected-label-value="{{ __('checkout.page.box_now_locker_selected') }}"
data-bbk-box-now-locker-no-results-value="{{ __('checkout.page.box_now_locker_no_results') }}" 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"> <p class="bbk-checkout-box-now-locker-label">
{{ __('checkout.page.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" class="w-4"
/> />
</div> </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>
<div class="flex space-x-2"> <div class="flex space-x-2">
@@ -0,0 +1,119 @@
<!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>
{{-- Πλήρηστοιχεία_red1 --}}
<div class="ch-company" style="top:9.07cm;left:1.95cm;width:3.38cm;">
@include('core::shipping.carriers.elta.partials._elta-details', ['style' => 'font-size:3.5pt;line-height:1.15;'])
</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>
@@ -35,11 +35,11 @@
.mc-text.mc-small { font-size: 4.6pt; } .mc-text.mc-small { font-size: 4.6pt; }
.mc-box { border: 1px solid #000; box-sizing: border-box; } .mc-box { border: 1px solid #000; box-sizing: border-box; }
.mc-title { position: absolute; font-weight: bold; font-size: 8pt; text-align: center; white-space: nowrap; } .mc-title { position: absolute; font-weight: bold; font-size: 8pt; line-height: 1; text-align: center; white-space: nowrap; }
.mc-barcode { position: absolute; text-align: center; } .mc-barcode { position: absolute; text-align: center; }
.mc-barcode img { height: 26px; } .mc-barcode img { width: 100%; height: 22px; }
.mc-voucher { position: absolute; text-align: center; font-weight: bold; font-size: 10.5pt; letter-spacing: 0.5px; } .mc-voucher { position: absolute; text-align: center; font-weight: bold; font-size: 10.5pt; line-height: 1; letter-spacing: 0.5px; }
.mc-copylabel { position: absolute; text-align: center; font-weight: bold; font-size: 8pt; } .mc-copylabel { position: absolute; text-align: center; font-weight: bold; font-size: 8pt; line-height: 1; }
.mc-cell { border: 1px solid #000; box-sizing: border-box; padding: 1px 2px; } .mc-cell { border: 1px solid #000; box-sizing: border-box; padding: 1px 2px; }
.mc-cell-label { font-size: 5.5pt; } .mc-cell-label { font-size: 5.5pt; }
@@ -74,7 +74,7 @@
that embedded bitmap. transform:rotate() (not that embedded bitmap. transform:rotate() (not
writing-mode:vertical-rl, confirmed broken in dompdf) rotates writing-mode:vertical-rl, confirmed broken in dompdf) rotates
a normal-flow block. */ a normal-flow block. */
.stub-strip { position: absolute; top: 2.25cm; left: 15.42cm; width: 5.58cm; height: 6.16cm; border-left: 1px dashed #000; } .stub-strip { position: absolute; top: 2.25cm; left: 16.05cm; width: 4.90cm; height: 6.16cm; border-left: 1px dashed #000; }
.stub-vtext { .stub-vtext {
position: absolute; top: 2.6cm; left: -1.3cm; width: 6cm; position: absolute; top: 2.6cm; left: -1.3cm; width: 6cm;
text-align: center; font-size: 6pt; font-weight: bold; text-align: center; font-size: 6pt; font-weight: bold;
@@ -101,7 +101,7 @@
<div class="cut-line" style="top:9.0cm;"><span>✂ ΑΠΟΚΟΨΤΕ ΕΔΩ</span></div> <div class="cut-line" style="top:9.0cm;"><span>✂ ΑΠΟΚΟΨΤΕ ΕΔΩ</span></div>
{{-- Copy 2: sender's copy — RDLC band top≈8.94cm on the full page --}} {{-- Copy 2: sender's copy — RDLC band top≈8.94cm on the full page --}}
<div class="band" style="top:9.05cm; width:15.75cm; height:9.9cm;"> <div class="band" style="top:9.30cm; width:15.75cm; height:9.2cm;">
@include('core::shipping.carriers.elta.partials._main-copy', ['copyType' => 'sender']) @include('core::shipping.carriers.elta.partials._main-copy', ['copyType' => 'sender'])
</div> </div>
@@ -79,6 +79,10 @@
<div class="a6-logo" style="top:0.12cm;left:0.10cm;width:1.66cm;height:1.26cm;"> <div class="a6-logo" style="top:0.12cm;left:0.10cm;width:1.66cm;height:1.26cm;">
<img src="{{ $eltaLogo }}" alt="ELTA Courier"> <img src="{{ $eltaLogo }}" alt="ELTA Courier">
</div> </div>
{{-- Πλήρηστοιχεία_black1: ELTA's company details, next to the logo --}}
<div style="position:absolute;top:0.10cm;left:1.92cm;width:4.29cm;height:1.60cm;">
@include('core::shipping.carriers.elta.partials._elta-details', ['style' => 'font-size:4.2pt;'])
</div>
<div class="a6-service-box" style="top:0.10cm;left:6.42cm;width:3.93cm;height:0.84cm;"> <div class="a6-service-box" style="top:0.10cm;left:6.42cm;width:3.93cm;height:0.84cm;">
<div class="a6-service-code" style="position:absolute;top:0.02cm;left:0.09cm;">ΥΠΗΡΕΣΙΑ: {{ $service_code }}</div> <div class="a6-service-code" style="position:absolute;top:0.02cm;left:0.09cm;">ΥΠΗΡΕΣΙΑ: {{ $service_code }}</div>
<div class="a6-service-name" style="position:absolute;top:0.40cm;left:0.04cm;width:3.81cm;">{{ $service_name }}</div> <div class="a6-service-name" style="position:absolute;top:0.40cm;left:0.04cm;width:3.81cm;">{{ $service_name }}</div>
@@ -89,6 +93,10 @@
<div class="a6-datetime" style="top:1.68cm;left:0.13cm;width:1.68cm;">{{ $date }}</div> <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> <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 {{-- The RDLC's own barcode textbox here uses the "Free 3 of 9
Extended" barcode font — we don't have that font, so this Extended" barcode font — we don't have that font, so this
would-be *{voucher}* fallback text is dropped in favor of the would-be *{voucher}* fallback text is dropped in favor of the
@@ -118,7 +126,7 @@
</colgroup> </colgroup>
<tr> <tr>
<td class="a6-cell-td" style="height:0.47cm;border-bottom:none;"> <td class="a6-cell-td" style="height:0.47cm;border-bottom:none;">
<div style="font-size:7pt;">{{ $xreosi }}</div> <div style="font-size:6pt;white-space:nowrap;">{{ $xreosi }}</div>
</td> </td>
<td class="a6-cell-td" style="height:0.47cm;text-align:center;border-bottom:none;"> <td class="a6-cell-td" style="height:0.47cm;text-align:center;border-bottom:none;">
<div style="font-size:7pt;">{{ $ocr_reference }}</div> <div style="font-size:7pt;">{{ $ocr_reference }}</div>
@@ -143,7 +151,7 @@
</td> </td>
<td class="a6-cell-td" style="height:0.97cm;"> <td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">ΟΓΚΟΜΕΤΡΙΚΟ ΒΑΡΟΣ(Kgr)</div> <div class="a6-cell-label">ΟΓΚΟΜΕΤΡΙΚΟ ΒΑΡΟΣ(Kgr)</div>
<div class="a6-cell-val" style="font-size:7pt;">{{ $volumetric_weight }}</div> <div class="a6-cell-val" style="font-size:6.5pt;white-space:nowrap;">{{ $volumetric_weight }}</div>
</td> </td>
<td class="a6-cell-td" style="height:0.97cm;"> <td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">Τεμάχια</div> <div class="a6-cell-label">Τεμάχια</div>
@@ -202,6 +210,10 @@
{{-- ΠΑΡΑΤΗΡΗΣΕΙΣ --}} {{-- ΠΑΡΑΤΗΡΗΣΕΙΣ --}}
<div class="a6-box" style="top:10.60cm;left:0.10cm;width:5.82cm;height:0.80cm;"> <div class="a6-box" style="top:10.60cm;left:0.10cm;width:5.82cm;height:0.80cm;">
<div class="a6-small-title">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div> <div class="a6-small-title">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div>
{{-- 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> </div>
@if ($multiPiece) @if ($multiPiece)
@@ -0,0 +1,15 @@
{{--
ELTA's company details exactly as its client prints them — the
"Πλήρηστοιχεία" images in sydetaE / sydetaE1 / SydetaLabelE (see
docs/elta.md, B8), reproduced as text since the bitmaps aren't ours
to ship. The caller positions and sizes the block.
--}}
<div class="elta-details" style="line-height:1.3; white-space:nowrap; {{ $style ?? '' }}">
<div style="font-weight:bold;">ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε.</div>
<div>Έδρα: Λεωφ. Ιωνίας 200 &amp; Ιακωβάτων 61,</div>
<div>111 44 Αθήνα</div>
<div>Α.Φ.Μ.: 094026421 | Δ.Ο.Υ.: ΚΕ.ΦΟ.Δ.Ε. ΑΤΤΙΚΗΣ</div>
<div>Τ. 210-6073000 | Ε. info@elta-courier.gr</div>
<div>www.elta-courier.gr</div>
<div>Γ.Ε.ΜΗ: 001092101000</div>
</div>
@@ -13,20 +13,16 @@
<div class="mc-logo" style="top:0.09cm;left:0.32cm;width:2.65cm;height:1.93cm;"> <div class="mc-logo" style="top:0.09cm;left:0.32cm;width:2.65cm;height:1.93cm;">
<img src="{{ $eltaLogo }}" alt="ELTA Courier"> <img src="{{ $eltaLogo }}" alt="ELTA Courier">
</div> </div>
{{-- Πλήρηστοιχεία_red1 --}}
<div class="mc-company" style="top:0.12cm;left:3.15cm;width:5.19cm;"> <div class="mc-company" style="top:0.12cm;left:3.15cm;width:5.19cm;">
<div class="mc-company-name">ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε.</div> @include('core::shipping.carriers.elta.partials._elta-details', ['style' => 'font-size:4.4pt;line-height:1.15;'])
<div>Έδρα: Απελλού 1, 105 51, Αθήνα</div>
<div>Υποκ/μα: Λ. Μεσογείων 395, 153 43, Αγ. Παρασκευή</div>
<div>Α.Φ.Μ.: 094026421 | Δ.Ο.Υ.: ΚΕΦΟ.Δ.Ε. ΑΤΤΙΚΗΣ</div>
<div>Τ. 210-6073000 | Ε. info@elta-courier.gr</div>
<div>www.eltacourier.gr</div>
</div> </div>
<div class="mc-text mc-small" style="top:1.80cm;left:3.15cm;width:5.03cm;">ΕΕΤΤ ΑΜ 99-150 Γενική Αδεια Ταχ/κων Υπηρεσιών</div> <div class="mc-text mc-small" style="top:1.80cm;left:3.15cm;width:5.03cm;">ΕΕΤΤ ΑΜ 99-150 Γενική Αδεια Ταχ/κων Υπηρεσιών</div>
{{-- Title / barcode / voucher, centered column at x=8.52..15.58 --}} {{-- Title / barcode / voucher, centered column at x=8.52..15.58 --}}
<div class="mc-box" style="top:0.05cm;left:8.52cm;width:7.06cm;height:1.75cm;"> <div class="mc-box" style="top:0.05cm;left:8.52cm;width:7.06cm;height:1.75cm;">
<div class="mc-title" style="top:0.19cm;left:0.05cm;width:6.85cm;">ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ</div> <div class="mc-title" style="top:0.10cm;left:0.05cm;width:6.85cm;">ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ</div>
<div class="mc-barcode" style="top:0.57cm;left:0.24cm;width:6.67cm;"> <div class="mc-barcode" style="top:0.45cm;left:0.24cm;width:6.56cm;">
<img src="{{ $barcode_voucher }}" alt=""> <img src="{{ $barcode_voucher }}" alt="">
</div> </div>
{{-- The RDLC's own barcode textbox uses the "Free 3 of 9 Extended" {{-- The RDLC's own barcode textbox uses the "Free 3 of 9 Extended"
@@ -35,16 +31,18 @@
barcode image above instead (visually equivalent, actually barcode image above instead (visually equivalent, actually
scannable), making this second plain-text *{voucher}* line scannable), making this second plain-text *{voucher}* line
redundant with it rather than a distinct field. --}} redundant with it rather than a distinct field. --}}
<div class="mc-voucher" style="top:1.29cm;left:0.24cm;width:6.64cm;">{{ $voucher_no }}</div> <div class="mc-voucher" style="top:1.20cm;left:0.00cm;width:7.04cm;">{{ $voucher_no }}</div>
</div> </div>
{{-- The sender's copy carries the COD warning under the voucher {{-- The sender's copy carries the COD warning under the voucher
(sydetaE.rdlc textbox205: antik_minima), only for cash on delivery. (sydetaE.rdlc textbox205: antik_minima), only for cash on delivery.
Its "Απόδειξη Είσπαξης" note (textbox211) sits at the top-left in the Its "Απόδειξη Είσπραξης" note (textbox211) sits at the top-left in the
RDLC, where our header block is — it goes in this copy's COD box RDLC, where our header block is — it goes in this copy's COD box
instead, under the breakdown. --}} instead, under the breakdown. --}}
@if ($copyType === 'sender' && $antik_minima) @if ($copyType === 'sender' && ($antik_minima || ($copy ?? null)))
<div style="position:absolute;top:1.84cm;left:8.52cm;width:7.06cm;text-align:center;font-size:8pt;font-weight:bold;line-height:1;">{{ $antik_minima }}</div> {{-- …or the copy field (textbox "copy"): "RETOUR" on a return voucher,
which never has COD. --}}
<div style="position:absolute;top:1.84cm;left:8.52cm;width:7.06cm;text-align:center;font-size:8pt;font-weight:bold;line-height:1;">{{ $antik_minima ?: $copy }}</div>
@endif @endif
{{-- 5-cell deposit/destination/weight/pieces/volumetric row — a genuinely {{-- 5-cell deposit/destination/weight/pieces/volumetric row — a genuinely
@@ -78,7 +76,7 @@
</td> </td>
<td class="mc-cell-td"> <td class="mc-cell-td">
<div class="mc-cell-label">Ογκ/κο Βάρος</div> <div class="mc-cell-label">Ογκ/κο Βάρος</div>
<div class="mc-cell-val" style="font-size:7pt;">{{ $volumetric_weight }}</div> <div class="mc-cell-val" style="font-size:4.7pt;white-space:nowrap;">{{ str_replace(' = ', '=', (string) $volumetric_weight) }}</div>
</td> </td>
</tr> </tr>
</table> </table>
@@ -132,13 +130,17 @@
@endforeach @endforeach
</div> </div>
<div class="mc-box" style="top:4.20cm;left:6.59cm;width:4.37cm;height:0.67cm;border-top:none;border-right:none;"> <div class="mc-box" style="top:4.10cm;left:6.60cm;width:4.37cm;height:0.77cm;border-top:none;">
<div style="position:absolute;top:0.03cm;left:0.05cm;font-size:6pt;">* REFERENCE No*</div> <div style="position:absolute;top:0.03cm;left:0.05cm;font-size:6pt;">* REFERENCE No*</div>
<div style="position:absolute;top:0.31cm;left:0.05cm;font-size:6.5pt;">{{ $order_reference }}</div> <div style="position:absolute;top:0.31cm;left:0.05cm;font-size:6.5pt;">{{ $order_reference }}</div>
</div> </div>
<div class="mc-box" style="top:4.97cm;left:6.60cm;width:4.37cm;height:1.31cm;border-top:none;"> <div class="mc-box" style="top:4.87cm;left:6.60cm;width:4.37cm;height:1.44cm;border-top:none;">
<div style="position:absolute;top:0.03cm;left:0.08cm;font-size:6pt;">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div> <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> </div>
{{-- Right-hand signature / print-timestamp column, differs by copy type --}} {{-- Right-hand signature / print-timestamp column, differs by copy type --}}
@@ -152,7 +154,7 @@
<div class="mc-box" style="top:6.37cm;left:11.48cm;width:3.89cm;height:2.03cm;"> <div class="mc-box" style="top:6.37cm;left:11.48cm;width:3.89cm;height:2.03cm;">
<div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;">ΓΙΑ ΤΗΝ ΠΑΡΑΛΑΒΗ</div> <div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;">ΓΙΑ ΤΗΝ ΠΑΡΑΛΑΒΗ</div>
<div style="position:absolute;top:0.38cm;left:0.08cm;font-size:6pt;">ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ</div> <div style="position:absolute;top:0.38cm;left:0.08cm;font-size:6pt;">ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ</div>
<div style="position:absolute;top:1.73cm;left:0.11cm;font-size:6pt;">{{ $date }} {{ $time }}</div> <div style="position:absolute;top:1.62cm;left:0.11cm;font-size:6pt;line-height:1;">{{ $date }} {{ $time }}</div>
</div> </div>
@else @else
<div class="mc-box" style="top:6.37cm;left:6.61cm;width:4.71cm;height:2.07cm;"> <div class="mc-box" style="top:6.37cm;left:6.61cm;width:4.71cm;height:2.07cm;">
@@ -166,6 +168,6 @@
</div> </div>
<div class="mc-box" style="top:6.37cm;left:11.48cm;width:3.89cm;height:2.03cm;"> <div class="mc-box" style="top:6.37cm;left:11.48cm;width:3.89cm;height:2.03cm;">
<div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΥΠΟΓΡΑΦΗ ΑΠΟΣΤΟΛΕΑ</div> <div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΥΠΟΓΡΑΦΗ ΑΠΟΣΤΟΛΕΑ</div>
<div style="position:absolute;top:1.73cm;left:0.11cm;font-size:6pt;">Ημερομηνία - Ωρα Εκτύπωσης: {{ $date }} {{ $time }}</div> <div style="position:absolute;top:1.30cm;left:0.11cm;font-size:6pt;line-height:1.2;">Ημερομηνία - Ωρα Εκτύπωσης:<br>{{ $date }} {{ $time }}</div>
</div> </div>
@endif @endif
@@ -1,132 +1,120 @@
{{-- {{--
Copy 3: the ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ/ΜΕΤΑΒΙΒΑΣΗ payment-receipt stub, Copy 3: the ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ/ΜΕΤΑΒΙΒΑΣΗ payment-receipt stub —
geometry transcribed from sydetaE.rdlc's third band (source Top the fields of sydetaE.rdlc's third band. The RDLC's own coordinates
values there run ~19.22cm-27.0cm on the full A4 page; offsets below don't survive dompdf's font metrics (its boxes ended up overlapping),
are relative to this partial's own container, i.e. the RDLC's Top so the stub is laid out on its own grid instead:
minus 19.22cm). This band is narrower than copies 1/2 (starts at
x=3.01cm instead of x=0.27cm) — the RDLC leaves its left margin for left 0.00–6.00cm stations/weight/pieces, ΑΠΟΣΤΟΛΕΑΣ, ΠΑΡΑΛΗΠΤΗΣ
the perforated tear line + rotated "3. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΚΑΤΑΘΕΣΗΣ" middle 6.20–11.00cm service, charge, Συν.Χρέωσης/remarks, ΕΠΙΒΑΡΥΝΣΕΙΣ
text, reproduced by the caller outside this partial. right 11.30cm– ELTA's company details, ΠΟΣΟ
with the signature / COD / ΠΟΣΟ row along the bottom. The band is
7.40cm tall (the "ΜΗ ΣΗΜΕΙΩΝΕΤΕ" line follows).
Expects all of EltaLabelRenderer's $data. Expects all of EltaLabelRenderer's $data.
--}} --}}
<div class="mc-box" style="top:0.00cm;left:6.44cm;width:6.64cm;height:1.15cm;">
<div class="mc-barcode" style="top:0.10cm;left:0.22cm;width:6.27cm;"> {{-- Voucher number + barcode --}}
<div class="mc-copylabel" style="top:0.35cm;left:0.00cm;width:5.80cm;text-align:left;font-size:9pt;">{{ $voucher_no }}</div>
<div class="mc-box" style="top:0.00cm;left:6.20cm;width:6.70cm;height:1.25cm;">
<div class="mc-barcode" style="top:0.08cm;left:0.20cm;width:6.26cm;">
<img src="{{ $barcode_voucher }}" alt=""> <img src="{{ $barcode_voucher }}" alt="">
</div> </div>
<div class="mc-voucher" style="top:0.75cm;left:0.24cm;width:6.14cm;">{{ $voucher_no }}</div> <div class="mc-voucher" style="top:0.78cm;left:0.00cm;width:6.66cm;">{{ $voucher_no }}</div>
</div> </div>
<div class="mc-copylabel" style="position:absolute;top:0.10cm;left:0.00cm;width:3.92cm;font-size:8pt;font-weight:bold;">{{ $voucher_no }}</div>
{{-- 4-cell deposit/destination/weight/pieces row: same doubled-border {{-- Left: stations / weight / pieces (one table, so shared edges are drawn once) --}}
issue and same fix as _main-copy.blade.php's 5-cell row — a real <table class="mc-cell-row" style="top:1.45cm;left:0.00cm;width:6.00cm;">
<table border-collapse:collapse> in place of 4 independently-bordered
absolute divs, positioned at the row's own RDLC top/left/width. --}}
<table class="mc-cell-row" style="top:1.60cm;left:0.02cm;width:5.61cm;height:0.82cm;">
<colgroup> <colgroup>
<col style="width:1.51cm;"><col style="width:1.67cm;"><col style="width:1.35cm;"><col style="width:1.08cm;"> <col style="width:1.60cm;"><col style="width:1.60cm;"><col style="width:1.40cm;"><col style="width:1.40cm;">
</colgroup> </colgroup>
{{-- border-bottom:none on every cell: the ΑΠΟΣΤΟΛΕΑΣ party box
immediately below (top:2.44cm, this row ends at 2.42cm) already
draws that shared line with its own border-top. --}}
<tr> <tr>
<td class="mc-cell-td" style="border-bottom:none;"> @foreach (['Γρ.Κατάθεσης' => $station_apo, 'Γρ.Προορισμού' => $station_pros, 'Βάρος' => $weight, 'Τεμάχια' => $package_label] as $label => $value)
<div class="mc-cell-label">Γρ.Κατάθεσης</div> <td class="mc-cell-td" style="height:0.80cm;">
<div class="mc-cell-val">{{ $station_apo }}</div> <div class="mc-cell-label" style="font-size:5pt;white-space:nowrap;">{{ $label }}</div>
</td> <div class="mc-cell-val">{{ $value }}</div>
<td class="mc-cell-td" style="border-bottom:none;"> </td>
<div class="mc-cell-label">Γρ.Προορισμού</div> @endforeach
<div class="mc-cell-val">{{ $station_pros }}</div>
</td>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Βάρος</div>
<div class="mc-cell-val">{{ $weight }}</div>
</td>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Τεμάχια</div>
<div class="mc-cell-val">{{ $package_label }}</div>
</td>
</tr> </tr>
</table> </table>
<div class="mc-box" style="top:1.60cm;left:5.90cm;width:4.87cm;height:0.40cm;">
<div style="position:absolute;top:0.05cm;left:0.13cm;font-size:6pt;">{{ $service_code }}</div>
<div style="position:absolute;top:0.03cm;left:0.85cm;width:3.89cm;font-size:6pt;">{{ $service_name }}</div>
</div>
{{-- Below: several panel pairs sit a hair apart (or, for the {{-- Left: ΑΠΟΣΤΟΛΕΑΣ — sender_1/sender_2 only (code + name), as the stub prints --}}
Συν.Χρέωσης/ΕΠΙΒΑΡΥΝΣΕΙΣ pair, even overlap slightly) per the RDLC's <div class="mc-box" style="top:2.40cm;left:0.00cm;width:6.00cm;height:0.95cm;">
own geometry — left untouched — close enough that two independent <div class="mc-party-title" style="top:0.04cm;left:0.10cm;font-size:6.5pt;line-height:1;">ΑΠΟΣΤΟΛΕΑΣ</div>
borders read as one doubled/thick line. border-top/border-bottom is
removed from one side of each pair so only the remaining box's
border draws that shared line. --}}
<div class="mc-box mc-charge" style="top:2.02cm;left:5.90cm;width:4.87cm;height:0.37cm;font-size:5.4pt;padding-top:4px;white-space:nowrap;overflow:hidden;border-top:none;">{{ str_replace(' ΠΙΣΤΩΣΗ', ' ΤΡ.ΠΛΗΡ: ΠΙΣΤΩΣΗ', $xreosi) }}</div>
<div class="mc-box mc-partybox" style="top:2.44cm;left:0.00cm;width:5.98cm;height:0.91cm;overflow:hidden;">
<div class="mc-party-title" style="top:0.03cm;left:0.10cm;font-size:6.5pt;">ΑΠΟΣΤΟΛΕΑΣ</div>
{{-- The stub prints sender_1/sender_2 only (code + name) --}}
@foreach (array_slice($sender_lines, 0, 2) as $i => $line) @foreach (array_slice($sender_lines, 0, 2) as $i => $line)
@if (trim((string) $line) !== '') @if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.30 + $i * 0.19 }}cm;left:0.06cm;width:5.85cm;font-size:5.6pt;">{{ $line }}</div> <div class="mc-party-line" style="top:{{ 0.34 + $i * 0.26 }}cm;left:0.10cm;width:5.80cm;font-size:5.6pt;line-height:1;">{{ $line }}</div>
@endif @endif
@endforeach @endforeach
</div> </div>
<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>
</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;"> {{-- Left: ΠΑΡΑΛΗΠΤΗΣ — rec_1..3: name, address, postcode (no phone) --}}
<div class="mc-party-title" style="top:0.06cm;left:0.10cm;font-size:6.5pt;">ΠΑΡΑΛΗΠΤΗΣ</div> <div class="mc-box" style="top:3.45cm;left:0.00cm;width:6.00cm;height:1.45cm;">
{{-- ...and rec_1..4: name, address, postcode — no phone --}} <div class="mc-party-title" style="top:0.04cm;left:0.10cm;font-size:6.5pt;line-height:1;">ΠΑΡΑΛΗΠΤΗΣ</div>
@foreach (array_slice($recipient_lines, 0, 3) as $i => $line) @foreach (array_slice($recipient_lines, 0, 3) as $i => $line)
@if (trim((string) $line) !== '') @if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.34 + $i * 0.26 }}cm;left:0.10cm;width:5.79cm;font-size:5.6pt;">{{ $line }}</div> <div class="mc-party-line" style="top:{{ 0.36 + $i * 0.30 }}cm;left:0.10cm;width:5.80cm;font-size:5.6pt;line-height:1;">{{ $line }}</div>
@endif
@endforeach
</div>
<div class="mc-box" style="top:3.28cm;left:6.06cm;width:4.71cm;height:1.41cm;">
<div style="position:absolute;top:0.05cm;left:0.14cm;font-size:6pt;">* ΕΠΙΒΑΡΥΝΣΕΙΣ *</div>
{{-- sur_1..4 (textbox139-142) --}}
@foreach ([$sur_1, $sur_2, $sur_3, $sur_4] as $i => $sur)
@if (filled($sur))
<div style="position:absolute;top:{{ 0.33 + $i * 0.265 }}cm;left:0.13cm;width:4.52cm;font-size:6pt;">{{ $sur }}</div>
@endif @endif
@endforeach @endforeach
</div> </div>
<div style="position:absolute;top:4.95cm;left:0.05cm;width:8.75cm;font-size:4.6pt;line-height:1.25;white-space:nowrap;"> {{-- Middle: service, then the charge line --}}
<div class="mc-box" style="top:1.45cm;left:6.20cm;width:4.80cm;height:0.45cm;">
<div style="position:absolute;top:0.10cm;left:0.12cm;font-size:6pt;line-height:1;">{{ $service_code }}</div>
<div style="position:absolute;top:0.10cm;left:0.80cm;width:3.90cm;font-size:6pt;line-height:1;white-space:nowrap;overflow:hidden;">{{ $service_name }}</div>
</div>
<div class="mc-box" style="top:1.90cm;left:6.20cm;width:4.80cm;height:0.40cm;border-top:none;text-align:center;font-weight:bold;">
<div style="position:absolute;top:0.10cm;left:0;width:100%;font-size:5.4pt;line-height:1;white-space:nowrap;">{{ str_replace(' ΠΙΣΤΩΣΗ', ' ΤΡ.ΠΛΗΡ: ΠΙΣΤΩΣΗ', $xreosi) }}</div>
</div>
{{-- Middle: Συν.Χρέωσης, Πρόσθετες Υπηρεσίες and the remarks (sxolia_1..2 on one line) --}}
<div class="mc-box" style="top:2.40cm;left:6.20cm;width:4.80cm;height:0.95cm;">
<div style="position:absolute;top:0.05cm;left:0.12cm;font-size:6pt;line-height:1;">Συν.Χρέωσης (€):</div>
<div style="position:absolute;top:0.32cm;left:0.12cm;font-size:6pt;line-height:1;">Πρόσθετες Υπηρεσίες</div>
@if (filled($sxolia))
<div style="position:absolute;top:0.62cm;left:0.12cm;width:4.56cm;font-size:4.6pt;line-height:1;white-space:nowrap;overflow:hidden;">{{ implode('', array_slice($sxolia, 0, 2)) }}</div>
@endif
</div>
{{-- Middle: ΕΠΙΒΑΡΥΝΣΕΙΣ — sur_1..4 --}}
<div class="mc-box" style="top:3.45cm;left:6.20cm;width:4.80cm;height:1.45cm;">
<div style="position:absolute;top:0.05cm;left:0.12cm;font-size:6pt;line-height:1;">* ΕΠΙΒΑΡΥΝΣΕΙΣ *</div>
@foreach (array_values(array_filter([$sur_1, $sur_2, $sur_3, $sur_4])) as $i => $sur)
<div style="position:absolute;top:{{ 0.33 + $i * 0.26 }}cm;left:0.12cm;width:4.56cm;font-size:6pt;line-height:1;white-space:nowrap;overflow:hidden;">{{ $sur }}</div>
@endforeach
</div>
{{-- Far right: ELTA's "Hellenic Post" logo and slogan (the RDLC's
Point5_ELTACourier_ΑποστολήμαςΕσείς image, printed there on its side) --}}
<div style="position:absolute;top:1.50cm;left:16.90cm;width:3.30cm;text-align:center;">
<img src="{{ $hellenicPostLogo }}" alt="ΕΛΤΑ" style="width:3.30cm;height:2.23cm;">
<div style="margin-top:0.15cm;font-size:7pt;font-weight:bold;line-height:1;color:#006ba6;">Αποστολή μας εσείς!</div>
</div>
{{-- Right: ELTA's company details (Πλήρηστοιχεία_black) --}}
<div style="position:absolute;top:1.50cm;left:11.30cm;width:5.30cm;">
@include('core::shipping.carriers.elta.partials._elta-details', ['style' => 'font-size:5.4pt;line-height:1.25;'])
</div>
<div style="position:absolute;top:5.00cm;left:0.00cm;width:11.00cm;font-size:4.6pt;line-height:1;white-space:nowrap;">
Έλαβα γνώση των όρων που αναγράφονται στο αντίγραφο 1 και 6 και τους αποδέχομαι ανεπιφύλακτα Έλαβα γνώση των όρων που αναγράφονται στο αντίγραφο 1 και 6 και τους αποδέχομαι ανεπιφύλακτα
</div> </div>
{{-- rectangle29 --}} {{-- Bottom row: signatures (left), the COD breakdown antik_1..6 (middle), ΠΟΣΟ (right) --}}
<div class="mc-box" style="top:5.46cm;left:0.03cm;width:2.75cm;height:1.66cm;"> <div class="mc-box" style="top:5.30cm;left:0.00cm;width:2.90cm;height:1.65cm;">
<div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΥΠΟΓΡΑΦΗ ΑΠΟΣΤΟΛΕΑ</div> <div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;font-weight:bold;line-height:1.2;">ΥΠΟΓΡΑΦΗ<br>ΑΠΟΣΤΟΛΕΑ</div>
<div style="position:absolute;top:1.35cm;left:0.08cm;font-size:6pt;">{{ $date }}</div> <div style="position:absolute;top:1.30cm;left:0.08cm;font-size:6pt;line-height:1;">{{ $date }}</div>
</div> </div>
{{-- rectangle43 --}} <div class="mc-box" style="top:5.30cm;left:3.00cm;width:3.00cm;height:1.65cm;">
<div class="mc-box" style="top:5.47cm;left:2.89cm;width:3.76cm;height:1.66cm;"> <div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;font-weight:bold;line-height:1.2;">ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ<br>ΠΑΡΑΛΗΠΤΗ</div>
<div style="position:absolute;top:0.09cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ ΠΑΡΑΛΗΠΤΗ</div> <div style="position:absolute;top:1.30cm;left:0.08cm;font-size:6pt;line-height:1;">Ημ/νία: &nbsp;&nbsp;&nbsp; Ώρα:</div>
<div style="position:absolute;top:1.35cm;left:0.08cm;font-size:6pt;">Ημ/νία: &nbsp;&nbsp;&nbsp; Ώρα:</div>
</div> </div>
{{-- Image18: the ELTA "ΠΟΡΤΑ-ΠΟΡΤΑ" details bitmap, above ΠΟΣΟ — <div class="mc-box" style="top:5.30cm;left:6.20cm;width:4.80cm;height:1.65cm;">
approximated with our logo + the same text, since the bitmap can't
be extracted. --}}
<div class="ps-brandbox-small" style="position:absolute;top:3.12cm;left:10.87cm;width:4.79cm;height:2.17cm;border:none;">
<div class="ps-brand-logo-sm"><img src="{{ $eltaLogo }}" alt=""></div>
<div class="ps-legal-sm">
<strong>ΠΟΡΤΑ-ΠΟΡΤΑ</strong><br>
ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε.<br>
ΕΕΤΤ ΑΜ: 99-150
</div>
</div>
{{-- rectangle44: the COD breakdown, antik_1..6 (textbox149-151,167-169) --}}
<div class="mc-box" style="top:5.48cm;left:6.76cm;width:3.99cm;height:1.68cm;">
@foreach ($antik_lines as $i => $line) @foreach ($antik_lines as $i => $line)
<div style="position:absolute;top:{{ 0.04 + $i * 0.265 }}cm;left:0.11cm;width:3.78cm;font-size:7pt;{{ $i === 0 ? 'font-weight:bold;' : '' }}">{{ $line }}</div> <div style="position:absolute;top:{{ 0.10 + $i * 0.30 }}cm;left:0.12cm;width:4.56cm;font-size:7pt;line-height:1;{{ $i === 0 ? 'font-weight:bold;' : '' }}">{{ $line }}</div>
@endforeach @endforeach
</div> </div>
{{-- rectangle45: Π Ο Σ Ο + antik_poso (textbox148/152) --}} <div class="mc-box" style="top:5.30cm;left:11.30cm;width:2.80cm;height:1.65cm;text-align:center;">
<div class="mc-box" style="top:5.55cm;left:10.84cm;width:2.72cm;height:1.61cm;text-align:center;"> <div style="position:absolute;top:0.18cm;left:0;width:100%;font-size:7pt;line-height:1;">Π Ο Σ Ο</div>
<div style="position:absolute;top:0.15cm;left:0;width:100%;font-size:7pt;">Π Ο Σ Ο</div> <div style="position:absolute;top:0.70cm;left:0;width:100%;font-size:13pt;font-weight:bold;line-height:1;">{{ $antik_poso }}</div>
<div style="position:absolute;top:0.70cm;left:0;width:100%;font-size:13pt;font-weight:bold;">{{ $antik_poso }}</div>
</div> </div>
+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\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache; use Modules\Core\Payment\Services\PaymentMethodCache;
use Modules\Core\Shipping\Contracts\SupportsCashCollection; use Modules\Core\Shipping\Contracts\SupportsCashCollection;
use Modules\Core\Shipping\Support\BoxNowShipping;
use Modules\Core\Shipping\Support\FulfillmentType; use Modules\Core\Shipping\Support\FulfillmentType;
/** /**
@@ -181,7 +182,7 @@ class CheckoutService
// switches BACK to Box Now and it resurfaces as if still chosen, // switches BACK to Box Now and it resurfaces as if still chosen,
// possibly for a locker that no longer exists/fits. Cleared here, // possibly for a locker that no longer exists/fits. Cleared here,
// the one place that knows the method just changed. // the one place that knows the method just changed.
if ($identifier !== 'box-now') { if (! BoxNowShipping::isOption($identifier)) {
$address = $cart->shippingAddress; $address = $cart->shippingAddress;
if ($address && isset($address->meta['box_now_locker'])) { if ($address && isset($address->meta['box_now_locker'])) {
@@ -57,7 +57,7 @@ class OrderDispatchedNotification extends BaseNotification
'reference' => $reference, 'reference' => $reference,
// Active, numbered, non-return shipments: the voucher numbers // Active, numbered, non-return shipments: the voucher numbers
// to show, plus the courier's link for manual carriers // 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. // on the customer's order page.
'shipments' => $order->shipments()->get() 'shipments' => $order->shipments()->get()
->reject(fn (Shipment $shipment) => $shipment->isCancelled() || $shipment->isReturn() || blank($shipment->tracking_reference)) ->reject(fn (Shipment $shipment) => $shipment->isCancelled() || $shipment->isReturn() || blank($shipment->tracking_reference))
@@ -118,6 +118,12 @@ class OrderFulfillmentService
'meta' => [ 'meta' => [
'services' => array_map(fn (ExtraService $service) => $service->value, $services), 'services' => array_map(fn (ExtraService $service) => $service->value, $services),
'cod_amount' => $this->flow->isCod($order) ? $order->total->decimal : null, '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; namespace Modules\Core\Providers;
use Carbon\Carbon;
use Filament\Support\Facades\FilamentTimezone;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Modules\Core\Command\AnonymizeCommand; use Modules\Core\Command\AnonymizeCommand;
use Modules\Core\Command\BackfillMissingSkusCommand; use Modules\Core\Command\BackfillMissingSkusCommand;
@@ -26,6 +29,19 @@ class CoreServiceProvider extends ServiceProvider
{ {
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core'); $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core');
Blade::anonymousComponentPath(__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->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadTranslationsFrom(__DIR__ . '/../../lang', 'core'); $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 — // run before the Shipping::extend() calls below, which resolve —
// and the facade then CACHES — whatever's bound at that moment). // and the facade then CACHES — whatever's bound at that moment).
$this->app->booted(function () { $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('acs', fn ($app) => $app->make(AcsRateDriver::class));
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class)); Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
@@ -39,7 +39,7 @@ class ShipmentCheckpointRender extends AbstractRender
$log->getExtraProperty('location'), $log->getExtraProperty('location'),
$log->getExtraProperty('message') ?: $log->getExtraProperty('carrier_status'), $log->getExtraProperty('message') ?: $log->getExtraProperty('carrier_status'),
])->filter()->unique()->implode(' · '), ])->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, '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' $codAmount = $request->paymentMode === 'cod'
? (float) ($request->amountToCollect ?? $order->total->decimal) ? (float) ($request->amountToCollect ?? $order->total->decimal)
: null; : null;
@@ -108,6 +113,7 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsExtr
'tracking_reference' => $voucherNo, 'tracking_reference' => $voucherNo,
'meta' => [ 'meta' => [
'station_destination' => $destination->stationId, 'station_destination' => $destination->stationId,
'reference' => (string) $order->reference,
'weight' => $weight, 'weight' => $weight,
'pickup_date' => now()->toDateString(), 'pickup_date' => now()->toDateString(),
'cod_amount' => $codAmount, 'cod_amount' => $codAmount,
@@ -210,7 +216,8 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsExtr
carrierStatus: $row['checkpoint_action'] ?? null, carrierStatus: $row['checkpoint_action'] ?? null,
message: $row['checkpoint_action'] ?? null, message: $row['checkpoint_action'] ?? null,
location: $row['checkpoint_location'] ?? 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, meta: $row,
); );
}); });
@@ -8,11 +8,13 @@ use Illuminate\Support\Collection;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException; use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\CreatesStandaloneVouchers;
use Modules\Core\Shipping\Contracts\SupportsTracking; use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Contracts\SupportsVoucherListing; use Modules\Core\Shipping\Contracts\SupportsVoucherListing;
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup; use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
use Modules\Core\Shipping\DTOs\CarrierVoucher; use Modules\Core\Shipping\DTOs\CarrierVoucher;
use Modules\Core\Shipping\DTOs\ShipmentRequest; use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\DTOs\VoucherRecipient;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint; use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
@@ -31,12 +33,11 @@ use Modules\Core\Shipping\Models\Shipment;
* $order->shippingAddress->meta['box_now_locker']). * $order->shippingAddress->meta['box_now_locker']).
* *
* Box Now ships by compartment size, not weight — unlike ACS, which bills * Box Now ships by compartment size, not weight — unlike ACS, which bills
* by kg. One 'items' entry per box in ShipmentRequest::$boxes, so an order * by kg. Each box in ShipmentRequest::$boxes is its own delivery request
* needing more than one physical parcel (doesn't fit one compartment) * (and so its own parcel, voucher and compartment): Box Now merges the
* sends that many entries in a single delivery request rather than * items of one request into a single parcel — see create().
* several separate ones.
*/ */
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup class BoxNowFulfillmentService implements CarrierFulfillmentInterface, CreatesStandaloneVouchers, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup
{ {
private const COMPARTMENT_SIZES = ['S' => 1, 'M' => 2, 'L' => 3]; private const COMPARTMENT_SIZES = ['S' => 1, 'M' => 2, 'L' => 3];
@@ -44,7 +45,35 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
public function createShipment(Order $order, ShipmentRequest $request): Shipment public function createShipment(Order $order, ShipmentRequest $request): Shipment
{ {
$address = $order->shippingAddress; // Box Now rejects an orderNumber it has seen before (P410), even
// for a cancelled request — so a re-created shipment after a cancel
// gets "-2", "-3", …; the first attempt keeps "{reference}-{id}".
$previousRequests = Shipment::where('order_id', $order->id)
->where('carrier', 'box-now')
->where('source', Shipment::SOURCE_CREATED)
->get()
// One per attempt — the boxes of one attempt share its reference.
->map(fn (Shipment $shipment) => $shipment->meta['reference'] ?? $shipment->meta['delivery_request_id'] ?? $shipment->id)
->unique()
->count();
$orderNumber = $order->reference.'-'.$order->id.($previousRequests > 0 ? '-'.($previousRequests + 1) : '');
return $this->create($request->recipient ?? VoucherRecipient::fromAddress($order->shippingAddress), $orderNumber, $request, $order);
}
/**
* A delivery request for a parcel that isn't a shop order ("Create
* voucher" on Carrier Vouchers). Our reference is its orderNumber, so
* it must be one Box Now hasn't seen before.
*/
public function createStandaloneVoucher(VoucherRecipient $recipient, string $reference, ShipmentRequest $request): Shipment
{
return $this->create($recipient, $reference, $request);
}
private function create(VoucherRecipient $recipient, string $orderNumber, ShipmentRequest $request, ?Order $order = null): Shipment
{
$destinationLocationId = $request->destinationLocationId; $destinationLocationId = $request->destinationLocationId;
if (! $destinationLocationId) { if (! $destinationLocationId) {
@@ -55,76 +84,105 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
throw new BoxNowApiException('At least one box (compartment size) is required for a Box Now shipment.'); throw new BoxNowApiException('At least one box (compartment size) is required for a Box Now shipment.');
} }
$isCod = $request->paymentMode === 'cod'; // The locker's pickup code goes out by SMS and email.
$contactNumber = $recipient->mobile ?: $recipient->phone;
// Box Now rejects an orderNumber it has seen before (P410), even if (blank($contactNumber) || blank($recipient->email)) {
// for a cancelled request — so a re-created shipment after a cancel throw new BoxNowApiException('Box Now needs the recipient\'s mobile and email — the locker pickup code is sent to both.');
// gets "-2", "-3", …; the first attempt keeps "{reference}-{id}".
$previousRequests = Shipment::where('order_id', $order->id)
->where('carrier', 'box-now')
->where('source', Shipment::SOURCE_CREATED)
->get()
->map(fn (Shipment $shipment) => $shipment->meta['delivery_request_id'] ?? $shipment->id)
->unique()
->count();
$orderNumber = $order->reference.'-'.$order->id.($previousRequests > 0 ? '-'.($previousRequests + 1) : '');
$response = $this->client->request('post', '/delivery-requests', [
'orderNumber' => $orderNumber,
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
'paymentMode' => $isCod ? 'cod' : 'prepaid',
'amountToBeCollected' => $isCod
? number_format($request->amountToCollect ?? $order->total->decimal, 2, '.', '')
: '0.00',
'origin' => [
'contactNumber' => config('boxnow.sender.phone'),
'contactEmail' => config('boxnow.sender.email'),
'contactName' => config('boxnow.sender.name'),
'locationId' => config('boxnow.origin_location_id'),
],
'destination' => [
'contactNumber' => $this->internationalPhone($address->contact_phone),
'contactEmail' => $address->contact_email,
'contactName' => trim("{$address->first_name} {$address->last_name}"),
'locationId' => $destinationLocationId,
],
'items' => collect($request->boxes)->values()->map(fn (string $size, int $index) => [
'id' => $order->id.'-'.($index + 1),
'name' => 'Order '.$order->reference.' (box '.($index + 1).')',
'value' => '0.00',
'compartmentSize' => self::COMPARTMENT_SIZES[$size] ?? self::COMPARTMENT_SIZES['S'],
])->all(),
]);
$parcels = collect($response['parcels'] ?? []);
if ($parcels->isEmpty()) {
throw new BoxNowApiException('Box Now delivery request succeeded but returned no parcel ids.', $response);
} }
// One Shipment row per box/parcel — each is independently $isCod = $request->paymentMode === 'cod';
// trackable/printable/cancellable via its own tracking_reference $codAmount = $isCod ? (float) ($request->amountToCollect ?? $order?->total->decimal) : null;
// (printLabel()/cancelShipment()/trackShipment() below already
// operate per-Shipment), even though all boxes were submitted in
// one delivery request. Siblings are linked via the shared
// delivery_request_id in meta.
// The cash is collected once for the whole request, so only the
// first parcel carries cod_amount.
$codAmount = $isCod ? (float) ($request->amountToCollect ?? $order->total->decimal) : null;
$shipments = $parcels->values()->map(fn (array $parcel, int $index) => Shipment::create([ if ($isCod && ! $codAmount) {
'order_id' => $order->id, throw new BoxNowApiException('Enter the cash on delivery amount.');
'carrier' => 'box-now', }
'tracking_reference' => (string) $parcel['id'],
'meta' => [
'delivery_request_id' => $response['id'] ?? null,
'locker_id' => $destinationLocationId,
'cod_amount' => $index === 0 ? $codAmount : null,
],
]));
return $shipments->first(); // The parcel's value: the order's total, or what staff entered
// (falling back to the cash to collect).
$value = $order?->total->decimal ?? $request->value ?? $codAmount ?? 0;
// One delivery request per box. Box Now's manual says every entry
// in "items" becomes its own parcel, but in practice (staging,
// Any-APM origin, verified 2026-10-01 with our payload and with
// the official plugin's) it puts all items of a request into ONE
// parcel at the largest size — two boxes would get one voucher.
// A request per box gives each box its own parcel, label and
// compartment. Each needs its own orderNumber ("-b1", "-b2", …);
// the order's value and any cash on delivery go on the first box.
$boxes = array_values($request->boxes);
$count = count($boxes);
$shipments = [];
try {
foreach ($boxes as $index => $size) {
$first = $index === 0;
$boxNumber = $count > 1 ? "{$orderNumber}-b".($index + 1) : $orderNumber;
$response = $this->client->request('post', '/delivery-requests', [
'orderNumber' => $boxNumber,
'invoiceValue' => number_format($first ? $value : 0, 2, '.', ''),
'paymentMode' => $isCod && $first ? 'cod' : 'prepaid',
'amountToBeCollected' => number_format($first ? ($codAmount ?? 0) : 0, 2, '.', ''),
'origin' => [
'contactNumber' => config('boxnow.sender.phone'),
'contactEmail' => config('boxnow.sender.email'),
'contactName' => config('boxnow.sender.name'),
'locationId' => config('boxnow.origin_location_id'),
],
'destination' => [
'contactNumber' => $this->internationalPhone($contactNumber),
'contactEmail' => $recipient->email,
'contactName' => $recipient->name,
'locationId' => $destinationLocationId,
],
'items' => [[
'id' => $boxNumber,
'name' => ($order ? 'Order '.$order->reference : $orderNumber).($count > 1 ? ' (box '.($index + 1).' of '.$count.')' : ''),
'value' => '0.00',
'weight' => 0,
'compartmentSize' => self::COMPARTMENT_SIZES[$size] ?? self::COMPARTMENT_SIZES['S'],
]],
]);
$parcelId = $response['parcels'][0]['id'] ?? null;
if (! $parcelId) {
throw new BoxNowApiException('Box Now delivery request succeeded but returned no parcel id.', $response);
}
$shipments[] = Shipment::create([
'order_id' => $order?->id,
'carrier' => 'box-now',
'tracking_reference' => (string) $parcelId,
'meta' => [
'delivery_request_id' => $response['id'] ?? null,
// Shared by the boxes of one shipment; each box's
// own orderNumber is box_order_number.
'reference' => $orderNumber,
'box_order_number' => $boxNumber,
'locker_id' => $destinationLocationId,
'cod_amount' => $first ? $codAmount : null,
...($count > 1 ? ['box' => $index + 1, 'boxes' => $count] : []),
// Who it goes to, as sent (Shipment::recipient()).
'recipient' => $recipient->toArray(),
],
]);
}
} catch (\Throwable $e) {
// No half shipments: cancel the boxes already created.
foreach ($shipments as $shipment) {
try {
$this->cancelShipment($shipment);
} catch (\Throwable $cancelFailure) {
report($cancelFailure);
}
}
throw $e;
}
return $shipments[0];
} }
/** /**
+19 -1
View File
@@ -5,6 +5,8 @@ namespace Modules\Core\Shipping\Carriers\Elta;
use DOMDocument; use DOMDocument;
use DOMElement; use DOMElement;
use DOMXPath; use DOMXPath;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException; use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
@@ -12,7 +14,8 @@ use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
* SOAP transport for ELTA Courier's web services. * SOAP transport for ELTA Courier's web services.
* *
* ELTA runs two parallel operation families at the same endpoint * ELTA runs two parallel operation families at the same endpoint
* (212.205.47.226:9003). The original CREATEAWB/PELB64VG/PELTT01/PELSTATION * (https://clients.elta-courier.gr; formerly 212.205.47.226:9003, which is
* plain HTTP only — see docs/elta.md). The original CREATEAWB/PELB64VG/PELTT01/PELSTATION
* set (sourced from the unofficial chinchillabrains/eltaws reference client) * set (sourced from the unofficial chinchillabrains/eltaws reference client)
* rejects this account's credentials with st_flag=3 ("invalid password"). * rejects this account's credentials with st_flag=3 ("invalid password").
* Decompiling ELTA's own official desktop client (ELTA_PEL.exe, from their * Decompiling ELTA's own official desktop client (ELTA_PEL.exe, from their
@@ -99,6 +102,15 @@ class EltaClient
return $this->call('PELTKNEW', $params); return $this->call('PELTKNEW', $params);
} }
/**
* PELTKBOX: the PostBox station and service for a postcode — what the
* client calls instead of PELTKNEW when "ΕΠΙΔΟΣΗ POST BOX" is ticked.
*/
public function getPostBoxStation(array $params): EltaResponse
{
return $this->call('PELTKBOX', $params);
}
private function call(string $operation, array $params): EltaResponse private function call(string $operation, array $params): EltaResponse
{ {
$namespace = '/'.$operation; $namespace = '/'.$operation;
@@ -112,11 +124,17 @@ class EltaClient
.'<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cre="'.$namespace.'">' .'<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cre="'.$namespace.'">'
.'<soapenv:Header/><soapenv:Body><cre:READ>'.$body.'</cre:READ></soapenv:Body></soapenv:Envelope>'; .'<soapenv:Header/><soapenv:Body><cre:READ>'.$body.'</cre:READ></soapenv:Body></soapenv:Envelope>';
// The host occasionally times out or answers a one-off 500; two
// quick retries cover that. Every call is idempotent except
// PELVGNEW, where a retry after a lost response could create a
// duplicate pending voucher — rare, and visible in the pending list.
$response = Http::withHeaders([ $response = Http::withHeaders([
'SOAPAction' => 'Get', 'SOAPAction' => 'Get',
'Content-Type' => 'text/xml', 'Content-Type' => 'text/xml',
]) ])
->timeout($this->config['timeout']) ->timeout($this->config['timeout'])
->retry(2, 500, fn (\Throwable $e) => $e instanceof ConnectionException
|| ($e instanceof RequestException && $e->response->serverError()), throw: false)
->withBody($envelope, 'text/xml') ->withBody($envelope, 'text/xml')
->post($this->config['service_location']); ->post($this->config['service_location']);
@@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Cache;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException; use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\CreatesStandaloneVouchers;
use Modules\Core\Shipping\Contracts\IssuesVoucherOnPrint; use Modules\Core\Shipping\Contracts\IssuesVoucherOnPrint;
use Modules\Core\Shipping\Contracts\SupportsBatchLabels; use Modules\Core\Shipping\Contracts\SupportsBatchLabels;
use Modules\Core\Shipping\Contracts\SupportsExtraServices; use Modules\Core\Shipping\Contracts\SupportsExtraServices;
@@ -18,6 +19,7 @@ use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
use Modules\Core\Shipping\DTOs\CarrierVoucher; use Modules\Core\Shipping\DTOs\CarrierVoucher;
use Modules\Core\Shipping\DTOs\ShipmentRequest; use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint; use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\DTOs\VoucherRecipient;
use Modules\Core\Shipping\Enums\ExtraService; use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
@@ -33,7 +35,7 @@ use Modules\Core\Shipping\Models\Shipment;
* *
* Not SupportsManifestBatching — ELTA has no manifest/pickup-list step. * Not SupportsManifestBatching — ELTA has no manifest/pickup-list step.
*/ */
class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVoucherOnPrint, SupportsBatchLabels, SupportsExtraServices, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup class EltaFulfillmentService implements CarrierFulfillmentInterface, CreatesStandaloneVouchers, IssuesVoucherOnPrint, SupportsBatchLabels, SupportsExtraServices, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup
{ {
/** PELMANIF2 / PELPARALVGNEW1 page through 100 rows at a time. */ /** PELMANIF2 / PELPARALVGNEW1 page through 100 rows at a time. */
private const MAX_PAGES = 50; private const MAX_PAGES = 50;
@@ -53,35 +55,109 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch
ExtraService::SaturdayDelivery->value => '004', ExtraService::SaturdayDelivery->value => '004',
ExtraService::TimedDelivery->value => '003', ExtraService::TimedDelivery->value => '003',
ExtraService::SpecialHandling->value => '002', ExtraService::SpecialHandling->value => '002',
ExtraService::PostBoxDelivery->value => '917',
ExtraService::Insurance->value => 'pel_asf_poso', ExtraService::Insurance->value => 'pel_asf_poso',
]; ];
} }
public function createShipment(Order $order, ShipmentRequest $request): Shipment public function createShipment(Order $order, ShipmentRequest $request): Shipment
{ {
$address = $order->shippingAddress; return $this->create($request->recipient ?? VoucherRecipient::fromAddress($order->shippingAddress), (string) $order->reference, $request, $order);
}
/**
* A voucher for a parcel that isn't a shop order ("Create voucher" on
* Carrier Vouchers): created pending, exactly like an order's.
*/
public function createStandaloneVoucher(VoucherRecipient $recipient, string $reference, ShipmentRequest $request): Shipment
{
return $this->create($recipient, $reference, $request);
}
private function create(VoucherRecipient $recipient, string $reference, ShipmentRequest $request, ?Order $order = null): Shipment
{
// Where the staff fix a missing or wrong phone: the order's
// shipping address, or the form they're filling in.
$fixIt = $order ? ' — add one to the shipping address.' : '.';
$given = $recipient->phones();
$weight = $request->weight ?? 0.5; $weight = $request->weight ?? 0.5;
$surcharges = $this->surchargeCodes($request); $surcharges = $this->surchargeSlots($request);
// The rules ELTA's own client checks before calling (docs/elta.md,
// A5) — ELTA itself accepts a pending voucher without them.
if ($request->packageCount > 20) {
throw new EltaApiException('ELTA allows at most 20 pieces per voucher.');
}
// All three dimensions or none, whole cm up to 999 ("Λάθος
// Διαστάσεις"); volumetric weight = X × Y × Z ÷ 5000.
$dimensions = $request->dimensions ? array_map('intval', array_values($request->dimensions)) : null;
if ($dimensions && (count($dimensions) !== 3 || min($dimensions) < 1 || max($dimensions) > 999)) {
throw new EltaApiException('ELTA needs all three dimensions, in whole cm from 1 to 999.');
}
$volumetric = $dimensions ? number_format(array_product($dimensions) / 5000, 3, '.', '') : null;
[$phone, $mobile] = $this->phones((string) $recipient->phone);
if (filled($recipient->mobile)) {
[$otherPhone, $otherMobile] = $this->phones($recipient->mobile);
$phone = $phone ?: $otherPhone;
$mobile = $mobile ?: $otherMobile;
}
if ($phone === '' && $mobile === '') {
throw new EltaApiException('ELTA needs a recipient phone number'.$fixIt);
}
$postBox = $request->has(ExtraService::PostBoxDelivery);
if ($postBox) {
match (true) {
$request->has(ExtraService::SpecialHandling) => throw new EltaApiException("ELTA doesn't allow PostBox delivery with special handling."),
$request->paymentMode === 'cod' => throw new EltaApiException("ELTA doesn't allow PostBox delivery with cash on delivery."),
// ELTA's client: "Χωρίς Κινητό" / "Λάθος Κινητό Τηλέφωνο".
$mobile === '' => throw new EltaApiException($order
? "ELTA needs a mobile number (10 digits, starting with 69) for PostBox delivery — the shipping address has \"{$given}\"."
: "ELTA needs a mobile number (10 digits, starting with 69) for PostBox delivery — \"{$given}\" isn't one."),
default => null,
};
}
// 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) $recipient->notes)), 0, 90);
$routing = $postBox
? $this->postBoxRouting($recipient->postcode, $recipient->city)
: $this->routing($recipient->postcode, $recipient->city);
$params = [ $params = [
'pel_apost_code' => config('elta.apost_code'), 'pel_apost_code' => config('elta.apost_code'),
'pel_paral_name' => trim("{$address->first_name} {$address->last_name}"), 'pel_paral_name' => $recipient->name,
'pel_paral_address' => $address->line_one, 'pel_paral_address' => $recipient->address,
'pel_paral_area' => $address->city, 'pel_paral_area' => $routing['area'],
'pel_paral_tk' => $address->postcode, 'pel_paral_tk' => $recipient->postcode,
'pel_paral_thl_1' => $address->contact_phone, 'pel_paral_thl_1' => $phone,
'pel_paral_thl_2' => '', 'pel_paral_thl_2' => $mobile,
'pel_service' => '', 'pel_service' => $routing['service'],
'pel_baros' => number_format($weight, 3, '.', ''), 'pel_baros' => number_format($weight, 3, '.', ''),
'pel_baros_xyz' => '', 'pel_baros_xyz' => $volumetric ?? '',
'pel_x' => '', 'pel_x' => $dimensions ? (string) $dimensions[0] : '',
'pel_y' => '', 'pel_y' => $dimensions ? (string) $dimensions[1] : '',
'pel_z' => '', 'pel_z' => $dimensions ? (string) $dimensions[2] : '',
'pel_temaxia' => (string) $request->packageCount, 'pel_temaxia' => (string) $request->packageCount,
'pel_paral_sxolia' => '', // Remarks right-aligned in 99 characters, then "2" = parcel or
'pel_sur_1' => $surcharges[0] ?? '', // "1" = envelope — the client's encoding of its mandatory
'pel_sur_2' => $surcharges[1] ?? '', // envelope/parcel choice. ELTA keeps the digit as the
'pel_sur_3' => $surcharges[2] ?? '', // last character of the stored remarks. Padded by characters,
// not bytes (sprintf), so Greek remarks line up too.
'pel_paral_sxolia' => str_repeat(' ', 99 - mb_strlen($remarks)).$remarks.($request->envelope ? '1' : '2'),
'pel_sur_1' => $surcharges[1],
'pel_sur_2' => $surcharges[2],
'pel_sur_3' => $surcharges[3],
// pel_ant_poso is the cash-on-delivery amount; pel_ant_poso1..4 // pel_ant_poso is the cash-on-delivery amount; pel_ant_poso1..4
// are cheques (with dates) in ELTA's client — never used here. // are cheques (with dates) in ELTA's client — never used here.
'pel_ant_poso' => '', 'pel_ant_poso' => '',
@@ -100,59 +176,125 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch
// Our order reference — comes back in ELTA's lists and tracking, // Our order reference — comes back in ELTA's lists and tracking,
// which is how we find the pending voucher's id below and match // which is how we find the pending voucher's id below and match
// vouchers to orders on the Carrier Vouchers screen. // vouchers to orders on the Carrier Vouchers screen.
'pel_ref_no' => (string) $order->reference, 'pel_ref_no' => $reference,
// "0" = save without issuing (see this class's docblock). // "0" = save without issuing (see this class's docblock).
'pel_insert_flag' => '0', 'pel_insert_flag' => '0',
'pel_paral_code' => '', 'pel_paral_code' => '',
'pel_retur_code' => '', // Special handling needs a return-to party; the client defaults
// it to the sender (us). ELTA then issues a return voucher
// (return_vg) along with the main one — printed as RETOUR.
'pel_retur_code' => $request->has(ExtraService::SpecialHandling) ? (string) config('elta.apost_code') : '',
]; ];
$codAmount = null; $codAmount = null;
if ($request->paymentMode === 'cod') { if ($request->paymentMode === 'cod') {
$codAmount = $request->amountToCollect ?? $order->total->decimal; $codAmount = $request->amountToCollect ?? $order?->total->decimal;
if (! $codAmount) {
throw new EltaApiException('Enter the cash on delivery amount.');
}
$params['pel_ant_poso'] = number_format($codAmount, 2, '.', ''); $params['pel_ant_poso'] = number_format($codAmount, 2, '.', '');
} }
$this->client->createVoucher($params)->throwIfError(); $this->client->createVoucher($params)->throwIfError();
$pieces = max(1, $request->packageCount);
// PELVGNEW doesn't return the new voucher's id, so find it in the // 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 — // pending list by our reference. Saved even if that lookup fails —
// the voucher exists at ELTA either way, and printing retries it. // the voucher exists at ELTA either way, and printing retries it.
return Shipment::create([ $master = Shipment::create([
'order_id' => $order->id, 'order_id' => $order?->id,
'carrier' => 'elta', 'carrier' => 'elta',
'source' => Shipment::SOURCE_CREATED, 'source' => Shipment::SOURCE_CREATED,
'tracking_reference' => null, 'tracking_reference' => null,
'meta' => [ 'meta' => [
'carrier_id' => $this->findPendingVoucherId((string) $order->reference), 'carrier_id' => $this->findPendingVoucherId($reference),
'reference' => $reference,
// Who it goes to, as sent — the label and the lists read it
// from here (Shipment::recipient()), never from the order.
'recipient' => $recipient->toArray(),
'delivery_instructions' => $remarks ?: null,
'routing' => $routing['note'],
'weight' => $weight, 'weight' => $weight,
'package_count' => $request->packageCount, 'package_count' => $pieces,
// The label's volumetric cell, as the client prints it (ogos_2).
'volumetric_weight' => $dimensions ? implode('x', $dimensions).' = '.$volumetric : null,
'envelope' => $request->envelope ?: null,
'cod_amount' => $codAmount, 'cod_amount' => $codAmount,
'services' => $request->serviceValues(), 'services' => $request->serviceValues(),
'insurance_amount' => $request->has(ExtraService::Insurance) ? $request->insuranceAmount : null, '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' => $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), then the
* return voucher when ELTA issued one; 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 public function printLabel(Shipment $shipment): string
{ {
$this->issue($shipment); return $this->printLabels(collect([$shipment]));
$pdf = $this->labelRenderer->render($shipment->refresh());
$shipment->update(['label_printed_at' => now()]);
return $pdf;
} }
/**
* Several labels in one PDF. A selected main voucher brings its
* children and its return voucher along (as printLabel() does); each
* page is printed once, in the client's order.
*/
public function printLabels(Collection $shipments): string public function printLabels(Collection $shipments): string
{ {
$shipments->each(fn (Shipment $shipment) => $this->issue($shipment)); $pages = 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) {
$pages->put($label->id, $label);
}
if ($shipment->is($master) && filled($master->meta['return_vg'] ?? null)) {
$pages->put("retour-{$master->id}", new EltaReturnLabel($master));
}
}
$pdf = $this->labelRenderer->renderMany($pages->values());
Shipment::whereIn('id', $pages->whereInstanceOf(Shipment::class)->keys())->update(['label_printed_at' => now()]);
return $pdf; return $pdf;
} }
@@ -163,6 +305,16 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch
*/ */
public function cancelShipment(Shipment $shipment): void 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)) { if (filled($shipment->tracking_reference)) {
throw new EltaApiException( 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.", "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 +324,52 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch
$this->client->deleteVoucher(['pel_vg_id' => $this->pendingVoucherId($shipment)])->throwIfError(); $this->client->deleteVoucher(['pel_vg_id' => $this->pendingVoucherId($shipment)])->throwIfError();
$shipment->update(['cancelled_at' => now()]); $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 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([ $response = $this->client->getTracking([
'web_vg' => $shipment->tracking_reference, 'web_vg' => $shipment->tracking_reference,
'pel_code' => config('elta.apost_code'), 'pel_code' => config('elta.apost_code'),
@@ -193,6 +387,8 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch
// delivered parcel yet. // delivered parcel yet.
$isDelivered = filled(trim($response->data['pel_rec']['a_rec_date_time'] ?? '', ' /:')); $isDelivered = filled(trim($response->data['pel_rec']['a_rec_date_time'] ?? '', ' /:'));
$this->storeDeliveryDetails($shipment, (array) ($response->data['pel_rec'] ?? []));
return collect(array_values($rows))->map(function (array $row, int $index) use ($rows, $isDelivered) { return collect(array_values($rows))->map(function (array $row, int $index) use ($rows, $isDelivered) {
$isLast = $index === count($rows) - 1; $isLast = $index === count($rows) - 1;
$dateTime = $row['web_date_time'] ?? ''; $dateTime = $row['web_date_time'] ?? '';
@@ -211,6 +407,38 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch
}); });
} }
/**
* What tracking's voucher record (pel_rec) says beyond the checkpoints:
* who received the parcel and when (a_rec_name / a_rec_date_time, blank
* until delivery), and the COD remittance — the cash ELTA paid back to
* us (a_antik1 / a_antik_text1). Kept on the shipment's meta as ELTA
* reports them; saved only when something changed.
*
* @param array<string, mixed> $record
*/
private function storeDeliveryDetails(Shipment $shipment, array $record): void
{
$text = fn (string $key) => trim((string) ($record[$key] ?? ''), " \t\n\r\0\x0B/:") !== ''
? trim((string) $record[$key])
: null;
$details = array_filter([
'delivered_to' => $text('a_rec_name'),
'delivered_at' => $text('a_rec_date_time'),
'cod_remitted' => (float) ($record['a_antik1'] ?? 0) > 0 ? (float) $record['a_antik1'] : null,
'cod_remittance' => $text('a_antik_text1'),
], fn ($value) => $value !== null);
$meta = $shipment->meta?->toArray() ?? [];
if ($details === [] || array_intersect_assoc($details, $meta) === $details) {
return;
}
$shipment->meta = array_merge($meta, $details);
$shipment->save();
}
/** /**
* PELMANIF2 (the client's "Πολλαπλή Αναζήτηση"): every issued voucher * PELMANIF2 (the client's "Πολλαπλή Αναζήτηση"): every issued voucher
* in the date range with its latest status. A second pass with * in the date range with its latest status. A second pass with
@@ -308,45 +536,177 @@ class EltaFulfillmentService implements CarrierFulfillmentInterface, IssuesVouch
'rec_srv' => $data['rec_srv'] ?? null, 'rec_srv' => $data['rec_srv'] ?? null,
'rec_srv_t' => $data['rec_srv_t'] ?? null, 'rec_srv_t' => $data['rec_srv_t'] ?? null,
'return_vg' => trim((string) ($data['return_vg'] ?? '')) ?: null, 'return_vg' => trim((string) ($data['return_vg'] ?? '')) ?: null,
'r_ocr_line' => trim((string) ($data['r_ocr_line'] ?? '')) ?: null,
'epitagh_vg' => trim((string) ($data['epitagh_vg'] ?? '')) ?: null, 'epitagh_vg' => trim((string) ($data['epitagh_vg'] ?? '')) ?: null,
]); ]);
$shipment->save(); $shipment->save();
foreach (array_filter(array_map('trim', (array) ($data['vg_child_no'] ?? []))) as $childVoucherNo) { // The children's numbers, in ELTA's order (vg_child_no), go to the
Shipment::create([ // 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, 'order_id' => $shipment->order_id,
'carrier' => 'elta', 'carrier' => 'elta',
'source' => Shipment::SOURCE_CREATED, 'source' => Shipment::SOURCE_CREATED,
'tracking_reference' => $childVoucherNo, 'meta' => [
'parent_reference' => $voucherNo, 'master_shipment_id' => $shipment->id,
'label_printed_at' => now(), 'reference' => $shipment->meta['reference'] ?? null,
'meta' => $shipment->meta->toArray(), '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();
} }
} }
/** /**
* @return array<int, string> * @return array<int, string>
*/ */
private function surchargeCodes(ShipmentRequest $request): array /**
* ELTA's surcharge slots are fixed, as its client fills them: special
* handling in pel_sur_1, set time in pel_sur_2, Saturday in pel_sur_3.
* ELTA stores whatever slot it's sent (it doesn't reorder them), and its
* own client only reads Saturday from slot 3.
*
* @return array{1: string, 2: string, 3: string}
*/
private function surchargeSlots(ShipmentRequest $request): array
{ {
$codes = $this->extraServices(); return [
1 => match (true) {
$request->has(ExtraService::SpecialHandling) => '002',
$request->has(ExtraService::PostBoxDelivery) => '917',
default => '',
},
2 => $request->has(ExtraService::TimedDelivery) ? '003' : '',
3 => $request->has(ExtraService::SaturdayDelivery) ? '004' : '',
];
}
return collect($request->services) /**
->reject(fn (ExtraService $service) => $service === ExtraService::Insurance) * The recipient's number as ELTA's two phone fields take it: 10 digits
->map(fn (ExtraService $service) => $codes[$service->value] ?? null) * (a +30 / 0030 prefix dropped); a mobile (69…) goes in the mobile
->filter() * field, anything else in the phone field.
->values() *
->take(3) * @return array{0: string, 1: string} [phone, mobile]
->all(); */
private function phones(string $number): array
{
$digits = preg_replace('/\D/', '', $number);
$digits = preg_replace('/^(0030|30)(?=\d{10}$)/', '', $digits);
if (strlen($digits) !== 10) {
return [substr($digits, 0, 10), ''];
}
return str_starts_with($digits, '69') ? ['', $digits] : [$digits, ''];
}
/**
* The service and area ELTA's client would send (docs/elta.md, A4):
* PELTKNEW from our station gives the postcode's service. When the
* postcode is split into localities, the customer's city is matched to
* one — the area becomes "code/title" and the service 231 (hard to
* reach) or 201. Without a match the service is left for ELTA to
* decide, as before, and the note says so.
*
* @return array{service: string, area: string, note: ?string}
*/
private function routing(string $postcode, string $city): array
{
$response = $this->client->getStation([
'pel_tk' => $postcode,
'sender_station' => $this->senderStation(),
]);
if ($response->hasError) {
throw new EltaApiException("ELTA doesn't accept the postcode {$postcode}: {$response->errorMessage}", $response->data);
}
$fields = $response->data['pel_fields'] ?? [];
$localities = array_values(array_filter(
(array) ($response->data['a_lview_data'] ?? []),
fn ($row) => is_array($row) && filled($row['wloc_code'] ?? null) && trim($row['wloc_code']) !== '0',
));
if ($localities === []) {
return ['service' => trim($fields['pel_service'] ?? ''), 'area' => $city, 'note' => null];
}
$wanted = $this->comparable($city);
$matches = array_values(array_filter($localities, fn ($row) => $this->comparable($row['wloc_title'] ?? '') === $wanted));
if (count($matches) !== 1) {
return ['service' => '', 'area' => $city, 'note' => "No single ELTA locality matches \"{$city}\" in {$postcode}; ELTA chose the service."];
}
$locality = $matches[0];
return [
'service' => trim($locality['wloc_disp_flag'] ?? '') === '1' ? '231' : '201',
'area' => trim($locality['wloc_code']).'/'.trim($locality['wloc_title']),
'note' => null,
];
}
/**
* PostBox delivery routes through PELTKBOX instead (the client's
* "ΕΠΙΔΟΣΗ POST BOX"): it gives the PostBox station's service, or
* "Το Τ.Κ. Δεν Εχει PostBox" when the postcode has none.
*
* @return array{service: string, area: string, note: ?string}
*/
private function postBoxRouting(string $postcode, string $city): array
{
$response = $this->client->getPostBoxStation([
'pel_tk' => $postcode,
'sender_station' => $this->senderStation(),
]);
if ($response->hasError) {
throw new EltaApiException("ELTA can't deliver to a PostBox for {$postcode}: {$response->errorMessage}", $response->data);
}
$fields = $response->data['pel_fields'] ?? [];
$station = trim($fields['rec_station_t'] ?? '');
return [
'service' => trim($fields['pel_service'] ?? ''),
'area' => $city,
'note' => $station !== '' ? "PostBox: {$station}" : null,
];
}
/**
* Upper-case, without accents or punctuation — ELTA's locality titles
* and customers' city names differ in exactly those ways.
*/
private function comparable(string $text): string
{
$text = mb_strtoupper(trim($text));
$text = strtr($text, ['Ά' => 'Α', 'Έ' => 'Ε', 'Ή' => 'Η', 'Ί' => 'Ι', 'Ό' => 'Ο', 'Ύ' => 'Υ', 'Ώ' => 'Ω', 'Ϊ' => 'Ι', 'Ϋ' => 'Υ']);
return preg_replace('/[^\p{L}\p{N}]+/u', ' ', $text);
} }
private function pendingVoucherId(Shipment $shipment): string private function pendingVoucherId(Shipment $shipment): string
{ {
$id = $shipment->meta['carrier_id'] ?? null; $id = $shipment->meta['carrier_id'] ?? null;
if (blank($id) && $shipment->order) { $reference = $shipment->meta['reference'] ?? $shipment->order?->reference;
$id = $this->findPendingVoucherId((string) $shipment->order->reference);
if (blank($id) && filled($reference)) {
$id = $this->findPendingVoucherId((string) $reference);
if ($id) { if ($id) {
$shipment->meta = array_merge($shipment->meta?->toArray() ?? [], ['carrier_id' => $id]); $shipment->meta = array_merge($shipment->meta?->toArray() ?? [], ['carrier_id' => $id]);
+239 -38
View File
@@ -5,7 +5,9 @@ namespace Modules\Core\Shipping\Carriers\Elta;
use Barryvdh\DomPDF\Facade\Pdf; use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Modules\Core\Shipping\DTOs\VoucherRecipient;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
use Picqer\Barcode\BarcodeGeneratorPNG; use Picqer\Barcode\BarcodeGeneratorPNG;
@@ -38,6 +40,7 @@ class EltaLabelRenderer
'special_handling' => '002 ΕΙΔΙΚΗ ΔΙΑΧΕΙΡΗΣΗ', 'special_handling' => '002 ΕΙΔΙΚΗ ΔΙΑΧΕΙΡΗΣΗ',
'timed_delivery' => '003 ΠΡΟΚΑΘΟΡΙΣΜΕΝΗ ΩΡΑ', 'timed_delivery' => '003 ΠΡΟΚΑΘΟΡΙΣΜΕΝΗ ΩΡΑ',
'saturday_delivery' => '004 ΕΠΙΔΟΣΗ ΣΑΒΒΑΤΟΥ', 'saturday_delivery' => '004 ΕΠΙΔΟΣΗ ΣΑΒΒΑΤΟΥ',
'post_box_delivery' => '* ΠΡΟΣΟΧΗ PostBox *',
]; ];
public function __construct(private readonly AreaResolver $areaResolver) {} public function __construct(private readonly AreaResolver $areaResolver) {}
@@ -54,27 +57,44 @@ class EltaLabelRenderer
* <head> (the styles are the same for every label) with a page break * <head> (the styles are the same for every label) with a page break
* between them. * between them.
* *
* @param Collection<int, Shipment> $shipments * @param Collection<int, Shipment|EltaReturnLabel> $shipments
*/ */
public function renderMany(Collection $shipments): string public function renderMany(Collection $shipments): string
{ {
$paperSize = config('elta.label_paper_size', 'a4'); $paperSize = config('elta.label_paper_size', 'a4');
$view = $paperSize === 'a6'
? 'core::shipping.carriers.elta.label-a6'
: 'core::shipping.carriers.elta.label-a4';
$head = null; $head = null;
$styles = [];
$pages = []; $pages = [];
foreach ($shipments as $shipment) { foreach ($shipments as $shipment) {
$html = view($view, $this->data($shipment))->render(); $data = $shipment instanceof EltaReturnLabel
? $this->returnData($shipment->shipment)
: $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>'); $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>' .'<style>.page { page-break-after: always; } .page:last-child { page-break-after: auto; }</style>'
.Str::afterLast($head, '</style>')
.'<body>'.implode('', $pages).'</body></html>'; .'<body>'.implode('', $pages).'</body></html>';
$pdf = Pdf::loadHTML($html); $pdf = Pdf::loadHTML($html);
@@ -98,29 +118,51 @@ class EltaLabelRenderer
*/ */
private function data(Shipment $shipment): array private function data(Shipment $shipment): array
{ {
$order = $shipment->order; $paperSize = config('elta.label_paper_size', 'a4');
$address = $order->shippingAddress; $piece = $this->pieceOf($shipment);
$meta = $shipment->meta?->toArray() ?? []; $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),
);
// Who it goes to, as saved on the voucher — the label never reads
// the order.
$recipient = $shipment->recipient() ?? $master?->recipient()
?? throw new \RuntimeException("Shipment {$shipment->id} has no recipient to print.");
// Issuing a pending voucher (PELVG01NEW1) returns the real // Issuing a pending voucher (PELVG01NEW1) returns the real
// destination station and service; vouchers issued before that flow // destination station and service; vouchers issued before that flow
// existed don't have them, so fall back to the postcode lookup. // existed don't have them, so fall back to the postcode lookup.
$station = filled($meta['rec_station'] ?? null) $station = filled($meta['rec_station'] ?? null)
? ['code' => $meta['rec_station'], 'name' => $meta['rec_station_t'] ?? ''] ? ['code' => $meta['rec_station'], 'name' => $meta['rec_station_t'] ?? '']
: (fn ($s) => ['code' => $s->code, 'name' => $s->name])($this->areaResolver->resolve($address->postcode)); : (fn ($s) => ['code' => $s->code, 'name' => $s->name])($this->areaResolver->resolve($recipient->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); $surcharges = $this->surchargeSlots($services);
$cod = $this->codFields(isset($meta['cod_amount']) ? (float) $meta['cod_amount'] : null); $cod = match (true) {
$isChild => $this->childCodFields(($master?->meta['cod_amount'] ?? null) > 0),
// PostBox never has COD (ELTA refuses it); its antik_1 is the
// PostBox warning instead (print_vg()).
in_array('post_box_delivery', $services, true) => $this->postBoxCodFields(),
default => $this->codFields(isset($meta['cod_amount']) ? (float) $meta['cod_amount'] : null),
};
$dateTime = (string) ($meta['date_time'] ?? ''); $dateTime = (string) ($meta['date_time'] ?? '');
$occurredAt = strlen($dateTime) >= 12 $occurredAt = strlen($dateTime) >= 12
? Carbon::createFromFormat('YmdHi', substr($dateTime, 0, 12)) ? Carbon::createFromFormat('YmdHi', substr($dateTime, 0, 12))
: now(); : now()->inDisplayTimezone();
$voucherNo = $shipment->tracking_reference; $voucherNo = $shipment->tracking_reference;
$ocrLine = (string) ($meta['ocr_line'] ?? ''); $ocrLine = $isChild ? '' : (string) ($meta['ocr_line'] ?? '');
$packageCount = (int) ($meta['package_count'] ?? 1); $packageCount = (int) ($piece['of'] ?? $meta['package_count'] ?? 1);
$pieceLabel = sprintf('%03d/%03d', $piece['number'] ?? 1, $packageCount);
$generator = new BarcodeGeneratorPNG(); $generator = new BarcodeGeneratorPNG();
@@ -132,11 +174,11 @@ class EltaLabelRenderer
'sender_phone' => config('elta.sender_phone'), 'sender_phone' => config('elta.sender_phone'),
'apost_code' => config('elta.apost_code'), 'apost_code' => config('elta.apost_code'),
'recipient_name' => trim("{$address->first_name} {$address->last_name}"), 'recipient_name' => $recipient->name,
'recipient_address' => $address->line_one, 'recipient_address' => $recipient->address,
'recipient_postcode' => $address->postcode, 'recipient_postcode' => $recipient->postcode,
'recipient_area' => $address->city, 'recipient_area' => $recipient->city,
'recipient_phone' => $address->contact_phone, 'recipient_phone' => $recipient->phones(),
// Mirrors Sydeta.cs::print_vg()'s own dataRow["sender_1..5"] / // Mirrors Sydeta.cs::print_vg()'s own dataRow["sender_1..5"] /
// dataRow["rec_1..5"] construction: name on its own line, then // dataRow["rec_1..5"] construction: name on its own line, then
@@ -152,12 +194,7 @@ class EltaLabelRenderer
config('elta.sender_address'), config('elta.sender_address'),
'TK:'.config('elta.sender_postcode').' '.config('elta.sender_area').' ΤΗΛ:'.config('elta.sender_phone'), 'TK:'.config('elta.sender_postcode').' '.config('elta.sender_area').' ΤΗΛ:'.config('elta.sender_phone'),
], ],
'recipient_lines' => [ 'recipient_lines' => $this->partyLines($recipient),
trim("{$address->first_name} {$address->last_name}"),
$address->line_one,
'TK:'.$address->postcode.' '.$address->city,
'ΤΗΛ: '.$address->contact_phone,
],
'date' => $occurredAt->format('d/m/Y'), 'date' => $occurredAt->format('d/m/Y'),
'time' => $occurredAt->format('H:i'), 'time' => $occurredAt->format('H:i'),
@@ -167,21 +204,38 @@ class EltaLabelRenderer
'weight' => $meta['weight'] ?? null, 'weight' => $meta['weight'] ?? null,
'package_count' => $packageCount, '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 'package_label' => $packageCount > 1
? sprintf('001/%03d', $packageCount) ? ($isChild && $paperSize !== 'a6' ? (string) $packageCount : $pieceLabel)
: '001', : '001',
'piece_label' => $pieceLabel,
'multiPiece' => $packageCount > 1, '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'), // Our deposit station: configured, or the one ELTA's login
// reported (cached by EltaFulfillmentService::senderStation()).
'station_apo' => config('elta.origin_station_code') ?: Cache::get('elta.user_station'),
'station_pros' => $station['code'], 'station_pros' => $station['code'],
'station_pros_title' => $station['name'], 'station_pros_title' => $station['name'],
'service_code' => $meta['rec_srv'] ?? '', 'service_code' => $meta['rec_srv'] ?? '',
'service_name' => $meta['rec_srv_t'] ?? '', 'service_name' => $meta['rec_srv_t'] ?? '',
'xreosi' => 'ΧΡΕΩΣΗ ΑΠΟΣΤΟΛΕΑ ΠΙΣΤΩΣΗ', // print_child() doesn't set these, so a child's cells stay empty.
'siimvasi' => '131775-9', 'xreosi' => $isChild ? null : 'ΧΡΕΩΣΗ ΑΠΟΣΤΟΛΕΑ ΠΙΣΤΩΣΗ',
'siimvasi' => $isChild ? null : '131775-9',
'cod_amount' => $meta['cod_amount'] ?? null, 'cod_amount' => $meta['cod_amount'] ?? null,
'ocr_line' => $ocrLine, 'ocr_line' => $ocrLine,
@@ -189,11 +243,16 @@ class EltaLabelRenderer
// from the OCR line, not the barcode's own voucher text — // from the OCR line, not the barcode's own voucher text —
// confirmed against the real sample: ">14260009814363<..." // confirmed against the real sample: ">14260009814363<..."
// yields reference "1426000981436" (13 chars after the '>'). // yields reference "1426000981436" (13 chars after the '>').
'ocr_reference' => strlen($ocrLine) >= 14 ? substr($ocrLine, 1, 13) : '', 'ocr_reference' => strlen($ocrLine) >= 14 ? substr($ocrLine, 1, 13) : null,
// The reference we send ELTA as pel_ref_no (order reference; // The reference we send ELTA as pel_ref_no (order reference;
// older vouchers were sent the order id). // older vouchers were sent the order id).
'order_reference' => (string) $order->reference, 'order_reference' => $isChild ? null : $shipment->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 // sydetaE.rdlc's "Πρόσθετες Υπηρεσίες" (sur_1..4): the ticked
// extra services, in ELTA's own wording and slots. // extra services, in ELTA's own wording and slots.
@@ -206,7 +265,10 @@ class EltaLabelRenderer
// we never send any, so the box stays empty like the client's. // we never send any, so the box stays empty like the client's.
'volumetric_weight' => $meta['volumetric_weight'] ?? null, 'volumetric_weight' => $meta['volumetric_weight'] ?? null,
'eltaLogo' => $this->logoDataUri(), 'eltaLogo' => $this->logoDataUri('elta-courier-logo.png'),
// The A4 payment stub's "Hellenic Post" logo (the RDLC's
// Point5_ELTACourier_ΑποστολήμαςΕσείς image).
'hellenicPostLogo' => $this->logoDataUri('elta-logo.png'),
]; ];
} }
@@ -225,6 +287,8 @@ class EltaLabelRenderer
if ($has('special_handling')) { if ($has('special_handling')) {
$slots[1] = self::SERVICE_TEXT['special_handling']; $slots[1] = self::SERVICE_TEXT['special_handling'];
} elseif ($has('post_box_delivery')) {
$slots[1] = self::SERVICE_TEXT['post_box_delivery'];
} }
if ($has('timed_delivery')) { if ($has('timed_delivery')) {
@@ -277,11 +341,148 @@ class EltaLabelRenderer
...$lines, ...$lines,
'antik_lines' => array_values(array_filter($lines)), 'antik_lines' => array_values(array_filter($lines)),
'antik_minima' => '* ΠΡΟΣΟΧΗ ΑΝΤΙΚΑΤΑΒΟΛΗ *', 'antik_minima' => '* ΠΡΟΣΟΧΗ ΑΝΤΙΚΑΤΑΒΟΛΗ *',
'apodiksi' => '* Απόδειξη Είσπαξης *', 'apodiksi' => '* Απόδειξη Είσπραξης *',
'antik_poso' => $sent, 'antik_poso' => $sent,
]; ];
} }
/**
* rec_1..4 as print_vg() builds them: name, address, "TK:" line, phones
* (also the RETOUR voucher's sender lines).
*
* @return array<int, string>
*/
private function partyLines(VoucherRecipient $recipient): array
{
return [
$recipient->name,
$recipient->address,
'TK:'.$recipient->postcode.' '.$recipient->city,
'ΤΗΛ: '.$recipient->phones(),
];
}
/**
* A PostBox voucher's COD column: just the PostBox warning (antik_1).
*
* @return array<string, mixed>
*/
private function postBoxCodFields(): array
{
return [
...$this->codFields(null),
'antik_1' => self::SERVICE_TEXT['post_box_delivery'],
'antik_lines' => [self::SERVICE_TEXT['post_box_delivery']],
];
}
/**
* The return voucher ("RETOUR", docs/elta.md B6), as the client fills
* it from the main voucher: the parties swap (our customer sends it
* back to us, the return-to party), it's charged to the recipient,
* 0.100 kg, one piece, no COD, and the remarks name the main voucher.
* Service 461 when the main voucher's service is in-city (1xx), else
* 462. It goes from the main voucher's destination station back to
* ours.
*
* @return array<string, mixed>
*/
private function returnData(Shipment $master): array
{
$data = $this->data($master);
$meta = $master->meta?->toArray() ?? [];
$returnVoucher = (string) $meta['return_vg'];
$ocrLine = (string) ($meta['r_ocr_line'] ?? '');
$inCity = str_starts_with((string) ($meta['rec_srv'] ?? ''), '1');
return [
...$data,
...$this->codFields(null),
'sender_lines' => $this->partyLines($master->recipient()),
'recipient_lines' => $data['sender_lines'],
'voucher_no' => $returnVoucher,
'barcode_voucher' => $this->barcodeDataUri(new BarcodeGeneratorPNG(), $returnVoucher),
'ocr_line' => $ocrLine,
'ocr_reference' => strlen($ocrLine) >= 14 ? substr($ocrLine, 1, 13) : null,
'service_code' => $inCity ? '461' : '462',
'service_name' => $inCity ? 'RETOUR ΕΝΤΟΣ ΠΟΛΗΣ' : 'RETOUR ΠΟΛΗ ΠΟΛΗ',
'xreosi' => 'ΧΡΕΩΣΗ ΠΑΡΑΛΗΠΤΗ ΠΙΣΤΩΣΗ',
'copy' => 'RETOUR',
'station_apo' => $data['station_pros'],
'station_pros' => $data['station_apo'],
'station_pros_title' => '',
'weight' => '0.100',
'package_count' => 1,
'package_label' => '1',
'piece_label' => '001/001',
'multiPiece' => false,
'polaplo' => null,
'isChild' => false,
'sur_1' => null, 'sur_2' => null, 'sur_3' => null, 'sur_4' => null,
'sxolia' => array_slice(mb_str_split('ΣΥΔΕΤΑ '.$master->tracking_reference.' Χρέωση Πελάτη '.config('elta.apost_code'), 27), 0, 3),
];
}
/**
* 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 * PNG over SVG — dompdf's inline <svg> support doesn't reliably
* render the barcode generator's <rect>-based bars (confirmed: the * render the barcode generator's <rect>-based bars (confirmed: the
@@ -301,9 +502,9 @@ class EltaLabelRenderer
* vendor:publish, which isn't guaranteed to have run, and dompdf * vendor:publish, which isn't guaranteed to have run, and dompdf
* needs either a real filesystem path or a working absolute URL. * needs either a real filesystem path or a working absolute URL.
*/ */
private function logoDataUri(): string private function logoDataUri(string $file): string
{ {
$path = __DIR__.'/../../../../resources/logos/elta-courier-logo.png'; $path = __DIR__.'/../../../../resources/logos/'.$file;
return 'data:image/png;base64,'.base64_encode(file_get_contents($path)); return 'data:image/png;base64,'.base64_encode(file_get_contents($path));
} }
@@ -0,0 +1,17 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Modules\Core\Shipping\Models\Shipment;
/**
* A page for the main voucher's return voucher ("RETOUR", docs/elta.md B6):
* ELTA issues one along with a special-handling voucher (meta.return_vg),
* and its client prints it after the voucher and its parcels. It isn't a
* shipment of its own, so EltaLabelRenderer gets the main voucher wrapped
* in this.
*/
final class EltaReturnLabel
{
public function __construct(public readonly Shipment $shipment) {}
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\DTOs\VoucherRecipient;
use Modules\Core\Shipping\Models\Shipment;
/**
* A carrier whose vouchers can be created without an order — from the
* Carrier Vouchers screen's "Create voucher", for a parcel that isn't a
* shop order. The voucher is an ordinary created shipment with no
* order_id: it prints, cancels and tracks like any other, and can be
* linked to an order later.
*/
interface CreatesStandaloneVouchers
{
/**
* @param string $reference sent to the carrier as our reference
* (it comes back in its lists and tracking).
*/
public function createStandaloneVoucher(VoucherRecipient $recipient, string $reference, ShipmentRequest $request): Shipment;
}
+12
View File
@@ -38,6 +38,18 @@ class ShipmentRequest
public readonly ?float $insuranceAmount = null, public readonly ?float $insuranceAmount = null,
public readonly ?string $deliveryUntil = null, public readonly ?string $deliveryUntil = null,
public readonly ?string $trackingReference = null, public readonly ?string $trackingReference = null,
/** An envelope (documents) rather than a parcel — ELTA only. */
public readonly bool $envelope = false,
/** @var array{0: int, 1: int, 2: int}|null length × width × height in cm, for volumetric weight — ELTA only. */
public readonly ?array $dimensions = null,
/** The parcel's value, for a voucher without an order (an order's is its total) — Box Now's invoiceValue. */
public readonly ?float $value = null,
/**
* Who it goes to, as staff confirmed it in the Create Shipment form —
* sent instead of the order's shipping address, which stays as the
* customer entered it. Null = the shipping address as is.
*/
public readonly ?VoucherRecipient $recipient = null,
) {} ) {}
public function has(ExtraService $service): bool public function has(ExtraService $service): bool
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace Modules\Core\Shipping\DTOs;
use Lunar\Models\Contracts\OrderAddress;
/**
* Who a voucher goes to — from an order's shipping address, or typed in
* by staff for a voucher that isn't for an order
* (CreatesStandaloneVouchers). Saved on the voucher (meta.recipient) when
* it's created, so labels and lists read it from there, never from the
* order.
*/
class VoucherRecipient
{
public function __construct(
public readonly string $name,
public readonly string $address,
public readonly string $postcode,
public readonly string $city,
public readonly ?string $phone = null,
public readonly ?string $mobile = null,
/** The remarks for the courier (an order's delivery instructions). */
public readonly ?string $notes = null,
/** Box Now sends the locker's pickup code here. */
public readonly ?string $email = null,
) {}
public static function fromAddress(OrderAddress $address): self
{
return new self(
name: trim("{$address->first_name} {$address->last_name}"),
address: (string) $address->line_one,
postcode: (string) $address->postcode,
city: (string) $address->city,
phone: $address->contact_phone,
notes: $address->delivery_instructions,
email: $address->contact_email,
);
}
/**
* @param array<string, ?string> $data as toArray() gives it
*/
public static function fromArray(array $data): self
{
return new self(
name: (string) ($data['name'] ?? ''),
address: (string) ($data['address'] ?? ''),
postcode: (string) ($data['postcode'] ?? ''),
city: (string) ($data['city'] ?? ''),
phone: $data['phone'] ?? null,
mobile: $data['mobile'] ?? null,
notes: $data['notes'] ?? null,
email: $data['email'] ?? null,
);
}
/**
* @return array<string, ?string>
*/
public function toArray(): array
{
return [
'name' => $this->name,
'address' => $this->address,
'postcode' => $this->postcode,
'city' => $this->city,
'phone' => $this->phone,
'mobile' => $this->mobile,
'notes' => $this->notes,
'email' => $this->email,
];
}
/** Both numbers, as they're printed on the label ("ΤΗΛ: …"). */
public function phones(): string
{
return trim($this->phone.' '.$this->mobile);
}
}
+3
View File
@@ -23,6 +23,8 @@ enum ExtraService: string
case Protocol = 'protocol'; case Protocol = 'protocol';
case Refrigerated = 'refrigerated'; case Refrigerated = 'refrigerated';
case ExchangePackage = 'exchange_package'; case ExchangePackage = 'exchange_package';
/** Delivered to a PostBox (parcel locker / post office box) instead of the address. */
case PostBoxDelivery = 'post_box_delivery';
/** /**
* Admin-facing only (staff pick these), so plain English like the rest * Admin-facing only (staff pick these), so plain English like the rest
@@ -42,6 +44,7 @@ enum ExtraService: string
self::Protocol => 'Protocol', self::Protocol => 'Protocol',
self::Refrigerated => 'Refrigerated', self::Refrigerated => 'Refrigerated',
self::ExchangePackage => 'Exchange package', self::ExchangePackage => 'Exchange package',
self::PostBoxDelivery => 'PostBox delivery',
}; };
} }
} }
@@ -19,6 +19,7 @@ use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier; use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource; use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Support\BoxNowShipping;
/** /**
* Adds a "Shipments" section to the order page's main column — previously * Adds a "Shipments" section to the order page's main column — previously
@@ -123,12 +124,13 @@ class OrderShipmentsExtension extends ViewPageExtension
->schema(fn (Shipment $record) => [ ->schema(fn (Shipment $record) => [
TextInput::make('tracking_reference') TextInput::make('tracking_reference')
->label('Voucher / tracking number') ->label('Voucher / tracking number')
->required($record->source === Shipment::SOURCE_MANUAL_VOUCHER)
->unique(Shipment::class, 'tracking_reference', ignorable: $record), ->unique(Shipment::class, 'tracking_reference', ignorable: $record),
]) ])
->action(fn (Shipment $record, array $data) => $record->update([ ->action(fn (Shipment $record, array $data) => $record->update([
'tracking_reference' => filled($data['tracking_reference']) ? trim($data['tracking_reference']) : null, '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') Action::make('add_tracking_update')
->label('Add tracking update') ->label('Add tracking update')
->icon('heroicon-o-plus-circle') ->icon('heroicon-o-plus-circle')
@@ -151,7 +153,7 @@ class OrderShipmentsExtension extends ViewPageExtension
->helperText('Shown to the customer on their order page.'), ->helperText('Shown to the customer on their order page.'),
]) ])
->action(fn (Shipment $record, array $data) => $this->addTrackingUpdate($record, $data)) ->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') Action::make('cancel_shipment')
->label('Cancel') ->label('Cancel')
->icon('heroicon-o-x-circle') ->icon('heroicon-o-x-circle')
@@ -169,7 +171,7 @@ class OrderShipmentsExtension extends ViewPageExtension
->hidden(fn (Shipment $record) => $record->shipmentInfo->isEmpty()) ->hidden(fn (Shipment $record) => $record->shipmentInfo->isEmpty())
->schema([ ->schema([
TextEntry::make('status') 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() ->inlineLabel()
->state(fn (ShipmentInfo $record) => (string) str($record->status->value)->replace('_', ' ')->title()) ->state(fn (ShipmentInfo $record) => (string) str($record->status->value)->replace('_', ' ')->title())
// Our status as the label, the carrier's own wording underneath. // 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 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; $parts[] = 'Locker '.$locker;
} }
@@ -244,6 +250,25 @@ class OrderShipmentsExtension extends ViewPageExtension
$parts[] = 'Manual voucher'; $parts[] = 'Manual voucher';
} }
// Carrier-reported extras (ELTA so far): its routing note, the
// return voucher issued with special handling, who received the
// parcel and when, and the COD cash paid back to us.
if ($routing = $record->meta['routing'] ?? null) {
$parts[] = $routing;
}
if ($returnVoucher = $record->meta['return_vg'] ?? null) {
$parts[] = 'Return voucher '.$returnVoucher;
}
if ($deliveredTo = $record->meta['delivered_to'] ?? null) {
$parts[] = trim('Received by '.$deliveredTo.' '.($record->meta['delivered_at'] ?? ''));
}
if ($remitted = $record->meta['cod_remitted'] ?? null) {
$parts[] = trim('COD paid back: €'.number_format((float) $remitted, 2).' '.($record->meta['cod_remittance'] ?? ''));
}
return implode(' · ', $parts); return implode(' · ', $parts);
} }
+53 -71
View File
@@ -6,7 +6,6 @@ use Filament\Actions\Action;
use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\TimePicker; use Filament\Forms\Components\TimePicker;
@@ -24,6 +23,7 @@ use Modules\Core\Shipping\Contracts\SupportsExtraServices;
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup; use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
use Modules\Core\Shipping\DTOs\ShipmentRequest; use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Enums\ExtraService; use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Filament\Support\VoucherForm;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Support\WeightCalculator; use Modules\Core\Shipping\Support\WeightCalculator;
use Throwable; use Throwable;
@@ -53,28 +53,24 @@ use Throwable;
* order to 'dispatched': that happens when the carrier reports picking the * order to 'dispatched': that happens when the carrier reports picking the
* parcel up (AdvanceFulfillmentOnCarrierCheckpoint). Staff also pick the * parcel up (AdvanceFulfillmentOnCarrierCheckpoint). Staff also pick the
* carrier's extra services here (Saturday delivery, insurance, …) from the * carrier's extra services here (Saturday delivery, insurance, …) from the
* normalized ExtraService list the carrier offers. The form branches * normalized ExtraService list the carrier offers. The form depends on
* on which carrier the order actually uses * the order's carrier (OrderFulfillmentService::carrierFor()):
* (OrderFulfillmentService::carrierFor()): a weight-billed carrier (ACS) *
* gets a single TOTAL weight field for the whole shipment (ACS has no * - ELTA and Box Now (CreatesStandaloneVouchers) get the full voucher
* per-package weight concept — one Weight value is sent alongside * form — Modules\Core\Shipping\Filament\Support\VoucherForm, the one
* Item_Quantity in the same ACS_Create_Voucher call, see * Carrier Vouchers' "Create voucher" uses — prefilled from the order:
* AcsFulfillmentService::createShipment()), pre-filled from the order's * recipient and delivery instructions from the shipping address, weight
* own line weights (Modules\Core\Shipping\Support\WeightCalculator) but * from the order lines (WeightCalculator), Box Now's locker from the
* still staff-editable, plus a package count (ShipmentRequest:: * customer's checkout choice, cash on delivery read-only from the
* $packageCount) — more than 1 issues a main voucher plus a * payment method. What staff change there (a phone, a typo in the
* multi-part sub-voucher per extra package (persistMultipartVouchers()), * address, another locker) goes to the carrier only
* each recorded as its own Shipment row sharing the same total weight in * (ShipmentRequest::$recipient); the order keeps what the customer
* meta. Box Now, which bills by compartment size rather than * entered.
* weight, gets a repeatable list of boxes (one row per physical parcel, * - A manual carrier gets its optional tracking number and a package
* each with its own S/M/L size) instead — see * count.
* Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService for how * - ACS gets a total weight (prefilled from the line weights) and a
* multiple boxes become multiple Shipment rows from one delivery request. * package count — more than 1 issues a main voucher plus a sub-voucher
* Box Now's locker field defaults from the shopper's own checkout * per extra package, each its own Shipment row.
* selection ($order->shippingAddress->meta['box_now_locker']) when present,
* but stays editable — staff can override to a different locker (e.g. the
* customer's choice turns out to be unavailable) or fill it in manually for
* an order placed before the checkout locker picker existed.
* *
* "Add manual voucher" records a voucher of an integrated carrier that * "Add manual voucher" records a voucher of an integrated carrier that
* wasn't created through our API — the carrier's system was down and staff * wasn't created through our API — the carrier's system was down and staff
@@ -120,16 +116,21 @@ class OrderViewExtension extends ViewPageExtension
->label('Create Shipment') ->label('Create Shipment')
->icon('heroicon-o-truck') ->icon('heroicon-o-truck')
->modalSubmitActionLabel('Create Shipment') ->modalSubmitActionLabel('Create Shipment')
->modalWidth(fn (Order $record) => VoucherForm::handles($this->service()->carrierFor($record)) ? '3xl' : null)
->schema(function (Order $record) { ->schema(function (Order $record) {
$carrier = $this->service()->carrierFor($record); $carrier = $this->service()->carrierFor($record);
$isBoxNow = $carrier === 'box-now';
$lockerId = $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null; // ELTA and Box Now: the full voucher form, prefilled from the
// order (the same one Carrier Vouchers' "Create voucher" uses).
if (VoucherForm::handles($carrier)) {
return VoucherForm::schema($record);
}
if ($carrier === 'manual') { if ($carrier === 'manual') {
return [ return [
TextInput::make('tracking_reference') TextInput::make('tracking_reference')
->label('Voucher / tracking number') ->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.'), ->helperText('Optional — add it later from the shipment if the courier hasn\'t given one yet.'),
TextInput::make('package_count') TextInput::make('package_count')
->label('Number of packages') ->label('Number of packages')
@@ -141,47 +142,22 @@ class OrderViewExtension extends ViewPageExtension
]; ];
} }
if (! $isBoxNow) { // Weight-billed carriers without the voucher form (ACS).
return [...[
TextInput::make('weight')
->label('Total weight (kg)')
->numeric()
->minValue(0)
->default(fn () => round(WeightCalculator::totalKg($record->lines), 2) ?: null)
->helperText("Calculated from the order's line weights — adjust if needed, or leave blank to use the carrier's default. One figure for the whole shipment, not per package."),
TextInput::make('package_count')
->label('Number of packages')
->numeric()
->integer()
->minValue(1)
->default(1)
->required()
->helperText('More than 1 issues a main voucher plus a sub-voucher per extra package, all sharing the total weight above.'),
], ...$this->extraServiceFields($record)];
}
return [...[ return [...[
TextInput::make('destination_location_id') TextInput::make('weight')
->label('Box Now locker ID') ->label('Total weight (kg)')
->default($lockerId) ->numeric()
->minValue(0)
->default(fn () => round(WeightCalculator::totalKg($record->lines), 2) ?: null)
->helperText("Calculated from the order's line weights — adjust if needed, or leave blank to use the carrier's default. One figure for the whole shipment, not per package."),
TextInput::make('package_count')
->label('Number of packages')
->numeric()
->integer()
->minValue(1)
->default(1)
->required() ->required()
->helperText($lockerId ->helperText('More than 1 issues a main voucher plus a sub-voucher per extra package, all sharing the total weight above.'),
? 'Set by the customer at checkout — override if the parcel needs to go to a different locker.'
: 'No locker was selected at checkout — enter it manually.'),
Repeater::make('boxes')
->label('Boxes')
->schema([
Select::make('size')
->label('Size')
->options(['S' => 'Small', 'M' => 'Medium', 'L' => 'Large'])
->default('S')
->native(false)
->required(),
])
->defaultItems(1)
->addActionLabel('Add another box')
->minItems(1)
->helperText('One row per physical parcel — Box Now ships by compartment size, not weight.'),
], ...$this->extraServiceFields($record)]; ], ...$this->extraServiceFields($record)];
}) })
->action(function (Order $record, array $data, Action $action) { ->action(function (Order $record, array $data, Action $action) {
@@ -202,9 +178,10 @@ class OrderViewExtension extends ViewPageExtension
$services = array_map(fn (string $value) => ExtraService::from($value), $data['services'] ?? []); $services = array_map(fn (string $value) => ExtraService::from($value), $data['services'] ?? []);
$result = $this->service()->createShipment( $request = VoucherForm::handles($this->service()->carrierFor($record))
$record, // The recipient as staff confirmed it in the form.
new ShipmentRequest( ? VoucherForm::request($data, $isCod ? $record->total->decimal : null, VoucherForm::recipient($data))
: new ShipmentRequest(
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null, weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
packageCount: (int) ($data['package_count'] ?? 1), packageCount: (int) ($data['package_count'] ?? 1),
destinationLocationId: $data['destination_location_id'] ?? null, destinationLocationId: $data['destination_location_id'] ?? null,
@@ -219,10 +196,13 @@ class OrderViewExtension extends ViewPageExtension
? ($data['delivery_until'] ?? null) ? ($data['delivery_until'] ?? null)
: null, : null,
trackingReference: $data['tracking_reference'] ?? null, trackingReference: $data['tracking_reference'] ?? null,
), );
);
$result = $this->service()->createShipment($record, $request);
$this->notify($result); $this->notify($result);
// The page still holds the shipments loaded before this action.
$record->load('shipments.shipmentInfo');
if (! $result->success) { if (! $result->success) {
$action->halt(); $action->halt();
@@ -302,7 +282,7 @@ class OrderViewExtension extends ViewPageExtension
TextInput::make('voucher_number') TextInput::make('voucher_number')
->label('Voucher number') ->label('Voucher number')
->required() ->required()
->unique(Shipment::class, 'tracking_reference') ->unique(Shipment::class, 'tracking_reference', ignoreRecord: false)
->live(onBlur: true) ->live(onBlur: true)
->afterStateUpdated(fn (Set $set) => $set('lookup', null)) ->afterStateUpdated(fn (Set $set) => $set('lookup', null))
->suffixAction( ->suffixAction(
@@ -331,6 +311,8 @@ class OrderViewExtension extends ViewPageExtension
); );
$this->notify($result); $this->notify($result);
// The page still holds the shipments loaded before this action.
$record->load('shipments.shipmentInfo');
if (! $result->success) { if (! $result->success) {
$action->halt(); $action->halt();
@@ -145,9 +145,8 @@ class ShippingMethodResourceExtension extends ResourceExtension
->required($isManual), ->required($isManual),
TextInput::make('tracking_url') TextInput::make('tracking_url')
->label('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}.') ->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://') ->rule('starts_with:http://,https://'),
->visible($isManual),
Toggle::make('collects_cash') Toggle::make('collects_cash')
->label('Courier collects cash on delivery') ->label('Courier collects cash on delivery')
->helperText('Offers cash on delivery at checkout for this shipping method.') ->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\Filament\Resources\CarrierVoucherResource\Pages\ViewCarrierVoucher;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Models\ShipmentInfo; use Modules\Core\Shipping\Models\ShipmentInfo;
use Modules\Core\Shipping\Support\BoxNowShipping;
use Modules\Core\Shipping\Support\ShipmentTimelineLogger; use Modules\Core\Shipping\Support\ShipmentTimelineLogger;
/** /**
@@ -61,13 +62,38 @@ class CarrierVoucherResource extends Resource
Shipment::SOURCE_SYNCED => 'From carrier', 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 public static function getEloquentQuery(): Builder
{ {
return parent::getEloquentQuery() return self::vouchers(parent::getEloquentQuery())
->whereNotNull('tracking_reference')
->with(['order.shippingAddress', 'shipmentInfo']); ->with(['order.shippingAddress', 'shipmentInfo']);
} }
/**
* Vouchers with a number, plus ours still waiting for one (an ELTA
* voucher gets its number when printed) so they can be printed from
* here too.
*/
public static function vouchers(Builder $query): Builder
{
return $query->where(fn (Builder $q) => $q
->whereNotNull('tracking_reference')
->orWhere(fn (Builder $pending) => $pending
->where('source', Shipment::SOURCE_CREATED)
->whereNull('cancelled_at')));
}
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
@@ -75,16 +101,26 @@ class CarrierVoucherResource extends Resource
->columns([ ->columns([
TextColumn::make('tracking_reference') TextColumn::make('tracking_reference')
->label('Tracking number') ->label('Tracking number')
->placeholder('Not issued yet')
->description(fn (Shipment $record) => $record->parcelDescription())
->copyable(), ->copyable(),
TextColumn::make('carrier')
->label('Carrier')
->formatStateUsing(fn (Shipment $record) => $record->carrierLabel()),
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') TextColumn::make('status')
->label('Status') ->label('Status')
->state(fn (Shipment $record) => self::status($record)) ->state(fn (Shipment $record) => self::status($record))
->placeholder('No updates yet') ->placeholder('None yet')
->badge() ->badge()
->color(fn (Shipment $record) => self::statusColor($record)), ->color(fn (Shipment $record) => self::statusColor($record)),
TextColumn::make('order.reference') TextColumn::make('order.reference')
->label('Order') ->label('Order')
->placeholder('Not linked'), ->placeholder('None'),
TextColumn::make('recipient') TextColumn::make('recipient')
->label('Recipient') ->label('Recipient')
->state(fn (Shipment $record) => self::recipient($record)) ->state(fn (Shipment $record) => self::recipient($record))
@@ -95,6 +131,9 @@ class CarrierVoucherResource extends Resource
->where('tracking_reference', 'ilike', "%{$search}%") ->where('tracking_reference', 'ilike', "%{$search}%")
->orWhere('meta->recipient_name', 'ilike', "%{$search}%") ->orWhere('meta->recipient_name', 'ilike', "%{$search}%")
->orWhere('meta->phone', 'ilike', "%{$search}%") ->orWhere('meta->phone', 'ilike', "%{$search}%")
->orWhere('meta->recipient->name', 'ilike', "%{$search}%")
->orWhere('meta->recipient->phone', 'ilike', "%{$search}%")
->orWhere('meta->recipient->mobile', 'ilike', "%{$search}%")
->orWhereHas('order', fn (Builder $order) => $order ->orWhereHas('order', fn (Builder $order) => $order
->where('reference', 'ilike', "%{$search}%") ->where('reference', 'ilike', "%{$search}%")
->orWhereHas('shippingAddress', fn (Builder $address) => $address ->orWhereHas('shippingAddress', fn (Builder $address) => $address
@@ -112,6 +151,7 @@ class CarrierVoucherResource extends Resource
SelectFilter::make('source')->options(self::SOURCES), SelectFilter::make('source')->options(self::SOURCES),
]) ])
->recordActions([ ->recordActions([
self::printAction(),
ViewAction::make(), ViewAction::make(),
]); ]);
} }
@@ -124,10 +164,15 @@ class CarrierVoucherResource extends Resource
->schema([ ->schema([
TextEntry::make('carrier') TextEntry::make('carrier')
->formatStateUsing(fn (Shipment $record) => $record->carrierLabel()), ->formatStateUsing(fn (Shipment $record) => $record->carrierLabel()),
TextEntry::make('tracking_reference')->label('Voucher')->copyable(), TextEntry::make('tracking_reference')
->label('Voucher')
->placeholder('Not issued yet — printing issues it')
->helperText(fn (Shipment $record) => $record->parcelDescription())
->copyable(),
TextEntry::make('source') TextEntry::make('source')
->badge() ->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') TextEntry::make('status')
->state(fn (Shipment $record) => self::status($record)) ->state(fn (Shipment $record) => self::status($record))
->placeholder('No updates yet') ->placeholder('No updates yet')
@@ -137,7 +182,10 @@ class CarrierVoucherResource extends Resource
->label('Carrier status') ->label('Carrier status')
->state(fn (Shipment $record) => self::carrierStatus($record)) ->state(fn (Shipment $record) => self::carrierStatus($record))
->placeholder('—'), ->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('meta.voucher_date')->label('Voucher date')->date()->placeholder('—'),
TextEntry::make('cod') TextEntry::make('cod')
->label('Cash on delivery') ->label('Cash on delivery')
@@ -162,12 +210,30 @@ class CarrierVoucherResource extends Resource
->state(fn (Shipment $record) => self::recipient($record)) ->state(fn (Shipment $record) => self::recipient($record))
->placeholder('—'), ->placeholder('—'),
TextEntry::make('postcode') TextEntry::make('postcode')
->state(fn (Shipment $record) => $record->meta['postcode'] ?? $record->order?->shippingAddress?->postcode) ->state(fn (Shipment $record) => self::postcode($record))
->placeholder('—'), ->placeholder('—'),
TextEntry::make('phone') TextEntry::make('phone')
->state(fn (Shipment $record) => self::phone($record)) ->state(fn (Shipment $record) => self::phone($record))
->placeholder('—'), ->placeholder('—'),
]), ]),
// What the carrier's tracking reports beyond the checkpoints
// (ELTA's pel_rec) — filled in by the tracking polls.
Section::make('Delivery')
->columns(3)
->schema([
TextEntry::make('meta.delivered_to')->label('Received by')->placeholder('—'),
TextEntry::make('meta.delivered_at')->label('Received at')->placeholder('—'),
TextEntry::make('cod_remitted')
->label('COD paid back')
->state(fn (Shipment $record) => ($amount = $record->meta['cod_remitted'] ?? null)
? trim('€'.number_format((float) $amount, 2).' '.($record->meta['cod_remittance'] ?? ''))
: null)
->placeholder('—'),
TextEntry::make('meta.return_vg')->label('Return voucher')->placeholder('—'),
])
->visible(fn (Shipment $record) => filled($record->meta['delivered_to'] ?? null)
|| filled($record->meta['cod_remitted'] ?? null)
|| filled($record->meta['return_vg'] ?? null)),
Section::make('Order') Section::make('Order')
->schema([ ->schema([
TextEntry::make('order.reference') TextEntry::make('order.reference')
@@ -206,7 +272,7 @@ class CarrierVoucherResource extends Resource
->visible(fn (Shipment $record) => $record->shipmentInfo->isNotEmpty()) ->visible(fn (Shipment $record) => $record->shipmentInfo->isNotEmpty())
->schema([ ->schema([
TextEntry::make('status') 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() ->inlineLabel()
->state(fn (ShipmentInfo $record) => (string) str($record->status->value)->replace('_', ' ')->title()) ->state(fn (ShipmentInfo $record) => (string) str($record->status->value)->replace('_', ' ')->title())
->helperText(fn (ShipmentInfo $record) => collect([$record->carrier_status !== 'manual' ? $record->carrier_status : null, $record->location, $record->message])->filter()->unique()->implode(' · ') ?: null), ->helperText(fn (ShipmentInfo $record) => collect([$record->carrier_status !== 'manual' ? $record->carrier_status : null, $record->location, $record->message])->filter()->unique()->implode(' · ') ?: null),
@@ -215,6 +281,21 @@ class CarrierVoucherResource extends Resource
]); ]);
} }
/**
* Opens the voucher's label (the same signed link as the order page and
* Pending Vouchers) — for vouchers created here, from an order or with
* "Create voucher". Carrier-reported and typed-in vouchers have no label
* of ours to print.
*/
public static function printAction(): Action
{
return Action::make('print')
->label('Print')
->icon('heroicon-o-printer')
->url(fn (Shipment $record) => ShipmentResource::labelUrl($record), shouldOpenInNewTab: true)
->visible(fn (Shipment $record) => $record->hasCarrierLabel());
}
/** /**
* Attaches an unlinked voucher to an order (suggestions first), from the * Attaches an unlinked voucher to an order (suggestions first), from the
* view page. * view page.
@@ -280,20 +361,47 @@ class CarrierVoucherResource extends Resource
])->filter()->implode(' · ')])->all(); ])->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;
}
/*
* A synced voucher's recipient is what the carrier reported
* (meta.recipient_name / phone / postcode); our own vouchers' is the
* one saved when they were created (Shipment::recipient()).
*/
private static function phone(Shipment $record): ?string private static function phone(Shipment $record): ?string
{ {
return $record->meta['phone'] ?? $record->order?->shippingAddress?->contact_phone; return $record->meta['phone'] ?? ($record->recipient()?->phones() ?: null);
} }
private static function recipient(Shipment $record): ?string private static function recipient(Shipment $record): ?string
{ {
if ($name = $record->meta['recipient_name'] ?? null) { return $record->meta['recipient_name'] ?? ($record->recipient()?->name ?: null);
return $name; }
}
$address = $record->order?->shippingAddress; private static function postcode(Shipment $record): ?string
{
return $address ? trim("{$address->first_name} {$address->last_name}") : null; return $record->meta['postcode'] ?? ($record->recipient()?->postcode ?: null);
} }
/** /**
@@ -0,0 +1,86 @@
<?php
namespace Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource\Actions;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\CreatesStandaloneVouchers;
use Modules\Core\Shipping\Contracts\IssuesVoucherOnPrint;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
use Modules\Core\Shipping\Filament\Support\VoucherForm;
use Throwable;
/**
* "Create voucher" on Carrier Vouchers: a voucher that isn't for an order
* (a replacement part, documents, a parcel for a partner), with every
* field the carrier takes (VoucherForm — the same form an order's Create
* Shipment uses, there prefilled from the order). Offered for carriers
* implementing CreatesStandaloneVouchers. The voucher is an ordinary
* created shipment without an order: an ELTA one is pending until
* printed, so it waits on Pending Vouchers like any other, and it can be
* linked to an order later from its view page.
*/
class CreateVoucherAction
{
public static function make(): Action
{
return Action::make('create_voucher')
->label('Create voucher')
->icon('heroicon-o-plus')
->modalHeading('Create a voucher')
->modalDescription('For a parcel that isn\'t a shop order. It\'s created with the carrier straight away.')
->modalSubmitActionLabel('Create voucher')
->modalWidth('3xl')
->schema(VoucherForm::schema())
->action(fn (array $data, Action $action) => self::create($data, $action))
->visible(fn () => VoucherForm::carriers() !== []);
}
private static function create(array $data, Action $action): void
{
$service = app(CarrierFulfillmentInterface::class, ['carrier' => $data['carrier']]);
$cod = (float) ($data['cod_amount'] ?? 0) > 0 ? (float) $data['cod_amount'] : null;
try {
if (! $service instanceof CreatesStandaloneVouchers) {
throw new \RuntimeException('This carrier can\'t create vouchers without an order.');
}
$shipment = $service->createStandaloneVoucher(
VoucherForm::recipient($data),
trim($data['reference']),
VoucherForm::request($data, $cod),
);
} catch (Throwable $e) {
report($e);
Notification::make()->title('Failed to create the voucher: '.$e->getMessage())->danger()->send();
// Keep the form open with what was typed.
$action->halt();
return;
}
// ELTA vouchers get their number when printed, so they wait on
// Pending Vouchers; offer the print straight away.
$pending = $service instanceof IssuesVoucherOnPrint && blank($shipment->tracking_reference);
Notification::make()
->title($pending ? 'Voucher created — it gets its number when printed.' : "Voucher {$shipment->tracking_reference} created.")
->body($pending ? 'Print it now, or later from Pending Vouchers.' : null)
->success()
->persistent()
->actions([
Action::make('print')
->label('Print')
->url(ShipmentResource::labelUrl($shipment), shouldOpenInNewTab: true),
Action::make('pending')
->label('Pending Vouchers')
->url(ShipmentResource::getUrl('index'))
->visible($pending),
])
->send();
}
}
@@ -13,6 +13,7 @@ use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup; use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource; use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource;
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource\Actions\CreateVoucherAction;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Services\CarrierVoucherSync; use Modules\Core\Shipping\Services\CarrierVoucherSync;
use Throwable; use Throwable;
@@ -29,12 +30,12 @@ class ListCarrierVouchers extends ListRecords
'all' => Tab::make('All'), 'all' => Tab::make('All'),
'unlinked' => Tab::make('Unlinked') 'unlinked' => Tab::make('Unlinked')
->modifyQueryUsing(fn (Builder $query) => $query->whereNull('order_id')) ->modifyQueryUsing(fn (Builder $query) => $query->whereNull('order_id'))
->badge(fn () => Shipment::whereNotNull('tracking_reference')->whereNull('order_id')->count() ?: null), ->badge(fn () => CarrierVoucherResource::vouchers(Shipment::query())->whereNull('order_id')->count() ?: null),
'returns' => Tab::make('Returns') 'returns' => Tab::make('Returns')
->modifyQueryUsing(fn (Builder $query) => $query->where(fn (Builder $q) => $q ->modifyQueryUsing(fn (Builder $query) => $query->where(fn (Builder $q) => $q
->where('meta->is_return', true) ->where('meta->is_return', true)
->orWhere('meta->reported_return', true))) ->orWhere('meta->reported_return', true)))
->badge(fn () => Shipment::whereNotNull('tracking_reference') ->badge(fn () => CarrierVoucherResource::vouchers(Shipment::query())
->where(fn (Builder $q) => $q->where('meta->is_return', true)->orWhere('meta->reported_return', true)) ->where(fn (Builder $q) => $q->where('meta->is_return', true)->orWhere('meta->reported_return', true))
->count() ?: null), ->count() ?: null),
]; ];
@@ -43,6 +44,7 @@ class ListCarrierVouchers extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
CreateVoucherAction::make(),
Action::make('lookup') Action::make('lookup')
->label('Look up voucher') ->label('Look up voucher')
->icon('heroicon-o-magnifying-glass') ->icon('heroicon-o-magnifying-glass')
@@ -14,12 +14,13 @@ class ViewCarrierVoucher extends ViewRecord
public function getTitle(): string public function getTitle(): string
{ {
return 'Voucher '.$this->getRecord()->tracking_reference; return 'Voucher '.($this->getRecord()->tracking_reference ?? '(not issued yet)');
} }
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
CarrierVoucherResource::printAction(),
Action::make('open_order') Action::make('open_order')
->label('Open order') ->label('Open order')
->icon('heroicon-o-arrow-top-right-on-square') ->icon('heroicon-o-arrow-top-right-on-square')
@@ -6,6 +6,8 @@ use Filament\Actions\ViewAction;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; 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\ListManifests;
use Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages\ViewManifest; use Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages\ViewManifest;
use Modules\Core\Shipping\Filament\Resources\ManifestResource\RelationManagers\ShipmentsRelationManager; use Modules\Core\Shipping\Filament\Resources\ManifestResource\RelationManagers\ShipmentsRelationManager;
@@ -30,6 +32,17 @@ class ManifestResource extends Resource
protected static ?string $navigationLabel = 'Issued Manifests'; 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 ?string $modelLabel = 'Manifest';
protected static ?int $navigationSort = 101; protected static ?int $navigationSort = 101;
@@ -11,7 +11,9 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\IssuesVoucherOnPrint;
use Modules\Core\Shipping\Contracts\SupportsBatchLabels; use Modules\Core\Shipping\Contracts\SupportsBatchLabels;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching; use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource\Pages\ListShipments; use Modules\Core\Shipping\Filament\Resources\ShipmentResource\Pages\ListShipments;
@@ -25,9 +27,10 @@ use Throwable;
* scoped to ListRecords), so carrier-by-carrier separation * scoped to ListRecords), so carrier-by-carrier separation
* (ListShipments::getTabs()) needed a real Resource to attach to. * (ListShipments::getTabs()) needed a real Resource to attach to.
* *
* Shows only shipments created through a carrier's API that are NOT yet on * Shows only shipments created through a carrier's API that still need
* an issued manifest — see Modules\Core\Shipping\Filament\Resources\ * their hand-over step: not yet on an issued manifest (see Modules\Core\
* ManifestResource for shipments that already are. Manual carriers, manual * 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 * vouchers and carrier-reported (synced) vouchers have no label to print
* or manifest to join, so they never show here. * or manifest to join, so they never show here.
* *
@@ -49,13 +52,28 @@ class ShipmentResource extends Resource
protected static ?int $navigationSort = 100; 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).
* Includes vouchers created without an order (Carrier Vouchers' "Create
* voucher"); carrier-reported ones never show here.
*/
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$issuedOnPrint = collect(Shipping::getSupportedDrivers())->keys()
->filter(fn (string $carrier) => self::fulfillmentService($carrier) instanceof IssuesVoucherOnPrint)
->values()
->all();
return parent::getEloquentQuery() return parent::getEloquentQuery()
->where('source', Shipment::SOURCE_CREATED) ->where('source', Shipment::SOURCE_CREATED)
->whereNotNull('order_id')
->whereNull('manifest_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 public static function table(Table $table): Table
@@ -69,8 +87,12 @@ class ShipmentResource extends Resource
TextColumn::make('tracking_reference') TextColumn::make('tracking_reference')
->label('Tracking #') ->label('Tracking #')
->placeholder('Not issued yet') ->placeholder('Not issued yet')
->description(fn (Shipment $record) => $record->parcelDescription())
->copyable(), ->copyable(),
TextColumn::make('order.reference')->label('Order'), TextColumn::make('order.reference')
->label('Order')
->placeholder('No order')
->description(fn (Shipment $record) => $record->order_id ? null : $record->recipient()?->name),
TextColumn::make('created_at')->label('Created')->dateTime(), TextColumn::make('created_at')->label('Created')->dateTime(),
TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'), TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
]) ])
@@ -0,0 +1,377 @@
<?php
namespace Modules\Core\Shipping\Filament\Support;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\TimePicker;
use Filament\Forms\Components\ToggleButtons;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Order\Services\OrderFulfillmentService;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\CreatesStandaloneVouchers;
use Modules\Core\Shipping\Contracts\SupportsExtraServices;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\DTOs\VoucherRecipient;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Support\BoxNowShipping;
use Modules\Core\Shipping\Support\WeightCalculator;
/**
* The voucher form, shared by Carrier Vouchers' "Create voucher" and an
* order's "Create Shipment" (for carriers implementing
* CreatesStandaloneVouchers). Its fields follow the carrier:
*
* - ELTA: recipient address and phones, remarks, envelope or parcel,
* packages, weight, dimensions and extra services;
* - Box Now: the locker, one size per box, and the recipient's mobile and
* email (the pickup code goes to both) — no address.
*
* For an order, the carrier is the order's, every field is prefilled
* from it (recipient, delivery instructions, weight, locker), cash on
* delivery is shown read-only (it follows the order's payment method),
* and the reference is the order's own. What staff change goes to the
* carrier only (ShipmentRequest::$recipient); the order stays as the
* customer entered it.
*/
class VoucherForm
{
/**
* @return array<int, mixed> the form's components
*/
public static function schema(?Order $order = null): array
{
$carrier = $order ? self::carrierOf($order) : null;
$defaults = $order ? self::defaultsFor($order, $carrier) : [
'package_count' => 1,
'weight' => 0.5,
];
$default = fn (string $key) => $defaults[$key] ?? null;
return [
$order
? Hidden::make('carrier')->default($carrier)
: Select::make('carrier')
->label('Carrier')
->options(fn () => self::carriers())
->default(fn () => array_key_first(self::carriers()))
->native(false)
->live()
->required(),
Section::make('Recipient')
->description($order ? 'From the order\'s shipping address — changes here go to the carrier only.' : null)
->columns(2)
->schema([
TextInput::make('name')->label('Name')->required()->maxLength(150)->default($default('name'))->columnSpanFull(),
TextInput::make('address')
->label('Address')
->required()
->maxLength(150)
->default($default('address'))
->visible(fn (Get $get) => ! self::isBoxNow($get)),
TextInput::make('postcode')
->label('Postcode')
->required()
->rule('regex:/^\d{3}\s?\d{2}$/')
->validationMessages(['regex' => 'Enter a 5-digit postcode.'])
->default($default('postcode'))
->visible(fn (Get $get) => ! self::isBoxNow($get)),
TextInput::make('city')
->label('City / area')
->required()
->maxLength(40)
->default($default('city'))
->visible(fn (Get $get) => ! self::isBoxNow($get)),
TextInput::make('phone')
->label('Phone')
->tel()
// ELTA needs at least one of the two numbers.
->requiredWithout('mobile')
->validationMessages(['required_without' => 'Enter a phone or a mobile.'])
->default($default('phone'))
->visible(fn (Get $get) => ! self::isBoxNow($get)),
TextInput::make('mobile')
->label('Mobile')
->tel()
->required(fn (Get $get) => self::isBoxNow($get))
->default($default('mobile'))
->helperText(fn (Get $get) => self::isBoxNow($get)
? 'The locker pickup code is sent here by SMS.'
: '10 digits, starting with 69 — required for PostBox delivery.'),
TextInput::make('email')
->label('Email')
->email()
->required()
->default($default('email'))
->helperText('The locker pickup code is sent here too.')
->visible(fn (Get $get) => self::isBoxNow($get)),
Textarea::make('notes')
->label('Remarks for the courier')
->maxLength(90)
->rows(2)
->default($default('notes'))
->helperText('Printed in ΠΑΡΑΤΗΡΗΣΕΙΣ, up to 90 characters.')
->columnSpanFull()
->visible(fn (Get $get) => ! self::isBoxNow($get)),
]),
Section::make('Locker and boxes')
->columns(2)
->visible(fn (Get $get) => self::isBoxNow($get))
->schema([
TextInput::make('locker_id')
->label('Box Now locker ID')
->required()
->default($default('locker_id'))
->helperText($order
? ($default('locker_id')
? 'Set by the customer at checkout — override if the parcel needs to go to a different locker.'
: 'No locker was selected at checkout — enter it manually.')
: null),
// An order's value is its total.
TextInput::make('value')
->label('Parcel value')
->numeric()
->minValue(0)
->prefix('€')
->helperText('Declared to Box Now; defaults to the cash on delivery amount.')
->visible(! $order),
Repeater::make('boxes')
->label('Boxes')
->schema([
Select::make('size')
->label('Size')
->options(['S' => 'Small', 'M' => 'Medium', 'L' => 'Large'])
->default('S')
->native(false)
->required(),
])
->defaultItems(1)
->minItems(1)
->addActionLabel('Add another box')
->helperText('One row per physical box — each gets its own voucher and locker compartment. Box Now ships by size, not weight.')
->columnSpanFull(),
]),
Section::make('Parcel')
->columns(2)
->visible(fn (Get $get) => ! self::isBoxNow($get))
->schema([
ToggleButtons::make('type')
->label('Type')
->options(['parcel' => 'Parcel', 'envelope' => 'Envelope'])
->default('parcel')
->inline()
->required(),
TextInput::make('package_count')
->label('Number of packages')
->numeric()
->integer()
->minValue(1)
->maxValue(20)
->default($default('package_count'))
->required()
->helperText('More than 1 issues a main voucher plus a voucher per extra package.'),
TextInput::make('weight')
->label('Weight')
->numeric()
->minValue(0.001)
->default($default('weight'))
->suffix('kg')
->required()
->helperText($order
? 'Calculated from the order\'s line weights — adjust if needed. The whole shipment, not per package.'
: 'The whole shipment, not per package.'),
Grid::make(3)
->schema([
TextInput::make('length')->label('Length')->numeric()->integer()->minValue(1)->maxValue(999)->suffix('cm'),
TextInput::make('width')->label('Width')->numeric()->integer()->minValue(1)->maxValue(999)->suffix('cm'),
TextInput::make('height')->label('Height')->numeric()->integer()->minValue(1)->maxValue(999)->suffix('cm'),
])
->columnSpan(1),
]),
Section::make('Payment and services')
->columns(2)
->schema([
$order
// Whether a shipment collects cash is a fact about the
// order (its payment method), not a choice made here.
? TextInput::make('cod_amount')
->label('Cash on delivery')
->prefix('€')
->default($default('cod_amount'))
->disabled()
->dehydrated(false)
->helperText('The order is paid on delivery — the courier collects its total.')
->visible(filled($default('cod_amount')))
: TextInput::make('cod_amount')
->label('Cash on delivery')
->numeric()
->minValue(0)
->prefix('€')
->helperText('Leave empty when the recipient pays nothing.'),
// An order's vouchers carry the order's reference.
TextInput::make('reference')
->label('Reference')
->required()
->maxLength(30)
->default(fn () => 'V'.now()->format('ymdHis'))
->helperText(fn (Get $get) => self::isBoxNow($get)
? 'Sent to Box Now as the order number — it must be one Box Now hasn\'t seen before.'
: 'Sent to the carrier and printed on the voucher — also how you find it in the carrier\'s lists.')
->visible(! $order),
CheckboxList::make('services')
->label('Extra services')
->options(fn (Get $get) => self::extraServices($get('carrier')))
->visible(fn (Get $get) => self::extraServices($get('carrier')) !== [])
->columns(2)
->live()
->columnSpanFull(),
TextInput::make('insurance_amount')
->label('Insured value')
->numeric()
->minValue(0)
->prefix('€')
->default($default('insurance_amount'))
->visible(fn (Get $get) => self::ticked($get, ExtraService::Insurance))
->required(fn (Get $get) => self::ticked($get, ExtraService::Insurance)),
TimePicker::make('delivery_until')
->label('Deliver by')
->seconds(false)
->visible(fn (Get $get) => self::ticked($get, ExtraService::TimedDelivery))
->required(fn (Get $get) => self::ticked($get, ExtraService::TimedDelivery)),
]),
];
}
/**
* The recipient as the form has it.
*/
public static function recipient(array $data): VoucherRecipient
{
$text = fn (string $key) => filled($data[$key] ?? null) ? trim($data[$key]) : null;
return new VoucherRecipient(
name: trim($data['name']),
address: (string) $text('address'),
postcode: preg_replace('/\s+/', '', (string) $text('postcode')),
city: (string) $text('city'),
phone: $text('phone'),
mobile: $text('mobile'),
notes: $text('notes'),
email: $text('email'),
);
}
/**
* The shipment request as the form has it. $cod is the cash to collect
* (null = prepaid); for an order it comes from the order, not the form.
*/
public static function request(array $data, ?float $cod, ?VoucherRecipient $recipient = null): ShipmentRequest
{
$services = array_map(fn (string $value) => ExtraService::from($value), $data['services'] ?? []);
$dimensions = array_values(array_filter([$data['length'] ?? null, $data['width'] ?? null, $data['height'] ?? null], 'filled'));
return new ShipmentRequest(
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
packageCount: (int) ($data['package_count'] ?? 1),
destinationLocationId: filled($data['locker_id'] ?? null) ? trim($data['locker_id']) : null,
paymentMode: $cod ? 'cod' : 'prepaid',
amountToCollect: $cod,
boxes: collect($data['boxes'] ?? [])->pluck('size')->all(),
services: $services,
insuranceAmount: in_array(ExtraService::Insurance, $services, true) ? (float) $data['insurance_amount'] : null,
deliveryUntil: in_array(ExtraService::TimedDelivery, $services, true) ? ($data['delivery_until'] ?? null) : null,
envelope: ($data['type'] ?? 'parcel') === 'envelope',
// All three or none — a partial set is ignored.
dimensions: count($dimensions) === 3 ? $dimensions : null,
value: filled($data['value'] ?? null) ? (float) $data['value'] : null,
recipient: $recipient,
);
}
/**
* Whether an order's Create Shipment uses this form: its carrier can
* take a recipient from it.
*/
public static function handles(?string $carrier): bool
{
return $carrier !== null && app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]) instanceof CreatesStandaloneVouchers;
}
/**
* @return array<string, string> carrier => name, for carriers that can create vouchers without an order
*/
public static function carriers(): array
{
return collect(Shipping::getSupportedDrivers())
->filter(fn ($driver, string $carrier) => self::handles($carrier))
->map(fn ($driver) => $driver->name())
->all();
}
/**
* @return array<string, mixed> field => default, from the order
*/
private static function defaultsFor(Order $order, ?string $carrier): array
{
$address = $order->shippingAddress;
$recipient = VoucherRecipient::fromAddress($address);
$isBoxNow = BoxNowShipping::isDriver($carrier);
return [
...$recipient->toArray(),
// One number on the order: Box Now texts it, ELTA sorts
// landlines from mobiles itself.
'phone' => $isBoxNow ? null : $recipient->phone,
'mobile' => $isBoxNow ? $recipient->phone : null,
'locker_id' => $address?->meta['box_now_locker']['locationId'] ?? null,
'package_count' => 1,
'weight' => round(WeightCalculator::totalKg($order->lines), 2) ?: 0.5,
'cod_amount' => app(OrderStatusFlow::class)->isCod($order) ? number_format($order->total->decimal, 2, '.', '') : null,
'insurance_amount' => $order->total->decimal,
];
}
private static function carrierOf(Order $order): ?string
{
return app(OrderFulfillmentService::class)->carrierFor($order);
}
/**
* @return array<string, string> ExtraService value => label
*/
private static function extraServices(?string $carrier): array
{
$service = $carrier ? app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]) : null;
if (! $service instanceof SupportsExtraServices) {
return [];
}
return collect(array_keys($service->extraServices()))
->mapWithKeys(fn (string $value) => [$value => ExtraService::from($value)->label()])
->all();
}
private static function isBoxNow(Get $get): bool
{
return BoxNowShipping::isDriver($get('carrier'));
}
private static function ticked(Get $get, ExtraService $service): bool
{
return in_array($service->value, $get('services') ?? [], true);
}
}
+27 -33
View File
@@ -3,40 +3,43 @@
namespace Modules\Core\Shipping\Jobs; namespace Modules\Core\Shipping\Jobs;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Bus;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking; use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment; 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, * Carrier-agnostic: finds every Shipment not yet in a terminal state on a
* skipping carriers whose fulfillment service doesn't implement * carrier whose fulfillment service implements SupportsTracking, and
* SupportsTracking. New checkpoints are recorded in shipment_info and * dispatches one RefreshShipmentTrackingJob per shipment as a batch. The
* dispatch ShipmentStatusUpdatedByCarrier — one event per new checkpoint. * 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 * Unique: a scheduled run is dropped while the previous one is still
* pollCarrierShipments() — one shipment's tracking lookup failing (a * queued or building its batch. The schedule's withoutOverlapping() alone
* carrier 500, a malformed parcel response) must not stop the rest of that * can't do this — for a queued job it only covers the dispatch itself.
* carrier's shipments in the same batch from being polled. The failure is
* reported and the loop continues.
*/ */
class PollShipmentTrackingJob implements ShouldQueue class PollShipmentTrackingJob implements ShouldBeUnique, ShouldQueue
{ {
use Dispatchable; use Dispatchable;
use InteractsWithQueue; use InteractsWithQueue;
use Queueable; 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 public function handle(): void
{ {
@@ -48,7 +51,7 @@ class PollShipmentTrackingJob implements ShouldQueue
return; return;
} }
Shipment::query() $jobs = Shipment::query()
->whereIn('carrier', $trackableCarriers) ->whereIn('carrier', $trackableCarriers)
->whereNull('cancelled_at') ->whereNull('cancelled_at')
// Pending vouchers (issued on print) have no number to track yet. // Pending vouchers (issued on print) have no number to track yet.
@@ -60,26 +63,17 @@ class PollShipmentTrackingJob implements ShouldQueue
TrackingStatus::Cancelled->value, TrackingStatus::Cancelled->value,
]); ]);
}) })
->chunkById(50, function ($shipments) { ->pluck('id')
$shipments->groupBy('carrier')->each( ->map(fn (int $id) => new RefreshShipmentTrackingJob($id));
fn ($group, $carrier) => $this->pollCarrierShipments($carrier, $group)
);
});
}
private function pollCarrierShipments(string $carrier, $shipments): void if ($jobs->isEmpty()) {
{
if (! $this->fulfillmentService($carrier) instanceof SupportsTracking) {
return; return;
} }
foreach ($shipments as $shipment) { Bus::batch($jobs)
try { ->name('Poll shipment tracking')
app(ShipmentTrackingRecorder::class)->refresh($shipment); ->allowFailures()
} catch (Throwable $e) { ->dispatch();
report($e);
}
}
} }
private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface 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);
}
}
+78 -4
View File
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Lunar\Models\Order; use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\DTOs\VoucherRecipient;
class Shipment extends Model class Shipment extends Model
{ {
@@ -51,6 +52,38 @@ class Shipment extends Model
return $this->shipmentInfo()->latest('occurred_at')->first(); return $this->shipmentInfo()->latest('occurred_at')->first();
} }
/**
* Who the voucher goes to, as saved when it was created
* (meta.recipient). Vouchers created before that was saved fall back to
* their order's shipping address; an extra parcel of a multi-parcel
* send falls back to its main voucher's.
*/
public function recipient(): ?VoucherRecipient
{
if (filled($this->meta['recipient'] ?? null)) {
return VoucherRecipient::fromArray((array) $this->meta['recipient']);
}
if ($address = $this->order?->shippingAddress) {
return VoucherRecipient::fromAddress($address);
}
$masterId = $this->meta['master_shipment_id'] ?? null;
return $masterId ? self::find($masterId)?->recipient() : null;
}
/**
* The reference we sent the carrier with this voucher (saved since
* 0.29.2); older vouchers were sent their order's reference.
*/
public function reference(): ?string
{
return filled($this->meta['reference'] ?? null)
? (string) $this->meta['reference']
: $this->order?->reference;
}
/** /**
* The carrier's display name: a manual carrier's own name (snapshotted * The carrier's display name: a manual carrier's own name (snapshotted
* from its shipping method), otherwise the shipping driver's name(). * from its shipping method), otherwise the shipping driver's name().
@@ -67,15 +100,15 @@ class Shipment extends Model
} }
/** /**
* A link to the carrier's own tracking page — manual carriers only. * A link to the carrier's own tracking page — manual carriers and
* Integrated carriers' history is synced, so customers follow it on * typed-in vouchers only. API-created shipments' history is synced, so
* our own order page instead. * customers follow it on our own order page instead.
*/ */
public function trackingUrl(): ?string public function trackingUrl(): ?string
{ {
$template = $this->meta['tracking_url'] ?? null; $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; return null;
} }
@@ -123,4 +156,45 @@ class Shipment extends Model
{ {
return in_array($this->source, [self::SOURCE_MANUAL, self::SOURCE_MANUAL_VOUCHER, self::SOURCE_SYNCED], true); 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
{
// Box Now: every box is its own voucher (no main voucher).
if (($this->meta['boxes'] ?? 0) > 1) {
return "Box {$this->meta['box']} of {$this->meta['boxes']}";
}
$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; namespace Modules\Core\Shipping\Support;
use Lunar\Models\Contracts\Cart as CartContract;
use Lunar\Shipping\Managers\ShippingManager as VendorShippingManager; use Lunar\Shipping\Managers\ShippingManager as VendorShippingManager;
use Lunar\Shipping\Resolvers\ShippingRateResolver;
/** /**
* Drops lunarphp/table-rate-shipping's own generic drivers (free-shipping, * 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 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 public function getSupportedDrivers(): \Illuminate\Support\Collection
{ {
return collect($this->customCreators) return collect($this->customCreators)