Compare commits

...
9 Commits
80 changed files with 4864 additions and 225 deletions
+122
View File
@@ -4,6 +4,128 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.29.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
+108 -45
View File
@@ -1,85 +1,148 @@
# Contributing to boboko-core
This is a Composer library, not a runnable app — you can't `php artisan serve` it directly. To develop and verify changes, you need a consumer app wired to a local checkout via a Composer path repository, plus a real database, since a large part of this package (Lunar models, migrations, Filament panel resources) can only be meaningfully verified against a live Lunar install.
This is a Composer library (and an npm package of the same name — see [JS/CSS](#jscss-a-real-npm-package)), not a runnable app — you can't `php artisan serve` it directly. To develop and verify changes, you need a consumer app wired to a local checkout, plus a real database, since a large part of this package (Lunar models, migrations, Filament panel resources) can only be meaningfully verified against a live Lunar install.
## Local dev setup
This works against any consumer app that follows the same convention — `boboko-test`, `boboko-starter`, `boboko-3dealer`, etc. — checked out next to this repo:
Consumer apps (`3dealer`, `boboko-test`, …) are checked out next to this repo:
```
RadicalElements/
├── boboko-core/ (this repo)
└── boboko-test/ (or boboko-starter, boboko-3dealer, ... — consumer app, Docker-based)
└── 3dealer/ (or boboko-test, ... — consumer app, Docker-based)
```
Each of these consumer apps ships a `bin/dc-core.sh` helper that wraps the Docker Compose overlay needed to bind-mount a local `boboko-core` checkout into the app container:
### Two modes: local and repo
A consumer app can consume core in one of two modes, and carries the wiring for both. The inactive one is parked under an underscore-prefixed key:
| | **local** — your `../boboko-core` checkout | **repo** — tagged releases from the forge |
|---|---|---|
| `composer.json` | `repositories`: path repo `../boboko-core` (`"symlink": true`) | `repositories`: VCS repo `https://code.radical-elements.com/boboko/core.git` |
| `package.json` | `@boboko/core`: `file:../boboko-core` | `@boboko/core`: `git+https://code.radical-elements.com/boboko/core.git#semver:0.x` |
| Docker Compose | `bin/dc-core.sh` (dev + `docker-compose.core-dev.yml` overlay) | `bin/dc` (dev only) |
- **The committed state is always repo mode.** Local mode rewrites both lockfiles to point at `../boboko-core`, which doesn't exist on the server — never commit it.
- Both sides use an open `0.x` range: `"boboko/core": "0.*"` in Composer, `#semver:0.x` in npm. Don't use a caret: below 1.0.0, `^0.27.0` means `>=0.27.0 <0.28.0` in both tools, so it would silently refuse the next minor.
- `docker-compose.core-dev.yml` bind-mounts `../boboko-core` into the containers — at `/var/www/boboko-core` for `app`/`queue`/`scheduler` (where the path repo resolves from `/var/www/html`) and at `/boboko-core` for `vite` (where `file:../boboko-core` resolves from `/app`). Inside the app container, `vendor/boboko/core` is a symlink into that mount.
### Switching modes: `bin/core-mode`
Don't swap the keys by hand — each consumer app ships a `bin/core-mode` script:
```bash
./bin/dc-core.sh exec app <command>
bin/core-mode # print the current mode
bin/core-mode local # work against ../boboko-core
bin/core-mode repo # back to tagged releases
```
This is shorthand for `docker compose -f docker-compose.dev.yml -f docker-compose.core-dev.yml exec app <command>`. Use `./bin/dc-core.sh` for everything below instead of typing the full compose invocation.
It swaps the `composer.json` / `package.json` wiring, runs `down` with the old mode's Compose wrapper and `up` with the new one, then waits until the entrypoints have re-resolved core. Running it for the mode you're already in skips the edits and just restarts the stack — in repo mode, that's how you pick up a newly pushed tag.
1. **Path repository.** In the consumer app's `composer.json`, the `repositories` array needs a path entry pointing at `../boboko-core`. If it only exists in a disabled block (e.g. `_repositories`), move it into the live array.
2. **Relaxed version constraint.** The consumer app's `composer.json` should require `"boboko/core": "0.*"` (not a tight `^0.0.1` caret) — otherwise Composer rejects newer `0.0.x` versions resolved from the path repo.
3. **Bind mount.** The consumer app's `docker-compose.core-dev.yml` overlays `../boboko-core` into the container at `/var/www/boboko-core`, matching where the path repo resolves it relative to `/var/www/html`.
4. **Re-resolve after every change.** Composer's path repo does not hot-reload — after editing anything in `boboko-core` (including adding new files, which need autoload discovery), the container needs to re-run `composer update boboko/core`. In `boboko-test`, the entrypoint does this automatically on every dev boot (see `docker/entrypoint.sh`), so `./bin/dc-core.sh up` alone picks up local core changes. If a consumer app's entrypoint doesn't do this yet, run it manually:
(`3dealer` has `bin/core-mode` and `bin/deploy`; they're app-agnostic, so other consumer apps can copy them as-is.)
```bash
./bin/dc-core.sh exec app composer update boboko/core --with-all-dependencies
```
### Day to day in local mode
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
// consumer app's resources/js/app.js
import { registerCheckout } from "../../vendor/boboko/core/resources/js/index.js";
import { registerCheckout, registerWishlist } from "@boboko/core";
registerCheckout(application);
registerWishlist(application);
```
```js
// consumer app's vite.config.js
import { boboko } from "@boboko/core/vite-plugin";
export default defineConfig({
plugins: [
laravel({
input: [
// Core's structural checkout styles load first, so the app's own theming wins.
"node_modules/@boboko/core/resources/css/checkout.css",
"resources/css/app.css",
"resources/js/app.js",
],
}),
boboko(),
],
});
```
```php
{{-- consumer app's layout --}}
@vite(['vendor/boboko/core/resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js'])
@vite(['node_modules/@boboko/core/resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js'])
```
**What a consumer's `vite.config.js` needs**, because `vendor/boboko/core` is a symlink in local path-repo dev (not a real directory):
`boboko()` owns the Vite settings the local-mode symlink needs, so consumers don't hand-copy them: it excludes `@boboko/core` from dependency pre-bundling (otherwise Vite serves a stale cached copy after you edit core), pre-bundles `leaflet`/`@hotwired/stimulus` explicitly, and turns on `resolve.preserveSymlinks` and `server.watch.followSymlinks` so bare imports resolve from the consumer's `node_modules` and core edits trigger HMR. All of it is a harmless no-op against a real installed copy in repo mode.
```js
export default defineConfig({
server: {
watch: {
// vendor/boboko/core is a symlink into ../boboko-core in local
// path-repo dev. Vite/chokidar don't follow symlinks for watched
// files by default, so edits to core's source wouldn't otherwise
// trigger HMR. No-op against a real installed copy (tagged VCS
// release) in production — there's no symlink to follow, and
// production only ever runs a one-shot `npm run build`, which
// doesn't watch anything regardless.
followSymlinks: true,
},
},
});
## Translations added in the UI
Default translation lines ship in core's seeders (`StorefrontTranslationsSeeder`, `CheckoutTranslationsSeeder`, `ValidationTranslationsSeeder`), which every app runs on boot and which only ever add missing keys. Lines added while building a storefront usually start in the Filament Language Lines UI instead. To move them into core:
```bash
bin/dc-core.sh exec app php artisan boboko:translations:pull # local mode: writes into ../boboko-core
bin/dc exec app php artisan boboko:translations:pull --dry-run # any mode: just list them
```
Bare imports inside this package's own JS (`leaflet`, `@hotwired/stimulus`) resolve against the *consumer's* `node_modules` — Node's normal upward `node_modules` resolution walks from `vendor/boboko/core/resources/js/...` up through `vendor/boboko/`, `vendor/`, to the consumer app's root, where `node_modules` lives. This works with zero extra config as long as `vendor/boboko/core` sits inside the consumer's own directory tree (true for both the symlink and the real-copy case) — a consuming app's `vite`-equivalent Docker service just needs the same bind mount PHP containers already get, landing at the same path:
It adds every `storefront` / `checkout` / `validation` key that's in the database but not in the matching seeder, appended at the end of `lines()` under a marker comment — move them into the right section before committing. Keys the seeder already has are never touched, even if their text was edited in the UI. `bin/deploy` runs it for you.
```yaml
# consumer app's docker-compose.core-dev.yml
services:
vite:
volumes:
- ../boboko-core:/app/vendor/boboko/core
```
## Releasing a version
(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
+4 -2
View File
@@ -2,7 +2,7 @@
"name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour",
"type": "library",
"version": "0.27.5",
"version": "0.29.0",
"autoload": {
"psr-4": {
"Modules\\Core\\": "src/"
@@ -18,7 +18,9 @@
"lunarphp/search": "*",
"lunarphp/meilisearch": "*",
"spatie/laravel-translation-loader": "^2.8",
"stripe/stripe-php": "^16.6"
"stripe/stripe-php": "^16.6",
"picqer/php-barcode-generator": "^3.3",
"barryvdh/laravel-dompdf": "^3.1"
},
"require-dev": {
"fakerphp/faker": "^1.23",
+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,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
@@ -1,6 +1,6 @@
{
"name": "@boboko/core",
"version": "0.27.5",
"version": "0.29.0",
"private": true,
"type": "module",
"description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.",
+1 -1
View File
@@ -1,5 +1,5 @@
// Single stable JS entry point for this package. A consuming app imports
// from here (vendor/boboko/core/resources/js/index.js), never from a path
// from here (`import { … } from "@boboko/core"`), never from a path
// reaching into a specific module's internals — so this file's exports can
// grow or its modules' internal layout can change without breaking every
// consumer's own entry point.
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

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

@@ -1,3 +1,12 @@
<p>Hi,</p>
<p>Your order <strong>{{ $reference }}</strong> is on its way.</p>
@foreach ($shipments ?? [] as $shipment)
<p>
{{ $shipment->carrierLabel() }}: <strong>{{ $shipment->tracking_reference }}</strong>
@if ($url = $shipment->trackingUrl())
— <a href="{{ $url }}">Track your parcel</a>
@endif
</p>
@endforeach
@@ -0,0 +1,13 @@
<div class="flex flex-col gap-1">
<div class="flex flex-wrap items-center gap-2">
@svg('heroicon-m-truck', ['class' => 'w-4 text-gray-500'])
<span>{{ $carrier }} {{ $trackingReference }}@if ($isReturn) (return)@endif</span>
<x-filament::badge :color="$statusColor">{{ $statusLabel }}</x-filament::badge>
</div>
@if ($details || $occurredAt)
<div class="text-sm text-gray-500">
{{ $details }}@if ($details && $occurredAt) · @endif{{ $occurredAt?->format('Y-m-d H:i') }}
</div>
@endif
</div>
@@ -0,0 +1,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,232 @@
<!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>
{{-- 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>
+1 -1
View File
@@ -300,7 +300,7 @@ class CheckoutService
if ($shippingMethod !== null && $method->driver === 'cash-on-delivery') {
$shippingDriver = collect(Shipping::getSupportedDrivers())->get($shippingMethod->driver);
return $shippingDriver instanceof SupportsCashCollection && $shippingDriver->collectsCash();
return $shippingDriver instanceof SupportsCashCollection && $shippingDriver->collectsCash($shippingMethod);
}
return true;
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace Modules\Core\Command;
use Illuminate\Console\Command;
use Illuminate\Database\Seeder;
use Modules\Core\Checkout\Database\Seeders\CheckoutTranslationsSeeder;
use Modules\Core\Localization\Database\Seeders\StorefrontTranslationsSeeder;
use Modules\Core\Localization\Database\Seeders\ValidationTranslationsSeeder;
use Modules\Core\Localization\Models\LanguageLine;
use ReflectionClass;
use ReflectionMethod;
/**
* The reverse of the translation seeders: copies lines added in the Filament
* Language Lines UI (e.g. while building the storefront) into the matching
* seeder's lines(), so they ship with core and every app gets them.
*
* Only adds keys the seeder doesn't have yet — a key that already exists in
* the seeder is left alone even if its text was edited in the database.
* New lines are appended at the end of lines() under a marker comment, to be
* moved into the right section by hand.
*
* Writes into the seeder files the app actually loaded, so it only makes
* sense in local mode, where vendor/boboko/core is a symlink to the
* ../boboko-core checkout. Against an installed copy it refuses to write;
* --dry-run works anywhere.
*/
class PullTranslationsCommand extends Command
{
protected $signature = 'boboko:translations:pull {--dry-run : Show what would be added without writing}';
protected $description = 'Add translation lines that exist in the database but not in the core translation seeders';
/** @var array<string, class-string<Seeder>> */
private const SEEDERS = [
'storefront' => StorefrontTranslationsSeeder::class,
'checkout' => CheckoutTranslationsSeeder::class,
'validation' => ValidationTranslationsSeeder::class,
];
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$total = 0;
foreach (self::SEEDERS as $group => $seederClass) {
$file = realpath((new ReflectionClass($seederClass))->getFileName());
if (! $dryRun && str_contains($file, '/vendor/')) {
$this->error("{$file} is an installed copy, not your ../boboko-core checkout.");
$this->line('Switch to local mode first (bin/core-mode local), or use --dry-run.');
return self::FAILURE;
}
$known = (new ReflectionMethod($seederClass, 'lines'))->invoke(new $seederClass);
$missing = LanguageLine::query()
->where('group', $group)
->whereNotIn('key', array_keys($known))
->orderBy('key')
->get();
if ($missing->isEmpty()) {
$this->line("{$group}: nothing missing");
continue;
}
$entries = '';
foreach ($missing as $line) {
$en = $line->text['en'] ?? '';
$el = $line->text['el'] ?? '';
if ($en === '' || $el === '') {
$this->warn(" {$group}.{$line->key} has no ".($en === '' ? 'English' : 'Greek').' text — added empty, fill it in');
}
$entries .= $this->entry($line->key, $en, $el);
$this->info(" + {$group}.{$line->key}");
}
$total += $missing->count();
if (! $dryRun && ! $this->append($file, $entries)) {
return self::FAILURE;
}
}
$this->newLine();
$this->line($dryRun
? "{$total} line(s) would be added."
: "{$total} line(s) added — move them into the right section and commit core.");
return self::SUCCESS;
}
/**
* One lines() entry in the seeders' own style: single line when short,
* split over several lines when long.
*/
private function entry(string $key, string $en, string $el): string
{
[$key, $en, $el] = array_map(fn (string $value) => var_export($value, true), [$key, $en, $el]);
$single = " {$key} => [{$en}, {$el}],\n";
if (mb_strlen($single) <= 120) {
return $single;
}
return " {$key} => [\n {$en},\n {$el},\n ],\n";
}
/**
* Inserts the entries right before the closing `];` of lines(), then
* lints the file and restores the original if the result doesn't parse.
*/
private function append(string $file, string $entries): bool
{
$original = file_get_contents($file);
$method = strpos($original, 'function lines(): array');
$close = $method === false ? false : strpos($original, "\n ];\n }", $method);
if ($close === false) {
$this->error("Couldn't find the end of lines() in {$file} — add these by hand.");
return false;
}
$before = rtrim(substr($original, 0, $close));
// The last existing entry doesn't always have a trailing comma.
if (! str_ends_with($before, ',') && ! str_ends_with($before, '[')) {
$before .= ',';
}
$updated = $before
."\n\n // ── Pulled from the database (boboko:translations:pull) — move into the right section ──\n"
.$entries
.substr($original, $close + 1);
file_put_contents($file, $updated);
exec(PHP_BINARY.' -l '.escapeshellarg($file).' 2>&1', $output, $exitCode);
if ($exitCode !== 0) {
file_put_contents($file, $original);
$this->error("Writing {$file} produced invalid PHP — restored the original:");
$this->line(implode("\n", $output));
return false;
}
return true;
}
}
+3
View File
@@ -49,6 +49,7 @@ use Modules\Core\Shipping\Extensions\OrderViewExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodListExtension;
use Modules\Core\Shipping\Extensions\ShippingMethodResourceExtension;
use Modules\Core\Shipping\Filament\Resources\ManifestResource;
use Modules\Core\Shipping\Filament\Resources\CarrierVoucherResource;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
use Modules\Core\Store\Filament\Pages\ManageStoreDetails;
@@ -65,6 +66,7 @@ class CorePlugin implements Plugin
->brandName('Boboko')
->brandLogo(asset('static/logos/core/boboko-logo.svg'))
->darkModeBrandLogo(asset('static/logos/core/boboko-logo-white.svg'))
->favicon(asset('static/logos/core/favicon.svg'))
->login(Login::class)
->resources([
LanguageLineResource::class,
@@ -73,6 +75,7 @@ class CorePlugin implements Plugin
CartResource::class,
PaymentMethodResource::class,
ShipmentResource::class,
CarrierVoucherResource::class,
ManifestResource::class,
])
->pages([
@@ -112,7 +112,7 @@ class CustomerAccountService
$order = $customer
?->orders()
->whereNotNull('placed_at')
->with(['lines', 'shippingAddress', 'billingAddress', 'transactions', 'shipments'])
->with(['lines', 'shippingAddress', 'billingAddress', 'transactions', 'shipments.shipmentInfo'])
->find($orderId);
if (! $order) {
@@ -98,6 +98,14 @@ class StorefrontTranslationsSeeder extends Seeder
'pagination.previous' => ['Previous page', 'Προηγούμενη σελίδα'],
'pagination.page' => ['Page :page', 'Σελίδα :page'],
// ── Error pages ─────────────────────────────────────────────
'errors.404_title' => ['Page not found', 'Η σελίδα δεν βρέθηκε'],
'errors.404_text' => [
'The page you are looking for does not exist or has been moved.',
'Η σελίδα που αναζητάς δεν υπάρχει ή έχει μετακινηθεί.',
],
'errors.back_home' => ['Back to home', 'Επιστροφή στην αρχική'],
// ── Reviews ─────────────────────────────────────────────────
'review.rating' => ['Rating', 'Βαθμολογία'],
'review.write_label' => ['Write a review', 'Γράψε μια αξιολόγηση'],
@@ -243,6 +251,26 @@ class StorefrontTranslationsSeeder extends Seeder
'orders.payment' => ['Payment method', 'Τρόπος πληρωμής'],
'orders.shipping_method' => ['Shipping method', 'Τρόπος αποστολής'],
'orders.tracking' => ['Tracking', 'Παρακολούθηση αποστολής'],
'orders.shipment' => ['Shipment', 'Αποστολή'],
'orders.voucher_number' => ['Tracking number', 'Αριθμός αποστολής'],
'orders.shipment_status' => ['Shipment status', 'Κατάσταση αποστολής'],
'orders.tracking_history' => ['Tracking history', 'Ιστορικό αποστολής'],
'orders.tracking_empty' => [
'No updates from the courier yet.',
'Δεν υπάρχουν ακόμα ενημερώσεις από την εταιρεία courier.',
],
'orders.track_on_carrier' => ['Track on the courier\'s website', 'Παρακολούθηση στη σελίδα του courier'],
// ── Tracking statuses (Modules\Core\Shipping\Enums\TrackingStatus) ──
'tracking_status.pending' => ['Awaiting pickup', 'Αναμονή παραλαβής'],
'tracking_status.collected_from_sender' => ['Picked up by the courier', 'Παραλήφθηκε από το courier'],
'tracking_status.in_transit' => ['In transit', 'Σε μεταφορά'],
'tracking_status.out_for_delivery' => ['Out for delivery', 'Προς παράδοση'],
'tracking_status.delivered' => ['Delivered', 'Παραδόθηκε'],
'tracking_status.failed' => ['Delivery attempt failed', 'Αποτυχία παράδοσης'],
'tracking_status.returned' => ['Returned to sender', 'Επιστράφηκε στον αποστολέα'],
'tracking_status.cancelled' => ['Cancelled', 'Ακυρώθηκε'],
'tracking_status.unknown' => ['Update', 'Ενημέρωση'],
'orders.items' => ['Items', 'Προϊόντα'],
'orders.subtotal' => ['Subtotal', 'Μερικό σύνολο'],
'orders.discount' => ['Discount', 'Έκπτωση'],
+6 -10
View File
@@ -7,16 +7,12 @@ use Lunar\Models\Order;
use Modules\Core\Shipping\Models\ShipmentInfo;
/**
* Dispatched by either of the two paths that move a carrier order's
* `status` to 'dispatched' — Modules\Core\Order\Listeners\
* AdvanceFulfillmentOnCarrierCheckpoint (automatic, reacting to a real
* carrier checkpoint) or Modules\Core\Order\Services\
* OrderFulfillmentService::createShipmentAndDispatch() (staff-driven, via
* the single "Update Status" action). $shipmentInfo is nullable
* specifically because of that second path — populated with the
* triggering checkpoint when it's real, null when staff drove it
* manually. Mirrors OrderDelivered's {order, shipmentInfo} shape, just
* with the nullability this one event additionally needs.
* Fired when the carrier picks the parcel up — Modules\Core\Order\
* Listeners\AdvanceFulfillmentOnCarrierCheckpoint, on the first InTransit /
* CollectedFromSender checkpoint (synced from the carrier, or entered by
* hand for a manual carrier). Creating a shipment doesn't move the order to
* dispatched on its own. $shipmentInfo is that checkpoint; nullable for a
* caller without one. Mirrors OrderDelivered's {order, shipmentInfo} shape.
*/
class OrderDispatched
{
+1
View File
@@ -21,5 +21,6 @@ class OrderStatusUpdated
public readonly Order $order,
public readonly ?string $previousStatus,
public readonly string $newStatus,
public readonly ?string $causeClass = null,
) {}
}
@@ -46,9 +46,10 @@ class AdvanceFulfillmentOnCarrierCheckpoint implements ShouldQueue
return;
}
$order = $event->shipmentInfo->shipment->order;
$shipment = $event->shipmentInfo->shipment;
$order = $shipment->order;
if (! $order || ! $this->flow->isValidTransition($order, 'dispatched')) {
if (! $order || ! $shipment->drivesOrderStatus() || ! $this->flow->isValidTransition($order, 'dispatched')) {
return;
}
@@ -26,9 +26,10 @@ class DeriveOrderDeliveredFromShipment implements ShouldQueue
return;
}
$order = $event->shipmentInfo->shipment->order;
$shipment = $event->shipmentInfo->shipment;
$order = $shipment->order;
if (! $order) {
if (! $order || ! $shipment->drivesOrderStatus()) {
return;
}
@@ -32,9 +32,10 @@ class MarkDeliveryFailedOnCarrierCheckpoint implements ShouldQueue
return;
}
$order = $event->shipmentInfo->shipment->order;
$shipment = $event->shipmentInfo->shipment;
$order = $shipment->order;
if (! $order || ! $this->flow->isValidTransition($order, 'delivery_failed')) {
if (! $order || ! $shipment->drivesOrderStatus() || ! $this->flow->isValidTransition($order, 'delivery_failed')) {
return;
}
@@ -8,6 +8,8 @@ use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Events\OrderDispatched;
use Modules\Core\Order\Support\OrderReferenceDisplay;
use Modules\Core\Shipping\Models\Shipment;
/**
* Fills a real, previously-unfilled customer-communication gap — before
@@ -53,6 +55,13 @@ class OrderDispatchedNotification extends BaseNotification
->view('core::order.notifications.dispatched', [
'order' => $order,
'reference' => $reference,
// Active, numbered, non-return shipments: the voucher numbers
// to show, plus the courier's link for manual carriers
// (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
* OrderReadyForPickup event (dispatched by Modules\Core\Shipping\
* Extensions\OrderViewExtension's "Mark Ready" action, store-pickup
* branch only), not the generic OrderStatusUpdated. Modules\Core\Order\
* Notifications\OrderStatusUpdatedNotification still separately
* suppresses itself for the legacy 'ready-for-pickup' status string, kept
* defensively even though nothing writes that literal value to
* Order::status anymore after this redesign.
* branch only), not the generic OrderStatusUpdated.
* Modules\Core\Order\Notifications\OrderStatusUpdatedNotification
* suppresses itself for that same write (its OWN_EMAIL map).
*/
class OrderPickupReadyNotification extends BaseNotification
{
@@ -6,7 +6,12 @@ use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Modules\Core\Notification\BaseNotification;
use Modules\Core\Order\Commands\CloseExpiredReturnWindows;
use Modules\Core\Order\Events\OrderStatusUpdated;
use Modules\Core\Order\Listeners\AdvanceFulfillmentOnCarrierCheckpoint;
use Modules\Core\Order\Listeners\AdvanceFulfillmentOnDelivered;
use Modules\Core\Order\Listeners\CompleteOrderOnPickedUp;
use Modules\Core\Order\Services\OrderFulfillmentService;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Support\OrderReferenceDisplay;
@@ -25,12 +30,22 @@ class OrderStatusUpdatedNotification extends BaseNotification
}
/**
* 'ready-for-pickup' has its own, richer notification
* (Modules\Core\Order\Notifications\OrderPickupReadyNotification) —
* both listen to the same OrderStatusUpdated event via
* NotificationRegistry, so without this the customer would get two
* emails for that one transition. Returning no channels is the
* standard Laravel way to suppress a notification outright.
* Writes that send their own, richer email for the new status — the
* generic one stays out of those, or the customer gets two. The same
* status written any other way (staff's "Update Status", which fires
* none of those events) still gets the generic email.
*/
private const OWN_EMAIL = [
'ready_for_pickup' => [OrderFulfillmentService::class.'::markReady'], // OrderPickupReadyNotification
'dispatched' => [AdvanceFulfillmentOnCarrierCheckpoint::class], // OrderDispatchedNotification
'delivered' => [AdvanceFulfillmentOnDelivered::class], // OrderDeliveredNotification
'completed' => [CompleteOrderOnPickedUp::class, CloseExpiredReturnWindows::class], // OrderCompletedNotification
];
/**
* Suppressed when a dedicated notification covers this write (see
* OWN_EMAIL). Returning no channels is the standard Laravel way to
* suppress a notification outright.
*
* Also suppressed for the order's very first transition off
* 'awaiting_payment' — for every payment method except bank transfer,
@@ -50,7 +65,7 @@ class OrderStatusUpdatedNotification extends BaseNotification
*/
public function via(object $notifiable): array
{
if ($this->event->newStatus === 'ready-for-pickup') {
if (in_array($this->event->causeClass, self::OWN_EMAIL[$this->event->newStatus] ?? [], true)) {
return [];
}
+2
View File
@@ -4,6 +4,7 @@ namespace Modules\Core\Order\Observers;
use Lunar\Models\Order;
use Modules\Core\Order\Events\OrderStatusUpdated;
use Modules\Core\Order\Services\OrderStatusWriter;
/**
* Generically dispatches OrderStatusUpdated for ANY write to `status`,
@@ -30,6 +31,7 @@ class OrderObserver
$order,
$order->getOriginal('status'),
$order->status,
OrderStatusWriter::currentCause(),
);
}
}
+67 -4
View File
@@ -12,6 +12,8 @@ use Modules\Core\Payment\DTOs\PaymentResult;
use Modules\Core\Payment\Enums\PaymentResultStatus;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Models\Shipment;
use Throwable;
/**
@@ -56,7 +58,15 @@ class OrderFulfillmentService
return OrderFulfillmentResult::success('Order marked ready.');
}
public function createShipmentAndDispatch(Order $order, ShipmentRequest $request): OrderFulfillmentResult
/**
* Creates the shipment only — the order stays at ready_for_dispatch
* until the carrier actually picks the parcel up. That move (and the
* "on its way" email) happens in Modules\Core\Order\Listeners\
* AdvanceFulfillmentOnCarrierCheckpoint, on the first InTransit /
* CollectedFromSender checkpoint — synced from the carrier, or entered
* by hand for a manual carrier.
*/
public function createShipment(Order $order, ShipmentRequest $request): OrderFulfillmentResult
{
if ($order->status !== 'ready_for_dispatch') {
return OrderFulfillmentResult::failure('This order is not ready to be dispatched.');
@@ -76,9 +86,48 @@ class OrderFulfillmentService
return OrderFulfillmentResult::failure('Failed to create shipment: '.$e->getMessage());
}
$this->writer->write($order, 'dispatched', self::class.'::createShipmentAndDispatch');
return OrderFulfillmentResult::success('Shipment created. The order moves to Dispatched when the carrier picks it up.');
}
return OrderFulfillmentResult::success('Shipment created and order dispatched.');
/**
* Records an integrated carrier's voucher that wasn't created through
* our API — the carrier's system was down and a pre-numbered paper
* voucher was used, or the courier wrote his own at pickup. It's still
* that carrier's voucher, so PollShipmentTrackingJob picks up its
* history once the carrier's API has it.
*
* @param array<int, ExtraService> $services
*/
public function addManualVoucher(Order $order, string $carrier, string $voucherNumber, array $services = []): OrderFulfillmentResult
{
if (! $this->canAddManualVoucher($order)) {
return OrderFulfillmentResult::failure('A voucher can only be added from Ready for Dispatch until the order is delivered.');
}
$voucherNumber = trim($voucherNumber);
if (Shipment::where('tracking_reference', $voucherNumber)->exists()) {
return OrderFulfillmentResult::failure("Voucher {$voucherNumber} is already recorded.");
}
Shipment::create([
'order_id' => $order->id,
'carrier' => $carrier,
'source' => Shipment::SOURCE_MANUAL_VOUCHER,
'tracking_reference' => $voucherNumber,
'meta' => [
'services' => array_map(fn (ExtraService $service) => $service->value, $services),
'cod_amount' => $this->flow->isCod($order) ? $order->total->decimal : null,
],
]);
return OrderFulfillmentResult::success("Voucher {$voucherNumber} added.");
}
public function canAddManualVoucher(Order $order): bool
{
return in_array($order->status, ['ready_for_dispatch', 'dispatched', 'delivery_failed'], true)
&& ! $order->isStorePickupOrder();
}
public function markPickedUp(Order $order): OrderFulfillmentResult
@@ -175,14 +224,28 @@ class OrderFulfillmentService
return OrderFulfillmentResult::success('Order marked as paid.');
}
/**
* A cancelled shipment doesn't block creating a new one.
*/
public function canCreateShipment(Order $order): bool
{
return $order->status === 'ready_for_dispatch'
&& ! $order->isStorePickupOrder()
&& $order->shipments()->exists() === false
&& $order->shipments()->whereNull('cancelled_at')->doesntExist()
&& $this->resolveFulfillmentService($order) !== null;
}
/**
* The order's shipping method — manual carriers keep their name,
* tracking URL template and cash-on-delivery setting in its `data`.
*/
public function shippingMethodFor(Order $order): ?ShippingMethod
{
$code = $order->shippingAddress?->shipping_option;
return $code ? ShippingMethod::where('code', $code)->first() : null;
}
/**
* Public wrapper around resolveCarrier() — Modules\Core\Shipping\
* Extensions\OrderViewExtension needs to know which carrier an order
+19 -1
View File
@@ -29,6 +29,18 @@ use Modules\Core\Order\Events\OrderStatusChanged;
*/
class OrderStatusWriter
{
/**
* The cause of the write in progress, so OrderObserver can put it on
* the generic OrderStatusUpdated event (null for writes that don't go
* through here, e.g. tinker).
*/
private static ?string $currentCause = null;
public static function currentCause(): ?string
{
return self::$currentCause;
}
public function write(Order $order, string $to, string $causeClass): void
{
$from = $order->status;
@@ -37,7 +49,13 @@ class OrderStatusWriter
return;
}
$order->update(['status' => $to]);
self::$currentCause = $causeClass;
try {
$order->update(['status' => $to]);
} finally {
self::$currentCause = null;
}
OrderStatusChanged::dispatch($order, $from, $to, $causeClass);
}
+3 -1
View File
@@ -66,7 +66,9 @@ class OrderStatus
*/
public static function fulfillment(Order $order): FulfillmentStatus
{
$shipments = $order->shipments->reject(fn ($shipment) => $shipment->cancelled_at !== null);
// Return vouchers linked to the order describe the parcel coming
// back, not its delivery — they never count towards fulfillment.
$shipments = $order->shipments->reject(fn ($shipment) => $shipment->isCancelled() || $shipment->isReturn());
if ($shipments->isEmpty()) {
return FulfillmentStatus::Unfulfilled;
@@ -5,6 +5,7 @@ namespace Modules\Core\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Lunar\Models\Language;
use Modules\Core\Command\PullTranslationsCommand;
use Modules\Core\Localization\Events\LanguageCreated;
use Modules\Core\Localization\Events\LanguageDeleted;
use Modules\Core\Localization\Events\LanguageUpdated;
@@ -46,5 +47,9 @@ class LocalizationServiceProvider extends ServiceProvider
}
Event::listen(LanguageUpdated::class, MigrateTranslationsForRenamedLanguage::class);
if ($this->app->runningInConsole()) {
$this->commands([PullTranslationsCommand::class]);
}
}
}
+33 -5
View File
@@ -4,9 +4,13 @@ namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule as ConsoleSchedule;
use Illuminate\Support\Facades\Event;
use Modules\Core\Shipping\ActivityLog\ShipmentCheckpointRender;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Listeners\LogShipmentCheckpointOnOrderTimeline;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use Livewire\Mechanisms\ComponentRegistry;
use Lunar\Admin\Support\ActivityLog\Manifest as ActivityLogManifest;
use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
use Lunar\Shipping\Filament\Resources\ShippingZoneResource\Pages\ManageShippingRates as VendorManageShippingRates;
@@ -24,10 +28,16 @@ use Modules\Core\Shipping\Carriers\Acs\Jobs\WarmAcsAreaCacheJob;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowFulfillmentService;
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowRateDriver;
use Modules\Core\Shipping\Carriers\Elta\EltaClient;
use Modules\Core\Shipping\Carriers\Elta\EltaFulfillmentService;
use Modules\Core\Shipping\Carriers\Elta\EltaRateDriver;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Filament\Pages\ManageShippingRates;
use Modules\Core\Shipping\Carriers\Manual\ManualFulfillmentService;
use Modules\Core\Shipping\Carriers\Manual\ManualRateDriver;
use Modules\Core\Shipping\Carriers\StorePickup\StorePickupRateDriver;
use Modules\Core\Shipping\Jobs\PollShipmentTrackingJob;
use Modules\Core\Shipping\Jobs\SyncCarrierVouchersJob;
use Modules\Core\Shipping\Listeners\InvalidateShippingOptions;
use Modules\Core\Shipping\Support\FulfillmentType;
use Modules\Core\Shipping\Support\ShippingManager;
@@ -39,14 +49,18 @@ class ShippingServiceProvider extends ServiceProvider
{
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/acs.php', 'acs');
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/boxnow.php', 'boxnow');
$this->mergeConfigFrom(__DIR__ . '/../../config/shippingCarriers/elta.php', 'elta');
$this->app->singleton(AcsClient::class, fn () => new AcsClient(config('acs')));
$this->app->singleton(BoxNowClient::class, fn () => new BoxNowClient(config('boxnow')));
$this->app->singleton(EltaClient::class, fn () => new EltaClient(config('elta')));
$this->app->bind(CarrierFulfillmentInterface::class, function ($app, array $params) {
return match ($params['carrier'] ?? null) {
'acs' => $app->make(AcsFulfillmentService::class),
'box-now' => $app->make(BoxNowFulfillmentService::class),
'elta' => $app->make(EltaFulfillmentService::class),
'manual' => $app->make(ManualFulfillmentService::class),
default => null,
};
});
@@ -64,9 +78,17 @@ class ShippingServiceProvider extends ServiceProvider
public function boot(): void
{
// Carrier checkpoints on the order page's Timeline.
Event::listen(ShipmentStatusUpdatedByCarrier::class, LogShipmentCheckpointOnOrderTimeline::class);
// Added when Lunar's manifest is built, not now: resolving it during
// boot builds it before the morph map exists, which keys every
// renderer (Lunar's own too) under a name the Timeline never looks up.
$this->app->afterResolving('lunar-activity-log', fn (ActivityLogManifest $manifest) => $manifest->addRender(Order::class, ShipmentCheckpointRender::class));
$this->publishes([
__DIR__ . '/../../config/shippingCarriers/acs.php' => config_path('shippingCarriers/acs.php'),
__DIR__ . '/../../config/shippingCarriers/boxnow.php' => config_path('shippingCarriers/boxnow.php'),
__DIR__ . '/../../config/shippingCarriers/elta.php' => config_path('shippingCarriers/elta.php'),
], 'core-config');
// Signed-URL auth only, same model as Lunar's own vendor
@@ -134,17 +156,23 @@ class ShippingServiceProvider extends ServiceProvider
$this->app->booted(function () {
$this->app->bind(ShippingMethodManagerInterface::class, fn ($app) => $app->make(ShippingManager::class));
Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
// Shipping::extend('acs', fn ($app) => $app->make(AcsRateDriver::class));
Shipping::extend('box-now', fn ($app) => $app->make(BoxNowRateDriver::class));
Shipping::extend('store-pickup', fn ($app) => $app->make(StorePickupRateDriver::class));
Shipping::extend('elta', fn ($app) => $app->make(EltaRateDriver::class));
Shipping::extend('manual', fn ($app) => $app->make(ManualRateDriver::class));
$this->app->make(ConsoleSchedule::class)
->job(new WarmAcsAreaCacheJob)
->dailyAt('06:00')
->when(fn () => ShippingMethod::where('driver', 'acs')->exists());
// $this->app->make(ConsoleSchedule::class)
// ->job(new WarmAcsAreaCacheJob)
// ->dailyAt('06:00')
// ->when(fn () => ShippingMethod::where('driver', 'acs')->exists());
$this->app->make(ConsoleSchedule::class)
->job(new PollShipmentTrackingJob)
->everyFiveMinutes();
$this->app->make(ConsoleSchedule::class)
->job(new SyncCarrierVouchersJob)
->everyThirtyMinutes();
$this->overrideRatesPageLivewireComponent();
@@ -0,0 +1,45 @@
<?php
namespace Modules\Core\Shipping\ActivityLog;
use Illuminate\Support\Carbon;
use Lunar\Admin\Support\ActivityLog\AbstractRender;
use Modules\Core\Shipping\Support\ShipmentTimelineLogger;
use Spatie\Activitylog\Models\Activity;
/**
* Draws a shipment checkpoint on the order page's Timeline (logged by
* ShipmentTimelineLogger): carrier + voucher, the status badge, and the
* carrier's own location / text.
*/
class ShipmentCheckpointRender extends AbstractRender
{
public function getEvent(): string
{
return ShipmentTimelineLogger::EVENT;
}
public function render(Activity $log)
{
$status = (string) $log->getExtraProperty('status');
$occurredAt = $log->getExtraProperty('occurred_at');
return view('core::shipping.activity.shipment-checkpoint', [
'carrier' => $log->getExtraProperty('carrier'),
'trackingReference' => $log->getExtraProperty('tracking_reference'),
'isReturn' => (bool) $log->getExtraProperty('is_return'),
'statusLabel' => (string) str($status)->replace('_', ' ')->title(),
'statusColor' => match ($status) {
'delivered' => 'success',
'failed', 'returned', 'cancelled' => 'danger',
'in_transit', 'out_for_delivery', 'collected_from_sender' => 'warning',
default => 'gray',
},
'details' => collect([
$log->getExtraProperty('location'),
$log->getExtraProperty('message') ?: $log->getExtraProperty('carrier_status'),
])->filter()->unique()->implode(' · '),
'occurredAt' => $occurredAt ? Carbon::parse($occurredAt)->timezone(config('app.timezone')) : null,
]);
}
}
@@ -3,22 +3,45 @@
namespace Modules\Core\Shipping\Carriers\Acs;
use RuntimeException;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Lunar\Models\Order;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsExtraServices;
use Modules\Core\Shipping\Contracts\SupportsVoucherListing;
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
use Modules\Core\Shipping\DTOs\CarrierVoucher;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\DTOs\ManifestResult;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Models\Manifest;
use Modules\Core\Shipping\Models\Shipment;
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsManifestBatching, SupportsTracking
class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsExtraServices, SupportsManifestBatching, SupportsTracking, SupportsVoucherListing, SupportsVoucherLookup
{
/**
* ACS's Acs_Delivery_Products codes. Timed delivery also sends
* Appointment_Until_Time; insurance sends Insurance_Ammount.
*/
public function extraServices(): array
{
return [
ExtraService::SaturdayDelivery->value => 'SAT',
ExtraService::MorningDelivery->value => 'MDV',
ExtraService::TimedDelivery->value => 'TDD',
ExtraService::Insurance->value => 'INS',
ExtraService::ReturnDocuments->value => 'RDO',
ExtraService::RemoteArea->value => 'REM',
ExtraService::Protocol->value => 'PRO',
];
}
public function __construct(
private readonly AcsClient $client,
private readonly AreaResolver $areaResolver,
@@ -44,12 +67,35 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
'Charge_Type' => 2,
'Item_Quantity' => $request->packageCount,
'Weight' => $weight,
// Our order reference, so the vouchers ACS reports back can be
// matched to their order.
'Reference_Key1' => (string) $order->reference,
];
if ($request->paymentMode === 'cod') {
$params['Cod_Ammount'] = $request->amountToCollect ?? $order->total->decimal;
$codAmount = $request->paymentMode === 'cod'
? (float) ($request->amountToCollect ?? $order->total->decimal)
: null;
$products = collect($request->services)
->map(fn (ExtraService $service) => $this->extraServices()[$service->value] ?? null)
->filter();
if ($codAmount !== null) {
$params['Cod_Ammount'] = $codAmount;
$params['Cod_Payment_Way'] = 0; // cash
$params['Acs_Delivery_Products'] = 'COD';
$products->push('COD');
}
if ($products->isNotEmpty()) {
$params['Acs_Delivery_Products'] = $products->unique()->implode(',');
}
if ($request->has(ExtraService::Insurance) && $request->insuranceAmount) {
$params['Insurance_Ammount'] = $request->insuranceAmount;
}
if ($request->has(ExtraService::TimedDelivery) && $request->deliveryUntil) {
$params['Appointment_Until_Time'] = substr($request->deliveryUntil, 0, 5);
}
$response = $this->client->call('ACS_Create_Voucher', $params)->throwIfError();
@@ -64,6 +110,9 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
'station_destination' => $destination->stationId,
'weight' => $weight,
'pickup_date' => now()->toDateString(),
'cod_amount' => $codAmount,
'services' => $request->serviceValues(),
'insurance_amount' => $request->has(ExtraService::Insurance) ? $request->insuranceAmount : null,
],
]);
@@ -167,6 +216,68 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
});
}
/**
* ACS can't list vouchers directly: each day's pickup lists
* (ACS_Get_Pickup_Lists) → their vouchers (ACS_Pickup_List_Display_
* Voucher). Only finalized vouchers appear, with our Reference_Key1 but
* no recipient or status — tracking fills those in.
*/
public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable
{
for ($day = $from->copy()->startOfDay(); $day->lte($to); $day = $day->addDay()) {
$date = $day->toDateString();
$lists = $this->client->call('ACS_Get_Pickup_Lists', ['Pickup_Date' => $date])
->throwIfError()->tableOutput['Table_Data'] ?? [];
foreach ($lists as $list) {
$vouchers = $this->client->call('ACS_Pickup_List_Display_Voucher', [
'PickupList_No' => $list['PickupList_No'],
'Pickup_Date' => $date,
])->throwIfError()->tableOutput['Table_Data'] ?? [];
foreach ($vouchers as $row) {
yield new CarrierVoucher(
carrier: 'acs',
voucherNumber: (string) $row['Voucher_no'],
reference: filled($row['Reference_Key1'] ?? null) ? (string) $row['Reference_Key1'] : null,
date: $day->copy(),
raw: $row,
);
}
}
}
}
/**
* ACS_Trackingsummary: recipient and delivery/return flags — ACS doesn't
* return our reference here.
*/
public function lookupVoucher(string $voucherNumber): ?CarrierVoucher
{
$row = $this->client->call('ACS_Trackingsummary', ['Voucher_No' => trim($voucherNumber)])
->throwIfError()->tableOutput['Table_Data'][0] ?? null;
if (! $row || blank($row['voucher_no'] ?? null)) {
return null;
}
return new CarrierVoucher(
carrier: 'acs',
voucherNumber: (string) $row['voucher_no'],
recipientName: $row['recipient'] ?? ($row['consignee'] ?? null),
statusText: (int) ($row['delivery_flag'] ?? 0) === 1 ? 'Delivered' : ($row['delivery_info'] ?? null),
status: match (true) {
(int) ($row['returned_flag'] ?? 0) === 1 => TrackingStatus::Returned,
(int) ($row['delivery_flag'] ?? 0) === 1 => TrackingStatus::Delivered,
default => null,
},
isReturn: (int) ($row['returned_flag'] ?? 0) === 1,
date: filled($row['pickup_date'] ?? null) ? Carbon::parse($row['pickup_date']) : null,
raw: $row,
);
}
private function isDelivered(Shipment $shipment): bool
{
try {
@@ -177,7 +288,14 @@ class AcsFulfillmentService implements CarrierFulfillmentInterface, SupportsMani
return false;
}
return (int) ($response->valueOutput['shipment_status'] ?? 0) === 4;
// The summary row is in ACSTableOutput.Table_Data, not
// ACSValueOutput — reading the latter always came back empty, so no
// ACS shipment was ever marked delivered. delivery_flag = 1 is
// ACS's documented "delivered"; shipment_status 4 means the same.
$summary = $response->tableOutput['Table_Data'][0] ?? [];
return (int) ($summary['delivery_flag'] ?? 0) === 1
|| (int) ($summary['shipment_status'] ?? 0) === 4;
}
private function guessStatusFromAction(string $action): TrackingStatus
+2 -1
View File
@@ -6,6 +6,7 @@ use Lunar\DataTypes\Price;
use Lunar\DataTypes\ShippingOption;
use Lunar\Shipping\DataTransferObjects\ShippingOptionRequest;
use Lunar\Shipping\Interfaces\ShippingRateInterface;
use Lunar\Shipping\Models\ShippingMethod;
use Lunar\Shipping\Models\ShippingRate;
use Modules\Core\Shipping\Carriers\Acs\Exceptions\AcsApiException;
use Modules\Core\Shipping\Concerns\CachesLivePricing;
@@ -40,7 +41,7 @@ class AcsRateDriver implements ShippingRateInterface, SupportsLivePricing, Decla
return 'carrier';
}
public function collectsCash(): bool
public function collectsCash(ShippingMethod $method): bool
{
return true;
}
@@ -2,12 +2,16 @@
namespace Modules\Core\Shipping\Carriers\BoxNow;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Lunar\Models\Order;
use Modules\Core\Shipping\Carriers\BoxNow\Exceptions\BoxNowApiException;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\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\TrackingStatus;
@@ -32,7 +36,7 @@ use Modules\Core\Shipping\Models\Shipment;
* sends that many entries in a single delivery request rather than
* 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];
@@ -53,8 +57,21 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
$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', [
'orderNumber' => $order->reference.'-'.$order->id,
'orderNumber' => $orderNumber,
'invoiceValue' => number_format($order->total->decimal, 2, '.', ''),
'paymentMode' => $isCod ? 'cod' : 'prepaid',
'amountToBeCollected' => $isCod
@@ -92,13 +109,18 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
// operate per-Shipment), even though all boxes were submitted in
// one delivery request. Siblings are linked via the shared
// delivery_request_id in meta.
$shipments = $parcels->map(fn (array $parcel) => Shipment::create([
// 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,
'carrier' => 'box-now',
'tracking_reference' => (string) $parcel['id'],
'meta' => [
'delivery_request_id' => $response['id'] ?? null,
'locker_id' => $destinationLocationId,
'cod_amount' => $index === 0 ? $codAmount : null,
],
]));
@@ -186,6 +208,91 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
));
}
/**
* Every parcel on the account (GET /parcels, 100 per page). Box Now has
* no date filter, so pages are read until a whole page is older than
* $from — its order isn't documented, so one old parcel alone doesn't
* stop the scan.
*/
public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable
{
$pageToken = null;
do {
$response = $this->client->request('get', '/parcels', array_filter([
'limit' => 100,
'pageToken' => $pageToken,
]));
$parcels = $response['data'] ?? [];
$anyInRange = false;
foreach ($parcels as $parcel) {
$created = Carbon::parse($parcel['createTime'] ?? 'now');
if ($created->lt($from)) {
continue;
}
$anyInRange = true;
if ($created->lte($to)) {
yield $this->voucherFromParcel($parcel);
}
}
$pageToken = $this->nextPageToken($response['pagination']['next'] ?? null);
} while ($pageToken && $anyInRange && $parcels !== []);
}
public function lookupVoucher(string $voucherNumber): ?CarrierVoucher
{
$parcel = $this->client->request('get', '/parcels', ['parcelId' => trim($voucherNumber)])['data'][0] ?? null;
return $parcel ? $this->voucherFromParcel($parcel) : null;
}
private function voucherFromParcel(array $parcel): CarrierVoucher
{
$request = $parcel['deliveryRequest'] ?? [];
$destination = $request['destination'] ?? [];
$state = $parcel['state'] ?? null;
return new CarrierVoucher(
carrier: 'box-now',
voucherNumber: (string) $parcel['id'],
reference: $request['orderNumber'] ?? null,
recipientName: $destination['contactName'] ?? null,
postcode: $destination['postalCode'] ?? ($destination['address']['postalCode'] ?? null),
phone: $destination['contactNumber'] ?? null,
statusText: $state,
status: $state ? $this->mapState($state) : null,
codAmount: ($request['paymentMode'] ?? null) === 'cod' ? ((float) ($request['amountToBeCollected'] ?? 0) ?: null) : null,
isReturn: in_array($state, ['returned', 'expired-return', 'accepted-for-return'], true),
date: isset($parcel['createTime']) ? Carbon::parse($parcel['createTime']) : null,
raw: $parcel,
);
}
/**
* pagination.next is documented only by name — accept either the bare
* token or a URL carrying it as ?pageToken=.
*/
private function nextPageToken(mixed $next): ?string
{
if (blank($next) || ! is_string($next)) {
return null;
}
if (str_contains($next, 'pageToken=')) {
parse_str((string) parse_url($next, PHP_URL_QUERY), $query);
return $query['pageToken'] ?? null;
}
return $next;
}
private function mapState(string $state): TrackingStatus
{
// TODO: confirm against a live BoxNow webhook payload whether a
@@ -201,7 +308,7 @@ class BoxNowFulfillmentService implements CarrierFulfillmentInterface, SupportsT
'in-final-destination', 'wait-for-load' => TrackingStatus::OutForDelivery,
'delivered' => TrackingStatus::Delivered,
'returned', 'accepted-for-return' => TrackingStatus::Returned,
'cancelled' => TrackingStatus::Cancelled,
'cancelled', 'canceled' => TrackingStatus::Cancelled,
'expired-return', 'missing' => TrackingStatus::Failed,
default => TrackingStatus::Unknown,
};
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Shipping\Carriers\Elta;
use Illuminate\Support\Facades\Cache;
/**
* Resolves a Greek postcode to its ELTA station code/name via PELTKNEW
* (the *NEW-family replacement for the old, differently-authenticated
* PELSTATION — see EltaClient's docblock). Unlike Acs\AreaResolver,
* there's no daily bulk-warm job — PELTKNEW only accepts a single pel_tk
* (postcode), with no fetch-all variant to warm a whole-country cache
* from, so this caches per-postcode on first lookup instead.
*/
class AreaResolver
{
public function __construct(private readonly EltaClient $client) {}
public function resolve(string $postcode): EltaStation
{
return Cache::remember(
"elta.station.{$postcode}",
now()->addDays(30),
function () use ($postcode) {
$response = $this->client->getStation([
'pel_tk' => $postcode,
'sender_station' => '',
])->throwIfError();
$fields = $response->data['pel_fields'] ?? [];
return new EltaStation(
code: $fields['rec_station'] ?? '',
name: $fields['rec_station_t'] ?? '',
);
},
);
}
}
+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,498 @@
<?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();
// PELVGNEW doesn't return the new voucher's id, so find it in the
// pending list by our reference. Saved even if that lookup fails —
// the voucher exists at ELTA either way, and printing retries it.
return Shipment::create([
'order_id' => $order->id,
'carrier' => 'elta',
'source' => Shipment::SOURCE_CREATED,
'tracking_reference' => null,
'meta' => [
'carrier_id' => $this->findPendingVoucherId((string) $order->reference),
'weight' => $weight,
'package_count' => $request->packageCount,
'cod_amount' => $codAmount,
'services' => $request->serviceValues(),
'insurance_amount' => $request->has(ExtraService::Insurance) ? $request->insuranceAmount : null,
],
]);
}
public function printLabel(Shipment $shipment): string
{
$this->issue($shipment);
$pdf = $this->labelRenderer->render($shipment->refresh());
$shipment->update(['label_printed_at' => now()]);
return $pdf;
}
public function printLabels(Collection $shipments): string
{
$shipments->each(fn (Shipment $shipment) => $this->issue($shipment));
$pdf = $this->labelRenderer->renderMany($shipments->map->refresh());
Shipment::whereIn('id', $shipments->pluck('id'))->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
{
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()]);
}
public function trackShipment(Shipment $shipment): Collection
{
$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();
foreach (array_filter(array_map('trim', (array) ($data['vg_child_no'] ?? []))) as $childVoucherNo) {
Shipment::create([
'order_id' => $shipment->order_id,
'carrier' => 'elta',
'source' => Shipment::SOURCE_CREATED,
'tracking_reference' => $childVoucherNo,
'parent_reference' => $voucherNo,
'label_printed_at' => now(),
'meta' => $shipment->meta->toArray(),
]);
}
}
/**
* @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,310 @@
<?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');
$view = $paperSize === 'a6'
? 'core::shipping.carriers.elta.label-a6'
: 'core::shipping.carriers.elta.label-a4';
$head = null;
$pages = [];
foreach ($shipments as $shipment) {
$html = view($view, $this->data($shipment))->render();
$head ??= Str::before($html, '<body');
$pages[] = Str::beforeLast(Str::after(Str::after($html, '<body'), '>'), '</body>');
}
$html = $head
.'<style>.page { page-break-after: always; } .page:last-child { page-break-after: auto; }</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;
$meta = $shipment->meta?->toArray() ?? [];
// 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));
$services = (array) ($meta['services'] ?? []);
$surcharges = $this->surchargeSlots($services);
$cod = $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();
$voucherNo = $shipment->tracking_reference;
$ocrLine = (string) ($meta['ocr_line'] ?? '');
$packageCount = (int) ($meta['package_count'] ?? 1);
$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,
'package_label' => $packageCount > 1
? sprintf('001/%03d', $packageCount)
: '001',
'multiPiece' => $packageCount > 1,
'polaplo' => $packageCount > 1 ? '* ΠΟΛΛΑΠΛΗ ΑΠΟΣΤΟΛΗ *' : 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'] ?? '',
'xreosi' => 'ΧΡΕΩΣΗ ΑΠΟΣΤΟΛΕΑ ΠΙΣΤΩΣΗ',
'siimvasi' => '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) : '',
// The reference we send ELTA as pel_ref_no (order reference;
// older vouchers were sent the order id).
'order_reference' => (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,
];
}
/**
* 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;
use Lunar\Shipping\Models\ShippingMethod;
/**
* A shipping rate driver implements this to say a person is physically
* present at handoff to collect cash — true for a courier like
@@ -14,6 +16,10 @@ namespace Modules\Core\Shipping\Contracts;
* Contracts\DeclaresFulfillmentType, which only distinguishes carrier vs.
* store_pickup and can't tell ACS and Box Now apart (both 'carrier').
*
* Receives the shipping method, since for some drivers it's a per-method
* setting — a manual carrier's "Courier collects cash on delivery" toggle
* (Modules\Core\Shipping\Carriers\Manual\ManualRateDriver).
*
* Not implemented at all means "no" — a driver with no opinion here
* (any of table-rate-shipping's own generic drivers, if one were ever
* re-added) is treated as not supporting cash collection, the safer
@@ -21,5 +27,5 @@ namespace Modules\Core\Shipping\Contracts;
*/
interface SupportsCashCollection
{
public function collectsCash(): bool;
public function collectsCash(ShippingMethod $method): bool;
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Modules\Core\Shipping\Enums\ExtraService;
/**
* A carrier that offers extra services (Saturday delivery, insurance, …).
* Only the carrier knows its own codes — the rest of the app deals in the
* normalized ExtraService cases.
*/
interface SupportsExtraServices
{
/**
* The services this carrier offers, keyed by ExtraService value, each
* mapped to the carrier's own code.
*
* @return array<string, string>
*/
public function extraServices(): array;
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Carbon\CarbonInterface;
use Modules\Core\Shipping\DTOs\CarrierVoucher;
/**
* A carrier that can list the vouchers on our account for a date range —
* feeds the Carrier Vouchers screen (returns, and vouchers not linked to
* any order yet).
*/
interface SupportsVoucherListing
{
/**
* @return iterable<CarrierVoucher>
*/
public function listVouchers(CarbonInterface $from, CarbonInterface $to): iterable;
}
@@ -0,0 +1,14 @@
<?php
namespace Modules\Core\Shipping\Contracts;
use Modules\Core\Shipping\DTOs\CarrierVoucher;
/**
* A carrier that can look up a single voucher by number — so a voucher can
* be found and linked to an order before the next list sync.
*/
interface SupportsVoucherLookup
{
public function lookupVoucher(string $voucherNumber): ?CarrierVoucher;
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Modules\Core\Shipping\DTOs;
use Carbon\CarbonInterface;
use Modules\Core\Shipping\Enums\TrackingStatus;
/**
* One voucher as a carrier's own list or lookup reports it — including ones
* we didn't create (made in the carrier's portal, typed in by a courier)
* and return vouchers. Stored as a `synced` Shipment by
* Modules\Core\Shipping\Services\CarrierVoucherSync.
*/
class CarrierVoucher
{
public function __construct(
public readonly string $carrier,
public readonly string $voucherNumber,
public readonly ?string $reference = null,
public readonly ?string $recipientName = null,
public readonly ?string $postcode = null,
public readonly ?string $phone = null,
// The carrier's own words; $status is the same mapped to ours.
public readonly ?string $statusText = null,
public readonly ?TrackingStatus $status = null,
public readonly ?float $codAmount = null,
public readonly bool $isReturn = false,
public readonly ?string $originalVoucher = null,
public readonly ?CarbonInterface $date = null,
public readonly array $raw = [],
) {}
}
+24
View File
@@ -2,6 +2,8 @@
namespace Modules\Core\Shipping\DTOs;
use Modules\Core\Shipping\Enums\ExtraService;
/**
* Carrier-agnostic input for CarrierFulfillmentInterface::createShipment().
* Every field is optional — a carrier reads only what it needs and ignores
@@ -19,6 +21,11 @@ class ShipmentRequest
* an order that doesn't fit one compartment. Empty for every other
* carrier, which ships as a single package described by $weight
* instead.
* @param array<int, ExtraService> $services staff-picked extra
* services; a carrier only receives ones it declares via
* SupportsExtraServices.
* @param ?float $insuranceAmount when ExtraService::Insurance is picked.
* @param ?string $deliveryUntil "HH:MM", when ExtraService::TimedDelivery is picked.
*/
public function __construct(
public readonly ?float $weight = null,
@@ -27,5 +34,22 @@ class ShipmentRequest
public readonly ?string $paymentMode = null,
public readonly ?float $amountToCollect = null,
public readonly array $boxes = [],
public readonly array $services = [],
public readonly ?float $insuranceAmount = null,
public readonly ?string $deliveryUntil = null,
public readonly ?string $trackingReference = null,
) {}
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,22 @@
namespace Modules\Core\Shipping\Extensions;
use Filament\Actions\Action;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Notifications\Notification;
use Filament\Infolists\Components\TextEntry;
use Illuminate\Support\Collection;
use Modules\Core\Shipping\Models\ShipmentInfo;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section;
use Illuminate\Support\Facades\URL;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
use Modules\Core\Shipping\Models\Shipment;
use Throwable;
/**
* Adds a "Shipments" section to the order page's main column — previously
@@ -85,8 +90,9 @@ class OrderShipmentsExtension extends ViewPageExtension
->contained(true)
->schema([
TextEntry::make('tracking_reference')
->label(fn (Shipment $record) => $this->carrierLabel($record))
->label(fn (Shipment $record) => $record->carrierLabel())
->inlineLabel()
->placeholder('Voucher not issued yet')
->copyable(),
TextEntry::make('status')
->label('Status')
@@ -104,14 +110,57 @@ class OrderShipmentsExtension extends ViewPageExtension
now()->addMinutes(5),
['shipment' => $record->id],
), shouldOpenInNewTab: true)
->visible(fn (Shipment $record) => ! $record->cancelled_at),
->visible(fn (Shipment $record) => $record->hasCarrierLabel()),
Action::make('open_carrier_tracking')
->label('Carrier tracking')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Shipment $record) => $record->trackingUrl(), shouldOpenInNewTab: true)
->visible(fn (Shipment $record) => filled($record->trackingUrl())),
Action::make('edit_tracking_reference')
->label('Edit number')
->icon('heroicon-o-pencil-square')
->fillForm(fn (Shipment $record) => ['tracking_reference' => $record->tracking_reference])
->schema(fn (Shipment $record) => [
TextInput::make('tracking_reference')
->label('Voucher / tracking number')
->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')
->label('Cancel')
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->modalDescription('Cancels this shipment with the carrier. This cannot be undone.')
->action(fn (Shipment $record) => $this->cancel($record))
->modalDescription(fn (Shipment $record) => $record->cancelsLocallyOnly()
? 'Marks this shipment as cancelled here only. Void the voucher with the courier as well.'
: 'Cancels this shipment with the carrier. This cannot be undone.')
->action(fn (Shipment $record) => ShipmentResource::cancelShipment($record))
->visible(fn (Shipment $record) => ! $record->cancelled_at),
]),
RepeatableEntry::make('shipmentInfo')
@@ -123,7 +172,11 @@ class OrderShipmentsExtension extends ViewPageExtension
->label(fn (ShipmentInfo $record) => $record->occurred_at->format('Y-m-d H:i'))
->inlineLabel()
->state(fn (ShipmentInfo $record) => (string) str($record->status->value)->replace('_', ' ')->title())
->helperText(fn (ShipmentInfo $record) => $record->location),
// Our status as the label, the carrier's own wording underneath.
->helperText(fn (ShipmentInfo $record) => collect([
$record->carrier_status !== 'manual' ? $record->carrier_status : null,
$record->location,
])->filter()->unique()->implode(' · ') ?: null),
]),
]),
]);
@@ -142,13 +195,29 @@ class OrderShipmentsExtension extends ViewPageExtension
return $record->shipmentInfo->sortBy('occurred_at')->values();
}
private function carrierLabel(Shipment $record): string
/**
* A manual carrier's checkpoint, entered by staff. Dispatches the same
* event as carrier polling, so the order's status and emails follow it
* exactly like an integrated carrier's.
*/
private function addTrackingUpdate(Shipment $record, array $data): void
{
return match ($record->carrier) {
'acs' => 'ACS',
'box-now' => 'Box Now',
default => (string) str($record->carrier)->title(),
};
$info = ShipmentInfo::create([
'shipment_id' => $record->id,
'status' => TrackingStatus::from($data['status']),
'carrier_status' => 'manual',
'message' => $data['message'] ?? null,
'location' => $data['location'] ?? null,
'occurred_at' => $data['occurred_at'],
]);
ShipmentStatusUpdatedByCarrier::dispatch($info);
Notification::make()
->title('Tracking update added.')
->success()
->send();
}
private function helperText(Shipment $record): string
@@ -159,6 +228,22 @@ class OrderShipmentsExtension extends ViewPageExtension
$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);
}
@@ -187,26 +272,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();
}
}
+217 -11
View File
@@ -3,17 +3,30 @@
namespace Modules\Core\Shipping\Extensions;
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\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\TimePicker;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Lunar\Admin\Support\Extending\ViewPageExtension;
use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Order\DTOs\OrderFulfillmentResult;
use Modules\Core\Order\Services\OrderFulfillmentService;
use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsExtraServices;
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
use Modules\Core\Shipping\DTOs\ShipmentRequest;
use Modules\Core\Shipping\Enums\ExtraService;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Support\WeightCalculator;
use Throwable;
/**
* Filament wiring only (labels, icons, visibility, form schema) for the
@@ -35,11 +48,12 @@ use Modules\Core\Shipping\Support\WeightCalculator;
*
* "Create Shipment" is its own separate header action, visible only for a
* carrier order sitting at 'ready_for_dispatch' — this is the one action
* that talks to a real carrier API and writes Order::status to
* 'dispatched' as a side effect of that succeeding, so it needs its own
* weight/locker inputs specific to that one real-world action, not
* bundled into the general-purpose status select where they'd appear for
* every revert/manual-override use of 'dispatched' too. The form branches
* that talks to a real carrier API, so it needs its own weight/locker
* inputs specific to that one real-world action. It does NOT move the
* order to 'dispatched': that happens when the carrier reports picking the
* parcel up (AdvanceFulfillmentOnCarrierCheckpoint). Staff also pick the
* carrier's extra services here (Saturday delivery, insurance, …) from the
* normalized ExtraService list the carrier offers. The form branches
* on which carrier the order actually uses
* (OrderFulfillmentService::carrierFor()): a weight-billed carrier (ACS)
* gets a single TOTAL weight field for the whole shipment (ACS has no
@@ -62,6 +76,13 @@ use Modules\Core\Shipping\Support\WeightCalculator;
* customer's choice turns out to be unavailable) or fill it in manually for
* an order placed before the checkout locker picker existed.
*
* "Add manual voucher" records a voucher of an integrated carrier that
* wasn't created through our API — the carrier's system was down and staff
* filled a pre-numbered paper voucher, or the courier wrote his own at
* pickup. Tracking picks its history up like any other shipment; the
* carrier's lookup (when it has one) previews what it reports for the
* number before saving.
*
* "Mark Paid" is a third, separate header action — Order::paid is
* independent of `status` (see OrderStatusFlow's own docblock), so it
* doesn't belong bundled into the status select either. Visible only when
@@ -86,6 +107,7 @@ class OrderViewExtension extends ViewPageExtension
: true);
$actions[] = $this->createShipmentAction();
$actions[] = $this->addManualVoucherAction();
$actions[] = $this->updateStatusAction();
$actions[] = $this->markPaidAction();
@@ -99,11 +121,28 @@ class OrderViewExtension extends ViewPageExtension
->icon('heroicon-o-truck')
->modalSubmitActionLabel('Create Shipment')
->schema(function (Order $record) {
$isBoxNow = $this->service()->carrierFor($record) === 'box-now';
$carrier = $this->service()->carrierFor($record);
$isBoxNow = $carrier === 'box-now';
$lockerId = $record->shippingAddress?->meta['box_now_locker']['locationId'] ?? null;
if (! $isBoxNow) {
if ($carrier === 'manual') {
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')
->label('Total weight (kg)')
->numeric()
@@ -118,10 +157,10 @@ class OrderViewExtension extends ViewPageExtension
->default(1)
->required()
->helperText('More than 1 issues a main voucher plus a sub-voucher per extra package, all sharing the total weight above.'),
];
], ...$this->extraServiceFields($record)];
}
return [
return [...[
TextInput::make('destination_location_id')
->label('Box Now locker ID')
->default($lockerId)
@@ -143,7 +182,7 @@ class OrderViewExtension extends ViewPageExtension
->addActionLabel('Add another box')
->minItems(1)
->helperText('One row per physical parcel — Box Now ships by compartment size, not weight.'),
];
], ...$this->extraServiceFields($record)];
})
->action(function (Order $record, array $data, Action $action) {
// Derived from the order itself, never from staff input —
@@ -161,7 +200,9 @@ class OrderViewExtension extends ViewPageExtension
// full payment was already settled, and never collect it.
$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,
new ShipmentRequest(
weight: filled($data['weight'] ?? null) ? (float) $data['weight'] : null,
@@ -170,6 +211,14 @@ class OrderViewExtension extends ViewPageExtension
paymentMode: $isCod ? 'cod' : 'prepaid',
amountToCollect: $isCod ? $record->total->decimal : null,
boxes: collect($data['boxes'] ?? [])->pluck('size')->all(),
services: $services,
insuranceAmount: in_array(ExtraService::Insurance, $services, true) && filled($data['insurance_amount'] ?? null)
? (float) $data['insurance_amount']
: null,
deliveryUntil: in_array(ExtraService::TimedDelivery, $services, true)
? ($data['delivery_until'] ?? null)
: null,
trackingReference: $data['tracking_reference'] ?? null,
),
);
@@ -182,6 +231,163 @@ class OrderViewExtension extends ViewPageExtension
->visible(fn (Order $record) => $this->service()->canCreateShipment($record));
}
/**
* The extra services the order's carrier offers, plus the details some
* of them need. Nothing is shown for carriers that offer none.
*/
private function extraServiceFields(Order $record): array
{
$options = $this->extraServiceOptions($this->service()->carrierFor($record));
if ($options === []) {
return [];
}
$ticked = fn (Get $get, ExtraService $service) => in_array($service->value, $get('services') ?? [], true);
return [
CheckboxList::make('services')
->label('Extra services')
->options($options)
->columns(2)
->live(),
TextInput::make('insurance_amount')
->label('Insured value')
->numeric()
->minValue(0)
->prefix('€')
->default($record->total->decimal)
->visible(fn (Get $get) => $ticked($get, ExtraService::Insurance))
->required(fn (Get $get) => $ticked($get, ExtraService::Insurance)),
TimePicker::make('delivery_until')
->label('Deliver by')
->seconds(false)
->visible(fn (Get $get) => $ticked($get, ExtraService::TimedDelivery))
->required(fn (Get $get) => $ticked($get, ExtraService::TimedDelivery)),
];
}
/**
* @return array<string, string> ExtraService value => label
*/
private function extraServiceOptions(?string $carrier): array
{
$service = $carrier ? app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]) : null;
if (! $service instanceof SupportsExtraServices) {
return [];
}
return collect(array_keys($service->extraServices()))
->mapWithKeys(fn (string $value) => [$value => ExtraService::from($value)->label()])
->all();
}
private function addManualVoucherAction(): Action
{
return Action::make('add_manual_voucher')
->label('Add manual voucher')
->icon('heroicon-o-pencil-square')
->modalDescription("For a voucher that exists at the carrier but wasn't created here — e.g. the carrier's system was down, or the courier wrote his own voucher at pickup. Its tracking history is picked up from the carrier.")
->modalSubmitActionLabel('Add voucher')
->schema(fn (Order $record) => [
Select::make('carrier')
->label('Carrier')
->options($this->integratedCarriers())
->default($this->service()->carrierFor($record))
->native(false)
->required()
->live()
->afterStateUpdated(fn (Set $set) => $set('lookup', null)),
TextInput::make('voucher_number')
->label('Voucher number')
->required()
->unique(Shipment::class, 'tracking_reference')
->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);
if (! $result->success) {
$action->halt();
}
})
->visible(fn (Order $record) => $this->service()->canAddManualVoucher($record));
}
/**
* Carriers with an API integration — manual carriers have no voucher
* of their own to look up or track.
*
* @return array<string, string>
*/
private function integratedCarriers(): array
{
return collect(Shipping::getSupportedDrivers())
->filter(fn ($driver, string $carrier) => $carrier !== 'manual'
&& app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]) !== null)
->map(fn ($driver) => $driver->name())
->all();
}
private function describeLookup(?string $carrier, ?string $number): string
{
if (blank($carrier) || blank($number)) {
return 'Pick the carrier and enter the voucher number first.';
}
$service = app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
if (! $service instanceof SupportsVoucherLookup) {
return "This carrier doesn't support looking vouchers up — you can still add it.";
}
try {
$voucher = $service->lookupVoucher(trim($number));
} catch (Throwable $e) {
report($e);
return "Couldn't reach the carrier ({$e->getMessage()}) — you can still add it; its history is picked up once the carrier responds.";
}
if (! $voucher) {
return 'The carrier has no record of this voucher yet — you can still add it.';
}
return collect([
$voucher->recipientName,
$voucher->postcode,
$voucher->reference ? 'Ref. '.$voucher->reference : null,
$voucher->statusText,
$voucher->codAmount ? 'COD €'.number_format($voucher->codAmount, 2) : null,
$voucher->isReturn ? 'Return voucher' : null,
])->filter()->implode(' · ');
}
private function updateStatusAction(): Action
{
return Action::make('update_status')
@@ -8,6 +8,8 @@ use Filament\Schemas\Components\Concerns\HasChildComponents;
use Filament\Schemas\Components\Utilities\Get;
use InvalidArgumentException;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\ResourceExtension;
@@ -110,15 +112,49 @@ class ShippingMethodResourceExtension extends ResourceExtension
}
if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) {
$component->schema(
$this->replaceChargeByField($component->getDefaultChildComponents())
$children = $this->replaceChargeByField($component->getDefaultChildComponents());
// The manual carrier's settings sit next to charge_by, in
// the same `data` group.
$hasChargeBy = collect($children)->contains(
fn (Component $child) => method_exists($child, 'getName') && $child->getName() === 'charge_by'
);
$component->schema($hasChargeBy ? [...$children, ...$this->manualCarrierFields()] : $children);
}
return $component;
}, $components);
}
/**
* Settings for a carrier without an integration (ManualRateDriver),
* stored in the method's `data`. Shown only for that driver.
*
* @return array<Component>
*/
private function manualCarrierFields(): array
{
$isManual = fn (Get $get) => $get('../driver') === 'manual';
return [
TextInput::make('carrier_name')
->label('Carrier name')
->helperText('Shown to staff and customers on this carrier\'s shipments, e.g. "Geniki Taxydromiki".')
->visible($isManual)
->required($isManual),
TextInput::make('tracking_url')
->label('Tracking URL')
->helperText('Optional. The carrier\'s tracking page, with {number} where the voucher number goes, e.g. https://example.com/track?number={number}.')
->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
{
return Select::make('charge_by')
@@ -0,0 +1,351 @@
<?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\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',
];
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')
->copyable(),
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')->copyable(),
TextEntry::make('source')
->badge()
->formatStateUsing(fn (string $state) => self::SOURCES[$state] ?? $state),
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('meta.reference')->label('Reference sent to the carrier')->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->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();
}
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(),
];
}
}
@@ -12,9 +12,8 @@ use Modules\Core\Shipping\Models\Shipment;
/**
* The shipments a given Manifest actually included — read-only (a
* shipment's manifest membership is set once, at issueManifest() time,
* never edited here). Reuses ShipmentResource::printShipment() for the
* "Print" action rather than duplicating its try/catch-and-notify
* handling.
* never edited here). "Print" opens the same signed label URL as the
* order page (ShipmentResource::labelUrl()).
*/
class ShipmentsRelationManager extends RelationManager
{
@@ -33,7 +32,7 @@ class ShipmentsRelationManager extends RelationManager
Action::make('print')
->label('Print')
->icon('heroicon-o-printer')
->action(fn (Shipment $record) => ShipmentResource::printShipment($record)),
->url(fn (Shipment $record) => ShipmentResource::labelUrl($record), shouldOpenInNewTab: true),
]);
}
}
@@ -10,7 +10,9 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\URL;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsBatchLabels;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource\Pages\ListShipments;
use Modules\Core\Shipping\Models\Shipment;
@@ -21,12 +23,17 @@ use Throwable;
* ManagePickupManifests page — a bare Page has no access to Filament's
* resource-level pill-tab UI (Filament\Resources\Concerns\HasTabs is
* scoped to ListRecords), so carrier-by-carrier separation
* (ListShipments::getTabs(), one tab per SupportsManifestBatching
* implementer) needed a real Resource to attach to.
* (ListShipments::getTabs()) needed a real Resource to attach to.
*
* Shows only shipments NOT yet on an issued manifest — see
* Modules\Core\Shipping\Filament\Resources\ManifestResource for
* shipments that already are.
* Shows only shipments created through a carrier's API that are NOT yet on
* an issued manifest — see Modules\Core\Shipping\Filament\Resources\
* ManifestResource for shipments that already are. Manual carriers, manual
* 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
{
@@ -45,6 +52,8 @@ class ShipmentResource extends Resource
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->where('source', Shipment::SOURCE_CREATED)
->whereNotNull('order_id')
->whereNull('manifest_id')
->whereNull('cancelled_at');
}
@@ -52,53 +61,132 @@ class ShipmentResource extends Resource
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
TextColumn::make('carrier')->badge(),
TextColumn::make('tracking_reference')->label('Tracking #'),
TextColumn::make('carrier')
->badge()
->formatStateUsing(fn (Shipment $record) => $record->carrierLabel()),
TextColumn::make('tracking_reference')
->label('Tracking #')
->placeholder('Not issued yet')
->copyable(),
TextColumn::make('order.reference')->label('Order'),
TextColumn::make('created_at')->label('Created')->dateTime(),
TextColumn::make('label_printed_at')->label('Printed')->dateTime()->placeholder('Not printed'),
])
->recordActions([
Action::make('print')
->label('Print')
->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([
BulkAction::make('print_selected')
->label('Print selected')
->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')
->label('Issue Manifest')
->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)),
]);
}
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);
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()
->title("No fulfillment integration configured for {$shipment->carrier}.")
->title('Failed to cancel shipment: '.$e->getMessage())
->danger()
->send();
return;
}
try {
$service->printLabel($shipment);
} catch (Throwable $e) {
report($e);
Notification::make()
->title("Failed to print label for {$shipment->tracking_reference}: {$e->getMessage()}")
->danger()
->send();
}
Notification::make()
->title('Shipment cancelled.')
->success()
->send();
}
public static function issueManifest(Collection $shipments): void
@@ -6,17 +6,17 @@ 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\IssuesVoucherOnPrint;
use Modules\Core\Shipping\Contracts\SupportsManifestBatching;
use Modules\Core\Shipping\Filament\Resources\ShipmentResource;
/**
* One tab per carrier that actually implements SupportsManifestBatching
* (ACS today) — a carrier with no manifest concept at all (Box Now,
* which books courier pickup at shipment-creation time, no separate
* batching step) never gets a tab here, since there is nothing to batch.
* Adding a new carrier (e.g. Speedex) that also implements the contract
* needs zero changes to this page — the tab list is derived from
* Shipping::getSupportedDrivers(), not hardcoded.
* One tab per carrier with a step between creating a shipment and handing
* it over: batching into a manifest (SupportsManifestBatching — ACS) or
* issuing the voucher when it's printed (IssuesVoucherOnPrint — ELTA). A
* carrier with neither (Box Now books the pickup at creation) never gets a
* tab. The tab list is derived from Shipping::getSupportedDrivers(), so a
* new carrier implementing either contract needs no change here.
*/
class ListShipments extends ListRecords
{
@@ -24,13 +24,14 @@ class ListShipments extends ListRecords
public function getTabs(): array
{
$carriers = collect(Shipping::getSupportedDrivers())
->keys()
->filter(fn (string $carrier) => ShipmentResource::fulfillmentService($carrier) instanceof SupportsManifestBatching);
return collect(Shipping::getSupportedDrivers())
->filter(function ($driver, string $carrier) {
$service = ShipmentResource::fulfillmentService($carrier);
return $carriers->mapWithKeys(fn (string $carrier) => [
$carrier => Tab::make(ucwords(str_replace('-', ' ', $carrier)))
->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)),
])->all();
return $service instanceof SupportsManifestBatching || $service instanceof IssuesVoucherOnPrint;
})
->map(fn ($driver, string $carrier) => Tab::make($driver->name())
->modifyQueryUsing(fn (Builder $query) => $query->where('carrier', $carrier)))
->all();
}
}
@@ -45,11 +45,21 @@ class DownloadShipmentLabelController extends Controller
$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);
// 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, [
'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\SupportsTracking;
use Modules\Core\Shipping\Enums\TrackingStatus;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Models\ShipmentInfo;
use Modules\Core\Shipping\Services\ShipmentTrackingRecorder;
use Throwable;
/**
@@ -52,6 +51,8 @@ class PollShipmentTrackingJob implements ShouldQueue
Shipment::query()
->whereIn('carrier', $trackableCarriers)
->whereNull('cancelled_at')
// Pending vouchers (issued on print) have no number to track yet.
->whereNotNull('tracking_reference')
->whereDoesntHave('shipmentInfo', function ($query) {
$query->whereIn('status', [
TrackingStatus::Delivered->value,
@@ -68,49 +69,19 @@ class PollShipmentTrackingJob implements ShouldQueue
private function pollCarrierShipments(string $carrier, $shipments): void
{
$service = $this->fulfillmentService($carrier);
if (! $service instanceof SupportsTracking) {
if (! $this->fulfillmentService($carrier) instanceof SupportsTracking) {
return;
}
foreach ($shipments as $shipment) {
try {
$this->recordNewCheckpoints($shipment, $service->trackShipment($shipment));
app(ShipmentTrackingRecorder::class)->refresh($shipment);
} catch (Throwable $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
{
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);
}
}
+86
View File
@@ -7,9 +7,22 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Lunar\Models\Order;
use Lunar\Shipping\Facades\Shipping;
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 $casts = [
@@ -37,4 +50,77 @@ class Shipment extends Model
{
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);
}
}
@@ -0,0 +1,224 @@
<?php
namespace Modules\Core\Shipping\Services;
use Carbon\CarbonInterface;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsVoucherListing;
use Modules\Core\Shipping\Contracts\SupportsVoucherLookup;
use Modules\Core\Shipping\DTOs\CarrierVoucher;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Support\VoucherOrderMatcher;
use Throwable;
/**
* Brings the vouchers each carrier reports into `shipments` (source
* `synced`), for the Carrier Vouchers screen: returns, and vouchers that
* never made it onto an order.
*
* A voucher we already have (any source) is never duplicated: its history
* is brought up to date from the carrier's tracking, it's flagged when the
* carrier lists it as a return (`meta.reported_return`), and one that came
* from the carrier also takes the carrier's latest details. The order link
* and our own shipments' details are never changed.
*
* Nothing is ever linked to an order here: vouchers get suggested orders
* (VoucherOrderMatcher, the reference match first), and staff link them.
* An unlinked voucher's suggestions are refreshed on every sync.
*/
class CarrierVoucherSync
{
/** Checkpoints added for the voucher being recorded (sync stats). */
private int $checkpointsAdded = 0;
public function __construct(
private readonly VoucherOrderMatcher $matcher,
private readonly ShipmentTrackingRecorder $tracking,
) {}
/**
* One carrier failing (API down, not configured) doesn't stop the
* others; its entry carries the error instead.
*
* @return array<string, array{seen: int, created: int, suggested: int, updated: int, error?: string}>
*/
public function syncAll(CarbonInterface $from, CarbonInterface $to): array
{
return collect(Shipping::getSupportedDrivers())->keys()
->filter(fn (string $carrier) => $this->service($carrier) instanceof SupportsVoucherListing)
->mapWithKeys(function (string $carrier) use ($from, $to) {
try {
return [$carrier => $this->sync($carrier, $from, $to)];
} catch (Throwable $e) {
report($e);
return [$carrier => ['seen' => 0, 'created' => 0, 'suggested' => 0, 'updated' => 0, 'error' => $e->getMessage()]];
}
})
->all();
}
/**
* @return array{seen: int, created: int, suggested: int, updated: int}
*/
public function sync(string $carrier, CarbonInterface $from, CarbonInterface $to): array
{
$service = $this->service($carrier);
$stats = ['seen' => 0, 'created' => 0, 'suggested' => 0, 'updated' => 0];
if (! $service instanceof SupportsVoucherListing) {
return $stats;
}
foreach ($service->listVouchers($from, $to) as $voucher) {
$stats['seen']++;
$this->checkpointsAdded = 0;
$shipment = $this->record($voucher);
if ($shipment?->wasRecentlyCreated) {
$stats['created']++;
$stats['suggested'] += filled($shipment->meta['suggested_order_ids'] ?? []) ? 1 : 0;
} elseif ($this->checkpointsAdded > 0) {
$stats['updated']++;
}
}
return $stats;
}
/**
* A voucher that hasn't synced yet, looked up by number. Returns the
* shipment it's recorded as (existing or new), or null when the carrier
* doesn't know it.
*/
public function lookup(string $carrier, string $voucherNumber): ?Shipment
{
$service = $this->service($carrier);
if (! $service instanceof SupportsVoucherLookup) {
return null;
}
$voucher = $service->lookupVoucher($voucherNumber);
return $voucher ? $this->record($voucher) : null;
}
private function record(CarrierVoucher $voucher): ?Shipment
{
if (blank($voucher->voucherNumber)) {
return null;
}
$existing = Shipment::where('tracking_reference', $voucher->voucherNumber)->first();
if ($existing) {
$this->refreshExisting($existing, $voucher);
return $existing;
}
$shipment = Shipment::create([
'carrier' => $voucher->carrier,
'source' => Shipment::SOURCE_SYNCED,
'tracking_reference' => $voucher->voucherNumber,
'meta' => [
'reference' => $voucher->reference,
'recipient_name' => $voucher->recipientName,
'postcode' => $voucher->postcode,
'phone' => $voucher->phone,
'cod_amount' => $voucher->codAmount,
'is_return' => $voucher->isReturn,
'original_voucher' => $voucher->originalVoucher,
'voucher_date' => $voucher->date?->toDateString(),
'suggested_order_ids' => $this->matcher->suggestions($voucher),
],
]);
$this->recordHistory($shipment, $voucher);
return $shipment;
}
/**
* A voucher we already have, seen again: its history is brought up to
* date (any source, unless cancelled here), and a voucher that came
* from the carrier also takes the carrier's latest details. The order
* link and our own shipments' details are never changed.
*/
private function refreshExisting(Shipment $shipment, CarrierVoucher $voucher): void
{
$meta = $shipment->meta?->toArray() ?? [];
if ($voucher->isReturn) {
$meta['reported_return'] = true;
}
if ($shipment->source === Shipment::SOURCE_SYNCED) {
// Only what the carrier reported this time — ACS's list, for
// one, has no recipient, which mustn't wipe what we have.
$meta = [...$meta, ...array_filter([
'recipient_name' => $voucher->recipientName,
'postcode' => $voucher->postcode,
'phone' => $voucher->phone,
'cod_amount' => $voucher->codAmount,
'original_voucher' => $voucher->originalVoucher,
], fn ($value) => $value !== null)];
$meta['is_return'] = ($meta['is_return'] ?? false) || $voucher->isReturn;
if ($shipment->order_id === null) {
$meta['suggested_order_ids'] = $this->matcher->suggestions($voucher);
}
}
if ($meta !== ($shipment->meta?->toArray() ?? [])) {
$shipment->update(['meta' => $meta]);
}
if (! $shipment->isCancelled()) {
$this->recordHistory($shipment, $voucher);
}
}
/**
* What the carrier reports becomes the voucher's history, like any
* shipment's: new checkpoints from its tracking when the carrier has
* it; otherwise the status from the voucher list, when it differs from
* the latest one we have.
*/
private function recordHistory(Shipment $shipment, CarrierVoucher $voucher): void
{
try {
$this->checkpointsAdded += $this->tracking->refresh($shipment);
} catch (Throwable $e) {
report($e);
}
if ($this->checkpointsAdded > 0 || ! $voucher->status) {
return;
}
$latest = $shipment->shipmentInfo()->latest('occurred_at')->first();
if ($latest?->status === $voucher->status) {
return;
}
$this->checkpointsAdded += $this->tracking->record($shipment, [new TrackingCheckpoint(
status: $voucher->status,
carrierStatus: $voucher->statusText,
message: $voucher->statusText,
location: null,
occurredAt: $latest ? now() : ($voucher->date ?? now()),
)]);
}
private function service(string $carrier): ?CarrierFulfillmentInterface
{
return app(CarrierFulfillmentInterface::class, ['carrier' => $carrier]);
}
}
@@ -0,0 +1,80 @@
<?php
namespace Modules\Core\Shipping\Services;
use Modules\Core\Shipping\Contracts\CarrierFulfillmentInterface;
use Modules\Core\Shipping\Contracts\SupportsTracking;
use Modules\Core\Shipping\DTOs\TrackingCheckpoint;
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Models\ShipmentInfo;
/**
* The one place carrier checkpoints become ShipmentInfo rows (the carrier
* info every screen reads): new checkpoints only — matched on status +
* time — each dispatching ShipmentStatusUpdatedByCarrier, so order status,
* emails and the order Timeline follow. Used by PollShipmentTrackingJob,
* the voucher sync and the "Refresh tracking" actions.
*/
class ShipmentTrackingRecorder
{
/**
* Fetches the shipment's history from its carrier and records what's
* new. Returns how many checkpoints were added (0 when the carrier has
* no tracking API).
*/
public function refresh(Shipment $shipment): int
{
$service = app(CarrierFulfillmentInterface::class, ['carrier' => $shipment->carrier]);
if (! $service instanceof SupportsTracking || blank($shipment->tracking_reference)) {
return 0;
}
return $this->record($shipment, $service->trackShipment($shipment));
}
/**
* @param iterable<TrackingCheckpoint> $checkpoints
*/
public function record(Shipment $shipment, iterable $checkpoints): int
{
$existing = $shipment->shipmentInfo()
->get(['status', 'occurred_at'])
->map(fn (ShipmentInfo $info) => $this->fingerprint($info->status->value, $info->occurred_at->toIso8601String()))
->flip();
$added = 0;
foreach ($checkpoints as $checkpoint) {
$fingerprint = $this->fingerprint($checkpoint->status->value, $checkpoint->occurredAt->toIso8601String());
if ($existing->has($fingerprint)) {
continue;
}
$existing->put($fingerprint, true);
$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);
$added++;
}
return $added;
}
private function fingerprint(string $status, string $occurredAt): string
{
return $status.'|'.$occurredAt;
}
}
@@ -0,0 +1,60 @@
<?php
namespace Modules\Core\Shipping\Support;
use Modules\Core\Shipping\Models\Shipment;
use Modules\Core\Shipping\Models\ShipmentInfo;
/**
* Writes a shipment's tracking checkpoints to its order's activity log, so
* they show on the order page's Timeline next to status changes and
* comments (rendered by ActivityLog\ShipmentCheckpointRender). One entry
* per checkpoint, logged as it's recorded; the checkpoint's own time is in
* the properties.
*/
class ShipmentTimelineLogger
{
public const EVENT = 'shipment-checkpoint';
public function log(ShipmentInfo $info): void
{
$shipment = $info->shipment;
$order = $shipment?->order;
if (! $order) {
return;
}
activity()
->useLog('lunarpanel')
->performedOn($order)
->event(self::EVENT)
->withProperties([
'shipment_info_id' => $info->id,
'shipment_id' => $shipment->id,
'carrier' => $shipment->carrierLabel(),
'tracking_reference' => $shipment->tracking_reference,
'is_return' => $shipment->isReturn(),
'status' => $info->status->value,
'carrier_status' => $info->carrier_status,
'message' => $info->message,
'location' => $info->location,
'occurred_at' => $info->occurred_at?->toIso8601String(),
])
->log(self::EVENT);
}
/**
* A voucher linked to an order after its history was recorded (Carrier
* Vouchers → Link to order): put that history on the order's Timeline,
* oldest first.
*/
public function backfill(Shipment $shipment): void
{
$shipment->loadMissing('shipmentInfo');
$shipment->shipmentInfo
->sortBy('occurred_at')
->each(fn (ShipmentInfo $info) => $this->log($info->setRelation('shipment', $shipment)));
}
}
@@ -0,0 +1,129 @@
<?php
namespace Modules\Core\Shipping\Support;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Lunar\Models\Order;
use Modules\Core\Shipping\DTOs\CarrierVoucher;
use Modules\Core\Shipping\Models\Shipment;
/**
* Suggests the orders a carrier-reported voucher may belong to. It never
* links anything — staff confirm a suggestion with "Link to order".
*
* Best first:
* - the order the reference we sent the carrier points to: the order
* reference (ELTA pel_ref_no, ACS Reference_Key1), Box Now's
* "{reference}-{id}[-{attempt}]" orderNumber, or the bare order id older
* ELTA vouchers were sent;
* - for a return voucher that names its original voucher, that shipment's
* order;
* - then orders with the same postcode and the same phone or recipient
* name, among recent orders with no active shipment with this carrier.
*/
class VoucherOrderMatcher
{
private const SUGGESTION_WINDOW_DAYS = 60;
private const MAX_SUGGESTIONS = 5;
/**
* @return array<int, int> order ids, best first
*/
public function suggestions(CarrierVoucher $voucher): array
{
return collect([$this->referenceMatch($voucher), ...$this->suggestedOrderIds($voucher)])
->filter()
->unique()
->values()
->all();
}
/**
* The order the voucher's own reference (or original voucher) points to.
*/
public function referenceMatch(CarrierVoucher $voucher): ?int
{
if ($voucher->originalVoucher
&& $orderId = Shipment::where('tracking_reference', $voucher->originalVoucher)->value('order_id')) {
return $orderId;
}
$reference = trim((string) $voucher->reference);
if ($reference === '') {
return null;
}
if ($id = Order::where('reference', $reference)->value('id')) {
return $id;
}
// Box Now: "{reference}-{id}", or "{reference}-{id}-{attempt}" for
// a shipment re-created after a cancel.
foreach (['/^(.+)-(\d+)-\d+$/', '/^(.+)-(\d+)$/'] as $pattern) {
if (preg_match($pattern, $reference, $m)
&& Order::whereKey((int) $m[2])->where('reference', $m[1])->exists()) {
return (int) $m[2];
}
}
if (ctype_digit($reference) && Order::whereKey((int) $reference)->exists()) {
return (int) $reference;
}
return null;
}
/**
* @return array<int, int> order ids, best first
*/
public function suggestedOrderIds(CarrierVoucher $voucher): array
{
if (blank($voucher->postcode) || (blank($voucher->recipientName) && blank($voucher->phone))) {
return [];
}
$since = ($voucher->date ?? now())->copy()->subDays(self::SUGGESTION_WINDOW_DAYS);
return Order::query()
->with('shippingAddress')
->where('placed_at', '>=', $since)
->whereHas('shippingAddress', fn ($q) => $q->where('postcode', trim($voucher->postcode)))
->whereDoesntHave('shipments', fn ($q) => $q->where('carrier', $voucher->carrier)->whereNull('cancelled_at'))
->latest('placed_at')
->limit(50)
->get()
->filter(fn (Order $order) => $this->samePhone($voucher->phone, $order->shippingAddress?->contact_phone)
|| $this->sameName($voucher->recipientName, trim($order->shippingAddress?->first_name.' '.$order->shippingAddress?->last_name)))
->take(self::MAX_SUGGESTIONS)
->pluck('id')
->values()
->all();
}
private function samePhone(?string $a, ?string $b): bool
{
$a = substr(preg_replace('/\D/', '', (string) $a), -10);
$b = substr(preg_replace('/\D/', '', (string) $b), -10);
return strlen($a) === 10 && $a === $b;
}
/**
* Carriers report names in capitals, often without accents or in the
* other script's word order, so compare the sets of name words.
*/
private function sameName(?string $a, ?string $b): bool
{
$words = fn (?string $name): Collection => collect(preg_split('/\s+/u', Str::upper(Str::ascii((string) $name))))
->filter()
->sort()
->values();
$a = $words($a);
return $a->isNotEmpty() && $a->all() === $words($b)->all();
}
}
+4
View File
@@ -2,6 +2,10 @@
use Illuminate\Support\Facades\Route;
use Modules\Core\Shipping\Http\Controllers\DownloadShipmentLabelController;
use Modules\Core\Shipping\Http\Controllers\DownloadShipmentLabelsController;
Route::get('shipments/labels', DownloadShipmentLabelsController::class)
->name('shipments.labels');
Route::get('shipments/{shipment}/label', DownloadShipmentLabelController::class)
->name('shipments.label');