Compare commits

..
28 Commits
Author SHA1 Message Date
arvanitakis 207cb13363 Bump version to 0.31.0 2026-10-01 01:51:41 +03:00
arvanitakis 5ff0d60f54 Feature: Shipping Carrier Additions, Creating a voucher without an order, verifying order details before creating voucher, ELTA Courier voucher print updates 2026-10-01 01:51:13 +03:00
arvanitakis c455c8adf2 Bump version to 0.30.0 2026-09-30 22:01:25 +03:00
arvanitakis dcff693b99 Feat: Batching up poll shipment jobs 2026-09-30 21:59:47 +03:00
arvanitakis 3020bd070c Feat: Correctly displaying Manual VOucher Edit Button 2026-09-30 21:30:13 +03:00
arvanitakis 162827dff7 Fix: Adding Comments to Fulfillment Services 2026-09-30 21:22:41 +03:00
arvanitakis cbb76070d9 Bump version to 0.29.3 2026-09-30 20:58:25 +03:00
arvanitakis 966f099ffb Fix: Turning the bind of the ShippingMethodManagerInterface to a singleton, so that it can actually resolve the shipping methods 2026-09-30 20:58:10 +03:00
arvanitakis b37ed69557 Bump version to 0.29.2 2026-09-30 19:48:19 +03:00
arvanitakis a8fe13e336 Feat: Updates to fullfilment services, shipping views, timezones, and elta labels 2026-09-30 19:47:38 +03:00
arvanitakis eadaf3a580 Fix: Correctly resolving shipping methods 2026-09-30 18:39:02 +03:00
arvanitakis dbea4a8d69 Fix: Resolving box now by instance, not by type. Also, fixing bug where missing shipping driver would break checkout 2026-09-30 17:48:59 +03:00
arvanitakis cd3245df7b Bump version to 0.29.0 2026-09-30 17:30:09 +03:00
arvanitakis 49ec4333af Feat: Shipping functionalities redesign, elta courier integration, commenting out acs since no integration can happen, flow updates 2026-09-30 17:28:47 +03:00
arvanitakis f411dec1da Merge branch 'master' into Shipping-Updates 2026-09-30 13:18:01 +03:00
arvanitakis ebf8fd7571 Bump version to 0.28.0 2026-09-30 12:11:12 +03:00
arvanitakis 536fe32e0d Docs: Adding to CONTRIBUTE.md for translations 2026-09-30 12:10:55 +03:00
arvanitakis 8293195395 Feat: Adding Translations Pull Command 2026-09-30 12:07:26 +03:00
arvanitakis d19fe1b6ea Docs: Updating CONTRIBUTE.md and index.js documentation for correct deploy 2026-09-30 11:33:39 +03:00
arvanitakis 44a2ddda4a Bump version to 0.27.5 2026-09-30 11:15:38 +03:00
arvanitakis 52f3036960 Feat: Adding hashes for otp codes 2026-09-30 11:15:27 +03:00
elvira 004f2382cb Bump version to 0.27.4 2026-09-29 21:30:41 +03:00
elvira 6cf142e35b Feat: Rework cart drawer line layout and order confirmation page 2026-09-29 21:25:28 +03:00
elvira 47851d36ec Bump version to 0.27.3 2026-09-29 20:29:04 +03:00
elvira c43682ef0c Feat: Show bank transfer instructions on order confirmation page 2026-09-29 20:25:43 +03:00
arvanitakis 332bf92e44 Fix: Adding check for duplicate transaction 2026-09-29 14:53:08 +03:00
arvanitakis 1b06486b2b Feat: Adding Elta integrations 2026-09-29 14:31:30 +03:00
arvanitakis 6f6ae03428 Feat: Updating Favicon for core plugin 2026-09-29 01:25:35 +03:00
119 changed files with 8694 additions and 532 deletions
+454
View File
@@ -4,6 +4,460 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.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
### Added
- **ELTA Courier** carrier (`elta` shipping driver): rates, shipment creation,
tracking, and labels rendered locally in ELTA's own A4 / A6 voucher layout.
Configured through `ELTA_*` env variables (`ELTA_APOST_CODE`,
`ELTA_USER_CODE`, `ELTA_SENDER_*`, `ELTA_LABEL_PAPER_SIZE`, …). Vouchers
are created pending and issued when first printed, so they can be
cancelled until then; once issued, ELTA has to cancel them.
- **Pending Vouchers** now has an ELTA tab next to ACS. Print opens the label
(issuing the voucher), Cancel cancels with the carrier, and **Print
selected** returns one combined PDF for carriers that support it (ELTA).
Issue Manifest only shows on tabs for carriers that batch (ACS).
- **Carrier Vouchers** screen (Sales): every voucher with a number, including
the ones carriers report that we don't have (daily sync of the last 14
days, plus **Sync now** and **Look up voucher**). Tabs All / Unlinked /
Returns. Each sync also brings every voucher it sees up to date: new
tracking checkpoints for all of them (ours included), and the carrier's
latest details for the ones that came from the carrier. **Status** is ours (normalized, the same on every carrier) and
**Carrier status** is the carrier's own wording. A view page per voucher
has its details (recipient, COD,
extra services, return info, suggested orders, tracking history). Vouchers
are never linked to an order automatically: each gets suggested orders
(the order its reference points to first, then same postcode and phone or
name), and staff link it with **Link to order**. Supported for ELTA, Box
Now and ACS
(finalized vouchers only).
- **Manual carriers**: a new "Manual carrier" shipping driver for couriers
without an integration. The shipping method holds the carrier name, an
optional tracking URL template (`{number}` = voucher number) and whether the
courier collects cash on delivery. Its shipments are created from the order
page, and staff add tracking updates by hand; they move the order through
dispatched / delivered / failed and send the same emails as integrated
carriers.
- **Extra services** (Saturday delivery, timed delivery, insurance, …), one
normalized list mapped to each carrier's own codes, picked by staff in
Create Shipment. Sent to ACS (`Acs_Delivery_Products`, `Insurance_Ammount`,
`Appointment_Until_Time`) and ELTA (`pel_sur_*`, `pel_asf_poso`), printed
on the ELTA label.
- **Add manual voucher** on the order page, for an integrated carrier's
voucher that wasn't created here (API down, or the courier wrote his own).
It previews what the carrier reports for the number, and its tracking
history is picked up like any other shipment.
- Customer tracking: the dispatched email lists each shipment's voucher
number and links to the order page, which shows the carrier, status and
the synced tracking history. Only manual carriers link to the courier's
site. New `storefront.orders.*` and `storefront.tracking_status.*` keys —
re-run `StorefrontTranslationsSeeder` in consuming apps.
- Carrier tracking on the order page's **Timeline**: every checkpoint of the
order's shipments (carrier polling or a manual tracking update) is logged
as it arrives, showing carrier, voucher, status, location and the
carrier's own time. Linking a voucher to an order from Carrier Vouchers
adds its history so far.
- Every shipment stores its cash-on-delivery amount and extra services; the
order page shows them. ACS vouchers now carry the order reference
(`Reference_Key1`) so they can be matched back.
### Changed
- Creating a shipment no longer marks the order **dispatched**. It moves to
dispatched when the carrier reports picking the parcel up, which is also
when the "on its way" email is sent (previously it was never sent after
Create Shipment).
- `shipments.tracking_reference` and `shipments.order_id` are nullable, and
shipments have a `source` (`created`, `manual_voucher`, `manual`,
`synced`). New migration.
- `SupportsCashCollection::collectsCash()` now receives the shipping method:
`collectsCash(ShippingMethod $method)`. Custom shipping drivers implementing
it need the new signature.
- Create Shipment is available again once all of an order's shipments are
cancelled.
- The order page shows ELTA and manual carriers by name, and hides Print for
shipments without a carrier label.
- `OrderStatusUpdated` carries the cause of the write (`$causeClass`, null
for writes outside `OrderStatusWriter`).
### Fixed
- Customers got two emails when an order became ready for pickup,
dispatched, delivered or completed: the dedicated one and the generic
"your order is now …" one, which only skipped a `ready-for-pickup` spelling
that is never written. The generic email now skips exactly the writes that
send their own email; a manual status change from the admin still sends
it.
- ELTA labels now print everything ELTA's own client prints (checked
field by field against its `SydetaLabelE` / `sydetaE` reports and
`print_vg()`): the cash-on-delivery amount and breakdown
("ΑΝΤΙΚΑΤΑΒΟΛΗ 17.00", "* ΑΝΑΛΥΣΗ *", "17.00 MΕΤΡΗΤΑ") in the COD column,
the A6 bottom banner, both A4 copies and the payment stub (with ΠΟΣΟ), the
A4 "* ΠΡΟΣΟΧΗ ΑΝΤΙΚΑΤΑΒΟΛΗ *" / "* Απόδειξη Είσπαξης *" notes, the
sender's "Κωδικός:" line, extra services in ELTA's own slots, and
"* ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ *". Previously the A6 label showed no COD amount at
all. The OCR line is no longer wrapped in doubled `> <` markers, and the
volumetric weight is left empty (not 0.000) when none was given.
- ELTA tracking texts were almost never recognized: ELTA sends them in
unaccented capitals ("ΠΑΡΑΔΟΘΗΚΕ") while the mapping looked for accented
words, so most checkpoints came through as Pending. Box Now's `canceled`
spelling is now recognized too.
- ACS shipments were never detected as delivered: the tracking summary was
read from `ACSValueOutput` instead of `ACSTableOutput`.
- Re-creating a Box Now shipment after cancelling it failed, because the
same `orderNumber` was sent again. Retries now send `{reference}-{id}-2`,
`-3`, …
## [0.28.0] - 2026-09-30
### Added
- `php artisan boboko:translations:pull` — the reverse of the translation
seeders: copies `storefront` / `checkout` / `validation` lines that exist in
the database (e.g. added through the Filament Language Lines UI while
building a storefront) but not in the matching seeder into that seeder's
`lines()`, appended under a marker comment. Keys a seeder already has are
never touched. Writes only against a local `../boboko-core` checkout (local
mode); `--dry-run` lists the lines from anywhere.
- Storefront translations for error pages: `storefront.errors.404_title`,
`storefront.errors.404_text`, `storefront.errors.back_home`. Re-run
`StorefrontTranslationsSeeder` in consuming apps (or restart the stack) to
add them.
### Changed
- `CONTRIBUTE.md` rewritten to match the actual setup: the local/repo modes
and the `bin/core-mode` switch, `@boboko/core` as a real npm package (the
old "no separate npm package" section was stale), releasing a version,
deploying a consumer app with `bin/deploy`, and pulling UI-added
translations into the seeders.
## [0.27.5] - 2026-09-30
### Security
- OTP codes (both customer login via `UserOtpService` and staff login via
`OtpService`) were stored in plaintext in `users.otp_code`/`lunar_staff.otp_code`
and compared against the plaintext guess. The staff path was additionally
weaker — a loose `!=` comparison with no timing-attack protection and no
attempt-limiting at all. Both columns are replaced with `otp_code_hash`
(bcrypt, via a `'hashed'` cast — same convention `password` already uses),
compared with `Hash::check()`. The cache-backed pending-signup OTP path
(an email with no `User` row yet) is hashed the same way. No backfill —
any code mid-flight when this deploys is invalidated (codes expire in 10
minutes regardless, so the practical impact is limited to a re-request).
- `App\Models\User`'s (3dealer) and `Modules\Core\Auth\Models\Staff`'s
`$hidden` arrays didn't list `otp_code`/`otp_expires_at`/`otp_attempts`/
`pending_email_code_hash`/etc. at all — any serialization of either model
(an API response, `Auth::user()` returned somewhere) would have leaked
those fields, including the (now-hashed, previously plaintext) OTP code
itself. `Staff` additionally never overrode Lunar's own base `$hidden`, so
it also lacked `password`/`remember_token` protection until now.
## [0.27.4] - 2026-09-29
### Added
- Generic `.bbk-notice` / `.bbk-notice--info` message box and a
`--bbk-color-info` custom property in `checkout.css`.
- Cart drawer focus handling: focus moves into the drawer on open, stays
inside it, returns to the opener on close, and is restored after each
cart update. Updates are announced as "Cart updated" instead of re-reading
the whole cart. New translation line `checkout.cart.updated` — re-run
`CheckoutTranslationsSeeder` in consuming apps to add it.
### Changed
- Cart drawer line: larger remove button on the title row, line total on
the quantity-stepper row, and screen-reader labels tied to the product
name. `.bbk-cart-item-aside` is removed — hosts restyling it should target
`.bbk-cart-item-head` / `.bbk-cart-item-foot` instead.
- Order confirmation page: narrower (560px), the confirmation-email note is
now an info box under the heading, the order summary comes before
shipping/billing (shown side by side), and the order number is prefixed
with `#`.
## [0.27.3] - 2026-09-29
### Added
- The checkout confirmation page now ends with the store's bank transfer
instructions (Store Details → Bank transfer) for a bank transfer order,
via `StoreDetailsService::bankTransferInstructionsFor()`. New translation
line `checkout.page.confirmation_bank_transfer_heading` — re-run
`CheckoutTranslationsSeeder` in consuming apps to add it.
### Fixed
- `StoreDetailsService::bankTransferInstructionsFor()` now fills a
`{order_reference}` (or `{{ order_reference }}`) typed into the bank
transfer instructions with the order's display reference
(`OrderReferenceDisplay`). It previously rendered literally in the order
confirmation email.
## [0.27.2] - 2026-09-29
### Fixed
- `Modules\Core\Order\Services\TransactionRecorder::record()` — made idempotent
on `(order_id, type, reference)`. A successful Stripe payment can legitimately
report `PaymentCaptured` twice for the same PaymentIntent (checkout's
synchronous capture via `pay()`, then the webhook confirming the same
outcome asynchronously via `handleCallback()`), both routing through
`resultFromIntent()`. With no dedupe check, this wrote two identical
`Transaction` rows for one real payment. Now returns the existing row
instead of creating a duplicate.
## [0.27.1] - 2026-09-29
### Added
- Temp logger for Stripe webhook
+105 -42
View File
@@ -1,85 +1,148 @@
# Contributing to boboko-core
This is a Composer library, not a runnable app — you can't `php artisan serve` it directly. To develop and verify changes, you need a consumer app wired to a local checkout via a Composer path repository, plus a real database, since a large part of this package (Lunar models, migrations, Filament panel resources) can only be meaningfully verified against a live Lunar install.
This is a Composer library (and an npm package of the same name — see [JS/CSS](#jscss-a-real-npm-package)), not a runnable app — you can't `php artisan serve` it directly. To develop and verify changes, you need a consumer app wired to a local checkout, plus a real database, since a large part of this package (Lunar models, migrations, Filament panel resources) can only be meaningfully verified against a live Lunar install.
## Local dev setup
This works against any consumer app that follows the same convention — `boboko-test`, `boboko-starter`, `boboko-3dealer`, etc. — checked out next to this repo:
Consumer apps (`3dealer`, `boboko-test`, …) are checked out next to this repo:
```
RadicalElements/
├── boboko-core/ (this repo)
└── boboko-test/ (or boboko-starter, boboko-3dealer, ... — consumer app, Docker-based)
└── 3dealer/ (or boboko-test, ... — consumer app, Docker-based)
```
Each of these consumer apps ships a `bin/dc-core.sh` helper that wraps the Docker Compose overlay needed to bind-mount a local `boboko-core` checkout into the app container:
### Two modes: local and repo
A consumer app can consume core in one of two modes, and carries the wiring for both. The inactive one is parked under an underscore-prefixed key:
| | **local** — your `../boboko-core` checkout | **repo** — tagged releases from the forge |
|---|---|---|
| `composer.json` | `repositories`: path repo `../boboko-core` (`"symlink": true`) | `repositories`: VCS repo `https://code.radical-elements.com/boboko/core.git` |
| `package.json` | `@boboko/core`: `file:../boboko-core` | `@boboko/core`: `git+https://code.radical-elements.com/boboko/core.git#semver:0.x` |
| Docker Compose | `bin/dc-core.sh` (dev + `docker-compose.core-dev.yml` overlay) | `bin/dc` (dev only) |
- **The committed state is always repo mode.** Local mode rewrites both lockfiles to point at `../boboko-core`, which doesn't exist on the server — never commit it.
- Both sides use an open `0.x` range: `"boboko/core": "0.*"` in Composer, `#semver:0.x` in npm. Don't use a caret: below 1.0.0, `^0.27.0` means `>=0.27.0 <0.28.0` in both tools, so it would silently refuse the next minor.
- `docker-compose.core-dev.yml` bind-mounts `../boboko-core` into the containers — at `/var/www/boboko-core` for `app`/`queue`/`scheduler` (where the path repo resolves from `/var/www/html`) and at `/boboko-core` for `vite` (where `file:../boboko-core` resolves from `/app`). Inside the app container, `vendor/boboko/core` is a symlink into that mount.
### Switching modes: `bin/core-mode`
Don't swap the keys by hand — each consumer app ships a `bin/core-mode` script:
```bash
./bin/dc-core.sh exec app <command>
bin/core-mode # print the current mode
bin/core-mode local # work against ../boboko-core
bin/core-mode repo # back to tagged releases
```
This is shorthand for `docker compose -f docker-compose.dev.yml -f docker-compose.core-dev.yml exec app <command>`. Use `./bin/dc-core.sh` for everything below instead of typing the full compose invocation.
It swaps the `composer.json` / `package.json` wiring, runs `down` with the old mode's Compose wrapper and `up` with the new one, then waits until the entrypoints have re-resolved core. Running it for the mode you're already in skips the edits and just restarts the stack — in repo mode, that's how you pick up a newly pushed tag.
1. **Path repository.** In the consumer app's `composer.json`, the `repositories` array needs a path entry pointing at `../boboko-core`. If it only exists in a disabled block (e.g. `_repositories`), move it into the live array.
2. **Relaxed version constraint.** The consumer app's `composer.json` should require `"boboko/core": "0.*"` (not a tight `^0.0.1` caret) — otherwise Composer rejects newer `0.0.x` versions resolved from the path repo.
3. **Bind mount.** The consumer app's `docker-compose.core-dev.yml` overlays `../boboko-core` into the container at `/var/www/boboko-core`, matching where the path repo resolves it relative to `/var/www/html`.
4. **Re-resolve after every change.** Composer's path repo does not hot-reload — after editing anything in `boboko-core` (including adding new files, which need autoload discovery), the container needs to re-run `composer update boboko/core`. In `boboko-test`, the entrypoint does this automatically on every dev boot (see `docker/entrypoint.sh`), so `./bin/dc-core.sh up` alone picks up local core changes. If a consumer app's entrypoint doesn't do this yet, run it manually:
(`3dealer` has `bin/core-mode` and `bin/deploy`; they're app-agnostic, so other consumer apps can copy them as-is.)
### Day to day in local mode
Use `bin/dc-core.sh` for every Compose command (`bin/dc-core.sh exec app …`, `bin/dc-core.sh logs -f`, …) — plain `docker compose` or `bin/dc` leaves the core mount out.
The dev entrypoints re-resolve core on **every boot**: `docker/entrypoint.sh` runs `composer update "boboko/*"` and `docker/entrypoint-vite.sh` runs `npm update @boboko/core`. So:
- **Whatever branch is checked out in `../boboko-core` is what the app runs.** Switching core branches switches the app's code — check which branch you're on before debugging "missing" features.
- PHP edits to existing files show up immediately (it's a symlink). **New classes, new migrations, or `composer.json` changes** need a re-resolve: restart the stack, or run
```bash
./bin/dc-core.sh exec app composer update boboko/core --with-all-dependencies
bin/dc-core.sh exec app composer update boboko/core --with-all-dependencies
```
Skipping this step is the most common cause of "my change isn't showing up."
Skipping this is the most common cause of "my change isn't showing up."
- In `3dealer`, the app container's `vendor/` is a named Docker volume, so the host's `vendor/` directory is stale — inspect packages inside the container, not on the host.
## JS/CSS: no separate npm package
## JS/CSS: a real npm package
This package's JS (Stimulus controllers) and CSS ship as plain source files under `resources/js/` and `resources/css/`, read directly by a consumer app's own Vite build — there is no separate `@boboko/core` npm package, and no `npm install`/`file:` dependency step of any kind.
Core's Stimulus controllers and CSS ship as the `@boboko/core` npm package, installed into the consumer's `node_modules` — as a symlink to `../boboko-core` in local mode, as a real copy of the tagged release in repo mode. It's a real package (rather than files read out of `vendor/`) so npm installs core's own dependencies (`leaflet`, `@hotwired/stimulus`) transitively, the same way Composer does for PHP.
The reason: Composer already gives every environment one single, unconditional path — `vendor/boboko/core` — whether that resolves to a real symlink into `../boboko-core` (local path repo) or a real installed copy (tagged VCS release). A consumer's `vite.config.js` and JS entry point just read straight from that path, so there is nothing to toggle on the JS side — whatever Composer resolved is exactly what Vite sees, automatically, in both dev and prod.
Public entry points (`package.json` `exports`):
**Stable entry point.** A consumer imports from [resources/js/index.js](resources/js/index.js) only — never from a path reaching into a specific module's internals (e.g. `resources/js/checkout/index.js` directly). That barrel file re-exports whatever a consumer needs (currently just `registerCheckout`), so this package's internal file layout can change without breaking every consumer's own entry point:
| Import | File |
|---|---|
| `@boboko/core` | `resources/js/index.js` — the stable barrel (`registerCheckout`, `registerWishlist`, …) |
| `@boboko/core/vite-plugin` | `vite-plugin.js` — `boboko()` |
| `@boboko/core/css/*` | `resources/css/*` |
| `@boboko/core/checkout`, `@boboko/core/checkout/*` | `resources/js/checkout/…` |
A consumer imports from the `@boboko/core` barrel only — not from a module's internal files — so the internal layout here can change without breaking every consumer:
```js
// consumer app's resources/js/app.js
import { registerCheckout } from "../../vendor/boboko/core/resources/js/index.js";
import { registerCheckout, registerWishlist } from "@boboko/core";
registerCheckout(application);
registerWishlist(application);
```
```js
// consumer app's vite.config.js
import { boboko } from "@boboko/core/vite-plugin";
export default defineConfig({
plugins: [
laravel({
input: [
// Core's structural checkout styles load first, so the app's own theming wins.
"node_modules/@boboko/core/resources/css/checkout.css",
"resources/css/app.css",
"resources/js/app.js",
],
}),
boboko(),
],
});
```
```php
{{-- consumer app's layout --}}
@vite(['vendor/boboko/core/resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js'])
@vite(['node_modules/@boboko/core/resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js'])
```
**What a consumer's `vite.config.js` needs**, because `vendor/boboko/core` is a symlink in local path-repo dev (not a real directory):
`boboko()` owns the Vite settings the local-mode symlink needs, so consumers don't hand-copy them: it excludes `@boboko/core` from dependency pre-bundling (otherwise Vite serves a stale cached copy after you edit core), pre-bundles `leaflet`/`@hotwired/stimulus` explicitly, and turns on `resolve.preserveSymlinks` and `server.watch.followSymlinks` so bare imports resolve from the consumer's `node_modules` and core edits trigger HMR. All of it is a harmless no-op against a real installed copy in repo mode.
```js
export default defineConfig({
server: {
watch: {
// vendor/boboko/core is a symlink into ../boboko-core in local
// path-repo dev. Vite/chokidar don't follow symlinks for watched
// files by default, so edits to core's source wouldn't otherwise
// trigger HMR. No-op against a real installed copy (tagged VCS
// release) in production — there's no symlink to follow, and
// production only ever runs a one-shot `npm run build`, which
// doesn't watch anything regardless.
followSymlinks: true,
},
},
});
## Translations added in the UI
Default translation lines ship in core's seeders (`StorefrontTranslationsSeeder`, `CheckoutTranslationsSeeder`, `ValidationTranslationsSeeder`), which every app runs on boot and which only ever add missing keys. Lines added while building a storefront usually start in the Filament Language Lines UI instead. To move them into core:
```bash
bin/dc-core.sh exec app php artisan boboko:translations:pull # local mode: writes into ../boboko-core
bin/dc exec app php artisan boboko:translations:pull --dry-run # any mode: just list them
```
Bare imports inside this package's own JS (`leaflet`, `@hotwired/stimulus`) resolve against the *consumer's* `node_modules` — Node's normal upward `node_modules` resolution walks from `vendor/boboko/core/resources/js/...` up through `vendor/boboko/`, `vendor/`, to the consumer app's root, where `node_modules` lives. This works with zero extra config as long as `vendor/boboko/core` sits inside the consumer's own directory tree (true for both the symlink and the real-copy case) — a consuming app's `vite`-equivalent Docker service just needs the same bind mount PHP containers already get, landing at the same path:
It adds every `storefront` / `checkout` / `validation` key that's in the database but not in the matching seeder, appended at the end of `lines()` under a marker comment — move them into the right section before committing. Keys the seeder already has are never touched, even if their text was edited in the UI. `bin/deploy` runs it for you.
```yaml
# consumer app's docker-compose.core-dev.yml
services:
vite:
volumes:
- ../boboko-core:/app/vendor/boboko/core
## Releasing a version
1. Bump `"version"` in **both** `composer.json` and `package.json` — they must match.
2. Add a `CHANGELOG.md` entry under the new version. While pre-1.0, a new capability for consuming apps is a **minor** bump (`0.27.x` → `0.28.0`); a fix, redesign or internal swap with no new capability is a **patch** bump.
3. Commit, tag `vX.Y.Z`, and push the commit **and** the tag:
```bash
git tag v0.27.5
git push origin master v0.27.5
```
(Match whatever the consumer's Vite container's working directory actually is — `/app` above, `/var/www/html` for the PHP containers in `boboko-test`'s convention.)
A tag alone changes nothing in production — each consumer app has to pick it up and deploy (below).
## Deploying a consumer app
Production only ever runs what the app's **committed lockfiles** pin. Each consumer app ships `bin/deploy`, which:
1. Refuses to run on the wrong branch, with uncommitted changes (other than the core wiring files), or behind `origin`.
2. Runs `php artisan boboko:translations:pull` (see [Translations added in the UI](#translations-added-in-the-ui)). In local mode, if it pulls any lines into `../boboko-core`, it stops — commit, tag and push core, then rerun. In repo mode it only checks, and stops if the database has lines core doesn't.
3. Runs `bin/core-mode repo` — switching from local mode if needed, restarting either way — so both lockfiles resolve the newest `0.x` tag. It warns if `../boboko-core` has a newer tag than what resolved (usually an unpushed tag).
4. Commits the lockfile bump (`Chore: Bumping boboko/core to X.Y.Z`) if there is one, shows what will be pushed, and asks for confirmation.
5. Pushes, then runs `vendor/bin/envoy run deploy` against the host in `.env.envoy`.
Envoy (`Envoy.blade.php`) then, on the server: `git reset --hard` + `git pull`, `docker compose build` (the `production` image target runs `composer install --no-dev` and `npm ci` from the committed lockfiles — this is where the core tag actually lands), `up -d`, caches config/routes/events, restarts `queue` and `scheduler`, and regenerates Stoic thumbnails. The production entrypoint skips Composer entirely and runs migrations (including core's), seeders, the Meilisearch sync, and `artisan optimize`.
After deploying you're left in repo mode — `bin/core-mode local` to go back.
When a change spans core and the app (e.g. a core migration plus an app model cast that depends on it), ship them together: tag core first, then commit the app change and deploy — `bin/deploy` bumps the lock to the new tag in the same deploy.
## Verifying changes against a real database
+4 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.27.1",
"version": "0.31.0",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
@@ -18,7 +18,9 @@
"lunarphp/search": "*",
"lunarphp/meilisearch": "*",
"spatie/laravel-translation-loader": "^2.8",
"stripe/stripe-php": "^16.6"
"stripe/stripe-php": "^16.6",
"picqer/php-barcode-generator": "^3.3",
"barryvdh/laravel-dompdf": "^3.1"
},
"require-dev": {
"fakerphp/faker": "^1.23",
+14
View File
@@ -16,6 +16,20 @@ return [
'auto_create_customer_for_user' => true,
/*
|--------------------------------------------------------------------------
| Display Timezone
|--------------------------------------------------------------------------
|
| Timestamps are stored in the app timezone (UTC). This is the timezone
| they're shown in — the admin (Filament's display timezone), the
| storefront, emails and the order Timeline. (Carriers that report times
| without an offset — ELTA, ACS — are read as Greek time regardless.)
|
*/
'display_timezone' => env('DISPLAY_TIMEZONE', 'Europe/Athens'),
/*
|--------------------------------------------------------------------------
| Privacy / GDPR data-subject requests
+72
View File
@@ -0,0 +1,72 @@
<?php
/*
|--------------------------------------------------------------------------
| ELTA Courier credentials
|--------------------------------------------------------------------------
|
| ELTA's API is SOAP (unlike ACS's JSON gateway or Box Now's REST +
| OAuth2). Two operation families live at the same endpoint — see
| Modules\Core\Shipping\Carriers\Elta\EltaClient's docblock for why only
| the "*NEW" family (create/track/station/cancel) is used; the old
| family (including its label-print operation) is unreachable with this
| account and isn't called anywhere in this integration. Labels are
| rendered locally instead — see
| Modules\Core\Shipping\Carriers\Elta\EltaLabelRenderer — which is why
| sender identity (name/address, unlike PELVGNEW's request which needs
| only apost_code) is configured here.
|
| Set these via environment variables — never commit real values.
|
| ELTA_SERVICE_LOCATION SOAP endpoint (defaults to the live production
| host; only needed if ELTA gives you a separate
| sandbox host).
| ELTA_USER_CODE Account user code (pel_user_code / pel_user).
| ELTA_USER_PASS Account security code — not used by anything the
| *NEW family calls; kept only in case ELTA ever
| asks for it.
| ELTA_APOST_CODE Sender/account code (pel_apost_code).
| ELTA_APOST_SUB_CODE Sender sub-code (pel_apost_sub_code).
| ELTA_SENDER_NAME Sender name printed on the label — PELVGNEW's
| own request has no such field, so this only
| matters for our own locally-rendered label.
| ELTA_SENDER_ADDRESS Sender street address printed on the label.
| ELTA_SENDER_POSTCODE Sender postcode printed on the label.
| ELTA_SENDER_AREA Sender area/city printed on the label.
| ELTA_SENDER_PHONE Sender phone printed on the label.
| ELTA_ORIGIN_STATION_CODE This account's home ELTA station code — shown
| on the label as "Γ.Κατάθεσης"/"Από". ELTA's own
| client gets this from a PELLOGINNEW response
| (user_station); configured here instead so a
| label print doesn't need an extra API call.
| ELTA_LABEL_PAPER_SIZE "a4" (default, 3 copies per page) or "a6"
| (single thermal label) — matches the real
| client's own one-time printer-setup choice, not
| varied per shipment.
|
*/
return [
// 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_pass' => env('ELTA_USER_PASS'),
'apost_code' => env('ELTA_APOST_CODE'),
'apost_sub_code' => env('ELTA_APOST_SUB_CODE'),
'sender_name' => env('ELTA_SENDER_NAME'),
'sender_address' => env('ELTA_SENDER_ADDRESS'),
'sender_postcode' => env('ELTA_SENDER_POSTCODE'),
'sender_area' => env('ELTA_SENDER_AREA'),
'sender_phone' => env('ELTA_SENDER_PHONE'),
'origin_station_code' => env('ELTA_ORIGIN_STATION_CODE'),
'label_paper_size' => env('ELTA_LABEL_PAPER_SIZE', 'a4'),
'timeout' => env('ELTA_HTTP_TIMEOUT', 15),
];
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* otp_code was stored in plaintext (a raw 6-digit string) and compared
* with hash_equals() against the plaintext guess in
* Modules\Core\Auth\Services\UserOtpService — hash_equals() only
* prevents a timing attack, it does nothing to protect the code itself
* from anyone with read access to the row. Replaced with a bcrypt hash,
* same pattern Modules\Core\Customer\Services\CustomerEmailChangeService
* already uses for its own pending_email_code_hash column.
*
* No backfill: any code mid-flight when this deploys is invalidated —
* codes expire in 10 minutes anyway, so the real-world impact is a
* shopper re-requesting one, not lost work.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('otp_code_hash')->nullable()->after('password');
});
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('otp_code');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('otp_code', 6)->nullable()->after('password');
});
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('otp_code_hash');
});
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Same fix as 2026_09_30_000001_hash_otp_code_on_users_table.php, for
* staff logins — see that migration's own docblock. This path was
* additionally weaker: Modules\Core\Auth\Services\OtpService compared
* with a loose != rather than hash_equals(), so it had no timing-attack
* protection at all on top of the plaintext storage.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('lunar_staff', function (Blueprint $table) {
$table->string('otp_code_hash')->nullable()->after('password');
});
Schema::table('lunar_staff', function (Blueprint $table) {
$table->dropColumn('otp_code');
});
}
public function down(): void
{
Schema::table('lunar_staff', function (Blueprint $table) {
$table->string('otp_code', 6)->nullable()->after('password');
});
Schema::table('lunar_staff', function (Blueprint $table) {
$table->dropColumn('otp_code_hash');
});
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* - tracking_reference becomes optional: some carriers only issue the
* voucher number when the label is printed (ELTA's pending vouchers).
* Still unique — Postgres allows any number of NULLs under a unique index.
* - order_id becomes optional: vouchers pulled from a carrier's own list
* can exist before they're linked to an order.
* - source records where the shipment came from: created (our admin, via
* the carrier's API), manual_voucher (an integrated carrier's voucher
* typed in by staff), manual (a carrier with no integration), synced
* (pulled from a carrier's voucher list).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->string('tracking_reference')->nullable()->change();
$table->unsignedBigInteger('order_id')->nullable()->change();
$table->string('source')->default('created')->index()->after('carrier');
});
}
public function down(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->dropColumn('source');
$table->unsignedBigInteger('order_id')->nullable(false)->change();
$table->string('tracking_reference')->nullable(false)->change();
});
}
};
+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
@@ -393,7 +393,7 @@ Because Filament instantiates `Lunar\Admin\Models\Staff` directly (not a subclas
```php
use Lunar\Admin\Models\Staff as LunarStaff;
LunarStaff::addActivitylogExcept(['otp_code', 'otp_expires_at', 'password']);
LunarStaff::addActivitylogExcept(['otp_code_hash', 'otp_expires_at', 'password']);
```
---
+3 -2
View File
@@ -30,11 +30,12 @@ Codes expire after **10 minutes**. After a successful validation the code is cle
### Database
Two columns on the `lunar_staff` table (added by `2026_05_06_000001_add_otp_to_lunar_staff_table`):
Two columns on the `lunar_staff` table (added by `2026_05_06_000001_add_otp_to_lunar_staff_table`,
`otp_code` replaced with a hashed column by `2026_09_30_000002_hash_otp_code_on_lunar_staff_table`):
| Column | Type | Purpose |
|---|---|---|
| `otp_code` | string, nullable | The generated code |
| `otp_code_hash` | string, nullable | Bcrypt hash of the generated code (`'hashed'` cast on `Staff`) |
| `otp_expires_at` | timestamp, nullable | Expiry time |
### Login Page
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@boboko/core",
"version": "0.27.1",
"version": "0.31.0",
"private": true,
"type": "module",
"description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.",
+110 -26
View File
@@ -53,6 +53,7 @@
--bbk-color-accent: #18181b;
--bbk-color-accent-text: #ffffff;
--bbk-color-danger: #dc2626;
--bbk-color-info: #2563eb;
--bbk-radius: 8px;
--bbk-radius-sm: 4px;
@@ -153,6 +154,11 @@
outline-offset: 2px;
}
/* Programmatic focus targets only (tabindex=-1) — never reached by Tab, so
no ring needed. */
.bbk-cart-heading:focus,
.bbk-checkout-summary-heading:focus { outline: none; }
.bbk-visually-hidden {
position: absolute;
width: 1px;
@@ -183,7 +189,7 @@
.bbk-cart-item {
display: grid;
grid-template-columns: 72px 1fr auto;
grid-template-columns: 72px 1fr;
gap: 0.875rem;
align-items: start;
}
@@ -199,8 +205,23 @@
.bbk-cart-item-detail { min-width: 0; }
/* Title + remove button share the first row; quantity stepper + line total
share the last one. */
.bbk-cart-item-head,
.bbk-cart-item-foot {
display: flex;
justify-content: space-between;
gap: 0.75rem;
}
.bbk-cart-item-head { align-items: flex-start; }
.bbk-cart-item-foot { align-items: center; }
.bbk-cart-item-remove-form { flex: 0 0 auto; }
.bbk-cart-item-title {
display: block;
min-width: 0;
margin: 0 0 0.25rem;
font-weight: 600;
color: inherit;
@@ -252,24 +273,24 @@ a.bbk-cart-item-title:hover { text-decoration: underline; }
color: var(--bbk-color-muted);
}
.bbk-cart-item-aside {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.bbk-cart-item-total { margin: 0; font-weight: 600; }
.bbk-cart-item-total { margin: 0; font-weight: 600; white-space: nowrap; }
/* 2.5rem hit area (same as the drawer's own close button), pulled up/right by
negative margins so the glyph lines up with the title's first line instead
of pushing the row taller. */
.bbk-cart-item-remove {
font-size: 1.125rem;
width: 1.5rem;
height: 1.5rem;
font-size: 1.75rem;
width: 2.5rem;
height: 2.5rem;
margin: -0.5rem -0.5rem 0 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--bbk-radius-sm);
}
.bbk-cart-item-remove:hover { background: var(--bbk-color-bg-muted); }
.bbk-cart-qty {
display: inline-flex;
align-items: center;
@@ -905,10 +926,39 @@ textarea.bbk-field-input { resize: vertical; }
to { transform: rotate(360deg); }
}
/* ── Notice ────────────────────────────────────────────────────────────
Inline, static message box: `.bbk-notice` + a tone modifier. Static
content, so no live-region role — for messages injected after load, add
role="status" (or role="alert" for errors) on the element itself. */
.bbk-notice {
--bbk-notice-color: var(--bbk-color-text);
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.875rem 1rem;
border: 1px solid color-mix(in srgb, var(--bbk-notice-color) 25%, transparent);
border-radius: var(--bbk-radius-sm);
background: color-mix(in srgb, var(--bbk-notice-color) 6%, var(--bbk-color-bg));
color: var(--bbk-color-text);
font-size: 0.875rem;
}
.bbk-notice--info { --bbk-notice-color: var(--bbk-color-info); }
.bbk-notice-icon {
flex: 0 0 auto;
width: 1.25rem;
height: 1.25rem;
color: var(--bbk-notice-color);
}
.bbk-notice-text { margin: 0; align-self: center; }
/* ── Confirmation page ─────────────────────────────────────────────── */
.bbk-confirmation {
max-width: 720px;
max-width: 560px;
margin: 0 auto;
padding: 3rem 1.5rem 5rem;
font-family: var(--bbk-font);
@@ -916,7 +966,7 @@ textarea.bbk-field-input { resize: vertical; }
}
.bbk-confirmation-heading {
margin: 0 0 1rem;
margin: 0 0 1.25rem;
font-size: 1.75rem;
font-weight: 700;
}
@@ -924,7 +974,7 @@ textarea.bbk-field-input { resize: vertical; }
.bbk-confirmation-ref { margin: 0 0 0.25rem; }
.bbk-confirmation-meta {
margin: 0 0 1rem;
margin: 1.5rem 0 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
@@ -940,17 +990,22 @@ textarea.bbk-field-input { resize: vertical; }
.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); }
.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; }
.bbk-confirmation-body {
margin: 2rem 0;
display: grid;
gap: 2.5rem;
.bbk-confirmation-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--bbk-color-border);
}
@media (min-width: 640px) {
.bbk-confirmation-body { grid-template-columns: 1fr 1fr; }
.bbk-confirmation-section-heading {
margin: 0 0 1rem;
font-size: 1.125rem;
font-weight: 700;
}
.bbk-confirmation-lines {
list-style: none;
margin: 0 0 1.25rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
@@ -965,22 +1020,51 @@ textarea.bbk-field-input { resize: vertical; }
.bbk-confirmation-line-detail { min-width: 0; }
.bbk-confirmation-line-name { margin: 0 0 0.25rem; }
.bbk-confirmation-line-total { margin: 0; white-space: nowrap; }
.bbk-confirmation-line-qty { color: var(--bbk-color-muted); }
.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; }
.bbk-confirmation-addresses {
display: flex;
flex-direction: column;
display: grid;
gap: 1.5rem;
}
.bbk-confirmation-address-heading {
@media (min-width: 480px) {
.bbk-confirmation-addresses { grid-template-columns: 1fr 1fr; }
}
.bbk-confirmation-address-heading,
.bbk-confirmation-bank-transfer-heading {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
}
/* Store-authored rich text (ManageStoreDetails' RichEditor) — may be
paragraphs, bold text or a bank/IBAN/BIC table. */
.bbk-confirmation-bank-transfer-body {
font-size: 0.875rem;
overflow-wrap: anywhere;
}
.bbk-confirmation-bank-transfer-body > :first-child { margin-top: 0; }
.bbk-confirmation-bank-transfer-body > :last-child { margin-bottom: 0; }
.bbk-confirmation-bank-transfer-body table {
width: 100%;
border-collapse: collapse;
}
.bbk-confirmation-bank-transfer-body th,
.bbk-confirmation-bank-transfer-body td {
padding: 0.375rem 0.5rem;
border: 1px solid var(--bbk-color-border);
text-align: left;
vertical-align: top;
}
.bbk-address-lines {
font-style: normal;
display: flex;
+82 -3
View File
@@ -9,11 +9,13 @@ import { csrfToken } from './csrf'
// - handles the in-drawer quantity / remove forms (fetch + method spoofing)
// - re-emits `bbk-cart:updated` {count, total} after every render so the host
// (e.g. the header bag icon) can react
// - dialog focus handling: focus moves into the panel on open, Tab is kept
// inside it, and focus returns to whatever opened it on close
//
// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
// attribute on the root, nothing here touches styles or class lists.
export default class extends Controller {
static targets = ['panel', 'body', 'error']
static targets = ['panel', 'body', 'error', 'heading', 'status']
connect() {
this.onChanged = this.onChanged.bind(this)
@@ -40,19 +42,31 @@ export default class extends Controller {
}
onKeydown(event) {
if (event.key === 'Escape' && !this.element.hidden) this.close()
// Only the drawer instance is a dialog — the checkout page's summary
// reuses this controller without a panel.
if (!this.hasPanelTarget || this.element.hidden) return
if (event.key === 'Escape') this.close()
if (event.key === 'Tab') this.trapFocus(event)
}
open() {
if (!this.element.hidden) return
this.returnFocusTo = document.activeElement
this.element.hidden = false
// Next frame, so the panel transitions from its off-canvas start.
requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open'))
requestAnimationFrame(() => {
this.element.setAttribute('data-bbk-cart-state', 'open')
if (this.hasHeadingTarget) this.headingTarget.focus({ preventScroll: true })
})
}
close() {
this.element.removeAttribute('data-bbk-cart-state')
if (this.returnFocusTo?.isConnected) this.returnFocusTo.focus({ preventScroll: true })
this.returnFocusTo = null
const panel = this.panelTarget
const done = () => {
this.element.hidden = true
@@ -132,6 +146,28 @@ export default class extends Controller {
}
}
// aria-modal hides the page from screen readers but doesn't stop Tab from
// walking out of the panel into it — wrap at either end instead.
trapFocus(event) {
const focusable = [...this.panelTarget.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
)].filter((el) => !el.closest('[hidden], [aria-hidden="true"]'))
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
const active = document.activeElement
if (event.shiftKey && (active === first || !this.panelTarget.contains(active) || (this.hasHeadingTarget && active === this.headingTarget))) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && (active === last || !this.panelTarget.contains(active))) {
event.preventDefault()
first.focus()
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
@@ -144,10 +180,53 @@ export default class extends Controller {
}
replaceBody(html) {
const restore = this.focusSnapshot()
this.bodyTarget.innerHTML = html
restore()
this.announce()
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
}
// Swapping the body destroys whatever control had focus (a qty stepper,
// a remove button, the coupon field), dropping keyboard/screen-reader
// users back at the top of the document. Returns a callback that, after
// the swap, re-focuses the equivalent control in the new markup — or the
// heading, when that control is gone (e.g. its line was just removed).
focusSnapshot() {
const active = document.activeElement
if (!active || !this.bodyTarget.contains(active)) return () => {}
let selector = null
if (active.id) {
selector = `#${CSS.escape(active.id)}`
} else {
const lineId = active.closest('[data-bbk-line-id]')?.dataset.bbkLineId
const dir = active.dataset.bbkCartDirParam
const control = ['bbk-cart-qty-input', 'bbk-cart-qty-btn', 'bbk-cart-item-remove']
.find((name) => active.classList.contains(name))
if (lineId && control) {
selector = `[data-bbk-line-id="${CSS.escape(lineId)}"] .${control}`
+ (dir ? `[data-bbk-cart-dir-param="${CSS.escape(dir)}"]` : '')
}
}
return () => {
const target = selector && this.bodyTarget.querySelector(selector)
if (target) target.focus({ preventScroll: true })
else if (this.hasHeadingTarget) this.headingTarget.focus({ preventScroll: true })
}
}
// Polite "Cart updated" — cleared first so an identical message is
// re-announced on the next update.
announce() {
if (!this.hasStatusTarget) return
const message = this.statusTarget.dataset.bbkCartMessage || ''
this.statusTarget.textContent = ''
requestAnimationFrame(() => { this.statusTarget.textContent = message })
}
emitUpdated(node) {
if (!node) return
@@ -123,7 +123,7 @@ export default class extends Controller {
}
async selectShipping(event) {
this.toggleBoxNowLocker(event.target.value)
this.toggleBoxNowLocker(event.target.dataset.boxNow === 'true')
// Tracked so flush() can await it — nothing else stops "place order"
// (a separate, unrelated click) from racing ahead of this request.
@@ -136,15 +136,17 @@ export default class extends Controller {
// re-renders wholesale on every shipping-option change — so its own
// Stimulus controller reconnects fresh each time and has no memory of
// which option was previously selected. This is the one place that
// knows the newly-chosen option's identifier, so it also owns
// showing/hiding the picker.
toggleBoxNowLocker(identifier) {
// knows the newly-chosen option, so it also owns showing/hiding the
// picker. Whether an option is Box Now comes from the server
// (data-box-now, by the method's driver) — the option's value is the
// merchant-typed method code, which needn't be "box-now".
toggleBoxNowLocker(isBoxNow) {
const picker = this.shippingOptionsTarget.querySelector('#bbk-box-now-locker')
if (!picker) return
const controller = this.application.getControllerForElementAndIdentifier(picker, 'bbk-box-now-locker')
if (identifier === 'box-now') {
if (isBoxNow) {
controller?.show()
} else {
controller?.hide()
+1 -1
View File
@@ -1,5 +1,5 @@
// Single stable JS entry point for this package. A consuming app imports
// from here (vendor/boboko/core/resources/js/index.js), never from a path
// from here (`import { … } from "@boboko/core"`), never from a path
// reaching into a specific module's internals — so this file's exports can
// grow or its modules' internal layout can change without breaking every
// consumer's own entry point.
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="151" height="150" viewBox="0 0 151 150"><metadata><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><rdf:Description><dc:creator>RealFaviconGenerator</dc:creator><dc:source>https://realfavicongenerator.net</dc:source></rdf:Description></rdf:RDF></metadata><image width="151" height="150" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJcAAACWCAYAAADTwxrcAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAOdEVYdFNvZnR3YXJlAEZpZ21hnrGWYwAACYRJREFUeAHtnctuG0cWhv8qJuuRgWSAWU1nMrO28gRuP4FkzANIegJZyAXIStQqQOLA8hOY2ieR/QSmnyDMOgnSuwCJAzLrmF05p6spURSvYpNddfp8gO6UxGb9fc6p21+AoiiKoiiKoiiKoiiKoiiKoiiKoiiKoiiKRAyUxVz2d+j9TvH5o3sZlKVQcY1z2d9FjhQW9+GwS99JMBLVbbLizaCHIV6jhS4JbwDlChXXZT+l93skpkPMFtKydOnvXOD/9zpQGiwuFpXDKX2Wono4onXo40WT02jzxLVZUU3iRfbo3hkaSHPE5YvyUxLWY2wfFtnDpkUxiybAhbrD9zUJi0nof/9Cz+MUDUK+uF72D6hhX8H3/OrFoU0Ce4qGIDstcqTgBg2PLr3yj6QPXcgVV7jCGtEr6zCxApOZFr/p7wcuLIbrwEsIRp64LvsJXdVzxEEquQaTlRZ5uIF7hSEU76vg02MXwpAWubirnyA2HEVaPw4nCjni8mNZdY1jrUsCf2OIQo64Yi+O+cbwk+hikCGub/uHiDEdTuJkRS8Z4jJiGiWVFL3iF5dvjARScNiHECRErgPI4kBKzzF+cQm600t2kMu4prjF5VOiuPEhWPsAAohbXPlWVpNuH5dr5KodAxF3+BR2ijnSyIm95pKXEq/ZReTELq7oG2Amefw3TrziEjjRO0GCyIk5ckkXV/Q0Y/ePUgsqLmVjqLiUjRGvuOTvXo5+V1DskUvuvj9bWDRFTeziyiAXjVw18wPk0kPkxC0uhy5k0pOwEztucVmx4noNAcQtLt9j7EIaBi8gAAkrUUXc5WNkUnZfxy8ui3NIGpIwEGNxGb+4fOF7ARlkEJTmpexblBG9nCz3Zxni4gZxeIa4ycoULwY5E9e+YTLECtdawlwG5YiLG8bgCDFiLHvVdyAMWUtufBc+tvSYAbnIQxBkGu5+12dr8BThw9H2I6nLh2QuFmQb7hjqL07jgtelyRSXr78eImSBeWGJmOaZhdxlzhwRwhTYoBRWB8KRvYZ+JLBwJoJHB0x10ACac2rZt/12rQ6ExpDA3VGTTpNt1nmLbO7hjXm3aQPAafCkKdFqnGaeFHvZPyzNbRNsjkExJcUzBw09+7rZZ1x7kbHtZYrqaLyoRugB6oz3wuLDqPZwN6ENyk4DL/0Rsf69ClRck3j3nN3ijW2MLP596zEOf9Irl9HPB8U6/gYfkq4oiqIoiqIoiqIoihIIWxnn+hVfJNO+/y98nkHZCO6XwpB4minxwHywnW14lYqrj6c7b/GWBiDtnoFLDMyuo4+Y/wR6Dsjosa+HGHb/ic+itw7aNoWQhjTD4E8U4QHgBPPdrnlGoUeDwb3CDqGFHgkuQ8VUIq7f8VVK4jigt30S01oW3vQ3MhJbtwV7dg8nGZSpFILKcUgv2B4JJMW6GJppyHFh/osOKmItcb3B14d0mVVP/I5hOiqym5Tp7piE8Bib8eLnzbkd+nixbjS7k7jKSMVLVlJsBRXZFkQ1Ce9iP1snkq0kLq6pcuSnlPoeY8uU6fLsPXzcQcMgYaUkqueo48gWTpcGR3eJYkuLi4S1S8K6XFSgbxoHc/4+Pj5BQyBhcbSq20OCo9gJRbGV9iIsJa43eMLF+vm6xXpVcBSzsA8lp8myYOdotY9QsGhTBFt6d/hCcf2BrzkNthEYkgVWCot3jW9zrf9yrCCwueL6A0+Oqc4J1tZHqsDcz/geYZ8l2TEfLjZ9mblvkVLhfsjCYrj+ozrwFdWDCYRAUespwj+k9JCe58JtelPFxY1FUeEpIoAFRiP7zyEAilin5VBD+ORok8AO5j3ETv+9/FXdvcIVSWnsLY5GmUEx3AC0ERPUi6Xnncz68S1xlQX8zF8IFY60v+HL0NPJVIoG8j3D2Nih1DHzed8QF6fDEHuGy0LFfRSpfArHqGOAtApoXpPS+dSscUNcAmqX1M93xkMZtaJO6cRpOT11gytx8Xwh4nDjmwuNrdRnNnIX8sie73R2pt0gV+IqJ6Kjh+vFWKJXWQwfQgbHk9GrEFc5TpRCDO4AMSAjao3w68vGKMQ1RC7pIpm0TPOhk0ISpvDauML67wm7SE+KgHE/Fs8vgSS45ziWGi0vpYlxXGsRFmYPIWMDWu1QJWOp0VJKjHLgcRE0L7rLixsRLg8gEYv715/CiRQX43ciBYvM1z2/Lke45roPuQTZgFSXiL2hMVZHWhrfSiCUYK/tLUJO12szmsy2Eov5Mf6BEDHCeom3KW4e2YccKHWh4lI2i4pL2RgqLmVjcG8xg1z+RJhsxcKoRjJ+x4OoYi+UesIZQqQV8UHvy1FoiqZ/8AOEEnBUziCXK3M5SouuB6HkyDMESPniZ5AIm8qVcEEvUlwctYJ2KWRHP4mMXZd9H592IbDAZHdChE0XErHX11UMReTAS4jDhX1NrWCORq6SjFJ+d/RFuRLVdSAITonv4ZOgG6+ou5y46NUd/6IQl7TUGEFK9Ljlva6iwN68nqsRehoTegYhsH8qIsD8j24COdGrM2lteSWud/DOuYzRetOJyq9LSvSyt6/jSlzUIAM2tEXE8M0RS9QaISR6daYZ8t6YuC6dkruIFL45onQZbCFmA+FsWtRibq2KaKHFdoQRFvemE6uNON31PNgbp8DYq36Gjfgtcfk730R1oTGmw0nMhziPMD3OPQRhpuHuGzxhP9RjBA4Ja2BhP5JgulvaKbGLc4LQMeia/+DhvIfMXCxIg5BsiXOBwBlieCLFzblIL7ZosNDLkoxP1Vj0oLkrUan+emwCntjOkR/R5HQHgohAYMXzW+a4lrni4uEJixZfaFBTKZwKJQprRFHge4FlCIvessJilj7753d81Q7BIK482OARCV/sOrQRQdVgDs8olbVXOWV2pVPL2POKGvZ5fRtpzQvqFR5xREWDcD+iTRGjrht7QAI/o8He8xV/bzVxMexCSEU0F/tb60n64/DcSegrHTZJLVFsjePw/K/fERbZXxi2qWjbmEVkWVs943nPpkWrWbifiqOHOYol2BQsqmERrbpYgzuLa8QmRKaiWkwhMn7NTYUOihWJ6vrPVQQbrQ2R7+dwaQvmwap1Gae+IdxLA/eiXF+mLEGZLvep4N67g9AGxYYKh5d8rvUqxfoyVCauSVhsbL5mYBOHPMHUf26zHMPeu3g30whVDYX315AE5510plk18QrYAfX8uuaD4IY6FEVRFEVRFEVRFEVRFEVRFEVRFEVRFEVRFKXJ/A2oC/VTZ0o/1AAAAABJRU5ErkJggg=="></image></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+50 -20
View File
@@ -2,6 +2,9 @@
Order confirmation. Reached only via a session flash of the placed order id
(CheckoutController::confirmation) — not deep-linkable. $order is a
Lunar\Models\Order with lines + shipping/billing addresses eager-loaded.
$bankTransferInstructions is already-sanitized HTML from
StoreDetailsService::bankTransferInstructionsFor(), or null unless this
is a bank transfer order with instructions filled in for this locale.
--}}
@extends('layouts.app')
@@ -11,10 +14,19 @@
<div class="bbk-confirmation">
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1>
<div class="bbk-notice bbk-notice--info">
<svg class="bbk-notice-icon" aria-hidden="true" focusable="false" viewBox="0 0 20 20" width="20" height="20">
<circle cx="10" cy="10" r="8.25" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M10 9v5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="10" cy="6.25" r="1" fill="currentColor"/>
</svg>
<p class="bbk-notice-text">{{ __('checkout.page.confirmation_email_note') }}</p>
</div>
<dl class="bbk-confirmation-meta">
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt>
<dd>{{ \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order) }}</dd>
<dd>#{{ \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order) }}</dd>
</div>
@if ($order->billingAddress?->contact_email)
@@ -39,8 +51,6 @@
@endif
</dl>
<p class="bbk-checkout-note">{{ __('checkout.page.confirmation_email_note') }}</p>
{{-- Guests: logging in with the order's email attaches it to an account
(boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin),
so it shows in their history. --}}
@@ -53,21 +63,29 @@
@endif
@endguest
<div class="bbk-confirmation-body">
<div class="bbk-confirmation-lines">
<section class="bbk-confirmation-section" aria-labelledby="bbk-confirmation-summary-heading">
<h2 class="bbk-confirmation-section-heading" id="bbk-confirmation-summary-heading">
{{ __('checkout.page.order_summary_heading') }}
</h2>
<ul class="bbk-confirmation-lines">
@foreach ($order->lines->where('type', '!=', 'shipping') as $line)
<div class="bbk-confirmation-line">
<li class="bbk-confirmation-line">
<div class="bbk-cart-item-media">
{{-- alt="" — the description is right beside it. --}}
@if ($thumb = $line->purchasable?->getThumbnailImage())
<img src="{{ $thumb }}" alt="{{ $line->description }}" width="72" height="72" loading="lazy">
<img src="{{ $thumb }}" alt="" width="72" height="72" loading="lazy">
@endif
</div>
<div class="bbk-confirmation-line-detail">
<span class="bbk-confirmation-line-name">
<p class="bbk-confirmation-line-name">
{{ $line->description }}
<span class="bbk-confirmation-line-qty">&times; {{ $line->quantity }}</span>
<span class="bbk-confirmation-line-qty">
<span aria-hidden="true">&times; {{ $line->quantity }}</span>
<span class="bbk-visually-hidden">— {{ __('checkout.cart.quantity') }}: {{ $line->quantity }}</span>
</span>
</p>
@if ($line->option)
<p class="bbk-cart-item-variant">{{ $line->option }}</p>
@@ -76,9 +94,13 @@
@include('checkout::partials.line-custom-fields', ['line' => $line])
</div>
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
</div>
<p class="bbk-confirmation-line-total">
<span class="bbk-visually-hidden">{{ __('checkout.cart.total') }}:</span>
{{ $line->sub_total?->formatted() }}
</p>
</li>
@endforeach
</ul>
<div class="bbk-cart-summary">
<div class="bbk-cart-summary-row">
@@ -110,23 +132,31 @@
<span>{{ $order->total?->formatted() }}</span>
</div>
</div>
</div>
</section>
<div class="bbk-confirmation-addresses">
@if ($order->shippingAddress || $order->billingAddress)
<div class="bbk-confirmation-section bbk-confirmation-addresses">
@if ($order->shippingAddress)
<div class="bbk-confirmation-address">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
<section class="bbk-confirmation-address" aria-labelledby="bbk-confirmation-shipping-heading">
<h2 class="bbk-confirmation-address-heading" id="bbk-confirmation-shipping-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
<x-checkout::address-lines :address="$order->shippingAddress" />
</div>
</section>
@endif
@if ($order->billingAddress)
<div class="bbk-confirmation-address">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
<section class="bbk-confirmation-address" aria-labelledby="bbk-confirmation-billing-heading">
<h2 class="bbk-confirmation-address-heading" id="bbk-confirmation-billing-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
<x-checkout::address-lines :address="$order->billingAddress" />
</div>
</section>
@endif
</div>
</div>
@endif
@if ($bankTransferInstructions)
<section class="bbk-confirmation-section bbk-confirmation-bank-transfer" aria-labelledby="bbk-confirmation-bank-transfer-heading">
<h2 class="bbk-confirmation-bank-transfer-heading" id="bbk-confirmation-bank-transfer-heading">{{ __('checkout.page.confirmation_bank_transfer_heading') }}</h2>
<div class="bbk-confirmation-bank-transfer-body">{!! $bankTransferInstructions !!}</div>
</section>
@endif
</div>
@endsection
+13 -2
View File
@@ -15,7 +15,9 @@
data-bbk-cart-target="panel"
>
<header class="bbk-cart-panel-header">
<h2 class="bbk-cart-heading" id="bbk-cart-heading">{{ __('checkout.cart.title') }}</h2>
{{-- tabindex=-1: the controller moves focus here on open, and back
here when the focused line is removed from under the user. --}}
<h2 class="bbk-cart-heading" id="bbk-cart-heading" tabindex="-1" data-bbk-cart-target="heading">{{ __('checkout.cart.title') }}</h2>
<button
type="button"
class="bbk-cart-dismiss"
@@ -26,7 +28,16 @@
@include('checkout::partials.cart-error')
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite">
{{-- A short announcement after each update, rather than aria-live on
the body itself, which re-read the whole cart on every change. --}}
<p
class="bbk-visually-hidden"
role="status"
data-bbk-cart-target="status"
data-bbk-cart-message="{{ __('checkout.cart.updated') }}"
></p>
<div class="bbk-cart-panel-body" data-bbk-cart-target="body">
@include('checkout::partials.cart-body')
</div>
</aside>
+8 -2
View File
@@ -267,9 +267,15 @@
<aside class="bbk-checkout-aside">
<div class="bbk-checkout-summary" data-controller="bbk-cart">
<h2 class="bbk-checkout-summary-heading">{{ __('checkout.page.order_summary_heading') }}</h2>
<h2 class="bbk-checkout-summary-heading" tabindex="-1" data-bbk-cart-target="heading">{{ __('checkout.page.order_summary_heading') }}</h2>
@include('checkout::partials.cart-error')
<div data-bbk-cart-target="body" aria-live="polite">
<p
class="bbk-visually-hidden"
role="status"
data-bbk-cart-target="status"
data-bbk-cart-message="{{ __('checkout.cart.updated') }}"
></p>
<div data-bbk-cart-target="body">
@include('checkout::partials.cart-body')
</div>
</div>
@@ -24,32 +24,60 @@
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
<div class="bbk-cart-item-media">
@if ($thumb)
{{-- Decorative duplicate of the title link below — hidden from AT
and skipped by keyboard so the product isn't announced twice. --}}
@if ($productUrl)
<a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1">
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
<img src="{{ $thumb }}" alt="" width="72" height="72" loading="lazy">
</a>
@else
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
<img src="{{ $thumb }}" alt="" width="72" height="72" loading="lazy">
@endif
@endif
</div>
<div class="bbk-cart-item-detail">
<div class="bbk-cart-item-head">
@if ($productUrl)
<a href="{{ $productUrl }}" class="bbk-cart-item-title">{{ $name }}</a>
<a href="{{ $productUrl }}" class="bbk-cart-item-title" id="bbk-cart-item-title-{{ $line->id }}">{{ $name }}</a>
@else
<p class="bbk-cart-item-title">{{ $name }}</p>
<p class="bbk-cart-item-title" id="bbk-cart-item-title-{{ $line->id }}">{{ $name }}</p>
@endif
<form
class="bbk-cart-item-remove-form"
method="POST"
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
{{-- Named "Remove", described by the product title, so AT
hears which line it removes rather than a bare "Remove". --}}
<button
type="submit"
class="bbk-cart-item-remove"
aria-label="{{ __('checkout.cart.remove') }}"
aria-describedby="bbk-cart-item-title-{{ $line->id }}"
><span aria-hidden="true">&times;</span></button>
</form>
</div>
@if ($variantLabel)
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
@endif
@include('checkout::partials.line-custom-fields', ['line' => $line])
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
<div class="bbk-cart-item-foot">
{{-- role=group + the title as its name: entering the stepper
announces which product's quantity is being changed. --}}
<form
class="bbk-cart-qty"
method="POST"
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
role="group"
aria-labelledby="bbk-cart-item-title-{{ $line->id }}"
>
@csrf
@method('PATCH')
@@ -59,7 +87,7 @@
data-action="bbk-cart#step"
data-bbk-cart-dir-param="-1"
aria-label="{{ __('checkout.cart.decrease') }}"
>&minus;</button>
><span aria-hidden="true">&minus;</span></button>
<input
type="number"
@@ -79,25 +107,13 @@
data-action="bbk-cart#step"
data-bbk-cart-dir-param="1"
aria-label="{{ __('checkout.cart.increase') }}"
>+</button>
><span aria-hidden="true">+</span></button>
</form>
<p class="bbk-cart-item-total">
<span class="bbk-visually-hidden">{{ __('checkout.cart.total') }}:</span>
{{ $line->subTotal?->formatted() }}
</p>
</div>
<div class="bbk-cart-item-aside">
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p>
<form
method="POST"
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
<button
type="submit"
class="bbk-cart-item-remove"
aria-label="{{ __('checkout.cart.remove') }}"
>&times;</button>
</form>
</div>
</li>
@@ -8,6 +8,8 @@
$shippingAddress, $shippingOptions come from the controller / page scope.
--}}
@php($selected = $shippingAddress?->shipping_option)
{{-- Box Now by the method's driver, not its (merchant-typed) code --}}
@php($boxNowCodes = \Modules\Core\Shipping\Support\BoxNowShipping::codes())
{{-- Rate resolution needs country (always Greece here) + postcode; until a
postcode is saved there's nothing to quote against yet. --}}
@@ -34,6 +36,7 @@
type="radio"
name="shipping_option"
value="{{ $option->identifier }}"
data-box-now="{{ in_array($option->identifier, $boxNowCodes, true) ? 'true' : 'false' }}"
@checked($selected === $option->identifier)
data-action="change->bbk-checkout-form#selectShipping"
>
@@ -73,7 +76,7 @@
data-bbk-box-now-locker-select-label-value="{{ __('checkout.page.box_now_locker_select') }}"
data-bbk-box-now-locker-selected-label-value="{{ __('checkout.page.box_now_locker_selected') }}"
data-bbk-box-now-locker-no-results-value="{{ __('checkout.page.box_now_locker_no_results') }}"
@if ($selected !== 'box-now') hidden @endif
@if (! in_array($selected, $boxNowCodes, true)) hidden @endif
>
<p class="bbk-checkout-box-now-locker-label">
{{ __('checkout.page.box_now_locker_label') }}
@@ -0,0 +1,113 @@
{{--
Copy of lunarphp/lunar's resources/views/livewire/components/activity-log-feed.blade.php
(the order page's Timeline), overriding it via View::prependNamespace('lunarpanel')
in CoreServiceProvider. The only changes: the day heading and each entry's time are
shown in core.display_timezone instead of UTC (Lunar formats them by hand, so
Filament's display timezone doesn't reach them). Re-copy it if Lunar changes the
original. Lunar still groups entries by UTC day, so an entry just after local
midnight can sit under the previous day's heading.
--}}
<div class="px-2 pb-4 scroll-mt-32" id="lunar-panel-timeline">
<div class="relative flex items-end gap-4 mt-4">
<div class="shrink-0">
<div>
<img src="{{ $this->userAvatar }}"
class="inline-block w-8 h-8 rounded-full" />
</div>
</div>
<form class="w-full"
wire:submit.prevent="addComment">
{{ $this->form }}
<div class="absolute right-0 mt-2">
{{ $this->addCommentAction }}
</div>
</form>
</div>
<div class="relative pt-8 -ml-[5px] z-10 pointer-events-none">
<span class="absolute inset-y-0 left-5 w-[2px] bg-gray-200 dark:bg-gray-600 rounded-full"></span>
<div class="flow-root">
<ul class="-my-8 divide-y-2 divide-gray-200 dark:divide-gray-600"
role="list">
@foreach ($this->activityLog as $log)
<li class="relative py-8 ml-5">
<p class="ml-8 font-bold text-gray-950 dark:text-gray-300">
{{ $log['items']->first()['log']->created_at->inDisplayTimezone()->format('F jS, Y') }}
</p>
<ul class="mt-4 space-y-6 pointer-events-auto">
@foreach ($log['items'] as $item)
@php
$logUserName = $item['log']->causer ? ($item['log']->causer->fullName ?: $item['log']->causer->name) : null;
@endphp
<li class="relative pl-8">
<div @class([
'absolute top-[2px]',
'-left-[calc(0.75rem_-_1px)]' => $item['log']->causer,
'-left-[calc(0.5rem_-_1px)]' => !$item['log']->causer,
])>
@if ($email = $item['log']->causer?->email)
<img
src="{{ $this->getAvatarUrl($email) }}"
class="w-6 h-6 rounded-full ring-4 ring-gray-200 dark:ring-gray-600"
alt="{{ $logUserName }}"
/>
@else
<span @class([
'absolute w-4 h-4 rounded-full ring-4',
match($item['log']->description){
'created' => 'bg-sky-500 ring-sky-100 dark:ring-sky-800',
'updated' => 'bg-teal-500 ring-teal-100 dark:ring-teal-800',
'status-update' => 'bg-purple-500 ring-purple-100 dark:ring-purple-800',
default => 'bg-gray-300 ring-gray-200 dark:ring-gray-600',
},
])>
</span>
@endif
</div>
<div @class([
'flex justify-between',
'pt-[5px]' => $item['log']->causer,
'pt-[1px]' => !$item['log']->causer,
])>
<div>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
@if (!$item['log']->causer)
{{ __('lunarpanel::components.activity-log.system') }}
@else
{{ $logUserName }}
@endif
</div>
@if (count($item['renderers']))
<div class="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">
@foreach ($item['renderers'] as $class)
{{ $class->render($item['log']) }}
@endforeach
</div>
@endif
</div>
<time class="flex-shrink-0 ml-4 text-xs mt-0.5 text-gray-500 dark:text-gray-400 font-medium">
{{ $item['log']->created_at->inDisplayTimezone()->format('h:ia') }}
</time>
</div>
</li>
@endforeach
</ul>
</li>
@endforeach
</ul>
</div>
</div>
<div class="pt-4">
{{ $this->activityLog->links('lunarpanel::components.activity-log.timeline-paginator.index', data: ['scrollTo' => '#lunar-panel-timeline']) }}
</div>
</div>
@@ -77,7 +77,7 @@
class="w-4"
/>
</div>
<span>{{ $transaction->created_at->format('jS F Y h:ia') }}</span>
<span>{{ $transaction->created_at->inDisplayTimezone()->format('jS F Y h:ia') }}</span>
</div>
<div class="flex space-x-2">
@@ -1,3 +1,12 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is on its way.</p>
@foreach ($shipments ?? [] as $shipment)
<p>
{{ $shipment->carrierLabel() }}: <strong>{{ $shipment->tracking_reference }}</strong>
@if ($url = $shipment->trackingUrl())
— <a href="{{ $url }}">Track your parcel</a>
@endif
</p>
@endforeach
@@ -0,0 +1,13 @@
<div class="flex flex-col gap-1">
<div class="flex flex-wrap items-center gap-2">
@svg('heroicon-m-truck', ['class' => 'w-4 text-gray-500'])
<span>{{ $carrier }} {{ $trackingReference }}@if ($isReturn) (return)@endif</span>
<x-filament::badge :color="$statusColor">{{ $statusLabel }}</x-filament::badge>
</div>
@if ($details || $occurredAt)
<div class="text-sm text-gray-500">
{{ $details }}@if ($details && $occurredAt) · @endif{{ $occurredAt?->format('Y-m-d H:i') }}
</div>
@endif
</div>
@@ -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>
@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="el">
<head>
<meta charset="utf-8">
<style>
{{--
Geometry is transcribed directly from ELTA_PEL.sydetaE.rdlc
(the "delivery copy" RDLC ELTA's own client selects for
printer_size==1, i.e. plain A4, per Sydeta.cs::print_vg()) —
every .mc-*/.ps-* absolute position below is that report's
own Top/Left in cm, walked through its Rectangle/Textbox
nesting so each value is already relative to its own
containing band. Static label text and font sizes/weights
also come from the RDLC's own Textbox/Style elements, not
guessed from the screenshot in ELTA's setup manual (that
manual image was used only as a sanity check afterwards).
--}}
@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; }
.page { position: relative; width: 21cm; height: 29.7cm; }
/* Each RDLC "band" (one ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ copy) is its own
positioning context, so every child offset below is a direct
transcription of the RDLC's own relative-to-band Top/Left —
no manual offset arithmetic. */
.band { position: absolute; left: 0.27cm; }
.band > * { position: absolute; }
.mc-logo, .mc-company, .mc-box, .mc-cell, .mc-partybox { position: absolute; }
.mc-logo img { width: 100%; height: 100%; object-fit: contain; }
.mc-company { font-size: 4.6pt; line-height: 1.25; }
.mc-company-name { font-weight: bold; font-size: 5.2pt; }
.mc-text.mc-small { font-size: 4.6pt; }
.mc-box { border: 1px solid #000; box-sizing: border-box; }
.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 img { width: 100%; height: 22px; }
.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; line-height: 1; }
.mc-cell { border: 1px solid #000; box-sizing: border-box; padding: 1px 2px; }
.mc-cell-label { font-size: 5.5pt; }
.mc-cell-val { font-weight: bold; font-size: 8pt; margin-top: 3px; }
.mc-cell-service { text-align: center; }
/* 5-cell deposit/destination/weight/pieces/volumetric row: a real
table with border-collapse so each internal shared edge is
drawn once, not twice (see _main-copy.blade.php comment). */
.mc-cell-row { position: absolute; border-collapse: collapse; table-layout: fixed; }
.mc-cell-td { border: 1px solid #000; box-sizing: border-box; padding: 1px 2px; vertical-align: top; }
.mc-partybox { border: 1px solid #000; box-sizing: border-box; }
.mc-party-title { position: absolute; font-weight: bold; font-size: 9pt; }
.mc-party-line { position: absolute; font-size: 6.3pt; white-space: nowrap; overflow: hidden; }
.mc-charge { text-align: center; font-weight: bold; font-size: 8pt; box-sizing: border-box; padding-top: 2px; }
/* Payment stub (copy 3) reuses the .mc-* classes but at smaller
scale, matching the RDLC's own narrower band. */
.ps-brandbox { border: 1.5px solid #c8102e; box-sizing: border-box; text-align: center; padding-top: 4px; }
.ps-brand-logo img { width: 60%; margin: 0 auto 8px; display: block; }
.ps-brand-tag { color: #c8102e; font-weight: bold; font-size: 9pt; }
.ps-brandbox-small { border: 1px solid #000; box-sizing: border-box; text-align: center; padding: 2px; }
.ps-brand-logo-sm img { width: 40%; margin: 2px auto; display: block; }
.ps-legal-sm { font-size: 4.6pt; line-height: 1.3; }
/* Perforated tear-off strip on the right of copy 1 only, with
the rotated "3. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΚΑΤΑΘΕΣΗΣ" label — RDLC's
image10 (x=15.42cm) is the dashed perforation line itself;
reproduced here as a CSS dashed border since we can't extract
that embedded bitmap. transform:rotate() (not
writing-mode:vertical-rl, confirmed broken in dompdf) rotates
a normal-flow block. */
.stub-strip { position: absolute; top: 2.25cm; left: 16.05cm; width: 4.90cm; height: 6.16cm; border-left: 1px dashed #000; }
.stub-vtext {
position: absolute; top: 2.6cm; left: -1.3cm; width: 6cm;
text-align: center; font-size: 6pt; font-weight: bold;
white-space: nowrap; transform: rotate(-90deg);
}
.cut-line { position: absolute; left: 0; width: 20.9cm; border-top: 1px dashed #000; text-align: center; font-weight: bold; font-size: 7pt; }
.cut-line span { position: relative; top: -5px; background: #fff; padding: 0 6px; }
.footer-ocr { position: absolute; top: 27.35cm; left: 0; width: 21cm; text-align: center; font-family: monospace; font-size: 8pt; letter-spacing: 1.5px; }
</style>
</head>
<body>
<div class="page">
{{-- Copy 1: delivery copy — RDLC band top=0 --}}
<div class="band" style="top:0.05cm; width:15.75cm; height:8.7cm;">
@include('core::shipping.carriers.elta.partials._main-copy', ['copyType' => 'delivery'])
</div>
<div class="stub-strip">
<div class="stub-vtext">3. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΚΑΤΑΘΕΣΗΣ</div>
</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 --}}
<div class="band" style="top:9.30cm; width:15.75cm; height:9.2cm;">
@include('core::shipping.carriers.elta.partials._main-copy', ['copyType' => 'sender'])
</div>
<div class="cut-line" style="top:18.75cm;"><span>✂ ΑΠΟΚΟΨΤΕ ΕΔΩ</span></div>
{{-- Copy 3: ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ / ΜΕΤΑΒΙΒΑΣΗ stub — RDLC band top≈19.22cm --}}
<div style="position:absolute; top:19.05cm; left:0.27cm; width:20.4cm; font-weight:bold; font-size:7pt; text-align:center;">
ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ / ΜΕΤΑΒΙΒΑΣΗ &nbsp; Ο. Αριθμός Λογ/μού Ταχυπληρωμής {{ $siimvasi }}
</div>
<div class="band" style="top:19.45cm; width:16.75cm; height:7.4cm;">
@include('core::shipping.carriers.elta.partials._payment-stub')
</div>
<div class="cut-line" style="top:26.85cm;"><span>ΜΗ ΣΗΜΕΙΩΝΕΤΕ ΚΑΤΩ ΑΠΟ ΑΥΤΗ ΤΗ ΓΡΑΜΜΗ</span></div>
{{-- antik_ocr: ELTA's OCR line as issued (it already has its own > < markers) --}}
<div class="footer-ocr">{{ $ocr_line }}</div>
</div>
</body>
</html>
@@ -0,0 +1,244 @@
<!DOCTYPE html>
<html lang="el">
<head>
<meta charset="utf-8">
<style>
{{--
Geometry transcribed directly from ELTA_PEL.SydetaLabelE.rdlc
— the RDLC ELTA's own client selects for printer_size==2
(the A6/"thermal label" printer-setup radio button), per
Sydeta.cs::print_vg(); RDLCPrinter.cs overrides the actual
print DeviceInfo to 10.4cm x 14.8cm for this printer_size
regardless of what PageWidth/PageHeight the RDLC itself
declares (all ELTA RDLCs declare A4 internally). Every
.a6-* absolute position below is that report's own
Top/Left in cm.
--}}
@page { margin: 0; size: 10.4cm 14.8cm; }
html, body { margin: 0; padding: 0; }
body { font-family: 'DejaVu Sans', sans-serif; font-size: 6.3pt; color: #000; }
{{--
height is deliberately a hair under the true 14.8cm page —
dompdf's border-box math isn't quite exact at this scale, and
a .page box sized to exactly fill the page (even with
box-sizing:border-box) was empirically confirmed to overflow
a fraction of a point past the page canvas and silently push
a second, blank page (reproduced with an otherwise-empty
.page div: only the border's presence, not any content,
triggered it — 14.7cm was stable, 14.75cm was not).
--}}
.page { position: relative; width: 10.4cm; height: 14.65cm; border: 1.5px solid #000; box-sizing: border-box; overflow: hidden; }
.page > * { position: absolute; box-sizing: border-box; }
.a6-logo img { width: 100%; height: 100%; object-fit: contain; }
.a6-service-box { border: 1px solid #000; text-align: center; }
.a6-service-code { font-weight: bold; font-size: 7pt; }
.a6-service-name { font-weight: bold; font-size: 6pt; text-align: center; }
.a6-title { font-weight: bold; font-size: 7pt; }
.a6-datetime { font-size: 6pt; }
.a6-copy { font-weight: bold; font-size: 7pt; text-align: right; }
.a6-station-box { border: 1px solid #000; }
.a6-station-label { font-size: 6pt; font-weight: bold; }
.a6-station-val { font-weight: bold; font-size: 8pt; }
.a6-cell { border: 1px solid #000; padding: 1px 3px; }
.a6-cell-label { font-size: 6pt; }
.a6-cell-val { font-weight: bold; font-size: 8pt; }
/* ΧΡΕΩΣΗ/REFERENCE/ΣΥΜΒΑΣΗ row and the Γρ.Κατάθεσης/ΒΑΡΟΣ/
ΟΓΚΟΜΕΤΡΙΚΟ/Τεμάχια row: real tables with border-collapse so
each internal shared edge is drawn once (see label-a6.blade.php
comment above their markup). */
.a6-cell-row { position: absolute; border-collapse: collapse; border-spacing: 0; table-layout: fixed; }
.a6-cell-td { border: 1px solid #000; box-sizing: border-box; padding: 1px 3px; vertical-align: top; overflow: hidden; }
.a6-box { border: 1px solid #000; padding: 2px 3px; }
.a6-party-title { font-weight: bold; font-size: 7pt; }
.a6-party-line { font-size: 8pt; white-space: nowrap; overflow: hidden; }
.a6-return-title { font-weight: bold; font-size: 6pt; }
.a6-return-item { font-size: 6pt; }
.a6-checkbox { border: 1px solid #000; display: inline-block; width: 6px; height: 6px; margin-right: 3px; vertical-align: middle; }
.a6-small-title { font-weight: bold; font-size: 5pt; }
.a6-small-val { font-size: 6pt; }
.a6-legal { font-size: 5pt; text-align: center; line-height: 1.15; }
.a6-barcode { text-align: center; }
.a6-barcode img { height: 24px; }
.a6-voucher { text-align: center; font-weight: bold; font-size: 12pt; letter-spacing: 1px; }
</style>
</head>
<body>
<div class="page">
{{-- Header: logo (0.10,0.12,1.66x1.26) + service box (6.42,0.10,3.93x0.84) --}}
<div class="a6-logo" style="top:0.12cm;left:0.10cm;width:1.66cm;height:1.26cm;">
<img src="{{ $eltaLogo }}" alt="ELTA Courier">
</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-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>
<div class="a6-licence-box" style="top:1.05cm;left:6.42cm;width:3.88cm;height:0.62cm;border:1px solid #000;text-align:center;font-size:5.3pt;font-weight:bold;line-height:1.3;padding-top:2px;">
ΕΕΤΤ ΑΜ: 99-150 Γενική Άδεια<br>Ταχ/κων Υπηρεσιών
</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>
{{-- textbox15 (copy): "ΠΟΛΛΑΠΛH" on the extra parcels of a multi-parcel send --}}
@if ($copy)
<div class="a6-title" style="top:1.84cm;left:7.95cm;width:2.21cm;">{{ $copy }}</div>
@endif
{{-- The RDLC's own barcode textbox here uses the "Free 3 of 9
Extended" barcode font — we don't have that font, so this
would-be *{voucher}* fallback text is dropped in favor of the
real Code 128 barcode image rendered near the bottom instead. --}}
<div class="a6-datetime" style="top:2.00cm;left:0.16cm;width:1.60cm;">{{ $time }}</div>
<div class="a6-station-box" style="top:2.33cm;left:0.10cm;width:10.20cm;height:0.44cm;">
<div class="a6-station-label" style="position:absolute;top:0.05cm;left:0.10cm;">Γρ. Επίδοσης :</div>
<div class="a6-station-val" style="position:absolute;top:0.03cm;left:2.50cm;">{{ $station_pros }}</div>
<div class="a6-station-val" style="position:absolute;top:0.03cm;left:3.90cm;width:6.06cm;">{{ $station_pros_title }}</div>
</div>
{{-- ΧΡΕΩΣΗ / REFERENCE / ΣΥΜΒΑΣΗ 3-cell row, and the Γρ.Κατάθεσης /
ΒΑΡΟΣ / ΟΓΚΟΜΕΤΡΙΚΟ ΒΑΡΟΣ / Τεμάχια row right below it: both are
genuinely tabular single rows of fixed-width cells, so — like
the equivalent A4 rows — they're built as real
<table border-collapse:collapse> instead of independently-
bordered absolute divs, which was drawing every shared edge
(each cell-to-cell seam, and the seam between these two rows)
twice, visibly doubling/thickening those lines versus a real
printed ELTA label. Each table is positioned at the row's own
RDLC top/left; the first table's bottom border is dropped since
the second table's top border already draws that shared line. --}}
<table class="a6-cell-row" style="top:2.82cm;left:0.10cm;width:10.16cm;">
<colgroup>
<col style="width:5.41cm;"><col style="width:2.84cm;"><col style="width:1.91cm;">
</colgroup>
<tr>
<td class="a6-cell-td" style="height:0.47cm;border-bottom:none;">
<div style="font-size:6pt;white-space:nowrap;">{{ $xreosi }}</div>
</td>
<td class="a6-cell-td" style="height:0.47cm;text-align:center;border-bottom:none;">
<div style="font-size:7pt;">{{ $ocr_reference }}</div>
</td>
<td class="a6-cell-td" style="height:0.47cm;text-align:center;border-bottom:none;">
<div style="font-size:7pt;">{{ $siimvasi }}</div>
</td>
</tr>
</table>
<table class="a6-cell-row" style="top:3.39cm;left:0.10cm;width:9.97cm;">
<colgroup>
<col style="width:2.00cm;"><col style="width:2.00cm;"><col style="width:4.12cm;"><col style="width:1.85cm;">
</colgroup>
<tr>
<td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">Γρ.Κατάθεσης:</div>
<div class="a6-cell-val">{{ $station_apo }}</div>
</td>
<td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">ΒΑΡΟΣ(Kgr)</div>
<div class="a6-cell-val">{{ $weight }}</div>
</td>
<td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">ΟΓΚΟΜΕΤΡΙΚΟ ΒΑΡΟΣ(Kgr)</div>
<div class="a6-cell-val" style="font-size:6.5pt;white-space:nowrap;">{{ $volumetric_weight }}</div>
</td>
<td class="a6-cell-td" style="height:0.97cm;">
<div class="a6-cell-label">Τεμάχια</div>
<div class="a6-cell-val">{{ $package_label }}</div>
</td>
</tr>
</table>
{{-- ΑΠΟΣΤΟΛΕΑΣ + ΑΙΤΙΑ ΕΠΙΣΤΡΟΦΗΣ --}}
<div class="a6-box" style="top:4.43cm;left:0.10cm;width:7.24cm;height:2.44cm;">
@include('core::shipping.carriers.elta.partials._party-box', [
'title' => 'ΑΠΟΣΤΟΛΕΑΣ',
'lines' => $sender_lines,
])
</div>
<div class="a6-box" style="top:4.43cm;left:7.55cm;width:2.80cm;height:2.44cm;">
<div class="a6-return-title">ΑΙΤΙΑ ΕΠΙΣΤΡΟΦΗΣ</div>
<div style="margin-top:4px;">
<div class="a6-return-item"><span class="a6-checkbox"></span>Άγνωστος</div>
<div class="a6-return-item" style="margin-top:4px;"><span class="a6-checkbox"></span>Ελλειπή Δ/νση</div>
<div class="a6-return-item" style="margin-top:4px;"><span class="a6-checkbox"></span>Απαράδεκτο</div>
<div class="a6-return-item" style="margin-top:4px;"><span class="a6-checkbox"></span>Άλλαξε Δ/νση</div>
<div class="a6-return-item" style="margin-top:4px;"><span class="a6-checkbox"></span>Αζήτητο</div>
</div>
</div>
{{-- ΠΑΡΑΛΗΠΤΗΣ + ΑΝΤΙΚΑΤΑΒΟΛΗ column --}}
<div class="a6-box" style="top:6.97cm;left:0.10cm;width:7.26cm;height:2.78cm;">
@include('core::shipping.carriers.elta.partials._party-box', [
'title' => 'ΠΑΡΑΛΗΠΤΗΣ',
'lines' => $recipient_lines,
])
</div>
{{-- antik_1..7 (textbox51/21/22/24/56/59/61): "ΑΝΤΙΚΑΤΑΒΟΛΗ 17.00",
"* ΑΝΑΛΥΣΗ *", "17.00 MΕΤΡΗΤΑ", one line every 0.37cm. --}}
<div class="a6-box" style="top:6.97cm;left:7.48cm;width:2.85cm;height:2.79cm;">
@foreach ($antik_lines as $i => $line)
<div style="position:absolute;top:{{ 0.16 + $i * 0.37 }}cm;left:0.10cm;width:2.62cm;font-size:6pt;">{{ $line }}</div>
@endforeach
</div>
{{-- REFERENCE / ΕΠΙΒΑΡΥΝΣΕΙΣ --}}
<div class="a6-box" style="top:9.84cm;left:0.10cm;width:5.81cm;height:0.69cm;">
<div class="a6-small-title">REFERENCE</div>
<div class="a6-small-val" style="margin-top:3px; font-weight:bold;">{{ $order_reference }}</div>
</div>
<div class="a6-box" style="top:9.84cm;left:6.00cm;width:4.37cm;height:1.56cm;">
<div class="a6-small-title">* ΕΠΙΒΑΡΥΝΣΕΙΣ *</div>
@foreach ([$sur_1 ?? null, $sur_2 ?? null, $sur_3 ?? null, $sur_4 ?? null] as $sur)
@if (!empty($sur))
<div style="font-size:6pt; margin-top:2px;">{{ $sur }}</div>
@endif
@endforeach
</div>
{{-- ΠΑΡΑΤΗΡΗΣΕΙΣ --}}
<div class="a6-box" style="top:10.60cm;left:0.10cm;width:5.82cm;height:0.80cm;">
<div class="a6-small-title">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div>
{{-- sxolia_1/2 (textbox49/50) --}}
@foreach (array_slice($sxolia, 0, 2) as $i => $line)
<div style="position:absolute;top:{{ 0.24 + $i * 0.26 }}cm;left:0.10cm;width:5.62cm;font-size:6pt;line-height:1;white-space:nowrap;overflow:hidden;">{{ $line }}</div>
@endforeach
</div>
@if ($multiPiece)
<div class="a6-box" style="top:11.58cm;left:0.10cm;width:10.23cm;height:0.30cm;text-align:center;">
<div style="font-weight:bold; font-size:8pt; line-height:1;">{{ $polaplo }}</div>
</div>
@endif
<div class="a6-box" style="top:11.98cm;left:0.10cm;width:10.23cm;height:0.52cm;">
<div class="a6-legal">
Ισχύουν οι Γενικοί Οροι Παραχής Υπηρεσιών οι οποίοι βρίσκονται αναρτημένοι στο www.elta-courier.gr<br>
και είναι διαθέσιμοι σε ολα τα καταστήματα της εταιρίας.
</div>
</div>
@if ($antik_1 ?? null)
<div class="a6-box" style="top:12.60cm;left:0.10cm;width:10.23cm;height:0.40cm;text-align:center;">
<div style="font-weight:bold; font-size:9pt; line-height:1;">{{ $antik_1 }}</div>
</div>
@endif
<div class="a6-barcode" style="top:13.18cm;left:0.10cm;width:10.27cm;">
<img src="{{ $barcode_voucher }}" alt="">
</div>
<div class="a6-voucher" style="top:14.02cm;left:0.10cm;width:10.30cm;">{{ $voucher_no }}</div>
</div>
</body>
</html>
@@ -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>
@@ -0,0 +1,173 @@
{{--
One "ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ" copy (delivery or sender's),
geometry transcribed directly from ELTA_PEL.sydetaE.rdlc's delivery-
copy band (Top/Left values there are relative to that band; here
they're absolute cm offsets from THIS partial's own 0,0, since the
caller wraps it in a `position:relative` container sized 15.75cm x
8.75cm — the same width/height as the RDLC band, read off its own
rectangle8/rectangle9/rectangle11/rectangle12 extents).
Expects: $copyType ('delivery'|'sender') and all of
EltaLabelRenderer's $data.
--}}
<div class="mc-logo" style="top:0.09cm;left:0.32cm;width:2.65cm;height:1.93cm;">
<img src="{{ $eltaLogo }}" alt="ELTA Courier">
</div>
{{-- Πλήρηστοιχεία_red1 --}}
<div class="mc-company" style="top:0.12cm;left:3.15cm;width:5.19cm;">
@include('core::shipping.carriers.elta.partials._elta-details', ['style' => 'font-size:4.4pt;line-height:1.15;'])
</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 --}}
<div class="mc-box" style="top:0.05cm;left:8.52cm;width:7.06cm;height:1.75cm;">
<div class="mc-title" style="top:0.10cm;left:0.05cm;width:6.85cm;">ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ</div>
<div class="mc-barcode" style="top:0.45cm;left:0.24cm;width:6.56cm;">
<img src="{{ $barcode_voucher }}" alt="">
</div>
{{-- The RDLC's own barcode textbox uses the "Free 3 of 9 Extended"
barcode font (literal *{voucher}* text rendered as bars by that
font) — we don't have that font, so we render a real Code 128
barcode image above instead (visually equivalent, actually
scannable), making this second plain-text *{voucher}* line
redundant with it rather than a distinct field. --}}
<div class="mc-voucher" style="top:1.20cm;left:0.00cm;width:7.04cm;">{{ $voucher_no }}</div>
</div>
{{-- The sender's copy carries the COD warning under the voucher
(sydetaE.rdlc textbox205: antik_minima), only for cash on delivery.
Its "Απόδειξη Είσπραξης" note (textbox211) sits at the top-left in the
RDLC, where our header block is — it goes in this copy's COD box
instead, under the breakdown. --}}
@if ($copyType === 'sender' && ($antik_minima || ($copy ?? null)))
{{-- …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
{{-- 5-cell deposit/destination/weight/pieces/volumetric row — a genuinely
tabular single row of fixed-width cells, so it's built as a real
<table border-collapse:collapse> rather than independently-bordered
absolute divs: that was drawing every shared internal edge twice
(once per adjacent cell), producing a visibly doubled/thickened line
that real printed ELTA labels don't show. The table is positioned via
the same absolute top/left/width the RDLC geometry gives the row as a
whole; each <td> keeps its own RDLC-derived width via <col>. --}}
<table class="mc-cell-row" style="top:2.20cm;left:0.27cm;width:8.44cm;height:0.82cm;">
<colgroup>
<col style="width:1.51cm;"><col style="width:1.67cm;"><col style="width:1.35cm;"><col style="width:1.08cm;"><col style="width:2.83cm;">
</colgroup>
<tr>
<td class="mc-cell-td">
<div class="mc-cell-label">Γρ.Κατάθεσης</div>
<div class="mc-cell-val">{{ $station_apo }}</div>
</td>
<td class="mc-cell-td">
<div class="mc-cell-label">Γρ.Προορισμού</div>
<div class="mc-cell-val">{{ $station_pros }}</div>
</td>
<td class="mc-cell-td">
<div class="mc-cell-label">Βάρος</div>
<div class="mc-cell-val">{{ $weight }}</div>
</td>
<td class="mc-cell-td">
<div class="mc-cell-label">Τεμάχια</div>
<div class="mc-cell-val">{{ $package_label }}</div>
</td>
<td class="mc-cell-td">
<div class="mc-cell-label">Ογκ/κο Βάρος</div>
<div class="mc-cell-val" style="font-size:4.7pt;white-space:nowrap;">{{ str_replace(' = ', '=', (string) $volumetric_weight) }}</div>
</td>
</tr>
</table>
<div class="mc-cell mc-cell-service" style="top:2.20cm;left:9.19cm;width:6.19cm;height:0.82cm;">
<div style="position:absolute;top:0.05cm;left:0.32cm;font-size:6.5pt;">ΥΠΗΡΕΣΙΑ :</div>
<div style="position:absolute;top:0.03cm;left:3.21cm;font-size:7.5pt;">{{ $service_code }}</div>
<div style="position:absolute;top:0.42cm;left:0.05cm;width:6.08cm;text-align:center;font-size:7.5pt;">{{ $service_name }}</div>
</div>
{{-- Sender box --}}
<div class="mc-box mc-partybox" style="top:3.12cm;left:0.27cm;width:6.24cm;height:2.53cm;">
<div class="mc-party-title" style="top:0.05cm;left:0.10cm;">ΑΠΟΣΤΟΛΕΑΣ</div>
@foreach ($sender_lines as $i => $line)
@if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.53 + $i * 0.395 }}cm;left:0.05cm;width:6.11cm;">{{ $line }}</div>
@endif
@endforeach
</div>
{{-- Recipient box --}}
<div class="mc-box mc-partybox" style="top:5.76cm;left:0.29cm;width:6.20cm;height:2.68cm;">
<div class="mc-party-title" style="top:0.05cm;left:0.05cm;">ΠΑΡΑΛΗΠΤΗΣ</div>
@foreach ($recipient_lines as $i => $line)
@if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.54 + $i * 0.445 }}cm;left:0.05cm;width:6.11cm;">{{ $line }}</div>
@endif
@endforeach
</div>
{{-- Charge banner + right-column panel stack (ΧΡΕΩΣΗ / Συν.Χρέωσης /
REFERENCE / ΠΑΡΑΤΗΡΗΣΕΙΣ, and the Πρόσθετες Υπηρεσίες column beside
them): these panels sit only a hair apart (~0.09-0.10cm, the RDLC's
own geometry, left untouched), close enough that each pair's two
independent borders read as one thick/doubled line at print
resolution. Each panel below keeps only the border sides it "owns"
on a shared seam (border-top/border-left removed where the
neighbouring panel above/left already draws that same line), so
every seam is drawn exactly once while every panel's outward-facing
sides keep their border. --}}
<div class="mc-box mc-charge" style="top:3.09cm;left:6.60cm;width:8.78cm;height:0.48cm;">{{ str_replace(' ΠΙΣΤΩΣΗ', ' ΤΡ.ΠΛΗΡ: ΠΙΣΤΩΣΗ', $xreosi) }}</div>
<div class="mc-box" style="top:3.66cm;left:6.60cm;width:4.37cm;height:0.44cm;border-top:none;">
<div style="position:absolute;top:0.05cm;left:0.05cm;font-size:7pt;font-weight:bold;">Συν.Χρέωσης (€):</div>
</div>
<div class="mc-box" style="top:3.66cm;left:11.07cm;width:4.31cm;height:2.65cm;border-top:none;">
<div style="position:absolute;top:0.04cm;left:0.05cm;font-size:6pt;">Πρόσθετες Υπηρεσίες</div>
@foreach ([$sur_1 ?? null, $sur_2 ?? null, $sur_3 ?? null, $sur_4 ?? null] as $i => $sur)
@if (!empty($sur))
<div style="position:absolute;top:{{ 0.35 + $i * 0.365 }}cm;left:0.08cm;width:4.18cm;font-size:6pt;">{{ $sur }}</div>
@endif
@endforeach
</div>
<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.31cm;left:0.05cm;font-size:6.5pt;">{{ $order_reference }}</div>
</div>
<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>
{{-- sxolia_1..3 (textbox92/95/94) --}}
@foreach ($sxolia as $i => $line)
<div style="position:absolute;top:{{ 0.30 + $i * 0.31 }}cm;left:0.10cm;width:4.18cm;font-size:7pt;line-height:1;white-space:nowrap;overflow:hidden;">{{ $line }}</div>
@endforeach
</div>
{{-- Right-hand signature / print-timestamp column, differs by copy type --}}
@if ($copyType === 'delivery')
<div class="mc-box" style="top:6.37cm;left:6.61cm;width:4.71cm;height:2.07cm;">
{{-- antik_1..6 (textbox102/108/107/106/105/104), first line bold --}}
@foreach ($antik_lines as $i => $line)
<div style="position:absolute;top:{{ 0.12 + $i * 0.31 }}cm;left:0.08cm;width:4.55cm;font-size:7pt;{{ $i === 0 ? 'font-weight:bold;' : '' }}">{{ $line }}</div>
@endforeach
</div>
<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.38cm;left:0.08cm;font-size:6pt;">ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ</div>
<div style="position:absolute;top:1.62cm;left:0.11cm;font-size:6pt;line-height:1;">{{ $date }} {{ $time }}</div>
</div>
@else
<div class="mc-box" style="top:6.37cm;left:6.61cm;width:4.71cm;height:2.07cm;">
{{-- antik_1..6 (textbox102/108/107/106/105/104), first line bold --}}
@foreach ($antik_lines as $i => $line)
<div style="position:absolute;top:{{ 0.12 + $i * 0.31 }}cm;left:0.08cm;width:4.55cm;font-size:7pt;{{ $i === 0 ? 'font-weight:bold;' : '' }}">{{ $line }}</div>
@endforeach
@if ($apodiksi)
<div style="position:absolute;top:1.62cm;left:0.08cm;width:4.55cm;font-size:7pt;">{{ $apodiksi }}</div>
@endif
</div>
<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:1.30cm;left:0.11cm;font-size:6pt;line-height:1.2;">Ημερομηνία - Ωρα Εκτύπωσης:<br>{{ $date }} {{ $time }}</div>
</div>
@endif
@@ -0,0 +1,17 @@
{{--
Sender/recipient box, matching SydetaLabelE.rdlc's rectangle10/
rectangle11 (ΑΠΟΣΤΟΛΕΑΣ/ΠΑΡΑΛΗΠΤΗΣ) layout: a bold title line, then
the RDLC's sender_1..5 / rec_1..5 fields as separate plain text
lines (ELTA's own client pre-wraps the address into these fixed-
width lines server-side, so we render each line separately rather
than reflowing the address ourselves, to match the real line
breaks).
Expects: $title, $lines (array of up to 5 strings).
--}}
<div class="pb-title">{{ $title }}</div>
@foreach ($lines as $line)
@if (trim((string) $line) !== '')
<div class="pb-line">{{ $line }}</div>
@endif
@endforeach
@@ -0,0 +1,120 @@
{{--
Copy 3: the ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ/ΜΕΤΑΒΙΒΑΣΗ payment-receipt stub —
the fields of sydetaE.rdlc's third band. The RDLC's own coordinates
don't survive dompdf's font metrics (its boxes ended up overlapping),
so the stub is laid out on its own grid instead:
left 0.00–6.00cm stations/weight/pieces, ΑΠΟΣΤΟΛΕΑΣ, ΠΑΡΑΛΗΠΤΗΣ
middle 6.20–11.00cm service, charge, Συν.Χρέωσης/remarks, ΕΠΙΒΑΡΥΝΣΕΙΣ
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.
--}}
{{-- 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="">
</div>
<div class="mc-voucher" style="top:0.78cm;left:0.00cm;width:6.66cm;">{{ $voucher_no }}</div>
</div>
{{-- Left: stations / weight / pieces (one table, so shared edges are drawn once) --}}
<table class="mc-cell-row" style="top:1.45cm;left:0.00cm;width:6.00cm;">
<colgroup>
<col style="width:1.60cm;"><col style="width:1.60cm;"><col style="width:1.40cm;"><col style="width:1.40cm;">
</colgroup>
<tr>
@foreach (['Γρ.Κατάθεσης' => $station_apo, 'Γρ.Προορισμού' => $station_pros, 'Βάρος' => $weight, 'Τεμάχια' => $package_label] as $label => $value)
<td class="mc-cell-td" style="height:0.80cm;">
<div class="mc-cell-label" style="font-size:5pt;white-space:nowrap;">{{ $label }}</div>
<div class="mc-cell-val">{{ $value }}</div>
</td>
@endforeach
</tr>
</table>
{{-- Left: ΑΠΟΣΤΟΛΕΑΣ — sender_1/sender_2 only (code + name), as the stub prints --}}
<div class="mc-box" style="top:2.40cm;left:0.00cm;width:6.00cm;height:0.95cm;">
<div class="mc-party-title" style="top:0.04cm;left:0.10cm;font-size:6.5pt;line-height:1;">ΑΠΟΣΤΟΛΕΑΣ</div>
@foreach (array_slice($sender_lines, 0, 2) as $i => $line)
@if (trim((string) $line) !== '')
<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
@endforeach
</div>
{{-- Left: ΠΑΡΑΛΗΠΤΗΣ — rec_1..3: name, address, postcode (no phone) --}}
<div class="mc-box" style="top:3.45cm;left:0.00cm;width:6.00cm;height:1.45cm;">
<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)
@if (trim((string) $line) !== '')
<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>
{{-- 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 και τους αποδέχομαι ανεπιφύλακτα
</div>
{{-- Bottom row: signatures (left), the COD breakdown antik_1..6 (middle), ΠΟΣΟ (right) --}}
<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;line-height:1.2;">ΥΠΟΓΡΑΦΗ<br>ΑΠΟΣΤΟΛΕΑ</div>
<div style="position:absolute;top:1.30cm;left:0.08cm;font-size:6pt;line-height:1;">{{ $date }}</div>
</div>
<div class="mc-box" style="top:5.30cm;left:3.00cm;width:3.00cm;height:1.65cm;">
<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.30cm;left:0.08cm;font-size:6pt;line-height:1;">Ημ/νία: &nbsp;&nbsp;&nbsp; Ώρα:</div>
</div>
<div class="mc-box" style="top:5.30cm;left:6.20cm;width:4.80cm;height:1.65cm;">
@foreach ($antik_lines as $i => $line)
<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
</div>
<div class="mc-box" style="top:5.30cm;left:11.30cm;width:2.80cm;height:1.65cm;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.70cm;left:0;width:100%;font-size:13pt;font-weight:bold;line-height:1;">{{ $antik_poso }}</div>
</div>
+13 -1
View File
@@ -11,7 +11,7 @@ class Staff extends ModelsStaff
'last_name',
'admin',
'email',
'otp_code',
'otp_code_hash',
'otp_expires_at',
];
@@ -19,6 +19,18 @@ class Staff extends ModelsStaff
'admin' => 'bool',
'email_verified_at' => 'datetime',
'password' => 'hashed',
'otp_code_hash' => 'hashed',
'otp_expires_at' => 'datetime',
];
// Overrides (doesn't merge with) Lunar\Admin\Models\Staff's own
// $hidden — repeats its password/remember_token here so this class
// doesn't silently drop that protection while adding otp_code_hash/
// otp_expires_at, which the base model has no reason to know about.
protected $hidden = [
'password',
'remember_token',
'otp_code_hash',
'otp_expires_at',
];
}
+11 -3
View File
@@ -2,6 +2,7 @@
namespace Modules\Core\Auth\Services;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Modules\Core\Auth\Mail\OtpMail;
use Modules\Core\Auth\Models\Staff;
@@ -27,7 +28,10 @@ class OtpService
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
$staff->otp_code = $code;
// otp_code_hash's 'hashed' cast (see Staff's own $casts) hashes
// this automatically on assignment, same as password — never
// stored or compared in plaintext.
$staff->otp_code_hash = $code;
$staff->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$staff->save();
@@ -44,11 +48,15 @@ class OtpService
return null;
}
if (! $staff->otp_expires_at || $staff->otp_code != $code || now()->isAfter($staff->otp_expires_at)) {
if (! $staff->otp_code_hash || ! $staff->otp_expires_at || now()->isAfter($staff->otp_expires_at)) {
return null;
}
$staff->otp_code = null;
if (! Hash::check($code, $staff->otp_code_hash)) {
return null;
}
$staff->otp_code_hash = null;
$staff->otp_expires_at = null;
$staff->save();
+17 -9
View File
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Modules\Core\Auth\Events\UserAuthenticated;
@@ -40,8 +41,11 @@ use Modules\Core\Auth\Mail\UserOtpMail;
* at all — firstOrCreate() and UserCreated only fire from validate(), and
* only once the code has actually been proven correct. An email that
* already has a User row is unaffected: its OTP state still lives on that
* row's own otp_code/otp_expires_at/otp_attempts columns exactly as
* before, so a returning shopper's login is unchanged.
* row's own otp_code_hash/otp_expires_at/otp_attempts columns exactly as
* before, so a returning shopper's login is unchanged. otp_code_hash
* holds a bcrypt hash of the code (the 'otp_code_hash' => 'hashed' cast
* on App\Models\User hashes it automatically on assignment, same as
* password), not the code itself — compared via Hash::check().
*
* Two independent throttles, both configured under core.auth.otp — see
* config/core.php's own comment for why they're separate: max_attempts
@@ -87,7 +91,7 @@ class UserOtpService
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
if ($user) {
$user->otp_code = $code;
$user->otp_code_hash = $code;
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$user->otp_attempts = 0;
$user->save();
@@ -96,8 +100,12 @@ class UserOtpService
// class's own docblock for why: creating one on every
// generateAndSend() call let anyone mint real User/Customer
// rows for an email nobody proved they owned.
//
// Hashed even in the cache (not just on the DB-backed path)
// — a code sitting in Cache::get()-able storage is the same
// exposure as a plaintext DB column if anything can read it.
Cache::put($this->pendingKey($email), [
'code' => $code,
'code_hash' => Hash::make($code),
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
'attempts' => 0,
], now()->addMinutes(self::EXPIRY_MINUTES));
@@ -154,15 +162,15 @@ class UserOtpService
return DB::transaction(function () use ($model, $email, $code) {
$user = $model::where('email', $email)->lockForUpdate()->first();
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
if (! $user || ! $user->otp_code_hash || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
return null;
}
if (! hash_equals((string) $user->otp_code, $code)) {
if (! Hash::check($code, $user->otp_code_hash)) {
$user->otp_attempts++;
if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) {
$user->otp_code = null;
$user->otp_code_hash = null;
$user->otp_expires_at = null;
$user->otp_attempts = 0;
}
@@ -172,7 +180,7 @@ class UserOtpService
return null;
}
$user->otp_code = null;
$user->otp_code_hash = null;
$user->otp_expires_at = null;
$user->otp_attempts = 0;
$user->save();
@@ -200,7 +208,7 @@ class UserOtpService
return null;
}
if (! hash_equals((string) $pending['code'], $code)) {
if (! Hash::check($code, $pending['code_hash'])) {
$pending['attempts']++;
if ($pending['attempts'] >= (int) config('core.auth.otp.max_attempts', 5)) {
@@ -60,6 +60,7 @@ class CheckoutTranslationsSeeder extends Seeder
'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'],
'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'],
'cart.remove' => ['Remove', 'Αφαίρεση'],
'cart.updated' => ['Cart updated', 'Το καλάθι ενημερώθηκε'],
'cart.subtotal' => ['Subtotal', 'Υποσύνολο'],
'cart.discount' => ['Discount', 'Έκπτωση'],
'cart.shipping' => ['Shipping', 'Μεταφορικά'],
@@ -190,6 +191,10 @@ class CheckoutTranslationsSeeder extends Seeder
'Θέλεις να παρακολουθείς την παραγγελία σου; Δημιούργησε λογαριασμό ή',
],
'page.confirmation_billing' => ['Billing', 'Χρέωση'],
'page.confirmation_bank_transfer_heading' => [
'Bank transfer details',
'Στοιχεία τραπεζικής μεταφοράς',
],
'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'],
'page.box_now_locker_label' => [
'Choose a Box Now locker',
@@ -28,6 +28,7 @@ use Modules\Core\Customer\Services\CustomerAccountService;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
use Modules\Core\Store\Services\StoreDetailsService;
/**
* The checkout page — one page, sections (contact / billing / shipping /
@@ -554,6 +555,8 @@ class CheckoutController extends Controller
return view('checkout::confirmation', [
'order' => $order,
'paymentMethodName' => $paymentMethodName,
'bankTransferInstructions' => app(StoreDetailsService::class)
->bankTransferInstructionsFor($order, $locale),
]);
}
+3 -2
View File
@@ -29,6 +29,7 @@ use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Payment\Services\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache;
use Modules\Core\Shipping\Contracts\SupportsCashCollection;
use Modules\Core\Shipping\Support\BoxNowShipping;
use Modules\Core\Shipping\Support\FulfillmentType;
/**
@@ -181,7 +182,7 @@ class CheckoutService
// switches BACK to Box Now and it resurfaces as if still chosen,
// possibly for a locker that no longer exists/fits. Cleared here,
// the one place that knows the method just changed.
if ($identifier !== 'box-now') {
if (! BoxNowShipping::isOption($identifier)) {
$address = $cart->shippingAddress;
if ($address && isset($address->meta['box_now_locker'])) {
@@ -300,7 +301,7 @@ class CheckoutService
if ($shippingMethod !== null && $method->driver === 'cash-on-delivery') {
$shippingDriver = collect(Shipping::getSupportedDrivers())->get($shippingMethod->driver);
return $shippingDriver instanceof SupportsCashCollection && $shippingDriver->collectsCash();
return $shippingDriver instanceof SupportsCashCollection && $shippingDriver->collectsCash($shippingMethod);
}
return true;
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Illuminate\Database\Seeder;
use Modules\Core\Checkout\Database\Seeders\CheckoutTranslationsSeeder;
use Modules\Core\Localization\Database\Seeders\StorefrontTranslationsSeeder;
use Modules\Core\Localization\Database\Seeders\ValidationTranslationsSeeder;
use Modules\Core\Localization\Models\LanguageLine;
use ReflectionClass;
use ReflectionMethod;
/**
* The reverse of the translation seeders: copies lines added in the Filament
* Language Lines UI (e.g. while building the storefront) into the matching
* seeder's lines(), so they ship with core and every app gets them.
*
* Only adds keys the seeder doesn't have yet — a key that already exists in
* the seeder is left alone even if its text was edited in the database.
* New lines are appended at the end of lines() under a marker comment, to be
* moved into the right section by hand.
*
* Writes into the seeder files the app actually loaded, so it only makes
* sense in local mode, where vendor/boboko/core is a symlink to the
* ../boboko-core checkout. Against an installed copy it refuses to write;
* --dry-run works anywhere.
*/
class PullTranslationsCommand extends Command
{
protected $signature = 'boboko:translations:pull {--dry-run : Show what would be added without writing}';
protected $description = 'Add translation lines that exist in the database but not in the core translation seeders';
/** @var array<string, class-string<Seeder>> */
private const SEEDERS = [
'storefront' => StorefrontTranslationsSeeder::class,
'checkout' => CheckoutTranslationsSeeder::class,
'validation' => ValidationTranslationsSeeder::class,
];
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$total = 0;
foreach (self::SEEDERS as $group => $seederClass) {
$file = realpath((new ReflectionClass($seederClass))->getFileName());
if (! $dryRun && str_contains($file, '/vendor/')) {
$this->error("{$file} is an installed copy, not your ../boboko-core checkout.");
$this->line('Switch to local mode first (bin/core-mode local), or use --dry-run.');
return self::FAILURE;
}
$known = (new ReflectionMethod($seederClass, 'lines'))->invoke(new $seederClass);
$missing = LanguageLine::query()
->where('group', $group)
->whereNotIn('key', array_keys($known))
->orderBy('key')
->get();
if ($missing->isEmpty()) {
$this->line("{$group}: nothing missing");
continue;
}
$entries = '';
foreach ($missing as $line) {
$en = $line->text['en'] ?? '';
$el = $line->text['el'] ?? '';
if ($en === '' || $el === '') {
$this->warn(" {$group}.{$line->key} has no ".($en === '' ? 'English' : 'Greek').' text — added empty, fill it in');
}
$entries .= $this->entry($line->key, $en, $el);
$this->info(" + {$group}.{$line->key}");
}
$total += $missing->count();
if (! $dryRun && ! $this->append($file, $entries)) {
return self::FAILURE;
}
}
$this->newLine();
$this->line($dryRun
? "{$total} line(s) would be added."
: "{$total} line(s) added — move them into the right section and commit core.");
return self::SUCCESS;
}
/**
* One lines() entry in the seeders' own style: single line when short,
* split over several lines when long.
*/
private function entry(string $key, string $en, string $el): string
{
[$key, $en, $el] = array_map(fn (string $value) => var_export($value, true), [$key, $en, $el]);
$single = " {$key} => [{$en}, {$el}],\n";
if (mb_strlen($single) <= 120) {
return $single;
}
return " {$key} => [\n {$en},\n {$el},\n ],\n";
}
/**
* Inserts the entries right before the closing `];` of lines(), then
* lints the file and restores the original if the result doesn't parse.
*/
private function append(string $file, string $entries): bool
{
$original = file_get_contents($file);
$method = strpos($original, 'function lines(): array');
$close = $method === false ? false : strpos($original, "\n ];\n }", $method);
if ($close === false) {
$this->error("Couldn't find the end of lines() in {$file} — add these by hand.");
return false;
}
$before = rtrim(substr($original, 0, $close));
// The last existing entry doesn't always have a trailing comma.
if (! str_ends_with($before, ',') && ! str_ends_with($before, '[')) {
$before .= ',';
}
$updated = $before
."\n\n // ── Pulled from the database (boboko:translations:pull) — move into the right section ──\n"
.$entries
.substr($original, $close + 1);
file_put_contents($file, $updated);
exec(PHP_BINARY.' -l '.escapeshellarg($file).' 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
file_put_contents($file, $original);
$this->error("Writing {$file} produced invalid PHP — restored the original:");
$this->line(implode("\n", $output));
return false;
}
return true;
}
}
+4 -1
View File
@@ -49,6 +49,7 @@ use Modules\Core\Shipping\Extensions\OrderViewExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
use Modules\Core\Shipping\Filament\Resources\ManifestResource;
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
use Modules\Core\Store\Filament\Pages\ManageStoreDetails;
@@ -65,6 +66,7 @@ class CorePlugin implements Plugin
->brandName('Boboko')
->brandLogo(asset('static/logos/core/boboko-logo.svg'))
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
->favicon(asset('static/logos/core/favicon.svg'))
->login(Login::class)
->resources([
LanguageLineResource::class,
@@ -73,6 +75,7 @@ class CorePlugin implements Plugin
CartResource::class,
PaymentMethodResource::class,
ShipmentResource::class,
CarrierVoucherResource::class,
ManifestResource::class,
])
->pages([
@@ -150,7 +153,7 @@ class CorePlugin implements Plugin
});
LunarStaff::addActivitylogExcept([
'otp_code',
'otp_code_hash',
'otp_expires_at',
'password',
'remember_token',
@@ -115,7 +115,7 @@ class CustomerDataProvider implements PersonalDataProvider
// secret tied to an identity that no longer exists here — clear
// it alongside name/email rather than leaving it to expire on
// its own 10-minute window.
'otp_code' => null,
'otp_code_hash' => null,
'otp_expires_at' => null,
'otp_attempts' => 0,
]);
@@ -112,7 +112,7 @@ class CustomerAccountService
$order = $customer
?->orders()
->whereNotNull('placed_at')
->with(['lines', 'shippingAddress', 'billingAddress', 'transactions', 'shipments'])
->with(['lines', 'shippingAddress', 'billingAddress', 'transactions', 'shipments.shipmentInfo'])
->find($orderId);
if (! $order) {
@@ -23,7 +23,7 @@ use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException;
* hash of the code, expiry, wrong-guess count) lives on the user's own
* row (see the migration adding pending_email/pending_email_code_hash/
* pending_email_expires_at/pending_email_attempts) — the same convention
* Auth\Services\UserOtpService's otp_code/otp_expires_at/otp_attempts
* Auth\Services\UserOtpService's otp_code_hash/otp_expires_at/otp_attempts
* already use — rather than the session, since a code arrives by email
* and is often opened on a different device/session than the one that
* requested it; a session-scoped pending change couldn't be confirmed
@@ -98,6 +98,14 @@ class StorefrontTranslationsSeeder extends Seeder
'pagination.previous' => ['Previous page', 'Προηγούμενη σελίδα'],
'pagination.page' => ['Page :page', 'Σελίδα :page'],
// ── Error pages ─────────────────────────────────────────────
'errors.404_title' => ['Page not found', 'Η σελίδα δεν βρέθηκε'],
'errors.404_text' => [
'The page you are looking for does not exist or has been moved.',
'Η σελίδα που αναζητάς δεν υπάρχει ή έχει μετακινηθεί.',
],
'errors.back_home' => ['Back to home', 'Επιστροφή στην αρχική'],
// ── Reviews ─────────────────────────────────────────────────
'review.rating' => ['Rating', 'Βαθμολογία'],
'review.write_label' => ['Write a review', 'Γράψε μια αξιολόγηση'],
@@ -243,6 +251,26 @@ class StorefrontTranslationsSeeder extends Seeder
'orders.payment' => ['Payment method', 'Τρόπος πληρωμής'],
'orders.shipping_method' => ['Shipping method', 'Τρόπος αποστολής'],
'orders.tracking' => ['Tracking', 'Παρακολούθηση αποστολής'],
'orders.shipment' => ['Shipment', 'Αποστολή'],
'orders.voucher_number' => ['Tracking number', 'Αριθμός αποστολής'],
'orders.shipment_status' => ['Shipment status', 'Κατάσταση αποστολής'],
'orders.tracking_history' => ['Tracking history', 'Ιστορικό αποστολής'],
'orders.tracking_empty' => [
'No updates from the courier yet.',
'Δεν υπάρχουν ακόμα ενημερώσεις από την εταιρεία courier.',
],
'orders.track_on_carrier' => ['Track on the courier\'s website', 'Παρακολούθηση στη σελίδα του courier'],
// ── Tracking statuses (Modules\Core\Shipping\Enums\TrackingStatus) ──
'tracking_status.pending' => ['Awaiting pickup', 'Αναμονή παραλαβής'],
'tracking_status.collected_from_sender' => ['Picked up by the courier', 'Παραλήφθηκε από το courier'],
'tracking_status.in_transit' => ['In transit', 'Σε μεταφορά'],
'tracking_status.out_for_delivery' => ['Out for delivery', 'Προς παράδοση'],
'tracking_status.delivered' => ['Delivered', 'Παραδόθηκε'],
'tracking_status.failed' => ['Delivery attempt failed', 'Αποτυχία παράδοσης'],
'tracking_status.returned' => ['Returned to sender', 'Επιστράφηκε στον αποστολέα'],
'tracking_status.cancelled' => ['Cancelled', 'Ακυρώθηκε'],
'tracking_status.unknown' => ['Update', 'Ενημέρωση'],
'orders.items' => ['Items', 'Προϊόντα'],
'orders.subtotal' => ['Subtotal', 'Μερικό σύνολο'],
'orders.discount' => ['Discount', 'Έκπτωση'],
+6 -10
View File
@@ -7,16 +7,12 @@ use Lunar\Models\Order;
use Modules\Core\Shipping\Models\ShipmentInfo;
/**
* Dispatched by either of the two paths that move a carrier order's
* `status` to 'dispatched' — Modules\Core\Order\Listeners\
* AdvanceFulfillmentOnCarrierCheckpoint (automatic, reacting to a real
* carrier checkpoint) or Modules\Core\Order\Services\
* OrderFulfillmentService::createShipmentAndDispatch() (staff-driven, via
* the single "Update Status" action). $shipmentInfo is nullable
* specifically because of that second path — populated with the
* triggering checkpoint when it's real, null when staff drove it
* manually. Mirrors OrderDelivered's {order, shipmentInfo} shape, just
* with the nullability this one event additionally needs.
* Fired when the carrier picks the parcel up — Modules\Core\Order\
* Listeners\AdvanceFulfillmentOnCarrierCheckpoint, on the first InTransit /
* CollectedFromSender checkpoint (synced from the carrier, or entered by
* hand for a manual carrier). Creating a shipment doesn't move the order to
* dispatched on its own. $shipmentInfo is that checkpoint; nullable for a
* caller without one. Mirrors OrderDelivered's {order, shipmentInfo} shape.
*/
class OrderDispatched
{
+1
View File
@@ -21,5 +21,6 @@ class OrderStatusUpdated
public readonly Order $order,
public readonly ?string $previousStatus,
public readonly string $newStatus,
public readonly ?string $causeClass = null,
) {}
}
@@ -46,9 +46,10 @@ class AdvanceFulfillmentOnCarrierCheckpoint implements ShouldQueue
return;
}
$order = $event->shipmentInfo->shipment->order;
$shipment = $event->shipmentInfo->shipment;
$order = $shipment->order;
if (! $order || ! $this->flow->isValidTransition($order, 'dispatched')) {
if (! $order || ! $shipment->drivesOrderStatus() || ! $this->flow->isValidTransition($order, 'dispatched')) {
return;
}
@@ -26,9 +26,10 @@ class DeriveOrderDeliveredFromShipment implements ShouldQueue
return;
}
$order = $event->shipmentInfo->shipment->order;
$shipment = $event->shipmentInfo->shipment;
$order = $shipment->order;
if (! $order) {
if (! $order || ! $shipment->drivesOrderStatus()) {
return;
}
@@ -32,9 +32,10 @@ class MarkDeliveryFailedOnCarrierCheckpoint implements ShouldQueue
return;
}
$order = $event->shipmentInfo->shipment->order;
$shipment = $event->shipmentInfo->shipment;
$order = $shipment->order;
if (! $order || ! $this->flow->isValidTransition($order, 'delivery_failed')) {
if (! $order || ! $shipment->drivesOrderStatus() || ! $this->flow->isValidTransition($order, 'delivery_failed')) {
return;
}
@@ -8,6 +8,8 @@ use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderDispatched;
use Modules\Core\Order\Support\OrderReferenceDisplay;
use Modules\Core\Shipping\Models\Shipment;
/**
* Fills a real, previously-unfilled customer-communication gap — before
@@ -53,6 +55,13 @@ class OrderDispatchedNotification extends BaseNotification
->view('core::order.notifications.dispatched', [
'order' => $order,
'reference' => $reference,
// Active, numbered, non-return shipments: the voucher numbers
// to show, plus the courier's link for manual carriers
// and typed-in vouchers (Shipment::trackingUrl()). API-created shipments' history is
// on the customer's order page.
'shipments' => $order->shipments()->get()
->reject(fn (Shipment $shipment) => $shipment->isCancelled() || $shipment->isReturn() || blank($shipment->tracking_reference))
->values(),
]);
}
}
@@ -13,11 +13,9 @@ use Modules\Core\Order\Support\OrderReferenceDisplay;
* "Your order is ready to collect" — listens to the specific
* OrderReadyForPickup event (dispatched by Modules\Core\Shipping\
* Extensions\OrderViewExtension's "Mark Ready" action, store-pickup
* branch only), not the generic OrderStatusUpdated. Modules\Core\Order\
* Notifications\OrderStatusUpdatedNotification still separately
* suppresses itself for the legacy 'ready-for-pickup' status string, kept
* defensively even though nothing writes that literal value to
* Order::status anymore after this redesign.
* branch only), not the generic OrderStatusUpdated.
* Modules\Core\Order\Notifications\OrderStatusUpdatedNotification
* suppresses itself for that same write (its OWN_EMAIL map).
*/
class OrderPickupReadyNotification extends BaseNotification
{
@@ -6,7 +6,12 @@ use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Commands\CloseExpiredReturnWindows;
use Modules\Core\Order\Events\OrderStatusUpdated;
use Modules\Core\Order\Listeners\AdvanceFulfillmentOnCarrierCheckpoint;
use Modules\Core\Order\Listeners\AdvanceFulfillmentOnDelivered;
use Modules\Core\Order\Listeners\CompleteOrderOnPickedUp;
use Modules\Core\Order\Services\OrderFulfillmentService;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Support\OrderReferenceDisplay;
@@ -25,12 +30,22 @@ class OrderStatusUpdatedNotification extends BaseNotification
}
/**
* 'ready-for-pickup' has its own, richer notification
* (Modules\Core\Order\Notifications\OrderPickupReadyNotification) —
* both listen to the same OrderStatusUpdated event via
* NotificationRegistry, so without this the customer would get two
* emails for that one transition. Returning no channels is the
* standard Laravel way to suppress a notification outright.
* Writes that send their own, richer email for the new status — the
* generic one stays out of those, or the customer gets two. The same
* status written any other way (staff's "Update Status", which fires
* none of those events) still gets the generic email.
*/
private const OWN_EMAIL = [
'ready_for_pickup' => [OrderFulfillmentService::class.'::markReady'], // OrderPickupReadyNotification
'dispatched' => [AdvanceFulfillmentOnCarrierCheckpoint::class], // OrderDispatchedNotification
'delivered' => [AdvanceFulfillmentOnDelivered::class], // OrderDeliveredNotification
'completed' => [CompleteOrderOnPickedUp::class, CloseExpiredReturnWindows::class], // OrderCompletedNotification
];
/**
* Suppressed when a dedicated notification covers this write (see
* OWN_EMAIL). Returning no channels is the standard Laravel way to
* suppress a notification outright.
*
* Also suppressed for the order's very first transition off
* 'awaiting_payment' — for every payment method except bank transfer,
@@ -50,7 +65,7 @@ class OrderStatusUpdatedNotification extends BaseNotification
*/
public function via(object $notifiable): array
{
if ($this->event->newStatus === 'ready-for-pickup') {
if (in_array($this->event->causeClass, self::OWN_EMAIL[$this->event->newStatus] ?? [], true)) {
return [];
}
+2
View File
@@ -4,6 +4,7 @@ namespace Modules\Core\Order\Observers;
use Lunar\Models\Order;
use Modules\Core\Order\Events\OrderStatusUpdated;
use Modules\Core\Order\Services\OrderStatusWriter;
/**
* Generically dispatches OrderStatusUpdated for ANY write to `status`,
@@ -30,6 +31,7 @@ class OrderObserver
$order,
$order->getOriginal('status'),
$order->status,
OrderStatusWriter::currentCause(),
);
}
}
+73 -4
View File
@@ -12,6 +12,8 @@ use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Models\Shipment;
use Throwable;
/**
@@ -56,7 +58,15 @@ class OrderFulfillmentService
return OrderFulfillmentResult::success('Order marked ready.');
}
public function createShipmentAndDispatch(Order $order, ShipmentRequest $request): OrderFulfillmentResult
/**
* Creates the shipment only — the order stays at ready_for_dispatch
* until the carrier actually picks the parcel up. That move (and the
* "on its way" email) happens in Modules\Core\Order\Listeners\
* AdvanceFulfillmentOnCarrierCheckpoint, on the first InTransit /
* CollectedFromSender checkpoint — synced from the carrier, or entered
* by hand for a manual carrier.
*/
public function createShipment(Order $order, ShipmentRequest $request): OrderFulfillmentResult
{
if ($order->status !== 'ready_for_dispatch') {
return OrderFulfillmentResult::failure('This order is not ready to be dispatched.');
@@ -76,9 +86,54 @@ class OrderFulfillmentService
return OrderFulfillmentResult::failure('Failed to create shipment: '.$e->getMessage());
}
$this->writer->write($order, 'dispatched', self::class.'::createShipmentAndDispatch');
return OrderFulfillmentResult::success('Shipment created. The order moves to Dispatched when the carrier picks it up.');
}
return OrderFulfillmentResult::success('Shipment created and order dispatched.');
/**
* Records an integrated carrier's voucher that wasn't created through
* our API — the carrier's system was down and a pre-numbered paper
* voucher was used, or the courier wrote his own at pickup. It's still
* that carrier's voucher, so PollShipmentTrackingJob picks up its
* history once the carrier's API has it.
*
* @param array<int, ExtraService> $services
*/
public function addManualVoucher(Order $order, string $carrier, string $voucherNumber, array $services = []): OrderFulfillmentResult
{
if (! $this->canAddManualVoucher($order)) {
return OrderFulfillmentResult::failure('A voucher can only be added from Ready for Dispatch until the order is delivered.');
}
$voucherNumber = trim($voucherNumber);
if (Shipment::where('tracking_reference', $voucherNumber)->exists()) {
return OrderFulfillmentResult::failure("Voucher {$voucherNumber} is already recorded.");
}
Shipment::create([
'order_id' => $order->id,
'carrier' => $carrier,
'source' => Shipment::SOURCE_MANUAL_VOUCHER,
'tracking_reference' => $voucherNumber,
'meta' => [
'services' => array_map(fn (ExtraService $service) => $service->value, $services),
'cod_amount' => $this->flow->isCod($order) ? $order->total->decimal : null,
// Copied like ManualFulfillmentService does, so the link
// keeps working if the method is edited later.
'tracking_url' => ShippingMethod::where('driver', $carrier)->get()
->map(fn (ShippingMethod $method) => $method->data['tracking_url'] ?? null)
->filter()
->first(),
],
]);
return OrderFulfillmentResult::success("Voucher {$voucherNumber} added.");
}
public function canAddManualVoucher(Order $order): bool
{
return in_array($order->status, ['ready_for_dispatch', 'dispatched', 'delivery_failed'], true)
&& ! $order->isStorePickupOrder();
}
public function markPickedUp(Order $order): OrderFulfillmentResult
@@ -175,14 +230,28 @@ class OrderFulfillmentService
return OrderFulfillmentResult::success('Order marked as paid.');
}
/**
* A cancelled shipment doesn't block creating a new one.
*/
public function canCreateShipment(Order $order): bool
{
return $order->status === 'ready_for_dispatch'
&& ! $order->isStorePickupOrder()
&& $order->shipments()->exists() === false
&& $order->shipments()->whereNull('cancelled_at')->doesntExist()
&& $this->resolveFulfillmentService($order) !== null;
}
/**
* The order's shipping method — manual carriers keep their name,
* tracking URL template and cash-on-delivery setting in its `data`.
*/
public function shippingMethodFor(Order $order): ?ShippingMethod
{
$code = $order->shippingAddress?->shipping_option;
return $code ? ShippingMethod::where('code', $code)->first() : null;
}
/**
* Public wrapper around resolveCarrier() — Modules\Core\Shipping\
* Extensions\OrderViewExtension needs to know which carrier an order
+18
View File
@@ -29,6 +29,18 @@ use Modules\Core\Order\Events\OrderStatusChanged;
*/
class OrderStatusWriter
{
/**
* The cause of the write in progress, so OrderObserver can put it on
* the generic OrderStatusUpdated event (null for writes that don't go
* through here, e.g. tinker).
*/
private static ?string $currentCause = null;
public static function currentCause(): ?string
{
return self::$currentCause;
}
public function write(Order $order, string $to, string $causeClass): void
{
$from = $order->status;
@@ -37,7 +49,13 @@ class OrderStatusWriter
return;
}
self::$currentCause = $causeClass;
try {
$order->update(['status' => $to]);
} finally {
self::$currentCause = null;
}
OrderStatusChanged::dispatch($order, $from, $to, $causeClass);
}
@@ -39,8 +39,26 @@ class TransactionRecorder
* elsewhere in this codebase (see the old, now-removed
* TransactionRecorder this replaces).
*/
/**
* Idempotent on (order_id, type, reference): a successful payment
* outcome can legitimately be reported twice for the same gateway
* reference — e.g. Stripe's pay()/handleCallback() both call
* resultFromIntent() and both dispatch PaymentCaptured once a
* PaymentIntent reaches "succeeded" (checkout's synchronous capture,
* then the webhook confirming the same outcome asynchronously) — so
* this returns the existing row instead of writing a duplicate.
*/
public function record(Order $order, string $type, string $driver, PaymentResult $result): Transaction
{
$existing = $order->transactions()
->where('type', $type)
->where('reference', $result->reference)
->first();
if ($existing !== null) {
return $existing;
}
return $order->transactions()->create([
'success' => $result->status === PaymentResultStatus::Succeeded,
'type' => $type,
+3 -1
View File
@@ -66,7 +66,9 @@ class OrderStatus
*/
public static function fulfillment(Order $order): FulfillmentStatus
{
$shipments = $order->shipments->reject(fn ($shipment) => $shipment->cancelled_at !== null);
// Return vouchers linked to the order describe the parcel coming
// back, not its delivery — they never count towards fulfillment.
$shipments = $order->shipments->reject(fn ($shipment) => $shipment->isCancelled() || $shipment->isReturn());
if ($shipments->isEmpty()) {
return FulfillmentStatus::Unfulfilled;
+16
View File
@@ -2,7 +2,10 @@
namespace Modules\Core\Providers;
use Carbon\Carbon;
use Filament\Support\Facades\FilamentTimezone;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Modules\Core\Command\AnonymizeCommand;
use Modules\Core\Command\BackfillMissingSkusCommand;
@@ -26,6 +29,19 @@ class CoreServiceProvider extends ServiceProvider
{
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core');
Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core');
// Stored in UTC, shown in the shop's timezone: Filament's display
// timezone covers every admin date column, entry and picker (pickers
// also convert back to UTC on save); inDisplayTimezone() is for
// dates formatted by hand (storefront, emails, custom labels).
$displayTimezone = config('core.display_timezone', config('app.timezone'));
FilamentTimezone::set($displayTimezone);
Carbon::macro('inDisplayTimezone', fn () => $this->copy()->setTimezone(config('core.display_timezone', config('app.timezone'))));
// Lunar's order Timeline formats its times by hand (in UTC); this
// overrides just that one view with a copy that converts them —
// see resources/views/lunarpanel/.
View::prependNamespace('lunarpanel', __DIR__ . '/../../resources/views/lunarpanel');
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadTranslationsFrom(__DIR__ . '/../../lang', 'core');
@@ -5,6 +5,7 @@ namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Lunar\Models\Language;
use Modules\Core\Command\PullTranslationsCommand;
use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted;
use Modules\Core\Localization\Events\LanguageUpdated;
@@ -46,5 +47,9 @@ class LocalizationServiceProvider extends ServiceProvider
}
Event::listen(LanguageUpdated::class, MigrateTranslationsForRenamedLanguage::class);
if ($this->app->runningInConsole()) {
$this->commands([PullTranslationsCommand::class]);
}
}
}
+40 -6
View File
@@ -4,9 +4,13 @@ namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule;
use Illuminate\Support\Facades\Event;
use Modules\Core\Shipping\ActivityLog\ShipmentCheckpointRender;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Listeners\LogShipmentCheckpointOnOrderTimeline;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use Livewire\Mechanisms\ComponentRegistry;
use Lunar\Admin\Support\ActivityLog\Manifest as ActivityLogManifest;
use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates;
@@ -24,10 +28,16 @@ use Modules\Core\Shipping\Carriers\Acs\Jobs\WarmAcsAreaCacheJob;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver;
use Modules\Core\Shipping\Carriers\Elta\EltaClient;
use Modules\Core\Shipping\Carriers\Elta\EltaFulfillmentService;
use Modules\Core\Shipping\Carriers\Elta\EltaRateDriver;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
use Modules\Core\Shipping\Carriers\Manual\ManualFulfillmentService;
use Modules\Core\Shipping\Carriers\Manual\ManualRateDriver;
use Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver;
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
use Modules\Core\Shipping\Jobs\SyncCarrierVouchersJob;
use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
use Modules\Core\Shipping\Support\FulfillmentType;
use Modules\Core\Shipping\Support\ShippingManager;
@@ -39,14 +49,18 @@ class ShippingServiceProvider extends ServiceProvider
{
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/acs.php', 'acs');
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/boxnow.php', 'boxnow');
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/elta.php', 'elta');
$this->app->singleton(AcsClient::class, fn () => new AcsClient(config('acs')));
$this->app->singleton(BoxNowClient::class, fn () => new BoxNowClient(config('boxnow')));
$this->app->singleton(EltaClient::class, fn () => new EltaClient(config('elta')));
$this->app->bind(CarrierFulfillmentInterface::class, function ($app, array $params) {
return match ($params['carrier'] ?? null) {
'acs' => $app->make(AcsFulfillmentService::class),
'box-now' => $app->make(BoxNowFulfillmentService::class),
'elta' => $app->make(EltaFulfillmentService::class),
'manual' => $app->make(ManualFulfillmentService::class),
default => null,
};
});
@@ -64,9 +78,17 @@ class ShippingServiceProvider extends ServiceProvider
public function boot(): void
{
// Carrier checkpoints on the order page's Timeline.
Event::listen(ShipmentStatusUpdatedByCarrier::class, LogShipmentCheckpointOnOrderTimeline::class);
// Added when Lunar's manifest is built, not now: resolving it during
// boot builds it before the morph map exists, which keys every
// renderer (Lunar's own too) under a name the Timeline never looks up.
$this->app->afterResolving('lunar-activity-log', fn (ActivityLogManifest $manifest) => $manifest->addRender(Order::class, ShipmentCheckpointRender::class));
$this->publishes([
__DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'),
__DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'),
__DIR__ . '/../../config/shippingCarriers/elta.php' => config_path('shippingCarriers/elta.php'),
], 'core-config');
// Signed-URL auth only, same model as Lunar's own vendor
@@ -132,19 +154,31 @@ class ShippingServiceProvider extends ServiceProvider
// run before the Shipping::extend() calls below, which resolve —
// and the facade then CACHES — whatever's bound at that moment).
$this->app->booted(function () {
$this->app->bind(ShippingMethodManagerInterface::class, fn ($app) => $app->make(ShippingManager::class));
// A singleton, not bind(): the queue worker clears every
// facade's cached instance before each job
// (Facade::clearResolvedInstances()), so with bind() the Shipping
// facade then built a fresh, empty manager — without the drivers
// extended below — and every queued job (tracking, voucher sync)
// saw no carriers at all.
$this->app->singleton(ShippingMethodManagerInterface::class, fn ($app) => $app->make(ShippingManager::class));
Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
// Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
Shipping::extend('store-pickup', fn ($app) => $app->make(StorePickupRateDriver::class));
Shipping::extend('elta', fn ($app) => $app->make(EltaRateDriver::class));
Shipping::extend('manual', fn ($app) => $app->make(ManualRateDriver::class));
$this->app->make(ConsoleSchedule::class)
->job(new WarmAcsAreaCacheJob)
->dailyAt('06:00')
->when(fn () => ShippingMethod::where('driver', 'acs')->exists());
// $this->app->make(ConsoleSchedule::class)
// ->job(new WarmAcsAreaCacheJob)
// ->dailyAt('06:00')
// ->when(fn () => ShippingMethod::where('driver', 'acs')->exists());
$this->app->make(ConsoleSchedule::class)
->job(new PollShipmentTrackingJob)
->everyFiveMinutes();
$this->app->make(ConsoleSchedule::class)
->job(new SyncCarrierVouchersJob)
->everyThirtyMinutes();
$this->overrideRatesPageLivewireComponent();
@@ -0,0 +1,45 @@
<?php
namespace Modules\Core\Shipping\ActivityLog;
use Illuminate\Support\Carbon;
use Lunar\Admin\Support\ActivityLog\AbstractRender;
use Modules\Core\Shipping\Support\ShipmentTimelineLogger;
use Spatie\Activitylog\Models\Activity;
/**
* Draws a shipment checkpoint on the order page's Timeline (logged by
* ShipmentTimelineLogger): carrier + voucher, the status badge, and the
* carrier's own location / text.
*/
class ShipmentCheckpointRender extends AbstractRender
{
public function getEvent(): string
{
return ShipmentTimelineLogger::EVENT;
}
public function render(Activity $log)
{
$status = (string) $log->getExtraProperty('status');
$occurredAt = $log->getExtraProperty('occurred_at');
return view('core::shipping.activity.shipment-checkpoint', [
'carrier' => $log->getExtraProperty('carrier'),
'trackingReference' => $log->getExtraProperty('tracking_reference'),
'isReturn' => (bool) $log->getExtraProperty('is_return'),
'statusLabel' => (string) str($status)->replace('_', ' ')->title(),
'statusColor' => match ($status) {
'delivered' => 'success',
'failed', 'returned', 'cancelled' => 'danger',
'in_transit', 'out_for_delivery', 'collected_from_sender' => 'warning',
default => 'gray',
},
'details' => collect([
$log->getExtraProperty('location'),
$log->getExtraProperty('message') ?: $log->getExtraProperty('carrier_status'),
])->filter()->unique()->implode(' · '),
'occurredAt' => $occurredAt ? Carbon::parse($occurredAt)->inDisplayTimezone() : null,
]);
}
}
@@ -3,22 +3,45 @@
namespace Modules\Core\Shipping\Carriers\Acs;
use RuntimeException;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Lunar\Models\Order;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsExtraServices;
use Modules\Core\Shipping\Contracts\SupportsVoucherListing;
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
use Modules\Core\Shipping\DTOs\CarrierVoucher;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\DTOs\ManifestResult;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Manifest;
use Modules\Core\Shipping\Models\Shipment;
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsExtraServices, SupportsManifestBatching, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup
{
/**
* ACS's Acs_Delivery_Products codes. Timed delivery also sends
* Appointment_Until_Time; insurance sends Insurance_Ammount.
*/
public function extraServices(): array
{
return [
ExtraService::SaturdayDelivery->value => 'SAT',
ExtraService::MorningDelivery->value => 'MDV',
ExtraService::TimedDelivery->value => 'TDD',
ExtraService::Insurance->value => 'INS',
ExtraService::ReturnDocuments->value => 'RDO',
ExtraService::RemoteArea->value => 'REM',
ExtraService::Protocol->value => 'PRO',
];
}
public function __construct(
private readonly AcsClient $client,
private readonly AreaResolver $areaResolver,
@@ -44,12 +67,40 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
'Charge_Type' => 2,
'Item_Quantity' => $request->packageCount,
'Weight' => $weight,
// Our order reference, so the vouchers ACS reports back can be
// matched to their order.
'Reference_Key1' => (string) $order->reference,
];
if ($request->paymentMode === 'cod') {
$params['Cod_Ammount'] = $request->amountToCollect ?? $order->total->decimal;
// The customer's delivery instructions, for the courier.
if ($instructions = trim(preg_replace('/\s+/u', ' ', (string) $address->delivery_instructions))) {
$params['Delivery_Notes'] = $instructions;
}
$codAmount = $request->paymentMode === 'cod'
? (float) ($request->amountToCollect ?? $order->total->decimal)
: null;
$products = collect($request->services)
->map(fn (ExtraService $service) => $this->extraServices()[$service->value] ?? null)
->filter();
if ($codAmount !== null) {
$params['Cod_Ammount'] = $codAmount;
$params['Cod_Payment_Way'] = 0; // cash
$params['Acs_Delivery_Products'] = 'COD';
$products->push('COD');
}
if ($products->isNotEmpty()) {
$params['Acs_Delivery_Products'] = $products->unique()->implode(',');
}
if ($request->has(ExtraService::Insurance) && $request->insuranceAmount) {
$params['Insurance_Ammount'] = $request->insuranceAmount;
}
if ($request->has(ExtraService::TimedDelivery) && $request->deliveryUntil) {
$params['Appointment_Until_Time'] = substr($request->deliveryUntil, 0, 5);
}
$response = $this->client->call('ACS_Create_Voucher', $params)->throwIfError();
@@ -62,8 +113,12 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
'tracking_reference' => $voucherNo,
'meta' => [
'station_destination' => $destination->stationId,
'reference' => (string) $order->reference,
'weight' => $weight,
'pickup_date' => now()->toDateString(),
'cod_amount' => $codAmount,
'services' => $request->serviceValues(),
'insurance_amount' => $request->has(ExtraService::Insurance) ? $request->insuranceAmount : null,
],
]);
@@ -161,12 +216,75 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
carrierStatus: $row['checkpoint_action'] ?? null,
message: $row['checkpoint_action'] ?? null,
location: $row['checkpoint_location'] ?? null,
occurredAt: Carbon::parse($row['checkpoint_date_time']),
// ACS reports Greek time without an offset.
occurredAt: Carbon::parse($row['checkpoint_date_time'], 'Europe/Athens')->setTimezone(config('app.timezone')),
meta: $row,
);
});
}
/**
* ACS can't list vouchers directly: each day's pickup lists
* (ACS_Get_Pickup_Lists) → their vouchers (ACS_Pickup_List_Display_
* Voucher). Only finalized vouchers appear, with our Reference_Key1 but
* no recipient or status — tracking fills those in.
*/
public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable
{
for ($day = $from->copy()->startOfDay(); $day->lte($to); $day = $day->addDay()) {
$date = $day->toDateString();
$lists = $this->client->call('ACS_Get_Pickup_Lists', ['Pickup_Date' => $date])
->throwIfError()->tableOutput['Table_Data'] ?? [];
foreach ($lists as $list) {
$vouchers = $this->client->call('ACS_Pickup_List_Display_Voucher', [
'PickupList_No' => $list['PickupList_No'],
'Pickup_Date' => $date,
])->throwIfError()->tableOutput['Table_Data'] ?? [];
foreach ($vouchers as $row) {
yield new CarrierVoucher(
carrier: 'acs',
voucherNumber: (string) $row['Voucher_no'],
reference: filled($row['Reference_Key1'] ?? null) ? (string) $row['Reference_Key1'] : null,
date: $day->copy(),
raw: $row,
);
}
}
}
}
/**
* ACS_Trackingsummary: recipient and delivery/return flags — ACS doesn't
* return our reference here.
*/
public function lookupVoucher(string $voucherNumber): ?CarrierVoucher
{
$row = $this->client->call('ACS_Trackingsummary', ['Voucher_No' => trim($voucherNumber)])
->throwIfError()->tableOutput['Table_Data'][0] ?? null;
if (! $row || blank($row['voucher_no'] ?? null)) {
return null;
}
return new CarrierVoucher(
carrier: 'acs',
voucherNumber: (string) $row['voucher_no'],
recipientName: $row['recipient'] ?? ($row['consignee'] ?? null),
statusText: (int) ($row['delivery_flag'] ?? 0) === 1 ? 'Delivered' : ($row['delivery_info'] ?? null),
status: match (true) {
(int) ($row['returned_flag'] ?? 0) === 1 => TrackingStatus::Returned,
(int) ($row['delivery_flag'] ?? 0) === 1 => TrackingStatus::Delivered,
default => null,
},
isReturn: (int) ($row['returned_flag'] ?? 0) === 1,
date: filled($row['pickup_date'] ?? null) ? Carbon::parse($row['pickup_date']) : null,
raw: $row,
);
}
private function isDelivered(Shipment $shipment): bool
{
try {
@@ -177,7 +295,14 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
return false;
}
return (int) ($response->valueOutput['shipment_status'] ?? 0) === 4;
// The summary row is in ACSTableOutput.Table_Data, not
// ACSValueOutput — reading the latter always came back empty, so no
// ACS shipment was ever marked delivered. delivery_flag = 1 is
// ACS's documented "delivered"; shipment_status 4 means the same.
$summary = $response->tableOutput['Table_Data'][0] ?? [];
return (int) ($summary['delivery_flag'] ?? 0) === 1
|| (int) ($summary['shipment_status'] ?? 0) === 4;
}
private function guessStatusFromAction(string $action): TrackingStatus
+2 -1
View File
@@ -6,6 +6,7 @@ use Lunar\DataTypes\Price;
use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\CachesLivePricing;
@@ -40,7 +41,7 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla
return 'carrier';
}
public function collectsCash(): bool
public function collectsCash(ShippingMethod $method): bool
{
return true;
}
@@ -2,13 +2,19 @@
namespace Modules\Core\Shipping\Carriers\BoxNow;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Lunar\Models\Order;
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\CreatesStandaloneVouchers;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Contracts\SupportsVoucherListing;
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
use Modules\Core\Shipping\DTOs\CarrierVoucher;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\DTOs\VoucherRecipient;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment;
@@ -27,12 +33,11 @@ use Modules\Core\Shipping\Models\Shipment;
* $order->shippingAddress->meta['box_now_locker']).
*
* 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
* needing more than one physical parcel (doesn't fit one compartment)
* sends that many entries in a single delivery request rather than
* several separate ones.
* by kg. Each box in ShipmentRequest::$boxes is its own delivery request
* (and so its own parcel, voucher and compartment): Box Now merges the
* items of one request into a single parcel — see create().
*/
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, CreatesStandaloneVouchers, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup
{
private const COMPARTMENT_SIZES = ['S' => 1, 'M' => 2, 'L' => 3];
@@ -40,7 +45,35 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
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;
if (! $destinationLocationId) {
@@ -51,15 +84,46 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
throw new BoxNowApiException('At least one box (compartment size) is required for a Box Now shipment.');
}
// The locker's pickup code goes out by SMS and email.
$contactNumber = $recipient->mobile ?: $recipient->phone;
if (blank($contactNumber) || blank($recipient->email)) {
throw new BoxNowApiException('Box Now needs the recipient\'s mobile and email — the locker pickup code is sent to both.');
}
$isCod = $request->paymentMode === 'cod';
$codAmount = $isCod ? (float) ($request->amountToCollect ?? $order?->total->decimal) : null;
if ($isCod && ! $codAmount) {
throw new BoxNowApiException('Enter the cash on delivery amount.');
}
// 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' => $order->reference.'-'.$order->id,
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
'paymentMode' => $isCod ? 'cod' : 'prepaid',
'amountToBeCollected' => $isCod
? number_format($request->amountToCollect ?? $order->total->decimal, 2, '.', '')
: '0.00',
'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'),
@@ -67,42 +131,58 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
'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}"),
'contactNumber' => $this->internationalPhone($contactNumber),
'contactEmail' => $recipient->email,
'contactName' => $recipient->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).')',
'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'],
])->all(),
]],
]);
$parcels = collect($response['parcels'] ?? []);
$parcelId = $response['parcels'][0]['id'] ?? null;
if ($parcels->isEmpty()) {
throw new BoxNowApiException('Box Now delivery request succeeded but returned no parcel ids.', $response);
if (! $parcelId) {
throw new BoxNowApiException('Box Now delivery request succeeded but returned no parcel id.', $response);
}
// One Shipment row per box/parcel — each is independently
// trackable/printable/cancellable via its own tracking_reference
// (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.
$shipments = $parcels->map(fn (array $parcel) => Shipment::create([
'order_id' => $order->id,
$shipments[] = Shipment::create([
'order_id' => $order?->id,
'carrier' => 'box-now',
'tracking_reference' => (string) $parcel['id'],
'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);
}
}
return $shipments->first();
throw $e;
}
return $shipments[0];
}
/**
@@ -186,6 +266,91 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
));
}
/**
* Every parcel on the account (GET /parcels, 100 per page). Box Now has
* no date filter, so pages are read until a whole page is older than
* $from — its order isn't documented, so one old parcel alone doesn't
* stop the scan.
*/
public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable
{
$pageToken = null;
do {
$response = $this->client->request('get', '/parcels', array_filter([
'limit' => 100,
'pageToken' => $pageToken,
]));
$parcels = $response['data'] ?? [];
$anyInRange = false;
foreach ($parcels as $parcel) {
$created = Carbon::parse($parcel['createTime'] ?? 'now');
if ($created->lt($from)) {
continue;
}
$anyInRange = true;
if ($created->lte($to)) {
yield $this->voucherFromParcel($parcel);
}
}
$pageToken = $this->nextPageToken($response['pagination']['next'] ?? null);
} while ($pageToken && $anyInRange && $parcels !== []);
}
public function lookupVoucher(string $voucherNumber): ?CarrierVoucher
{
$parcel = $this->client->request('get', '/parcels', ['parcelId' => trim($voucherNumber)])['data'][0] ?? null;
return $parcel ? $this->voucherFromParcel($parcel) : null;
}
private function voucherFromParcel(array $parcel): CarrierVoucher
{
$request = $parcel['deliveryRequest'] ?? [];
$destination = $request['destination'] ?? [];
$state = $parcel['state'] ?? null;
return new CarrierVoucher(
carrier: 'box-now',
voucherNumber: (string) $parcel['id'],
reference: $request['orderNumber'] ?? null,
recipientName: $destination['contactName'] ?? null,
postcode: $destination['postalCode'] ?? ($destination['address']['postalCode'] ?? null),
phone: $destination['contactNumber'] ?? null,
statusText: $state,
status: $state ? $this->mapState($state) : null,
codAmount: ($request['paymentMode'] ?? null) === 'cod' ? ((float) ($request['amountToBeCollected'] ?? 0) ?: null) : null,
isReturn: in_array($state, ['returned', 'expired-return', 'accepted-for-return'], true),
date: isset($parcel['createTime']) ? Carbon::parse($parcel['createTime']) : null,
raw: $parcel,
);
}
/**
* pagination.next is documented only by name — accept either the bare
* token or a URL carrying it as ?pageToken=.
*/
private function nextPageToken(mixed $next): ?string
{
if (blank($next) || ! is_string($next)) {
return null;
}
if (str_contains($next, 'pageToken=')) {
parse_str((string) parse_url($next, PHP_URL_QUERY), $query);
return $query['pageToken'] ?? null;
}
return $next;
}
private function mapState(string $state): TrackingStatus
{
// TODO: confirm against a live BoxNow webhook payload whether a
@@ -201,7 +366,7 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
'in-final-destination', 'wait-for-load' => TrackingStatus::OutForDelivery,
'delivered' => TrackingStatus::Delivered,
'returned', 'accepted-for-return' => TrackingStatus::Returned,
'cancelled' => TrackingStatus::Cancelled,
'cancelled', 'canceled' => TrackingStatus::Cancelled,
'expired-return', 'missing' => TrackingStatus::Failed,
default => TrackingStatus::Unknown,
};
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Illuminate\Support\Facades\Cache;
/**
* Resolves a Greek postcode to its ELTA station code/name via PELTKNEW
* (the *NEW-family replacement for the old, differently-authenticated
* PELSTATION — see EltaClient's docblock). Unlike Acs\AreaResolver,
* there's no daily bulk-warm job — PELTKNEW only accepts a single pel_tk
* (postcode), with no fetch-all variant to warm a whole-country cache
* from, so this caches per-postcode on first lookup instead.
*/
class AreaResolver
{
public function __construct(private readonly EltaClient $client) {}
public function resolve(string $postcode): EltaStation
{
return Cache::remember(
"elta.station.{$postcode}",
now()->addDays(30),
function () use ($postcode) {
$response = $this->client->getStation([
'pel_tk' => $postcode,
'sender_station' => '',
])->throwIfError();
$fields = $response->data['pel_fields'] ?? [];
return new EltaStation(
code: $fields['rec_station'] ?? '',
name: $fields['rec_station_t'] ?? '',
);
},
);
}
}
+200
View File
@@ -0,0 +1,200 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use DOMDocument;
use DOMElement;
use DOMXPath;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
/**
* SOAP transport for ELTA Courier's web services.
*
* ELTA runs two parallel operation families at the same endpoint
* (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)
* rejects this account's credentials with st_flag=3 ("invalid password").
* Decompiling ELTA's own official desktop client (ELTA_PEL.exe, from their
* public .msi installer) showed it never calls CREATEAWB at all — it
* exclusively uses a newer "*NEW" family (PELVGNEW, PELLOGINNEW,
* PELTTNEW01, PELTKNEW, PELVGDEL, PELPARALVGNEW1). PELVGNEW was
* live-verified directly against the production endpoint with this same
* account and succeeded (st_flag=0, real voucher number returned) — it
* requires no password at all, just the account/user code pair.
*
* No .wsdl files exist for the *NEW family (only reconstructed from the
* decompiled C# proxy classes' method signatures). PHP's SoapClient in
* non-WSDL mode refuses to serialize these calls at all (SoapFault "Error
* in client request message" regardless of style/use/SoapVar wrapping —
* this legacy Micro Focus/CICS gateway's document/literal shape doesn't
* match what ext-soap's non-WSDL client is willing to build), so *NEW
* operations are sent as hand-built SOAP 1.1 envelopes over plain HTTP —
* proven directly against the live endpoint — with the response parsed
* back via DOMDocument/DOMXPath.
*
* The old family's label operation (PELB64VG) is deliberately NOT
* exposed here — it fails with the same st_flag=3 as CREATEAWB (same
* legacy family), and has no *NEW replacement since ELTA's own client
* renders labels locally instead of fetching them (see
* EltaLabelRenderer).
*/
class EltaClient
{
public function __construct(private readonly array $config) {}
public function login(array $params): EltaResponse
{
return $this->call('PELLOGINNEW', $params);
}
public function createVoucher(array $params): EltaResponse
{
return $this->call('PELVGNEW', $params);
}
public function listVouchers(array $params): EltaResponse
{
return $this->call('PELPARALVGNEW1', $params);
}
/**
* Issues a pending voucher (created with pel_insert_flag "0"): assigns
* its voucher number, OCR line, destination station and service. This
* is the official client's "print" — after it, the voucher can no
* longer be cancelled.
*/
public function issueVoucher(array $params): EltaResponse
{
return $this->call('PELVG01NEW1', $params);
}
/**
* Only pending (not yet issued) vouchers can be deleted.
*/
public function deleteVoucher(array $params): EltaResponse
{
return $this->call('PELVGDEL', $params);
}
/**
* The client's "Πολλαπλή Αναζήτηση": every issued voucher on the
* account between date_1 and date_2 (dd/mm/yyyy), with its latest
* status. status_flag: 0 all, 1 delivered, 2 undelivered, 3 to be
* returned. Paged 100 at a time via in_id (the last row's
* pel_col_id).
*/
public function searchVouchers(array $params): EltaResponse
{
return $this->call('PELMANIF2', $params);
}
public function getTracking(array $params): EltaResponse
{
return $this->call('PELTTNEW01', $params);
}
public function getStation(array $params): EltaResponse
{
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
{
$namespace = '/'.$operation;
$body = '';
foreach ($params as $key => $value) {
$body .= '<cre:'.$key.'>'.htmlspecialchars((string) $value, ENT_XML1).'</cre:'.$key.'>';
}
$envelope = '<?xml version="1.0" encoding="UTF-8"?>'
.'<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>';
// 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([
'SOAPAction' => 'Get',
'Content-Type' => 'text/xml',
])
->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')
->post($this->config['service_location']);
if ($response->failed()) {
throw new EltaApiException('ELTA request failed: HTTP '.$response->status());
}
return EltaResponse::fromSoapResult($this->parseEnvelope($response->body()));
}
private function parseEnvelope(string $xml): array
{
$dom = new DOMDocument();
if (! $dom->loadXML($xml)) {
throw new EltaApiException('ELTA returned a response that could not be parsed as XML.');
}
$xpath = new DOMXPath($dom);
$xpath->registerNamespace('soapenv', 'http://schemas.xmlsoap.org/soap/envelope/');
$readResponse = $xpath->query('//soapenv:Body/*')->item(0);
if (! $readResponse instanceof DOMElement) {
throw new EltaApiException('ELTA response did not contain a SOAP body.');
}
return $this->nodeToArray($readResponse);
}
private function nodeToArray(DOMElement $element): array
{
$data = [];
foreach ($element->childNodes as $child) {
if (! $child instanceof DOMElement) {
continue;
}
$name = $child->localName;
$hasElementChildren = false;
foreach ($child->childNodes as $grandchild) {
if ($grandchild instanceof DOMElement) {
$hasElementChildren = true;
break;
}
}
$value = $hasElementChildren ? $this->nodeToArray($child) : trim($child->textContent);
if (array_key_exists($name, $data)) {
if (! is_array($data[$name]) || ! array_is_list($data[$name])) {
$data[$name] = [$data[$name]];
}
$data[$name][] = $value;
} else {
$data[$name] = $value;
}
}
return $data;
}
}
@@ -0,0 +1,858 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Lunar\Models\Order;
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\CreatesStandaloneVouchers;
use Modules\Core\Shipping\Contracts\IssuesVoucherOnPrint;
use Modules\Core\Shipping\Contracts\SupportsBatchLabels;
use Modules\Core\Shipping\Contracts\SupportsExtraServices;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Contracts\SupportsVoucherListing;
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
use Modules\Core\Shipping\DTOs\CarrierVoucher;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\DTOs\VoucherRecipient;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Shipment;
/**
* ELTA vouchers are created **pending** (pel_insert_flag "0", live-verified):
* ELTA stores the voucher with an internal id but no voucher number, and it
* can still be cancelled. Printing issues it (PELVG01NEW1 — what ELTA's own
* client does from its group-editing screen): that assigns the voucher
* number, OCR line, destination station and service, and from then on ELTA
* refuses to delete it ("Δεν Επιτρέπεται! Εχει Γίνει Εκτύπωση"). So a
* shipment has no tracking_reference until its label is printed.
*
* Not SupportsManifestBatching — ELTA has no manifest/pickup-list step.
*/
class EltaFulfillmentService implements CarrierFulfillmentInterface, CreatesStandaloneVouchers, IssuesVoucherOnPrint, SupportsBatchLabels, SupportsExtraServices, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup
{
/** PELMANIF2 / PELPARALVGNEW1 page through 100 rows at a time. */
private const MAX_PAGES = 50;
public function __construct(
private readonly EltaClient $client,
private readonly EltaLabelRenderer $labelRenderer,
) {}
/**
* ELTA surcharge codes (pel_sur_1..3), from its own client's checkboxes.
* Insurance isn't a surcharge — it's the amount in pel_asf_poso.
*/
public function extraServices(): array
{
return [
ExtraService::SaturdayDelivery->value => '004',
ExtraService::TimedDelivery->value => '003',
ExtraService::SpecialHandling->value => '002',
ExtraService::PostBoxDelivery->value => '917',
ExtraService::Insurance->value => 'pel_asf_poso',
];
}
public function createShipment(Order $order, ShipmentRequest $request): Shipment
{
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;
$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 = [
'pel_apost_code' => config('elta.apost_code'),
'pel_paral_name' => $recipient->name,
'pel_paral_address' => $recipient->address,
'pel_paral_area' => $routing['area'],
'pel_paral_tk' => $recipient->postcode,
'pel_paral_thl_1' => $phone,
'pel_paral_thl_2' => $mobile,
'pel_service' => $routing['service'],
'pel_baros' => number_format($weight, 3, '.', ''),
'pel_baros_xyz' => $volumetric ?? '',
'pel_x' => $dimensions ? (string) $dimensions[0] : '',
'pel_y' => $dimensions ? (string) $dimensions[1] : '',
'pel_z' => $dimensions ? (string) $dimensions[2] : '',
'pel_temaxia' => (string) $request->packageCount,
// Remarks right-aligned in 99 characters, then "2" = parcel or
// "1" = envelope — the client's encoding of its mandatory
// envelope/parcel choice. ELTA keeps the digit as the
// 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
// are cheques (with dates) in ELTA's client — never used here.
'pel_ant_poso' => '',
'pel_ant_poso1' => '',
'pel_ant_poso2' => '',
'pel_ant_poso3' => '',
'pel_ant_poso4' => '',
'pel_ant_date1' => '',
'pel_ant_date2' => '',
'pel_ant_date3' => '',
'pel_ant_date4' => '',
'pel_asf_poso' => $request->has(ExtraService::Insurance) && $request->insuranceAmount
? number_format($request->insuranceAmount, 2, '.', '')
: '',
'pel_user' => config('elta.user_code'),
// Our order reference — comes back in ELTA's lists and tracking,
// which is how we find the pending voucher's id below and match
// vouchers to orders on the Carrier Vouchers screen.
'pel_ref_no' => $reference,
// "0" = save without issuing (see this class's docblock).
'pel_insert_flag' => '0',
'pel_paral_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;
if ($request->paymentMode === 'cod') {
$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, '.', '');
}
$this->client->createVoucher($params)->throwIfError();
$pieces = max(1, $request->packageCount);
// PELVGNEW doesn't return the new voucher's id, so find it in the
// pending list by our reference. Saved even if that lookup fails —
// the voucher exists at ELTA either way, and printing retries it.
$master = Shipment::create([
'order_id' => $order?->id,
'carrier' => 'elta',
'source' => Shipment::SOURCE_CREATED,
'tracking_reference' => null,
'meta' => [
'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,
'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,
'services' => $request->serviceValues(),
'insurance_amount' => $request->has(ExtraService::Insurance) ? $request->insuranceAmount : null,
...($pieces > 1 ? ['piece' => 1, 'pieces' => $pieces] : []),
],
]);
// One row per extra parcel straight away, so the order shows every
// parcel; ELTA gives them their own voucher numbers only when the
// main voucher is issued (issue()). They belong to the main voucher:
// no COD or services of their own.
for ($piece = 2; $piece <= $pieces; $piece++) {
Shipment::create([
'order_id' => $order?->id,
'carrier' => 'elta',
'source' => Shipment::SOURCE_CREATED,
'tracking_reference' => null,
'meta' => [
'master_shipment_id' => $master->id,
'reference' => $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
{
return $this->printLabels(collect([$shipment]));
}
/**
* 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
{
$pages = collect();
foreach ($shipments as $shipment) {
$master = $this->masterOf($shipment);
$this->issue($master);
$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;
}
/**
* Pending (not yet printed) vouchers are deleted at ELTA. Issued ones
* can't be — ELTA only allows that before printing.
*/
public function cancelShipment(Shipment $shipment): void
{
$master = $this->masterOf($shipment);
if (! $shipment->is($master)) {
$piece = $shipment->meta['piece'] ?? null;
throw new EltaApiException(
'This is '.($piece ? "parcel {$piece}" : 'an extra parcel').' of a multi-parcel ELTA voucher — ELTA cancels the whole voucher, so cancel the main parcel instead.',
);
}
if (filled($shipment->tracking_reference)) {
throw new EltaApiException(
"ELTA voucher {$shipment->tracking_reference} is already printed, and ELTA only allows cancelling vouchers that haven't been printed yet. Contact ELTA to cancel it.",
);
}
$this->client->deleteVoucher(['pel_vg_id' => $this->pendingVoucherId($shipment)])->throwIfError();
$shipment->update(['cancelled_at' => now()]);
$this->childrenOf($shipment)->each->update(['cancelled_at' => now()]);
}
/**
* The main voucher's shipment for a child parcel, or the shipment
* itself. Children created by createShipment() point at it via
* meta.master_shipment_id; older ones only via parent_reference.
*/
private function masterOf(Shipment $shipment): Shipment
{
if ($id = $shipment->meta['master_shipment_id'] ?? null) {
return Shipment::find($id) ?? $shipment;
}
if (filled($shipment->parent_reference)) {
return Shipment::where('tracking_reference', $shipment->parent_reference)->first() ?? $shipment;
}
return $shipment;
}
/**
* @return Collection<int, Shipment> the main voucher's child parcels, in piece order
*/
private function childrenOf(Shipment $master): Collection
{
return Shipment::query()
->where('carrier', 'elta')
->where(fn ($query) => $query
->where('meta->master_shipment_id', $master->id)
->when($master->tracking_reference, fn ($q) => $q->orWhere('parent_reference', $master->tracking_reference)))
->orderBy('id')
->get()
->sortBy(fn (Shipment $child) => $child->meta['piece'] ?? PHP_INT_MAX)
->values();
}
public function trackShipment(Shipment $shipment): Collection
{
// ELTA tracks a multi-parcel send by its main voucher only — an
// extra parcel's own number is rejected ("Λάθος ΣΥΔΕΤΑ"). Its
// history is the main voucher's, which is tracked on its own row.
if (! $shipment->is($this->masterOf($shipment))) {
return collect();
}
$response = $this->client->getTracking([
'web_vg' => $shipment->tracking_reference,
'pel_code' => config('elta.apost_code'),
])->throwIfError();
$rows = array_filter(
(array) ($response->data['web_status'] ?? []),
fn (array $row) => filled($row['web_date_time'] ?? null),
);
// No explicit delivered-confirmation field like the old
// PELTT01's pod_name — pel_rec.a_rec_date_time is blank until
// delivery per the real client's own rendering, used as the
// delivered signal here; unconfirmed against a genuinely
// delivered parcel yet.
$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) {
$isLast = $index === count($rows) - 1;
$dateTime = $row['web_date_time'] ?? '';
return new TrackingCheckpoint(
status: $isLast && $isDelivered
? TrackingStatus::Delivered
: $this->guessStatusFromTitle($row['web_status_name'] ?? ''),
carrierStatus: $row['web_status_name'] ?? null,
message: $row['web_sxolia'] ?: ($row['web_status_name'] ?? null),
location: $row['web_station'] ?? null,
// ELTA reports Greek local time.
occurredAt: Carbon::createFromFormat('YmdHi', substr($dateTime, 0, 12), 'Europe/Athens')->setTimezone(config('app.timezone')),
meta: $row,
);
});
}
/**
* 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
* in the date range with its latest status. A second pass with
* status_flag 3 ("Προς Επιστροφή") marks the ones heading back to us.
*/
public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable
{
$returning = collect($this->searchVouchers($from, $to, '3'))->pluck('pel_col_1')->flip();
foreach ($this->searchVouchers($from, $to, '0') as $row) {
$number = trim($row['pel_col_1']);
yield new CarrierVoucher(
carrier: 'elta',
voucherNumber: $number,
reference: filled($row['pel_col_2'] ?? null) ? trim($row['pel_col_2']) : null,
recipientName: trim($row['pel_col_3'] ?? '') ?: null,
postcode: trim($row['pel_col_4'] ?? '') ?: null,
statusText: trim($row['pel_col_6'] ?? '') ?: null,
status: filled(trim($row['pel_col_6'] ?? '')) ? $this->guessStatusFromTitle(trim($row['pel_col_6'])) : null,
codAmount: (float) ($row['pel_col_8'] ?? 0) ?: null,
isReturn: $returning->has($row['pel_col_1']),
date: $this->dateFromListRow($row['pel_col_7'] ?? ''),
raw: $row,
);
}
}
/**
* PELTTNEW01 (tracking) also returns the voucher's details (pel_rec):
* recipient, postcode, our reference, cash on delivery.
*/
public function lookupVoucher(string $voucherNumber): ?CarrierVoucher
{
$response = $this->client->getTracking([
'web_vg' => trim($voucherNumber),
'pel_code' => config('elta.apost_code'),
]);
$record = $response->data['pel_rec'] ?? [];
if ($response->hasError || blank($record['a_vg_3'] ?? null)) {
return null;
}
$statuses = array_values(array_filter(
(array) ($response->data['web_status'] ?? []),
fn (array $row) => filled($row['web_date_time'] ?? null),
));
return new CarrierVoucher(
carrier: 'elta',
voucherNumber: trim($record['a_vg_3']),
reference: trim($record['a_ref'] ?? '') ?: null,
recipientName: trim($record['a_rec_title'] ?? '') ?: null,
postcode: trim($record['a_rec_postal'] ?? '') ?: null,
phone: trim($record['a_rec_tel_1'] ?? '') ?: null,
statusText: filled($statuses) ? trim(end($statuses)['web_status_name'] ?? '') : null,
status: filled($statuses) ? $this->guessStatusFromTitle(trim(end($statuses)['web_status_name'] ?? '')) : null,
codAmount: (float) ($record['a_antik'] ?? 0) ?: null,
date: $this->dateFromListRow($record['a_sender_date'] ?? ''),
raw: $record,
);
}
/**
* Issues a pending voucher (no-op once it has a number): stores the
* voucher number and what ELTA returns for the label, plus one shipment
* per child voucher of a multi-piece send.
*/
private function issue(Shipment $shipment): void
{
if (filled($shipment->tracking_reference)) {
return;
}
$response = $this->client->issueVoucher([
'pel_id' => $this->pendingVoucherId($shipment),
'sender_station' => $this->senderStation(),
])->throwIfError();
$data = $response->data;
$voucherNo = trim((string) ($data['vg_code'] ?? ''));
if ($voucherNo === '') {
throw new EltaApiException('ELTA issued the voucher but returned no voucher number.', $data);
}
$shipment->tracking_reference = $voucherNo;
$shipment->meta = array_merge($shipment->meta?->toArray() ?? [], [
'ocr_line' => $data['ocr_line'] ?? null,
'date_time' => $data['date_time'] ?? null,
'rec_station' => $data['rec_station'] ?? null,
'rec_station_t' => $data['rec_station_t'] ?? null,
'rec_srv' => $data['rec_srv'] ?? null,
'rec_srv_t' => $data['rec_srv_t'] ?? 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,
]);
$shipment->save();
// The children's numbers, in ELTA's order (vg_child_no), go to the
// pending child rows in piece order; any parcel without a row (older
// shipments) gets one.
$pending = $this->childrenOf($shipment)->filter(fn (Shipment $child) => blank($child->tracking_reference))->values();
$pieces = (int) ($shipment->meta['package_count'] ?? 1);
$childNumbers = array_values(array_filter(array_map('trim', (array) ($data['vg_child_no'] ?? []))));
foreach ($childNumbers as $i => $childVoucherNo) {
$child = $pending->get($i) ?? new Shipment([
'order_id' => $shipment->order_id,
'carrier' => 'elta',
'source' => Shipment::SOURCE_CREATED,
'meta' => [
'master_shipment_id' => $shipment->id,
'reference' => $shipment->meta['reference'] ?? null,
'piece' => $i + 2,
'pieces' => max($pieces, count($childNumbers) + 1),
'weight' => $shipment->meta['weight'] ?? null,
'package_count' => max($pieces, count($childNumbers) + 1),
],
]);
$child->tracking_reference = $childVoucherNo;
$child->parent_reference = $voucherNo;
$child->save();
}
}
/**
* @return array<int, string>
*/
/**
* 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
{
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' : '',
];
}
/**
* The recipient's number as ELTA's two phone fields take it: 10 digits
* (a +30 / 0030 prefix dropped); a mobile (69…) goes in the mobile
* field, anything else in the phone field.
*
* @return array{0: string, 1: string} [phone, mobile]
*/
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
{
$id = $shipment->meta['carrier_id'] ?? null;
$reference = $shipment->meta['reference'] ?? $shipment->order?->reference;
if (blank($id) && filled($reference)) {
$id = $this->findPendingVoucherId((string) $reference);
if ($id) {
$shipment->meta = array_merge($shipment->meta?->toArray() ?? [], ['carrier_id' => $id]);
$shipment->save();
}
}
if (blank($id)) {
throw new EltaApiException("Couldn't find this shipment's pending voucher at ELTA.");
}
return $id;
}
/**
* The newest not-yet-issued voucher in ELTA's list carrying our
* reference. flag_1/flag_2 are the client's "Show all" / "All users"
* checkboxes — both "1", or even a just-created voucher is filtered out.
*/
private function findPendingVoucherId(string $reference): ?string
{
$inId = '0';
$match = null;
for ($page = 0; $page < self::MAX_PAGES; $page++) {
$rows = array_values(array_filter(
(array) ($this->client->listVouchers([
'pel_code' => config('elta.apost_code'),
'pel_user_code' => config('elta.user_code'),
'flag_1' => '1',
'flag_2' => '1',
'in_id' => $inId,
])->throwIfError()->data['vg_rec'] ?? []),
fn (array $row) => filled($row['pel_vg_id'] ?? null),
));
foreach ($rows as $row) {
if (trim($row['pel_ref_no'] ?? '') === $reference && blank(trim($row['pel_paral_vg'] ?? ''))
&& ($match === null || $row['pel_vg_id'] > $match)) {
$match = $row['pel_vg_id'];
}
}
if (count($rows) < 100) {
break;
}
$inId = (string) end($rows)['pel_vg_id'];
}
return $match;
}
/**
* @return array<int, array<string, string>>
*/
private function searchVouchers(CarbonInterface $from, CarbonInterface $to, string $statusFlag): array
{
$inId = '0';
$all = [];
for ($page = 0; $page < self::MAX_PAGES; $page++) {
$rows = array_values(array_filter(
(array) ($this->client->searchVouchers([
'pel_code' => config('elta.apost_code'),
'date_1' => $from->format('d/m/Y'),
'date_2' => $to->format('d/m/Y'),
'paral_code' => '',
'status_flag' => $statusFlag,
'in_id' => $inId,
])->throwIfError()->data['ag_pel_rec'] ?? []),
fn (array $row) => filled(trim($row['pel_col_1'] ?? '')),
));
array_push($all, ...$rows);
if (count($rows) < 100) {
break;
}
$inId = (string) end($rows)['pel_col_id'];
}
return $all;
}
/**
* ELTA dates in its lists look like "29/09/26 09:41 PEL CLIENT" or
* "29/09/2026 09:51".
*/
private function dateFromListRow(string $value): ?CarbonInterface
{
if (! preg_match('#(\d{2})/(\d{2})/(\d{2,4})#', $value, $m)) {
return null;
}
$year = strlen($m[3]) === 2 ? '20'.$m[3] : $m[3];
return Carbon::createFromDate((int) $year, (int) $m[2], (int) $m[1])->startOfDay();
}
/**
* The station ELTA needs when issuing a voucher: configured
* (ELTA_ORIGIN_STATION_CODE), or read from the login response once —
* ELTA returns the account's user_station there even though our
* account's login password is rejected.
*/
private function senderStation(): string
{
if (filled(config('elta.origin_station_code'))) {
return (string) config('elta.origin_station_code');
}
return (string) Cache::rememberForever('elta.user_station', fn () => $this->client->login([
'pel_code' => config('elta.apost_code'),
'user_code' => config('elta.user_code'),
'user_pass' => config('elta.user_pass'),
])->data['user_station'] ?? '');
}
/**
* web_status_name is free text with no structured status code — same
* limitation Acs\AcsFulfillmentService::guessStatusFromAction() has.
* Live-confirmed text seen so far: "ΔΗΜΙΟΥΡΓΙΑ ΣΥ.ΔΕ.ΤΑ. ΑΠΟ ΠΕΛΑΤΗ"
* (voucher created) — matched via a Pending-ish fallback below since
* it isn't yet in transit. Other statuses are best-guess substring
* matches to refine as more real tracking text is observed.
*/
/**
* ELTA's status texts come in unaccented capitals ("ΠΑΡΑΔΟΘΗΚΕ"), so
* they're lowercased and stripped of accents before matching.
*/
private function guessStatusFromTitle(string $title): TrackingStatus
{
$title = strtr(mb_strtolower($title), [
'ά' => 'α', 'έ' => 'ε', 'ή' => 'η', 'ί' => 'ι', 'ό' => 'ο', 'ύ' => 'υ', 'ώ' => 'ω',
'ϊ' => 'ι', 'ϋ' => 'υ', 'ΐ' => 'ι', 'ΰ' => 'υ',
]);
return match (true) {
str_contains($title, 'επιστροφ') => TrackingStatus::Returned,
str_contains($title, 'παραδοθηκε') => TrackingStatus::Delivered,
str_contains($title, 'διανομη') => TrackingStatus::OutForDelivery,
str_contains($title, 'παραλαβη') => TrackingStatus::CollectedFromSender,
str_contains($title, 'μεταφορα') || str_contains($title, 'διαμετακομιση') || str_contains($title, 'διακινηση') => TrackingStatus::InTransit,
default => TrackingStatus::Pending,
};
}
}
@@ -0,0 +1,511 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Modules\Core\Shipping\DTOs\VoucherRecipient;
use Modules\Core\Shipping\Models\Shipment;
use Picqer\Barcode\BarcodeGeneratorPNG;
/**
* Renders an ELTA shipping label as a PDF ourselves — there is no *NEW
* operation for fetching one (see EltaFulfillmentService::printLabel()'s
* docblock), so this reproduces ELTA's own official label design instead
* of a server-fetched file.
*
* Field layout/text is traced directly from ELTA's own 2024 Customer
* Client Installation Manual, which includes real sample images of both
* the A4 voucher (3 stacked copies: delivery copy, sender's copy, a
* ΤΑΧΥΠΛΗΡΩΜΗ payment-receipt stub) and the A6 thermal label (single
* copy, with a return-reason checkbox list the A4 version doesn't have)
* — not from decompiled client code, which an earlier version of this
* class was built from and turned out to look nothing like a real
* label. The barcode is Code 128 (single barcode per label, matching
* both real samples) encoding the voucher number itself — an earlier
* version guessed Code 39 with a second "OCR" barcode from misreading
* decompiled code; neither survived comparison against the real
* samples.
*/
class EltaLabelRenderer
{
/**
* ELTA's own wording for each surcharge, as its client prints them in
* the label's "Πρόσθετες Υπηρεσίες" box (Sydeta.cs::print_vg()).
*/
private const SERVICE_TEXT = [
'special_handling' => '002 ΕΙΔΙΚΗ ΔΙΑΧΕΙΡΗΣΗ',
'timed_delivery' => '003 ΠΡΟΚΑΘΟΡΙΣΜΕΝΗ ΩΡΑ',
'saturday_delivery' => '004 ΕΠΙΔΟΣΗ ΣΑΒΒΑΤΟΥ',
'post_box_delivery' => '* ΠΡΟΣΟΧΗ PostBox *',
];
public function __construct(private readonly AreaResolver $areaResolver) {}
public function render(Shipment $shipment): string
{
return $this->renderMany(collect([$shipment]));
}
/**
* Several labels in one PDF (the Pending Vouchers screen's "Print
* selected"). Each label view is a full page — a single `.page` block
* inside <body> — so the pages are stacked under the first view's
* <head> (the styles are the same for every label) with a page break
* between them.
*
* @param Collection<int, Shipment|EltaReturnLabel> $shipments
*/
public function renderMany(Collection $shipments): string
{
$paperSize = config('elta.label_paper_size', 'a4');
$head = null;
$styles = [];
$pages = [];
foreach ($shipments as $shipment) {
$data = $shipment instanceof EltaReturnLabel
? $this->returnData($shipment->shipment)
: $this->data($shipment);
// A6 prints every parcel on the same label; on A4 the extra
// parcels of a multi-parcel send use ELTA's own separate, smaller
// report (sydetaE1.rdlc) — so one PDF can mix two layouts, and
// each layout's styles are carried into the shared <head>.
$view = match (true) {
$paperSize === 'a6' => 'core::shipping.carriers.elta.label-a6',
$data['isChild'] => 'core::shipping.carriers.elta.label-a4-child',
default => 'core::shipping.carriers.elta.label-a4',
};
$html = view($view, $data)->render();
$viewHead = Str::before($html, '<body');
$head ??= $viewHead;
preg_match_all('#<style>.*?</style>#s', $viewHead, $matches);
$styles[$view] = implode('', $matches[0]);
$pages[] = Str::beforeLast(Str::after(Str::after($html, '<body'), '>'), '</body>');
}
$html = Str::before($head, '<style>')
.implode('', $styles)
.'<style>.page { page-break-after: always; } .page:last-child { page-break-after: auto; }</style>'
.Str::afterLast($head, '</style>')
.'<body>'.implode('', $pages).'</body></html>';
$pdf = Pdf::loadHTML($html);
// A6 = 104mm x 148mm, per RDLCPrinter.cs's own DeviceInfo
// override for printer_size==2 (1mm ≈ 2.8346pt) — note this is
// ELTA's own thermal-label size, not the ISO A6 (105x148mm). A
// 4-value paper array plus an orientation string together confuse
// dompdf into doubling the canvas and silently overflowing to a
// second blank page; the array alone is already portrait (height >
// width), so no orientation argument.
$paperSize === 'a6'
? $pdf->setPaper([0, 0, 294.80, 419.53])
: $pdf->setPaper('a4');
return $pdf->output();
}
/**
* @return array<string, mixed>
*/
private function data(Shipment $shipment): array
{
$paperSize = config('elta.label_paper_size', 'a4');
$piece = $this->pieceOf($shipment);
$master = $piece['master'] ?? null;
// A child parcel's label details (stations, service, issue time,
// weight) are the main voucher's.
$meta = array_merge(
$master?->meta?->toArray() ?? [],
array_filter($shipment->meta?->toArray() ?? [], fn ($value) => $value !== null),
);
// 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
// destination station and service; vouchers issued before that flow
// existed don't have them, so fall back to the postcode lookup.
$station = filled($meta['rec_station'] ?? null)
? ['code' => $meta['rec_station'], 'name' => $meta['rec_station_t'] ?? '']
: (fn ($s) => ['code' => $s->code, 'name' => $s->name])($this->areaResolver->resolve($recipient->postcode));
$isChild = $piece !== null && $piece['number'] > 1;
$masterVoucher = $isChild ? ($master?->tracking_reference ?? $shipment->parent_reference) : null;
$services = $isChild ? [] : (array) ($meta['services'] ?? []);
$surcharges = $this->surchargeSlots($services);
$cod = 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'] ?? '');
$occurredAt = strlen($dateTime) >= 12
? Carbon::createFromFormat('YmdHi', substr($dateTime, 0, 12))
: now()->inDisplayTimezone();
$voucherNo = $shipment->tracking_reference;
$ocrLine = $isChild ? '' : (string) ($meta['ocr_line'] ?? '');
$packageCount = (int) ($piece['of'] ?? $meta['package_count'] ?? 1);
$pieceLabel = sprintf('%03d/%03d', $piece['number'] ?? 1, $packageCount);
$generator = new BarcodeGeneratorPNG();
return [
'sender_name' => config('elta.sender_name'),
'sender_address' => config('elta.sender_address'),
'sender_postcode' => config('elta.sender_postcode'),
'sender_area' => config('elta.sender_area'),
'sender_phone' => config('elta.sender_phone'),
'apost_code' => config('elta.apost_code'),
'recipient_name' => $recipient->name,
'recipient_address' => $recipient->address,
'recipient_postcode' => $recipient->postcode,
'recipient_area' => $recipient->city,
'recipient_phone' => $recipient->phones(),
// Mirrors Sydeta.cs::print_vg()'s own dataRow["sender_1..5"] /
// dataRow["rec_1..5"] construction: name on its own line, then
// address, then "TK:<postcode> <area> ΤΗΛ:<phone>" — each of
// the RDLC's rectangle8/rectangle9 boxes renders these as 3
// separate textbox lines (not 5; sender_2/sender_3 and
// rec_2/rec_3 only appear when a line's text exceeds the
// report's 40-char wrap width, which our own data never hits
// in practice for a name/address/contact triple).
'sender_lines' => [
'Κωδικός:'.config('elta.apost_code'),
config('elta.sender_name'),
config('elta.sender_address'),
'TK:'.config('elta.sender_postcode').' '.config('elta.sender_area').' ΤΗΛ:'.config('elta.sender_phone'),
],
'recipient_lines' => $this->partyLines($recipient),
'date' => $occurredAt->format('d/m/Y'),
'time' => $occurredAt->format('H:i'),
'voucher_no' => $voucherNo,
'barcode_voucher' => $this->barcodeDataUri($generator, $voucherNo),
'weight' => $meta['weight'] ?? null,
'package_count' => $packageCount,
// Τεμάχια: "001/002" on the main voucher, "002/002" on the second
// parcel (print_vg() / print_child()). The A4 child report shows
// the piece in its own big box (periexomeno) and just the total
// count under Τεμάχια.
'package_label' => $packageCount > 1
? ($isChild && $paperSize !== 'a6' ? (string) $packageCount : $pieceLabel)
: '001',
'piece_label' => $pieceLabel,
'multiPiece' => $packageCount > 1,
'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,
// 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_title' => $station['name'],
'service_code' => $meta['rec_srv'] ?? '',
'service_name' => $meta['rec_srv_t'] ?? '',
// print_child() doesn't set these, so a child's cells stay empty.
'xreosi' => $isChild ? null : 'ΧΡΕΩΣΗ ΑΠΟΣΤΟΛΕΑ ΠΙΣΤΩΣΗ',
'siimvasi' => $isChild ? null : '131775-9',
'cod_amount' => $meta['cod_amount'] ?? null,
'ocr_line' => $ocrLine,
// The A6 layout's 3-cell row shows a 13-digit reference derived
// from the OCR line, not the barcode's own voucher text —
// confirmed against the real sample: ">14260009814363<..."
// yields reference "1426000981436" (13 chars after the '>').
'ocr_reference' => strlen($ocrLine) >= 14 ? substr($ocrLine, 1, 13) : null,
// The reference we send ELTA as pel_ref_no (order reference;
// older vouchers were sent the order id).
'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
// extra services, in ELTA's own wording and slots.
'sur_1' => $surcharges[1],
'sur_2' => $surcharges[2],
'sur_3' => $surcharges[3],
'sur_4' => null,
...$cod,
// ogos_2: "LxWxH = volume", only when dimensions were given —
// we never send any, so the box stays empty like the client's.
'volumetric_weight' => $meta['volumetric_weight'] ?? null,
'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'),
];
}
/**
* The client's own sur_1..3 placement (Sydeta.cs::print_vg()): 002 is
* always sur_1; 003 is always sur_2; 004 is sur_3 after 003, else
* sur_2. Slots can stay empty (003 alone leaves sur_1 blank).
*
* @param array<int, string> $services ExtraService values
* @return array{1: ?string, 2: ?string, 3: ?string}
*/
private function surchargeSlots(array $services): array
{
$has = fn (string $service) => in_array($service, $services, true);
$slots = [1 => null, 2 => null, 3 => null];
if ($has('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')) {
$slots[2] = self::SERVICE_TEXT['timed_delivery'];
if ($has('saturday_delivery')) {
$slots[3] = self::SERVICE_TEXT['saturday_delivery'];
}
} elseif ($has('saturday_delivery')) {
$slots[2] = self::SERVICE_TEXT['saturday_delivery'];
}
return $slots;
}
/**
* The cash-on-delivery texts exactly as the client prints them
* (Sydeta.cs::print_vg(), cash branch): antik_1 "ΑΝΤΙΚΑΤΑΒΟΛΗ 17.00"
* heads the COD column and the A6 bottom banner, then "* ΑΝΑΛΥΣΗ *" and
* the cash line ("17.00 MΕΤΡΗΤΑ" — the amount as sent in pel_ant_poso,
* the client's own Latin "M" kept). antik_minima / apodiksi / antik_poso
* are the A4 payment stub's. We only ever send cash (no cheques), so
* antik_4..7 stay empty.
*
* @return array<string, mixed>
*/
private function codFields(?float $amount): array
{
if (! $amount) {
return [
'antik_1' => null, 'antik_2' => null, 'antik_3' => null,
'antik_4' => null, 'antik_5' => null, 'antik_6' => null, 'antik_7' => null,
'antik_lines' => [],
'antik_minima' => null,
'apodiksi' => null,
'antik_poso' => '0.00',
];
}
$sent = number_format($amount, 2, '.', '');
$lines = [
'antik_1' => 'ΑΝΤΙΚΑΤΑΒΟΛΗ '.$sent,
'antik_2' => '* ΑΝΑΛΥΣΗ *',
'antik_3' => $sent.' MΕΤΡΗΤΑ',
'antik_4' => null, 'antik_5' => null, 'antik_6' => null, 'antik_7' => null,
];
return [
...$lines,
'antik_lines' => array_values(array_filter($lines)),
'antik_minima' => '* ΠΡΟΣΟΧΗ ΑΝΤΙΚΑΤΑΒΟΛΗ *',
'apodiksi' => '* Απόδειξη Είσπραξης *',
'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
* render the barcode generator's <rect>-based bars (confirmed: the
* SVG output rendered as literal text in a live test), while a
* base64 PNG <img> is unambiguous.
*/
private function barcodeDataUri(BarcodeGeneratorPNG $generator, string $payload): string
{
$png = $generator->getBarcode($payload, BarcodeGeneratorPNG::TYPE_CODE_128);
return 'data:image/png;base64,'.base64_encode($png);
}
/**
* Embedded as a base64 data URI rather than referenced by URL —
* resources/logos/ is only copied into a consuming app's public/ on
* vendor:publish, which isn't guaranteed to have run, and dompdf
* needs either a real filesystem path or a working absolute URL.
*/
private function logoDataUri(string $file): string
{
$path = __DIR__.'/../../../../resources/logos/'.$file;
return 'data:image/png;base64,'.base64_encode(file_get_contents($path));
}
}
@@ -0,0 +1,71 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsCashCollection;
/**
* None of ELTA's four SOAP operations (create/label/track/station) expose
* a price-quote/calculation call, so — like Box Now — this always
* resolves the method's own charge_by + price-break configuration, never
* live pricing. Does not implement SupportsLivePricing.
*
* Implements SupportsCashCollection: an ELTA courier hands a parcel to
* the recipient in person, the same fact as AcsRateDriver, unlike Box
* Now's unattended lockers.
*/
class EltaRateDriver implements ShippingRateInterface, DeclaresFulfillmentType, SupportsCashCollection
{
use ResolvesFixedPricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate;
public function name(): string
{
return 'ELTA Courier';
}
public function fulfillmentType(): string
{
return 'carrier';
}
public function collectsCash(ShippingMethod $method): bool
{
return true;
}
public function description(): string
{
return 'Delivery via ELTA Courier.';
}
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{
if ($this->cartHasExcludedProducts($shippingOptionRequest->shippingRate, $shippingOptionRequest->cart)) {
return null;
}
return $this->resolveFixedPrice(
$shippingOptionRequest->shippingRate,
$shippingOptionRequest->shippingRate->shippingMethod,
$shippingOptionRequest->cart,
);
}
public function on(ShippingRate $shippingRate): self
{
$this->shippingRate = $shippingRate;
return $this;
}
}
@@ -0,0 +1,43 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Modules\Core\Shipping\Carriers\Elta\Exceptions\EltaApiException;
/**
* Normalizes a SoapClient::READ() stdClass result — every ELTA operation's
* response carries the same st_flag (0=success, 1-99=error)/st_title
* shape, live-verified against the real production endpoint (see
* EltaClient's own docblock). Mirrors Acs\AcsResponse's hasError/data
* shape so the rest of the integration (rate driver, fulfillment service)
* reads identically regardless of transport (SOAP here vs. ACS's JSON).
*/
class EltaResponse
{
private function __construct(
public readonly bool $hasError,
public readonly ?string $errorMessage,
public readonly array $data,
) {}
public static function fromSoapResult(mixed $result): self
{
$data = json_decode(json_encode($result), true) ?? [];
$flag = (int) ($data['st_flag'] ?? 0);
return new self(
hasError: $flag !== 0,
errorMessage: $flag !== 0 ? ($data['st_title'] ?? 'Unknown ELTA error') : null,
data: $data,
);
}
public function throwIfError(): self
{
if ($this->hasError) {
throw new EltaApiException($this->errorMessage ?? 'Unknown ELTA API error', $this->data);
}
return $this;
}
}
@@ -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,11 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
class EltaStation
{
public function __construct(
public readonly string $code,
public readonly string $name,
) {}
}
@@ -0,0 +1,14 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta\Exceptions;
use RuntimeException;
use Throwable;
class EltaApiException extends RuntimeException
{
public function __construct(string $message, public readonly array $data = [], ?Throwable $previous = null)
{
parent::__construct($message, previous: $previous);
}
}
@@ -0,0 +1,52 @@
<?php
namespace Modules\Core\Shipping\Carriers\Manual;
use Lunar\Models\Order;
use Lunar\Shipping\Models\ShippingMethod;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Models\Shipment;
use RuntimeException;
/**
* Shipments of a manual carrier (ManualRateDriver): recorded here only,
* with no carrier API. The carrier's name and tracking URL template are
* copied from the shipping method into the shipment's meta, so links keep
* working if the method is edited later. Tracking updates are added by
* staff from the order page.
*/
class ManualFulfillmentService implements CarrierFulfillmentInterface
{
public function createShipment(Order $order, ShipmentRequest $request): Shipment
{
$code = $order->shippingAddress?->shipping_option;
$method = $code ? ShippingMethod::where('code', $code)->first() : null;
return Shipment::create([
'order_id' => $order->id,
'carrier' => 'manual',
'source' => Shipment::SOURCE_MANUAL,
'tracking_reference' => filled($request->trackingReference) ? trim($request->trackingReference) : null,
'meta' => [
'carrier_name' => $method?->data['carrier_name'] ?? null,
'tracking_url' => $method?->data['tracking_url'] ?? null,
'package_count' => $request->packageCount,
'weight' => $request->weight,
'cod_amount' => $request->paymentMode === 'cod'
? (float) ($request->amountToCollect ?? $order->total->decimal)
: null,
],
]);
}
public function printLabel(Shipment $shipment): string
{
throw new RuntimeException('Manual carriers have no printable label.');
}
public function cancelShipment(Shipment $shipment): void
{
$shipment->update(['cancelled_at' => now()]);
}
}
@@ -0,0 +1,74 @@
<?php
namespace Modules\Core\Shipping\Carriers\Manual;
use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Concerns\ExcludesRestrictedProducts;
use Modules\Core\Shipping\Concerns\ResolvesFixedPricing;
use Modules\Core\Shipping\Contracts\DeclaresFulfillmentType;
use Modules\Core\Shipping\Contracts\SupportsCashCollection;
/**
* A carrier we have no integration with (e.g. Geniki), added by the
* merchant as a shipping method with this driver. The method's `data`
* holds the carrier's details, set in the admin
* (ShippingMethodResourceExtension):
*
* - carrier_name: shown to staff and customers on its shipments;
* - tracking_url: optional link template, `{number}` = the voucher number;
* - collects_cash: whether its courier takes cash on delivery.
*
* Priced like Store Pickup / Box Now (charge_by + price breaks); its
* shipments are created and tracked by hand (ManualFulfillmentService).
*/
class ManualRateDriver implements ShippingRateInterface, DeclaresFulfillmentType, SupportsCashCollection
{
use ResolvesFixedPricing;
use ExcludesRestrictedProducts;
public ShippingRate $shippingRate;
public function name(): string
{
return 'Manual carrier';
}
public function description(): string
{
return 'A carrier without an integration — shipments are entered and tracked by hand.';
}
public function fulfillmentType(): string
{
return 'carrier';
}
public function collectsCash(ShippingMethod $method): bool
{
return (bool) ($method->data['collects_cash'] ?? false);
}
public function resolve(ShippingOptionRequest $shippingOptionRequest): ?ShippingOption
{
if ($this->cartHasExcludedProducts($shippingOptionRequest->shippingRate, $shippingOptionRequest->cart)) {
return null;
}
return $this->resolveFixedPrice(
$shippingOptionRequest->shippingRate,
$shippingOptionRequest->shippingRate->shippingMethod,
$shippingOptionRequest->cart,
);
}
public function on(ShippingRate $shippingRate): self
{
$this->shippingRate = $shippingRate;
return $this;
}
}
@@ -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;
}
@@ -0,0 +1,14 @@
<?php
namespace Modules\Core\Shipping\Contracts;
/**
* A carrier whose shipments are created as pending records and only get a
* voucher number when the label is printed (ELTA). Until then the shipment
* has no tracking_reference and can still be cancelled; printLabel()
* issues the voucher. Its shipments get a tab on the Pending Vouchers
* screen, like carriers with a manifest step.
*/
interface IssuesVoucherOnPrint
{
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Illuminate\Support\Collection;
use Modules\Core\Shipping\Models\Shipment;
/**
* A carrier that can print several shipments' labels as one PDF — behind
* the Pending Vouchers screen's "Print selected". Same side effects as
* printLabel() per shipment (issuing pending vouchers, setting
* label_printed_at).
*/
interface SupportsBatchLabels
{
/**
* @param Collection<int, Shipment> $shipments
*/
public function printLabels(Collection $shipments): string;
}
@@ -2,6 +2,8 @@
namespace Modules\Core\Shipping\Contracts;
use Lunar\Shipping\Models\ShippingMethod;
/**
* A shipping rate driver implements this to say a person is physically
* present at handoff to collect cash — true for a courier like
@@ -14,6 +16,10 @@ namespace Modules\Core\Shipping\Contracts;
* Contracts\DeclaresFulfillmentType, which only distinguishes carrier vs.
* store_pickup and can't tell ACS and Box Now apart (both 'carrier').
*
* Receives the shipping method, since for some drivers it's a per-method
* setting — a manual carrier's "Courier collects cash on delivery" toggle
* (Modules\Core\Shipping\Carriers\Manual\ManualRateDriver).
*
* Not implemented at all means "no" — a driver with no opinion here
* (any of table-rate-shipping's own generic drivers, if one were ever
* re-added) is treated as not supporting cash collection, the safer
@@ -21,5 +27,5 @@ namespace Modules\Core\Shipping\Contracts;
*/
interface SupportsCashCollection
{
public function collectsCash(): bool;
public function collectsCash(ShippingMethod $method): bool;
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Modules\Core\Shipping\Enums\ExtraService;
/**
* A carrier that offers extra services (Saturday delivery, insurance, …).
* Only the carrier knows its own codes — the rest of the app deals in the
* normalized ExtraService cases.
*/
interface SupportsExtraServices
{
/**
* The services this carrier offers, keyed by ExtraService value, each
* mapped to the carrier's own code.
*
* @return array<string, string>
*/
public function extraServices(): array;
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Carbon\CarbonInterface;
use Modules\Core\Shipping\DTOs\CarrierVoucher;
/**
* A carrier that can list the vouchers on our account for a date range —
* feeds the Carrier Vouchers screen (returns, and vouchers not linked to
* any order yet).
*/
interface SupportsVoucherListing
{
/**
* @return iterable<CarrierVoucher>
*/
public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable;
}
@@ -0,0 +1,14 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Modules\Core\Shipping\DTOs\CarrierVoucher;
/**
* A carrier that can look up a single voucher by number — so a voucher can
* be found and linked to an order before the next list sync.
*/
interface SupportsVoucherLookup
{
public function lookupVoucher(string $voucherNumber): ?CarrierVoucher;
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Modules\Core\Shipping\DTOs;
use Carbon\CarbonInterface;
use Modules\Core\Shipping\Enums\TrackingStatus;
/**
* One voucher as a carrier's own list or lookup reports it — including ones
* we didn't create (made in the carrier's portal, typed in by a courier)
* and return vouchers. Stored as a `synced` Shipment by
* Modules\Core\Shipping\Services\CarrierVoucherSync.
*/
class CarrierVoucher
{
public function __construct(
public readonly string $carrier,
public readonly string $voucherNumber,
public readonly ?string $reference = null,
public readonly ?string $recipientName = null,
public readonly ?string $postcode = null,
public readonly ?string $phone = null,
// The carrier's own words; $status is the same mapped to ours.
public readonly ?string $statusText = null,
public readonly ?TrackingStatus $status = null,
public readonly ?float $codAmount = null,
public readonly bool $isReturn = false,
public readonly ?string $originalVoucher = null,
public readonly ?CarbonInterface $date = null,
public readonly array $raw = [],
) {}
}
+36
View File
@@ -2,6 +2,8 @@
namespace Modules\Core\Shipping\DTOs;
use Modules\Core\Shipping\Enums\ExtraService;
/**
* Carrier-agnostic input for CarrierFulfillmentInterface::createShipment().
* Every field is optional — a carrier reads only what it needs and ignores
@@ -19,6 +21,11 @@ class ShipmentRequest
* an order that doesn't fit one compartment. Empty for every other
* carrier, which ships as a single package described by $weight
* instead.
* @param array<int, ExtraService> $services staff-picked extra
* services; a carrier only receives ones it declares via
* SupportsExtraServices.
* @param ?float $insuranceAmount when ExtraService::Insurance is picked.
* @param ?string $deliveryUntil "HH:MM", when ExtraService::TimedDelivery is picked.
*/
public function __construct(
public readonly ?float $weight = null,
@@ -27,5 +34,34 @@ class ShipmentRequest
public readonly ?string $paymentMode = null,
public readonly ?float $amountToCollect = null,
public readonly array $boxes = [],
public readonly array $services = [],
public readonly ?float $insuranceAmount = null,
public readonly ?string $deliveryUntil = 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
{
return in_array($service, $this->services, true);
}
/**
* @return array<int, string>
*/
public function serviceValues(): array
{
return array_map(fn (ExtraService $service) => $service->value, $this->services);
}
}
+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);
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace Modules\Core\Shipping\Enums;
/**
* Extra carrier services, normalized: "Saturday delivery" is the same case
* whatever code each carrier uses for it. A carrier declares which ones it
* offers and maps them to its own codes via
* Modules\Core\Shipping\Contracts\SupportsExtraServices. Staff pick them
* per shipment; customers never see them.
*/
enum ExtraService: string
{
case SaturdayDelivery = 'saturday_delivery';
case MorningDelivery = 'morning_delivery';
/** Delivery by a set time / within a time slot. */
case TimedDelivery = 'timed_delivery';
case SameDayDelivery = 'same_day_delivery';
case Insurance = 'insurance';
case SpecialHandling = 'special_handling';
case ReturnDocuments = 'return_documents';
case RemoteArea = 'remote_area';
case Protocol = 'protocol';
case Refrigerated = 'refrigerated';
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
* of the admin UI.
*/
public function label(): string
{
return match ($this) {
self::SaturdayDelivery => 'Saturday delivery',
self::MorningDelivery => 'Morning delivery',
self::TimedDelivery => 'Timed delivery',
self::SameDayDelivery => 'Same-day delivery',
self::Insurance => 'Insurance',
self::SpecialHandling => 'Special handling',
self::ReturnDocuments => 'Return documents',
self::RemoteArea => 'Remote area',
self::Protocol => 'Protocol',
self::Refrigerated => 'Refrigerated',
self::ExchangePackage => 'Exchange package',
self::PostBoxDelivery => 'PostBox delivery',
};
}
}
@@ -3,17 +3,23 @@
namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Notifications\Notification;
use Filament\Infolists\Components\TextEntry;
use Illuminate\Support\Collection;
use Modules\Core\Shipping\Models\ShipmentInfo;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section;
use Illuminate\Support\Facades\URL;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
use Modules\Core\Shipping\Models\Shipment;
use Throwable;
use Modules\Core\Shipping\Support\BoxNowShipping;
/**
* Adds a "Shipments" section to the order page's main column — previously
@@ -85,8 +91,9 @@ class OrderShipmentsExtension extends ViewPageExtension
->contained(true)
->schema([
TextEntry::make('tracking_reference')
->label(fn (Shipment $record) => $this->carrierLabel($record))
->label(fn (Shipment $record) => $record->carrierLabel())
->inlineLabel()
->placeholder('Voucher not issued yet')
->copyable(),
TextEntry::make('status')
->label('Status')
@@ -104,14 +111,58 @@ class OrderShipmentsExtension extends ViewPageExtension
now()->addMinutes(5),
['shipment' => $record->id],
), shouldOpenInNewTab: true)
->visible(fn (Shipment $record) => ! $record->cancelled_at),
->visible(fn (Shipment $record) => $record->hasCarrierLabel()),
Action::make('open_carrier_tracking')
->label('Carrier tracking')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Shipment $record) => $record->trackingUrl(), shouldOpenInNewTab: true)
->visible(fn (Shipment $record) => filled($record->trackingUrl())),
Action::make('edit_tracking_reference')
->label('Edit number')
->icon('heroicon-o-pencil-square')
->fillForm(fn (Shipment $record) => ['tracking_reference' => $record->tracking_reference])
->schema(fn (Shipment $record) => [
TextInput::make('tracking_reference')
->label('Voucher / tracking number')
->required($record->source === Shipment::SOURCE_MANUAL_VOUCHER)
->unique(Shipment::class, 'tracking_reference', ignorable: $record),
])
->action(fn (Shipment $record, array $data) => $record->update([
'tracking_reference' => filled($data['tracking_reference']) ? trim($data['tracking_reference']) : null,
]))
->visible(fn (Shipment $record) => in_array($record->source, [Shipment::SOURCE_MANUAL, Shipment::SOURCE_MANUAL_VOUCHER], true) && ! $record->isCancelled()),
Action::make('add_tracking_update')
->label('Add tracking update')
->icon('heroicon-o-plus-circle')
->schema([
Select::make('status')
->label('Status')
->options(collect(TrackingStatus::cases())
->reject(fn (TrackingStatus $status) => in_array($status, [TrackingStatus::Unknown, TrackingStatus::Pending], true))
->mapWithKeys(fn (TrackingStatus $status) => [$status->value => (string) str($status->value)->replace('_', ' ')->title()]))
->native(false)
->required(),
DateTimePicker::make('occurred_at')
->label('When')
->seconds(false)
->default(now())
->required(),
TextInput::make('location')->label('Location'),
TextInput::make('message')
->label('Message')
->helperText('Shown to the customer on their order page.'),
])
->action(fn (Shipment $record, array $data) => $this->addTrackingUpdate($record, $data))
->visible(fn (Shipment $record) => in_array($record->source, [Shipment::SOURCE_MANUAL, Shipment::SOURCE_MANUAL_VOUCHER], true) && ! $record->isCancelled()),
Action::make('cancel_shipment')
->label('Cancel')
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->modalDescription('Cancels this shipment with the carrier. This cannot be undone.')
->action(fn (Shipment $record) => $this->cancel($record))
->modalDescription(fn (Shipment $record) => $record->cancelsLocallyOnly()
? 'Marks this shipment as cancelled here only. Void the voucher with the courier as well.'
: 'Cancels this shipment with the carrier. This cannot be undone.')
->action(fn (Shipment $record) => ShipmentResource::cancelShipment($record))
->visible(fn (Shipment $record) => ! $record->cancelled_at),
]),
RepeatableEntry::make('shipmentInfo')
@@ -120,10 +171,14 @@ class OrderShipmentsExtension extends ViewPageExtension
->hidden(fn (Shipment $record) => $record->shipmentInfo->isEmpty())
->schema([
TextEntry::make('status')
->label(fn (ShipmentInfo $record) => $record->occurred_at->format('Y-m-d H:i'))
->label(fn (ShipmentInfo $record) => $record->occurred_at->inDisplayTimezone()->format('Y-m-d H:i'))
->inlineLabel()
->state(fn (ShipmentInfo $record) => (string) str($record->status->value)->replace('_', ' ')->title())
->helperText(fn (ShipmentInfo $record) => $record->location),
// Our status as the label, the carrier's own wording underneath.
->helperText(fn (ShipmentInfo $record) => collect([
$record->carrier_status !== 'manual' ? $record->carrier_status : null,
$record->location,
])->filter()->unique()->implode(' · ') ?: null),
]),
]),
]);
@@ -142,23 +197,78 @@ class OrderShipmentsExtension extends ViewPageExtension
return $record->shipmentInfo->sortBy('occurred_at')->values();
}
private function carrierLabel(Shipment $record): string
/**
* A manual carrier's checkpoint, entered by staff. Dispatches the same
* event as carrier polling, so the order's status and emails follow it
* exactly like an integrated carrier's.
*/
private function addTrackingUpdate(Shipment $record, array $data): void
{
return match ($record->carrier) {
'acs' => 'ACS',
'box-now' => 'Box Now',
default => (string) str($record->carrier)->title(),
};
$info = ShipmentInfo::create([
'shipment_id' => $record->id,
'status' => TrackingStatus::from($data['status']),
'carrier_status' => 'manual',
'message' => $data['message'] ?? null,
'location' => $data['location'] ?? null,
'occurred_at' => $data['occurred_at'],
]);
ShipmentStatusUpdatedByCarrier::dispatch($info);
Notification::make()
->title('Tracking update added.')
->success()
->send();
}
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;
}
if ($cod = $record->meta['cod_amount'] ?? null) {
$parts[] = 'Cash on delivery: €'.number_format((float) $cod, 2);
}
$services = collect($record->meta['services'] ?? [])
->map(fn (string $value) => ExtraService::tryFrom($value)?->label())
->filter();
if ($services->isNotEmpty()) {
$parts[] = $services->implode(', ');
}
if ($record->source === Shipment::SOURCE_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);
}
@@ -187,26 +297,4 @@ class OrderShipmentsExtension extends ViewPageExtension
};
}
private function cancel(Shipment $record): void
{
$service = app(CarrierFulfillmentInterface::class, ['carrier' => $record->carrier]);
try {
$service->cancelShipment($record);
} catch (Throwable $e) {
report($e);
Notification::make()
->title('Failed to cancel shipment: '.$e->getMessage())
->color('danger')
->send();
return;
}
Notification::make()
->title('Shipment cancelled.')
->color('success')
->send();
}
}
+248 -60
View File
@@ -3,17 +3,30 @@
namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\Action;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\TimePicker;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Order\DTOs\OrderFulfillmentResult;
use Modules\Core\Order\Services\OrderFulfillmentService;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsExtraServices;
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Filament\Support\VoucherForm;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Support\WeightCalculator;
use Throwable;
/**
* Filament wiring only (labels, icons, visibility, form schema) for the
@@ -35,32 +48,36 @@ use Modules\Core\Shipping\Support\WeightCalculator;
*
* "Create Shipment" is its own separate header action, visible only for a
* carrier order sitting at 'ready_for_dispatch' — this is the one action
* that talks to a real carrier API and writes Order::status to
* 'dispatched' as a side effect of that succeeding, so it needs its own
* weight/locker inputs specific to that one real-world action, not
* bundled into the general-purpose status select where they'd appear for
* every revert/manual-override use of 'dispatched' too. The form branches
* on which carrier the order actually uses
* (OrderFulfillmentService::carrierFor()): a weight-billed carrier (ACS)
* gets a single TOTAL weight field for the whole shipment (ACS has no
* per-package weight concept — one Weight value is sent alongside
* Item_Quantity in the same ACS_Create_Voucher call, see
* AcsFulfillmentService::createShipment()), pre-filled from the order's
* own line weights (Modules\Core\Shipping\Support\WeightCalculator) but
* still staff-editable, plus a package count (ShipmentRequest::
* $packageCount) — more than 1 issues a main voucher plus a
* multi-part sub-voucher per extra package (persistMultipartVouchers()),
* each recorded as its own Shipment row sharing the same total weight in
* meta. Box Now, which bills by compartment size rather than
* weight, gets a repeatable list of boxes (one row per physical parcel,
* each with its own S/M/L size) instead — see
* Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService for how
* multiple boxes become multiple Shipment rows from one delivery request.
* Box Now's locker field defaults from the shopper's own checkout
* 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.
* that talks to a real carrier API, so it needs its own weight/locker
* inputs specific to that one real-world action. It does NOT move the
* order to 'dispatched': that happens when the carrier reports picking the
* parcel up (AdvanceFulfillmentOnCarrierCheckpoint). Staff also pick the
* carrier's extra services here (Saturday delivery, insurance, …) from the
* normalized ExtraService list the carrier offers. The form depends on
* the order's carrier (OrderFulfillmentService::carrierFor()):
*
* - ELTA and Box Now (CreatesStandaloneVouchers) get the full voucher
* form — Modules\Core\Shipping\Filament\Support\VoucherForm, the one
* Carrier Vouchers' "Create voucher" uses — prefilled from the order:
* recipient and delivery instructions from the shipping address, weight
* from the order lines (WeightCalculator), Box Now's locker from the
* customer's checkout choice, cash on delivery read-only from the
* payment method. What staff change there (a phone, a typo in the
* address, another locker) goes to the carrier only
* (ShipmentRequest::$recipient); the order keeps what the customer
* entered.
* - A manual carrier gets its optional tracking number and a package
* count.
* - ACS gets a total weight (prefilled from the line weights) and a
* package count — more than 1 issues a main voucher plus a sub-voucher
* per extra package, each its own Shipment row.
*
* "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
* filled a pre-numbered paper voucher, or the courier wrote his own at
* pickup. Tracking picks its history up like any other shipment; the
* carrier's lookup (when it has one) previews what it reports for the
* number before saving.
*
* "Mark Paid" is a third, separate header action — Order::paid is
* independent of `status` (see OrderStatusFlow's own docblock), so it
@@ -86,6 +103,7 @@ class OrderViewExtension extends ViewPageExtension
: true);
$actions[] = $this->createShipmentAction();
$actions[] = $this->addManualVoucherAction();
$actions[] = $this->updateStatusAction();
$actions[] = $this->markPaidAction();
@@ -98,12 +116,34 @@ class OrderViewExtension extends ViewPageExtension
->label('Create Shipment')
->icon('heroicon-o-truck')
->modalSubmitActionLabel('Create Shipment')
->modalWidth(fn (Order $record) => VoucherForm::handles($this->service()->carrierFor($record)) ? '3xl' : null)
->schema(function (Order $record) {
$isBoxNow = $this->service()->carrierFor($record) === 'box-now';
$lockerId = $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null;
$carrier = $this->service()->carrierFor($record);
if (! $isBoxNow) {
// 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') {
return [
TextInput::make('tracking_reference')
->label('Voucher / tracking number')
->unique(Shipment::class, 'tracking_reference', ignoreRecord: false)
->helperText('Optional — add it later from the shipment if the courier hasn\'t given one yet.'),
TextInput::make('package_count')
->label('Number of packages')
->numeric()
->integer()
->minValue(1)
->default(1)
->required(),
];
}
// Weight-billed carriers without the voucher form (ACS).
return [...[
TextInput::make('weight')
->label('Total weight (kg)')
->numeric()
@@ -118,32 +158,7 @@ class OrderViewExtension extends ViewPageExtension
->default(1)
->required()
->helperText('More than 1 issues a main voucher plus a sub-voucher per extra package, all sharing the total weight above.'),
];
}
return [
TextInput::make('destination_location_id')
->label('Box Now locker ID')
->default($lockerId)
->required()
->helperText($lockerId
? '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)];
})
->action(function (Order $record, array $data, Action $action) {
// Derived from the order itself, never from staff input —
@@ -161,19 +176,33 @@ class OrderViewExtension extends ViewPageExtension
// full payment was already settled, and never collect it.
$isCod = app(OrderStatusFlow::class)->isCod($record);
$result = $this->service()->createShipmentAndDispatch(
$record,
new ShipmentRequest(
$services = array_map(fn (string $value) => ExtraService::from($value), $data['services'] ?? []);
$request = VoucherForm::handles($this->service()->carrierFor($record))
// The recipient as staff confirmed it in the form.
? VoucherForm::request($data, $isCod ? $record->total->decimal : null, VoucherForm::recipient($data))
: new ShipmentRequest(
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
packageCount: (int) ($data['package_count'] ?? 1),
destinationLocationId: $data['destination_location_id'] ?? null,
paymentMode: $isCod ? 'cod' : 'prepaid',
amountToCollect: $isCod ? $record->total->decimal : null,
boxes: collect($data['boxes'] ?? [])->pluck('size')->all(),
),
services: $services,
insuranceAmount: in_array(ExtraService::Insurance, $services, true) && filled($data['insurance_amount'] ?? null)
? (float) $data['insurance_amount']
: null,
deliveryUntil: in_array(ExtraService::TimedDelivery, $services, true)
? ($data['delivery_until'] ?? null)
: null,
trackingReference: $data['tracking_reference'] ?? null,
);
$result = $this->service()->createShipment($record, $request);
$this->notify($result);
// The page still holds the shipments loaded before this action.
$record->load('shipments.shipmentInfo');
if (! $result->success) {
$action->halt();
@@ -182,6 +211,165 @@ class OrderViewExtension extends ViewPageExtension
->visible(fn (Order $record) => $this->service()->canCreateShipment($record));
}
/**
* The extra services the order's carrier offers, plus the details some
* of them need. Nothing is shown for carriers that offer none.
*/
private function extraServiceFields(Order $record): array
{
$options = $this->extraServiceOptions($this->service()->carrierFor($record));
if ($options === []) {
return [];
}
$ticked = fn (Get $get, ExtraService $service) => in_array($service->value, $get('services') ?? [], true);
return [
CheckboxList::make('services')
->label('Extra services')
->options($options)
->columns(2)
->live(),
TextInput::make('insurance_amount')
->label('Insured value')
->numeric()
->minValue(0)
->prefix('€')
->default($record->total->decimal)
->visible(fn (Get $get) => $ticked($get, ExtraService::Insurance))
->required(fn (Get $get) => $ticked($get, ExtraService::Insurance)),
TimePicker::make('delivery_until')
->label('Deliver by')
->seconds(false)
->visible(fn (Get $get) => $ticked($get, ExtraService::TimedDelivery))
->required(fn (Get $get) => $ticked($get, ExtraService::TimedDelivery)),
];
}
/**
* @return array<string, string> ExtraService value => label
*/
private function extraServiceOptions(?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 function addManualVoucherAction(): Action
{
return Action::make('add_manual_voucher')
->label('Add manual voucher')
->icon('heroicon-o-pencil-square')
->modalDescription("For a voucher that exists at the carrier but wasn't created here — e.g. the carrier's system was down, or the courier wrote his own voucher at pickup. Its tracking history is picked up from the carrier.")
->modalSubmitActionLabel('Add voucher')
->schema(fn (Order $record) => [
Select::make('carrier')
->label('Carrier')
->options($this->integratedCarriers())
->default($this->service()->carrierFor($record))
->native(false)
->required()
->live()
->afterStateUpdated(fn (Set $set) => $set('lookup', null)),
TextInput::make('voucher_number')
->label('Voucher number')
->required()
->unique(Shipment::class, 'tracking_reference', ignoreRecord: false)
->live(onBlur: true)
->afterStateUpdated(fn (Set $set) => $set('lookup', null))
->suffixAction(
Action::make('lookup')
->icon('heroicon-o-magnifying-glass')
->tooltip('Look up at the carrier')
->action(fn (Get $get, Set $set) => $set('lookup', $this->describeLookup($get('carrier'), $get('voucher_number')))),
),
Hidden::make('lookup'),
Placeholder::make('lookup_preview')
->label('Carrier reports')
->content(fn (Get $get) => $get('lookup'))
->visible(fn (Get $get) => filled($get('lookup'))),
CheckboxList::make('services')
->label('Extra services')
->options(fn (Get $get) => $this->extraServiceOptions($get('carrier')))
->columns(2)
->visible(fn (Get $get) => $this->extraServiceOptions($get('carrier')) !== []),
])
->action(function (Order $record, array $data, Action $action) {
$result = $this->service()->addManualVoucher(
$record,
$data['carrier'],
$data['voucher_number'],
array_map(fn (string $value) => ExtraService::from($value), $data['services'] ?? []),
);
$this->notify($result);
// The page still holds the shipments loaded before this action.
$record->load('shipments.shipmentInfo');
if (! $result->success) {
$action->halt();
}
})
->visible(fn (Order $record) => $this->service()->canAddManualVoucher($record));
}
/**
* Carriers with an API integration — manual carriers have no voucher
* of their own to look up or track.
*
* @return array<string, string>
*/
private function integratedCarriers(): array
{
return collect(Shipping::getSupportedDrivers())
->filter(fn ($driver, string $carrier) => $carrier !== 'manual'
&& app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]) !== null)
->map(fn ($driver) => $driver->name())
->all();
}
private function describeLookup(?string $carrier, ?string $number): string
{
if (blank($carrier) || blank($number)) {
return 'Pick the carrier and enter the voucher number first.';
}
$service = app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
if (! $service instanceof SupportsVoucherLookup) {
return "This carrier doesn't support looking vouchers up — you can still add it.";
}
try {
$voucher = $service->lookupVoucher(trim($number));
} catch (Throwable $e) {
report($e);
return "Couldn't reach the carrier ({$e->getMessage()}) — you can still add it; its history is picked up once the carrier responds.";
}
if (! $voucher) {
return 'The carrier has no record of this voucher yet — you can still add it.';
}
return collect([
$voucher->recipientName,
$voucher->postcode,
$voucher->reference ? 'Ref. '.$voucher->reference : null,
$voucher->statusText,
$voucher->codAmount ? 'COD €'.number_format($voucher->codAmount, 2) : null,
$voucher->isReturn ? 'Return voucher' : null,
])->filter()->implode(' · ');
}
private function updateStatusAction(): Action
{
return Action::make('update_status')
@@ -8,6 +8,8 @@ use Filament\Schemas\Components\Concerns\HasChildComponents;
use Filament\Schemas\Components\Utilities\Get;
use InvalidArgumentException;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\ResourceExtension;
@@ -110,15 +112,48 @@ class ShippingMethodResourceExtension extends ResourceExtension
}
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema(
$this->replaceChargeByField($component->getDefaultChildComponents())
$children = $this->replaceChargeByField($component->getDefaultChildComponents());
// The manual carrier's settings sit next to charge_by, in
// the same `data` group.
$hasChargeBy = collect($children)->contains(
fn (Component $child) => method_exists($child, 'getName') && $child->getName() === 'charge_by'
);
$component->schema($hasChargeBy ? [...$children, ...$this->manualCarrierFields()] : $children);
}
return $component;
}, $components);
}
/**
* Settings for a carrier without an integration (ManualRateDriver),
* stored in the method's `data`. Shown only for that driver.
*
* @return array<Component>
*/
private function manualCarrierFields(): array
{
$isManual = fn (Get $get) => $get('../driver') === 'manual';
return [
TextInput::make('carrier_name')
->label('Carrier name')
->helperText('Shown to staff and customers on this carrier\'s shipments, e.g. "Geniki Taxydromiki".')
->visible($isManual)
->required($isManual),
TextInput::make('tracking_url')
->label('Tracking URL')
->helperText('Optional. The carrier\'s tracking page, with {number} where the voucher number goes, e.g. https://example.com/track?number={number}. For an integrated carrier it\'s only used on vouchers typed in by hand.')
->rule('starts_with:http://,https://'),
Toggle::make('collects_cash')
->label('Courier collects cash on delivery')
->helperText('Offers cash on delivery at checkout for this shipping method.')
->visible($isManual),
];
}
private function chargeBySelect(): Select
{
return Select::make('charge_by')
@@ -0,0 +1,459 @@
<?php
namespace Modules\Core\Shipping\Filament\Resources;
use Filament\Actions\Action;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Lunar\Admin\Filament\Resources\OrderResource;
use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource\Pages\ListCarrierVouchers;
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource\Pages\ViewCarrierVoucher;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Models\ShipmentInfo;
use Modules\Core\Shipping\Support\BoxNowShipping;
use Modules\Core\Shipping\Support\ShipmentTimelineLogger;
/**
* Every voucher with a number — ours and the ones carriers report
* (CarrierVoucherSync) — to follow returns and reconcile vouchers that
* never made it onto an order. Tabs: All / Unlinked / Returns. The list
* shows the tracking number, our status, order, recipient and phone; the view page has the
* details (carrier, voucher, carrier status, COD, services, return info,
* suggested orders, tracking history). Vouchers are never linked
* automatically: each carries suggested orders (its reference match
* first, then same postcode + phone or name), and
* "Link to order" attaches it, after which it counts towards that order
* like any other shipment.
*/
class CarrierVoucherResource extends Resource
{
protected static ?string $model = Shipment::class;
protected static ?string $slug = 'carrier-vouchers';
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-queue-list';
protected static string | \UnitEnum | null $navigationGroup = 'Sales';
protected static ?string $navigationLabel = 'Carrier Vouchers';
protected static ?string $modelLabel = 'Carrier voucher';
protected static ?int $navigationSort = 101;
private const SOURCES = [
Shipment::SOURCE_CREATED => 'Created here',
Shipment::SOURCE_MANUAL_VOUCHER => 'Manual voucher',
Shipment::SOURCE_MANUAL => 'Manual 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
{
return self::vouchers(parent::getEloquentQuery())
->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
{
return $table
->defaultSort('created_at', 'desc')
->columns([
TextColumn::make('tracking_reference')
->label('Tracking number')
->placeholder('Not issued yet')
->description(fn (Shipment $record) => $record->parcelDescription())
->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')
->label('Status')
->state(fn (Shipment $record) => self::status($record))
->placeholder('None yet')
->badge()
->color(fn (Shipment $record) => self::statusColor($record)),
TextColumn::make('order.reference')
->label('Order')
->placeholder('None'),
TextColumn::make('recipient')
->label('Recipient')
->state(fn (Shipment $record) => self::recipient($record))
->placeholder('—')
// One search box for the voucher number, order reference,
// recipient and phone.
->searchable(query: fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
->where('tracking_reference', 'ilike', "%{$search}%")
->orWhere('meta->recipient_name', '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
->where('reference', 'ilike', "%{$search}%")
->orWhereHas('shippingAddress', fn (Builder $address) => $address
->where('first_name', 'ilike', "%{$search}%")
->orWhere('last_name', 'ilike', "%{$search}%")
->orWhere('contact_phone', 'ilike', "%{$search}%"))))),
TextColumn::make('phone')
->label('Phone')
->state(fn (Shipment $record) => self::phone($record))
->placeholder('—'),
])
->filters([
SelectFilter::make('carrier')
->options(fn () => collect(Shipping::getSupportedDrivers())->map(fn ($driver) => $driver->name())->all()),
SelectFilter::make('source')->options(self::SOURCES),
])
->recordActions([
self::printAction(),
ViewAction::make(),
]);
}
public static function infolist(Schema $schema): Schema
{
return $schema->components([
Section::make('Voucher')
->columns(3)
->schema([
TextEntry::make('carrier')
->formatStateUsing(fn (Shipment $record) => $record->carrierLabel()),
TextEntry::make('tracking_reference')
->label('Voucher')
->placeholder('Not issued yet — printing issues it')
->helperText(fn (Shipment $record) => $record->parcelDescription())
->copyable(),
TextEntry::make('source')
->badge()
->formatStateUsing(fn (string $state) => self::SOURCES[$state] ?? $state)
->color(fn (string $state) => self::SOURCE_COLORS[$state] ?? 'gray'),
TextEntry::make('status')
->state(fn (Shipment $record) => self::status($record))
->placeholder('No updates yet')
->badge()
->color(fn (Shipment $record) => self::statusColor($record)),
TextEntry::make('carrier_status')
->label('Carrier status')
->state(fn (Shipment $record) => self::carrierStatus($record))
->placeholder('—'),
TextEntry::make('sent_reference')
->label('Reference sent to the carrier')
->state(fn (Shipment $record) => self::sentReference($record))
->placeholder('—'),
TextEntry::make('meta.voucher_date')->label('Voucher date')->date()->placeholder('—'),
TextEntry::make('cod')
->label('Cash on delivery')
->state(fn (Shipment $record) => ($amount = $record->meta['cod_amount'] ?? null)
? '€'.number_format((float) $amount, 2)
: null)
->placeholder('—'),
TextEntry::make('services')
->label('Extra services')
->state(fn (Shipment $record) => collect($record->meta['services'] ?? [])
->map(fn (string $value) => ExtraService::tryFrom($value)?->label())
->filter()
->implode(', ') ?: null)
->placeholder('—'),
TextEntry::make('created_at')->label('Recorded')->dateTime(),
]),
Section::make('Recipient')
->columns(3)
->schema([
TextEntry::make('recipient')
->label('Name')
->state(fn (Shipment $record) => self::recipient($record))
->placeholder('—'),
TextEntry::make('postcode')
->state(fn (Shipment $record) => self::postcode($record))
->placeholder('—'),
TextEntry::make('phone')
->state(fn (Shipment $record) => self::phone($record))
->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')
->schema([
TextEntry::make('order.reference')
->label('Linked order')
->placeholder('Not linked to an order')
->url(fn (Shipment $record) => $record->order_id
? OrderResource::getUrl('order', ['record' => $record->order_id])
: null),
TextEntry::make('suggested')
->label('Suggested orders')
->state(fn (Shipment $record) => array_values(self::orderOptions(Order::with('shippingAddress')
->whereKey($record->meta['suggested_order_ids'] ?? [])
->get())))
->listWithLineBreaks()
->helperText('The order its reference points to first, then orders with the same postcode and phone or name.')
->visible(fn (Shipment $record) => ! $record->order_id && filled($record->meta['suggested_order_ids'] ?? [])),
]),
Section::make('Return')
->columns(2)
->schema([
TextEntry::make('return_kind')
->label('Reported as')
->state(fn (Shipment $record) => $record->isReturn() ? 'Return voucher' : 'Being returned to sender'),
TextEntry::make('meta.original_voucher')->label('Original voucher')->placeholder('—'),
])
->visible(fn (Shipment $record) => self::isReturnLike($record)),
Section::make('Tracking history')
->schema([
TextEntry::make('no_history')
->hiddenLabel()
->state('No tracking updates yet.')
->visible(fn (Shipment $record) => $record->shipmentInfo->isEmpty()),
RepeatableEntry::make('history')
->hiddenLabel()
->state(fn (Shipment $record) => $record->shipmentInfo->sortByDesc('occurred_at')->values())
->visible(fn (Shipment $record) => $record->shipmentInfo->isNotEmpty())
->schema([
TextEntry::make('status')
->label(fn (ShipmentInfo $record) => $record->occurred_at->inDisplayTimezone()->format('Y-m-d H:i'))
->inlineLabel()
->state(fn (ShipmentInfo $record) => (string) str($record->status->value)->replace('_', ' ')->title())
->helperText(fn (ShipmentInfo $record) => collect([$record->carrier_status !== 'manual' ? $record->carrier_status : null, $record->location, $record->message])->filter()->unique()->implode(' · ') ?: null),
]),
]),
]);
}
/**
* 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
* view page.
*/
public static function linkOrderAction(): Action
{
return Action::make('link_order')
->label('Link to order')
->icon('heroicon-o-link')
->schema(fn (Shipment $record) => [
Select::make('order_id')
->label('Order')
->options(fn () => self::orderOptions(Order::with('shippingAddress')
->whereKey($record->meta['suggested_order_ids'] ?? [])
->get()))
->default(($record->meta['suggested_order_ids'] ?? [])[0] ?? null)
->searchable()
->getSearchResultsUsing(fn (string $search) => self::orderOptions(Order::with('shippingAddress')
->where('reference', 'ilike', "%{$search}%")
->orWhereHas('shippingAddress', fn ($q) => $q
->where('last_name', 'ilike', "%{$search}%")
->orWhere('postcode', $search))
->latest('placed_at')
->limit(20)
->get()))
->getOptionLabelUsing(fn ($value) => self::orderOptions(Order::with('shippingAddress')->whereKey($value)->get())[$value] ?? $value)
->helperText('Suggestions: the order its reference points to, then orders with the same postcode and phone or name.')
->required(),
])
->action(function (Shipment $record, array $data) {
$record->update(['order_id' => $data['order_id']]);
// Its history so far goes onto the order's Timeline.
app(ShipmentTimelineLogger::class)->backfill($record->refresh());
Notification::make()->title('Voucher linked to the order.')->success()->send();
})
->visible(fn (Shipment $record) => $record->order_id === null);
}
public static function unlinkOrderAction(): Action
{
return Action::make('unlink_order')
->label('Unlink from order')
->icon('heroicon-o-x-mark')
->color('gray')
->requiresConfirmation()
->action(fn (Shipment $record) => $record->update(['order_id' => null]))
->visible(fn (Shipment $record) => $record->order_id !== null && $record->source === Shipment::SOURCE_SYNCED);
}
/**
* @param iterable<Order> $orders
* @return array<int, string>
*/
private static function orderOptions(iterable $orders): array
{
return collect($orders)->mapWithKeys(fn (Order $order) => [$order->id => collect([
$order->reference,
trim($order->shippingAddress?->first_name.' '.$order->shippingAddress?->last_name),
$order->shippingAddress?->postcode,
$order->placed_at?->format('Y-m-d'),
])->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
{
return $record->meta['phone'] ?? ($record->recipient()?->phones() ?: null);
}
private static function recipient(Shipment $record): ?string
{
return $record->meta['recipient_name'] ?? ($record->recipient()?->name ?: null);
}
private static function postcode(Shipment $record): ?string
{
return $record->meta['postcode'] ?? ($record->recipient()?->postcode ?: null);
}
/**
* Our status (TrackingStatus): cancelled here, else the latest
* checkpoint in the voucher's history.
*/
private static function trackingStatus(Shipment $record): ?TrackingStatus
{
if ($record->isCancelled()) {
return TrackingStatus::Cancelled;
}
return $record->shipmentInfo->sortBy('occurred_at')->last()?->status;
}
private static function status(Shipment $record): ?string
{
$status = self::trackingStatus($record);
return $status ? (string) str($status->value)->replace('_', ' ')->title() : null;
}
private static function statusColor(Shipment $record): string
{
return match (self::trackingStatus($record)) {
TrackingStatus::Delivered => 'success',
TrackingStatus::Failed, TrackingStatus::Returned, TrackingStatus::Cancelled => 'danger',
TrackingStatus::InTransit, TrackingStatus::OutForDelivery, TrackingStatus::CollectedFromSender => 'warning',
default => 'gray',
};
}
/**
* The carrier's own wording for the latest status, as it sent it.
*/
private static function carrierStatus(Shipment $record): ?string
{
$latest = $record->shipmentInfo->sortBy('occurred_at')->last();
return $latest?->carrier_status !== 'manual' ? $latest?->carrier_status : null;
}
public static function isReturnLike(Shipment $record): bool
{
return $record->isReturn() || (bool) ($record->meta['reported_return'] ?? false);
}
public static function getPages(): array
{
return [
'index' => ListCarrierVouchers::route('/'),
'view' => ViewCarrierVoucher::route('/{record}'),
];
}
}
@@ -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();
}
}
@@ -0,0 +1,114 @@
<?php
namespace Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource\Pages;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Tabs\Tab;
use Illuminate\Database\Eloquent\Builder;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
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\Services\CarrierVoucherSync;
use Throwable;
class ListCarrierVouchers extends ListRecords
{
protected static string $resource = CarrierVoucherResource::class;
private const SYNC_DAYS = 14;
public function getTabs(): array
{
return [
'all' => Tab::make('All'),
'unlinked' => Tab::make('Unlinked')
->modifyQueryUsing(fn (Builder $query) => $query->whereNull('order_id'))
->badge(fn () => CarrierVoucherResource::vouchers(Shipment::query())->whereNull('order_id')->count() ?: null),
'returns' => Tab::make('Returns')
->modifyQueryUsing(fn (Builder $query) => $query->where(fn (Builder $q) => $q
->where('meta->is_return', true)
->orWhere('meta->reported_return', true)))
->badge(fn () => CarrierVoucherResource::vouchers(Shipment::query())
->where(fn (Builder $q) => $q->where('meta->is_return', true)->orWhere('meta->reported_return', true))
->count() ?: null),
];
}
protected function getHeaderActions(): array
{
return [
CreateVoucherAction::make(),
Action::make('lookup')
->label('Look up voucher')
->icon('heroicon-o-magnifying-glass')
->modalDescription('Fetches a voucher from the carrier that hasn\'t synced yet and records it here.')
->schema([
Select::make('carrier')
->label('Carrier')
->options(fn () => collect(Shipping::getSupportedDrivers())
->filter(fn ($driver, string $carrier) => app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]) instanceof SupportsVoucherLookup)
->map(fn ($driver) => $driver->name())
->all())
->native(false)
->required(),
TextInput::make('voucher_number')->label('Voucher number')->required(),
])
->action(fn (array $data) => $this->lookup($data['carrier'], $data['voucher_number'])),
Action::make('sync')
->label('Sync now')
->icon('heroicon-o-arrow-path')
->action(fn () => $this->sync()),
];
}
private function lookup(string $carrier, string $number): void
{
try {
$shipment = app(CarrierVoucherSync::class)->lookup($carrier, $number);
} catch (Throwable $e) {
report($e);
Notification::make()->title('Lookup failed: '.$e->getMessage())->danger()->send();
return;
}
if (! $shipment) {
Notification::make()->title("The carrier has no record of {$number}.")->warning()->send();
return;
}
$body = match (true) {
! $shipment->wasRecentlyCreated => 'Already recorded here.',
filled($shipment->meta['suggested_order_ids'] ?? []) => 'Recorded — it has a suggested order to link it to.',
default => 'Recorded, not linked to an order.',
};
Notification::make()->title("Voucher {$shipment->tracking_reference}")->body($body)->success()->send();
}
private function sync(): void
{
$results = app(CarrierVoucherSync::class)->syncAll(now()->subDays(self::SYNC_DAYS)->startOfDay(), now());
foreach ($results as $carrier => $stats) {
$name = Shipping::getSupportedDrivers()->get($carrier)?->name() ?? $carrier;
isset($stats['error'])
? Notification::make()->title("{$name}: sync failed")->body($stats['error'])->danger()->send()
: Notification::make()
->title("{$name}: {$stats['created']} new, {$stats['updated']} updated, of {$stats['seen']} vouchers")
->body($stats['suggested'] ? "{$stats['suggested']} new have a suggested order to link." : null)
->success()
->send();
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource\Pages;
use Filament\Actions\Action;
use Filament\Resources\Pages\ViewRecord;
use Lunar\Admin\Filament\Resources\OrderResource;
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource;
use Modules\Core\Shipping\Models\Shipment;
class ViewCarrierVoucher extends ViewRecord
{
protected static string $resource = CarrierVoucherResource::class;
public function getTitle(): string
{
return 'Voucher '.($this->getRecord()->tracking_reference ?? '(not issued yet)');
}
protected function getHeaderActions(): array
{
return [
CarrierVoucherResource::printAction(),
Action::make('open_order')
->label('Open order')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Shipment $record) => OrderResource::getUrl('order', ['record' => $record->order_id]))
->visible(fn (Shipment $record) => $record->order_id !== null),
CarrierVoucherResource::linkOrderAction(),
CarrierVoucherResource::unlinkOrderAction(),
];
}
}
@@ -6,6 +6,8 @@ use Filament\Actions\ViewAction;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages\ListManifests;
use Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages\ViewManifest;
use Modules\Core\Shipping\Filament\Resources\ManifestResource\RelationManagers\ShipmentsRelationManager;
@@ -30,6 +32,17 @@ class ManifestResource extends Resource
protected static ?string $navigationLabel = 'Issued Manifests';
/**
* Only carriers that batch into manifests (ACS) produce any — with none
* registered the menu entry would lead to an empty screen, so it's
* hidden (the page itself, and any past manifests, stay reachable).
*/
public static function shouldRegisterNavigation(): bool
{
return collect(Shipping::getSupportedDrivers())->keys()
->contains(fn (string $carrier) => ShipmentResource::fulfillmentService($carrier) instanceof SupportsManifestBatching);
}
protected static ?string $modelLabel = 'Manifest';
protected static ?int $navigationSort = 101;
@@ -12,9 +12,8 @@ use Modules\Core\Shipping\Models\Shipment;
/**
* The shipments a given Manifest actually included — read-only (a
* shipment's manifest membership is set once, at issueManifest() time,
* never edited here). Reuses ShipmentResource::printShipment() for the
* "Print" action rather than duplicating its try/catch-and-notify
* handling.
* never edited here). "Print" opens the same signed label URL as the
* order page (ShipmentResource::labelUrl()).
*/
class ShipmentsRelationManager extends RelationManager
{
@@ -33,7 +32,7 @@ class ShipmentsRelationManager extends RelationManager
Action::make('print')
->label('Print')
->icon('heroicon-o-printer')
->action(fn (Shipment $record) => ShipmentResource::printShipment($record)),
->url(fn (Shipment $record) => ShipmentResource::labelUrl($record), shouldOpenInNewTab: true),
]);
}
}

Some files were not shown because too many files have changed in this diff Show More