Compare commits

...
21 Commits
Author SHA1 Message Date
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 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
109 changed files with 6460 additions and 396 deletions
+279
View File
@@ -4,6 +4,285 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.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 ## [0.27.2] - 2026-09-29
### Fixed ### Fixed
- `Modules\Core\Order\Services\TransactionRecorder::record()` — made idempotent - `Modules\Core\Order\Services\TransactionRecorder::record()` — made idempotent
+108 -45
View File
@@ -1,85 +1,148 @@
# Contributing to boboko-core # 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 ## 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/ RadicalElements/
├── boboko-core/ (this repo) ├── 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 ```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. (`3dealer` has `bin/core-mode` and `bin/deploy`; they're app-agnostic, so other consumer apps can copy them as-is.)
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:
```bash ### Day to day in local mode
./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." 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.
## JS/CSS: no separate npm package 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:
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. - **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
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. ```bash
bin/dc-core.sh exec app composer update boboko/core --with-all-dependencies
```
**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: 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: a real npm package
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.
Public entry points (`package.json` `exports`):
| 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 ```js
// consumer app's resources/js/app.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); 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 ```php
{{-- consumer app's layout --}} {{-- 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 ## Translations added in the UI
export default defineConfig({
server: { 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:
watch: {
// vendor/boboko/core is a symlink into ../boboko-core in local ```bash
// path-repo dev. Vite/chokidar don't follow symlinks for watched bin/dc-core.sh exec app php artisan boboko:translations:pull # local mode: writes into ../boboko-core
// files by default, so edits to core's source wouldn't otherwise bin/dc exec app php artisan boboko:translations:pull --dry-run # any mode: just list them
// 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,
},
},
});
``` ```
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 ## Releasing a version
# consumer app's docker-compose.core-dev.yml
services:
vite:
volumes:
- ../boboko-core:/app/vendor/boboko/core
```
(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.) 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
```
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 ## Verifying changes against a real database
+4 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.27.2", "version": "0.29.3",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
@@ -18,7 +18,9 @@
"lunarphp/search": "*", "lunarphp/search": "*",
"lunarphp/meilisearch": "*", "lunarphp/meilisearch": "*",
"spatie/laravel-translation-loader": "^2.8", "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": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
+14
View File
@@ -16,6 +16,20 @@ return [
'auto_create_customer_for_user' => true, 'auto_create_customer_for_user' => true,
/*
|--------------------------------------------------------------------------
| Display Timezone
|--------------------------------------------------------------------------
|
| Timestamps are stored in the app timezone (UTC). This is the timezone
| they're shown in — the admin (Filament's display timezone), the
| storefront, emails and the order Timeline. (Carriers that report times
| without an offset — ELTA, ACS — are read as Greek time regardless.)
|
*/
'display_timezone' => env('DISPLAY_TIMEZONE', 'Europe/Athens'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Privacy / GDPR data-subject requests | Privacy / GDPR data-subject requests
+70
View File
@@ -0,0 +1,70 @@
<?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 [
'service_location' => env('ELTA_SERVICE_LOCATION', 'http://212.205.47.226:9003'),
'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();
});
}
};
+1 -1
View File
@@ -393,7 +393,7 @@ Because Filament instantiates `Lunar\Admin\Models\Staff` directly (not a subclas
```php ```php
use Lunar\Admin\Models\Staff as LunarStaff; 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 ### 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 | | 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 | | `otp_expires_at` | timestamp, nullable | Expiry time |
### Login Page ### Login Page
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@boboko/core", "name": "@boboko/core",
"version": "0.27.2", "version": "0.29.3",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.", "description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.",
+110 -26
View File
@@ -53,6 +53,7 @@
--bbk-color-accent: #18181b; --bbk-color-accent: #18181b;
--bbk-color-accent-text: #ffffff; --bbk-color-accent-text: #ffffff;
--bbk-color-danger: #dc2626; --bbk-color-danger: #dc2626;
--bbk-color-info: #2563eb;
--bbk-radius: 8px; --bbk-radius: 8px;
--bbk-radius-sm: 4px; --bbk-radius-sm: 4px;
@@ -153,6 +154,11 @@
outline-offset: 2px; 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 { .bbk-visually-hidden {
position: absolute; position: absolute;
width: 1px; width: 1px;
@@ -183,7 +189,7 @@
.bbk-cart-item { .bbk-cart-item {
display: grid; display: grid;
grid-template-columns: 72px 1fr auto; grid-template-columns: 72px 1fr;
gap: 0.875rem; gap: 0.875rem;
align-items: start; align-items: start;
} }
@@ -199,8 +205,23 @@
.bbk-cart-item-detail { min-width: 0; } .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 { .bbk-cart-item-title {
display: block; display: block;
min-width: 0;
margin: 0 0 0.25rem; margin: 0 0 0.25rem;
font-weight: 600; font-weight: 600;
color: inherit; color: inherit;
@@ -252,24 +273,24 @@ a.bbk-cart-item-title:hover { text-decoration: underline; }
color: var(--bbk-color-muted); color: var(--bbk-color-muted);
} }
.bbk-cart-item-aside { .bbk-cart-item-total { margin: 0; font-weight: 600; white-space: nowrap; }
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.bbk-cart-item-total { margin: 0; font-weight: 600; }
/* 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 { .bbk-cart-item-remove {
font-size: 1.125rem; font-size: 1.75rem;
width: 1.5rem; width: 2.5rem;
height: 1.5rem; height: 2.5rem;
margin: -0.5rem -0.5rem 0 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: var(--bbk-radius-sm);
} }
.bbk-cart-item-remove:hover { background: var(--bbk-color-bg-muted); }
.bbk-cart-qty { .bbk-cart-qty {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -905,10 +926,39 @@ textarea.bbk-field-input { resize: vertical; }
to { transform: rotate(360deg); } 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 ─────────────────────────────────────────────── */ /* ── Confirmation page ─────────────────────────────────────────────── */
.bbk-confirmation { .bbk-confirmation {
max-width: 720px; max-width: 560px;
margin: 0 auto; margin: 0 auto;
padding: 3rem 1.5rem 5rem; padding: 3rem 1.5rem 5rem;
font-family: var(--bbk-font); font-family: var(--bbk-font);
@@ -916,7 +966,7 @@ textarea.bbk-field-input { resize: vertical; }
} }
.bbk-confirmation-heading { .bbk-confirmation-heading {
margin: 0 0 1rem; margin: 0 0 1.25rem;
font-size: 1.75rem; font-size: 1.75rem;
font-weight: 700; font-weight: 700;
} }
@@ -924,7 +974,7 @@ textarea.bbk-field-input { resize: vertical; }
.bbk-confirmation-ref { margin: 0 0 0.25rem; } .bbk-confirmation-ref { margin: 0 0 0.25rem; }
.bbk-confirmation-meta { .bbk-confirmation-meta {
margin: 0 0 1rem; margin: 1.5rem 0 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; 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 dt { color: var(--bbk-color-muted); }
.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; } .bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; }
.bbk-confirmation-body { .bbk-confirmation-section {
margin: 2rem 0; margin-top: 2rem;
display: grid; padding-top: 1.5rem;
gap: 2.5rem; border-top: 1px solid var(--bbk-color-border);
} }
@media (min-width: 640px) { .bbk-confirmation-section-heading {
.bbk-confirmation-body { grid-template-columns: 1fr 1fr; } margin: 0 0 1rem;
font-size: 1.125rem;
font-weight: 700;
} }
.bbk-confirmation-lines { .bbk-confirmation-lines {
list-style: none;
margin: 0 0 1.25rem;
padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
@@ -965,22 +1020,51 @@ textarea.bbk-field-input { resize: vertical; }
.bbk-confirmation-line-detail { min-width: 0; } .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-line-qty { color: var(--bbk-color-muted); }
.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; }
.bbk-confirmation-addresses { .bbk-confirmation-addresses {
display: flex; display: grid;
flex-direction: column;
gap: 1.5rem; 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; margin: 0 0 0.5rem;
font-size: 0.9375rem; font-size: 0.9375rem;
font-weight: 700; 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 { .bbk-address-lines {
font-style: normal; font-style: normal;
display: flex; display: flex;
+82 -3
View File
@@ -9,11 +9,13 @@ import { csrfToken } from './csrf'
// - handles the in-drawer quantity / remove forms (fetch + method spoofing) // - handles the in-drawer quantity / remove forms (fetch + method spoofing)
// - re-emits `bbk-cart:updated` {count, total} after every render so the host // - re-emits `bbk-cart:updated` {count, total} after every render so the host
// (e.g. the header bag icon) can react // (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 // Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
// attribute on the root, nothing here touches styles or class lists. // attribute on the root, nothing here touches styles or class lists.
export default class extends Controller { export default class extends Controller {
static targets = ['panel', 'body', 'error'] static targets = ['panel', 'body', 'error', 'heading', 'status']
connect() { connect() {
this.onChanged = this.onChanged.bind(this) this.onChanged = this.onChanged.bind(this)
@@ -40,19 +42,31 @@ export default class extends Controller {
} }
onKeydown(event) { 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() { open() {
if (!this.element.hidden) return if (!this.element.hidden) return
this.returnFocusTo = document.activeElement
this.element.hidden = false this.element.hidden = false
// Next frame, so the panel transitions from its off-canvas start. // 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() { close() {
this.element.removeAttribute('data-bbk-cart-state') this.element.removeAttribute('data-bbk-cart-state')
if (this.returnFocusTo?.isConnected) this.returnFocusTo.focus({ preventScroll: true })
this.returnFocusTo = null
const panel = this.panelTarget const panel = this.panelTarget
const done = () => { const done = () => {
this.element.hidden = true 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) { showError(message) {
if (!this.hasErrorTarget || !message) return if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message this.errorTarget.textContent = message
@@ -144,10 +180,53 @@ export default class extends Controller {
} }
replaceBody(html) { replaceBody(html) {
const restore = this.focusSnapshot()
this.bodyTarget.innerHTML = html this.bodyTarget.innerHTML = html
restore()
this.announce()
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]')) 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) { emitUpdated(node) {
if (!node) return if (!node) return
@@ -123,7 +123,7 @@ export default class extends Controller {
} }
async selectShipping(event) { async selectShipping(event) {
this.toggleBoxNowLocker(event.target.value) this.toggleBoxNowLocker(event.target.dataset.boxNow === 'true')
// Tracked so flush() can await it — nothing else stops "place order" // Tracked so flush() can await it — nothing else stops "place order"
// (a separate, unrelated click) from racing ahead of this request. // (a separate, unrelated click) from racing ahead of this request.
@@ -136,15 +136,17 @@ export default class extends Controller {
// re-renders wholesale on every shipping-option change — so its own // re-renders wholesale on every shipping-option change — so its own
// Stimulus controller reconnects fresh each time and has no memory of // Stimulus controller reconnects fresh each time and has no memory of
// which option was previously selected. This is the one place that // which option was previously selected. This is the one place that
// knows the newly-chosen option's identifier, so it also owns // knows the newly-chosen option, so it also owns showing/hiding the
// showing/hiding the picker. // picker. Whether an option is Box Now comes from the server
toggleBoxNowLocker(identifier) { // (data-box-now, by the method's driver) — the option's value is the
// merchant-typed method code, which needn't be "box-now".
toggleBoxNowLocker(isBoxNow) {
const picker = this.shippingOptionsTarget.querySelector('#bbk-box-now-locker') const picker = this.shippingOptionsTarget.querySelector('#bbk-box-now-locker')
if (!picker) return if (!picker) return
const controller = this.application.getControllerForElementAndIdentifier(picker, 'bbk-box-now-locker') const controller = this.application.getControllerForElementAndIdentifier(picker, 'bbk-box-now-locker')
if (identifier === 'box-now') { if (isBoxNow) {
controller?.show() controller?.show()
} else { } else {
controller?.hide() controller?.hide()
+1 -1
View File
@@ -1,5 +1,5 @@
// Single stable JS entry point for this package. A consuming app imports // 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 // reaching into a specific module's internals — so this file's exports can
// grow or its modules' internal layout can change without breaking every // grow or its modules' internal layout can change without breaking every
// consumer's own entry point. // consumer's own entry point.
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 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

+76 -46
View File
@@ -2,6 +2,9 @@
Order confirmation. Reached only via a session flash of the placed order id Order confirmation. Reached only via a session flash of the placed order id
(CheckoutController::confirmation) — not deep-linkable. $order is a (CheckoutController::confirmation) — not deep-linkable. $order is a
Lunar\Models\Order with lines + shipping/billing addresses eager-loaded. 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') @extends('layouts.app')
@@ -11,10 +14,19 @@
<div class="bbk-confirmation"> <div class="bbk-confirmation">
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1> <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"> <dl class="bbk-confirmation-meta">
<div class="bbk-confirmation-meta-row"> <div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt> <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> </div>
@if ($order->billingAddress?->contact_email) @if ($order->billingAddress?->contact_email)
@@ -39,8 +51,6 @@
@endif @endif
</dl> </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 {{-- Guests: logging in with the order's email attaches it to an account
(boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin), (boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin),
so it shows in their history. --}} so it shows in their history. --}}
@@ -53,21 +63,29 @@
@endif @endif
@endguest @endguest
<div class="bbk-confirmation-body"> <section class="bbk-confirmation-section" aria-labelledby="bbk-confirmation-summary-heading">
<div class="bbk-confirmation-lines"> <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) @foreach ($order->lines->where('type', '!=', 'shipping') as $line)
<div class="bbk-confirmation-line"> <li class="bbk-confirmation-line">
<div class="bbk-cart-item-media"> <div class="bbk-cart-item-media">
{{-- alt="" — the description is right beside it. --}}
@if ($thumb = $line->purchasable?->getThumbnailImage()) @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 @endif
</div> </div>
<div class="bbk-confirmation-line-detail"> <div class="bbk-confirmation-line-detail">
<span class="bbk-confirmation-line-name"> <p class="bbk-confirmation-line-name">
{{ $line->description }} {{ $line->description }}
<span class="bbk-confirmation-line-qty">&times; {{ $line->quantity }}</span> <span class="bbk-confirmation-line-qty">
</span> <span aria-hidden="true">&times; {{ $line->quantity }}</span>
<span class="bbk-visually-hidden">— {{ __('checkout.cart.quantity') }}: {{ $line->quantity }}</span>
</span>
</p>
@if ($line->option) @if ($line->option)
<p class="bbk-cart-item-variant">{{ $line->option }}</p> <p class="bbk-cart-item-variant">{{ $line->option }}</p>
@@ -76,57 +94,69 @@
@include('checkout::partials.line-custom-fields', ['line' => $line]) @include('checkout::partials.line-custom-fields', ['line' => $line])
</div> </div>
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span> <p class="bbk-confirmation-line-total">
</div> <span class="bbk-visually-hidden">{{ __('checkout.cart.total') }}:</span>
{{ $line->sub_total?->formatted() }}
</p>
</li>
@endforeach @endforeach
</ul>
<div class="bbk-cart-summary"> <div class="bbk-cart-summary">
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $order->sub_total?->formatted() }}</span>
</div>
@if ($order->discount_total?->value > 0)
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
<span>{{ __('checkout.cart.discount') }}</span>
<span>&minus;{{ $order->discount_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
<span>{{ $order->shipping_total?->formatted() }}</span>
</div>
@if ($order->tax_total?->value > 0)
<div class="bbk-cart-summary-row"> <div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span> <span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $order->sub_total?->formatted() }}</span> <span>{{ $order->tax_total->formatted() }}</span>
</div> </div>
@endif
@if ($order->discount_total?->value > 0) <div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount"> <span>{{ __('checkout.cart.total') }}</span>
<span>{{ __('checkout.cart.discount') }}</span> <span>{{ $order->total?->formatted() }}</span>
<span>&minus;{{ $order->discount_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
<span>{{ $order->shipping_total?->formatted() }}</span>
</div>
@if ($order->tax_total?->value > 0)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $order->tax_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<span>{{ __('checkout.cart.total') }}</span>
<span>{{ $order->total?->formatted() }}</span>
</div>
</div> </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) @if ($order->shippingAddress)
<div class="bbk-confirmation-address"> <section class="bbk-confirmation-address" aria-labelledby="bbk-confirmation-shipping-heading">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2> <h2 class="bbk-confirmation-address-heading" id="bbk-confirmation-shipping-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
<x-checkout::address-lines :address="$order->shippingAddress" /> <x-checkout::address-lines :address="$order->shippingAddress" />
</div> </section>
@endif @endif
@if ($order->billingAddress) @if ($order->billingAddress)
<div class="bbk-confirmation-address"> <section class="bbk-confirmation-address" aria-labelledby="bbk-confirmation-billing-heading">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2> <h2 class="bbk-confirmation-address-heading" id="bbk-confirmation-billing-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
<x-checkout::address-lines :address="$order->billingAddress" /> <x-checkout::address-lines :address="$order->billingAddress" />
</div> </section>
@endif @endif
</div> </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> </div>
@endsection @endsection
+13 -2
View File
@@ -15,7 +15,9 @@
data-bbk-cart-target="panel" data-bbk-cart-target="panel"
> >
<header class="bbk-cart-panel-header"> <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 <button
type="button" type="button"
class="bbk-cart-dismiss" class="bbk-cart-dismiss"
@@ -26,7 +28,16 @@
@include('checkout::partials.cart-error') @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') @include('checkout::partials.cart-body')
</div> </div>
</aside> </aside>
+8 -2
View File
@@ -267,9 +267,15 @@
<aside class="bbk-checkout-aside"> <aside class="bbk-checkout-aside">
<div class="bbk-checkout-summary" data-controller="bbk-cart"> <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') @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') @include('checkout::partials.cart-body')
</div> </div>
</div> </div>
@@ -24,80 +24,96 @@
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}"> <li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
<div class="bbk-cart-item-media"> <div class="bbk-cart-item-media">
@if ($thumb) @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) @if ($productUrl)
<a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1"> <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> </a>
@else @else
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy"> <img src="{{ $thumb }}" alt="" width="72" height="72" loading="lazy">
@endif @endif
@endif @endif
</div> </div>
<div class="bbk-cart-item-detail"> <div class="bbk-cart-item-detail">
@if ($productUrl) <div class="bbk-cart-item-head">
<a href="{{ $productUrl }}" class="bbk-cart-item-title">{{ $name }}</a> @if ($productUrl)
@else <a href="{{ $productUrl }}" class="bbk-cart-item-title" id="bbk-cart-item-title-{{ $line->id }}">{{ $name }}</a>
<p class="bbk-cart-item-title">{{ $name }}</p> @else
@endif <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) @if ($variantLabel)
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p> <p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
@endif @endif
@include('checkout::partials.line-custom-fields', ['line' => $line]) @include('checkout::partials.line-custom-fields', ['line' => $line])
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p> <p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
<form <div class="bbk-cart-item-foot">
class="bbk-cart-qty" {{-- role=group + the title as its name: entering the stepper
method="POST" announces which product's quantity is being changed. --}}
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}" <form
> class="bbk-cart-qty"
@csrf method="POST"
@method('PATCH') action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
<button role="group"
type="button" aria-labelledby="bbk-cart-item-title-{{ $line->id }}"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="-1"
aria-label="{{ __('checkout.cart.decrease') }}"
>&minus;</button>
<input
type="number"
name="quantity"
value="{{ $line->quantity }}"
min="0"
inputmode="numeric"
class="bbk-cart-qty-input"
data-action="change->bbk-cart#submit"
data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
aria-label="{{ __('checkout.cart.quantity') }}"
> >
@csrf
@method('PATCH')
<button
type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="-1"
aria-label="{{ __('checkout.cart.decrease') }}"
><span aria-hidden="true">&minus;</span></button>
<button <input
type="button" type="number"
class="bbk-cart-qty-btn" name="quantity"
data-action="bbk-cart#step" value="{{ $line->quantity }}"
data-bbk-cart-dir-param="1" min="0"
aria-label="{{ __('checkout.cart.increase') }}" inputmode="numeric"
>+</button> class="bbk-cart-qty-input"
</form> data-action="change->bbk-cart#submit"
</div> data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
aria-label="{{ __('checkout.cart.quantity') }}"
>
<div class="bbk-cart-item-aside"> <button
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p> type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="1"
aria-label="{{ __('checkout.cart.increase') }}"
><span aria-hidden="true">+</span></button>
</form>
<form <p class="bbk-cart-item-total">
method="POST" <span class="bbk-visually-hidden">{{ __('checkout.cart.total') }}:</span>
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}" {{ $line->subTotal?->formatted() }}
data-action="submit->bbk-cart#submit" </p>
> </div>
@csrf
@method('DELETE')
<button
type="submit"
class="bbk-cart-item-remove"
aria-label="{{ __('checkout.cart.remove') }}"
>&times;</button>
</form>
</div> </div>
</li> </li>
@@ -8,6 +8,8 @@
$shippingAddress, $shippingOptions come from the controller / page scope. $shippingAddress, $shippingOptions come from the controller / page scope.
--}} --}}
@php($selected = $shippingAddress?->shipping_option) @php($selected = $shippingAddress?->shipping_option)
{{-- Box Now by the method's driver, not its (merchant-typed) code --}}
@php($boxNowCodes = \Modules\Core\Shipping\Support\BoxNowShipping::codes())
{{-- Rate resolution needs country (always Greece here) + postcode; until a {{-- Rate resolution needs country (always Greece here) + postcode; until a
postcode is saved there's nothing to quote against yet. --}} postcode is saved there's nothing to quote against yet. --}}
@@ -34,6 +36,7 @@
type="radio" type="radio"
name="shipping_option" name="shipping_option"
value="{{ $option->identifier }}" value="{{ $option->identifier }}"
data-box-now="{{ in_array($option->identifier, $boxNowCodes, true) ? 'true' : 'false' }}"
@checked($selected === $option->identifier) @checked($selected === $option->identifier)
data-action="change->bbk-checkout-form#selectShipping" data-action="change->bbk-checkout-form#selectShipping"
> >
@@ -73,7 +76,7 @@
data-bbk-box-now-locker-select-label-value="{{ __('checkout.page.box_now_locker_select') }}" data-bbk-box-now-locker-select-label-value="{{ __('checkout.page.box_now_locker_select') }}"
data-bbk-box-now-locker-selected-label-value="{{ __('checkout.page.box_now_locker_selected') }}" data-bbk-box-now-locker-selected-label-value="{{ __('checkout.page.box_now_locker_selected') }}"
data-bbk-box-now-locker-no-results-value="{{ __('checkout.page.box_now_locker_no_results') }}" data-bbk-box-now-locker-no-results-value="{{ __('checkout.page.box_now_locker_no_results') }}"
@if ($selected !== 'box-now') hidden @endif @if (! in_array($selected, $boxNowCodes, true)) hidden @endif
> >
<p class="bbk-checkout-box-now-locker-label"> <p class="bbk-checkout-box-now-locker-label">
{{ __('checkout.page.box_now_locker_label') }} {{ __('checkout.page.box_now_locker_label') }}
@@ -0,0 +1,113 @@
{{--
Copy of lunarphp/lunar's resources/views/livewire/components/activity-log-feed.blade.php
(the order page's Timeline), overriding it via View::prependNamespace('lunarpanel')
in CoreServiceProvider. The only changes: the day heading and each entry's time are
shown in core.display_timezone instead of UTC (Lunar formats them by hand, so
Filament's display timezone doesn't reach them). Re-copy it if Lunar changes the
original. Lunar still groups entries by UTC day, so an entry just after local
midnight can sit under the previous day's heading.
--}}
<div class="px-2 pb-4 scroll-mt-32" id="lunar-panel-timeline">
<div class="relative flex items-end gap-4 mt-4">
<div class="shrink-0">
<div>
<img src="{{ $this->userAvatar }}"
class="inline-block w-8 h-8 rounded-full" />
</div>
</div>
<form class="w-full"
wire:submit.prevent="addComment">
{{ $this->form }}
<div class="absolute right-0 mt-2">
{{ $this->addCommentAction }}
</div>
</form>
</div>
<div class="relative pt-8 -ml-[5px] z-10 pointer-events-none">
<span class="absolute inset-y-0 left-5 w-[2px] bg-gray-200 dark:bg-gray-600 rounded-full"></span>
<div class="flow-root">
<ul class="-my-8 divide-y-2 divide-gray-200 dark:divide-gray-600"
role="list">
@foreach ($this->activityLog as $log)
<li class="relative py-8 ml-5">
<p class="ml-8 font-bold text-gray-950 dark:text-gray-300">
{{ $log['items']->first()['log']->created_at->inDisplayTimezone()->format('F jS, Y') }}
</p>
<ul class="mt-4 space-y-6 pointer-events-auto">
@foreach ($log['items'] as $item)
@php
$logUserName = $item['log']->causer ? ($item['log']->causer->fullName ?: $item['log']->causer->name) : null;
@endphp
<li class="relative pl-8">
<div @class([
'absolute top-[2px]',
'-left-[calc(0.75rem_-_1px)]' => $item['log']->causer,
'-left-[calc(0.5rem_-_1px)]' => !$item['log']->causer,
])>
@if ($email = $item['log']->causer?->email)
<img
src="{{ $this->getAvatarUrl($email) }}"
class="w-6 h-6 rounded-full ring-4 ring-gray-200 dark:ring-gray-600"
alt="{{ $logUserName }}"
/>
@else
<span @class([
'absolute w-4 h-4 rounded-full ring-4',
match($item['log']->description){
'created' => 'bg-sky-500 ring-sky-100 dark:ring-sky-800',
'updated' => 'bg-teal-500 ring-teal-100 dark:ring-teal-800',
'status-update' => 'bg-purple-500 ring-purple-100 dark:ring-purple-800',
default => 'bg-gray-300 ring-gray-200 dark:ring-gray-600',
},
])>
</span>
@endif
</div>
<div @class([
'flex justify-between',
'pt-[5px]' => $item['log']->causer,
'pt-[1px]' => !$item['log']->causer,
])>
<div>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
@if (!$item['log']->causer)
{{ __('lunarpanel::components.activity-log.system') }}
@else
{{ $logUserName }}
@endif
</div>
@if (count($item['renderers']))
<div class="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">
@foreach ($item['renderers'] as $class)
{{ $class->render($item['log']) }}
@endforeach
</div>
@endif
</div>
<time class="flex-shrink-0 ml-4 text-xs mt-0.5 text-gray-500 dark:text-gray-400 font-medium">
{{ $item['log']->created_at->inDisplayTimezone()->format('h:ia') }}
</time>
</div>
</li>
@endforeach
</ul>
</li>
@endforeach
</ul>
</div>
</div>
<div class="pt-4">
{{ $this->activityLog->links('lunarpanel::components.activity-log.timeline-paginator.index', data: ['scrollTo' => '#lunar-panel-timeline']) }}
</div>
</div>
@@ -77,7 +77,7 @@
class="w-4" class="w-4"
/> />
</div> </div>
<span>{{ $transaction->created_at->format('jS F Y h:ia') }}</span> <span>{{ $transaction->created_at->inDisplayTimezone()->format('jS F Y h:ia') }}</span>
</div> </div>
<div class="flex space-x-2"> <div class="flex space-x-2">
@@ -1,3 +1,12 @@
<p>Hi,</p> <p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is on its way.</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,120 @@
<!DOCTYPE html>
<html lang="el">
<head>
<meta charset="utf-8">
<style>
{{--
An extra parcel of a multi-parcel send on A4 — ELTA's own client
prints these with a separate, smaller report (ELTA_PEL.sydetaE1.rdlc,
via epexergasia.cs::print_child()) instead of the full 3-copy
voucher: one block with the parcel's own voucher/barcode, its
piece ("002/002"), the master voucher and, for cash on delivery,
only the warning (the amount is collected on the master). Every
position below is that report's own Top/Left in cm, on the page.
Classes are ch-* so they don't collide with label-a4's when a
batch PDF holds both.
--}}
@page { margin: 0; size: 21cm 29.7cm; }
html, body { margin: 0; padding: 0; }
body { font-family: 'DejaVu Sans', sans-serif; font-size: 6.5pt; color: #000; }
.ch-page { position: relative; width: 21cm; height: 29.7cm; }
.ch-page > * { position: absolute; box-sizing: border-box; }
.ch-box { border: 1px solid #000; }
.ch-box > * { position: absolute; }
.ch-label { font-size: 6pt; }
.ch-val { font-size: 8pt; font-weight: bold; }
.ch-line { font-size: 9pt; white-space: nowrap; overflow: hidden; }
.ch-center { text-align: center; left: 0; width: 100%; }
.ch-logo img { width: 100%; }
.ch-company { font-size: 5pt; line-height: 1.25; white-space: nowrap; }
.ch-company-name { font-weight: bold; font-size: 5.5pt; }
.ch-barcode img { width: 100%; height: 0.62cm; }
</style>
</head>
<body>
<div class="page ch-page">
{{-- Image22 (logo) + image4 (ELTA details bitmap, reproduced as text) --}}
<div class="ch-logo" style="top:9.07cm;left:0.41cm;width:1.33cm;height:1.28cm;">
<img src="{{ $eltaLogo }}" alt="ELTA Courier">
</div>
<div class="ch-company" style="top:9.07cm;left:1.95cm;width:3.38cm;">
<div class="ch-company-name">ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε.</div>
<div>Απελλού 1, 105 51, Αθήνα</div>
<div>Τ. 210-6073000 | www.eltacourier.gr</div>
</div>
<div style="top:10.38cm;left:0.41cm;width:4.99cm;font-size:6pt;">ΕΕΤΤ ΑΜ 99-150 Γενική Αδεια Ταχ/κων Υπηρεσιών</div>
{{-- rectangle17: title, barcode, this parcel's voucher --}}
<div class="ch-box" style="top:9.02cm;left:5.44cm;width:8.07cm;height:1.76cm;">
<div class="ch-center" style="top:0.13cm;font-size:9pt;font-weight:bold;">ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ</div>
<div class="ch-barcode" style="top:0.57cm;left:0.19cm;width:7.65cm;">
<img src="{{ $barcode_voucher }}" alt="">
</div>
<div class="ch-center" style="top:1.30cm;font-size:11pt;font-weight:bold;">{{ $voucher_no }}</div>
</div>
<div style="top:11.02cm;left:5.10cm;width:4.55cm;font-size:8pt;">Ημερομηνία - Ωρα Εκτύπωσης :</div>
<div style="top:11.02cm;left:9.70cm;width:3.70cm;font-size:8pt;">{{ $date }} {{ $time }}</div>
{{-- rectangle18-21: deposit station, weight, volumetric, pieces --}}
<div class="ch-box" style="top:11.45cm;left:0.11cm;width:1.51cm;height:0.82cm;">
<div class="ch-label" style="top:0.11cm;left:0.05cm;">Γρ.Κατάθεσης</div>
<div style="top:0.42cm;left:0.05cm;font-size:9pt;font-weight:bold;">{{ $station_apo }}</div>
</div>
<div class="ch-box" style="top:11.45cm;left:1.81cm;width:1.35cm;height:0.82cm;">
<div class="ch-label" style="top:0.11cm;left:0.05cm;">Βάρος</div>
<div class="ch-val" style="top:0.42cm;left:0.05cm;">{{ $weight }}</div>
</div>
<div class="ch-box" style="top:11.45cm;left:3.29cm;width:2.83cm;height:0.82cm;">
<div class="ch-label" style="top:0.11cm;left:0.05cm;">Ογκ/κο Βάρος</div>
<div class="ch-val" style="top:0.42cm;left:0.05cm;">{{ $volumetric_weight }}</div>
</div>
<div class="ch-box" style="top:11.45cm;left:6.28cm;width:1.22cm;height:0.82cm;">
<div class="ch-label" style="top:0.11cm;left:0.05cm;">Τεμάχια</div>
<div class="ch-val" style="top:0.42cm;left:0.05cm;">{{ $package_label }}</div>
</div>
{{-- rectangle1: service --}}
<div class="ch-box" style="top:11.48cm;left:13.49cm;width:6.20cm;height:0.86cm;">
<div style="top:0.06cm;left:0.13cm;font-size:8pt;font-weight:bold;">ΥΠΗΡΕΣΙΑ :</div>
<div style="top:0.04cm;left:3.19cm;font-size:9pt;font-weight:bold;">{{ $service_code }}</div>
<div style="top:0.43cm;left:0.06cm;width:5.82cm;font-size:8pt;font-weight:bold;">{{ $service_name }}</div>
</div>
{{-- rectangle30: destination station --}}
<div class="ch-box" style="top:12.38cm;left:0.11cm;width:12.59cm;height:0.61cm;">
<div class="ch-label" style="top:0.18cm;left:0.08cm;">Γρ.Προορισμού</div>
<div class="ch-val" style="top:0.13cm;left:1.79cm;">{{ $station_pros }}</div>
<div class="ch-val" style="top:0.14cm;left:3.65cm;width:8.73cm;">{{ $station_pros_title }}</div>
</div>
{{-- rectangle52: multi-parcel panel — piece, COD warning, master voucher --}}
<div class="ch-box" style="top:12.61cm;left:13.50cm;width:6.27cm;height:6.24cm;">
<div class="ch-center" style="top:0.26cm;font-size:10pt;font-weight:bold;">** ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ **</div>
<div class="ch-center" style="top:1.89cm;font-size:20pt;font-weight:bold;">{{ $piece_label }}</div>
@if ($antik_1)
<div class="ch-center" style="top:3.66cm;font-size:10pt;font-weight:bold;">{{ $antik_1 }}</div>
@endif
<div class="ch-center" style="top:4.77cm;font-size:10pt;font-weight:bold;">ΜASTER ΣΥΔΕΤΑ</div>
<div class="ch-center" style="top:5.31cm;font-size:11pt;font-weight:bold;">{{ $copy }}</div>
</div>
{{-- rectangle24: sender (sender_1..5) --}}
<div class="ch-box" style="top:13.06cm;left:0.11cm;width:12.57cm;height:2.74cm;">
<div style="top:0.08cm;left:0.05cm;font-size:9pt;font-weight:bold;">ΑΠΟΣΤΟΛΕΑΣ</div>
@foreach ($sender_lines as $i => $line)
<div class="ch-line" style="top:{{ 0.53 + $i * 0.43 }}cm;left:0.05cm;width:11.96cm;">{{ $line }}</div>
@endforeach
</div>
{{-- rectangle27: recipient (rec_1..5) --}}
<div class="ch-box" style="top:15.94cm;left:0.11cm;width:12.57cm;height:2.90cm;">
<div style="top:0.05cm;left:0.05cm;font-size:9pt;font-weight:bold;">ΠΑΡΑΛΗΠΤΗΣ</div>
@foreach ($recipient_lines as $i => $line)
<div class="ch-line" style="top:{{ 0.49 + $i * 0.44 }}cm;left:0.05cm;width:12.12cm;font-weight:bold;">{{ $line }}</div>
@endforeach
</div>
</div>
</body>
</html>
@@ -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; text-align: center; white-space: nowrap; }
.mc-barcode { position: absolute; text-align: center; }
.mc-barcode img { height: 26px; }
.mc-voucher { position: absolute; text-align: center; font-weight: bold; font-size: 10.5pt; letter-spacing: 0.5px; }
.mc-copylabel { position: absolute; text-align: center; font-weight: bold; font-size: 8pt; }
.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: 15.42cm; width: 5.58cm; 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.05cm; width:15.75cm; height:9.9cm;">
@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,236 @@
<!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>
<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:7pt;">{{ $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:7pt;">{{ $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>
</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,171 @@
{{--
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>
<div class="mc-company" style="top:0.12cm;left:3.15cm;width:5.19cm;">
<div class="mc-company-name">ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε.</div>
<div>Έδρα: Απελλού 1, 105 51, Αθήνα</div>
<div>Υποκ/μα: Λ. Μεσογείων 395, 153 43, Αγ. Παρασκευή</div>
<div>Α.Φ.Μ.: 094026421 | Δ.Ο.Υ.: ΚΕΦΟ.Δ.Ε. ΑΤΤΙΚΗΣ</div>
<div>Τ. 210-6073000 | Ε. info@elta-courier.gr</div>
<div>www.eltacourier.gr</div>
</div>
<div 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.19cm;left:0.05cm;width:6.85cm;">ΣΥΝΟΔΕΥΤΙΚΟ ΔΕΛΤΙΟ ΤΑΧΥΜΕΤΑΦΟΡΑΣ</div>
<div class="mc-barcode" style="top:0.57cm;left:0.24cm;width:6.67cm;">
<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.29cm;left:0.24cm;width:6.64cm;">{{ $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)
<div style="position:absolute;top:1.84cm;left:8.52cm;width:7.06cm;text-align:center;font-size:8pt;font-weight:bold;line-height:1;">{{ $antik_minima }}</div>
@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:7pt;">{{ $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.20cm;left:6.59cm;width:4.37cm;height:0.67cm;border-top:none;border-right: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.97cm;left:6.60cm;width:4.37cm;height:1.31cm;border-top:none;">
<div style="position:absolute;top:0.03cm;left:0.08cm;font-size:6pt;">* ΠΑΡΑΤΗΡΗΣΕΙΣ *</div>
</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.73cm;left:0.11cm;font-size:6pt;">{{ $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.73cm;left:0.11cm;font-size:6pt;">Ημερομηνία - Ωρα Εκτύπωσης: {{ $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,132 @@
{{--
Copy 3: the ΤΑΧΥΠΛΗΡΩΜΗ ΕΙΣΠΡΑΞΗ/ΜΕΤΑΒΙΒΑΣΗ payment-receipt stub,
geometry transcribed from sydetaE.rdlc's third band (source Top
values there run ~19.22cm-27.0cm on the full A4 page; offsets below
are relative to this partial's own container, i.e. the RDLC's Top
minus 19.22cm). This band is narrower than copies 1/2 (starts at
x=3.01cm instead of x=0.27cm) — the RDLC leaves its left margin for
the perforated tear line + rotated "3. ΑΡΧΕΙΟ / ΓΡΑΦΕΙΟ ΚΑΤΑΘΕΣΗΣ"
text, reproduced by the caller outside this partial.
Expects all of EltaLabelRenderer's $data.
--}}
<div class="mc-box" style="top:0.00cm;left:6.44cm;width:6.64cm;height:1.15cm;">
<div class="mc-barcode" style="top:0.10cm;left:0.22cm;width:6.27cm;">
<img src="{{ $barcode_voucher }}" alt="">
</div>
<div class="mc-voucher" style="top:0.75cm;left:0.24cm;width:6.14cm;">{{ $voucher_no }}</div>
</div>
<div class="mc-copylabel" style="position:absolute;top:0.10cm;left:0.00cm;width:3.92cm;font-size:8pt;font-weight:bold;">{{ $voucher_no }}</div>
{{-- 4-cell deposit/destination/weight/pieces row: same doubled-border
issue and same fix as _main-copy.blade.php's 5-cell row — a real
<table border-collapse:collapse> in place of 4 independently-bordered
absolute divs, positioned at the row's own RDLC top/left/width. --}}
<table class="mc-cell-row" style="top:1.60cm;left:0.02cm;width:5.61cm;height:0.82cm;">
<colgroup>
<col style="width:1.51cm;"><col style="width:1.67cm;"><col style="width:1.35cm;"><col style="width:1.08cm;">
</colgroup>
{{-- border-bottom:none on every cell: the ΑΠΟΣΤΟΛΕΑΣ party box
immediately below (top:2.44cm, this row ends at 2.42cm) already
draws that shared line with its own border-top. --}}
<tr>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Γρ.Κατάθεσης</div>
<div class="mc-cell-val">{{ $station_apo }}</div>
</td>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Γρ.Προορισμού</div>
<div class="mc-cell-val">{{ $station_pros }}</div>
</td>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Βάρος</div>
<div class="mc-cell-val">{{ $weight }}</div>
</td>
<td class="mc-cell-td" style="border-bottom:none;">
<div class="mc-cell-label">Τεμάχια</div>
<div class="mc-cell-val">{{ $package_label }}</div>
</td>
</tr>
</table>
<div class="mc-box" style="top:1.60cm;left:5.90cm;width:4.87cm;height:0.40cm;">
<div style="position:absolute;top:0.05cm;left:0.13cm;font-size:6pt;">{{ $service_code }}</div>
<div style="position:absolute;top:0.03cm;left:0.85cm;width:3.89cm;font-size:6pt;">{{ $service_name }}</div>
</div>
{{-- Below: several panel pairs sit a hair apart (or, for the
Συν.Χρέωσης/ΕΠΙΒΑΡΥΝΣΕΙΣ pair, even overlap slightly) per the RDLC's
own geometry — left untouched — close enough that two independent
borders read as one doubled/thick line. border-top/border-bottom is
removed from one side of each pair so only the remaining box's
border draws that shared line. --}}
<div class="mc-box mc-charge" style="top:2.02cm;left:5.90cm;width:4.87cm;height:0.37cm;font-size:5.4pt;padding-top:4px;white-space:nowrap;overflow:hidden;border-top:none;">{{ str_replace(' ΠΙΣΤΩΣΗ', ' ΤΡ.ΠΛΗΡ: ΠΙΣΤΩΣΗ', $xreosi) }}</div>
<div class="mc-box mc-partybox" style="top:2.44cm;left:0.00cm;width:5.98cm;height:0.91cm;overflow:hidden;">
<div class="mc-party-title" style="top:0.03cm;left:0.10cm;font-size:6.5pt;">ΑΠΟΣΤΟΛΕΑΣ</div>
{{-- The stub prints sender_1/sender_2 only (code + name) --}}
@foreach (array_slice($sender_lines, 0, 2) as $i => $line)
@if (trim((string) $line) !== '')
<div class="mc-party-line" style="top:{{ 0.30 + $i * 0.19 }}cm;left:0.06cm;width:5.85cm;font-size:5.6pt;">{{ $line }}</div>
@endif
@endforeach
</div>
<div class="mc-box" style="top:2.45cm;left:6.06cm;width:4.71cm;height:1.17cm;border-bottom:none;">
<div style="position:absolute;top:0.02cm;left:0.16cm;font-size:6pt;">Συν.Χρέωσης (€):</div>
<div style="position:absolute;top:0.30cm;left:0.16cm;font-size:6pt;">Πρόσθετες Υπηρεσίες</div>
</div>
<div class="mc-box mc-partybox" style="top:3.35cm;left:0.00cm;width:5.97cm;height:1.63cm;overflow:hidden;border-top:none;">
<div class="mc-party-title" style="top:0.06cm;left:0.10cm;font-size:6.5pt;">ΠΑΡΑΛΗΠΤΗΣ</div>
{{-- ...and rec_1..4: name, address, postcode — no phone --}}
@foreach (array_slice($recipient_lines, 0, 3) 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.79cm;font-size:5.6pt;">{{ $line }}</div>
@endif
@endforeach
</div>
<div class="mc-box" style="top:3.28cm;left:6.06cm;width:4.71cm;height:1.41cm;">
<div style="position:absolute;top:0.05cm;left:0.14cm;font-size:6pt;">* ΕΠΙΒΑΡΥΝΣΕΙΣ *</div>
{{-- sur_1..4 (textbox139-142) --}}
@foreach ([$sur_1, $sur_2, $sur_3, $sur_4] as $i => $sur)
@if (filled($sur))
<div style="position:absolute;top:{{ 0.33 + $i * 0.265 }}cm;left:0.13cm;width:4.52cm;font-size:6pt;">{{ $sur }}</div>
@endif
@endforeach
</div>
<div style="position:absolute;top:4.95cm;left:0.05cm;width:8.75cm;font-size:4.6pt;line-height:1.25;white-space:nowrap;">
Έλαβα γνώση των όρων που αναγράφονται στο αντίγραφο 1 και 6 και τους αποδέχομαι ανεπιφύλακτα
</div>
{{-- rectangle29 --}}
<div class="mc-box" style="top:5.46cm;left:0.03cm;width:2.75cm;height:1.66cm;">
<div style="position:absolute;top:0.08cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΥΠΟΓΡΑΦΗ ΑΠΟΣΤΟΛΕΑ</div>
<div style="position:absolute;top:1.35cm;left:0.08cm;font-size:6pt;">{{ $date }}</div>
</div>
{{-- rectangle43 --}}
<div class="mc-box" style="top:5.47cm;left:2.89cm;width:3.76cm;height:1.66cm;">
<div style="position:absolute;top:0.09cm;left:0.08cm;font-size:6pt;font-weight:bold;">ΟΝΟΜΑ/ΥΠΟΓΡΑΦΗ ΠΑΡΑΛΗΠΤΗ</div>
<div style="position:absolute;top:1.35cm;left:0.08cm;font-size:6pt;">Ημ/νία: &nbsp;&nbsp;&nbsp; Ώρα:</div>
</div>
{{-- Image18: the ELTA "ΠΟΡΤΑ-ΠΟΡΤΑ" details bitmap, above ΠΟΣΟ —
approximated with our logo + the same text, since the bitmap can't
be extracted. --}}
<div class="ps-brandbox-small" style="position:absolute;top:3.12cm;left:10.87cm;width:4.79cm;height:2.17cm;border:none;">
<div class="ps-brand-logo-sm"><img src="{{ $eltaLogo }}" alt=""></div>
<div class="ps-legal-sm">
<strong>ΠΟΡΤΑ-ΠΟΡΤΑ</strong><br>
ΕΛΛΗΝΙΚΑ ΤΑΧΥΔΡΟΜΕΙΑ Α.Ε.<br>
ΕΕΤΤ ΑΜ: 99-150
</div>
</div>
{{-- rectangle44: the COD breakdown, antik_1..6 (textbox149-151,167-169) --}}
<div class="mc-box" style="top:5.48cm;left:6.76cm;width:3.99cm;height:1.68cm;">
@foreach ($antik_lines as $i => $line)
<div style="position:absolute;top:{{ 0.04 + $i * 0.265 }}cm;left:0.11cm;width:3.78cm;font-size:7pt;{{ $i === 0 ? 'font-weight:bold;' : '' }}">{{ $line }}</div>
@endforeach
</div>
{{-- rectangle45: Π Ο Σ Ο + antik_poso (textbox148/152) --}}
<div class="mc-box" style="top:5.55cm;left:10.84cm;width:2.72cm;height:1.61cm;text-align:center;">
<div style="position:absolute;top:0.15cm;left:0;width:100%;font-size:7pt;">Π Ο Σ Ο</div>
<div style="position:absolute;top:0.70cm;left:0;width:100%;font-size:13pt;font-weight:bold;">{{ $antik_poso }}</div>
</div>
+13 -1
View File
@@ -11,7 +11,7 @@ class Staff extends ModelsStaff
'last_name', 'last_name',
'admin', 'admin',
'email', 'email',
'otp_code', 'otp_code_hash',
'otp_expires_at', 'otp_expires_at',
]; ];
@@ -19,6 +19,18 @@ class Staff extends ModelsStaff
'admin' => 'bool', 'admin' => 'bool',
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'otp_code_hash' => 'hashed',
'otp_expires_at' => 'datetime', '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; namespace Modules\Core\Auth\Services;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Modules\Core\Auth\Mail\OtpMail; use Modules\Core\Auth\Mail\OtpMail;
use Modules\Core\Auth\Models\Staff; 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); $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->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$staff->save(); $staff->save();
@@ -44,11 +48,15 @@ class OtpService
return null; 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; 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->otp_expires_at = null;
$staff->save(); $staff->save();
+17 -9
View File
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Modules\Core\Auth\Events\UserAuthenticated; 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 * at all — firstOrCreate() and UserCreated only fire from validate(), and
* only once the code has actually been proven correct. An email that * 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 * 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 * row's own otp_code_hash/otp_expires_at/otp_attempts columns exactly as
* before, so a returning shopper's login is unchanged. * 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 * Two independent throttles, both configured under core.auth.otp — see
* config/core.php's own comment for why they're separate: max_attempts * 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); $code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
if ($user) { if ($user) {
$user->otp_code = $code; $user->otp_code_hash = $code;
$user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES); $user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES);
$user->otp_attempts = 0; $user->otp_attempts = 0;
$user->save(); $user->save();
@@ -96,8 +100,12 @@ class UserOtpService
// class's own docblock for why: creating one on every // class's own docblock for why: creating one on every
// generateAndSend() call let anyone mint real User/Customer // generateAndSend() call let anyone mint real User/Customer
// rows for an email nobody proved they owned. // 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), [ Cache::put($this->pendingKey($email), [
'code' => $code, 'code_hash' => Hash::make($code),
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp, 'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
'attempts' => 0, 'attempts' => 0,
], now()->addMinutes(self::EXPIRY_MINUTES)); ], now()->addMinutes(self::EXPIRY_MINUTES));
@@ -154,15 +162,15 @@ class UserOtpService
return DB::transaction(function () use ($model, $email, $code) { return DB::transaction(function () use ($model, $email, $code) {
$user = $model::where('email', $email)->lockForUpdate()->first(); $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; return null;
} }
if (! hash_equals((string) $user->otp_code, $code)) { if (! Hash::check($code, $user->otp_code_hash)) {
$user->otp_attempts++; $user->otp_attempts++;
if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) { 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_expires_at = null;
$user->otp_attempts = 0; $user->otp_attempts = 0;
} }
@@ -172,7 +180,7 @@ class UserOtpService
return null; return null;
} }
$user->otp_code = null; $user->otp_code_hash = null;
$user->otp_expires_at = null; $user->otp_expires_at = null;
$user->otp_attempts = 0; $user->otp_attempts = 0;
$user->save(); $user->save();
@@ -200,7 +208,7 @@ class UserOtpService
return null; return null;
} }
if (! hash_equals((string) $pending['code'], $code)) { if (! Hash::check($code, $pending['code_hash'])) {
$pending['attempts']++; $pending['attempts']++;
if ($pending['attempts'] >= (int) config('core.auth.otp.max_attempts', 5)) { if ($pending['attempts'] >= (int) config('core.auth.otp.max_attempts', 5)) {
@@ -60,6 +60,7 @@ class CheckoutTranslationsSeeder extends Seeder
'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'], 'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'],
'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'], 'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'],
'cart.remove' => ['Remove', 'Αφαίρεση'], 'cart.remove' => ['Remove', 'Αφαίρεση'],
'cart.updated' => ['Cart updated', 'Το καλάθι ενημερώθηκε'],
'cart.subtotal' => ['Subtotal', 'Υποσύνολο'], 'cart.subtotal' => ['Subtotal', 'Υποσύνολο'],
'cart.discount' => ['Discount', 'Έκπτωση'], 'cart.discount' => ['Discount', 'Έκπτωση'],
'cart.shipping' => ['Shipping', 'Μεταφορικά'], 'cart.shipping' => ['Shipping', 'Μεταφορικά'],
@@ -190,6 +191,10 @@ class CheckoutTranslationsSeeder extends Seeder
'Θέλεις να παρακολουθείς την παραγγελία σου; Δημιούργησε λογαριασμό ή', 'Θέλεις να παρακολουθείς την παραγγελία σου; Δημιούργησε λογαριασμό ή',
], ],
'page.confirmation_billing' => ['Billing', 'Χρέωση'], 'page.confirmation_billing' => ['Billing', 'Χρέωση'],
'page.confirmation_bank_transfer_heading' => [
'Bank transfer details',
'Στοιχεία τραπεζικής μεταφοράς',
],
'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'], 'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'],
'page.box_now_locker_label' => [ 'page.box_now_locker_label' => [
'Choose a Box Now locker', '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\Enums\PaymentResultStatus;
use Modules\Core\Payment\Models\PaymentMethod; use Modules\Core\Payment\Models\PaymentMethod;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient; use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
use Modules\Core\Store\Services\StoreDetailsService;
/** /**
* The checkout page — one page, sections (contact / billing / shipping / * The checkout page — one page, sections (contact / billing / shipping /
@@ -554,6 +555,8 @@ class CheckoutController extends Controller
return view('checkout::confirmation', [ return view('checkout::confirmation', [
'order' => $order, 'order' => $order,
'paymentMethodName' => $paymentMethodName, '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\PaymentDriverRegistry;
use Modules\Core\Payment\Services\PaymentMethodCache; use Modules\Core\Payment\Services\PaymentMethodCache;
use Modules\Core\Shipping\Contracts\SupportsCashCollection; use Modules\Core\Shipping\Contracts\SupportsCashCollection;
use Modules\Core\Shipping\Support\BoxNowShipping;
use Modules\Core\Shipping\Support\FulfillmentType; use Modules\Core\Shipping\Support\FulfillmentType;
/** /**
@@ -181,7 +182,7 @@ class CheckoutService
// switches BACK to Box Now and it resurfaces as if still chosen, // switches BACK to Box Now and it resurfaces as if still chosen,
// possibly for a locker that no longer exists/fits. Cleared here, // possibly for a locker that no longer exists/fits. Cleared here,
// the one place that knows the method just changed. // the one place that knows the method just changed.
if ($identifier !== 'box-now') { if (! BoxNowShipping::isOption($identifier)) {
$address = $cart->shippingAddress; $address = $cart->shippingAddress;
if ($address && isset($address->meta['box_now_locker'])) { if ($address && isset($address->meta['box_now_locker'])) {
@@ -300,7 +301,7 @@ class CheckoutService
if ($shippingMethod !== null && $method->driver === 'cash-on-delivery') { if ($shippingMethod !== null && $method->driver === 'cash-on-delivery') {
$shippingDriver = collect(Shipping::getSupportedDrivers())->get($shippingMethod->driver); $shippingDriver = collect(Shipping::getSupportedDrivers())->get($shippingMethod->driver);
return $shippingDriver instanceof SupportsCashCollection && $shippingDriver->collectsCash(); return $shippingDriver instanceof SupportsCashCollection && $shippingDriver->collectsCash($shippingMethod);
} }
return true; 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\ShippingMethodListExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension; use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
use Modules\Core\Shipping\Filament\Resources\ManifestResource; use Modules\Core\Shipping\Filament\Resources\ManifestResource;
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource; use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
use Modules\Core\Store\Filament\Pages\ManageStoreDetails; use Modules\Core\Store\Filament\Pages\ManageStoreDetails;
@@ -65,6 +66,7 @@ class CorePlugin implements Plugin
->brandName('Boboko') ->brandName('Boboko')
->brandLogo(asset('static/logos/core/boboko-logo.svg')) ->brandLogo(asset('static/logos/core/boboko-logo.svg'))
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg')) ->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
->favicon(asset('static/logos/core/favicon.svg'))
->login(Login::class) ->login(Login::class)
->resources([ ->resources([
LanguageLineResource::class, LanguageLineResource::class,
@@ -73,6 +75,7 @@ class CorePlugin implements Plugin
CartResource::class, CartResource::class,
PaymentMethodResource::class, PaymentMethodResource::class,
ShipmentResource::class, ShipmentResource::class,
CarrierVoucherResource::class,
ManifestResource::class, ManifestResource::class,
]) ])
->pages([ ->pages([
@@ -150,7 +153,7 @@ class CorePlugin implements Plugin
}); });
LunarStaff::addActivitylogExcept([ LunarStaff::addActivitylogExcept([
'otp_code', 'otp_code_hash',
'otp_expires_at', 'otp_expires_at',
'password', 'password',
'remember_token', 'remember_token',
@@ -115,7 +115,7 @@ class CustomerDataProvider implements PersonalDataProvider
// secret tied to an identity that no longer exists here — clear // secret tied to an identity that no longer exists here — clear
// it alongside name/email rather than leaving it to expire on // it alongside name/email rather than leaving it to expire on
// its own 10-minute window. // its own 10-minute window.
'otp_code' => null, 'otp_code_hash' => null,
'otp_expires_at' => null, 'otp_expires_at' => null,
'otp_attempts' => 0, 'otp_attempts' => 0,
]); ]);
@@ -112,7 +112,7 @@ class CustomerAccountService
$order = $customer $order = $customer
?->orders() ?->orders()
->whereNotNull('placed_at') ->whereNotNull('placed_at')
->with(['lines', 'shippingAddress', 'billingAddress', 'transactions', 'shipments']) ->with(['lines', 'shippingAddress', 'billingAddress', 'transactions', 'shipments.shipmentInfo'])
->find($orderId); ->find($orderId);
if (! $order) { 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 * 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/ * row (see the migration adding pending_email/pending_email_code_hash/
* pending_email_expires_at/pending_email_attempts) — the same convention * 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 * 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 * and is often opened on a different device/session than the one that
* requested it; a session-scoped pending change couldn't be confirmed * requested it; a session-scoped pending change couldn't be confirmed
@@ -98,6 +98,14 @@ class StorefrontTranslationsSeeder extends Seeder
'pagination.previous' => ['Previous page', 'Προηγούμενη σελίδα'], 'pagination.previous' => ['Previous page', 'Προηγούμενη σελίδα'],
'pagination.page' => ['Page :page', 'Σελίδα :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 ───────────────────────────────────────────────── // ── Reviews ─────────────────────────────────────────────────
'review.rating' => ['Rating', 'Βαθμολογία'], 'review.rating' => ['Rating', 'Βαθμολογία'],
'review.write_label' => ['Write a review', 'Γράψε μια αξιολόγηση'], 'review.write_label' => ['Write a review', 'Γράψε μια αξιολόγηση'],
@@ -243,6 +251,26 @@ class StorefrontTranslationsSeeder extends Seeder
'orders.payment' => ['Payment method', 'Τρόπος πληρωμής'], 'orders.payment' => ['Payment method', 'Τρόπος πληρωμής'],
'orders.shipping_method' => ['Shipping method', 'Τρόπος αποστολής'], 'orders.shipping_method' => ['Shipping method', 'Τρόπος αποστολής'],
'orders.tracking' => ['Tracking', 'Παρακολούθηση αποστολής'], '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.items' => ['Items', 'Προϊόντα'],
'orders.subtotal' => ['Subtotal', 'Μερικό σύνολο'], 'orders.subtotal' => ['Subtotal', 'Μερικό σύνολο'],
'orders.discount' => ['Discount', 'Έκπτωση'], 'orders.discount' => ['Discount', 'Έκπτωση'],
+6 -10
View File
@@ -7,16 +7,12 @@ use Lunar\Models\Order;
use Modules\Core\Shipping\Models\ShipmentInfo; use Modules\Core\Shipping\Models\ShipmentInfo;
/** /**
* Dispatched by either of the two paths that move a carrier order's * Fired when the carrier picks the parcel up — Modules\Core\Order\
* `status` to 'dispatched' — Modules\Core\Order\Listeners\ * Listeners\AdvanceFulfillmentOnCarrierCheckpoint, on the first InTransit /
* AdvanceFulfillmentOnCarrierCheckpoint (automatic, reacting to a real * CollectedFromSender checkpoint (synced from the carrier, or entered by
* carrier checkpoint) or Modules\Core\Order\Services\ * hand for a manual carrier). Creating a shipment doesn't move the order to
* OrderFulfillmentService::createShipmentAndDispatch() (staff-driven, via * dispatched on its own. $shipmentInfo is that checkpoint; nullable for a
* the single "Update Status" action). $shipmentInfo is nullable * caller without one. Mirrors OrderDelivered's {order, shipmentInfo} shape.
* 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.
*/ */
class OrderDispatched class OrderDispatched
{ {
+1
View File
@@ -21,5 +21,6 @@ class OrderStatusUpdated
public readonly Order $order, public readonly Order $order,
public readonly ?string $previousStatus, public readonly ?string $previousStatus,
public readonly string $newStatus, public readonly string $newStatus,
public readonly ?string $causeClass = null,
) {} ) {}
} }
@@ -46,9 +46,10 @@ class AdvanceFulfillmentOnCarrierCheckpoint implements ShouldQueue
return; 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; return;
} }
@@ -26,9 +26,10 @@ class DeriveOrderDeliveredFromShipment implements ShouldQueue
return; return;
} }
$order = $event->shipmentInfo->shipment->order; $shipment = $event->shipmentInfo->shipment;
$order = $shipment->order;
if (! $order) { if (! $order || ! $shipment->drivesOrderStatus()) {
return; return;
} }
@@ -32,9 +32,10 @@ class MarkDeliveryFailedOnCarrierCheckpoint implements ShouldQueue
return; 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; return;
} }
@@ -8,6 +8,8 @@ use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification; use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderDispatched; use Modules\Core\Order\Events\OrderDispatched;
use Modules\Core\Order\Support\OrderReferenceDisplay; use Modules\Core\Order\Support\OrderReferenceDisplay;
use Modules\Core\Shipping\Models\Shipment;
/** /**
* Fills a real, previously-unfilled customer-communication gap — before * Fills a real, previously-unfilled customer-communication gap — before
@@ -53,6 +55,13 @@ class OrderDispatchedNotification extends BaseNotification
->view('core::order.notifications.dispatched', [ ->view('core::order.notifications.dispatched', [
'order' => $order, 'order' => $order,
'reference' => $reference, 'reference' => $reference,
// Active, numbered, non-return shipments: the voucher numbers
// to show, plus the courier's link for manual carriers
// (Shipment::trackingUrl()). Integrated carriers' history is
// 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 * "Your order is ready to collect" — listens to the specific
* OrderReadyForPickup event (dispatched by Modules\Core\Shipping\ * OrderReadyForPickup event (dispatched by Modules\Core\Shipping\
* Extensions\OrderViewExtension's "Mark Ready" action, store-pickup * Extensions\OrderViewExtension's "Mark Ready" action, store-pickup
* branch only), not the generic OrderStatusUpdated. Modules\Core\Order\ * branch only), not the generic OrderStatusUpdated.
* Notifications\OrderStatusUpdatedNotification still separately * Modules\Core\Order\Notifications\OrderStatusUpdatedNotification
* suppresses itself for the legacy 'ready-for-pickup' status string, kept * suppresses itself for that same write (its OWN_EMAIL map).
* defensively even though nothing writes that literal value to
* Order::status anymore after this redesign.
*/ */
class OrderPickupReadyNotification extends BaseNotification class OrderPickupReadyNotification extends BaseNotification
{ {
@@ -6,7 +6,12 @@ use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade; use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification; use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Commands\CloseExpiredReturnWindows;
use Modules\Core\Order\Events\OrderStatusUpdated; 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\Services\OrderStatusFlow;
use Modules\Core\Order\Support\OrderReferenceDisplay; use Modules\Core\Order\Support\OrderReferenceDisplay;
@@ -25,12 +30,22 @@ class OrderStatusUpdatedNotification extends BaseNotification
} }
/** /**
* 'ready-for-pickup' has its own, richer notification * Writes that send their own, richer email for the new status — the
* (Modules\Core\Order\Notifications\OrderPickupReadyNotification) — * generic one stays out of those, or the customer gets two. The same
* both listen to the same OrderStatusUpdated event via * status written any other way (staff's "Update Status", which fires
* NotificationRegistry, so without this the customer would get two * none of those events) still gets the generic email.
* emails for that one transition. Returning no channels is the */
* standard Laravel way to suppress a notification outright. 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 * Also suppressed for the order's very first transition off
* 'awaiting_payment' — for every payment method except bank transfer, * 'awaiting_payment' — for every payment method except bank transfer,
@@ -50,7 +65,7 @@ class OrderStatusUpdatedNotification extends BaseNotification
*/ */
public function via(object $notifiable): array 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 []; return [];
} }
+2
View File
@@ -4,6 +4,7 @@ namespace Modules\Core\Order\Observers;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Order\Events\OrderStatusUpdated; use Modules\Core\Order\Events\OrderStatusUpdated;
use Modules\Core\Order\Services\OrderStatusWriter;
/** /**
* Generically dispatches OrderStatusUpdated for ANY write to `status`, * Generically dispatches OrderStatusUpdated for ANY write to `status`,
@@ -30,6 +31,7 @@ class OrderObserver
$order, $order,
$order->getOriginal('status'), $order->getOriginal('status'),
$order->status, $order->status,
OrderStatusWriter::currentCause(),
); );
} }
} }
+67 -4
View File
@@ -12,6 +12,8 @@ use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus; use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\DTOs\ShipmentRequest; use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Models\Shipment;
use Throwable; use Throwable;
/** /**
@@ -56,7 +58,15 @@ class OrderFulfillmentService
return OrderFulfillmentResult::success('Order marked ready.'); 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') { if ($order->status !== 'ready_for_dispatch') {
return OrderFulfillmentResult::failure('This order is not ready to be dispatched.'); return OrderFulfillmentResult::failure('This order is not ready to be dispatched.');
@@ -76,9 +86,48 @@ class OrderFulfillmentService
return OrderFulfillmentResult::failure('Failed to create shipment: '.$e->getMessage()); 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,
],
]);
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 public function markPickedUp(Order $order): OrderFulfillmentResult
@@ -175,14 +224,28 @@ class OrderFulfillmentService
return OrderFulfillmentResult::success('Order marked as paid.'); return OrderFulfillmentResult::success('Order marked as paid.');
} }
/**
* A cancelled shipment doesn't block creating a new one.
*/
public function canCreateShipment(Order $order): bool public function canCreateShipment(Order $order): bool
{ {
return $order->status === 'ready_for_dispatch' return $order->status === 'ready_for_dispatch'
&& ! $order->isStorePickupOrder() && ! $order->isStorePickupOrder()
&& $order->shipments()->exists() === false && $order->shipments()->whereNull('cancelled_at')->doesntExist()
&& $this->resolveFulfillmentService($order) !== null; && $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\ * Public wrapper around resolveCarrier() — Modules\Core\Shipping\
* Extensions\OrderViewExtension needs to know which carrier an order * Extensions\OrderViewExtension needs to know which carrier an order
+19 -1
View File
@@ -29,6 +29,18 @@ use Modules\Core\Order\Events\OrderStatusChanged;
*/ */
class OrderStatusWriter 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 public function write(Order $order, string $to, string $causeClass): void
{ {
$from = $order->status; $from = $order->status;
@@ -37,7 +49,13 @@ class OrderStatusWriter
return; return;
} }
$order->update(['status' => $to]); self::$currentCause = $causeClass;
try {
$order->update(['status' => $to]);
} finally {
self::$currentCause = null;
}
OrderStatusChanged::dispatch($order, $from, $to, $causeClass); OrderStatusChanged::dispatch($order, $from, $to, $causeClass);
} }
+3 -1
View File
@@ -66,7 +66,9 @@ class OrderStatus
*/ */
public static function fulfillment(Order $order): FulfillmentStatus 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()) { if ($shipments->isEmpty()) {
return FulfillmentStatus::Unfulfilled; return FulfillmentStatus::Unfulfilled;
+16
View File
@@ -2,7 +2,10 @@
namespace Modules\Core\Providers; namespace Modules\Core\Providers;
use Carbon\Carbon;
use Filament\Support\Facades\FilamentTimezone;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Modules\Core\Command\AnonymizeCommand; use Modules\Core\Command\AnonymizeCommand;
use Modules\Core\Command\BackfillMissingSkusCommand; use Modules\Core\Command\BackfillMissingSkusCommand;
@@ -26,6 +29,19 @@ class CoreServiceProvider extends ServiceProvider
{ {
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core'); $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'core');
Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core'); Blade::anonymousComponentPath(__DIR__ . '/../../resources/views', 'core');
// Stored in UTC, shown in the shop's timezone: Filament's display
// timezone covers every admin date column, entry and picker (pickers
// also convert back to UTC on save); inDisplayTimezone() is for
// dates formatted by hand (storefront, emails, custom labels).
$displayTimezone = config('core.display_timezone', config('app.timezone'));
FilamentTimezone::set($displayTimezone);
Carbon::macro('inDisplayTimezone', fn () => $this->copy()->setTimezone(config('core.display_timezone', config('app.timezone'))));
// Lunar's order Timeline formats its times by hand (in UTC); this
// overrides just that one view with a copy that converts them —
// see resources/views/lunarpanel/.
View::prependNamespace('lunarpanel', __DIR__ . '/../../resources/views/lunarpanel');
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadTranslationsFrom(__DIR__ . '/../../lang', 'core'); $this->loadTranslationsFrom(__DIR__ . '/../../lang', 'core');
@@ -5,6 +5,7 @@ namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Lunar\Models\Language; use Lunar\Models\Language;
use Modules\Core\Command\PullTranslationsCommand;
use Modules\Core\Localization\Events\LanguageCreated; use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted; use Modules\Core\Localization\Events\LanguageDeleted;
use Modules\Core\Localization\Events\LanguageUpdated; use Modules\Core\Localization\Events\LanguageUpdated;
@@ -46,5 +47,9 @@ class LocalizationServiceProvider extends ServiceProvider
} }
Event::listen(LanguageUpdated::class, MigrateTranslationsForRenamedLanguage::class); 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\Console\Scheduling\Schedule as ConsoleSchedule;
use Illuminate\Support\Facades\Event; 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 Illuminate\Support\ServiceProvider;
use Livewire\Livewire; use Livewire\Livewire;
use Livewire\Mechanisms\ComponentRegistry; use Livewire\Mechanisms\ComponentRegistry;
use Lunar\Admin\Support\ActivityLog\Manifest as ActivityLogManifest;
use Lunar\Models\Order; use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates; 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\BoxNowClient;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService; use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver; 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\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates; 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\Carriers\StorePickup\StorePickupRateDriver;
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob; use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
use Modules\Core\Shipping\Jobs\SyncCarrierVouchersJob;
use Modules\Core\Shipping\Listeners\InvalidateShippingOptions; use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
use Modules\Core\Shipping\Support\FulfillmentType; use Modules\Core\Shipping\Support\FulfillmentType;
use Modules\Core\Shipping\Support\ShippingManager; 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/acs.php', 'acs');
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/boxnow.php', 'boxnow'); $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(AcsClient::class, fn () => new AcsClient(config('acs')));
$this->app->singleton(BoxNowClient::class, fn () => new BoxNowClient(config('boxnow'))); $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) { $this->app->bind(CarrierFulfillmentInterface::class, function ($app, array $params) {
return match ($params['carrier'] ?? null) { return match ($params['carrier'] ?? null) {
'acs' => $app->make(AcsFulfillmentService::class), 'acs' => $app->make(AcsFulfillmentService::class),
'box-now' => $app->make(BoxNowFulfillmentService::class), 'box-now' => $app->make(BoxNowFulfillmentService::class),
'elta' => $app->make(EltaFulfillmentService::class),
'manual' => $app->make(ManualFulfillmentService::class),
default => null, default => null,
}; };
}); });
@@ -64,9 +78,17 @@ class ShippingServiceProvider extends ServiceProvider
public function boot(): void 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([ $this->publishes([
__DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'), __DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'),
__DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'), __DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'),
__DIR__ . '/../../config/shippingCarriers/elta.php' => config_path('shippingCarriers/elta.php'),
], 'core-config'); ], 'core-config');
// Signed-URL auth only, same model as Lunar's own vendor // 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 — // run before the Shipping::extend() calls below, which resolve —
// and the facade then CACHES — whatever's bound at that moment). // and the facade then CACHES — whatever's bound at that moment).
$this->app->booted(function () { $this->app->booted(function () {
$this->app->bind(ShippingMethodManagerInterface::class, fn ($app) => $app->make(ShippingManager::class)); // A singleton, not bind(): the queue worker clears every
// facade's cached instance before each job
// (Facade::clearResolvedInstances()), so with bind() the Shipping
// facade then built a fresh, empty manager — without the drivers
// extended below — and every queued job (tracking, voucher sync)
// saw no carriers at all.
$this->app->singleton(ShippingMethodManagerInterface::class, fn ($app) => $app->make(ShippingManager::class));
Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class)); // Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class)); Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
Shipping::extend('store-pickup', fn ($app) => $app->make(StorePickupRateDriver::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) // $this->app->make(ConsoleSchedule::class)
->job(new WarmAcsAreaCacheJob) // ->job(new WarmAcsAreaCacheJob)
->dailyAt('06:00') // ->dailyAt('06:00')
->when(fn () => ShippingMethod::where('driver', 'acs')->exists()); // ->when(fn () => ShippingMethod::where('driver', 'acs')->exists());
$this->app->make(ConsoleSchedule::class) $this->app->make(ConsoleSchedule::class)
->job(new PollShipmentTrackingJob) ->job(new PollShipmentTrackingJob)
->everyFiveMinutes();
$this->app->make(ConsoleSchedule::class)
->job(new SyncCarrierVouchersJob)
->everyThirtyMinutes(); ->everyThirtyMinutes();
$this->overrideRatesPageLivewireComponent(); $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; namespace Modules\Core\Shipping\Carriers\Acs;
use RuntimeException; use RuntimeException;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; 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\SupportsManifestBatching;
use Modules\Core\Shipping\Contracts\SupportsTracking; use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\DTOs\ManifestResult; use Modules\Core\Shipping\DTOs\ManifestResult;
use Modules\Core\Shipping\DTOs\ShipmentRequest; use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint; use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Manifest; use Modules\Core\Shipping\Models\Manifest;
use Modules\Core\Shipping\Models\Shipment; 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( public function __construct(
private readonly AcsClient $client, private readonly AcsClient $client,
private readonly AreaResolver $areaResolver, private readonly AreaResolver $areaResolver,
@@ -44,12 +67,35 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
'Charge_Type' => 2, 'Charge_Type' => 2,
'Item_Quantity' => $request->packageCount, 'Item_Quantity' => $request->packageCount,
'Weight' => $weight, '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') { $codAmount = $request->paymentMode === 'cod'
$params['Cod_Ammount'] = $request->amountToCollect ?? $order->total->decimal; ? (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['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(); $response = $this->client->call('ACS_Create_Voucher', $params)->throwIfError();
@@ -62,8 +108,12 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
'tracking_reference' => $voucherNo, 'tracking_reference' => $voucherNo,
'meta' => [ 'meta' => [
'station_destination' => $destination->stationId, 'station_destination' => $destination->stationId,
'reference' => (string) $order->reference,
'weight' => $weight, 'weight' => $weight,
'pickup_date' => now()->toDateString(), 'pickup_date' => now()->toDateString(),
'cod_amount' => $codAmount,
'services' => $request->serviceValues(),
'insurance_amount' => $request->has(ExtraService::Insurance) ? $request->insuranceAmount : null,
], ],
]); ]);
@@ -161,12 +211,75 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
carrierStatus: $row['checkpoint_action'] ?? null, carrierStatus: $row['checkpoint_action'] ?? null,
message: $row['checkpoint_action'] ?? null, message: $row['checkpoint_action'] ?? null,
location: $row['checkpoint_location'] ?? null, location: $row['checkpoint_location'] ?? null,
occurredAt: Carbon::parse($row['checkpoint_date_time']), // ACS reports Greek time without an offset.
occurredAt: Carbon::parse($row['checkpoint_date_time'], 'Europe/Athens')->setTimezone(config('app.timezone')),
meta: $row, meta: $row,
); );
}); });
} }
/**
* 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 private function isDelivered(Shipment $shipment): bool
{ {
try { try {
@@ -177,7 +290,14 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
return false; 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 private function guessStatusFromAction(string $action): TrackingStatus
+2 -1
View File
@@ -6,6 +6,7 @@ use Lunar\DataTypes\Price;
use Lunar\DataTypes\ShippingOption; use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest; use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface; use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate; use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException; use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\CachesLivePricing; use Modules\Core\Shipping\Concerns\CachesLivePricing;
@@ -40,7 +41,7 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla
return 'carrier'; return 'carrier';
} }
public function collectsCash(): bool public function collectsCash(ShippingMethod $method): bool
{ {
return true; return true;
} }
@@ -2,12 +2,16 @@
namespace Modules\Core\Shipping\Carriers\BoxNow; namespace Modules\Core\Shipping\Carriers\BoxNow;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException; use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking; 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\ShipmentRequest;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint; use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
@@ -32,7 +36,7 @@ use Modules\Core\Shipping\Models\Shipment;
* sends that many entries in a single delivery request rather than * sends that many entries in a single delivery request rather than
* several separate ones. * several separate ones.
*/ */
class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup
{ {
private const COMPARTMENT_SIZES = ['S' => 1, 'M' => 2, 'L' => 3]; private const COMPARTMENT_SIZES = ['S' => 1, 'M' => 2, 'L' => 3];
@@ -53,8 +57,21 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
$isCod = $request->paymentMode === 'cod'; $isCod = $request->paymentMode === 'cod';
// 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()
->map(fn (Shipment $shipment) => $shipment->meta['delivery_request_id'] ?? $shipment->id)
->unique()
->count();
$orderNumber = $order->reference.'-'.$order->id.($previousRequests > 0 ? '-'.($previousRequests + 1) : '');
$response = $this->client->request('post', '/delivery-requests', [ $response = $this->client->request('post', '/delivery-requests', [
'orderNumber' => $order->reference.'-'.$order->id, 'orderNumber' => $orderNumber,
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''), 'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
'paymentMode' => $isCod ? 'cod' : 'prepaid', 'paymentMode' => $isCod ? 'cod' : 'prepaid',
'amountToBeCollected' => $isCod 'amountToBeCollected' => $isCod
@@ -92,13 +109,19 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
// operate per-Shipment), even though all boxes were submitted in // operate per-Shipment), even though all boxes were submitted in
// one delivery request. Siblings are linked via the shared // one delivery request. Siblings are linked via the shared
// delivery_request_id in meta. // delivery_request_id in meta.
$shipments = $parcels->map(fn (array $parcel) => Shipment::create([ // The cash is collected once for the whole request, so only the
// first parcel carries cod_amount.
$codAmount = $isCod ? (float) ($request->amountToCollect ?? $order->total->decimal) : null;
$shipments = $parcels->values()->map(fn (array $parcel, int $index) => Shipment::create([
'order_id' => $order->id, 'order_id' => $order->id,
'carrier' => 'box-now', 'carrier' => 'box-now',
'tracking_reference' => (string) $parcel['id'], 'tracking_reference' => (string) $parcel['id'],
'meta' => [ 'meta' => [
'delivery_request_id' => $response['id'] ?? null, 'delivery_request_id' => $response['id'] ?? null,
'reference' => $orderNumber,
'locker_id' => $destinationLocationId, 'locker_id' => $destinationLocationId,
'cod_amount' => $index === 0 ? $codAmount : null,
], ],
])); ]));
@@ -186,6 +209,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 private function mapState(string $state): TrackingStatus
{ {
// TODO: confirm against a live BoxNow webhook payload whether a // TODO: confirm against a live BoxNow webhook payload whether a
@@ -201,7 +309,7 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
'in-final-destination', 'wait-for-load' => TrackingStatus::OutForDelivery, 'in-final-destination', 'wait-for-load' => TrackingStatus::OutForDelivery,
'delivered' => TrackingStatus::Delivered, 'delivered' => TrackingStatus::Delivered,
'returned', 'accepted-for-return' => TrackingStatus::Returned, 'returned', 'accepted-for-return' => TrackingStatus::Returned,
'cancelled' => TrackingStatus::Cancelled, 'cancelled', 'canceled' => TrackingStatus::Cancelled,
'expired-return', 'missing' => TrackingStatus::Failed, 'expired-return', 'missing' => TrackingStatus::Failed,
default => TrackingStatus::Unknown, 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'] ?? '',
);
},
);
}
}
+182
View File
@@ -0,0 +1,182 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use DOMDocument;
use DOMElement;
use DOMXPath;
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
* (212.205.47.226:9003). 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);
}
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>';
$response = Http::withHeaders([
'SOAPAction' => 'Get',
'Content-Type' => 'text/xml',
])
->timeout($this->config['timeout'])
->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,622 @@
<?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\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\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, 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::Insurance->value => 'pel_asf_poso',
];
}
public function createShipment(Order $order, ShipmentRequest $request): Shipment
{
$address = $order->shippingAddress;
$weight = $request->weight ?? 0.5;
$surcharges = $this->surchargeCodes($request);
$params = [
'pel_apost_code' => config('elta.apost_code'),
'pel_paral_name' => trim("{$address->first_name} {$address->last_name}"),
'pel_paral_address' => $address->line_one,
'pel_paral_area' => $address->city,
'pel_paral_tk' => $address->postcode,
'pel_paral_thl_1' => $address->contact_phone,
'pel_paral_thl_2' => '',
'pel_service' => '',
'pel_baros' => number_format($weight, 3, '.', ''),
'pel_baros_xyz' => '',
'pel_x' => '',
'pel_y' => '',
'pel_z' => '',
'pel_temaxia' => (string) $request->packageCount,
'pel_paral_sxolia' => '',
'pel_sur_1' => $surcharges[0] ?? '',
'pel_sur_2' => $surcharges[1] ?? '',
'pel_sur_3' => $surcharges[2] ?? '',
// 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' => (string) $order->reference,
// "0" = save without issuing (see this class's docblock).
'pel_insert_flag' => '0',
'pel_paral_code' => '',
'pel_retur_code' => '',
];
$codAmount = null;
if ($request->paymentMode === 'cod') {
$codAmount = $request->amountToCollect ?? $order->total->decimal;
$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((string) $order->reference),
'reference' => (string) $order->reference,
'weight' => $weight,
'package_count' => $pieces,
'cod_amount' => $codAmount,
'services' => $request->serviceValues(),
'insurance_amount' => $request->has(ExtraService::Insurance) ? $request->insuranceAmount : null,
...($pieces > 1 ? ['piece' => 1, 'pieces' => $pieces] : []),
],
]);
// One row per extra parcel straight away, so the order shows every
// parcel; ELTA gives them their own voucher numbers only when the
// main voucher is issued (issue()). They belong to the main voucher:
// no COD or services of their own.
for ($piece = 2; $piece <= $pieces; $piece++) {
Shipment::create([
'order_id' => $order->id,
'carrier' => 'elta',
'source' => Shipment::SOURCE_CREATED,
'tracking_reference' => null,
'meta' => [
'master_shipment_id' => $master->id,
'reference' => (string) $order->reference,
'piece' => $piece,
'pieces' => $pieces,
'weight' => $weight,
'package_count' => $pieces,
],
]);
}
return $master;
}
/**
* Like ELTA's own client: printing the main voucher of a multi-parcel
* send prints every parcel (main first, then each child); printing a
* child prints just that parcel. Either way the main voucher is issued
* first — that's what gives the children their numbers.
*/
public function printLabel(Shipment $shipment): string
{
$master = $this->masterOf($shipment);
$this->issue($master);
$labels = $shipment->is($master)
? collect([$master->refresh(), ...$this->childrenOf($master)])
: collect([$shipment->refresh()]);
$pdf = $this->labelRenderer->renderMany($labels);
Shipment::whereIn('id', $labels->pluck('id'))->update(['label_printed_at' => now()]);
return $pdf;
}
/**
* Several labels in one PDF. A selected main voucher brings its
* children along (as printLabel() does); each parcel is printed once,
* main voucher before its children.
*/
public function printLabels(Collection $shipments): string
{
$labels = 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) {
$labels->put($label->id, $label);
}
}
$pdf = $this->labelRenderer->renderMany($labels->values());
Shipment::whereIn('id', $labels->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'] ?? '', ' /:'));
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,
);
});
}
/**
* 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,
'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>
*/
private function surchargeCodes(ShipmentRequest $request): array
{
$codes = $this->extraServices();
return collect($request->services)
->reject(fn (ExtraService $service) => $service === ExtraService::Insurance)
->map(fn (ExtraService $service) => $codes[$service->value] ?? null)
->filter()
->values()
->take(3)
->all();
}
private function pendingVoucherId(Shipment $shipment): string
{
$id = $shipment->meta['carrier_id'] ?? null;
if (blank($id) && $shipment->order) {
$id = $this->findPendingVoucherId((string) $shipment->order->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,408 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
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 ΕΠΙΔΟΣΗ ΣΑΒΒΑΤΟΥ',
];
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> $shipments
*/
public function renderMany(Collection $shipments): string
{
$paperSize = config('elta.label_paper_size', 'a4');
$head = null;
$styles = [];
$pages = [];
foreach ($shipments as $shipment) {
$data = $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
{
$order = $shipment->order;
$address = $order->shippingAddress;
$paperSize = config('elta.label_paper_size', 'a4');
$piece = $this->pieceOf($shipment);
$master = $piece['master'] ?? null;
// A child parcel's label details (stations, service, issue time,
// weight) are the main voucher's.
$meta = array_merge(
$master?->meta?->toArray() ?? [],
array_filter($shipment->meta?->toArray() ?? [], fn ($value) => $value !== null),
);
// Issuing a pending voucher (PELVG01NEW1) returns the real
// destination station and service; vouchers issued before that flow
// 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($address->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 = $isChild
? $this->childCodFields(($master?->meta['cod_amount'] ?? null) > 0)
: $this->codFields(isset($meta['cod_amount']) ? (float) $meta['cod_amount'] : null);
$dateTime = (string) ($meta['date_time'] ?? '');
$occurredAt = strlen($dateTime) >= 12
? Carbon::createFromFormat('YmdHi', substr($dateTime, 0, 12))
: now()->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' => trim("{$address->first_name} {$address->last_name}"),
'recipient_address' => $address->line_one,
'recipient_postcode' => $address->postcode,
'recipient_area' => $address->city,
'recipient_phone' => $address->contact_phone,
// 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' => [
trim("{$address->first_name} {$address->last_name}"),
$address->line_one,
'TK:'.$address->postcode.' '.$address->city,
'ΤΗΛ: '.$address->contact_phone,
],
'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,
'station_apo' => config('elta.origin_station_code'),
'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 : (string) $order->reference,
// 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(),
];
}
/**
* 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'];
}
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,
];
}
/**
* 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
{
$path = __DIR__.'/../../../../resources/logos/elta-courier-logo.png';
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,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,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; namespace Modules\Core\Shipping\Contracts;
use Lunar\Shipping\Models\ShippingMethod;
/** /**
* A shipping rate driver implements this to say a person is physically * A shipping rate driver implements this to say a person is physically
* present at handoff to collect cash — true for a courier like * 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. * Contracts\DeclaresFulfillmentType, which only distinguishes carrier vs.
* store_pickup and can't tell ACS and Box Now apart (both 'carrier'). * 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 * 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 * (any of table-rate-shipping's own generic drivers, if one were ever
* re-added) is treated as not supporting cash collection, the safer * re-added) is treated as not supporting cash collection, the safer
@@ -21,5 +27,5 @@ namespace Modules\Core\Shipping\Contracts;
*/ */
interface SupportsCashCollection 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 = [],
) {}
}
+24
View File
@@ -2,6 +2,8 @@
namespace Modules\Core\Shipping\DTOs; namespace Modules\Core\Shipping\DTOs;
use Modules\Core\Shipping\Enums\ExtraService;
/** /**
* Carrier-agnostic input for CarrierFulfillmentInterface::createShipment(). * Carrier-agnostic input for CarrierFulfillmentInterface::createShipment().
* Every field is optional — a carrier reads only what it needs and ignores * 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 * an order that doesn't fit one compartment. Empty for every other
* carrier, which ships as a single package described by $weight * carrier, which ships as a single package described by $weight
* instead. * 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 function __construct(
public readonly ?float $weight = null, public readonly ?float $weight = null,
@@ -27,5 +34,22 @@ class ShipmentRequest
public readonly ?string $paymentMode = null, public readonly ?string $paymentMode = null,
public readonly ?float $amountToCollect = null, public readonly ?float $amountToCollect = null,
public readonly array $boxes = [], public readonly array $boxes = [],
public readonly array $services = [],
public readonly ?float $insuranceAmount = null,
public readonly ?string $deliveryUntil = null,
public readonly ?string $trackingReference = 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);
}
} }
+47
View File
@@ -0,0 +1,47 @@
<?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';
/**
* 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',
};
}
}
@@ -3,17 +3,23 @@
namespace Modules\Core\Shipping\Extensions; namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\Action; 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\Infolists\Components\RepeatableEntry;
use Filament\Notifications\Notification;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Modules\Core\Shipping\Models\ShipmentInfo; use Modules\Core\Shipping\Models\ShipmentInfo;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Lunar\Admin\Support\Extending\ViewPageExtension; 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 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 * Adds a "Shipments" section to the order page's main column — previously
@@ -85,8 +91,9 @@ class OrderShipmentsExtension extends ViewPageExtension
->contained(true) ->contained(true)
->schema([ ->schema([
TextEntry::make('tracking_reference') TextEntry::make('tracking_reference')
->label(fn (Shipment $record) => $this->carrierLabel($record)) ->label(fn (Shipment $record) => $record->carrierLabel())
->inlineLabel() ->inlineLabel()
->placeholder('Voucher not issued yet')
->copyable(), ->copyable(),
TextEntry::make('status') TextEntry::make('status')
->label('Status') ->label('Status')
@@ -104,14 +111,57 @@ class OrderShipmentsExtension extends ViewPageExtension
now()->addMinutes(5), now()->addMinutes(5),
['shipment' => $record->id], ['shipment' => $record->id],
), shouldOpenInNewTab: true) ), 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')
->unique(Shipment::class, 'tracking_reference', ignorable: $record),
])
->action(fn (Shipment $record, array $data) => $record->update([
'tracking_reference' => filled($data['tracking_reference']) ? trim($data['tracking_reference']) : null,
]))
->visible(fn (Shipment $record) => $record->source === Shipment::SOURCE_MANUAL && ! $record->isCancelled()),
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) => $record->source === Shipment::SOURCE_MANUAL && ! $record->isCancelled()),
Action::make('cancel_shipment') Action::make('cancel_shipment')
->label('Cancel') ->label('Cancel')
->icon('heroicon-o-x-circle') ->icon('heroicon-o-x-circle')
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->modalDescription('Cancels this shipment with the carrier. This cannot be undone.') ->modalDescription(fn (Shipment $record) => $record->cancelsLocallyOnly()
->action(fn (Shipment $record) => $this->cancel($record)) ? '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), ->visible(fn (Shipment $record) => ! $record->cancelled_at),
]), ]),
RepeatableEntry::make('shipmentInfo') RepeatableEntry::make('shipmentInfo')
@@ -120,10 +170,14 @@ class OrderShipmentsExtension extends ViewPageExtension
->hidden(fn (Shipment $record) => $record->shipmentInfo->isEmpty()) ->hidden(fn (Shipment $record) => $record->shipmentInfo->isEmpty())
->schema([ ->schema([
TextEntry::make('status') TextEntry::make('status')
->label(fn (ShipmentInfo $record) => $record->occurred_at->format('Y-m-d H:i')) ->label(fn (ShipmentInfo $record) => $record->occurred_at->inDisplayTimezone()->format('Y-m-d H:i'))
->inlineLabel() ->inlineLabel()
->state(fn (ShipmentInfo $record) => (string) str($record->status->value)->replace('_', ' ')->title()) ->state(fn (ShipmentInfo $record) => (string) str($record->status->value)->replace('_', ' ')->title())
->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 +196,59 @@ class OrderShipmentsExtension extends ViewPageExtension
return $record->shipmentInfo->sortBy('occurred_at')->values(); 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) { $info = ShipmentInfo::create([
'acs' => 'ACS', 'shipment_id' => $record->id,
'box-now' => 'Box Now', 'status' => TrackingStatus::from($data['status']),
default => (string) str($record->carrier)->title(), '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 private function helperText(Shipment $record): string
{ {
$parts = ['Created '.$record->created_at->format('Y-m-d H:i')]; $parts = ['Created '.$record->created_at->inDisplayTimezone()->format('Y-m-d H:i')];
if ($record->carrier === 'box-now' && $locker = $record->meta['locker_id'] ?? null) { if ($parcel = $record->parcelDescription()) {
$parts[] = $parcel;
}
if (BoxNowShipping::isDriver($record->carrier) && $locker = $record->meta['locker_id'] ?? null) {
$parts[] = 'Locker '.$locker; $parts[] = 'Locker '.$locker;
} }
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';
}
return implode(' · ', $parts); return implode(' · ', $parts);
} }
@@ -187,26 +277,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();
}
} }
+222 -11
View File
@@ -3,17 +3,31 @@
namespace Modules\Core\Shipping\Extensions; namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\TimePicker;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Lunar\Admin\Support\Extending\ViewPageExtension; use Lunar\Admin\Support\Extending\ViewPageExtension;
use Lunar\Models\Order; use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Order\DTOs\OrderFulfillmentResult; use Modules\Core\Order\DTOs\OrderFulfillmentResult;
use Modules\Core\Order\Services\OrderFulfillmentService; use Modules\Core\Order\Services\OrderFulfillmentService;
use Modules\Core\Order\Services\OrderStatusFlow; 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\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Support\BoxNowShipping;
use Modules\Core\Shipping\Support\WeightCalculator; use Modules\Core\Shipping\Support\WeightCalculator;
use Throwable;
/** /**
* Filament wiring only (labels, icons, visibility, form schema) for the * Filament wiring only (labels, icons, visibility, form schema) for the
@@ -35,11 +49,12 @@ use Modules\Core\Shipping\Support\WeightCalculator;
* *
* "Create Shipment" is its own separate header action, visible only for a * "Create Shipment" is its own separate header action, visible only for a
* carrier order sitting at 'ready_for_dispatch' — this is the one action * carrier order sitting at 'ready_for_dispatch' — this is the one action
* that talks to a real carrier API and writes Order::status to * that talks to a real carrier API, so it needs its own weight/locker
* 'dispatched' as a side effect of that succeeding, so it needs its own * inputs specific to that one real-world action. It does NOT move the
* weight/locker inputs specific to that one real-world action, not * order to 'dispatched': that happens when the carrier reports picking the
* bundled into the general-purpose status select where they'd appear for * parcel up (AdvanceFulfillmentOnCarrierCheckpoint). Staff also pick the
* every revert/manual-override use of 'dispatched' too. The form branches * carrier's extra services here (Saturday delivery, insurance, …) from the
* normalized ExtraService list the carrier offers. The form branches
* on which carrier the order actually uses * on which carrier the order actually uses
* (OrderFulfillmentService::carrierFor()): a weight-billed carrier (ACS) * (OrderFulfillmentService::carrierFor()): a weight-billed carrier (ACS)
* gets a single TOTAL weight field for the whole shipment (ACS has no * gets a single TOTAL weight field for the whole shipment (ACS has no
@@ -62,6 +77,13 @@ use Modules\Core\Shipping\Support\WeightCalculator;
* customer's choice turns out to be unavailable) or fill it in manually for * customer's choice turns out to be unavailable) or fill it in manually for
* an order placed before the checkout locker picker existed. * an order placed before the checkout locker picker existed.
* *
* "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 * "Mark Paid" is a third, separate header action — Order::paid is
* independent of `status` (see OrderStatusFlow's own docblock), so it * independent of `status` (see OrderStatusFlow's own docblock), so it
* doesn't belong bundled into the status select either. Visible only when * doesn't belong bundled into the status select either. Visible only when
@@ -86,6 +108,7 @@ class OrderViewExtension extends ViewPageExtension
: true); : true);
$actions[] = $this->createShipmentAction(); $actions[] = $this->createShipmentAction();
$actions[] = $this->addManualVoucherAction();
$actions[] = $this->updateStatusAction(); $actions[] = $this->updateStatusAction();
$actions[] = $this->markPaidAction(); $actions[] = $this->markPaidAction();
@@ -99,11 +122,28 @@ class OrderViewExtension extends ViewPageExtension
->icon('heroicon-o-truck') ->icon('heroicon-o-truck')
->modalSubmitActionLabel('Create Shipment') ->modalSubmitActionLabel('Create Shipment')
->schema(function (Order $record) { ->schema(function (Order $record) {
$isBoxNow = $this->service()->carrierFor($record) === 'box-now'; $carrier = $this->service()->carrierFor($record);
$isBoxNow = BoxNowShipping::isDriver($carrier);
$lockerId = $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null; $lockerId = $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null;
if (! $isBoxNow) { if ($carrier === 'manual') {
return [ return [
TextInput::make('tracking_reference')
->label('Voucher / tracking number')
->unique(Shipment::class, 'tracking_reference')
->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(),
];
}
if (! $isBoxNow) {
return [...[
TextInput::make('weight') TextInput::make('weight')
->label('Total weight (kg)') ->label('Total weight (kg)')
->numeric() ->numeric()
@@ -118,10 +158,10 @@ class OrderViewExtension extends ViewPageExtension
->default(1) ->default(1)
->required() ->required()
->helperText('More than 1 issues a main voucher plus a sub-voucher per extra package, all sharing the total weight above.'), ->helperText('More than 1 issues a main voucher plus a sub-voucher per extra package, all sharing the total weight above.'),
]; ], ...$this->extraServiceFields($record)];
} }
return [ return [...[
TextInput::make('destination_location_id') TextInput::make('destination_location_id')
->label('Box Now locker ID') ->label('Box Now locker ID')
->default($lockerId) ->default($lockerId)
@@ -143,7 +183,7 @@ class OrderViewExtension extends ViewPageExtension
->addActionLabel('Add another box') ->addActionLabel('Add another box')
->minItems(1) ->minItems(1)
->helperText('One row per physical parcel — Box Now ships by compartment size, not weight.'), ->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) { ->action(function (Order $record, array $data, Action $action) {
// Derived from the order itself, never from staff input — // Derived from the order itself, never from staff input —
@@ -161,7 +201,9 @@ class OrderViewExtension extends ViewPageExtension
// full payment was already settled, and never collect it. // full payment was already settled, and never collect it.
$isCod = app(OrderStatusFlow::class)->isCod($record); $isCod = app(OrderStatusFlow::class)->isCod($record);
$result = $this->service()->createShipmentAndDispatch( $services = array_map(fn (string $value) => ExtraService::from($value), $data['services'] ?? []);
$result = $this->service()->createShipment(
$record, $record,
new ShipmentRequest( new ShipmentRequest(
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null, weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
@@ -170,10 +212,20 @@ class OrderViewExtension extends ViewPageExtension
paymentMode: $isCod ? 'cod' : 'prepaid', paymentMode: $isCod ? 'cod' : 'prepaid',
amountToCollect: $isCod ? $record->total->decimal : null, amountToCollect: $isCod ? $record->total->decimal : null,
boxes: collect($data['boxes'] ?? [])->pluck('size')->all(), 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,
), ),
); );
$this->notify($result); $this->notify($result);
// The page still holds the shipments loaded before this action.
$record->load('shipments.shipmentInfo');
if (! $result->success) { if (! $result->success) {
$action->halt(); $action->halt();
@@ -182,6 +234,165 @@ class OrderViewExtension extends ViewPageExtension
->visible(fn (Order $record) => $this->service()->canCreateShipment($record)); ->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')
->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 private function updateStatusAction(): Action
{ {
return Action::make('update_status') return Action::make('update_status')
@@ -8,6 +8,8 @@ use Filament\Schemas\Components\Concerns\HasChildComponents;
use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Get;
use InvalidArgumentException; use InvalidArgumentException;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\ResourceExtension; use Lunar\Admin\Support\Extending\ResourceExtension;
@@ -110,15 +112,49 @@ class ShippingMethodResourceExtension extends ResourceExtension
} }
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) { if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema( $children = $this->replaceChargeByField($component->getDefaultChildComponents());
$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; return $component;
}, $components); }, $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}.')
->rule('starts_with:http://,https://')
->visible($isManual),
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 private function chargeBySelect(): Select
{ {
return Select::make('charge_by') return Select::make('charge_by')
@@ -0,0 +1,399 @@
<?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 parent::getEloquentQuery()
->whereNotNull('tracking_reference')
->with(['order.shippingAddress', 'shipmentInfo']);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
TextColumn::make('tracking_reference')
->label('Tracking number')
->description(fn (Shipment $record) => $record->parcelDescription())
->copyable(),
TextColumn::make('source')
->label('Source')
->badge()
->formatStateUsing(fn (string $state) => self::SOURCES[$state] ?? $state)
->color(fn (string $state) => self::SOURCE_COLORS[$state] ?? 'gray'),
TextColumn::make('status')
->label('Status')
->state(fn (Shipment $record) => self::status($record))
->placeholder('No updates yet')
->badge()
->color(fn (Shipment $record) => self::statusColor($record)),
TextColumn::make('order.reference')
->label('Order')
->placeholder('Not linked'),
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}%")
->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([
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')
->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) => $record->meta['postcode'] ?? $record->order?->shippingAddress?->postcode)
->placeholder('—'),
TextEntry::make('phone')
->state(fn (Shipment $record) => self::phone($record))
->placeholder('—'),
]),
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),
]),
]),
]);
}
/**
* 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;
}
private static function phone(Shipment $record): ?string
{
return $record->meta['phone'] ?? $record->order?->shippingAddress?->contact_phone;
}
private static function recipient(Shipment $record): ?string
{
if ($name = $record->meta['recipient_name'] ?? null) {
return $name;
}
$address = $record->order?->shippingAddress;
return $address ? trim("{$address->first_name} {$address->last_name}") : 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,112 @@
<?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\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 () => Shipment::whereNotNull('tracking_reference')->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 () => Shipment::whereNotNull('tracking_reference')
->where(fn (Builder $q) => $q->where('meta->is_return', true)->orWhere('meta->reported_return', true))
->count() ?: null),
];
}
protected function getHeaderActions(): array
{
return [
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,32 @@
<?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;
}
protected function getHeaderActions(): array
{
return [
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\Resources\Resource;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages\ListManifests; use Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages\ListManifests;
use Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages\ViewManifest; use Modules\Core\Shipping\Filament\Resources\ManifestResource\Pages\ViewManifest;
use Modules\Core\Shipping\Filament\Resources\ManifestResource\RelationManagers\ShipmentsRelationManager; use Modules\Core\Shipping\Filament\Resources\ManifestResource\RelationManagers\ShipmentsRelationManager;
@@ -30,6 +32,17 @@ class ManifestResource extends Resource
protected static ?string $navigationLabel = 'Issued Manifests'; protected static ?string $navigationLabel = 'Issued Manifests';
/**
* Only carriers that batch into manifests (ACS) produce any — with none
* registered the menu entry would lead to an empty screen, so it's
* hidden (the page itself, and any past manifests, stay reachable).
*/
public static function shouldRegisterNavigation(): bool
{
return collect(Shipping::getSupportedDrivers())->keys()
->contains(fn (string $carrier) => ShipmentResource::fulfillmentService($carrier) instanceof SupportsManifestBatching);
}
protected static ?string $modelLabel = 'Manifest'; protected static ?string $modelLabel = 'Manifest';
protected static ?int $navigationSort = 101; protected static ?int $navigationSort = 101;
@@ -12,9 +12,8 @@ use Modules\Core\Shipping\Models\Shipment;
/** /**
* The shipments a given Manifest actually included — read-only (a * The shipments a given Manifest actually included — read-only (a
* shipment's manifest membership is set once, at issueManifest() time, * shipment's manifest membership is set once, at issueManifest() time,
* never edited here). Reuses ShipmentResource::printShipment() for the * never edited here). "Print" opens the same signed label URL as the
* "Print" action rather than duplicating its try/catch-and-notify * order page (ShipmentResource::labelUrl()).
* handling.
*/ */
class ShipmentsRelationManager extends RelationManager class ShipmentsRelationManager extends RelationManager
{ {
@@ -33,7 +32,7 @@ class ShipmentsRelationManager extends RelationManager
Action::make('print') Action::make('print')
->label('Print') ->label('Print')
->icon('heroicon-o-printer') ->icon('heroicon-o-printer')
->action(fn (Shipment $record) => ShipmentResource::printShipment($record)), ->url(fn (Shipment $record) => ShipmentResource::labelUrl($record), shouldOpenInNewTab: true),
]); ]);
} }
} }
@@ -10,7 +10,11 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\URL;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\IssuesVoucherOnPrint;
use Modules\Core\Shipping\Contracts\SupportsBatchLabels;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching; use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource\Pages\ListShipments; use Modules\Core\Shipping\Filament\Resources\ShipmentResource\Pages\ListShipments;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
@@ -21,12 +25,18 @@ use Throwable;
* ManagePickupManifests page — a bare Page has no access to Filament's * ManagePickupManifests page — a bare Page has no access to Filament's
* resource-level pill-tab UI (Filament\Resources\Concerns\HasTabs is * resource-level pill-tab UI (Filament\Resources\Concerns\HasTabs is
* scoped to ListRecords), so carrier-by-carrier separation * scoped to ListRecords), so carrier-by-carrier separation
* (ListShipments::getTabs(), one tab per SupportsManifestBatching * (ListShipments::getTabs()) needed a real Resource to attach to.
* implementer) needed a real Resource to attach to.
* *
* Shows only shipments NOT yet on an issued manifest — see * Shows only shipments created through a carrier's API that still need
* Modules\Core\Shipping\Filament\Resources\ManifestResource for * their hand-over step: not yet on an issued manifest (see Modules\Core\
* shipments that already are. * Shipping\Filament\Resources\ManifestResource for those that are), or
* for carriers that issue vouchers on print, not yet printed. Manual carriers, manual
* vouchers and carrier-reported (synced) vouchers have no label to print
* or manifest to join, so they never show here.
*
* Print opens the label through the same signed URL as the order page; for
* carriers that issue the voucher on print (ELTA), that first open is what
* gives the shipment its voucher number.
*/ */
class ShipmentResource extends Resource class ShipmentResource extends Resource
{ {
@@ -42,63 +52,159 @@ class ShipmentResource extends Resource
protected static ?int $navigationSort = 100; protected static ?int $navigationSort = 100;
/**
* A voucher leaves this queue once its carrier's hand-over step is
* done: on a manifest for carriers that batch (ACS), printed for
* carriers that issue the voucher on print (ELTA — every parcel of a
* multi-parcel send is printed together, so they leave together).
*/
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$issuedOnPrint = collect(Shipping::getSupportedDrivers())->keys()
->filter(fn (string $carrier) => self::fulfillmentService($carrier) instanceof IssuesVoucherOnPrint)
->values()
->all();
return parent::getEloquentQuery() return parent::getEloquentQuery()
->where('source', Shipment::SOURCE_CREATED)
->whereNotNull('order_id')
->whereNull('manifest_id') ->whereNull('manifest_id')
->whereNull('cancelled_at'); ->whereNull('cancelled_at')
->where(fn (Builder $query) => $query
->whereNotIn('carrier', $issuedOnPrint)
->orWhereNull('label_printed_at'));
} }
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
->defaultSort('created_at', 'desc')
->columns([ ->columns([
TextColumn::make('carrier')->badge(), TextColumn::make('carrier')
TextColumn::make('tracking_reference')->label('Tracking #'), ->badge()
->formatStateUsing(fn (Shipment $record) => $record->carrierLabel()),
TextColumn::make('tracking_reference')
->label('Tracking #')
->placeholder('Not issued yet')
->description(fn (Shipment $record) => $record->parcelDescription())
->copyable(),
TextColumn::make('order.reference')->label('Order'), TextColumn::make('order.reference')->label('Order'),
TextColumn::make('created_at')->label('Created')->dateTime(),
TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'), TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
]) ])
->recordActions([ ->recordActions([
Action::make('print') Action::make('print')
->label('Print') ->label('Print')
->icon('heroicon-o-printer') ->icon('heroicon-o-printer')
->action(fn (Shipment $record) => self::printShipment($record)), ->url(fn (Shipment $record) => self::labelUrl($record), shouldOpenInNewTab: true),
Action::make('cancel')
->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) => self::cancelShipment($record)),
]) ])
->toolbarActions([ ->toolbarActions([
BulkAction::make('print_selected') BulkAction::make('print_selected')
->label('Print selected') ->label('Print selected')
->icon('heroicon-o-printer') ->icon('heroicon-o-printer')
->action(fn (Collection $records) => $records->each(fn (Shipment $shipment) => self::printShipment($shipment))), ->deselectRecordsAfterCompletion()
->action(fn (Collection $records, $livewire) => self::printSelected($records, $livewire)),
BulkAction::make('issue_manifest') BulkAction::make('issue_manifest')
->label('Issue Manifest') ->label('Issue Manifest')
->icon('heroicon-o-check-circle') ->icon('heroicon-o-check-circle')
->visible(fn ($livewire) => filled($livewire->activeTab ?? null)
&& self::fulfillmentService($livewire->activeTab) instanceof SupportsManifestBatching)
->action(fn (Collection $records) => self::issueManifest($records)), ->action(fn (Collection $records) => self::issueManifest($records)),
]); ]);
} }
public static function printShipment(Shipment $shipment): void public static function labelUrl(Shipment $shipment): string
{ {
return URL::temporarySignedRoute('shipments.label', now()->addMinutes(5), ['shipment' => $shipment->id]);
}
/**
* One combined PDF for carriers that support it (opened in a new tab,
* with a link in the notification in case the browser blocks the
* pop-up); anything else has to be printed row by row.
*/
public static function printSelected(Collection $shipments, $livewire): void
{
$carriers = $shipments->pluck('carrier')->unique();
if ($carriers->count() !== 1) {
Notification::make()
->title('Select shipments of a single carrier to print them together.')
->warning()
->send();
return;
}
$shipment = $shipments->first();
if (! self::fulfillmentService($shipment->carrier) instanceof SupportsBatchLabels) {
Notification::make()
->title("{$shipment->carrierLabel()} labels can't be combined — print them individually.")
->warning()
->send();
return;
}
$url = URL::temporarySignedRoute('shipments.labels', now()->addMinutes(5), [
'ids' => $shipments->pluck('id')->sort()->implode(','),
]);
$livewire->js('window.open('.json_encode($url).', "_blank")');
Notification::make()
->title('Printing '.$shipments->count().' labels.')
->success()
->actions([
Action::make('open')->label('Open PDF')->url($url, shouldOpenInNewTab: true),
])
->send();
}
public static function cancelShipment(Shipment $shipment): void
{
if ($shipment->cancelsLocallyOnly()) {
$shipment->update(['cancelled_at' => now()]);
Notification::make()
->title('Shipment marked as cancelled. Void the voucher with the courier as well.')
->success()
->send();
return;
}
$service = self::fulfillmentService($shipment->carrier); $service = self::fulfillmentService($shipment->carrier);
if (! $service) { try {
if (! $service) {
throw new \RuntimeException("No fulfillment integration configured for {$shipment->carrierLabel()}.");
}
$service->cancelShipment($shipment);
} catch (Throwable $e) {
report($e);
Notification::make() Notification::make()
->title("No fulfillment integration configured for {$shipment->carrier}.") ->title('Failed to cancel shipment: '.$e->getMessage())
->danger() ->danger()
->send(); ->send();
return; return;
} }
try { Notification::make()
$service->printLabel($shipment); ->title('Shipment cancelled.')
} catch (Throwable $e) { ->success()
report($e); ->send();
Notification::make()
->title("Failed to print label for {$shipment->tracking_reference}: {$e->getMessage()}")
->danger()
->send();
}
} }
public static function issueManifest(Collection $shipments): void public static function issueManifest(Collection $shipments): void
@@ -6,17 +6,17 @@ use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Tabs\Tab;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Lunar\Shipping\Facades\Shipping; use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\IssuesVoucherOnPrint;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching; use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource; use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
/** /**
* One tab per carrier that actually implements SupportsManifestBatching * One tab per carrier with a step between creating a shipment and handing
* (ACS today) — a carrier with no manifest concept at all (Box Now, * it over: batching into a manifest (SupportsManifestBatching — ACS) or
* which books courier pickup at shipment-creation time, no separate * issuing the voucher when it's printed (IssuesVoucherOnPrint — ELTA). A
* batching step) never gets a tab here, since there is nothing to batch. * carrier with neither (Box Now books the pickup at creation) never gets a
* Adding a new carrier (e.g. Speedex) that also implements the contract * tab. The tab list is derived from Shipping::getSupportedDrivers(), so a
* needs zero changes to this page — the tab list is derived from * new carrier implementing either contract needs no change here.
* Shipping::getSupportedDrivers(), not hardcoded.
*/ */
class ListShipments extends ListRecords class ListShipments extends ListRecords
{ {
@@ -24,13 +24,14 @@ class ListShipments extends ListRecords
public function getTabs(): array public function getTabs(): array
{ {
$carriers = collect(Shipping::getSupportedDrivers()) return collect(Shipping::getSupportedDrivers())
->keys() ->filter(function ($driver, string $carrier) {
->filter(fn (string $carrier) => ShipmentResource::fulfillmentService($carrier) instanceof SupportsManifestBatching); $service = ShipmentResource::fulfillmentService($carrier);
return $carriers->mapWithKeys(fn (string $carrier) => [ return $service instanceof SupportsManifestBatching || $service instanceof IssuesVoucherOnPrint;
$carrier => Tab::make(ucwords(str_replace('-', ' ', $carrier))) })
->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)), ->map(fn ($driver, string $carrier) => Tab::make($driver->name())
])->all(); ->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)))
->all();
} }
} }
@@ -45,11 +45,21 @@ class DownloadShipmentLabelController extends Controller
$service = app(CarrierFulfillmentInterface::class, ['carrier' => $shipment->carrier]); $service = app(CarrierFulfillmentInterface::class, ['carrier' => $shipment->carrier]);
// Manual carriers, typed-in vouchers and synced ones have no
// carrier label to print.
if (! $service || ! $shipment->hasCarrierLabel()) {
abort(404);
}
$bytes = $service->printLabel($shipment); $bytes = $service->printLabel($shipment);
// Printing may have just issued the voucher number (ELTA pending
// vouchers), so name the file after the refreshed record.
$name = $shipment->refresh()->tracking_reference ?? $shipment->id;
return response($bytes, 200, [ return response($bytes, 200, [
'Content-Type' => 'application/pdf', 'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="shipment-'.$shipment->tracking_reference.'.pdf"', 'Content-Disposition' => 'inline; filename="shipment-'.$name.'.pdf"',
]); ]);
} }
} }
@@ -0,0 +1,48 @@
<?php
namespace Modules\Core\Shipping\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsBatchLabels;
use Modules\Core\Shipping\Models\Shipment;
/**
* The Pending Vouchers screen's "Print selected": several shipments' labels
* as one PDF. Same signed-URL auth as DownloadShipmentLabelController —
* the ids are part of the signed query string, so they can't be changed.
* All shipments must be one carrier's (the screen's tabs are per carrier)
* and that carrier must implement SupportsBatchLabels.
*/
class DownloadShipmentLabelsController extends Controller
{
public function __invoke(Request $request)
{
if (! $request->hasValidSignature()) {
abort(401);
}
$ids = array_filter(array_map('intval', explode(',', (string) $request->query('ids'))));
$shipments = Shipment::whereIn('id', $ids)->orderBy('id')->get()
->filter(fn (Shipment $shipment) => $shipment->hasCarrierLabel());
$carriers = $shipments->pluck('carrier')->unique();
if ($shipments->isEmpty() || $carriers->count() !== 1) {
abort(404);
}
$service = app(CarrierFulfillmentInterface::class, ['carrier' => $carriers->first()]);
if (! $service instanceof SupportsBatchLabels) {
abort(404);
}
return response($service->printLabels($shipments), 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="labels-'.now()->format('Ymd-His').'.pdf"',
]);
}
}
+5 -34
View File
@@ -11,9 +11,8 @@ use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface; use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking; use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Enums\TrackingStatus; use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Models\Shipment; use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Models\ShipmentInfo; use Modules\Core\Shipping\Services\ShipmentTrackingRecorder;
use Throwable; use Throwable;
/** /**
@@ -52,6 +51,8 @@ class PollShipmentTrackingJob implements ShouldQueue
Shipment::query() Shipment::query()
->whereIn('carrier', $trackableCarriers) ->whereIn('carrier', $trackableCarriers)
->whereNull('cancelled_at') ->whereNull('cancelled_at')
// Pending vouchers (issued on print) have no number to track yet.
->whereNotNull('tracking_reference')
->whereDoesntHave('shipmentInfo', function ($query) { ->whereDoesntHave('shipmentInfo', function ($query) {
$query->whereIn('status', [ $query->whereIn('status', [
TrackingStatus::Delivered->value, TrackingStatus::Delivered->value,
@@ -68,49 +69,19 @@ class PollShipmentTrackingJob implements ShouldQueue
private function pollCarrierShipments(string $carrier, $shipments): void private function pollCarrierShipments(string $carrier, $shipments): void
{ {
$service = $this->fulfillmentService($carrier); if (! $this->fulfillmentService($carrier) instanceof SupportsTracking) {
if (! $service instanceof SupportsTracking) {
return; return;
} }
foreach ($shipments as $shipment) { foreach ($shipments as $shipment) {
try { try {
$this->recordNewCheckpoints($shipment, $service->trackShipment($shipment)); app(ShipmentTrackingRecorder::class)->refresh($shipment);
} catch (Throwable $e) { } catch (Throwable $e) {
report($e); report($e);
} }
} }
} }
private function recordNewCheckpoints(Shipment $shipment, $checkpoints): void
{
$existing = $shipment->shipmentInfo()
->get(['status', 'occurred_at'])
->map(fn ($info) => $info->status->value.'|'.$info->occurred_at->toIso8601String())
->flip();
foreach ($checkpoints as $checkpoint) {
$fingerprint = $checkpoint->status->value.'|'.$checkpoint->occurredAt->toIso8601String();
if ($existing->has($fingerprint)) {
continue;
}
$info = ShipmentInfo::create([
'shipment_id' => $shipment->id,
'status' => $checkpoint->status,
'carrier_status' => $checkpoint->carrierStatus,
'message' => $checkpoint->message,
'location' => $checkpoint->location,
'occurred_at' => $checkpoint->occurredAt,
'meta' => $checkpoint->meta,
]);
ShipmentStatusUpdatedByCarrier::dispatch($info);
}
}
private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface private function fulfillmentService(string $carrier): ?CarrierFulfillmentInterface
{ {
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]); return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
@@ -0,0 +1,34 @@
<?php
namespace Modules\Core\Shipping\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Modules\Core\Shipping\Services\CarrierVoucherSync;
/**
* Daily pull of every carrier's reported vouchers for the last $days days
* into the Carrier Vouchers screen (CarrierVoucherSync). Also run from
* that screen's "Sync now".
*/
class SyncCarrierVouchersJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 2;
public int $backoff = 300;
public function __construct(public readonly int $days = 14) {}
public function handle(CarrierVoucherSync $sync): void
{
$sync->syncAll(now()->subDays($this->days)->startOfDay(), now());
}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Shipping\Listeners;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Support\ShipmentTimelineLogger;
/**
* Every new checkpoint (carrier polling or a manual tracking update) goes
* onto its order's Timeline as it's recorded.
*/
class LogShipmentCheckpointOnOrderTimeline
{
public function __construct(private readonly ShipmentTimelineLogger $logger) {}
public function handle(ShipmentStatusUpdatedByCarrier $event): void
{
$this->logger->log($event->shipmentInfo);
}
}
+122
View File
@@ -7,9 +7,22 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Lunar\Models\Order; use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
class Shipment extends Model class Shipment extends Model
{ {
/** Created from our admin through the carrier's API. */
public const SOURCE_CREATED = 'created';
/** An integrated carrier's voucher whose number staff typed in (API down, or the courier's own voucher). */
public const SOURCE_MANUAL_VOUCHER = 'manual_voucher';
/** A carrier with no integration (the `manual` shipping driver); tracked by hand. */
public const SOURCE_MANUAL = 'manual';
/** Pulled from a carrier's own voucher list; may not be linked to an order yet. */
public const SOURCE_SYNCED = 'synced';
protected $guarded = []; protected $guarded = [];
protected $casts = [ protected $casts = [
@@ -37,4 +50,113 @@ class Shipment extends Model
{ {
return $this->shipmentInfo()->latest('occurred_at')->first(); return $this->shipmentInfo()->latest('occurred_at')->first();
} }
/**
* The carrier's display name: a manual carrier's own name (snapshotted
* from its shipping method), otherwise the shipping driver's name().
*/
public function carrierLabel(): string
{
if (filled($this->meta['carrier_name'] ?? null)) {
return $this->meta['carrier_name'];
}
$driver = collect(Shipping::getSupportedDrivers())->get($this->carrier);
return $driver?->name() ?? ucwords(str_replace('-', ' ', $this->carrier));
}
/**
* A link to the carrier's own tracking page — manual carriers only.
* Integrated carriers' history is synced, so customers follow it on
* our own order page instead.
*/
public function trackingUrl(): ?string
{
$template = $this->meta['tracking_url'] ?? null;
if ($this->source !== self::SOURCE_MANUAL || blank($template) || blank($this->tracking_reference)) {
return null;
}
return str_replace('{number}', rawurlencode($this->tracking_reference), $template);
}
public function isCancelled(): bool
{
return $this->cancelled_at !== null;
}
/**
* A return voucher (coming back to us), from a carrier's list. Its
* checkpoints must never drive the order's own status.
*/
public function isReturn(): bool
{
return (bool) ($this->meta['is_return'] ?? false);
}
/**
* Whether this shipment's carrier checkpoints should move its order's
* status (dispatched / delivered / delivery failed).
*/
public function drivesOrderStatus(): bool
{
return $this->order_id !== null && ! $this->isReturn() && ! $this->isCancelled();
}
/**
* Only shipments created through the carrier's API have a label to
* print — manual carriers, typed-in vouchers and synced ones don't.
*/
public function hasCarrierLabel(): bool
{
return $this->source === self::SOURCE_CREATED && ! $this->isCancelled();
}
/**
* Cancelling a manual carrier's shipment or a typed-in voucher is a
* local record change only — there's nothing to cancel at the carrier
* through the API.
*/
public function cancelsLocallyOnly(): bool
{
return in_array($this->source, [self::SOURCE_MANUAL, self::SOURCE_MANUAL_VOUCHER, self::SOURCE_SYNCED], true);
}
/**
* This parcel's place in a multi-parcel send, for display — e.g.
* "Main voucher · parcel 1 of 3" or "Parcel 2 of 3 · of NZ000987399GR".
* Null for a single-parcel shipment.
*
* Read from what's already stored: the piece numbers in meta (sends
* created as multi-parcel), else parent_reference (the main voucher's
* number, set on issued extra parcels — also ACS multi-part vouchers).
*/
public function parcelDescription(): ?string
{
$piece = $this->meta['piece'] ?? null;
$pieces = $this->meta['pieces'] ?? null;
if ($piece && $pieces > 1) {
if ((int) $piece === 1) {
return "Main voucher · parcel 1 of {$pieces}";
}
$main = $this->parent_reference ?? 'the main voucher (not issued yet)';
return "Parcel {$piece} of {$pieces} · of {$main}";
}
if (filled($this->parent_reference)) {
return "Extra parcel · of {$this->parent_reference}";
}
if (filled($this->tracking_reference)
&& static::where('parent_reference', $this->tracking_reference)->exists()) {
return 'Main voucher';
}
return null;
}
} }

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