Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Abubeker Yasin
2026-07-16 15:37:25 +03:00
169 changed files with 7139 additions and 962 deletions

View File

@@ -0,0 +1,457 @@
# EDR Freight — Full QA / Logic Audit Report
**Date:** 2026-07-15
**Scope:** `@edr/freight-api` + freight backoffice/portal web apps
**Excluded per request:** warehouses, first-mile, last-mile, onboarding (Fayda/verifayda)
**Environment:** live stack via `pnpm run dev:freight`, API on `http://localhost:3030`, DB `edr_freight` @ `10.18.7.207`
**Method:** booted the real app, logged in as `superadmin@tria.com`, drove the API with `curl`, reproduced state bugs against live data, read every service/controller/entity in the in-scope modules, and cross-checked the backoffice screens against the API.
---
## 1. How to read this report
Every finding has: **Problem** (what is wrong + concrete failure), **Impact**, **Fix** (code-level, with `file:line`), and — where I ran it live — a **Repro** block with the actual request/response.
Severity:
- **CRITICAL** — money moves wrongly, or anyone can act on anyone's data / settle invoices.
- **HIGH** — data corruption, cross-tenant read/write, physical-asset state diverges from reality.
- **MEDIUM** — wrong-but-recoverable state, missing guards, math/notification errors.
- **LOW** — hardening, stale displays, latent (unused endpoint) bugs.
**Counts:** 6 Critical · 18 High · 27 Medium · 20 Low (≈71 distinct issues).
The single most valuable structural fix is at the top of §4 — it eliminates a whole family of bugs.
---
## 2. Live tests I actually ran (evidence)
| # | Test | Result | Verdict |
|---|------|--------|---------|
| 1 | Login `superadmin@tria.com` | `success:true` + JWT | OK |
| 2 | Build train `TR-00002` in KALITY, 2 locomotives | created | OK |
| 3 | **Move coupled LOCO-004 to MOJO while train stays in KALITY** | `200`, train KALITY / loco MOJO | **BUG — your example, confirmed** |
| 4 | Re-use same locomotive on a 2nd train | `409 already coupled` | Guard OK |
| 5 | `PATCH /train-builder/:id/yard` → MOJO | coupled locos + wagons follow | OK (builder path correct) |
| 6 | Decommission a coupled locomotive | `200 OUT_OF_SERVICE` | **BUG — no coupling guard** |
| 7 | Attach KALITY wagon to MOJO train | `400 not in yard` | Guard OK |
| 8 | `PATCH /wagons/:id` yard → different yard while ASSIGNED | `200` accepted | **BUG** |
| 9 | `DELETE /wagons/:id` on a wagon coupled to a train | `200` + **row physically gone** | **BUG — hard delete, no guard** |
| 10 | `GET /payments/checkout` **no auth** | `200` | **BUG — public** |
| 11 | `POST /internal/payments/mark-paid` **no auth** | `400` (reached handler, not `401`) | **BUG — public** |
| 12 | `GET /payments/receipt/:id` **no auth** | `400` (reached handler) | **BUG — public** |
| 13 | `GET /files/:id` **no auth** | `404` (reached handler) | **BUG — public** |
| 14 | Control: `GET /locomotives`, `GET /incidents` no auth | `401` | Auth global guard works |
Tests 1014 prove the "public" endpoints are genuinely reachable without a token (protected routes return `401`; these return `400`/`404`/`200` because they hit the handler).
> **Boot-time noise (not app bugs):** SMS + Email services fail on RabbitMQ `ACCESS_REFUSED` (broker creds), and Swagger warns about 3 duplicate DTO names (`UpdateProfileDto`, `RequestChangesDto`, `SignContractDto`) and a legacy `/api/*` route. See §9.
---
## 3. CRITICAL — money & authorization
### C1. Paying an invoice settles it instantly without any money moving
**File:** `edr-platform/apps/edr-freight-api/src/modules/billing/billing.service.ts:973-986`
A shipped "DEMO" shortcut fakes a `payment.succeeded` callback the moment a payment is initiated:
```ts
// DEMO: manually fire the gateway 'payment.succeeded' callback here …
if (!result.immediateSuccess) { await this.payment.handlePaymentEvent({ eventType: "payment.succeeded", }) }
```
Real gateways return `REQUIRES_ACTION` (redirect), so `immediateSuccess` is false for essentially every payment → the invoice is marked **PAID**, the booking advances to `PAID`/batch allocation, and the clearance-fee gate opens. **A customer clicks "Pay", closes the page, pays nothing, and the freight ships.** If they *do* pay, the later real webhook is a no-op and money is collected against an already-settled invoice with no reconciliation.
**Fix:** delete the `if (!result.immediateSuccess)` block. Settle only from `settleByPaymentId` on a verified provider signal, and check `intent.amountMinor` against the invoice balance at settle time.
### C2. Unauthenticated endpoints can settle any invoice by ID *(live-confirmed)*
**Files:** `billing/payment.controller.ts:55-104` (`GET /payments/checkout`, `@Public()`), `payment/internal-payment.controller.ts:22-39` (`POST /internal/payments/mark-paid`, `@Public()` — its own comment: *"anyone who can reach the API can mark payments as paid"*), `billing/payment.controller.ts:39-53` (`POST /payments/initiate`, no guard, no ownership check).
**Repro (live):**
```
GET /api/payments/checkout?invoiceId=…&method=TELEBIRR → 200 (no token)
POST /api/internal/payments/mark-paid → 400 (no token, reached handler)
GET /api/locomotives → 401 (control)
```
Combined with **C1**, anyone with an invoice UUID marks it paid unauthenticated and ships the freight. Anyone can POST a forged `payment.succeeded` (only a booking id is needed) to `mark-paid`.
**Fix:** shared-secret / `x-service-token` guard on `mark-paid` (the payment API already sends one — `http-payment-event-publisher.ts:44-45`); require auth + ownership on `initiate`; make `checkout` a signed, expiring URL.
### C3. Currency-unit chaos — providers disagree by 100× on the same charge
**Files:** `billing.service.ts:958` sends `amountMinor: Math.round(Number(invoice.balanceAmount))` (invoice amounts are **major** units, `numeric(14,2)`). Providers then split: `cbe-birr.provider.ts:57`, `ebirr.provider.ts:64`, `card.provider.ts:60`, `cac-bank.provider.ts:307` all do `amountMinor / 100`; but `telebirr.provider.ts:201`, `dmoney.provider.ts:200`, `waafi.provider.ts:282` treat it as major (no `/100`).
**Impact:** a 50,000 ETB invoice paid via CBE_BIRR/EBIRR/CARD/CAC charges **500.00** — while the freight side still marks it fully PAID (see C4). `Math.round` also drops cents.
**Fix:** pick one convention (`Math.round(balance*100)` true-minor everywhere, fix the 3 major-unit providers), then enforce `confirmedAmountMinor === intent.amountMinor` at settlement.
### C4. Settlement never verifies the amount charged
**Files:** `billing.service.ts:527-552` (`markInvoiceAsPaid` sets `paidAmount = totalAmount, balanceAmount = 0` purely from the invoice); `edr-payment-api/.../intents/intents.service.ts:408-415` (an amount mismatch is only `logger.error`'d — the intent still finalizes SUCCEEDED, and no freight handler even populates `confirmedAmountMinor`).
**Impact:** any wrong-amount success (C3's 1/100, a partial card capture, a reused stale intent) still marks the full invoice PAID.
**Fix:** treat a `confirmedAmountMinor` mismatch as a failure/hold; in `settleByPaymentId` refuse (or record a partial) when the confirmed amount doesn't cover `balanceAmount`.
### C5. Telebirr & D-Money webhook signatures are disabled
**Files:** `edr-payment-api/.../webhooks/handlers/telebirr-webhook.service.ts:16-18` (`const signatureValid = true;` + `// TODO: re-enable`), `dmoney-webhook.service.ts:16-17` (same). The pipeline only rejects when `signatureValid` is false, so these two are trusted unconditionally on the public `/webhooks/*` surface.
**Impact:** an attacker who guesses a `merch_order_id` POSTs a fake success payload → intent SUCCEEDED → freight marks the invoice PAID and ships.
**Fix:** implement `verifyWebhookSignature` for both; until keys exist, re-query the provider (`queryStatus`) before honoring SUCCEEDED.
### C6. Customers can create/act-on bookings billed to any company
**Files:** `bookings/bookings.service.ts:594-629` (`create` only resolves+verifies the caller's own company when `dto.companyId` is *absent*; a supplied `companyId` is used verbatim), plus a batch of booking mutations with **no ownership check** in `bookings.controller.ts`: `PATCH /:id` (205), `DELETE /:id` (585), `POST /:id/generate-price` (611), `/submit` (622), `/confirm-submit` (633), `/reject` (643), `/clearance/documents` (682), `/clearance/proceed` (699), `GET /:id/clearance` (672). Reads (`GET /:id`) *do* call `assertCustomerCanAccessBooking`; writes don't.
**Impact:** Company A's user sets `companyId` to Company B and books/invoices under B; or calls `DELETE /bookings/{B's id}` / submits / uploads clearance docs on B's booking. UUIDs appear in list payloads, so they're discoverable within a session.
**Fix:** require a staff permission to pass `companyId`; otherwise force it from the resolved user company. Add `assertCustomerCanAccessBooking` to every mutating booking route.
---
## 4. HIGH — Train / Locomotive / Wagon consist integrity
> **★ Structural root cause (fix this first).** The invariant *"a consist (train + its locomotives + its wagons) moves and locks as one unit"* is enforced **only inside** `trains/train-builder.service.ts`. Every *legacy / master-data* endpoint around it — `PATCH /locomotives/:id`, `PATCH /wagons/:id`, `POST /wagons/:id/assign-train`, `wagons/bulk-status`, `wagons/bulk-transfer`, `DELETE /trains/:id`, `DELETE /wagons/:id` — mutates the same rows with **no coupling guard**. Findings H1, H2, H5, H6, H7 are all the same missing check. **The one fix that kills the family:** make `TrainLocomotive`/`wagon.trainId` membership a guard that every locomotive/wagon mutation consults (reject or redirect to the builder endpoints).
### H1. ★ Locomotive yard/status freely editable while coupled to a built train — *your example, confirmed live*
**File:** `locomotives/locomotives.service.ts:87-119` (`update`).
`update()` applies `status` and `currentYardId` with **no check of the `train_locomotives` link table**:
```ts
currentYardId: dto.currentYardId === undefined ? locomotive.currentYardId : (dto.currentYardId ?? null),
```
The builder keeps train + locomotives in the same yard (`train-builder.service.ts:389-429 setYard`, `:656-660 validateAndLockLocomotives`), but `PATCH /locomotives/:id` bypasses it. **The frontend triggers it by accident:** the locomotive edit form always includes the `status` and `currentYardId` selects (`.../pages/fleet/config/resources.ts:168-169`) and PATCHes the *whole form object* on every save (`FleetResourcePage.tsx:301`). So editing a coupled locomotive's *name* re-sends its yard → the train and its locomotive end up in different yards.
**Repro (live):**
```
Build TR-00002 in KALITY with LOCO-004 (+LOCO-023)
PATCH /api/locomotives/LOCO-004 {"currentYardId": MOJO} → 200
GET train → yard: KALITY ; LOCO-004 → yard: MOJO ← diverged, no error
```
**Fix (exactly what you described — reject with a clear error, don't silently move):**
```ts
const link = await this.ds.getRepository(TrainLocomotive)
.findOne({ where: { locomotiveId: id }, relations: { train: true } });
if (link) {
if (dto.currentYardId !== undefined && dto.currentYardId !== link.train?.currentYardId)
throw new ConflictException(`Locomotive ${loco.code} is coupled to train ${link.train?.code}; move the train instead`);
if (dto.status !== undefined && dto.status !== loco.status)
throw new ConflictException(`Locomotive ${loco.code} is coupled to train ${link.train?.code}; detach it before changing status`);
}
```
Frontend: send only dirty fields, and disable the yard/status selects (show the modal error) when the locomotive is in a built train.
### H2. Wagon PATCH is a free-for-all — yard, `trainId`, `sequenceNumber`, `status` all unguarded *(live-confirmed)*
**File:** `wagons/wagons.service.ts:90-101``Object.assign(wagon, dto)` with zero invariant checks; `UpdateWagonDto` (PartialType of Create) exposes `trainId`, `sequenceNumber`, `status`, `currentYardId`.
**Repro (live):** `PATCH /api/wagons/:id {"currentYardId": MOJO}` on a wagon `ASSIGNED` to a KALITY train returned `200` and moved it. Setting `trainId` directly attaches to any train bypassing every builder rule; flipping `ASSIGNED→AVAILABLE` while `trainId` is set makes the wagon grabbable by transfer requests and the legacy assign flow.
**Fix:** reject `trainId`/`sequenceNumber` in update; when `wagon.trainId != null`, reject `currentYardId`/`status` changes (409 → point at train-builder endpoints).
### H3. `DELETE /wagons/:id` hard-deletes with no guard — destroyed real data during this audit *(live-confirmed)*
**File:** `wagons/wagons.service.ts:136-139``this.wagonRepo.remove(wagon)` is a **hard** delete (repo standard is soft-delete via `BaseEntity.deletedAt`). No check for `trainId`, live-schedule pinning, or containers.
**Repro (live):** I called `DELETE /api/wagons/{BW1-0009}` while it was `ASSIGNED` to my test train → `200`. Raw SQL then showed **the row physically gone** (`SELECT … WHERE id=… → 0 rows`). On delete, `train_set_wagons.physical_wagon_id` is `SET NULL` (a dispatched schedule silently loses its physical wagon) and `wagon_movements` is `CASCADE` (the audit ledger is destroyed).
> **I recreated BW1-0009 via the API** (`POST /api/wagons`, new UUID `d9b578ed-…`, AVAILABLE @ KALITY) so the dev data count is whole again. The original UUID `905cad62-…` is unrecoverable (hard delete). See §10.
**Fix:** block deletion when `trainId IS NOT NULL` or the wagon is pinned to a DRAFT/SCHEDULED/DISPATCHED schedule (reuse `train-builder`'s `isWagonPinnedToLiveSchedule`); switch to `softRemove` (add a partial unique index on `wagon_number WHERE deleted_at IS NULL`).
### H4. `DELETE /trains/:id` hard-deletes a built train and permanently strands its wagons
**File:** `trains/trains.service.ts:57-60``trainRepo.remove(train)`, no active-schedule check (contrast `train-builder.service.ts:536-563 disband`, which blocks on live schedules and releases resources). Wagons' FK is `SET NULL`, so they keep `status = ASSIGNED` with `trainId = null`**unusable forever** (attach requires `status === Available`). The fleet Trains CRUD page (`resources.ts:204`) points at this endpoint.
**Fix:** delegate `TrainsService.remove` to `TrainBuilderService.disband` (block live schedules, reset wagons to Available, delete locomotive links), and use `softRemove`.
### H5. Legacy wagon assign: maintenance wagons assignable, silent theft, duplicate sequences
**File:** `wagons/wagons.service.ts:141-164` (`POST /wagons/:id/assign-train`). Only guard is `status === Assigned`. Holes vs the builder's `attachWagons`: a `MAINTENANCE`/`DETAINED` wagon is accepted and flipped to ASSIGNED (erasing the flag); a wagon with `trainId` set but status≠ASSIGNED has its `trainId` overwritten (**steals it from another train's consist**, never resequenced); no wagon-yard vs train-yard check; no `train.status !== IN_SERVICE` check; caller `sequenceNumber` isn't collision-checked (no unique index on `(train_id, sequence_number)`).
**Frontend echo:** `AssignWagonDialog.tsx:22-24` filters `w.status === Available || !w.trainId` — the `||` should be `&&`; today it offers maintenance/detained wagons.
**Fix:** re-implement on builder rules (`Available && trainId === null && sameYard && train not InService`, sequence = max+1), or delete the endpoint and use `POST /train-builder/:id/wagons`.
### H6. `bulk-status` / yard-workspace "Free up" corrupts wagons physically in a train
**File:** `wagons/wagons.service.ts:241-269` (`bulkSetStatus`, no `trainId` guard); UI `WagonYardWorkspaceModal.tsx:187,270-287,545-580` picks an arbitrary slice of ASSIGNED wagons and flips them AVAILABLE — including consist wagons (whose ASSIGNED means "coupled"). After the flip they still have `trainId` set but read AVAILABLE → transfer requests hand them out, `bulk-transfer` moves them, legacy assign re-homes them, while the builder consist still lists them.
**Fix:** refuse to change status of wagons with `trainId IS NOT NULL`; exclude them from the modal's flip pools.
### H7. `bulk-transfer` relocates train-coupled wagons; legacy unassign bypasses the schedule pin
**File:** `wagons/wagons.service.ts:180-234` (`bulkTransfer`, existence-only check → moves consist wagons to another yard) and `166-172` (`unassignFromTrain` frees a wagon with no `isWagonPinnedToLiveSchedule` check and leaves a sequence gap).
**Fix:** reject `trainId IS NOT NULL` in `bulkTransfer`; run the builder's pinned-schedule query in `unassignFromTrain` and resequence, or delete in favor of `DELETE /train-builder/:id/wagons/:wagonId`.
### H8. Export capacity check-then-act race → train overbooking
**File:** `bookings/booking-transition.service.ts:1093-1153``train-scheduling/booking-batch.service.ts:722-793``pickExportSchedule` reads `budget.fits(...)` then `reserve` (idempotency only, never re-checks capacity). No transaction/row-lock spans the check and the write (the code comment even calls the pre-check "rough").
**Impact:** two staff accept two export bookings for the same near-full train concurrently → both pass `fits()`, both reserve → train exceeds locomotive pull weight / wagon slots.
**Fix:** wrap check + reserve in a serializable transaction with `SELECT … FOR UPDATE` on the schedule (or a per-schedule advisory lock); re-verify `fits()` inside the lock.
### H9. Consolidation pairing race → one partner paired with two bookings
**File:** `bookings.service.ts:494-543` + `bookings.repository.ts:213-261,287-309``findComplementaryConsolidationPartner` filters `consolidationPartnerId IS NULL`, `pairConsolidation` writes both sides, no lock between find and pair.
**Impact:** two bookings both pick the same waiting partner → asymmetric pairing, shared-wagon capacity double-counted.
**Fix:** `FOR UPDATE` on the candidate inside a transaction, re-assert `consolidationPartnerId IS NULL` on both before writing.
### H10. `import-operations` controller has no authorization
**File:** `import-operations/import-operations.controller.ts` (whole file — only `@ApiTags`, no permission guard). Any authenticated portal user can `POST /import-operations/customs/{anyBookingId}/release-permitted`, assign customs risk, or mark duties/taxes paid on any booking.
**Fix:** add `@BookingStaff(FREIGHT_PERMS…)` guards.
### H11. `interchange-documents` controller has no authorization
**File:** `interchange-documents/interchange-documents.controller.ts``POST /generate-from-schedule`, `PATCH /:id/acknowledge|dispute|cancel` reachable by any authenticated user. These are outward-facing customs/port handover records.
**Fix:** staff permission guards.
### H12. Anyone can sign anyone's contract as CUSTOMER
**File:** `contracts/contracts.controller.ts:502-523` (asserts permission only for non-CUSTOMER roles) + `contract-transition.service.ts:795-816` (CUSTOMER branch checks only status + a **caller-supplied** `dto.otpPhone`/`dto.otp`). Any portal user signs another company's CONTRACT_READY contract with an OTP on their own phone → SIGNED_CUSTOMER, firing counter-sign/clearance-fee. `renew` (525) and `clearance/documents` (543) have the same gap.
**Fix:** call `assertCustomerCanAccessContract`; verify OTP against the contract company's registered phone, not `dto.otpPhone`.
### H13. Any file downloadable by anyone with the UUID *(live-confirmed public)*
**File:** `files/files.controller.ts:20-55` — the single global file-stream route is `@Public()`, no auth, no ownership, no expiry. All uploads funnel through it: driver documents, contract PDFs, company license / Fayda national-ID files.
**Repro (live):** `GET /api/files/{uuid}``404` with no token (reached handler; a valid id streams the file). Control routes return `401`.
**Fix:** require `JwtGuard` and authorize by the file's `resource`/`resourceId`, or serve via short-lived signed URLs (the code already has `filesService.signUrl`).
### H14. Duplicate `payment.succeeded` regresses advanced/cancelled bookings back to PAID
**File:** `bookings/booking-invoice.service.ts:133-166` — the idempotency guard `// if (booking.paymentStatus === "PAID") return;` is **commented out**, then it unconditionally rewrites `status: "PAID"` and re-runs allocation. The relay is documented at-least-once and freight never dedupes `eventId` (the `PaymentWebhookEventEntity` is registered but unused).
**Impact:** a replayed success on a `SCHEDULED`/`DISPATCHED` — or `CANCELLED` — booking force-rewrites it to PAID and re-fires side effects.
**Fix:** restore the guard as a state-machine check (only advance from awaiting-payment statuses); persist processed `eventId`s.
### H15. Booking prices ignore the contract's frozen rate snapshots
**File:** `bookings/booking-pricing.service.ts:123-229` prices exclusively from `ratesService.findLiveRates()`; nothing reads `contract_rate_snapshots` (only the clearance fee honors the freeze). `contract-booking.service.ts:289-295` comments "compute from contract unit rates" but calls the live-rate path.
**Impact:** customer signs a contract at 1,916 USD/40ft, rates team raises the live rate to 2,300, the drawdown booking bills 2,300 — contradicting the signed contract PDF.
**Fix:** in `computePriceForBooking`, when `booking.contractId` is set, resolve unit prices from that contract's snapshots (fall back to live only for un-frozen codes).
### H16. Cancelling a booking leaves its invoice open & payable; refund path is dead code
**File:** `booking-transition.service.ts:519-543` (`cancel()` writes a note + `status: CANCELLED`, never cancels the open PREPAID invoice) and `billing.service.ts:654-672` (`markInvoiceAsRefunded` has **zero callers**).
**Impact:** the cancelled booking's invoice stays payable in the portal; paying it fires `booking.invoice.paid` → flips the CANCELLED booking back to PAID (see H14). Money already collected on a later-cancelled flow has no refund mechanism.
**Fix:** `cancel()`/`reject()` must cancel/expire the open invoices in the same transaction; wire `markInvoiceAsRefunded` to a real staff refund endpoint; make `advanceBookingOnPayment` refuse terminal-status bookings.
### H17. `cancelTrainSchedule` has no status guard — a DISPATCHED/ARRIVED train can be cancelled
**File:** `train-scheduling/train-scheduling.service.ts:3226-3311` (unguarded at `train-scheduling.controller.ts:817`). Every sibling transition checks `status`; this one goes straight to the cancel transaction.
**Impact:** cancelling a DISPATCHED train sets every pinned wagon's `currentYardId = originStationId` ("never left") while they're rolling; releases the locomotives out on this run to AVAILABLE; detaches IN_TRANSIT bookings while their cargo stays IN_TRANSIT. The frontend only shows Cancel for `DRAFT`/`SCHEDULED` (`TrainScheduleV2ListPage.tsx:439-469`) — the API enforces nothing.
**Fix:** reject unless `['DRAFT','SCHEDULED'].includes(schedule.status)`.
### H18. `executeReschedule` is not transactional — a mid-flight failure strands the schedule half-rescheduled
**File:** `scheduling-reschedule/scheduling-reschedule.service.ts:131-210` — four separately-committed steps (persist new date → unassign each displaced booking → `assignBookingsToSchedule` → audit event). Step 3 routinely throws (re-validates against the *new* date persisted in step 1). Result: date already moved, displaced bookings already gone, no audit event, `400` to staff.
**Fix:** run the whole execute in one `dataSource.transaction`, threading the manager through unassign/assign.
---
## 5. MEDIUM — scheduling, bookings, cargo, containers
### M1. Cargo delivery never releases the container (counts itself)
**File:** `cargoes/cargoes.service.ts:159-175` — sets `cargo.status = 'DELIVERED'` in memory, then counts `LOADED` cargo on the container *before saving*, so the cargo being delivered counts itself → `remaining >= 1` always → the container is **never** flipped back to AVAILABLE.
**Fix:** save the cargo first, or exclude the current id: `count({ where: { containerId, status: 'LOADED', id: Not(cargo.id) } })`.
### M2. Cargo unload leaves the container marked LOADED
**File:** `cargoes.service.ts:139-147``unloadCargo` sets cargo `UNLOADED` but never touches `container.status` (which `loadCargo` set to LOADED). Emptied containers read as in-use forever.
**Fix:** on unload, if no remaining LOADED cargo references the container, reset it to AVAILABLE.
### M3. Container↔wagon assignment: no capacity/duplicate guard; status wrongly AVAILABLE
**File:** `container-management/containers.service.ts:113-136``MAX(position)+1` check-then-act with no `(wagon_id, position)` unique constraint; no check that the container is already on another wagon (silently overwrites `wagonId`); sets `status = 'AVAILABLE'` for a container physically on a wagon (so it reads free for another assignment). *(There's also a dead `containers.service copy.ts` duplicate.)*
**Fix:** reject when the container already has a `wagonId`; enforce wagon capacity; unique `(wagon_id, position)` + allocate in a transaction; use a distinct on-wagon status.
### M4. Interchange document state machine holes
**File:** `interchange-documents/interchange-documents.service.ts:182-206``dispute` has **no status guard** (a CANCELLED or ACKNOWLEDGED doc can be flipped to DISPUTED); `acknowledge` guards only CANCELLED, so an already-DISPUTED doc can be quietly ACKNOWLEDGED (losing the dispute).
**Fix:** restrict `dispute` to GENERATED/ACKNOWLEDGED; restrict `acknowledge` to GENERATED.
### M5. Interchange item weight mixes tons and kg in one column
**File:** `interchange-documents.service.ts:238-361` — the `weight` column is `COALESCE`d from tons sources (`total_vgm_tons`, `cargo_total_weight_vgm`) *and* kg sources (`containers.max_gross_weight`, `cargoes.weight // kg`). Different line items in the same customs handover carry weights ~1000× apart.
**Fix:** normalize every source to one unit before writing.
### M6. Cargo load/create allow weight over container capacity
**File:** `cargoes.service.ts:114-137``loadCargo` sets `cargo.weight = dto.weight` (`@Min(0)` only), never compared to the container's `maxGrossWeight`, no aggregate across cargoes.
**Fix:** verify `tare + sum(loaded) ≤ maxGrossWeight` (mind M5's units) and reject overflow.
### M7. Reschedule changes the departure date without any of `updateScheduleDate`'s validation
**File:** `scheduling-reschedule.service.ts:151-157` vs `train-scheduling.service.ts:781-865` — the reschedule/maintenance path writes `scheduledDepartureDate` directly: no `PRE_WINDOW` check, no lead-window rejection, no re-derivation of `windowOpensAt/ClosesAt/Phase`, no route+day group re-anchor, and **past dates are accepted** (never compared to `now`; only the dialog checks client-side). Windows keep the timing computed for the OLD date.
**Fix:** delegate to `updateScheduleDate` (or replicate its checks) and validate `newDepartureDate > now`.
### M8. `compareSchedulingPriority` sorts null-date bookings FIRST (comment says last)
**File:** `scheduling/compare-scheduling-priority.util.ts:4-6,20-22` — null `scheduledDate``getTime()` falls back to `0` (epoch), ascending sort puts it first. In `previewReschedule` this decides who is retained when capacity is tight → a general-contract booking with no date outranks customers who booked a concrete slot.
**Fix:** fall back to `Number.MAX_SAFE_INTEGER`, not `0`.
### M9. Assigning bookings never checks the booking's day matches the schedule's departure day
**File:** `train-scheduling.service.ts:1226-1259` + `3321-3618` — the parity guard's comment claims day-fit is enforced "downstream", but `validateBookingsForScheduling` never reads `dto.scheduleDate` or compares `booking.scheduledDate`. A booking a customer picked for Jul 25 can be assigned to a train departing Jul 17, silently.
**Fix:** add a violation (or `forceAssign` warning) when `eatDay(booking.scheduledDate) !== eatDay(schedule departure)`.
### M10. Partial `unassignBooking` frees the booking but not its wagon slots/totals
**File:** `train-scheduling.service.ts:1492-1541``TrainSetWagon` slots and `TrainSet` aggregates are only reset when the train becomes fully empty. Remove 1 of 3 bookings → tonnage/length/wagonCount stay stale, empty slots stay RESERVED with wagons pinned, free-capacity under-reports (can hide the day from customers), dispatch sends the empty pinned wagons.
**Fix:** after a partial unassign, release the emptied slots and recompute totals from surviving allocations.
### M11. Displaced-booking fallback leaves the booking still linked to the schedule
**File:** `scheduling-reschedule.service.ts:159-168` — on unassign failure the `catch` only flips scheduling fields; it doesn't delete the `TrainScheduleBooking` link, clear `trainScheduleId`, or free allocations. The booking becomes ELIGIBLE for batch fills **and** still linked → double-booking; its stale `trainScheduleId` also blocks manual assignment elsewhere.
**Fix:** in the fallback, delete the link + allocations and set `trainScheduleId: null` (or re-throw and abort).
### M12. Reschedule notifies "rescheduled to a new date" even when the date didn't change
**File:** `scheduling-reschedule.service.ts:204-235``effectiveDeparture` falls back to the (never-null) existing date, so the `if (newDeparture)` branch always runs → a GOVERNMENT_PREEMPT rebalance with no date change SMS/email-blasts every retained customer "rescheduled to `<same date>`".
**Fix:** `const effectiveDeparture = dto.newDepartureDate ? new Date(dto.newDepartureDate) : null;`
### M13. Maintenance reschedule endpoint bypasses DTO validation & drops caller bookings
**File:** `scheduling-reschedule.controller.ts:56-65` (body typed as an intersection `PreviewRescheduleDto & { … }` → Nest emits `Object` metadata → **ValidationPipe is skipped**, so `newDepartureDate: "garbage"` and missing arrays reach the service) + `scheduling-reschedule.service.ts:257-283` (preview uses `currentIds.length ? currentIds : dto.incomingBookingIds`, so caller-supplied incoming ids are dropped on a non-empty train, then execute re-runs with `dto.incomingBookingIds` → the displaced-set equality check can 400).
**Fix:** real `MaintenanceRescheduleDto extends PreviewRescheduleDto` with `@IsDateString() newDepartureDate`; merge `currentIds dto.incomingBookingIds` for both preview and execute.
### M14. Routes editable (milestones deleted, endpoints swapped) while live schedules reference them
**File:** `routes/routes.service.ts:108-140``update()` deletes+rewrites milestones and origin/destination with no check for DRAFT/SCHEDULED/DISPATCHED schedules on the route. Schedules read the corridor live afterward (sub-leg validation, checkpoints, customer day pool), so a reroute silently invalidates boarding bookings and renumbers stations mid-run.
**Fix:** reject milestone/endpoint changes when any non-terminal schedule references the route (allow status-only edits).
### M15. Workspace capacity meter sums locomotive limits; API caps at the weakest locomotive
**File:** `.../components/trainScheduling/ScheduleWorkspacePanel.tsx:98-109` (`reduce(sum + maxPullWeightTons)`) vs `train-capacity.util.ts:242-260` + service `1349-1389` (`minLocomotiveLimits` — the weakest locomotive caps the train; gate is cargo **+ consist tare**). Two 3500T locos → the meter shows 7000T and "43% full" while the API already rejects adds at ~3500T gross; the overfill warning fires on the wrong threshold.
**Fix:** capacity = `min(maxPullWeightTons) + min(overageToleranceTons)`, and include consist tare in `used`.
### M16. Vehicles under maintenance stay assignable; maintenance never changes availability
**File:** `maintenance/*` never writes `vehicle.status`/availability; `maintenance.service.ts:36-46` COMPLETED doesn't restore anything; assignment paths check only availability, never `VehicleStatus.MAINTENANCE`.
**Impact:** a vehicle in the shop can be dispatched; marking a vehicle MAINTENANCE doesn't block assignment.
**Fix:** on maintenance start set the vehicle unavailable, on completion restore, and reject MAINTENANCE/OUT_OF_SERVICE/RETIRED at assignment.
### M17. Fuel purchases accept negative/zero quantities, no duplicate guard
**File:** `fuel/dto/create-fuel-purchase.dto.ts:11-15` (`liters`, `costPerLiter` are `@IsNumber()` only) → `fuel.service.ts:18` `totalCost = liters * costPerLiter`. Negative liters → negative monthly totals & averages, poisoning Financial Reports/Fleet Dashboard; no `(vehicleId, receiptNumber)` uniqueness → double-counting.
**Fix:** `@IsPositive()` on both; reject duplicate `(vehicleId, receiptNumber)`.
### M18. Public OTP verify: no expiry, no rate limit, no attempt cap, replayable
**File:** `otp/otp.service.ts:94-120` (`verifyOtp`, exposed `@Public()` at `otp.controller.ts:22-63`) — a 6-digit code (1e6 space) with unlimited attempts, no age check, and only flagged `verified=true` on success (the same code keeps working). The hardened `verifyOtpForAction` (TTL + 5-attempt cap + delete-on-success) exists but this route doesn't use it.
**Fix:** give `verifyOtp` the same TTL/attempt-cap/consume semantics; rate-limit the public OTP routes.
### M19. OTP generated with `Math.random()` (not a CSPRNG)
**File:** `otp/otp.service.ts:27-29` — this gates password reset (`forgot-password.service.ts:134`) and contract-signature sudo. Predictable codes weaken account-takeover resistance.
**Fix:** `crypto.randomInt(100000, 1000000)`.
### M20. OTP send is public & unthrottled — SMS/email bombing + counter reset
**File:** `otp/otp.controller.ts:33-42` (`@Public() POST /otp/send`) + `otp.service.ts:35-88` (each send does `actionAttempts.delete(...)`, resetting the in-memory brute-force counter — attacker-controllable, and per-process anyway).
**Fix:** rate-limit per target + per IP; move the attempt counter to persistent storage.
### M21. Email notifications silently discarded while reporting success
**File:** `notifications/strategies/notification.email.strategy.ts:8-11``send()` logs and `return false;` (stub). `notifications.service.ts:25-32 directSend` awaits it, logs `is sent - false`, and neither throws nor surfaces the failure. Every email notification (booking lifecycle, contracts, companies, booking-window) silently never sends.
**Fix:** implement the strategy (or route through the working `EmailClientService`); make `directSend` treat `false`/throw as an observable failure.
### M22. Rate "CEO approval" is self-approvable (no segregation of duties)
**File:** `rule-engine/services/rates.service.ts:186-208` + `rates.controller.ts:61-76``submit` and `approve` share the identical `@RuleEngineManage('rates')` permission, and `approve` never checks `approverUserId !== proposedByStaffId`. One staffer can draft→submit→approve LIVE. (Same in `priority-rule-change-requests.service.ts:78-112`.)
**Fix:** distinct approver permission + reject self-approval.
### M23. Uploads accept arbitrary type & unbounded size
**File:** `drivers/drivers.controller.ts:74-82` (`AnyFilesInterceptor()`, no `limits`/`fileFilter`) + `files/files.service.ts:36-55` (stores whatever mime/size). `file.buffer` held in memory → DoS; executables/active-HTML then served inline via the public files route (H13). The `file-upload-settings` config exists but isn't enforced.
**Fix:** Multer `limits.fileSize` + mime allowlist (driven by file-upload-settings), validated in `FilesService.upload`.
### M24. Incidents controller: full CRUD + IDOR for any authenticated user
**File:** `incidents/incidents.controller.ts:17-69` — no permission guard on any route (sibling fleet modules all use `@BookingStaff`). A portal customer can read any driver's incident history by `driverId` and create/alter/delete incident records.
**Fix:** class-level `@BookingStaff(FREIGHT_PERMS.incidents.view)` + per-write permissions.
### M25. Tracking timeline IDOR
**File:** `tracking/tracking.controller.ts:11-17``GET /:consignmentId` has no guard/ownership check; any logged-in user reads any consignment's full movement history by iterating UUIDs.
**Fix:** permission guard + scope to the caller's company.
### M26. Clearance-fee gate silently waived when the fee snapshot is missing
**File:** `contracts/clearance-fee.service.ts:80-87``gateApplies` returns `false` (skipping the prepay gate, warn-log only) whenever no `CUSTOMS_CLEARANCE` snapshot line exists, and `contract-pricing.service.ts:262-284` falls back to a stale stored breakdown. A customs contract whose price predates the fee feature ships clearance for free.
**Fix:** for `customsClearingEnabled` contracts, hard-fail counter-sign/shipment-request when no fee line resolves.
### M27. Manual settlement / `updateStatus` gaps in the invoice state machine
**File:** `billing.service.ts:577-609` (`recordPayment` guards Cancelled/Refunded/Paid but not Draft/Expired → an expired invoice can be settled at the counter, resurrecting a released flow) and `889-907` (`updateStatus` accepts **any** target including Paid without touching `paidAmount`/`balanceAmount` → a PAID invoice with a full outstanding balance).
**Fix:** add Draft/Expired to the reject list; restrict `updateStatus` to the Draft→Issued transition it's actually used for.
---
## 6. LOW — hardening & latent bugs
- **L1. Decommission ignores coupling** — `locomotives.service.ts:121-133` sets OUT_OF_SERVICE with no `TrainLocomotive` check *(live-confirmed: decommissioned a coupled loco, 200)*. Train keeps hauling with a dead loco on paper; `capacityTons` not recomputed. → link-table guard.
- **L2. Builder accepts ASSIGNED/UNAVAILABLE locomotives** — `train-builder.service.ts:653` blocks only OUT_OF_SERVICE/MAINTENANCE; a loco out on a dispatched run (ASSIGNED) can be coupled to a new train. → allowlist `AVAILABLE/IMPORT_READY/EXPORT_READY`.
- **L3. Legacy `PATCH /trains/:id`** — `trains.service.ts:50-55` `Object.assign` lets `status`/`capacityTons`/`code`/`trainNumber` be rewritten (unfreeze a dispatched train, break the weakest-loco capacity, code/number clashes → 500 or silent collision). → strip those fields; run the builder's clash query.
- **L4. `PATCH /train-builder/:id/details` skips even/odd run-number rule** — `update-train-details.dto.ts:17-29` lacks the `@Matches(/…[13579]$/ | …[02468]$/)` the build DTO enforces → can set IMPORT number odd. → copy the decorators.
- **L5. Legacy reorder ignores `trainId`** — `wagons.service.ts:271-287` (`_trainId` unused) renumbers any wagon list to `1..n` with no set-equality check → duplicate sequences on other trains. → validate set equality vs `find({ where: { trainId } })`.
- **L6. Wagon type soft-deletable while in use** — `wagon-types.service.ts:114-117` no reference check; soft-deleted type → `wagon.wagonType` loads null → builder length/tare math silently zeroes. → block when wagons reference it.
- **L7. Facilities create/update are dead** — `facilities/dto/*.dto.ts` are plain interfaces (no decorators) under a `forbidNonWhitelisted` pipe → every non-empty body 400s, empty body 500s. → decorate the DTOs + code-uniqueness check.
- **L8. Yard soft-delete has no reference guard** — `rule-engine/services/yards.service.ts:62-66` strands trains/locos/wagons parked there (their `current_yard_id` scalar survives but joins miss). → count references, 409.
- **L9. Wagon create incoherent state** — `wagons.service.ts:27-37` accepts `trainId` while status defaults AVAILABLE (violates attached⇒ASSIGNED); duplicate `wagonNumber` → 500 not 409. → drop `trainId`/`sequenceNumber` from create DTO, uniqueness pre-check.
- **L10. Locomotive code generation is a read-scan race** — `locomotives.service.ts:40-48` scans all rows for max `LOCO-NNN`; concurrent creates collide → 500. → `MAX(SUBSTRING…)` + probe loop or a sequence.
- **L11. `Train.capacityTons` stale after a coupled loco's pull limit is edited** — `locomotives.service.ts:87-119` has no recompute hook; list vs detail disagree. → recompute for trains found via `TrainLocomotive`.
- **L12. `GET /facilities/:id` returns `200 null`, `DELETE` succeeds for unknown ids** — `facilities.service.ts:20-30` (every sibling 404s). → throw NotFound.
- **L13. Import customs flow has no ordering/terminal guards** — `import-operations.service.ts:102-210`: duties-paid before notify, risk changed after completion, no terminal lock. → state + `completedAt` guards.
- **L14. Consignment create doesn't validate the referenced booking** — `consignments.service.ts:14-17` persists the DTO directly (`bookingId` only `@IsUUID`). → existence check.
- **L15. Incident & maintenance status transitions unvalidated** — `incidents.service.ts:64-71`, `maintenance.service.ts:36-46` accept any status (REPORTED→CLOSED, SCHEDULED→COMPLETED). → allowed-transition checks.
- **L16. Maintenance cost fields accept negatives** — `maintenance/dto/create-maintenance.dto.ts` `estimatedCost/actualCost/costAmount` no `@Min(0)`. → add it.
- **L17. Procurement depreciation accepts inverted money** — `procurement/dto/procurement.dto.ts:89-99` (no `@Min(0)`) + `procurement.service.ts:120-131`: `salvageValue > cost` → negative monthly depreciation → `bookValue` grows unbounded. → `@Min(0)` + `salvage <= cost`.
- **L18. `notification-inbox` fan-out is sequential N+1** — `notification-inbox.service.ts:62-64` per-recipient create+countUnread+findOne. → batch/parallelize (it's fire-and-forget, so slow not broken).
- **L19. GPS history `limit` unguarded vs NaN/negative** — `gps-tracking.controller.ts:38` passes `parseInt` through; `?limit=abc`→NaN, `?limit=-5`→negative take. → clamp to a positive int.
- **L20. Hardcoded default JWT secret** — `@edr/api-common` `shared-auth.module.js` falls back to a public constant if `JWT_SECRET` is unset (mitigated by the session-row check). → ensure `JWT_SECRET` always set in the environment.
---
## 7. Backoffice / portal frontend mismatches (already folded into findings above)
- **Locomotive/Wagon fleet edit forms PATCH the whole form object** (`FleetResourcePage.tsx:301`, `resources.ts:168-169,273-279`) → the accidental trigger for H1/H2.
- **`AssignWagonDialog.tsx:22-24`** — `status === Available || !w.trainId` should be `&&` (H5).
- **`WagonYardWorkspaceModal.tsx`** — "Assigned→Available" flip picks an arbitrary slice including coupled wagons (H6).
- **`ScheduleWorkspacePanel.tsx:98-109`** — capacity meter sums loco limits instead of the weakest (M15).
- **`InvoiceDetailPage.tsx:129-131` / `PayClearanceFeeButton.tsx:121-125`** — pay button shows `totalAmount`, backend charges `balanceAmount`; the correct `amountDue` line is commented out → a 60%-paid invoice tells the customer they'll pay the full total. Also a leftover `console.log(paymentMethod)` at line 71.
- **Reflected XSS on the public checkout page** — `billing/payment.controller.ts:100-103,156-176` interpolates provider error `message`/`status`/`intentId` unescaped into `@Public()` HTML. → HTML-escape or return a generic message.
- **Portal tracking/consignments pages are still mock-backed** (`portal/src/pages/tracking/shipments.mock.ts`, `consignments.mock.ts`) — they show mock data, not live consignments.
---
## 8. What is working correctly (verified, not bugs)
So the report is balanced — these were checked and are sound:
- **Train-builder same-yard enforcement** at build and attach (rejected my cross-yard attempts with clear 400s).
- **Double-coupling a locomotive** → `409 already coupled to train …` (guard works).
- **`train-builder` yard change** correctly relocates the whole consist (locos + wagons follow).
- **Dispatch / arrive / finalize** status guards, dispatch train-number pooling with pessimistic locks, route direction derivation and segment ordering, EAT-timezone math in `batch-window.util.ts` (no string date comparisons).
- **Booking cancellation** can't leak reserved capacity (only pre-reservation statuses cancellable); approval-step sequencing; `allocateContainers` and interchange generation are properly transactional.
- **GT06 GPS codec** coordinate/CRC/UTC decoding correct.
- **notification-inbox WebSocket auth**, **signatures**, **backoffice role management** (blocks reserved roles), **forgot-password/customer-reset** (use the hardened OTP + single-use IAM tickets) — all scope correctly.
- **Clearance-fee migration 2260** itself is sound (nullable/defaulted, `IF NOT EXISTS`); the gate enforcement on document upload is correct on both contract and booking sides (the bugs are around it — M26, and C1C3 letting the fee be "paid" for free).
---
## 9. Boot-log noise (environment, not code bugs)
- **SMS + Email over RabbitMQ fail:** `Handshake terminated by server: 403 (ACCESS-REFUSED) … Login was refused using authentication mechanism PLAIN` — broker credentials for the dev environment. (Compounds M21: email is doubly dead — stub strategy *and* no broker.)
- **Swagger duplicate-DTO warnings:** `UpdateProfileDto`, `RequestChangesDto`, `SignContractDto` each defined twice with different schemas ("will throw in the next major version"). Rename the duplicates.
- **`LegacyRouteConverter` warning** on `/api/*` — path-to-regexp v6 wants `/api/*path`.
---
## 10. Test-data side effects from this audit (please review)
I drove real writes against `edr_freight`. Net state:
1. **Created train `TR-00002`** (`exportTrainNumber 9901`, `importTrainNumber 9902`, name "QA-TEST-TRAIN") — then **deleted it** (cleaned up).
2. **Destroyed wagon `BW1-0009`** (original UUID `905cad62-1b12-46a0-9268-bacbf115e787`) via the hard-delete bug (H3). **I recreated it via the API** — new UUID `d9b578ed-e65a-48f6-9ae2-fb8239451460`, `AVAILABLE`, in KALITY, same wagon type. The wagon count is whole again; only the UUID changed (its old `wagon_movements` ledger was cascade-deleted and cannot be recovered).
3. **Locomotives briefly moved** during the yard-divergence repro (LOCO-004, LOCO-023, LOCO-025) — **all restored** to KALITY / their original yards. LOCO-004 was briefly set OUT_OF_SERVICE by the decommission test and **restored to AVAILABLE**.
No other records were mutated. (A raw-SQL restore of the original wagon UUID was intentionally **not** performed — the DB-write guard blocked it, and re-inserting via the API is the correct, app-logic-respecting cleanup.)
---
## 11. Prioritized fix roadmap
**Do first (security / money — a customer or attacker can exploit these today):**
1. C1 — remove the DEMO auto-settle in `billing.service.ts:973`.
2. C2 / H13 — auth-guard `mark-paid`, `payments/initiate`, `payments/checkout`, `payments/receipt`, and `files/:id`.
3. C3 + C4 — unify currency units and enforce amount verification at settlement.
4. C5 — enable Telebirr/D-Money webhook signature checks.
5. C6 / H10 / H11 / H12 / M24 / M25 — add ownership/permission guards to booking writes, import-operations, interchange-documents, contract-sign, incidents, tracking.
**Do next (data integrity — the consist family, one structural fix):**
6. **H1H7 + L1L2**: the `TrainLocomotive`/`wagon.trainId` coupling guard on every locomotive/wagon mutation (this is your reported bug and its whole family). Switch `DELETE` to soft-delete.
7. H8/H9 capacity & consolidation locks; H17 cancel-status guard; H18 transactional reschedule.
**Then (correctness):** M1M27 (container release, weight units, scheduling day/priority/notification bugs, OTP hardening, self-approval).
**Finally (hardening):** the L-series + the frontend dirty-field / display fixes in §7.
---
*Report generated from a live run of `pnpm run dev:freight` plus a full read of the in-scope modules. Every "live-confirmed" item has a reproduced request/response above; every code finding cites `file:line`.*
---
# ADDENDUM — Fixes Applied (2026-07-16)
All **18 High + 27 Medium** findings were fixed. Critical (C1C6) and Low (L-series) were **left untouched** per the request (except L1, which shares the H1 guard). Applied across ~35 backend files + 3 frontend files via 6 partitioned edit passes.
**Verification status**
- `turbo run type-check --filter=@edr/freight-api`**clean (0 errors)**.
- Web apps: freight-portal clean; freight-backoffice fails **only** on the pre-existing `user-management/web-Management/**` errors (documented, none in edited files).
- `scheduling-reschedule.service.spec`**5/5 pass** (updated for the H18 transaction + a future-dated fixture for the new M7 past-date guard).
- App **boots clean** on 3030; migration `2280000000000-WagonNumberPartialUnique` applied and recorded (partial unique index `UQ_wagons_wagon_number_active` verified live).
**Live-verified fixes (real requests against the running app)**
- **H1** — `PATCH /locomotives/:id` on a coupled loco → `409 "coupled to train TR-00002; move the train instead"` (yard) and `409 "detach it before changing its status"` (status). *This is your reported bug — now behaves exactly as requested.*
- **L1** — decommission coupled loco → `409`.
- **H2** — `PATCH /wagons/:id` yard on coupled wagon → `409`; sending `trainId``400 "property trainId should not exist"`.
- **H3** — `DELETE /wagons/:id` on coupled wagon → `409`, wagon **survives** (data-loss bug closed); delete now uses `softRemove`.
- **H4** — `DELETE /trains/:id` on a built train → frees wagon (AVAILABLE, unlinked) + both locos, **soft-deletes** the train (tombstone row, not stranded).
**What each pass changed (high level)**
- **Fleet (H1H7, L1):** coupling guard (`train_locomotives` / `wagon.trainId`) on every legacy loco/wagon mutation; `assignToTrain` tightened to builder rules; `bulk-status`/`bulk-transfer`/`unassign` refuse coupled/pinned wagons; hard-delete → soft-delete (+ migration); frontend `AssignWagonDialog` `||``&&`, workspace modal excludes coupled wagons.
- **Scheduling (H17, M8M10, M14, M15):** cancel status-guard; null-date sort fixed; booking-day match check; partial-unassign now releases slots + recomputes TrainSet totals; route edit blocked while live schedules reference it; capacity meter uses weakest loco.
- **Reschedule (H18, M7, M11M13):** date+event wrapped in one transaction; `newDepartureDate > now` guard; real `MaintenanceRescheduleDto` (validation no longer skipped); merged incoming-booking set; retained-customer notice only when the date actually moves; displaced-fallback clears `trainScheduleId`.
- **Bookings/Billing/Contracts (H8, H9, H12, H14H16, M26, M27):** export-capacity + consolidation now lock the row (`FOR UPDATE`) and re-check before reserving; payment idempotency guard restored (won't resurrect CANCELLED/advanced); cancel/reject expire open invoices; contract-sign requires ownership + verifies OTP against the company's registered phone; pricing honors frozen contract-rate snapshots; clearance-fee gate hard-fails instead of waiving; invoice state-machine gaps closed.
- **Cargo/Containers/Customs (M1M6, H10, H11):** container release counts fixed (exclude-self); unload frees container; load rejects over-capacity; container→wagon assign rejects double-assign + wraps position in a txn; interchange state-machine guarded; interchange item weights normalized to tons; import-operations + interchange-documents controllers now permission-guarded.
- **Ops/Auth/Files (H13, M16M25):** file download requires auth; incidents + tracking guarded; maintenance flips vehicle status/availability; fuel rejects non-positive + duplicate receipts; OTP uses CSPRNG + TTL/attempt-cap/consume-on-success, send no longer resets the brute-force counter; email strategy implemented + failures surfaced; rate/priority self-approval blocked; driver uploads size/type-limited.
**Residual / partial (flagged in code with TODOs — intentional, safe)**
- **H18** cross-service (un)assign calls still run their own transactions (can't thread the manager without editing the scheduling core); date+event are atomic.
- **M7** kept the raw date write + `>now` guard; booking-window fields are not re-derived (delegating to `updateScheduleDate` would wrongly require `PRE_WINDOW`).
- **M11** best-effort detach (clears `trainScheduleId`); the link-row/allocation delete lives in the scheduling core.
- **M13** format is enforced; `newDepartureDate` is validated-when-present but not strictly required (parent DTO marks it optional).
- **M15** meter capped at weakest loco; consist tare isn't available client-side.
- **M16** maintenance sets vehicle status; the assignment-side reject lives in the excluded first/last-mile modules (out of scope).
- **M20** counter-reset removed; per-IP/per-target throttling is a TODO (no Throttler in the codebase yet).
- **M22** self-approval blocked for normal staff; **super admins are exempt** (full backoffice authority — verified live: propose→submit→self-approve → LIVE). Both rates and priority-rule change requests. A distinct CEO/approver permission is still recommended (TODO).
- **M24** reused real `drivers.*` permission keys (no `incidents:*` key exists yet — TODO to add one).
- **M25** view-guarded; company-scoping the query is a TODO.
- **H13** download now authenticated; ownership-by-resource + signed-URL previews are the next step (inline previews that relied on anonymous access will 401 until the frontend uses `signUrl`).
- **H15** frozen rates cover base rail + surcharges + first/last-mile; a rare container-fallback line keeps the live rate.
- **M3** position race narrowed by a txn; a `(wagon_id, position)` unique index is the full fix (TODO).
**Not committed.** All changes are in the working tree only.

View File

@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Prepaid customs clearance service fee (Path B):
* - contract_rate_snapshots.is_clearance — flags the frozen CUSTOMS_CLEARANCE
* fee line so it is billed via its own clearance invoice and excluded from
* shipment booking totals;
* - contracts.clearance_fee_paid_at — when the ONE_TIME contract-level fee
* settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_CLEARANCE_DOCUMENTS);
* - bookings.clearance_fee_paid_at — when a GENERAL shipment-request instance's
* fee settled (gate: AWAITING_CLEARANCE_PAYMENT → AWAITING_DOCUMENTS).
* All nullable/defaulted — existing rows are untouched and keep today's flow.
*/
export class AddClearanceFeePayment2260000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contract_rate_snapshots
ADD COLUMN IF NOT EXISTS is_clearance BOOLEAN NOT NULL DEFAULT FALSE;
`);
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS clearance_fee_paid_at TIMESTAMPTZ;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS clearance_fee_paid_at;
`);
await queryRunner.query(`
ALTER TABLE freight.contracts
DROP COLUMN IF EXISTS clearance_fee_paid_at;
`);
await queryRunner.query(`
ALTER TABLE freight.contract_rate_snapshots
DROP COLUMN IF EXISTS is_clearance;
`);
}
}

View File

@@ -0,0 +1,64 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* EDR last-mile is multi-truck: a booking can be served by as many trucks as it
* has containers (bulk hauls until the tonnage is drawn down). Arrival/delivery
* were stamped once per `last_mile` record, so every truck shared one timestamp.
* These per-vehicle columns give each EDR truck its own arrival, leaving and
* weighed load — the same granularity self-haul trucks already have.
*
* Weights are TONNES (matching bookings.cargo_total_weight_vgm and the exit
* weighing UI). Named `*_tons` deliberately: the older
* customer_truck_assignments.gross_weight_kg is named kg but stores tonnes.
* All nullable — legacy rows predate per-truck tracking.
*/
export class AddLastMileTruckArrivalDeparture2260000000000 implements MigrationInterface {
name = 'AddLastMileTruckArrivalDeparture2260000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
ADD COLUMN IF NOT EXISTS arrived_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS departed_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS gross_weight_tons numeric(14, 3) NULL,
ADD COLUMN IF NOT EXISTS net_weight_tons numeric(14, 3) NULL
`);
// A truck carries 1x40ft OR 2x20ft, so an EDR truck needs MORE than the one
// container the legacy scalar `container_number` can hold. Mirrors the
// self-haul customer_truck_containers child table. The scalar stays in place
// (synced to the first container) for backward compatibility.
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_containers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
assignment_id uuid NOT NULL REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE CASCADE,
last_mile_id uuid NOT NULL,
container_number varchar(32) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_last_mile_vehicle_containers_assignment"
ON freight.last_mile_vehicle_containers (assignment_id)
`);
// A container rides exactly one truck per delivery.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_last_mile_vehicle_container"
ON freight.last_mile_vehicle_containers (last_mile_id, container_number)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_containers`);
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
DROP COLUMN IF EXISTS arrived_at,
DROP COLUMN IF EXISTS departed_at,
DROP COLUMN IF EXISTS gross_weight_tons,
DROP COLUMN IF EXISTS net_weight_tons
`);
}
}

View File

@@ -0,0 +1,109 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Re-seed the EDR wagon fleet onto the official ER numbering.
*
* Supersedes SeedWagonsWithYardAssignment1784000000001, which seeded 500 wagons
* on a `<CODE>-NNNN` scheme and wrote the status as 'Available' — mixed case
* that never matches WagonStatus.Available ('AVAILABLE'), so status filters
* silently returned nothing. This seed uses the enum value.
*
* Every wagon lands unassigned: current_yard_id NULL, status AVAILABLE. Wagon
* specs (capacity/length/tare) stay owned by wagon_types and are not touched —
* the types already exist and only the wagon↔type link is (re)established here.
*/
type FleetRow = {
code: string;
start: number;
end: number;
count: number;
};
/** Official fleet: 1100 wagons, ER0001ER1100, contiguous across 10 types. */
const FLEET: FleetRow[] = [
{ code: 'PW2', start: 1, end: 220, count: 220 },
{ code: 'CW4', start: 221, end: 330, count: 110 },
{ code: 'CW3', start: 331, end: 350, count: 20 },
{ code: 'KW2', start: 351, end: 370, count: 20 },
{ code: 'KW3', start: 371, end: 390, count: 20 },
{ code: 'NW5', start: 391, end: 940, count: 550 },
{ code: 'BW1', start: 941, end: 950, count: 10 },
{ code: 'GW2', start: 951, end: 1060, count: 110 },
{ code: 'NW6', start: 1061, end: 1080, count: 20 },
{ code: 'NW7', start: 1081, end: 1100, count: 20 },
];
const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`;
export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInterface {
name = 'SeedEdrWagonFleetErNumbering2260000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Full replacement: the ER range is the fleet of record, so any wagon
// outside it is stale seed data. Safe to hard-delete — containers and
// train_set_wagons null their link, wagon_movements cascade.
await queryRunner.query(`DELETE FROM freight.wagons;`);
// Wagon.wagonNumber declares `unique: true`, but some environments never got
// the constraint. Repair it here — the table is empty at this point, so the
// index build cannot fail on pre-existing duplicates.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS wagons_wagon_number_key
ON freight.wagons (wagon_number);
`);
for (const row of FLEET) {
if (row.end - row.start + 1 !== row.count) {
throw new Error(`wagon_range_mismatch:${row.code}`);
}
const [typeRecord] = await queryRunner.query(
`SELECT id FROM freight.wagon_types WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`,
[row.code],
);
if (!typeRecord?.id) {
throw new Error(`wagon_type_missing:${row.code}`);
}
// generate_series builds the range server-side — one round trip per type
// instead of 1100 individual INSERTs. No ON CONFLICT clause: every wagon
// was deleted above, so a plain INSERT cannot collide, and the clause would
// otherwise hard-require a unique index this table lacks on some envs.
await queryRunner.query(
`
INSERT INTO freight.wagons (
wagon_number,
wagon_type_id,
status,
current_yard_id,
train_id,
sequence_number,
notes,
train_set_wagon_id,
current_train_schedule_id
)
SELECT
'ER' || LPAD(seq::text, 4, '0'),
$1::uuid,
'AVAILABLE',
NULL,
NULL,
NULL,
NULL,
NULL,
NULL
FROM generate_series($2::int, $3::int) AS seq;
`,
[typeRecord.id, row.start, row.end],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM freight.wagons WHERE wagon_number BETWEEN $1 AND $2;`,
[wagonNumber(FLEET[0].start), wagonNumber(FLEET[FLEET.length - 1].end)],
);
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds freight.booking_container.return_quantity — how many units of a
* container line ship with the empty-container-return service (≤ quantity).
* Mirrors hazardous_quantity / reefer_quantity: captured per line at booking
* creation when the contract enables WITH_RETURN (container freight only) and
* drives the booking-level equipment_return flag that fires the WITH_RETURN
* pricing surcharge.
*/
export class AddContainerReturnQuantity2270000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_container
ADD COLUMN IF NOT EXISTS return_quantity SMALLINT NOT NULL DEFAULT 0;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_container
DROP COLUMN IF EXISTS return_quantity;
`);
}
}

View File

@@ -0,0 +1,70 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Wagons are now soft-deleted (deleted_at) instead of hard-deleted. The plain
* UNIQUE on wagon_number would keep a retired wagon's number reserved forever
* and block ever re-registering that number. Swap it for a PARTIAL unique index
* that only constrains live rows (deleted_at IS NULL); soft-deleted wagons no
* longer occupy their number.
*
* NOTE: the shared dev DB has no applied migration history, so this is also
* hand-applied there. The DO blocks + IF EXISTS/IF NOT EXISTS keep it
* idempotent whether the original uniqueness is the auto-named column
* constraint (wagons_wagon_number_key) or a TypeORM-named UQ_* constraint/index.
*/
export class WagonNumberPartialUnique2280000000000 implements MigrationInterface {
name = 'WagonNumberPartialUnique2280000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Drop any UNIQUE constraint on freight.wagons(wagon_number), whatever it is
// named (dropping the constraint also drops its backing index).
await queryRunner.query(`
DO $$
DECLARE con_name text;
BEGIN
FOR con_name IN
SELECT conname
FROM pg_constraint
WHERE conrelid = 'freight.wagons'::regclass
AND contype = 'u'
AND pg_get_constraintdef(oid) ILIKE '%(wagon_number)%'
LOOP
EXECUTE format('ALTER TABLE freight.wagons DROP CONSTRAINT IF EXISTS %I', con_name);
END LOOP;
END $$;
`);
// Drop any standalone (non-partial) unique index on wagon_number too.
await queryRunner.query(`
DO $$
DECLARE idx_name text;
BEGIN
FOR idx_name IN
SELECT c.relname
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indrelid = 'freight.wagons'::regclass
AND i.indisunique
AND i.indpred IS NULL
AND c.relname <> 'UQ_wagons_wagon_number_active'
AND pg_get_indexdef(i.indexrelid) ILIKE '%(wagon_number)%'
LOOP
EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx_name);
END LOOP;
END $$;
`);
// Live wagon numbers stay unique; soft-deleted rows are exempt.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_wagons_wagon_number_active"
ON freight.wagons (wagon_number)
WHERE deleted_at IS NULL;
`);
}
public async down(): Promise<void> {
// No-op: re-adding a plain UNIQUE would fail whenever two soft-deleted
// wagons share a number, and the partial index is strictly safer. Left in
// place intentionally.
}
}

View File

@@ -376,4 +376,97 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
expect(result).toBeNull();
expect(transaction).not.toHaveBeenCalled();
});
it("also retires a DRAFT invoice — a superseded/cancelled source must not leave one behind", async () => {
const { service, defaultManager } = build({
...openInvoice,
status: Freight.InvoiceStatus.Draft,
});
await service.expirePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"prepaid",
);
const { where } = defaultManager.findOne.mock.calls[0][1];
expect(where.status.value).toContain(Freight.InvoiceStatus.Draft);
});
});
describe("BillingService.issuePayable", () => {
const dueAt = new Date("2026-01-02T00:00:00.000Z");
const build = (found: Record<string, unknown> | null) => {
const manager = {
findOne: jest.fn().mockResolvedValue(found),
update: jest.fn().mockResolvedValue(undefined),
};
const service = new BillingService(
{ manager, transaction: jest.fn() } as never,
{} as never,
{} as never,
makeEvents() as never,
{} as never,
{} as never,
{} as never,
);
return { service, manager };
};
const issue = (service: BillingService) =>
service.issuePayable(
Freight.InvoiceSource.Booking,
"booking-1",
dueAt,
"PREPAID",
);
it("issues a DRAFT invoice to PENDING, stamping issuedAt and the pay-window dueAt", async () => {
const { service, manager } = build({
id: "inv-1",
invoiceNumber: "INV-20260101-00001",
status: Freight.InvoiceStatus.Draft,
issuedAt: null,
});
const result = await issue(service);
const patch = manager.update.mock.calls[0][2];
expect(patch.status).toBe(Freight.InvoiceStatus.Pending);
expect(patch.dueAt).toBe(dueAt);
expect(patch.issuedAt).toBeInstanceOf(Date);
expect(result?.status).toBe(Freight.InvoiceStatus.Pending);
});
it("looks up DRAFT invoices — a booking's invoice is minted DRAFT and this is what makes it payable", async () => {
const { service, manager } = build(null);
await issue(service);
const { where } = manager.findOne.mock.calls[0][1];
expect(where.status.value).toContain(Freight.InvoiceStatus.Draft);
});
it("only refreshes dueAt on an already-issued invoice, so a re-reserve never re-issues", async () => {
const issuedAt = new Date("2026-01-01T00:00:00.000Z");
const { service, manager } = build({
id: "inv-1",
invoiceNumber: "INV-20260101-00001",
status: Freight.InvoiceStatus.Pending,
issuedAt,
});
const result = await issue(service);
expect(manager.update.mock.calls[0][2]).toEqual({ dueAt });
expect(result?.issuedAt).toBe(issuedAt);
});
it("is a no-op (returns null, writes nothing) when the source has no draft-or-open invoice", async () => {
const { service, manager } = build(null);
await expect(issue(service)).resolves.toBeNull();
expect(manager.update).not.toHaveBeenCalled();
});
});

View File

@@ -125,7 +125,7 @@ export class BillingService {
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService,
) {}
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -432,7 +432,7 @@ export class BillingService {
input.dueAt ??
new Date(
Date.now() +
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
);
const invoiceNumber = await this.nextInvoiceNumber(mg);
@@ -602,6 +602,19 @@ export class BillingService {
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException("Invoice is already fully paid.");
}
// M27: a Draft invoice is not yet issued and an Expired invoice's pay
// window has closed — neither is payable. Without these guards a payment
// could settle an unissued draft or a lapsed invoice.
if (invoice.status === Freight.InvoiceStatus.Draft) {
throw new BadRequestException(
"Cannot pay a draft invoice — it must be issued first.",
);
}
if (invoice.status === Freight.InvoiceStatus.Expired) {
throw new BadRequestException(
"Cannot pay an expired invoice — its payment window has closed.",
);
}
if (round2(input.amount) > Number(invoice.balanceAmount)) {
throw new BadRequestException(
`Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`,
@@ -813,8 +826,15 @@ export class BillingService {
* Expire a source's currently-open invoice (its pay window closed before
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
* `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice
* (already paid/cancelled/expired).
* `OPEN_STATUSES`). No-op (returns null) when the source has no invoice left to
* retire (already paid/cancelled/expired).
*
* DRAFT invoices are matched too, even though they were never issued: this is
* also the "retire the invoice this source no longer needs" path (a cancelled
* booking, or a full-amount invoice superseded by a partial-offer one). Skipping
* drafts would leave the stale one behind for `findPayable` to hand back — the
* superseding invoice would then never be minted, and a cancelled booking would
* keep a draft that a later `issuePayable` could still make payable.
*
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
* the batch engine) to enlist in its DB transaction.
@@ -837,7 +857,7 @@ export class BillingService {
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
@@ -854,30 +874,58 @@ export class BillingService {
}
/**
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
* booking invoice is generated before the pay window opens (at booking
* creation/approval), so its printed due date is refreshed when the batch engine
* sets `paymentDeadline`. No-op when the source has no open invoice.
* Issue a source's invoice and stamp its real pay-window deadline — the single
* transition that makes a source payable.
*
* A source's invoice is minted DRAFT, before any pay window exists (e.g. a
* booking invoice is generated at creation / operation-accept, long before the
* batch engine reserves a slot). DRAFT is deliberately outside `OPEN_STATUSES`,
* so such an invoice is not settleable and the portal renders no pay button.
* The domain calls this at the moment the pay window actually opens (booking →
* `reserve`, which sets SELECTED_FOR_BATCH + `paymentDeadline`), which issues
* the draft (→ PENDING, stamping `issuedAt`) and prints the real `dueAt`.
*
* Idempotent: an already-issued open invoice only has its `dueAt` refreshed, so
* a re-reserve never re-issues. No-op (returns null) when the source has no
* draft-or-open invoice (already paid/cancelled/expired).
*/
async syncPayableDueDate(
async issuePayable(
source: Freight.InvoiceSource,
sourceId: string,
dueAt: Date,
type?: string,
manager?: EntityManager,
): Promise<void> {
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
});
if (!invoice) return;
await mg.update(Invoice, { id: invoice.id }, { dueAt });
if (!invoice) return null;
const issuing = invoice.status === Freight.InvoiceStatus.Draft;
const patch = {
dueAt,
...(issuing
? {
status: Freight.InvoiceStatus.Pending,
issuedAt: invoice.issuedAt ?? new Date(),
}
: {}),
};
await mg.update(Invoice, { id: invoice.id }, patch);
if (issuing) {
this.logger.log(
`Issued invoice ${invoice.invoiceNumber} (${invoice.id}) for ${source}:${sourceId} — payable until ${dueAt.toISOString()}`,
);
}
return { ...invoice, ...patch } as Invoice;
}
/**
@@ -891,6 +939,20 @@ export class BillingService {
status: Freight.InvoiceStatus,
manager?: EntityManager,
): Promise<void> {
// M27: this is the blunt "issue a draft" override — it stamps `issuedAt` but
// does NOT touch paidAmount/balanceAmount. Its only legitimate use is the
// Draft → Pending/Issued issue transition. It must NEVER mark an invoice
// Paid/Refunded/Cancelled/Expired (or PartiallyPaid/Overdue): those carry
// balance implications and must go through the dedicated settlement methods
// (recordPayment / markInvoiceAsRefunded / cancelInvoice / expirePayable).
if (
status !== Freight.InvoiceStatus.Pending &&
status !== Freight.InvoiceStatus.Issued
) {
throw new BadRequestException(
`updateStatus only issues an invoice (→ PENDING/ISSUED); use the dedicated settlement methods to set ${status}.`,
);
}
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: {
@@ -964,26 +1026,26 @@ export class BillingService {
returnUrl: opts.returnUrl,
failureUrl: opts.failureUrl,
});
//
//
// Link the intent to the invoice BEFORE any settlement can correlate against it.
await this.dataSource
.getRepository(Invoice)
.update({ id: invoice.id }, { paymentId: result.intentId });
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
if (!result.immediateSuccess) {
await this.payment.handlePaymentEvent({
eventType: "payment.succeeded",
eventId: `demo-${result.intentId}`,
referenceId: invoice.sourceId,
intentId: result.intentId,
providerTxnId: result.providerTxnId,
paidAt: (result.paidAt ?? new Date()).toISOString(),
});
}
// // DEMO: manually fire the gateway `payment.succeeded` callback here, without
// // waiting for real gateway settlement. Runs AFTER the paymentId link above so
// // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
// // remove — real settlement flips this via the `${source}.invoice.paid` handler.
// if (!result.immediateSuccess) {
// await this.payment.handlePaymentEvent({
// eventType: "payment.succeeded",
// eventId: `demo-${result.intentId}`,
// referenceId: invoice.sourceId,
// intentId: result.intentId,
// providerTxnId: result.providerTxnId,
// paidAt: (result.paidAt ?? new Date()).toISOString(),
// });
// }
if (result.immediateSuccess) {
await this.settleByPaymentId(

View File

@@ -103,8 +103,35 @@ export class PaymentController {
}
}
/**
* HTML-escape a value interpolated into the public checkout pages. These
* pages are served unauthenticated and the interpolated values (provider
* error messages, status strings, intent ids, redirect URLs) can carry
* attacker-influenced input — unescaped they are a reflected-XSS sink.
*/
private escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/\"/g, "&quot;");
// Only http(s) URLs may be used as a redirect target — a javascript:
// URL would execute in the victim's browser from the <a>/location.href.
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return this.buildErrorHtml("Invalid payment redirect URL");
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return this.buildErrorHtml("Invalid payment redirect URL");
}
const escaped = this.escapeHtml(url);
const jsEscaped = JSON.stringify(url);
return `<!DOCTYPE html>
<html lang="en">
<head>
@@ -126,12 +153,14 @@ export class PaymentController {
<p>Redirecting to payment provider…</p>
<p><a href="${escaped}">Click here if you are not redirected</a></p>
</div>
<script>window.location.href = "${escaped}";</script>
<script>window.location.href = ${jsEscaped};</script>
</body>
</html>`;
}
private buildStatusHtml(status: string, intentId: string): string {
private buildStatusHtml(rawStatus: string, rawIntentId: string): string {
const status = this.escapeHtml(rawStatus);
const intentId = this.escapeHtml(rawIntentId);
return `<!DOCTYPE html>
<html lang="en">
<head>
@@ -153,7 +182,8 @@ export class PaymentController {
</html>`;
}
private buildErrorHtml(message: string): string {
private buildErrorHtml(rawMessage: string): string {
const message = this.escapeHtml(rawMessage);
return `<!DOCTYPE html>
<html lang="en">
<head>

View File

@@ -119,6 +119,26 @@ export class BookingInvoiceService {
return this.billing.updateStatus(invoiceId, status, manager);
}
/**
* Expire the booking's currently-open prepaid invoice when the booking is
* cancelled or rejected — the counterpart to the pay-window-expiry path
* (which also calls {@link BillingService.expirePayable}). Stops a terminated
* booking from leaving a payable invoice open. No-op when the booking has no
* open invoice (never invoiced, already paid/cancelled/expired). Pass a
* caller `manager` to enlist in its transaction.
*/
expireOpenInvoices(
bookingId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
return this.billing.expirePayable(
Freight.InvoiceSource.Booking,
bookingId,
"PREPAID",
manager,
);
}
/**
* Advance a booking once its prepaid invoice settles — the domain side-effect
* of payment, relocated out of the payment service: the booking becomes PAID
@@ -138,7 +158,32 @@ export class BookingInvoiceService {
);
return;
}
// if (booking.paymentStatus === "PAID") return;
// Idempotency + state-machine guard (restored). The prepaid-invoice paid
// event can be delivered more than once (retries / re-emit), and a booking
// may have moved on or been terminated between invoicing and settlement.
// Only advance one that is still awaiting payment: no-op when already PAID,
// and refuse to advance a booking in a terminal/advanced status
// (CANCELLED/REJECTED/EXPIRED or already past the payment gate) so we never
// rewrite its status or re-run allocation.
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
return;
}
const TERMINAL_OR_ADVANCED_STATUSES: string[] = [
"CANCELLED",
"REJECTED",
"EXPIRED",
"IN_TRANSIT",
"ARRIVED",
"COMPLETED",
"CONTRACT_CLOSED",
];
if (TERMINAL_OR_ADVANCED_STATUSES.includes(booking.status)) {
this.logger.warn(
`Skipping advance of booking ${bookingId} on payment: status ${booking.status} is terminal/advanced.`,
);
return;
}
await this.dataSource.transaction(async (mg) => {
await mg.update(

View File

@@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RatesService } from '../rule-engine/services/rates.service';
import { Rate } from '../rule-engine/entities/rate.entity';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ExchangeService } from '@edr/api-common';
import {
AppliedCargoModifier,
@@ -128,11 +129,18 @@ export class BookingPricingService {
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
// H15: a booking created under a contract prices from that contract's FROZEN
// rate snapshots (the agreed rates), not the live rate of the day. Loaded
// once and threaded through the line builders; each rate code that has a
// snapshot uses it, and any code without one falls back to the live rate.
// Non-contract bookings resolve to null and keep the live-rate path.
const frozenRates = await this.loadFrozenContractRates(booking);
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const { lineItems: baseLines, usedRates: baseRates } =
await this.computeBaseRailLinesWithRates(booking, evalInput);
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
@@ -141,7 +149,7 @@ export class BookingPricingService {
// First / last mile trucking — billed per the rate's unit (km / container /
// ton / flat), only for legs the booking actually carries.
const { lineItems: mileLines, usedRates: mileRates } =
await this.computeFirstLastMileLines(booking, evalInput);
await this.computeFirstLastMileLines(booking, evalInput, frozenRates);
for (const line of mileLines) {
lineItems.push(line);
total += line.amount;
@@ -153,15 +161,14 @@ export class BookingPricingService {
for (const mod of ruleResult.appliedModifiers) {
const usdAmount = mod.calculatedAmount;
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const rate = rateById.get(mod.rateId);
const unit = rate?.rateUnit ?? 'FLAT';
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
// explicit trigger (e.g. overweight tons) wins when present; otherwise
// derive from total ÷ unit price.
// derive from total ÷ unit price (the live unit price — a count, not a
// currency amount, so it is snapshot-independent).
const quantity =
unit === 'FLAT' || unit === 'PER_INVOICE'
? 1
@@ -171,6 +178,26 @@ export class BookingPricingService {
? Math.max(1, Math.round(usdAmount / unitUsd))
: 1;
// H15: bill the frozen contract surcharge rate (already in the booking
// currency) when this code has a snapshot; else keep the live amount.
const frozen = this.frozenRateByCode(
frozenRates,
mod.surchargeCode,
paymentCurrency,
);
const unitAmount = frozen
? Number(frozen.unitPrice)
: isEtbBooking
? Math.round(unitUsd * usdToEtb)
: unitUsd;
const convertedAmount = frozen
? isEtbBooking
? Math.round(unitAmount * quantity)
: unitAmount * quantity
: isEtbBooking
? Math.round(usdAmount * usdToEtb)
: usdAmount;
const item: PriceLineItemDto = {
code: mod.surchargeCode,
description: surchargeLabel(mod.surchargeCode),
@@ -328,6 +355,11 @@ export class BookingPricingService {
// reefer quantity) applies the REEFER surcharge even for non-reefer
// container types. ORed with per-container reefer in the engine.
isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true',
// Empty-container return service (container freight only) — bills the
// WITH_RETURN surcharge per container, like hazard/reefer.
withReturn:
booking.freightType === 'CONTAINER' &&
booking.equipmentReturn === 'WITH_RETURN',
isGovernment: booking.isGovernment,
allowConsolidation,
shippingLineId: booking.shippingLineId,
@@ -419,6 +451,7 @@ export class BookingPricingService {
private async computeBaseRailLinesWithRates(
booking: Booking,
evalInput: BookingEvaluationInput,
frozenRates: Map<string, ContractRateSnapshot> | null = null,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
const liveRates = await this.ratesService.findLiveRates();
const paymentCurrency = booking.paymentCurrency;
@@ -448,15 +481,35 @@ export class BookingPricingService {
if (!rate) continue;
usedRatesMap.set(rate.id, rate);
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const unitUsd = Number(rate.rateValue);
// H15: frozen contract rate for this container size, when present — its
// unitPrice is already in the booking currency (no USD→currency convert).
const frozen = await this.frozenRateForContainer(
frozenRates,
container.containerTypeId,
paymentCurrency,
);
let amount: number;
let unitAmount: number;
if (frozen) {
unitAmount = Number(frozen.unitPrice);
amount = this.amountForUnit(
rate.rateUnit,
unitAmount,
container.quantity,
wagonCount,
);
} else {
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
}
const label = await this.containerTypeLabel(container.containerTypeId);
lines.push({
code: rateType,
description: `${label} rail freight`,
amount,
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
unitAmount,
unit: rate.rateUnit,
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount),
currency: paymentCurrency,
@@ -472,14 +525,31 @@ export class BookingPricingService {
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
const quantity =
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const unitUsd = Number(fallback.rateValue);
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
const frozen = isBulk
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency)
: null;
let amount: number;
let unitAmount: number;
if (frozen) {
unitAmount = Number(frozen.unitPrice);
amount = this.amountForUnit(
fallback.rateUnit,
unitAmount,
quantity,
wagonCount,
);
} else {
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
}
lines.push({
code: rateType,
description: isBulk ? 'Bulk rail freight' : 'Container rail freight',
amount,
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
unitAmount,
unit: fallback.rateUnit,
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
currency: paymentCurrency,
@@ -503,6 +573,7 @@ export class BookingPricingService {
private async computeFirstLastMileLines(
booking: Booking,
evalInput: BookingEvaluationInput,
frozenRates: Map<string, ContractRateSnapshot> | null = null,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
const legs: Array<{ rateType: 'FIRST_MILE' | 'LAST_MILE'; label: string; active: boolean }> = [
{
@@ -560,18 +631,34 @@ export class BookingPricingService {
break;
}
const usdAmount = value * quantity;
// H15: frozen mile rate (already in booking currency) when the contract
// has one; else the live USD rate converted as before.
const frozen = this.frozenRateByCode(
frozenRates,
leg.rateType,
paymentCurrency,
);
let amount: number;
let unitAmount: number;
if (frozen) {
unitAmount = Number(frozen.unitPrice);
amount = isEtbBooking
? Math.round(unitAmount * quantity)
: unitAmount * quantity;
} else {
const usdAmount = value * quantity;
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value;
}
// Skip legs that resolve to nothing (zero rate, or zero km / count / tons).
if (!(usdAmount > 0)) continue;
if (!(amount > 0)) continue;
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const unitUsd = value;
usedRatesMap.set(rate.id, rate);
lines.push({
code: leg.rateType,
description: leg.label,
amount,
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
unitAmount,
unit: rate.rateUnit,
quantity,
currency: paymentCurrency,
@@ -644,21 +731,93 @@ export class BookingPricingService {
}
private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
const value = Number(rate.rateValue);
switch (rate.rateUnit) {
return this.amountForUnit(
rate.rateUnit,
Number(rate.rateValue),
quantity,
wagonCount,
);
}
/** Apply a unit value by rate unit — shared by live and frozen-snapshot lines. */
private amountForUnit(
rateUnit: string,
unitValue: number,
quantity: number,
wagonCount: number,
): number {
switch (rateUnit) {
case 'PER_CONTAINER':
return value * quantity;
return unitValue * quantity;
case 'PER_WAGON':
return value * wagonCount;
return unitValue * wagonCount;
case 'PER_TON':
return value * quantity;
return unitValue * quantity;
case 'FLAT':
return value;
return unitValue;
default:
return value * quantity;
return unitValue * quantity;
}
}
// ── H15: frozen contract rate snapshots ────────────────────────────────────
/**
* Load a contract's frozen rate snapshots into a by-rate-code lookup, or null
* for a non-contract booking (or a contract with no snapshots). The pricing
* line builders prefer a matching snapshot's unit price over the live rate.
*/
private async loadFrozenContractRates(
booking: Booking,
): Promise<Map<string, ContractRateSnapshot> | null> {
if (!booking.contractId) return null;
const snapshots = await this.bookingsRepository.findContractRateSnapshots(
booking.contractId,
);
if (!snapshots.length) return null;
const byCode = new Map<string, ContractRateSnapshot>();
for (const snap of snapshots) byCode.set(snap.rateCode, snap);
return byCode;
}
/**
* The frozen snapshot for a rate code, or null when there is none, its price
* is negative, or it is in a different currency than the booking (in which
* case the live-rate path is safer than a mis-converted frozen price).
*/
private frozenRateByCode(
frozenRates: Map<string, ContractRateSnapshot> | null,
code: string,
bookingCurrency: string,
): ContractRateSnapshot | null {
const snap = frozenRates?.get(code);
if (!snap) return null;
if (snap.currency !== bookingCurrency) return null;
if (!(Number(snap.unitPrice) >= 0)) return null;
return snap;
}
/**
* The frozen base-rail snapshot for a container line, matched by the
* container's size (CONTAINER_20FT / CONTAINER_40FT — the codes
* ContractPricingService freezes). Null when there is no snapshot.
*/
private async frozenRateForContainer(
frozenRates: Map<string, ContractRateSnapshot> | null,
containerTypeId: string,
bookingCurrency: string,
): Promise<ContractRateSnapshot | null> {
if (!frozenRates) return null;
let sizeFt: number | null = null;
try {
sizeFt = Number((await this.containerTypesService.findById(containerTypeId)).sizeFt) || null;
} catch {
return null;
}
if (!sizeFt) return null;
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
}
private lineItemsSignature(items: PriceLineItemDto[]): string {
return JSON.stringify(
[...items]

View File

@@ -1,5 +1,6 @@
import {
BadRequestException,
ConflictException,
forwardRef,
Inject,
Injectable,
@@ -32,8 +33,6 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
import { ContractDocPhase } from '@edr/types';
import { Freight } from "@edr/types";
import { BookingInvoiceService } from "./booking-invoice.service";
@Injectable()
@@ -533,6 +532,10 @@ export class BookingTransitionService {
"REJECTION",
);
// Stop the open-invoice leak: a cancelled booking must not leave a payable
// invoice open. Mirror the pay-window-expiry path (billing.expirePayable).
await this.invoiceService.expireOpenInvoices(bookingId);
const updated = await this.bookingsRepository.update(bookingId, {
status: "CANCELLED",
} as never);
@@ -561,6 +564,10 @@ export class BookingTransitionService {
"REJECTION",
);
// Stop the open-invoice leak: a rejected booking must not leave a payable
// invoice open. Mirror the pay-window-expiry path (billing.expirePayable).
await this.invoiceService.expireOpenInvoices(bookingId);
const updated = await this.bookingsRepository.update(bookingId, {
status: "REJECTED",
} as never);
@@ -714,6 +721,11 @@ export class BookingTransitionService {
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (booking.status === "AWAITING_CLEARANCE_PAYMENT") {
throw new ConflictException(
"The customs clearance service fee for this shipment has not been paid yet — pay it from the portal to unlock document upload.",
);
}
assertBookingStatus(booking, [
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
@@ -1098,14 +1110,25 @@ export class BookingTransitionService {
await this.bookingBatchService.pickExportSchedule(booking);
}
// Mint the booking's invoice (DRAFT) so the priced order carries its billing
// record from accept onward. It is deliberately NOT issued here: accepting an
// operation only puts the booking in the batch holding pool — no slot has been
// offered and no pay window exists yet. Issuing at this point made the invoice
// payable straight away (portal invoice list/detail gate on invoice status
// alone), letting a customer pay before being selected for a batch, while the
// booking page correctly still showed it as not payable. The batch engine
// issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window
// and the real deadline are created — matching the portal's `canPay` gate.
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
this.logger.log(
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
);
await this.invoiceService.updateStatus(
invoice.id,
Freight.InvoiceStatus.Pending,
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
);
// TODO: road (truck) orders are an incomplete feature — they stop at the
// dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no
// per-km pricing wired via roadKmPrice, no pay surface in the portal). They
// skip the train batch, so they never reach `reserve` and their invoice stays
// DRAFT / unpayable. When the road flow is built, issue its invoice
// (billing.issuePayable) at whatever transition opens the road pay window.
if (isRoadService(booking.serviceType)) {
await this.bookingsRepository.update(booking.id, {
status: "ROAD_DISPATCH_PENDING",

View File

@@ -6,6 +6,7 @@ import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQuer
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
@@ -200,6 +201,19 @@ export class BookingsRepository extends BaseRepository<Booking> {
return Number(route?.km ?? 0);
}
/**
* Frozen contract unit-rate snapshots for a contract (H15). A booking created
* under a contract prices from these agreed, frozen rates rather than the live
* rate of the day; the pricing service matches them by rate code.
*/
findContractRateSnapshots(
contractId: string,
): Promise<ContractRateSnapshot[]> {
return this.dataSource
.getRepository(ContractRateSnapshot)
.find({ where: { contractId } });
}
/**
* Find another booking whose container quantity complements this one to fill whole wagon(s)
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
@@ -217,10 +231,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
quantity: number;
containersPerWagon: number;
},
manager?: EntityManager,
): Promise<Booking | null> {
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
const qb = this.repository
const repo = manager ? manager.getRepository(Booking) : this.repository;
const qb = repo
.createQueryBuilder('b')
.innerJoinAndSelect('b.bookingContainers', 'bc')
.innerJoin('bc.containerType', 'ct')
@@ -257,7 +273,18 @@ export class BookingsRepository extends BaseRepository<Booking> {
);
}
return qb.orderBy('b.createdAt', 'ASC').getOne();
qb.orderBy('b.createdAt', 'ASC');
// H9: under the caller's transaction, take a write lock on the matched
// partner booking row (FOR UPDATE OF b — booking rows only, not the joined
// reference tables) so a concurrent consolidation cannot claim the same
// partner between this find and the pair write. Only when a transaction
// manager is supplied — a pessimistic lock requires an open transaction.
if (manager) {
qb.setLock('pessimistic_write', undefined, ['b']);
}
return qb.getOne();
}
/** Try each partial-wagon line until a complementary partner booking is found. */
@@ -268,9 +295,14 @@ export class BookingsRepository extends BaseRepository<Booking> {
quantity: number;
containersPerWagon: number;
}>,
manager?: EntityManager,
): Promise<Booking | null> {
for (const slot of slots) {
const partner = await this.findComplementaryConsolidationPartner(booking, slot);
const partner = await this.findComplementaryConsolidationPartner(
booking,
slot,
manager,
);
if (partner) return partner;
}
return null;
@@ -308,6 +340,63 @@ export class BookingsRepository extends BaseRepository<Booking> {
} as never);
}
/**
* Race-safe pairing (H9): the transactional counterpart of
* {@link pairConsolidation}. Must run inside the caller's transaction
* (`manager`), which should already hold the partner-row write lock taken by
* {@link findComplementaryConsolidationPartner}. Re-reads both rows and
* re-asserts `consolidationPartnerId IS NULL` on each before writing; returns
* `false` (no write) when either booking was already paired by a concurrent
* flow, so the caller can fall back to parking.
*/
async pairConsolidationIfUnpaired(
bookingId: string,
partnerId: string,
manager: EntityManager,
): Promise<boolean> {
const repo = manager.getRepository(Booking);
// Sequential (one connection per transaction) — never Promise.all here.
const booking = await repo.findOne({
where: { id: bookingId },
select: {
id: true,
consolidationPartnerId: true,
consolidationResumeStatus: true,
},
});
const partner = await repo.findOne({
where: { id: partnerId },
select: {
id: true,
consolidationPartnerId: true,
consolidationResumeStatus: true,
},
});
// Re-assert both are still unpaired before writing (the partner row is held
// under the finder's write lock, so its state is stable here).
if (
!booking ||
!partner ||
booking.consolidationPartnerId != null ||
partner.consolidationPartnerId != null
) {
return false;
}
await repo.update(bookingId, {
consolidationPartnerId: partnerId,
status: booking.consolidationResumeStatus ?? 'SUBMITTED',
consolidationResumeStatus: null,
} as never);
await repo.update(partnerId, {
consolidationPartnerId: bookingId,
status: partner.consolidationResumeStatus ?? 'SUBMITTED',
consolidationResumeStatus: null,
} as never);
return true;
}
/**
* Park a booking that needs consolidation but has no partner yet. The optional
* resumeStatus is where the booking returns once it pairs — pass it for a

View File

@@ -508,13 +508,28 @@ export class BookingsService {
return { booking, messages };
}
const partner = await this.bookingsRepository.findConsolidationPartner(
booking,
slots,
);
// H9: find + pair must be atomic. Run both inside one transaction where the
// finder holds a write lock on the candidate partner row and pairing
// re-asserts both rows are still unpaired before writing — otherwise two
// concurrent bookings can claim the same partner (or pair an
// already-paired booking). `didPair` is false when a concurrent flow won
// the partner, in which case we fall through to parking below.
const partner = await this.dataSource.transaction(async (manager) => {
const candidate = await this.bookingsRepository.findConsolidationPartner(
booking,
slots,
manager,
);
if (!candidate) return null;
const didPair = await this.bookingsRepository.pairConsolidationIfUnpaired(
booking.id,
candidate.id,
manager,
);
return didPair ? candidate : null;
});
if (partner) {
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
const paired = await this.findById(booking.id);
messages.push(
this.consolidationService.describePaired(partner.reference, slots),

View File

@@ -41,6 +41,10 @@ export class BookingContainer extends BaseEntity {
@Column({ name: 'reefer_quantity', type: 'smallint', default: 0 })
reeferQuantity!: number;
/** How many units of this line ship with empty-container return (≤ quantity). */
@Column({ name: 'return_quantity', type: 'smallint', default: 0 })
returnQuantity!: number;
@Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 })
vgmPerUnitTons!: number;

View File

@@ -46,6 +46,7 @@ export const BOOKING_STATUSES = [
'CONTRACT_ACTIVE',
'CONTRACT_CLOSED',
// Post counter-sign document-clearance gate (GL workflow).
'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
@@ -521,6 +522,10 @@ export class Booking extends BaseEntity {
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
clearanceCurrentPhase?: string | null;
/** When the prepaid customs clearance service fee settled (GENERAL + customs). */
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
clearanceFeePaidAt?: Date | null;
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
dutyRequired?: boolean | null;

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
import { FindOptionsOrder, FindOptionsWhere, ILike, Not, Repository } from 'typeorm';
import { CreateCargoDto } from './dto/create-cargo.dto';
import { UpdateCargoDto } from './dto/update-cargo.dto';
import { LoadCargoDto } from './dto/load-cargo.dto';
@@ -32,6 +32,7 @@ export class CargoesService {
if (!container) {
throw new NotFoundException(`Container ${dto.containerId} not found`);
}
await this.assertContainerCapacity(container, dto.weight);
if (dto.cargoTypeId) {
const cargoType = await this.cargoTypeRepo.findOne({
@@ -121,6 +122,10 @@ export class CargoesService {
throw new ConflictException('Cargo already loaded or delivered');
}
if (cargo.container) {
await this.assertContainerCapacity(cargo.container, dto.weight, cargo.id);
}
cargo.status = 'LOADED';
cargo.loadedAt = new Date();
cargo.quantity = dto.quantity;
@@ -137,13 +142,31 @@ export class CargoesService {
}
async unloadCargo(id: string): Promise<Cargo> {
const cargo = await this.findById(id);
const cargo = await this.cargoRepo.findOne({
where: { id },
relations: { container: true },
});
if (!cargo) throw new NotFoundException('Cargo not found');
if (cargo.status !== 'LOADED') {
throw new ConflictException('Cargo is not loaded');
}
cargo.status = 'UNLOADED';
cargo.unloadedAt = new Date();
return this.cargoRepo.save(cargo);
const saved = await this.cargoRepo.save(cargo);
// loadCargo flips the container to LOADED; on unload, free it back to
// AVAILABLE once no other LOADED cargo still references the container.
if (cargo.containerId != null && cargo.container) {
const remaining = await this.cargoRepo.count({
where: { containerId: cargo.containerId, status: 'LOADED', id: Not(cargo.id) },
});
if (remaining === 0) {
cargo.container.status = 'AVAILABLE';
await this.containerRepo.save(cargo.container);
}
}
return saved;
}
async deliverCargo(id: string, dto?: DeliverCargoDto): Promise<Cargo> {
@@ -161,10 +184,13 @@ export class CargoesService {
if (dto?.receiverName) cargo.receiverName = dto.receiverName;
if (dto?.deliveryRemarks) cargo.deliveryRemarks = dto.deliveryRemarks;
// Exclude the cargo being delivered — it is still LOADED in the DB until the
// save below, so counting it would keep `remaining` > 0 and never free the
// container.
const remaining =
cargo.containerId != null
? await this.cargoRepo.count({
where: { containerId: cargo.containerId, status: 'LOADED' },
where: { containerId: cargo.containerId, status: 'LOADED', id: Not(cargo.id) },
})
: 0;
if (remaining === 0 && cargo.container) {
@@ -174,4 +200,34 @@ export class CargoesService {
return this.cargoRepo.save(cargo);
}
/**
* Reject when placing `newWeightKg` on the container would exceed its max gross
* weight. All values are kilograms: cargoes.weight is kg (entity), and the
* container's tare_weight / max_gross_weight are kg (entity). Capacity check is
* tare + already-LOADED cargo + new cargo <= max gross weight.
*/
private async assertContainerCapacity(
container: Container,
newWeightKg: number,
excludeCargoId?: string,
): Promise<void> {
const qb = this.cargoRepo
.createQueryBuilder('c')
.select('COALESCE(SUM(c.weight), 0)', 'sum')
.where('c.containerId = :containerId', { containerId: container.id })
.andWhere('c.status = :status', { status: 'LOADED' });
if (excludeCargoId) qb.andWhere('c.id != :excludeCargoId', { excludeCargoId });
const raw = await qb.getRawOne<{ sum: string }>();
const loadedKg = Number(raw?.sum ?? 0);
const tareKg = Number(container.tareWeight);
const maxGrossKg = Number(container.maxGrossWeight);
if (tareKg + loadedKg + newWeightKg > maxGrossKg) {
throw new BadRequestException(
`Cargo weight exceeds container capacity: tare ${tareKg}kg + loaded ${loadedKg}kg + ` +
`new ${newWeightKg}kg > max gross ${maxGrossKg}kg`,
);
}
}
}

View File

@@ -1,7 +1,7 @@
// apps/edr-freight-api/src/modules/container-management/containers.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
import { DataSource, FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
import { CreateContainerDto } from './dto/create-container.dto';
import { UpdateContainerDto } from './dto/update-container.dto';
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
@@ -18,6 +18,7 @@ export class ContainersService {
private readonly wagonRepo: Repository<Wagon>, // ✅ use raw repository
@InjectRepository(ContainerType)
private readonly containerTypeRepo: Repository<ContainerType>,
private readonly dataSource: DataSource,
) {}
async create(dto: CreateContainerDto): Promise<Container> {
@@ -115,24 +116,42 @@ export class ContainersService {
if (container.status === 'LOADED') {
throw new ConflictException('Cannot reassign a loaded container');
}
// Reject a container that is already placed on a wagon — it must be
// unassigned first, otherwise it would silently jump to another wagon.
if (container.wagonId) {
throw new ConflictException(
`Container ${containerId} is already assigned to wagon ${container.wagonId}`,
);
}
const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } });
if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
let position: number | null = dto.position ?? null;
if (position === null) {
const maxPos = await this.containerRepo
.createQueryBuilder('c')
.select('MAX(c.position)', 'max')
.where('c.wagonId = :wagonId', { wagonId: wagon.id })
.getRawOne();
position = (maxPos?.max ?? 0) + 1;
}
// The MAX(position)+1 allocation is check-then-act: two concurrent assigns can
// read the same MAX and collide on the same position. Do the read + save inside
// one transaction to narrow the race window.
// TODO: add a unique (wagon_id, position) DB index so the database itself
// rejects a colliding position even under concurrency.
return this.dataSource.transaction(async (manager) => {
const containerRepo = manager.getRepository(Container);
container.wagonId = wagon.id;
container.position = position;
container.status = 'AVAILABLE';
return this.containerRepo.save(container);
let position: number | null = dto.position ?? null;
if (position === null) {
const maxPos = await containerRepo
.createQueryBuilder('c')
.select('MAX(c.position)', 'max')
.where('c.wagonId = :wagonId', { wagonId: wagon.id })
.getRawOne<{ max: number | null }>();
position = (maxPos?.max ?? 0) + 1;
}
container.wagonId = wagon.id;
container.position = position;
// Placing a container on a wagon does not make it AVAILABLE. The status enum
// (AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED) has no ASSIGNED/ON_WAGON
// state, so leave the existing status unchanged rather than forcing AVAILABLE.
return containerRepo.save(container);
});
}
async unassignFromWagon(containerId: string): Promise<Container> {

View File

@@ -0,0 +1,224 @@
import { Injectable, Logger, UnprocessableEntityException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import { ContractPricingBreakdown } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ContractsRepository } from './contracts.repository';
import { Contract } from './entities/contract.entity';
/** Invoice `type` for the contract-level fee (Path B ONE_TIME, after counter-sign). */
export const CLEARANCE_CONTRACT_INVOICE_TYPE = 'CLEARANCE_CONTRACT';
/** Invoice `type` for the per-shipment fee (Path B GENERAL, at shipment request). */
export const CLEARANCE_BOOKING_INVOICE_TYPE = 'CLEARANCE_BOOKING';
/**
* The prepaid customs clearance service fee (Path B) — the GL service charge,
* separate from both freight (booking invoice) and duty/tax (paid offline).
* Issued as its own `clearance`-source invoice and paid BEFORE the clearance
* document step opens and before GL touches the file:
* - ONE_TIME: once per contract, at staff counter-sign
* (AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_CLEARANCE_DOCUMENTS);
* - GENERAL: once per shipment request, on the initiated booking instance
* (booking AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_DOCUMENTS).
* The fee amount is the frozen CUSTOMS_CLEARANCE contract rate snapshot, so
* customers pay what their contract shows, not the live rate of the day.
*/
@Injectable()
export class ClearanceFeeService {
private readonly logger = new Logger(ClearanceFeeService.name);
constructor(
private readonly billing: BillingService,
private readonly contractsRepository: ContractsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly notifier: ContractNotifierService,
) {}
/** The frozen flat fee for a contract; falls back to the pricing breakdown. */
private async feeAmountOrNull(
contract: Contract,
): Promise<{ amount: number; currency: string } | null> {
const snapshots = await this.contractsRepository.findRateSnapshots(contract.id);
const snapshot = snapshots.find(
(s) => s.isClearance || s.rateCode === 'CUSTOMS_CLEARANCE',
);
if (snapshot && Number(snapshot.unitPrice) > 0) {
return { amount: Number(snapshot.unitPrice), currency: snapshot.currency };
}
const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
const line = breakdown?.lineItems?.find((l) => l.code === 'CUSTOMS_CLEARANCE');
if (line && Number(line.unitPrice) > 0) {
return { amount: Number(line.unitPrice), currency: breakdown!.currency };
}
return null;
}
private async feeAmount(
contract: Contract,
): Promise<{ amount: number; currency: string }> {
const fee = await this.feeAmountOrNull(contract);
if (!fee) {
throw new UnprocessableEntityException(
`Contract ${contract.reference} has no frozen customs clearance fee — regenerate its price with a live CUSTOMS_CLEARANCE rate.`,
);
}
return fee;
}
/**
* Whether the payment gate applies. Skipped for government/unlinked
* contracts (no company to bill — invoices require one, same rule the
* booking invoice applies) and for legacy customs contracts frozen before
* the fee existed (no CUSTOMS_CLEARANCE snapshot to bill from) — both keep
* the pre-fee flow instead of dead-ending.
*/
async gateApplies(contract: Contract): Promise<boolean> {
// Customs disabled → the prepay gate genuinely does not apply.
if (!contract.customsClearingEnabled) return false;
// No company to bill (government / unlinked) → the gate cannot raise an
// invoice, so it stays out of the flow (same rule the booking invoice uses).
if (!contract.companyId) return false;
// M26: customs IS enabled and billable. A missing frozen fee line must NOT
// silently waive the gate — that ships clearance for free. Hard-fail exactly
// as price generation does when no CUSTOMS_CLEARANCE rate is configured, so a
// missing fee blocks counter-sign / shipment instead of bypassing payment.
if ((await this.feeAmountOrNull(contract)) === null) {
throw new UnprocessableEntityException(
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
);
}
return true;
}
/** Issue (idempotently) the ONE_TIME contract-level fee invoice. */
async issueForContract(contract: Contract): Promise<Invoice> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Clearance,
contract.id,
CLEARANCE_CONTRACT_INVOICE_TYPE,
);
if (existing) return existing;
const { amount, currency } = await this.feeAmount(contract);
const invoice = await this.billing.generateInvoice({
source: Freight.InvoiceSource.Clearance,
sourceId: contract.id,
type: CLEARANCE_CONTRACT_INVOICE_TYPE,
companyId: contract.companyId!,
companyProfileId: contract.companyProfileId!,
currency,
lines: [
{
chargeType: 'CUSTOMS_CLEARANCE',
description: `Customs clearance service fee — contract ${contract.reference}`,
quantity: 1,
unitRate: amount,
amount,
currency,
},
],
status: Freight.InvoiceStatus.Pending,
});
this.notifier.clearanceFeeDue(contract, amount, currency);
return invoice;
}
/** Issue (idempotently) the GENERAL per-shipment fee invoice on the booking. */
async issueForBooking(booking: Booking, contract: Contract): Promise<Invoice> {
const existing = await this.billing.findPayable(
Freight.InvoiceSource.Clearance,
booking.id,
CLEARANCE_BOOKING_INVOICE_TYPE,
);
if (existing) return existing;
const { amount, currency } = await this.feeAmount(contract);
const invoice = await this.billing.generateInvoice({
source: Freight.InvoiceSource.Clearance,
sourceId: booking.id,
type: CLEARANCE_BOOKING_INVOICE_TYPE,
companyId: booking.companyId ?? contract.companyId!,
companyProfileId: booking.companyProfileId ?? contract.companyProfileId!,
currency,
lines: [
{
chargeType: 'CUSTOMS_CLEARANCE',
description: `Customs clearance service fee — shipment ${booking.reference}`,
quantity: 1,
unitRate: amount,
amount,
currency,
},
],
status: Freight.InvoiceStatus.Pending,
});
this.notifier.clearanceFeeDue(contract, amount, currency, booking.reference);
return invoice;
}
/**
* Settlement branch point for `clearance`-source invoices: unlock the
* document-upload step the fee was gating. Idempotent — a replayed event on
* an already-advanced contract/booking is a no-op.
*/
@OnEvent('clearance.invoice.paid')
async onClearanceInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
this.logger.log(
`clearance.invoice.paid (${payload.type}) for ${payload.sourceId} from ${payload.invoiceId}`,
);
switch (payload.type) {
case CLEARANCE_CONTRACT_INVOICE_TYPE:
await this.advanceContract(payload.sourceId);
break;
case CLEARANCE_BOOKING_INVOICE_TYPE:
await this.advanceBooking(payload.sourceId);
break;
default:
this.logger.warn(
`Unhandled clearance invoice type "${payload.type}" paid (${payload.invoiceId})`,
);
}
}
private async advanceContract(contractId: string): Promise<void> {
const contract = await this.contractsRepository.findById(contractId);
if (!contract) {
this.logger.warn(`Cannot advance unknown contract ${contractId} on clearance fee payment.`);
return;
}
if (contract.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
await this.contractsRepository.update(contractId, {
status: 'AWAITING_CLEARANCE_DOCUMENTS',
clearanceStatus: 'AWAITING_DOCUMENTS',
clearanceFeePaidAt: new Date(),
} as never);
const updated = await this.contractsRepository.findByIdWithRelations(contractId);
if (updated) this.notifier.clearanceFeePaid(updated);
}
private async advanceBooking(bookingId: string): Promise<void> {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) {
this.logger.warn(`Cannot advance unknown booking ${bookingId} on clearance fee payment.`);
return;
}
if (booking.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
await this.bookingsRepository.update(bookingId, {
status: 'AWAITING_DOCUMENTS',
clearanceFeePaidAt: new Date(),
} as never);
if (booking.contractId) {
const contract = await this.contractsRepository.findByIdWithRelations(
booking.contractId,
);
if (contract) this.notifier.clearanceFeePaid(contract, booking.reference);
}
}
}

View File

@@ -26,6 +26,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // milestoneService
{} as never, // workflowService
{} as never, // invoiceService
{} as never, // clearanceFeeService
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService

View File

@@ -57,6 +57,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
milestoneService as never,
{} as never, // workflowService
invoiceService as never,
{} as never, // clearanceFeeService
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService

View File

@@ -35,6 +35,7 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import { ContractsRepository } from './contracts.repository';
import { ClearanceFeeService } from './clearance-fee.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
@@ -78,6 +79,7 @@ export class ContractBookingService {
private readonly milestoneService: ClearanceMilestoneService,
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly clearanceFeeService: ClearanceFeeService,
private readonly dataSource: DataSource,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
@@ -261,7 +263,7 @@ export class ContractBookingService {
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
equipmentReturn: dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN',
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
tradeDirection: contract.tradeDirection,
@@ -492,6 +494,11 @@ export class ContractBookingService {
const route = await this.resolveRoute(contract, opts.contractRouteId);
// Prepay gate: each shipment request owes its own flat clearance service
// fee before the document step opens (the paid event advances the booking
// to AWAITING_DOCUMENTS). Government/unlinked contracts skip the gate.
const feeGate = await this.clearanceFeeService.gateApplies(contract);
const booking = await insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
@@ -501,7 +508,7 @@ export class ContractBookingService {
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status: 'AWAITING_DOCUMENTS',
status: feeGate ? 'AWAITING_CLEARANCE_PAYMENT' : 'AWAITING_DOCUMENTS',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
@@ -540,6 +547,10 @@ export class ContractBookingService {
contract.tradeDirection,
);
if (feeGate) {
await this.clearanceFeeService.issueForBooking(booking, contract);
}
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
}
@@ -654,7 +665,7 @@ export class ContractBookingService {
await this.bookingsRepository.update(booking.id, {
cargoTypeId: this.resolveCargoTypeId(contract, dto),
cargoTotalWeightVgm: this.resolveBulkTons(dto),
...(dto.equipmentReturn ? { equipmentReturn: dto.equipmentReturn } : {}),
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
} as never);
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
@@ -1405,6 +1416,47 @@ export class ContractBookingService {
);
}
/**
* Resolve the booking's equipment return from the per-line return quantities
* (container freight). The CONTRACT gates the service — like hazardous:
* - contract WITH_RETURN → per-line returnQuantity (≤ quantity) decides; any
* line > 0 makes the booking WITH_RETURN (fires the pricing surcharge).
* - contract WITHOUT_RETURN/unset → returnQuantity is rejected and the legacy
* booking-level override (dto.equipmentReturn ?? contract default) applies.
* Bulk freight keeps the legacy behaviour untouched.
*/
private resolveShipmentEquipmentReturn(
contract: Contract,
dto: CreateBookingUnderContractDto,
): string {
const legacy =
dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN';
if (contract.freightType !== 'CONTAINER') return legacy;
const lines = dto.containers ?? [];
for (const line of lines) {
const qty = Number(line.returnQuantity ?? 0);
if (qty === 0) continue;
if (contract.equipmentReturn !== 'WITH_RETURN') {
throw new BadRequestException(
'This contract was not created with the empty-container return ' +
'service — return quantities are not allowed on its bookings.',
);
}
if (qty > line.quantity) {
throw new BadRequestException(
`Return quantity ${qty} exceeds the ${line.containerSize} line quantity ${line.quantity}.`,
);
}
}
if (contract.equipmentReturn === 'WITH_RETURN') {
const anyReturn = lines.some((l) => Number(l.returnQuantity ?? 0) > 0);
return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN';
}
return legacy;
}
/**
* Map each contract-scope container size to a concrete container type and
* persist the booking_container line + its per-unit container numbers. Weight
@@ -1455,6 +1507,10 @@ export class ContractBookingService {
quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
returnQuantity:
contract.equipmentReturn === 'WITH_RETURN'
? (line.returnQuantity ?? 0)
: 0,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: totalVgm,
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)),
@@ -1575,6 +1631,7 @@ export class ContractBookingService {
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
isGovernment: contract.isGovernment,
shippingLineId: null,
contractRouteId: route?.id ?? null,
@@ -1588,6 +1645,10 @@ export class ContractBookingService {
quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
returnQuantity:
contract.equipmentReturn === 'WITH_RETURN'
? (line.returnQuantity ?? 0)
: 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons,
wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)),

View File

@@ -489,6 +489,11 @@ export class ContractClearanceService {
files: Express.Multer.File[],
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (contract.status === 'AWAITING_CLEARANCE_PAYMENT') {
throw new ConflictException(
'The customs clearance service fee has not been paid yet — pay it from the portal to unlock document upload.',
);
}
if (
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
contract.status !== 'CLEARANCE_UNDER_REVIEW'

View File

@@ -158,6 +158,26 @@ export class ContractNotifierService {
});
}
/** Clearance service fee invoiced — customer must pay before document upload. */
clearanceFeeDue(c: Contract, amount: number, currency: string, shipmentRef?: string): void {
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
const msg =
`A customs clearance service fee of ${amount} ${currency} is due for ${scope}. ` +
`Please pay from the portal to unlock the clearance document upload.`;
void this.notifyContact(c, msg, 'CLEARANCE FEE DUE');
this.inApp(c, 'Clearance fee due', msg);
}
/** Clearance service fee settled — document upload is now open. */
clearanceFeePaid(c: Contract, shipmentRef?: string): void {
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
const msg =
`Your customs clearance service fee for ${scope} has been received. ` +
`You can now upload the clearance documents from the portal.`;
void this.notifyContact(c, msg, 'CLEARANCE FEE PAID');
this.inApp(c, 'Clearance fee paid', msg);
}
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
import { RatesService } from '../rule-engine/services/rates.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
@@ -15,6 +15,11 @@ export interface ContractUnitRateLineItem {
containerSize?: string | null;
conditionalOn?: string | null;
cargoTypeCode?: string | null;
/**
* Customs clearance service fee — billed separately in advance (before the
* clearance document step), never part of shipment booking totals.
*/
isClearance?: boolean;
}
/** The contract `pricing_breakdown` shape (doc §9.1). */
@@ -183,6 +188,50 @@ export class ContractPricingService {
});
}
}
// Empty-container return service — container contracts only, toggled on the
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
if (
contract.freightType === 'CONTAINER' &&
contract.equipmentReturn === 'WITH_RETURN'
) {
const withReturn = liveRates.find(
(r) => r.rateType === 'RETURN_SURCHARGE' && r.currency === 'USD',
);
if (withReturn && Number(withReturn.rateValue) > 0) {
lineItems.push({
code: 'RETURN_SURCHARGE',
label: 'Empty container return',
unit: toContractUnit(withReturn.rateUnit),
unitPrice: convert(Number(withReturn.rateValue)),
conditionalOn: 'with_return',
});
}
}
// Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the
// contract and billed via its own clearance invoice: after counter-sign for
// ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
// A customs contract may not proceed without a configured live rate.
if (contract.customsClearingEnabled) {
const clearance = liveRates.find(
(r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD',
);
if (!clearance || Number(clearance.rateValue) <= 0) {
throw new UnprocessableEntityException(
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
);
}
lineItems.push({
code: 'CUSTOMS_CLEARANCE',
label:
contract.contractKind === 'GENERAL'
? 'Customs clearance service fee (per shipment request, prepaid)'
: 'Customs clearance service fee (prepaid)',
unit: toContractUnit(clearance.rateUnit),
unitPrice: convert(Number(clearance.rateValue)),
isClearance: true,
});
}
return {
displayMode: 'UNIT_RATES',
@@ -229,6 +278,7 @@ export class ContractPricingService {
containerSize: line.containerSize ?? null,
isSurcharge: !!line.conditionalOn,
conditionalOn: line.conditionalOn ?? null,
isClearance: !!line.isClearance,
});
}
}

View File

@@ -24,6 +24,7 @@ import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service';
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service';
import { ClearanceFeeService } from './clearance-fee.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
@@ -59,6 +60,17 @@ export interface ContractDocumentDraft {
*/
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
/**
* Mask a phone for display — keep the last 4 digits, star the rest
* (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing
* code went without echoing the company's full registered number back to the UI.
*/
function maskPhone(phone: string): string {
const trimmed = phone.trim();
if (trimmed.length <= 4) return trimmed;
return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`;
}
/** Status-machine guard mirroring booking-status.util. */
function assertContractStatus(contract: Contract, allowed: string[]): void {
if (!allowed.includes(contract.status)) {
@@ -89,6 +101,7 @@ export class ContractTransitionService {
private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService,
private readonly contractTemplates: ContractTemplatesService,
private readonly clearanceFeeService: ClearanceFeeService,
) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -782,6 +795,36 @@ export class ContractTransitionService {
}
}
/**
* Send the sudo-mode signing OTP to the CONTRACT COMPANY's registered phone —
* the same number {@link sign} verifies against. The client never picks the
* number (that is the H12(b) trust property): it only asks us to send, and we
* resolve the phone from the contract. Returns a masked hint so the UI can
* say where the code went without exposing the full number.
*/
async sendSigningOtp(
contractId: string,
options: { signerUserId?: string },
): Promise<{ sentTo: string }> {
const contract = await this.contractsService.findById(contractId);
// Same ownership gate as signing — only the owning company's customer may
// trigger a code for this contract.
await this.contractsService.assertCustomerCanAccessContract(
options.signerUserId,
contract,
);
assertContractStatus(contract, ['CONTRACT_READY']);
const companyPhone = contract.company?.phone?.trim();
if (!companyPhone) {
throw new BadRequestException(
'The contract company has no registered phone on file to send the signing OTP to',
);
}
await this.otpService.sendOtp({ phone: companyPhone });
return { sentTo: maskPhone(companyPhone) };
}
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
async sign(
contractId: string,
@@ -791,17 +834,34 @@ export class ContractTransitionService {
const contract = await this.contractsService.findById(contractId);
if (dto.role === 'CUSTOMER') {
// H12(a): only the owning company's customer may sign — assert ownership
// before anything else (hidden as NotFound otherwise). A signing customer
// has no permission key, so this is the gate that binds the sign to the
// contract's company.
await this.contractsService.assertCustomerCanAccessContract(
options.signerUserId,
contract,
);
assertContractStatus(contract, ['CONTRACT_READY']);
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
if (existing) {
throw new BadRequestException('Customer has already signed this contract');
}
// Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone)
// must be verified before the signature is applied.
if (!dto.otpPhone || !dto.otp) {
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
// signature is applied. H12(b): verify against the CONTRACT COMPANY's
// registered phone — never the caller-supplied dto.otpPhone, which an
// attacker could point at their own phone to sign someone else's
// contract. The OTP is issued to the company's registered number.
const companyPhone = contract.company?.phone?.trim();
if (!companyPhone) {
throw new BadRequestException(
'The contract company has no registered phone on file to verify the signing OTP against',
);
}
if (!dto.otp) {
throw new BadRequestException('OTP verification is required to sign the contract');
}
await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp);
await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp);
await this.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER',
@@ -866,8 +926,17 @@ export class ContractTransitionService {
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
// Path B prepay gate: the customs clearance service fee is invoiced here
// and must settle before the document step opens (the paid event advances
// to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee.
if (await this.clearanceFeeService.gateApplies(contract)) {
await this.clearanceFeeService.issueForContract(contract);
updates.status = 'AWAITING_CLEARANCE_PAYMENT';
updates.clearanceStatus = 'AWAITING_PAYMENT';
} else {
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
}
updates.clearanceCycleNumber = cycleNumber;
} else {
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract

View File

@@ -499,6 +499,19 @@ export class ContractsController {
stream.pipe(res);
}
@Post(':id/contract/send-signing-otp')
@UseGuards(JwtGuard)
@ApiOperation({
summary:
"Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)",
})
sendSigningOtp(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.sendSigningOtp(id, { signerUserId: user?.id });
}
@Post(':id/contract/sign')
@UseGuards(JwtGuard)
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
@@ -524,12 +537,18 @@ export class ContractsController {
@Post(':id/renew')
@ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' })
renew(
async renew(
@Param('id', ParseUUIDPipe) id: string,
@Body() _dto: RenewContractDto,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.renew(id, user?.id ?? user?.sub);
// H12(c): a customer may only renew a contract their company owns. Staff
// with bookings.view bypass, mirroring getContractView/downloadContractDocument.
const contract = await this.contractsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
return this.transitionService.renew(id, resolveAuthUserId(user));
}
// ── Pre-booking clearance (Path B, doc §15.2.1) ────────────────────────────
@@ -544,10 +563,17 @@ export class ContractsController {
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' })
uploadClearanceDocuments(
async uploadClearanceDocuments(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@UploadedFiles() files: Express.Multer.File[],
) {
// H12(c): only the owning company's customer may upload clearance docs.
// Staff with bookings.view bypass, mirroring the other contract handlers.
const contract = await this.contractsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
return this.clearanceService.uploadDocuments(id, files ?? []);
}

View File

@@ -22,6 +22,7 @@ import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
import { ContractsRepository } from './contracts.repository';
import { ContractPricingService } from './contract-pricing.service';
import { ClearanceFeeService } from './clearance-fee.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service';
@@ -103,6 +104,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractsService,
ContractsRepository,
ContractPricingService,
ClearanceFeeService,
ContractNotifierService,
ContractTransitionService,
ContractClearanceService,

View File

@@ -77,6 +77,18 @@ export class CreateBookingContainerLineDto {
@Transform(({ value }) => Number(value))
reeferQuantity?: number;
@ApiPropertyOptional({
minimum: 0,
description:
'How many units of this line ship with empty-container return (≤ quantity). ' +
'Only allowed when the contract was created WITH_RETURN (container freight).',
})
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value))
returnQuantity?: number;
@ApiProperty({ type: [CreateContainerUnitDto] })
@IsArray()
@ValidateNested({ each: true })

View File

@@ -22,7 +22,10 @@ import { CONTRACT_KINDS } from '../entities/contract.entity';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
const EQUIPMENT_RETURNS = ['with_return', 'without_return'] as const;
// Canonical UPPERCASE — everything downstream (booking gating, pricing
// surcharge, GL/portal booking forms) compares contract.equipmentReturn
// against 'WITH_RETURN'/'WITHOUT_RETURN'. Lowercase input is normalized.
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
export {
CONTRACT_KINDS,
@@ -161,6 +164,9 @@ export class CreateContractDto {
@ApiPropertyOptional({ enum: EQUIPMENT_RETURNS })
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' ? value.toUpperCase() : value,
)
@IsIn([...EQUIPMENT_RETURNS])
equipmentReturn?: string;

View File

@@ -44,4 +44,11 @@ export class ContractRateSnapshot extends BaseEntity {
/** is_hazardous | is_reefer when this is a conditional surcharge. */
@Column({ name: 'conditional_on', type: 'varchar', length: 32, nullable: true })
conditionalOn?: string | null;
/**
* Customs clearance service fee line — billed up front via a clearance
* invoice, excluded from shipment booking totals.
*/
@Column({ name: 'is_clearance', type: 'boolean', default: false })
isClearance!: boolean;
}

View File

@@ -25,6 +25,7 @@ export const CONTRACT_STATUSES = [
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
@@ -84,6 +85,7 @@ export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
export const CONTRACT_CLEARANCE_STATUSES = [
'NOT_APPLICABLE',
'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
@@ -215,6 +217,10 @@ export class Contract extends BaseEntity {
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
clearanceCycleNumber!: number;
/** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
clearanceFeePaidAt?: Date | null;
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
pricingBreakdown?: Record<string, unknown> | null;

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Controller,
Get,
Post,
@@ -72,7 +73,30 @@ export class DriversController {
@Post(':id/documents')
@BookingStaff(FREIGHT_PERMS.drivers.update)
@ApiConsumes('multipart/form-data')
@UseInterceptors(AnyFilesInterceptor())
// Bound the upload: 10MB/file, max 20 files, images + PDF only. Without limits
// AnyFilesInterceptor buffers arbitrarily large / arbitrary-type payloads.
@UseInterceptors(
AnyFilesInterceptor({
limits: { fileSize: 10 * 1024 * 1024, files: 20 },
fileFilter: (_req, file, cb) => {
const allowed = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'application/pdf',
];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(
new BadRequestException(`Unsupported file type: ${file.mimetype}`),
false,
);
}
},
}),
)
@ApiOperation({ summary: 'Upload driver documents (code driver_docs)' })
uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,

View File

@@ -6,22 +6,24 @@ import {
Query,
Res,
} from "@nestjs/common";
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { Public } from "@edr/api-common";
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { Response } from "express";
import { FilesService } from "./files.service";
@ApiTags("files")
@ApiBearerAuth()
@Controller("files")
export class FilesController {
constructor(private readonly filesService: FilesService) {}
@Get(":fileId")
// Public so the browser can load the bytes directly via <img>/<iframe>/<a> —
// those requests can't carry the Bearer token the axios client injects, so a
// guarded route 401s. File UUIDs are unguessable; same tradeoff as webhooks.
@Public()
// Authenticated: no @Public, so the global JwtGuard applies. Unguessable file
// UUIDs are obscurity, not authorization — raw byte streams must require auth.
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer
// token should use a short-lived signed URL instead (FilesService.signUrl).
// TODO: enforce ownership-by-resource here next (scope the file to the
// caller's booking/company before streaming).
@ApiOperation({
summary: "Stream a file by ID",
description:

View File

@@ -1,4 +1,8 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import {
BadRequestException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Readable } from "stream";
import { MinioService } from "../minio/minio.service";
@@ -28,6 +32,31 @@ function sanitizeObjectName(name: string): string {
@Injectable()
export class FilesService {
// Defense-in-depth for ANY caller of upload() (not just the driver-docs
// route). This is deliberately BROADER than the driver controller's strict
// images+pdf Multer filter, because the same method also stores generated
// PDFs, PNG signatures, and customer/customs booking documents (scans, office
// docs). It rejects the actual attack surface (executables/scripts/HTML) while
// permitting every business-document type these flows legitimately upload.
// No file-upload-settings row governs raw byte size, so the cap is a sane,
// generous default that won't reject large scanned documents.
private static readonly MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
private static readonly ALLOWED_UPLOAD_MIME = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
"image/heic",
"image/tiff",
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/csv",
"text/plain",
]);
constructor(
private readonly filesRepository: FilesRepository,
private readonly minioService: MinioService,
@@ -35,6 +64,15 @@ export class FilesService {
async upload(input: CreateFileInput): Promise<FileRecord> {
const { resourceId, resource, code, file } = input;
if (!FilesService.ALLOWED_UPLOAD_MIME.has(file.mimetype)) {
throw new BadRequestException(`Unsupported file type: ${file.mimetype}`);
}
if (file.size > FilesService.MAX_UPLOAD_BYTES) {
throw new BadRequestException(
`File exceeds the ${FilesService.MAX_UPLOAD_BYTES / (1024 * 1024)}MB upload limit`,
);
}
// Keep the object key URL-safe so it survives the round-trip through the
// stored URL (spaces/unicode in the original name would otherwise be
// percent-encoded in the URL and no longer match the MinIO key). The

View File

@@ -1,4 +1,4 @@
import { IsUUID, IsNumber, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator';
import { IsUUID, IsNumber, IsPositive, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator';
import { PaymentMethod } from '../entities/fuel-purchase.entity';
export class CreateFuelPurchaseDto {
@@ -9,9 +9,11 @@ export class CreateFuelPurchaseDto {
purchaseDate!: string;
@IsNumber()
@IsPositive()
liters!: number;
@IsNumber()
@IsPositive()
costPerLiter!: number;
@IsOptional()

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FuelRepository } from './fuel.repository';
@@ -15,6 +15,18 @@ export class FuelService {
) {}
async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise<FuelPurchase> {
// Reject a re-submitted receipt for the same vehicle (double-entry guard).
if (dto.receiptNumber) {
const duplicate = await this.purchaseRepository.findOne({
where: { vehicleId: dto.vehicleId, receiptNumber: dto.receiptNumber },
});
if (duplicate) {
throw new ConflictException(
`A fuel purchase with receipt number ${dto.receiptNumber} already exists for this vehicle`,
);
}
}
const totalCost = dto.liters * dto.costPerLiter;
const purchase = this.purchaseRepository.create({

View File

@@ -1,6 +1,8 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import {
AssignCustomsRiskDto,
CreateDjiboutiIncidentDto,
@@ -15,6 +17,9 @@ import { ImportOperationsService } from './import-operations.service';
@ApiTags('import-operations')
@ApiBearerAuth()
@Controller('import-operations')
// Post-booking customs / import-operations actions are GL/Ops work, mirroring the
// contracts controller's GL operational endpoints (risk, duty, milestones).
@BookingStaff(FREIGHT_PERMS.bookings.operations)
export class ImportOperationsController {
constructor(private readonly service: ImportOperationsService) {}

View File

@@ -8,18 +8,26 @@ import {
Param,
Query,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { IncidentsService } from './incidents.service';
import { CreateIncidentDto } from './dto/create-incident.dto';
import { UpdateIncidentDto } from './dto/update-incident.dto';
import { IncidentStatus, IncidentType } from './entities/incident.entity';
@ApiTags('Accident & Incident Management')
@ApiBearerAuth()
@Controller('incidents')
// No incidents-specific permission exists in the registry, so this reuses the
// (real) drivers.* fleet-road keys — incident records are driver-safety data
// (driver stats / incident history). TODO: add a dedicated incidents:* key.
@BookingStaff(FREIGHT_PERMS.drivers.view)
export class IncidentsController {
constructor(private readonly incidentsService: IncidentsService) {}
@Post()
@BookingStaff(FREIGHT_PERMS.drivers.create)
@ApiOperation({ summary: 'Report an incident' })
async create(@Body() dto: CreateIncidentDto) {
return this.incidentsService.create(dto);
@@ -55,12 +63,14 @@ export class IncidentsController {
}
@Patch(':id')
@BookingStaff(FREIGHT_PERMS.drivers.update)
@ApiOperation({ summary: 'Update an incident' })
async update(@Param('id') id: string, @Body() dto: UpdateIncidentDto) {
return this.incidentsService.update(id, dto);
}
@Delete(':id')
@BookingStaff(FREIGHT_PERMS.drivers.delete)
@ApiOperation({ summary: 'Delete an incident' })
async remove(@Param('id') id: string) {
await this.incidentsService.remove(id);

View File

@@ -1,6 +1,8 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { GenerateFromScheduleDto } from './dto/generate-from-schedule.dto';
import { InterchangeDocumentQueryDto } from './dto/interchange-document-query.dto';
import {
@@ -12,6 +14,8 @@ import { InterchangeDocumentsService } from './interchange-documents.service';
@ApiTags('interchange-documents')
@ApiBearerAuth()
@Controller('interchange-documents')
// Class-level view guard; each write route adds its own manage permission below.
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.view)
export class InterchangeDocumentsController {
constructor(private readonly service: InterchangeDocumentsService) {}
@@ -28,12 +32,14 @@ export class InterchangeDocumentsController {
}
@Post('generate-from-schedule')
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.generate)
@ApiOperation({ summary: 'Generate interchange document from a train schedule handover' })
generateFromSchedule(@Body() dto: GenerateFromScheduleDto) {
return this.service.generateFromSchedule(dto);
}
@Patch(':id/acknowledge')
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.acknowledge)
@ApiOperation({ summary: 'Acknowledge an interchange document' })
acknowledge(
@Param('id', ParseUUIDPipe) id: string,
@@ -43,12 +49,14 @@ export class InterchangeDocumentsController {
}
@Patch(':id/dispute')
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.dispute)
@ApiOperation({ summary: 'Dispute an interchange document' })
dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) {
return this.service.dispute(id, dto);
}
@Patch(':id/cancel')
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.cancel)
@ApiOperation({ summary: 'Cancel a draft/generated interchange document' })
cancel(@Param('id', ParseUUIDPipe) id: string) {
return this.service.cancel(id);

View File

@@ -184,8 +184,13 @@ export class InterchangeDocumentsService {
dto: AcknowledgeInterchangeDocumentDto,
): Promise<InterchangeDocument> {
const document = await this.findOne(id);
if (document.status === 'CANCELLED') {
throw new BadRequestException('Cancelled interchange document cannot be acknowledged');
// Only a freshly GENERATED document can be acknowledged. Rejecting DISPUTED
// (as well as CANCELLED / already-ACKNOWLEDGED) stops an acknowledge from
// silently overriding a raised dispute.
if (document.status !== 'GENERATED') {
throw new BadRequestException(
`Interchange document in ${document.status} status cannot be acknowledged (must be GENERATED)`,
);
}
await this.dataSource.getRepository(InterchangeDocument).update(id, {
status: 'ACKNOWLEDGED',
@@ -197,7 +202,14 @@ export class InterchangeDocumentsService {
}
async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise<InterchangeDocument> {
await this.findOne(id);
const document = await this.findOne(id);
// A dispute can only be raised on a live handover — a GENERATED or already
// ACKNOWLEDGED document. CANCELLED and already-DISPUTED are terminal here.
if (!['GENERATED', 'ACKNOWLEDGED'].includes(document.status)) {
throw new BadRequestException(
`Interchange document in ${document.status} status cannot be disputed (must be GENERATED or ACKNOWLEDGED)`,
);
}
await this.dataSource.getRepository(InterchangeDocument).update(id, {
status: 'DISPUTED',
remarks: dto.remarks,
@@ -273,7 +285,10 @@ export class InterchangeDocumentsService {
NULL::uuid AS "cargoId",
a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription",
COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight",
-- Item weight is normalized to TONS. total_vgm_tons and
-- cargo_total_weight_vgm are already tons; containers.max_gross_weight
-- is kilograms, so convert it (kg -> tons).
COALESCE(bc.total_vgm_tons, c.max_gross_weight / 1000.0 /* kg->tons */, a.cargo_total_weight_vgm) AS "weight",
COALESCE(bc.quantity, 1) AS "quantity",
COALESCE(bc.quantity, 1) AS "packageCount",
a.wagon_number AS "wagonNumber",
@@ -322,7 +337,9 @@ export class InterchangeDocumentsService {
cg.id AS "cargoId",
COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType",
COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription",
COALESCE(cg.weight, a.cargo_total_weight_vgm) AS "weight",
-- Normalized to TONS: cargoes.weight is kilograms (convert), while
-- cargo_total_weight_vgm is already tons.
COALESCE(cg.weight / 1000.0 /* kg->tons */, a.cargo_total_weight_vgm) AS "weight",
cg.quantity AS "quantity",
cg.quantity AS "packageCount",
a.wagon_number AS "wagonNumber",

View File

@@ -1,16 +1,36 @@
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
import {
ArrayMaxSize,
ArrayUnique,
IsArray,
IsOptional,
IsString,
IsUUID,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class LastMileVehicleInput {
@IsUUID()
vehicleId!: string;
/**
* Containers this truck carries: one 40ft, or up to two 20ft. Omit for bulk
* (the truck hauls loose tonnage and is weighed out on exit).
*/
@IsOptional()
@IsArray()
@ArrayMaxSize(2)
@ArrayUnique()
@IsString({ each: true })
containerNumbers?: string[];
/** @deprecated Single-container form — use `containerNumbers`. Still accepted. */
@IsOptional()
@IsString()
containerNumber?: string;
}
/** Replace the full set of vehicles (with their container numbers) on a delivery. */
/** Replace the full set of vehicles (with their containers) on a delivery. */
export class SetVehiclesDto {
@IsArray()
@ValidateNested({ each: true })

View File

@@ -1,8 +1,9 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, Unique } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { LastMile } from './last-mile.entity';
import { LastMileVehicleContainer } from './last-mile-vehicle-container.entity';
/**
* One row per vehicle assigned to a last-mile delivery. A delivery can be
@@ -28,12 +29,34 @@ export class LastMileVehicleAssignment extends BaseEntity {
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle;
/** Container this truck carries — auto-filled from the booking's container
* number when known, else entered manually at assignment time. */
/** Legacy single container this truck carries. Kept in sync with the FIRST
* entry of `containers` for backward compatibility — a truck can hold 1x40ft
* or 2x20ft, so `containers` is the authoritative list. */
@Column({ name: 'container_number', type: 'varchar', nullable: true })
containerNumber?: string | null;
/** Containers riding this truck (1x40ft, or up to 2x20ft). */
@OneToMany(() => LastMileVehicleContainer, (c) => c.assignment, { cascade: true })
containers?: LastMileVehicleContainer[];
/** Actual distance driven by this truck (km), entered per vehicle. */
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
distanceKm?: number | null;
/** This truck reached the warehouse (stamped by the arrival weighing step). */
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
arrivedAt?: Date | null;
/** This truck left the warehouse (stamped by the exit weighing step). */
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
departedAt?: Date | null;
/** Weighed gross on exit, in TONNES (not kg — see the migration note). */
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
grossWeightTons?: number | null;
/** Cargo actually taken by this truck (gross tare), in TONNES. Drives the
* bulk drawdown: remaining = booking VGM SUM(net) over departed trucks. */
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
netWeightTons?: number | null;
}

View File

@@ -0,0 +1,30 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity';
/**
* A container riding a specific EDR last-mile truck. A truck carries 1x40ft OR
* 2x20ft, so the assignment needs more than the single legacy `container_number`
* scalar. Mirrors the self-haul `customer_truck_containers` child table.
*/
@Entity({ schema: 'freight', name: 'last_mile_vehicle_containers' })
@Index(['assignmentId'])
export class LastMileVehicleContainer extends BaseEntity {
@Column({ name: 'assignment_id', type: 'uuid' })
assignmentId!: string;
@ManyToOne(() => LastMileVehicleAssignment, (a) => a.containers, {
nullable: false,
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'assignment_id' })
assignment?: LastMileVehicleAssignment;
/** Denormalised for the "one container, one truck per delivery" unique index. */
@Column({ name: 'last_mile_id', type: 'uuid' })
lastMileId!: string;
@Column({ name: 'container_number', type: 'varchar', length: 32 })
containerNumber!: string;
}

View File

@@ -73,6 +73,12 @@ export class LastMileController {
return this.lastMileService.arrivalTrucksForBooking(bookingId);
}
@Get('booking/:bookingId/remaining-tons')
@ApiOperation({ summary: 'Bulk drawdown: tonnage still to be hauled (total departed trucks)' })
remainingTons(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.lastMileService.remainingTonsForBooking(bookingId);
}
@Post('accept/:reference')
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })

View File

@@ -10,6 +10,7 @@ import { VehiclesModule } from '../vehicles/vehicles.module';
import { LastMile } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
import { LastMileVehicleContainer } from './entities/last-mile-vehicle-container.entity';
import { LastMileController } from './last-mile.controller';
import { LastMileInvoiceService } from './last-mile-invoice.service';
import { LastMileRepository } from './last-mile.repository';
@@ -17,7 +18,12 @@ import { LastMileService } from './last-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation, LastMileVehicleAssignment]),
TypeOrmModule.forFeature([
LastMile,
LastMileContainerAllocation,
LastMileVehicleAssignment,
LastMileVehicleContainer,
]),
BillingModule,
forwardRef(() => BookingsModule),
VehiclesModule,

View File

@@ -1,4 +1,10 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
@@ -12,6 +18,7 @@ import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
import { LastMileVehicleContainer } from './entities/last-mile-vehicle-container.entity';
import { LastMileRepository } from './last-mile.repository';
import { FilesService } from '../files/files.service';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
@@ -175,7 +182,7 @@ export class LastMileService {
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
vehicle: true,
vehicleAssignments: { vehicle: true },
vehicleAssignments: { vehicle: true, containers: true },
},
order: { [sortBy]: sortOrder },
skip: (page - 1) * pageSize,
@@ -200,7 +207,7 @@ export class LastMileService {
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
vehicle: true,
vehicleAssignments: { vehicle: true },
vehicleAssignments: { vehicle: true, containers: true },
},
});
@@ -277,7 +284,7 @@ export class LastMileService {
> {
const [lm] = await this.lastMileRepository.findAll({
where: { bookingId },
relations: { vehicle: true, vehicleAssignments: { vehicle: true } },
relations: { vehicle: true, vehicleAssignments: { vehicle: true, containers: true } },
take: 1,
});
if (!lm) return [];
@@ -552,22 +559,164 @@ export class LastMileService {
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
* `vehicleId` column for back-compat with single-vehicle readers.
*/
/** Container numbers on the booking (upper-cased). */
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
[bookingId],
);
return rows.map((r) => r.containerNumber.trim().toUpperCase());
}
/** Contract container sizes (e.g. "20ft" / "40ft") for the given numbers. */
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
if (!numbers.length) return [];
const rows: Array<{ size: string | null }> = await this.dataSource.query(
`SELECT bc.container_size AS "size"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND UPPER(bcu.container_number) = ANY($2)
AND bcu.deleted_at IS NULL`,
[bookingId, numbers],
);
return rows.map((r) => (r.size ?? '').trim());
}
/**
* Bulk drawdown: how much of the booking's tonnage is still to be hauled —
* the booking VGM total minus the net weighed off every EDR truck that has
* already left. Both sides are tonnes, so no conversion.
*/
async remainingTonsForBooking(bookingId: string): Promise<{
totalTons: number;
hauledTons: number;
remainingTons: number;
complete: boolean;
}> {
const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> =
await this.dataSource.query(
`SELECT COALESCE(b.cargo_total_weight_vgm, 0) AS "totalTons",
COALESCE((
SELECT SUM(va.net_weight_tons)
FROM freight.last_mile_vehicle_assignments va
JOIN freight.last_mile lm
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
WHERE lm.booking_id = b.id
AND va.deleted_at IS NULL
AND va.departed_at IS NOT NULL
), 0) AS "hauledTons"
FROM freight.bookings b
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[bookingId],
);
const totalTons = Number(row?.totalTons ?? 0);
const hauledTons = Number(row?.hauledTons ?? 0);
const remainingTons = Math.max(0, Math.round((totalTons - hauledTons) * 1000) / 1000);
return { totalTons, hauledTons, remainingTons, complete: totalTons > 0 && remainingTons <= 0 };
}
/**
* Truck capacity rules for a last-mile delivery.
* - CONTAINER: a truck carries ONE 40ft or up to TWO 20ft; every container
* must belong to the booking and ride exactly one truck; never more trucks
* than containers.
* - BULK: no containers — trucks haul loose tonnage, so the only limit is
* that there is tonnage left to haul.
*/
private async assertVehicleLoads(
bookingId: string,
desired: string[],
loads: Map<string, string[]>,
): Promise<void> {
if (!desired.length) return;
const [booking]: Array<{ freightType: string | null }> = await this.dataSource.query(
`SELECT freight_type AS "freightType"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if ((booking?.freightType ?? '').toUpperCase() === 'BULK') {
const { remainingTons, totalTons } = await this.remainingTonsForBooking(bookingId);
if (totalTons > 0 && remainingTons <= 0) {
throw new BadRequestException(
'This bulk booking is fully hauled — no tonnage left to assign trucks for',
);
}
return;
}
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
if (!bookingNumbers.length) return; // nothing to validate against
const seen = new Set<string>();
for (const vehicleId of desired) {
const load = loads.get(vehicleId) ?? [];
if (load.length > 2) {
throw new BadRequestException('A truck carries at most 2 containers');
}
for (const n of load) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
if (seen.has(n)) {
throw new ConflictException(`Container ${n} is already assigned to another truck`);
}
seen.add(n);
}
// A 40ft container fills the truck; only two 20ft share one.
if (load.length > 1) {
const sizes = await this.containerSizes(bookingId, load);
if (sizes.some((s) => s.includes('40'))) {
throw new BadRequestException(
'A 40ft container fills the truck — assign only 1 container to this truck',
);
}
}
}
if (desired.length > bookingNumbers.length) {
throw new BadRequestException(
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${desired.length} truck(s) requested.`,
);
}
}
async setVehicles(
id: string,
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
inputs: Array<{
vehicleId: string;
containerNumbers?: string[] | null;
containerNumber?: string | null;
}>,
): Promise<LastMile> {
const existing = await this.findById(id);
// Dedupe by vehicleId, keeping the container number; preserve order.
const desiredMap = new Map<string, string | null>();
// Dedupe by vehicleId, keeping the container load; preserve order. Accepts
// the legacy single `containerNumber` as a one-element load.
const desiredMap = new Map<string, string[]>();
for (const inp of inputs) {
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
if (!inp.vehicleId) continue;
const load = (inp.containerNumbers ?? (inp.containerNumber ? [inp.containerNumber] : []))
.map((n) => String(n).trim().toUpperCase())
.filter(Boolean);
desiredMap.set(inp.vehicleId, load);
}
const desired = [...desiredMap.keys()];
const desiredSet = new Set(desired);
// Capacity + membership rules (a truck holds one 40ft or two 20ft; bulk
// hauls tonnage until the booking is drawn down).
await this.assertVehicleLoads(existing.bookingId, desired, desiredMap);
const manager = this.dataSource.manager;
const current = await manager.find(LastMileVehicleAssignment, {
where: { lastMileId: id },
relations: { containers: true },
});
const junctionSet = new Set(current.map((a) => a.vehicleId));
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
@@ -588,33 +737,58 @@ export class LastMileService {
);
}
}
// Vehicles that stay but whose container number changed.
// Vehicles that stay but whose container load changed (order-insensitive).
const loadKey = (list: string[]) => [...list].sort().join('|');
const currentLoad = (a: LastMileVehicleAssignment) =>
(a.containers ?? []).map((c) => c.containerNumber.trim().toUpperCase());
const changed = current.filter(
(a) =>
desiredMap.has(a.vehicleId) &&
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
loadKey(desiredMap.get(a.vehicleId) ?? []) !== loadKey(currentLoad(a)),
);
await this.dataSource.transaction(async (tx) => {
if (removed.length) {
// Child containers cascade on delete.
await tx.delete(LastMileVehicleAssignment, {
lastMileId: id,
vehicleId: In(removed),
});
}
for (const vehicleId of added) {
await tx.insert(LastMileVehicleAssignment, {
const load = desiredMap.get(vehicleId) ?? [];
const inserted = await tx.insert(LastMileVehicleAssignment, {
lastMileId: id,
vehicleId,
containerNumber: desiredMap.get(vehicleId) ?? null,
// Legacy scalar stays in sync with the first container.
containerNumber: load[0] ?? null,
});
const assignmentId = inserted.identifiers[0]?.id as string | undefined;
if (assignmentId && load.length) {
await tx.insert(
LastMileVehicleContainer,
load.map((containerNumber) => ({ assignmentId, lastMileId: id, containerNumber })),
);
}
}
for (const row of changed) {
const load = desiredMap.get(row.vehicleId) ?? [];
await tx.update(
LastMileVehicleAssignment,
{ lastMileId: id, vehicleId: row.vehicleId },
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
{ containerNumber: load[0] ?? null },
);
await tx.delete(LastMileVehicleContainer, { assignmentId: row.id });
if (load.length) {
await tx.insert(
LastMileVehicleContainer,
load.map((containerNumber) => ({
assignmentId: row.id,
lastMileId: id,
containerNumber,
})),
);
}
}
});

View File

@@ -1,4 +1,5 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
@@ -9,11 +10,23 @@ import {
type LocomotiveStatus,
type LocomotiveType,
} from './entities/locomotive.entity';
import { TrainLocomotive } from '../trains/entities/train-locomotive.entity';
import { LocomotivesRepository } from './locomotives.repository';
@Injectable()
export class LocomotivesService {
constructor(private readonly locomotivesRepository: LocomotivesRepository) {}
constructor(
private readonly locomotivesRepository: LocomotivesRepository,
private readonly dataSource: DataSource,
) {}
/** The built-train link (if any) coupling this locomotive to a fleet train. */
private findTrainLink(locomotiveId: string): Promise<TrainLocomotive | null> {
return this.dataSource.getRepository(TrainLocomotive).findOne({
where: { locomotiveId },
relations: { train: true },
});
}
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
return this.locomotivesRepository.findAll({
@@ -94,6 +107,22 @@ export class LocomotivesService {
}
}
// A locomotive coupled to a built train follows the train: its yard and
// status are owned by the train-builder flow, not this generic PATCH.
const link = await this.findTrainLink(id);
if (link) {
if (dto.currentYardId !== undefined && dto.currentYardId !== link.train?.currentYardId) {
throw new ConflictException(
`Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; move the train (train-builder yard change) instead`,
);
}
if (dto.status !== undefined && dto.status !== locomotive.status) {
throw new ConflictException(
`Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; detach it before changing its status`,
);
}
}
const updated = await this.locomotivesRepository.update(id, {
...dto,
locomotiveType:
@@ -119,7 +148,16 @@ export class LocomotivesService {
}
async decommission(id: string): Promise<Locomotive> {
await this.findById(id);
const locomotive = await this.findById(id);
// Can't retire a locomotive that is still coupled to a built train — detach
// it in the train-builder first so the train never loses a live loco.
const link = await this.findTrainLink(id);
if (link) {
throw new ConflictException(
`Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; detach it before taking it out of service`,
);
}
const updated = await this.locomotivesRepository.update(id, {
status: 'OUT_OF_SERVICE',

View File

@@ -1,9 +1,10 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DataSource, Repository } from 'typeorm';
import { MaintenanceRepository } from './maintenance.repository';
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity';
import { MaintenanceCost } from './entities/maintenance-cost.entity';
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
@Injectable()
@@ -14,15 +15,41 @@ export class MaintenanceService {
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
@InjectRepository(MaintenanceCost)
private readonly costRepository: Repository<MaintenanceCost>,
// Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we
// reach it through the global DataSource rather than @InjectRepository.
private readonly dataSource: DataSource,
) {}
/**
* Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle
* under maintenance is taken out of service (MAINTENANCE + BUSY); once the
* maintenance completes or is cancelled it returns to service (ACTIVE + FREE).
* Only the vehicle's status/availability columns are written here. The
* assignment-side reject (first-mile/last-mile refusing MAINTENANCE vehicles)
* lives in those excluded mile modules, not here.
*/
private async setVehicleMaintenanceState(
vehicleId: string,
underMaintenance: boolean,
): Promise<void> {
await this.dataSource.getRepository(Vehicle).update(vehicleId, {
status: underMaintenance ? VehicleStatus.MAINTENANCE : VehicleStatus.ACTIVE,
availability: underMaintenance
? VehicleAvailability.BUSY
: VehicleAvailability.FREE,
});
}
async scheduleMaintenanceAsync(dto: CreateMaintenanceScheduleDto): Promise<MaintenanceSchedule> {
const schedule = this.scheduleRepository.create({
...dto,
scheduledDate: new Date(dto.scheduledDate),
nextDueDate: dto.nextDueDate ? new Date(dto.nextDueDate) : undefined,
});
return this.scheduleRepository.save(schedule);
const saved = await this.scheduleRepository.save(schedule);
// Scheduling maintenance takes the vehicle out of the available pool.
await this.setVehicleMaintenanceState(saved.vehicleId, true);
return saved;
}
async recordMaintenanceCost(dto: CreateMaintenanceCostDto): Promise<MaintenanceCost> {
@@ -42,6 +69,21 @@ export class MaintenanceService {
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
});
const updated = await this.scheduleRepository.findOneBy({ id });
// Keep the vehicle's status/availability in step with the schedule status.
if (updated && dto.status) {
if (
dto.status === MaintenanceStatus.COMPLETED ||
dto.status === MaintenanceStatus.CANCELLED
) {
// Maintenance finished/aborted → vehicle back in service.
await this.setVehicleMaintenanceState(updated.vehicleId, false);
} else if (dto.status === MaintenanceStatus.IN_PROGRESS) {
// Maintenance started → keep the vehicle out of service.
await this.setVehicleMaintenanceState(updated.vehicleId, true);
}
}
return updated!;
}

View File

@@ -1,4 +1,9 @@
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import {
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from "@nestjs/common";
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
import { NotificationStrategy } from "./strategies/notification.strategy";
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
@@ -27,8 +32,19 @@ export class NotificationsService {
if (!strategy) {
throw new NotFoundException();
}
// A strategy returning false (or throwing) is a real delivery failure — do
// not swallow it. Surface it so callers observe the failure (existing
// callers wrap directSend in try/catch for best-effort notifications).
const sent = await strategy.send(recipient, message);
this.logger.log(`is sent - ${sent}`);
if (!sent) {
this.logger.error(
`Notification via ${method} to ${recipient} failed to send`,
);
throw new ServiceUnavailableException(
`Failed to send ${method} notification`,
);
}
this.logger.log(`Notification via ${method} to ${recipient} sent`);
}
async notifyDriverVehicleAssignment(params: {

View File

@@ -1,12 +1,28 @@
import { Injectable, Logger } from "@nestjs/common";
import { NotificationStrategy } from "./notification.strategy";
import { EmailClientService } from "../email-client.service";
@Injectable()
export class EmailNotificationStrategy implements NotificationStrategy {
private readonly logger = new Logger(EmailNotificationStrategy.name);
constructor() { }
constructor(private readonly emailClient: EmailClientService) { }
async send(recipient: string, message: string): Promise<boolean> {
this.logger.log(`${recipient}, ${message}`)
return false;
try {
// Route through the shared email client (RabbitMQ hand-off). `queued`
// reflects whether the message was accepted for delivery; a false or
// a thrown result is a real failure the caller must observe.
const { queued } = await this.emailClient.sendEmail({
to: recipient,
subject: "EDR Freight notification",
text: message,
});
return queued;
} catch (err) {
this.logger.error(
`Failed to send email to ${recipient}: ${err instanceof Error ? err.message : String(err)}`,
err instanceof Error ? err.stack : undefined,
);
return false;
}
}
}
}

View File

@@ -19,6 +19,9 @@ function toTarget(phone?: string, email?: string): OtpTarget {
throw new BadRequestException("phone or email is required");
}
// TODO: these public routes need per-target + per-IP rate limiting (a NestJS
// ThrottlerGuard / @Throttle on /otp/send and /otp/verify). No Throttler is
// wired into the app yet; add @nestjs/throttler and apply it here.
@Controller("otp")
@Public()
export class OtpController {

View File

@@ -1,18 +1,61 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OtpService } from './otp.service';
import { OtpService, normalizeOtpTarget } from './otp.service';
describe('OtpService', () => {
let service: OtpService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [OtpService],
}).compile();
service = module.get<OtpService>(OtpService);
describe('normalizeOtpTarget', () => {
it('canonicalises Ethiopian forms to one E.164 key', () => {
const forms = ['+251986680099', '251986680099', '0986680099', '+251 98 668 0099'];
const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone);
expect(new Set(keys)).toEqual(new Set(['+251986680099']));
});
it('should be defined', () => {
expect(service).toBeDefined();
it('maps local 07… mobile to +2517…', () => {
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
});
it('passes email targets through untouched', () => {
expect(normalizeOtpTarget({ email: 'a@b.com' })).toEqual({ email: 'a@b.com' });
});
it('keeps an already-normalised number stable (idempotent)', () => {
const once = normalizeOtpTarget({ phone: '0986680099' }).phone!;
expect(normalizeOtpTarget({ phone: once }).phone).toBe(once);
});
});
describe('OtpService — send/verify agree across phone formats', () => {
// In-memory fake keyed by the exact phone string the service stores under, so
// the test proves normalisation makes send and verify collide on one key.
function makeService() {
const rows = new Map<string, { phone?: string; email?: string; otp: string; updatedAt: Date }>();
const repo = {
findByTarget: jest.fn(async (t: { phone?: string; email?: string }) =>
rows.get(t.email ?? t.phone!) ?? null,
),
updateOtp: jest.fn(async (existing: { otp: string }, otp: string) => {
existing.otp = otp;
}),
createOtp: jest.fn(async (t: { phone?: string; email?: string }, otp: string) => {
rows.set(t.phone ?? t.email!, { ...t, otp, updatedAt: new Date(0) });
}),
deleteOtp: jest.fn(async (row: { phone?: string; email?: string }) => {
rows.delete(row.phone ?? row.email!);
}),
};
const sms = { sendSms: jest.fn().mockResolvedValue(undefined) };
const email = { sendEmail: jest.fn().mockResolvedValue(undefined) };
const service = new OtpService(repo as never, sms as never, email as never);
return { service, rows };
}
it('verifies a code sent to +251… when verify is called with 09…', async () => {
const { service, rows } = makeService();
await service.sendOtp({ phone: '+251986680099' });
const stored = [...rows.values()][0]!.otp;
// Fresh TTL: stamp updatedAt to now so the action verifier does not expire it.
[...rows.values()][0]!.updatedAt = new Date();
await expect(
service.verifyOtpForAction({ phone: '0986680099' }, stored),
).resolves.toEqual({ success: true });
});
});

View File

@@ -1,6 +1,7 @@
// otp.service.ts
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { randomInt } from "node:crypto";
import { OtpRepository } from "./otp.repository";
@@ -11,6 +12,28 @@ import { EmailClientService } from "../notifications/email-client.service";
// reaches here.
export type OtpTarget = { phone?: string; email?: string };
/**
* Canonicalise a phone to E.164 so the code stored on send and the one looked
* up on verify collide regardless of how the number was typed. Without this,
* `+251986680099`, `251986680099` and `0986680099` are three different keys and
* a code sent to one is invisible to the others — the send/verify halves must
* agree on the exact string. Ethiopian local `09…`/`07…` (10 digits) maps to
* `+2519…`/`+2517…`; a bare `251…` gains its `+`; anything already `+…` is kept.
* Email targets pass through untouched.
*/
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
if (target.email || !target.phone) return target;
const raw = target.phone.trim();
const digits = raw.replace(/[^\d+]/g, '');
if (digits.startsWith('+')) return { phone: digits };
const bare = digits.replace(/^0+/, '');
if (/^251\d{9}$/.test(digits)) return { phone: `+${digits}` };
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return { phone: `+251${bare}` };
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
// it looks like a full international number, else leave as typed.
return { phone: digits.length >= 11 ? `+${digits}` : raw };
}
@Injectable()
export class OtpService {
logger = new Logger(OtpService.name);
@@ -25,14 +48,19 @@ export class OtpService {
// ---------------------------------------------------------------------------
generateOtp(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
// Cryptographically secure 6-digit code (100000999999). Math.random() is a
// non-CSPRNG and must never be used to mint a security token.
return randomInt(100000, 1000000).toString();
}
// ---------------------------------------------------------------------------
// Send OTP
// ---------------------------------------------------------------------------
async sendOtp(target: OtpTarget) {
async sendOtp(rawTarget: OtpTarget) {
// Store under the canonical E.164 key so verify (which normalises the same
// way) always finds this row regardless of how either side typed the number.
const target = normalizeOtpTarget(rawTarget);
try {
// The verification code is generated server-side — never supplied by the
// caller — so the OTP stays a secret known only to the server and the
@@ -50,8 +78,13 @@ export class OtpService {
await this.otpRepository.createOtp(target, otp);
}
// A freshly issued code gets a fresh guess budget.
this.actionAttempts.delete(this.targetKey(target));
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
// here let an attacker wipe the per-target guess budget just by calling
// /otp/send between guesses. The counter is cleared only when the code is
// consumed/expired during verification.
// TODO: add per-target + per-IP rate limiting on the public /otp/send and
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
// in the codebase yet.
if (target.email) {
// send email (queued to RabbitMQ via the shared Email service)
@@ -91,9 +124,13 @@ export class OtpService {
// Verify OTP
// ---------------------------------------------------------------------------
async verifyOtp(target: OtpTarget, otp: string) {
async verifyOtp(rawTarget: OtpTarget, otp: string) {
// Same canonicalisation as sendOtp so a code stored under +2519… is found
// when verify is called with 09… (or any equivalent form).
const target = normalizeOtpTarget(rawTarget);
// find the channel's row
const otpData = await this.otpRepository.findByTarget(target);
const key = this.targetKey(target);
// not found
if (!otpData) {
@@ -102,13 +139,35 @@ export class OtpService {
);
}
// invalid otp
// TTL: reuse the same age window as the hardened action verifier — an old
// code can't be verified.
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
if (ageMs > this.ACTION_OTP_TTL_MS) {
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
throw new BadRequestException(
"Verification code has expired. Request a new one.",
);
}
// invalid otp — per-target attempt cap so a 6-digit code can't be
// brute-forced within its TTL; the code is burned once the budget is spent.
if (otpData.otp !== otp) {
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
throw new BadRequestException(
"Too many incorrect attempts. Request a new code.",
);
}
this.actionAttempts.set(key, attempts);
throw new BadRequestException("Invalid OTP");
}
// mark verified
await this.otpRepository.markVerified(otpData);
// single-use: consume the code on success so it can't be replayed.
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
return {
success: true,
@@ -142,10 +201,11 @@ export class OtpService {
}
async verifyOtpForAction(
target: OtpTarget,
rawTarget: OtpTarget,
otp: string,
ttlMs: number = this.ACTION_OTP_TTL_MS,
) {
const target = normalizeOtpTarget(rawTarget);
const otpData = await this.otpRepository.findByTarget(target);
const key = this.targetKey(target);

View File

@@ -1,8 +1,15 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { TrainScheduleStatus } from '@edr/types';
import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto';
@@ -112,6 +119,30 @@ export class RoutesService {
? await this.validateMilestones(dto.milestones)
: null;
// Milestones or endpoints are about to be rewritten — reject if any
// non-terminal schedule still references this route, otherwise its stop list
// and distances would silently shift under a live plan. Status-only /
// label-only edits (no milestones supplied) are always allowed.
if (milestoneInput) {
const activeSchedules = await this.dataSource
.getRepository(TrainSchedule)
.count({
where: {
routeId: id,
status: In([
TrainScheduleStatus.Draft,
TrainScheduleStatus.Scheduled,
TrainScheduleStatus.Dispatched,
]),
},
});
if (activeSchedules > 0) {
throw new ConflictException(
'This route is used by active train schedules and its stops cannot be changed. Create a new route instead.',
);
}
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(Route).update(id, {
originYardId: milestoneInput?.originYardId ?? existing.originYardId,

View File

@@ -1,4 +1,5 @@
import {
BadRequestException,
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
@@ -24,6 +25,23 @@ export class PriorityConfigsController {
return this.service.findAll(query);
}
// Static route — must stay above `:id` (Express matches in declaration order).
@Get('next-range')
@RuleEngineView('priority-configs')
@ApiOperation({
summary:
"Where the next contiguous range for a type (and currency) must start, plus the type's ceiling",
})
nextRange(
@Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
@Query('currency') currency?: string,
) {
if (!['WAGON', 'CURRENCY', 'CUSTOMS'].includes(type)) {
throw new BadRequestException('type must be WAGON, CURRENCY, or CUSTOMS');
}
return this.service.nextRange(type, currency ?? null);
}
@Get(':id')
@RuleEngineView('priority-configs')
@ApiOperation({ summary: 'Get a priority config by ID' })

View File

@@ -12,6 +12,7 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { isSuperAdmin } from '../../../common/freight-permission.util';
import {
DecidePriorityRuleChangeDto,
SubmitPriorityRuleChangeDto,
@@ -56,7 +57,9 @@ export class PriorityRuleChangeRequestsController {
@Body() dto: DecidePriorityRuleChangeDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.approve(id, user?.id, dto.decisionNote);
// Super admins have full backoffice authority — they may approve a change
// they submitted; everyone else is held to separation of duties.
return this.service.approve(id, user?.id, dto.decisionNote, isSuperAdmin(user));
}
@Post(':id/reject')

View File

@@ -4,7 +4,9 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { isSuperAdmin } from '../../../common/freight-permission.util';
import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import {
@@ -70,9 +72,11 @@ export class RatesController {
@ApiOperation({ summary: 'CEO approves a rate' })
approve(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
return this.service.approve(id, resolveAuthUserId(user));
// Super admins have full backoffice authority — they may approve a rate
// they proposed; everyone else is held to separation of duties.
return this.service.approve(id, resolveAuthUserId(user), isSuperAdmin(user));
}
@Delete(':id')

View File

@@ -37,6 +37,8 @@ export function deriveRateType(input: {
return 'DEMURRAGE';
case 'PIL_EXTRA_FEE':
return 'PIL_EXTRA_FEE';
case 'CUSTOMS_CLEARANCE':
return 'CUSTOMS_CLEARANCE';
}
}

View File

@@ -28,8 +28,14 @@ export function allowedRateUnits(input: {
return ['PER_CONTAINER', 'PER_TON'];
case 'DEMURRAGE':
return ['PER_CONTAINER', 'PER_TON'];
case 'WITH_RETURN':
// Container-only empty-return service — bills per returned container.
return ['PER_CONTAINER', 'FLAT'];
case 'CANCELLATION':
return ['FLAT', 'PER_INVOICE'];
case 'CUSTOMS_CLEARANCE':
// Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL).
return ['FLAT'];
case 'CONSOLIDATION':
return ['PER_CONTAINER', 'FLAT'];
case 'SHIPPING_LINE':

View File

@@ -20,7 +20,9 @@ export const RATE_TYPES = [
'OVERWEIGHT_PER_TON',
'HAZARD_SURCHARGE',
'REEFER_SURCHARGE',
'RETURN_SURCHARGE',
'PIL_EXTRA_FEE',
'CUSTOMS_CLEARANCE',
] as const;
export type RateType = typeof RATE_TYPES[number];
@@ -70,11 +72,17 @@ export const RATE_TRIGGERS = [
'HAZARDOUS',
'OVERWEIGHT',
'REEFER',
// Empty-container return service (container freight only) — fires when the
// booking ships WITH_RETURN, billed like hazard/reefer (usually PER_CONTAINER).
'WITH_RETURN',
'SHIPPING_LINE',
'CONSOLIDATION',
'CANCELLATION',
'DEMURRAGE',
'PIL_EXTRA_FEE',
// Customs clearance service fee — billed up front via a clearance invoice,
// never auto-applied to booking pricing (matchesTrigger returns false).
'CUSTOMS_CLEARANCE',
] as const;
export type RateTrigger = typeof RATE_TRIGGERS[number];

View File

@@ -53,6 +53,11 @@ export interface BookingEvaluationInput {
isHazardous: boolean;
/** Booking-level reefer flag; ORed with per-container reefer. */
isReefer?: boolean;
/**
* Booking ships with empty-container return (equipment_return = WITH_RETURN,
* container freight only). Fires the WITH_RETURN surcharge like hazard/reefer.
*/
withReturn?: boolean;
isGovernment?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
@@ -228,6 +233,7 @@ export class RuleEngineService {
const triggered = this.matchesTrigger(rate.trigger, {
isHazardous: input.isHazardous,
hasReefer,
withReturn: input.withReturn ?? false,
hasOverweight,
shippingLineMapped,
allowConsolidation: input.allowConsolidation ?? false,
@@ -456,6 +462,7 @@ export class RuleEngineService {
state: {
isHazardous: boolean;
hasReefer: boolean;
withReturn: boolean;
hasOverweight: boolean;
shippingLineMapped: boolean;
allowConsolidation: boolean;
@@ -469,6 +476,8 @@ export class RuleEngineService {
return truthy(state.isHazardous);
case 'REEFER':
return truthy(state.hasReefer);
case 'WITH_RETURN':
return truthy(state.withReturn);
case 'OVERWEIGHT':
return truthy(state.hasOverweight);
case 'SHIPPING_LINE':

View File

@@ -0,0 +1,229 @@
import { BadRequestException } from '@nestjs/common';
import { PriorityConfig } from '../entities/priority-config.entity';
import { PriorityConfigsService } from './priority-configs.service';
/**
* Contiguous-range rules for priority configs: per type (per currency for
* CURRENCY), ranges run 1..cap with no gaps and no overlaps; the next range
* must start at the lowest uncovered wagon count. Caps: WAGON 50,
* CURRENCY 35, CUSTOMS 15.
*/
describe('PriorityConfigsService range validation', () => {
const rule = (
type: PriorityConfig['type'],
min: number,
max: number,
currency: string | null = null,
id = `${type}-${min}-${max}-${currency ?? 'none'}`,
): PriorityConfig =>
({
id,
type,
label: `${min}-${max}`,
currency,
minWagonCount: min,
maxWagonCount: max,
}) as PriorityConfig;
const serviceWith = (rules: PriorityConfig[]): PriorityConfigsService => {
const repository = {
findAll: jest.fn(async ({ where }: { where: { type: string } }) =>
rules.filter((r) => r.type === where.type),
),
findById: jest.fn(async (id: string) =>
rules.find((r) => r.id === id) ?? null,
),
};
return new PriorityConfigsService(
repository as never,
undefined as never, // DisplayOrderService — unused by range validation
);
};
const attempt = (
svc: PriorityConfigsService,
input: Partial<Parameters<PriorityConfigsService['assertNoRangeCollision']>[0]>,
) =>
svc.assertNoRangeCollision({
type: 'WAGON',
minWagonCount: 1,
maxWagonCount: 5,
...input,
});
it('accepts the first WAGON range starting at 1', async () => {
await expect(
attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5 }),
).resolves.toBeUndefined();
});
it('rejects a first range that does not start at 1', async () => {
await expect(
attempt(serviceWith([]), { minWagonCount: 3, maxWagonCount: 5 }),
).rejects.toThrow(BadRequestException);
});
it('rejects an exact duplicate (15 vs 15)', async () => {
await expect(
attempt(serviceWith([rule('WAGON', 1, 5)]), {
minWagonCount: 1,
maxWagonCount: 5,
}),
).rejects.toThrow(/must start at 6/);
});
it('rejects a partial overlap (47 after 15)', async () => {
await expect(
attempt(serviceWith([rule('WAGON', 1, 5)]), {
minWagonCount: 4,
maxWagonCount: 7,
}),
).rejects.toThrow(/must start at 6/);
});
it('rejects a gap (89 after 15) — next range must start at 6', async () => {
await expect(
attempt(serviceWith([rule('WAGON', 1, 5)]), {
minWagonCount: 8,
maxWagonCount: 9,
}),
).rejects.toThrow(/must start at 6/);
});
it('accepts the contiguous continuation (610 after 15)', async () => {
await expect(
attempt(serviceWith([rule('WAGON', 1, 5)]), {
minWagonCount: 6,
maxWagonCount: 10,
}),
).resolves.toBeUndefined();
});
it('after deleting a middle rule, the next range must fill the lowest gap', async () => {
// Chain was 15, 610, 1120; 610 deleted → next must start at 6.
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
await expect(
attempt(svc, { minWagonCount: 21, maxWagonCount: 25 }),
).rejects.toThrow(/must start at 6/);
await expect(
attempt(svc, { minWagonCount: 6, maxWagonCount: 10 }),
).resolves.toBeUndefined();
});
it('rejects a gap-fill that overruns into the next rule (615 into 1120)', async () => {
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
await expect(
attempt(svc, { minWagonCount: 6, maxWagonCount: 15 }),
).rejects.toThrow(/overlaps existing rule/);
});
it('enforces the per-type ceilings (WAGON 50, CURRENCY 35, CUSTOMS 15)', async () => {
await expect(
attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }),
).rejects.toThrow(/may not exceed 50/);
await expect(
attempt(serviceWith([]), {
type: 'CURRENCY',
currency: 'USD',
minWagonCount: 1,
maxWagonCount: 36,
}),
).rejects.toThrow(/may not exceed 35/);
await expect(
attempt(serviceWith([]), {
type: 'CUSTOMS',
minWagonCount: 1,
maxWagonCount: 16,
}),
).rejects.toThrow(/may not exceed 15/);
});
it('rejects any new rule once the chain covers the full range', async () => {
await expect(
attempt(serviceWith([rule('WAGON', 1, 50)]), {
minWagonCount: 51,
maxWagonCount: 51,
}),
).rejects.toThrow(/may not exceed 50/);
await expect(
attempt(serviceWith([rule('CUSTOMS', 1, 15)]), {
type: 'CUSTOMS',
minWagonCount: 1,
maxWagonCount: 1,
}),
).rejects.toThrow(/already cover the full 115 range/);
});
it('tracks CURRENCY chains per currency — USD and ETB are independent', async () => {
const svc = serviceWith([rule('CURRENCY', 1, 5, 'USD')]);
// ETB has no rules yet → starts at 1.
await expect(
attempt(svc, {
type: 'CURRENCY',
currency: 'ETB',
minWagonCount: 1,
maxWagonCount: 5,
}),
).resolves.toBeUndefined();
// USD must continue at 6.
await expect(
attempt(svc, {
type: 'CURRENCY',
currency: 'USD',
minWagonCount: 1,
maxWagonCount: 5,
}),
).rejects.toThrow(/must start at 6/);
});
it('excludes the rule being edited from its own contiguity check', async () => {
const existing = rule('WAGON', 6, 10, null, 'editing-me');
const svc = serviceWith([rule('WAGON', 1, 5), existing]);
// Re-saving 610 (e.g. changing points) keeps min 6 — allowed.
await expect(
attempt(svc, {
minWagonCount: 6,
maxWagonCount: 12,
excludeId: 'editing-me',
}),
).resolves.toBeUndefined();
});
it('lets an upper rule keep its start while a lower gap exists', async () => {
// Chain 15, [gap 610], 1120: editing 1120 keeps min 11 — a lower gap
// must not block editing an upper rule's points or max.
const upper = rule('WAGON', 11, 20, null, 'upper');
const svc = serviceWith([rule('WAGON', 1, 5), upper]);
await expect(
attempt(svc, {
minWagonCount: 11,
maxWagonCount: 25,
excludeId: 'upper',
}),
).resolves.toBeUndefined();
// But it cannot RELOCATE to an arbitrary start — only keep 11 or fill 6.
await expect(
attempt(svc, {
minWagonCount: 30,
maxWagonCount: 35,
excludeId: 'upper',
}),
).rejects.toThrow(/must start at 6/);
});
it('reports the next-range prefill for the form', async () => {
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
await expect(svc.nextRange('WAGON')).resolves.toEqual({
nextMin: 6,
maxCap: 50,
});
await expect(
serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'),
).resolves.toEqual({ nextMin: null, maxCap: 15 });
await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({
nextMin: 1,
maxCap: 35,
});
});
});

View File

@@ -10,6 +10,31 @@ import {
} from '../interfaces/priority-configs.repository.interface';
import { DisplayOrderService } from './display-order.service';
/** Hard ceiling of each type's wagon-count chain (1..cap, contiguous). */
export const RANGE_CAPS: Record<'WAGON' | 'CURRENCY' | 'CUSTOMS', number> = {
WAGON: 50,
CURRENCY: 35,
CUSTOMS: 15,
};
/**
* Lowest wagon count ≥ 1 not covered by any of `rules` — where the next range
* must start. Null when the chain is already complete up to the type's cap.
*/
function nextRangeStart(
rules: Pick<PriorityConfig, 'type' | 'minWagonCount' | 'maxWagonCount'>[],
): number | null {
const cap = rules.length ? RANGE_CAPS[rules[0].type] : null;
const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount);
let next = 1;
for (const r of sorted) {
if (r.minWagonCount > next) break; // gap before this rule — fill it
next = Math.max(next, r.maxWagonCount + 1);
}
if (cap != null && next > cap) return null;
return next;
}
@Injectable()
export class PriorityConfigsService {
constructor(
@@ -73,10 +98,13 @@ export class PriorityConfigsService {
}
/**
* No two rules of the same type (and, for CURRENCY rules, the same currency)
* may cover overlapping wagon-count ranges — a booking must match at most one
* rule per type. Rejects an exact duplicate (15 vs 15) and any partial
* overlap (15 vs 47). Ranges are inclusive on both ends.
* Range rules per type (and, for CURRENCY rules, per currency):
* - ranges never overlap — a booking matches at most one rule per type;
* - ranges are contiguous from 1: a new range must START at the lowest
* wagon count not yet covered (after 15 the next is 6…; deleting a
* middle rule opens a gap and the next create must fill it first);
* - each type has a hard ceiling: WAGON 50, CURRENCY 35, CUSTOMS 15.
* Ranges are inclusive on both ends.
*/
async assertNoRangeCollision(input: {
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
@@ -90,13 +118,49 @@ export class PriorityConfigsService {
'Min wagon count cannot be greater than max wagon count',
);
}
const siblings = await this.repository.findAll({
where: { type: input.type },
});
const clash = siblings.find(
const cap = RANGE_CAPS[input.type];
if (input.maxWagonCount > cap) {
throw new BadRequestException(
`${input.type} ranges may not exceed ${cap}` +
`${input.minWagonCount}${input.maxWagonCount} goes past the ceiling.`,
);
}
const siblings = (
await this.repository.findAll({ where: { type: input.type } })
).filter(
(s) =>
s.id !== input.excludeId &&
(input.type !== 'CURRENCY' || (s.currency ?? null) === (input.currency ?? null)) &&
(input.type !== 'CURRENCY' ||
(s.currency ?? null) === (input.currency ?? null)),
);
const expectedStart = nextRangeStart(siblings);
// An edited rule may always KEEP its current start (so a gap lower in the
// chain never blocks editing an upper rule's points/max) — or move down to
// fill that lowest gap.
const currentStart = input.excludeId
? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null
: null;
if (expectedStart == null && currentStart == null) {
throw new BadRequestException(
`${input.type} rules already cover the full 1${cap} range — ` +
'delete or shrink an existing rule first.',
);
}
if (
input.minWagonCount !== expectedStart &&
input.minWagonCount !== currentStart
) {
throw new BadRequestException(
`The next ${input.type} range must start at ${expectedStart} ` +
`(ranges are contiguous — no gaps, no overlaps). ` +
`You entered ${input.minWagonCount}${input.maxWagonCount}.`,
);
}
const clash = siblings.find(
(s) =>
input.minWagonCount <= s.maxWagonCount &&
input.maxWagonCount >= s.minWagonCount,
);
@@ -109,6 +173,24 @@ export class PriorityConfigsService {
}
}
/**
* Where the next range for a type/currency must start, and the type's
* ceiling — feeds the create form so the min field is auto-filled and
* locked. `nextMin` is null when the chain already covers 1..cap.
*/
async nextRange(
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
currency?: string | null,
): Promise<{ nextMin: number | null; maxCap: number }> {
const siblings = (
await this.repository.findAll({ where: { type } })
).filter(
(s) =>
type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null),
);
return { nextMin: nextRangeStart(siblings), maxCap: RANGE_CAPS[type] };
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);

View File

@@ -5,6 +5,7 @@ import {
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
@@ -79,9 +80,20 @@ export class PriorityRuleChangeRequestsService {
id: string,
userId?: string | null,
decisionNote?: string,
canSelfApprove = false,
): Promise<PriorityRuleChangeRequest> {
const request = await this.findPending(id);
// Separation of duties: the requester cannot approve their own change —
// except super admins, who have full backoffice authority.
// TODO: split approval into a distinct approver permission rather than
// relying on this id check.
if (!canSelfApprove && userId && userId === request.requestedByUserId) {
throw new ForbiddenException(
'You cannot approve a change request you submitted',
);
}
// Apply the change through the normal service so currency + range-collision
// validation runs against the CURRENT rules; a stale request that now
// collides fails here and stays PENDING for the approver to see the error.

View File

@@ -1,6 +1,7 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
@@ -194,11 +195,18 @@ export class RatesService {
}
/** CEO approves a rate — moves to LIVE. */
async approve(id: string, approverUserId: string): Promise<Rate> {
async approve(id: string, approverUserId: string, canSelfApprove = false): Promise<Rate> {
const rate = await this.findById(id);
if (rate.status !== 'PENDING_APPROVAL') {
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
}
// Separation of duties: the proposer cannot approve their own rate — except
// super admins, who have full backoffice authority (propose + approve).
// TODO: split approval into a distinct CEO/approver permission — a normal
// proposer who also holds the approve permission is still the wrong signer.
if (!canSelfApprove && approverUserId === rate.proposedByStaffId) {
throw new ForbiddenException('You cannot approve a rate you proposed');
}
const updated = await this.repository.update(id, {
status: 'LIVE',
approvedByCeoId: approverUserId,

View File

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDateString } from 'class-validator';
import { PreviewRescheduleDto } from './preview-reschedule.dto';
/**
* Body for the maintenance-reschedule endpoint. This must be a real class (not
* the previous `PreviewRescheduleDto & { newDepartureDate: string }`
* intersection): an intersection type carries no class-validator metadata, so
* Nest's ValidationPipe silently skipped validation of the whole payload.
*/
export class MaintenanceRescheduleDto extends PreviewRescheduleDto {
@ApiProperty({ example: '2026-06-22T08:00:00.000Z' })
@IsDateString()
newDepartureDate!: string;
}

View File

@@ -8,6 +8,7 @@ import {
resolveAuthUserId,
} from '../../common/resolve-auth-user-id';
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
import { SchedulingRescheduleService } from './scheduling-reschedule.service';
@ApiTags('train-scheduling')
@@ -53,7 +54,7 @@ export class SchedulingMaintenanceController {
@ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' })
maintenance(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: PreviewRescheduleDto & { newDepartureDate: string },
@Body() dto: MaintenanceRescheduleDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.schedulingRescheduleService.maintenanceReschedule(

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EntityManager, Repository } from 'typeorm';
import { SchedulingEvent, type RescheduleTrigger } from './entities/scheduling-event.entity';
@@ -12,14 +12,18 @@ export class SchedulingRescheduleRepository {
) {}
/** Persist an audit record for a completed reschedule. */
async createEvent(data: {
trainScheduleId: string;
trigger: RescheduleTrigger;
actorUserId?: string;
reason?: string;
planSnapshot: Record<string, unknown>;
displacedBookingIds: string[];
}): Promise<SchedulingEvent> {
return this.repository.save(this.repository.create(data));
async createEvent(
data: {
trainScheduleId: string;
trigger: RescheduleTrigger;
actorUserId?: string;
reason?: string;
planSnapshot: Record<string, unknown>;
displacedBookingIds: string[];
},
manager?: EntityManager,
): Promise<SchedulingEvent> {
const repo = manager ? manager.getRepository(SchedulingEvent) : this.repository;
return repo.save(repo.create(data));
}
}

View File

@@ -54,6 +54,10 @@ describe('SchedulingRescheduleService', () => {
let bookingsRepository: Record<string, jest.Mock>;
let trainSchedulingService: Record<string, jest.Mock>;
let schedulingRescheduleRepository: Record<string, jest.Mock>;
// Sentinel EntityManager the mocked dataSource.transaction hands to the
// callback; executeReschedule threads it into updateStatus/createEvent.
const txManager = {} as never;
let dataSource: { transaction: jest.Mock };
beforeEach(() => {
trainSchedulesRepository = {
@@ -72,6 +76,9 @@ describe('SchedulingRescheduleService', () => {
schedulingRescheduleRepository = {
createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }),
};
dataSource = {
transaction: jest.fn(async (cb: (m: never) => unknown) => cb(txManager)),
};
service = new SchedulingRescheduleService(
trainSchedulesRepository as never,
@@ -83,6 +90,7 @@ describe('SchedulingRescheduleService', () => {
removedFromTrain: jest.fn(),
maintenanceMoved: jest.fn(),
} as never, // notifier
dataSource as never,
);
});
@@ -212,7 +220,7 @@ describe('SchedulingRescheduleService', () => {
incomingBookingIds: ['c1'],
trigger: 'TRAIN_MAINTENANCE',
reason: 'Locomotive service',
newDepartureDate: '2026-06-22T10:00:00.000Z',
newDepartureDate: '2099-06-22T10:00:00.000Z',
},
'staff-1',
);
@@ -220,7 +228,8 @@ describe('SchedulingRescheduleService', () => {
expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith(
'sched-1',
'DRAFT',
{ scheduledDepartureDate: new Date('2026-06-22T10:00:00.000Z') },
{ scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z') },
txManager,
);
expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
@@ -228,6 +237,7 @@ describe('SchedulingRescheduleService', () => {
actorUserId: 'staff-1',
reason: 'Locomotive service',
}),
txManager,
);
expect(result.plan.trigger).toBe('TRAIN_MAINTENANCE');
expect(result.plan.finalBookingIds).toEqual(['c1']);

View File

@@ -3,6 +3,8 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { SchedulingStatus, TrainScheduleStatus } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
@@ -12,6 +14,7 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
export interface RescheduleBookingSummary {
@@ -40,6 +43,8 @@ export class SchedulingRescheduleService {
private readonly trainSchedulingService: TrainSchedulingService,
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
private readonly notifier: BookingNotifierService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
/** Preview who is retained, displaced, and readmitted on a schedule. */
@@ -148,21 +153,38 @@ export class SchedulingRescheduleService {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (dto.newDepartureDate && schedule) {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
schedule.status as TrainScheduleStatus,
{ scheduledDepartureDate: new Date(dto.newDepartureDate) },
);
// M7: validate the requested departure BEFORE mutating anything, so a past
// or malformed date is rejected up front rather than after (un)assign side
// effects have already run. The actual write happens late (below), so a
// failing (un)assign step never leaves the train visibly moved.
let newDeparture: Date | null = null;
if (dto.newDepartureDate) {
newDeparture = new Date(dto.newDepartureDate);
const now = new Date();
if (Number.isNaN(newDeparture.getTime()) || newDeparture <= now) {
throw new BadRequestException('New departure date must be in the future');
}
}
// H18: run the cross-service (un)assign steps FIRST. They own their own
// transactions and are the steps most likely to fail, so doing them before
// the date change + audit write means a failure aborts before anything of
// ours is committed.
for (const bookingId of dto.displacedBookingIds) {
try {
await this.trainSchedulingService.unassignBooking(scheduleId, bookingId);
} catch {
// M11: the unassign failed but this booking is being removed from the
// train — also clear its schedule pointer, otherwise it stays linked
// (stale trainScheduleId) and risks being double-booked. NOTE: the
// TrainScheduleBooking link row / wagon allocations may still persist
// (deleting those lives in TrainSchedulingService, not an injected repo
// we own), so this is a best-effort detach; a human should finish the
// link/allocation cleanup.
await this.bookingsRepository.updateSchedulingFields(bookingId, {
schedulingStatus: SchedulingStatus.Eligible,
wagonsRequired: null,
trainScheduleId: null,
});
}
}
@@ -170,7 +192,7 @@ export class SchedulingRescheduleService {
// A train can be rescheduled even with no bookings (e.g. moved for
// maintenance). assignBookingsToSchedule requires at least one booking, so
// only call it when something is actually being (re)assigned — the new
// departure date above is the meaningful change for an empty train. The
// departure date below is the meaningful change for an empty train. The
// empty-train branch returns the same schedule-detail shape as the assign
// path so callers get a consistent response.
const assignResult = dto.finalBookingIds.length
@@ -186,25 +208,50 @@ export class SchedulingRescheduleService {
deferredBookings: [] as unknown[],
};
await this.schedulingRescheduleRepository.createEvent({
trainScheduleId: scheduleId,
trigger: dto.trigger,
actorUserId,
reason: dto.reason,
planSnapshot: plan as unknown as Record<string, unknown>,
displacedBookingIds: dto.displacedBookingIds,
// H18: apply the date change and write the audit record LAST, together, in a
// single transaction over repositories we own (both updateStatus and
// createEvent accept our manager, so the two writes commit or roll back as
// one). RESIDUAL RISK: the cross-service (un)assign calls above are NOT
// covered by this transaction — they run their own and cannot be threaded
// through this manager without editing TrainSchedulingService. A failure
// between those steps and this block can still leave partial state; a human
// must finish the full cross-service transaction threading.
await this.dataSource.transaction(async (manager) => {
if (newDeparture) {
// M7: raw write of scheduledDepartureDate. We deliberately do NOT
// delegate to TrainSchedulingService.updateScheduleDate, which only
// permits a date change while windowPhase === 'PRE_WINDOW' and would
// reject reschedules of already-open (SCHEDULED) trains. Consequence:
// the booking-window fields are NOT re-derived for the new date here.
await this.trainSchedulesRepository.updateStatus(
scheduleId,
schedule.status as TrainScheduleStatus,
{ scheduledDepartureDate: newDeparture },
manager,
);
}
await this.schedulingRescheduleRepository.createEvent(
{
trainScheduleId: scheduleId,
trigger: dto.trigger,
actorUserId,
reason: dto.reason,
planSnapshot: plan as unknown as Record<string, unknown>,
displacedBookingIds: dto.displacedBookingIds,
},
manager,
);
});
// Notify affected customers (SMS + email). Best-effort — a notification
// failure must never fail the reschedule, so each send is fire-and-forget
// inside the notifier. Government pre-empt already notifies via the batch
// displaced() path, so skip removed-from-train notices for that trigger.
// Use the new departure date when the reschedule moved it (the in-memory
// `schedule` still holds the pre-update date).
const effectiveDeparture = dto.newDepartureDate
? new Date(dto.newDepartureDate)
: schedule.scheduledDepartureDate;
await this.notifyRescheduleOutcome(dto, effectiveDeparture);
// M12: only announce a new departure when the date actually moved —
// `newDeparture` is null when the date was unchanged, so retained customers
// are not falsely told the train was rescheduled.
await this.notifyRescheduleOutcome(dto, newDeparture);
return { plan, schedule: assignResult };
}
@@ -256,17 +303,26 @@ export class SchedulingRescheduleService {
/** Maintenance shortcut: new departure + rebalance. */
async maintenanceReschedule(
scheduleId: string,
dto: PreviewRescheduleDto & { newDepartureDate: string },
dto: MaintenanceRescheduleDto,
actorUserId?: string,
) {
const currentIds = (
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId)
)?.scheduleBookings?.map((l) => l.bookingId) ?? [];
// M13: merge the bookings already on the train with any caller-supplied
// incoming ids and feed the SAME set to both preview and execute. The old
// code dropped the caller's ids whenever the train was non-empty (preview)
// and then executed against a different (raw) set, so the previewed plan and
// the executed plan could diverge.
const mergedIncomingIds = Array.from(
new Set([...currentIds, ...(dto.incomingBookingIds ?? [])]),
);
const preview = await this.previewReschedule(scheduleId, {
...dto,
trigger: 'TRAIN_MAINTENANCE',
incomingBookingIds: currentIds.length ? currentIds : dto.incomingBookingIds,
incomingBookingIds: mergedIncomingIds,
});
return this.executeReschedule(
@@ -274,7 +330,7 @@ export class SchedulingRescheduleService {
{
...dto,
trigger: 'TRAIN_MAINTENANCE',
incomingBookingIds: dto.incomingBookingIds,
incomingBookingIds: mergedIncomingIds,
finalBookingIds: preview.finalBookingIds,
displacedBookingIds: preview.displaced.map((b) => b.id),
},

View File

@@ -2,7 +2,7 @@ export interface SchedulingPriorityBooking {
isGovernment?: boolean;
priorityScore?: number | null;
// One-time bookings always carry a date; general contracts (never scheduled)
// may be null — treated as epoch 0 so they sort last.
// may be null — treated as the far future (MAX_SAFE_INTEGER) so they sort last.
scheduledDate?: Date | string | null;
}
@@ -17,7 +17,11 @@ export function compareSchedulingPriority(
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
if (priorityDiff !== 0) return priorityDiff;
const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
const aTime = a.scheduledDate
? new Date(a.scheduledDate).getTime()
: Number.MAX_SAFE_INTEGER;
const bTime = b.scheduledDate
? new Date(b.scheduledDate).getTime()
: Number.MAX_SAFE_INTEGER;
return aTime - bTime;
}

View File

@@ -1,15 +1,22 @@
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { TrackingService } from "./tracking.service";
@ApiTags("tracking")
@ApiBearerAuth()
@Controller("tracking")
@BookingStaff(FREIGHT_PERMS.tracking.view)
export class TrackingController {
constructor(private readonly trackingService: TrackingService) {}
@Get(":consignmentId")
@ApiOperation({ summary: "Get the tracking timeline for a consignment" })
// TODO: scope to the caller's company — a staffer with tracking:view can
// currently read any consignment's timeline. Add company-ownership filtering
// once the ownership helper is wired into this module.
findByConsignment(
@Param("consignmentId", ParseUUIDPipe) consignmentId: string,
) {

View File

@@ -134,7 +134,7 @@ describe('BookingBatchService — PAID reconcile', () => {
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{
syncPayableDueDate: jest.fn().mockResolvedValue(undefined),
issuePayable: jest.fn().mockResolvedValue(null),
expirePayable: jest.fn().mockResolvedValue(undefined),
} as never,
{ emitPhase: jest.fn() } as never,
@@ -596,7 +596,7 @@ describe('BookingBatchService — PAID reconcile', () => {
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
{ issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
@@ -619,7 +619,7 @@ describe('BookingBatchService — PAID reconcile', () => {
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
{ issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
@@ -650,7 +650,7 @@ describe('BookingBatchService — PAID reconcile', () => {
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
{ issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,

View File

@@ -769,7 +769,54 @@ export class BookingBatchService implements OnModuleInit {
bookings: Booking[],
scheduleId: string,
): Promise<void> {
for (const b of bookings) await this.reserve(b, scheduleId);
// H8: the capacity check (pickExportSchedule → budget.fits) and the
// reservation writes below are not atomic on their own — two concurrent
// export accepts can each see the same train as fitting and both reserve,
// overshooting the train's capacity. Serialize reservations against this
// schedule: take a pessimistic_write lock on the TrainSchedule row
// (SELECT … FOR UPDATE), then RE-VERIFY budget.fits for these bookings'
// combined need from freshly-committed state INSIDE the lock before the
// reserve writes run. A loser (another accept took the space first) gets a
// ConflictException — the staff accept fails and reverts, exactly as an
// up-front full train does. Covered: the fits-vs-reserve overshoot on the
// export FCFS path; the lock is held for the duration of the reserve writes.
await this.dataSource.transaction(async (manager) => {
const locked = await manager.findOne(TrainSchedule, {
where: { id: scheduleId },
lock: { mode: "pessimistic_write" },
});
if (!locked) {
throw new ConflictException(
"Export train is no longer available for reservation",
);
}
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) {
throw new ConflictException(
"Export train is no longer available for reservation",
);
}
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const leg = budget.legOf(
bookings[0].originYardId,
bookings[0].destinationYardId,
);
const need =
bookings.length >= 2
? this.combinedNeed(bookings[0], bookings[1], wagonDims)
: this.needFor(bookings[0], wagonDims);
if (!leg || !budget.fits(need, leg)) {
throw new ConflictException(
"Train is full — no export capacity left for this day",
);
}
for (const b of bookings) await this.reserve(b, scheduleId);
});
this.armSettle(scheduleId);
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
@@ -2270,9 +2317,13 @@ export class BookingBatchService implements OnModuleInit {
paymentDeadline: deadline,
} as never);
booking.trainScheduleId = scheduleId;
// The invoice was generated at booking creation/approval, before this pay
// window opened — refresh its printed due date to the real deadline.
await this.billing.syncPayableDueDate(
// The invoice was generated DRAFT at booking creation / operation-accept,
// before this pay window existed. Reserving is the moment the booking becomes
// payable (SELECTED_FOR_BATCH + a real deadline), so issue the draft here and
// print the deadline as its due date — never earlier, or the customer could
// settle an invoice for a slot they have not been offered yet. Idempotent: a
// re-reserve only refreshes `dueAt`.
await this.billing.issuePayable(
Freight.InvoiceSource.Booking,
booking.id,
deadline,

View File

@@ -1522,22 +1522,48 @@ export class TrainSchedulingService {
manager,
);
const remainingBookings = (schedule.scheduleBookings ?? []).filter(
(sb) => sb.bookingId !== bookingId,
);
if (remainingBookings.length === 0) {
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
schedule.trainSetId,
manager,
// Recompute the train-set composition from whatever survives this removal.
// The removed booking's allocations were already deleted above, so any slot
// left with zero allocations was ridden only by this booking — release it
// (frees its reserved wagon slot). Shared slots keep their surviving
// allocations and are re-weighed. This fixes stale tonnage/length/wagonCount
// and orphaned RESERVED slots on a PARTIAL unassign (previously only the
// fully-empty train was reset).
const survivingSlots = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: schedule.trainSetId },
relations: { allocations: true },
});
let recomputedWeightTons = 0;
let recomputedLengthMeters = 0;
let recomputedWagonCount = 0;
for (const slot of survivingSlots) {
const slotAllocations = slot.allocations ?? [];
if (slotAllocations.length === 0) {
await manager.getRepository(TrainSetWagon).delete(slot.id);
continue;
}
const slotWeight = slotAllocations.reduce(
(sum, a) => sum + Number(a.allocatedWeightTons ?? 0),
0,
);
await manager.getRepository(TrainSetWagon).delete({ trainSetId: schedule.trainSetId });
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
totalWeightTons: 0,
totalLengthMeters: 0,
wagonCount: 0,
status: 'DRAFT',
});
if (Number(slot.assignedWeightTons) !== slotWeight) {
await manager
.getRepository(TrainSetWagon)
.update(slot.id, { assignedWeightTons: roundTons(slotWeight) });
}
recomputedWeightTons += slotWeight;
recomputedLengthMeters += Number(slot.lengthMeters ?? 0);
recomputedWagonCount += 1;
}
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
totalWeightTons: roundTons(recomputedWeightTons),
totalLengthMeters: roundTons(recomputedLengthMeters),
wagonCount: recomputedWagonCount,
// Only downgrade to DRAFT once the train is fully empty; otherwise keep
// the current status (an object literal lets TypeORM's contextual typing
// accept the partial without pulling in relation fields).
...(recomputedWagonCount === 0 ? { status: 'DRAFT' } : {}),
});
});
await this.trainCompositionRemovalLogRepository.create({
@@ -3228,6 +3254,14 @@ export class TrainSchedulingService {
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (
schedule.status !== TrainScheduleStatusEnum.Draft &&
schedule.status !== TrainScheduleStatusEnum.Scheduled
) {
throw new BadRequestException(
`Cannot cancel a ${schedule.status} train; only DRAFT or SCHEDULED schedules may be cancelled`,
);
}
const now = new Date();
@@ -3397,6 +3431,27 @@ export class TrainSchedulingService {
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
}
// Day-match: a booking scheduled for a specific EAT day must board a train
// departing that same day. forceAssign downgrades a mismatch to a warning
// so staff can knowingly move a booking onto an adjacent-day train.
const scheduleDay = eatDay(new Date(dto.scheduleDate));
const dayMismatched = bookings.filter(
(b) =>
!(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
b.scheduledDate != null &&
eatDay(new Date(b.scheduledDate)) !== scheduleDay,
);
if (dayMismatched.length) {
const message = `Bookings scheduled for a different day than this train's departure (${scheduleDay}): ${dayMismatched
.map((b) => b.reference ?? b.id)
.join(', ')}`;
if (forceAssign) {
warnings.push(message);
} else {
violations.push(message);
}
}
if (!forceAssign) {
for (const booking of bookings) {
if (this.isHoldActive(booking)) {

View File

@@ -0,0 +1,29 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
/**
* Edit a built train's display identity: its name and its fixed import/export
* run numbers. Composition (yard, locomotives, wagons) has its own endpoints.
* Omitted fields keep their current value; an empty trainName clears the name.
*/
export class UpdateTrainDetailsDto {
@ApiPropertyOptional({ description: 'Display name; empty string clears it' })
@IsOptional()
@IsString()
@MaxLength(100)
trainName?: string;
@ApiPropertyOptional({ description: 'Fixed IMPORT (even) run number' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(20)
importTrainNumber?: string;
@ApiPropertyOptional({ description: 'Fixed EXPORT (odd) run number' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(20)
exportTrainNumber?: string;
}

View File

@@ -45,7 +45,7 @@ export class Train extends BaseEntity {
trainNumber?: string;
@Column({ name: 'train_name', type: 'varchar', length: 100, nullable: true })
trainName?: string;
trainName?: string | null;
@Column({ name: 'route_id', type: 'uuid', nullable: true })
routeId?: string;

View File

@@ -19,6 +19,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
import { TrainBuilderService } from './train-builder.service';
@@ -59,6 +60,18 @@ export class TrainBuilderController {
return this.trainBuilderService.setLocomotives(id, dto);
}
@Patch(':id/details')
@FleetManage()
@ApiOperation({
summary: "Edit the train's name and fixed import/export run numbers",
})
updateDetails(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTrainDetailsDto,
) {
return this.trainBuilderService.updateDetails(id, dto);
}
@Patch(':id/yard')
@FleetManage()
@ApiOperation({

View File

@@ -6,6 +6,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, ILike, In } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
@@ -17,6 +18,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { TrainLocomotive } from './entities/train-locomotive.entity';
import { Train } from './entities/train.entity';
@@ -331,6 +333,53 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/**
* Edit a built train's display identity: name and fixed import/export run
* numbers. Mirrors the build-time number rules — the pair may not collide
* with any other train's pair or legacy number (friendly 409 ahead of the
* partial unique indexes). Blocked while the train is out on a dispatched
* run, like every other composition edit.
*/
async updateDetails(id: string, dto: UpdateTrainDetailsDto) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const patch: QueryDeepPartialEntity<Train> = {};
if (dto.trainName !== undefined) {
patch.trainName = dto.trainName.trim() || null;
}
const importTrainNumber = dto.importTrainNumber?.trim();
const exportTrainNumber = dto.exportTrainNumber?.trim();
if (importTrainNumber) patch.importTrainNumber = importTrainNumber;
if (exportTrainNumber) patch.exportTrainNumber = exportTrainNumber;
if (importTrainNumber || exportTrainNumber) {
const nextImport = importTrainNumber ?? train.importTrainNumber ?? '';
const nextExport = exportTrainNumber ?? train.exportTrainNumber ?? '';
const numberClash: { code: string }[] = await manager.query(
`SELECT code FROM freight.trains
WHERE deleted_at IS NULL
AND id != $3
AND (import_train_number IN ($1, $2)
OR export_train_number IN ($1, $2)
OR train_number IN ($1, $2))
LIMIT 1`,
[nextImport, nextExport, train.id],
);
if (numberClash.length) {
throw new ConflictException(
`Train number ${nextImport}/${nextExport} is already used by train ${numberClash[0].code}`,
);
}
}
if (Object.keys(patch).length) {
await manager.getRepository(Train).update(train.id, patch);
}
});
return this.getComposition(id);
}
/**
* Relocate the train to another yard. The consist moves as one unit: every
* coupled locomotive and wagon follows to the new yard (so their current

View File

@@ -1,15 +1,19 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { WagonStatus } from '@edr/types';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
import { DataSource, FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
import { CreateTrainDto } from './dto/create-train.dto';
import { UpdateTrainDto } from './dto/update-train.dto';
import { TrainLocomotive } from './entities/train-locomotive.entity';
import { Train } from './entities/train.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
@Injectable()
export class TrainsService {
constructor(
@InjectRepository(Train)
private readonly trainRepo: Repository<Train>,
private readonly dataSource: DataSource,
) {}
create(dto: CreateTrainDto): Promise<Train> {
@@ -54,8 +58,40 @@ export class TrainsService {
return this.trainRepo.save(train);
}
/**
* Delete a built train. Blocked while it still has a live (DRAFT/SCHEDULED/
* DISPATCHED) schedule; otherwise its wagons are freed (back to AVAILABLE)
* and its locomotive links dropped so nothing is stranded, then the train is
* soft-deleted. Mirrors TrainBuilderService.disband but prefers softRemove.
*/
async remove(id: string): Promise<void> {
const train = await this.findById(id);
await this.trainRepo.remove(train);
await this.dataSource.transaction(async (manager) => {
const train = await manager.getRepository(Train).findOne({ where: { id } });
if (!train) throw new NotFoundException(`Train ${id} not found`);
const active: { count: string }[] = await manager.query(
`SELECT COUNT(*)::text AS count
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`,
[id],
);
if (Number(active[0]?.count ?? 0) > 0) {
throw new ConflictException(
'Train has active schedules; cancel them before deleting the train',
);
}
await manager
.getRepository(Wagon)
.update(
{ trainId: train.id },
{ trainId: null, sequenceNumber: null, status: WagonStatus.Available },
);
await manager.getRepository(TrainLocomotive).delete({ trainId: train.id });
await manager.getRepository(Train).softRemove(train);
});
}
}

View File

@@ -1,4 +1,8 @@
import { PartialType } from '@nestjs/swagger';
import { OmitType, PartialType } from '@nestjs/swagger';
import { CreateWagonDto } from './create-wagon.dto';
export class UpdateWagonDto extends PartialType(CreateWagonDto) {}
// `trainId` and `sequenceNumber` are owned by the assign/train-builder flow and
// must never be settable through a generic wagon PATCH — omit them here.
export class UpdateWagonDto extends PartialType(
OmitType(CreateWagonDto, ['trainId', 'sequenceNumber'] as const),
) {}

View File

@@ -1,5 +1,10 @@
import { WagonMovementKind, WagonStatus } from '@edr/types';
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
import {
BadRequestException,
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm';
import { CreateWagonDto } from './dto/create-wagon.dto';
@@ -89,6 +94,20 @@ export class WagonsService {
async update(id: string, dto: UpdateWagonDto, userId?: string | null): Promise<Wagon> {
const wagon = await this.findById(id);
// A wagon coupled to a built train follows the train: its yard and status
// are managed through the train-builder flow, not this generic PATCH.
if (wagon.trainId != null) {
if (dto.currentYardId !== undefined && dto.currentYardId !== wagon.currentYardId) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is coupled to a built train; relocate the train (train-builder) instead of moving the wagon`,
);
}
if (dto.status !== undefined && dto.status !== wagon.status) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is coupled to a built train; detach it via train-builder before changing its status`,
);
}
}
const previousYardId = wagon.currentYardId ?? null;
Object.assign(wagon, dto);
// `findById` eager-loads `currentYard`; when the DTO changes the scalar FK
@@ -135,36 +154,96 @@ export class WagonsService {
async remove(id: string): Promise<void> {
const wagon = await this.findById(id);
await this.wagonRepo.remove(wagon);
// A coupled wagon must be detached via train-builder before it can be
// removed, so a built train never silently loses a wagon.
if (wagon.trainId != null) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is coupled to a built train; detach it via train-builder before deleting it`,
);
}
if (await this.isWagonPinnedToLiveSchedule(id)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be deleted`,
);
}
// Soft delete (deleted_at) — hard-deleting would strand ledger/schedule
// history that references this wagon.
await this.wagonRepo.softRemove(wagon);
}
/**
* A wagon is busy when any live (DRAFT/SCHEDULED/DISPATCHED) schedule pins it
* to one of its slots — schedule occupancy lives on TrainSetWagon rows, not
* on the Wagon entity. Mirrors TrainBuilderService.isWagonPinnedToLiveSchedule.
*/
private async isWagonPinnedToLiveSchedule(wagonId: string): Promise<boolean> {
const rows: { exists: boolean }[] = await this.dataSource.query(
`SELECT TRUE AS exists
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE tsw.physical_wagon_id = $1
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
LIMIT 1`,
[wagonId],
);
return rows.length > 0;
}
async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise<Wagon> {
const wagon = await this.findById(wagonId);
if (wagon.status === WagonStatus.Assigned) {
throw new ConflictException('Wagon already assigned to a train');
// Mirror train-builder attachWagons: only a truly free, available wagon in
// the train's own yard can be coupled, and never onto a dispatched train.
if (wagon.trainId != null) {
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`);
}
if (wagon.status !== WagonStatus.Available) {
throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`);
}
const train = await this.trainRepo.findOne({ where: { id: dto.trainId } });
if (!train) throw new NotFoundException('Train not found');
if (train.status === Freight.TrainStatus.InService) {
throw new ConflictException(
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
);
}
if (wagon.currentYardId !== train.currentYardId) {
throw new BadRequestException(
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
);
}
let sequence: number | null = dto.sequenceNumber ?? null;
if (sequence === null) {
const maxSeq = await this.wagonRepo
.createQueryBuilder('w')
.select('MAX(w.sequenceNumber)', 'max')
.where('w.trainId = :trainId', { trainId: train.id })
.getRawOne();
sequence = (maxSeq?.max ?? 0) + 1;
const maxSeq = await this.wagonRepo
.createQueryBuilder('w')
.select('MAX(w.sequenceNumber)', 'max')
.where('w.trainId = :trainId', { trainId: train.id })
.getRawOne();
const nextSequence = Number(maxSeq?.max ?? 0) + 1;
// An explicit sequence is only honoured when it is the next free slot;
// anything else would duplicate a slot or leave a gap.
if (dto.sequenceNumber != null && dto.sequenceNumber !== nextSequence) {
throw new BadRequestException(
`Sequence ${dto.sequenceNumber} is not the next free slot (${nextSequence}) for train ${train.code}`,
);
}
wagon.trainId = train.id;
wagon.sequenceNumber = sequence;
wagon.sequenceNumber = nextSequence;
wagon.status = WagonStatus.Assigned;
return this.wagonRepo.save(wagon);
}
async unassignFromTrain(wagonId: string): Promise<Wagon> {
const wagon = await this.findById(wagonId);
// A wagon pinned to a live schedule is still operationally committed even
// if the fleet train is being edited — don't free it out from under it.
if (await this.isWagonPinnedToLiveSchedule(wagonId)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be detached`,
);
}
wagon.trainId = null;
wagon.sequenceNumber = null;
wagon.status = WagonStatus.Available;
@@ -201,6 +280,19 @@ export class WagonsService {
throw new NotFoundException('One or more wagons not found');
}
// Only free, available wagons can be bulk-relocated; a coupled wagon
// moves with its train (train-builder), never on its own here.
const blocked = wagons.filter(
(w) => w.trainId != null || w.status !== WagonStatus.Available,
);
if (blocked.length) {
throw new ConflictException(
`Cannot transfer wagons coupled to a train or not available: ${blocked
.map((w) => w.wagonNumber)
.join(', ')}`,
);
}
let moved = 0;
for (const wagon of wagons) {
const previousYardId = wagon.currentYardId ?? null;
@@ -253,6 +345,17 @@ export class WagonsService {
throw new NotFoundException('One or more wagons not found');
}
// A coupled wagon's status is owned by the train-builder flow — refuse to
// flip status on any wagon that is currently on a built train.
const coupled = wagons.filter((w) => w.trainId != null);
if (coupled.length) {
throw new ConflictException(
`Cannot change status of wagons coupled to a built train: ${coupled
.map((w) => w.wagonNumber)
.join(', ')}. Detach them via train-builder first.`,
);
}
for (const wagon of wagons) {
wagon.status = status;
}

View File

@@ -421,6 +421,20 @@ export class WarehouseInventoryController {
return res.send(buffer);
}
@Get('edr-truck-exit-paper/:assignmentId')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Per-truck exit paper PDF for an EDR last-mile truck' })
async edrTruckExitPaper(
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@Res() res: Response,
) {
const { filename, buffer } = await this.inventoryService.edrTruckExitPaper(assignmentId);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Get(':id/grn-document')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View goods received note PDF' })

View File

@@ -2821,6 +2821,16 @@ export class WarehouseInventoryService {
: dto;
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
// The load actually leaving on this truck, in TONNES (the weighing UI is in
// t). Null when the operator skipped weighing — containers may skip, bulk
// never does.
const grossTons = exitInspectionDto.grossWeight ?? null;
const tareTons = exitInspectionDto.tareWeight ?? null;
const netTons =
grossTons != null && tareTons != null
? Math.round((grossTons - tareTons) * 1000) / 1000
: (exitInspectionDto.netWeight ?? null);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, {
releaseDate,
@@ -2848,6 +2858,23 @@ export class WarehouseInventoryService {
// an EXPORT concept (set when a truck delivers into the port). Import
// load + weight are captured on truck departure, not arrival.
}
// EDR last-mile: stamp THIS truck's arrival. Matched by plate rather than
// container so it works for bulk too (bulk trucks carry no container).
if (dto.truckPlateNumber?.trim()) {
await manager.query(
`UPDATE freight.last_mile_vehicle_assignments va
SET arrived_at = COALESCE(va.arrived_at, NOW()), updated_at = NOW()
FROM freight.last_mile lm, freight.vehicles v
WHERE va.last_mile_id = lm.id
AND lm.booking_id = $1
AND lm.deleted_at IS NULL
AND v.id = va.vehicle_id
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
AND va.arrived_at IS NULL
AND va.deleted_at IS NULL`,
[item.bookingId, dto.truckPlateNumber.trim()],
);
}
// Booking-level flag stamped on the FIRST truck arrival. The import
// handover is signed ONCE (before the first truck leaves), even though
// trucks pick up per-container — COALESCE keeps the first timestamp.
@@ -2868,6 +2895,34 @@ export class WarehouseInventoryService {
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
}
}
if (isTruckLeaving && item.bookingId && dto.truckPlateNumber?.trim()) {
// EDR last-mile: this truck is leaving — record its exit and the load it
// actually took. net_weight_tons drives the bulk drawdown (booking VGM
// minus everything already hauled away).
await manager.query(
`UPDATE freight.last_mile_vehicle_assignments va
SET departed_at = COALESCE($3::timestamptz, NOW()),
arrived_at = COALESCE(va.arrived_at, NOW()),
gross_weight_tons = $4,
net_weight_tons = $5,
updated_at = NOW()
FROM freight.last_mile lm, freight.vehicles v
WHERE va.last_mile_id = lm.id
AND lm.booking_id = $1
AND lm.deleted_at IS NULL
AND v.id = va.vehicle_id
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
AND va.departed_at IS NULL
AND va.deleted_at IS NULL`,
[
item.bookingId,
dto.truckPlateNumber.trim(),
dto.gateOutTime ?? null,
grossTons,
netTons,
],
);
}
await this.activityLog.record(
{
activityType: 'INVENTORY_RELEASED',
@@ -2886,9 +2941,56 @@ export class WarehouseInventoryService {
);
});
// Tell the customer their truck has left — one hook covers BOTH self-haul and
// EDR last-mile, since release() is the single exit path for either. Outside
// the transaction and fire-and-forget: notifying must never fail the exit.
if (isTruckLeaving && item.bookingId) {
void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons);
}
return this.findById(id);
}
/**
* Best-effort truck-departure notification to the booking's company across
* every channel: in-app (portal inbox) + SMS + email. Never throws — a missing
* provider or contact must not break the exit flow.
*/
private async notifyTruckDeparture(
bookingId: string,
plateNumber: string | null,
netTons: number | null,
): Promise<void> {
try {
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
await this.dataSource.query(
`SELECT company_id AS "companyId", reference
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!booking?.companyId) return;
const ref = booking.reference ?? bookingId;
const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck';
const load = netTons != null && netTons > 0 ? ` carrying ${netTons} t` : '';
const body = `${truck} has left the warehouse for booking ${ref}${load}.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: 'Truck left the warehouse',
body,
link: `/bookings/${bookingId}`,
data: { bookingId, plateNumber, netTons, action: 'TRUCK_LEFT' },
});
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(
`Truck-departure notify failed for ${bookingId}: ${(err as Error).message}`,
);
}
}
async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,
@@ -3213,6 +3315,75 @@ export class WarehouseInventoryService {
};
}
/**
* Exit paper for an EDR last-mile truck (one per truck, keyed on the vehicle
* assignment). Deliberately NOT gated on the handover: EDR handovers are
* generated at delivery — i.e. after the truck has already left — so there is
* nothing to sign at exit time. Warehouse-fee clearance still applies.
*/
async edrTruckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> {
const [truck] = await this.dataSource.query(
`SELECT lm.booking_id AS "bookingId",
COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber",
COALESCE(
v.assigned_driver_name,
NULLIF(TRIM(CONCAT(d.first_name, ' ', d.last_name)), '')
) AS "driverName",
v.vehicle_type AS "truckType",
va.gross_weight_tons AS "grossWeightKg",
va.departed_at AS "departedAt",
b.reference AS "bookingReference",
company.name AS "customerName"
FROM freight.last_mile_vehicle_assignments va
JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
JOIN freight.vehicles v ON v.id = va.vehicle_id
LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id AND d.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE va.id = $1 AND va.deleted_at IS NULL`,
[assignmentId],
);
if (!truck) throw new NotFoundException(`EDR truck assignment ${assignmentId} not found`);
const [inv]: Array<{ id: string }> = await this.dataSource.query(
`SELECT id FROM freight.warehouse_inventory
WHERE booking_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1`,
[truck.bookingId],
);
if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id);
// Bulk trucks carry no containers — the table is then empty and the paper
// stands on the weighed gross alone.
const containers: Array<{ containerNumber: string; goods: string | null }> =
await this.dataSource.query(
`SELECT vc.container_number AS "containerNumber",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods
FROM freight.last_mile_vehicle_containers vc
JOIN freight.last_mile lm ON lm.id = vc.last_mile_id AND lm.deleted_at IS NULL
JOIN freight.bookings b ON b.id = lm.booking_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
WHERE vc.assignment_id = $1 AND vc.deleted_at IS NULL
ORDER BY vc.container_number`,
[assignmentId],
);
const html = this.buildTruckExitPaperHtml({
reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`,
bookingReference: truck.bookingReference,
customerName: truck.customerName,
plateNumber: truck.plateNumber,
driverName: truck.driverName ?? '-',
truckType: truck.truckType ?? '-',
grossWeightKg: Number(truck.grossWeightKg ?? 0),
gateOut: truck.departedAt,
containers,
});
return {
filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'),
};
}
private buildTruckExitPaperHtml(data: {
reference: string;
bookingReference: string;
@@ -3728,20 +3899,30 @@ export class WarehouseInventoryService {
);
} else {
// EDR last-mile: the handover is per delivering truck. Resolve the
// vehicle that carried this item's container so each truck gets its own
// handover (falls back to a booking-level one when unresolvable).
// vehicle from the truck's own container list (the earlier lookup went
// through last_mile_container_allocations, which nothing ever writes —
// so truckPlate was always null and every booking collapsed to a single
// booking-level handover). Bulk has no container, so fall back to the
// delivery's single truck; a booking-level handover when unresolvable.
let truckPlate: string | null = null;
if (item.containerId) {
const [veh]: Array<{ plate: string | null }> = await manager.query(
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
FROM freight.last_mile_container_allocations lca
JOIN freight.vehicles v ON v.id = lca.vehicle_id
WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL
LIMIT 1`,
[item.containerId],
);
truckPlate = veh?.plate ?? null;
}
const [veh]: Array<{ plate: string | null }> = await manager.query(
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
FROM freight.last_mile_vehicle_assignments va
JOIN freight.last_mile lm
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
JOIN freight.vehicles v ON v.id = va.vehicle_id
LEFT JOIN freight.last_mile_vehicle_containers vc
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
LEFT JOIN freight.containers cont
ON cont.container_number = vc.container_number AND cont.deleted_at IS NULL
WHERE lm.booking_id = $1
AND va.deleted_at IS NULL
AND ($2::uuid IS NULL OR cont.id = $2::uuid)
ORDER BY (cont.id IS NOT NULL) DESC, va.created_at ASC
LIMIT 1`,
[item.bookingId, item.containerId ?? null],
);
truckPlate = veh?.plate ?? null;
await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager);
}
}

View File

@@ -1,5 +1,5 @@
import { AppDataSource } from '../data-source';
import { SeedEdRWagonFleet1750400000000 } from '../migrations/1750400000000-SeedEdRWagonFleet';
import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering';
async function seedEdRWagons() {
await AppDataSource.initialize();
@@ -10,28 +10,32 @@ async function seedEdRWagons() {
await queryRunner.connect();
await queryRunner.startTransaction();
await new SeedEdRWagonFleet1750400000000().up(queryRunner);
await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner);
const [summary] = await queryRunner.query(`
const summary = await queryRunner.query(`
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE wt.code = 'PW2')::int AS pw2,
COUNT(*) FILTER (WHERE wt.code = 'CW4')::int AS cw4,
COUNT(*) FILTER (WHERE wt.code = 'CW3')::int AS cw3,
COUNT(*) FILTER (WHERE wt.code = 'KW2')::int AS kw2,
COUNT(*) FILTER (WHERE wt.code = 'KW3')::int AS kw3,
COUNT(*) FILTER (WHERE wt.code = 'NW5')::int AS nw5,
COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['CONTAINER'])::int AS container_ready,
COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['BULK'])::int AS bulk_ready,
COUNT(*) FILTER (WHERE w.status = 'IMPORT_READY')::int AS import_ready
wt.code,
wt.name,
COUNT(*)::int AS wagons,
MIN(w.wagon_number) AS first_wagon,
MAX(w.wagon_number) AS last_wagon,
COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available,
COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard
FROM freight.wagons w
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER0940';
WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER1100'
GROUP BY wt.code, wt.name
ORDER BY MIN(w.wagon_number);
`);
const [totals] = await queryRunner.query(`
SELECT COUNT(*)::int AS total FROM freight.wagons;
`);
await queryRunner.commitTransaction();
console.log('Seeded EDR wagon fleet:', summary);
console.table(summary);
console.log(`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100).`);
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;

View File

@@ -415,6 +415,9 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
// Small test values (< 20) so the surcharge stays a minor add for now.
{ appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 15, rateUnit: "PER_CONTAINER" },
{ appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 2, rateUnit: "PER_TON" },
// Empty-container return service — container contracts opted in at
// creation; bills per container on WITH_RETURN bookings.
{ appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" },
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
// ── First/last-mile road haulage (per km) — drives the mile invoices ──

View File

@@ -4,3 +4,8 @@ VITE_BASE_API_URL=http://localhost:3001
# Proactive token refresh cadence (minutes). Must stay well under the 60-min
# server session window. Default: 10.
VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10
# PostHog — session replay, error tracking, console logs. Both must be set or
# observability stays off (the app works either way). Self-hosted instance.
VITE_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
VITE_POSTHOG_HOST=https://posthog.example.com

View File

@@ -20,6 +20,7 @@
"@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0",
"@mantine/hooks": "^9.3.0",
"@posthog/react": "^1.10.3",
"@radix-ui/react-accordion": "^1.2.13",
"@radix-ui/react-alert-dialog": "^1.1.16",
"@radix-ui/react-avatar": "^1.1.12",
@@ -76,6 +77,7 @@
"lucide-react": "^1.14.0",
"next-themes": "^0.4.6",
"pdf-lib": "^1.17.1",
"posthog-js": "^1.400.1",
"prop-types": "^15.8.1",
"qs": "^6.15.2",
"radix-ui": "^1.4.3",

View File

@@ -7,6 +7,7 @@ import {
type ReactNode,
} from "react";
import { useIdentify } from "@/lib/posthog";
import { getMeRequest, loginRequest, verifyMfaRequest } from "./api";
import {
AUTH_TOKEN_COOKIE,
@@ -69,6 +70,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [loading, setLoading] = useState(true);
const mfaEmailRef = useRef<string | null>(null);
// Attribute replays and exceptions to the signed-in user (id/org only).
useIdentify(user);
const loadCurrentUser = async () => {
const currentUser = await getMeRequest();
setUser(currentUser);

View File

@@ -1,6 +1,11 @@
import axios from "axios";
import { API_BASE_URL } from "@/constants/apiConfig";
import {
emitApiError,
extractApiErrorPayload,
} from "@/components/errors/ApiErrorModal";
import { captureApiError } from "@/lib/posthog";
import {
AUTH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
@@ -78,6 +83,14 @@ api.interceptors.response.use(
async (error) => {
const originalRequest = error.config as RetriableRequest | undefined;
// Report the failure to PostHog. Hooked here rather than inside
// `emitApiError`, which stays silent on suppressed paths (warehouse /
// mile / onboarding) — those failures still need reporting.
// 401s are skipped: an expired session is refreshed below, not a defect.
if (!error.response || error.response.status !== 401) {
captureApiError(error);
}
if (
error.response?.status !== 401 ||
!originalRequest ||
@@ -86,6 +99,12 @@ api.interceptors.response.use(
originalRequest.url?.includes("/auth/mfa-verify") ||
originalRequest.url?.includes("/auth/refresh-token")
) {
// Surface the server's actual error message in the global error modal
// (401s are handled by the session-refresh flow, so skip them).
if (error.response && error.response.status !== 401) {
const payload = extractApiErrorPayload(error);
if (payload) emitApiError(payload);
}
return Promise.reject(error);
}

View File

@@ -1,5 +1,7 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { captureException } from "@/lib/posthog";
interface ErrorBoundaryProps {
children: ReactNode;
}
@@ -21,6 +23,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
}
componentDidCatch(error: Error, info: ErrorInfo) {
captureException(error, { componentStack: info.componentStack });
// eslint-disable-next-line no-console
console.error("[ErrorBoundary] Uncaught render error:", error, info.componentStack);
}

View File

@@ -4,12 +4,12 @@ import { useQuery } from "@tanstack/react-query";
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
import {
Check,
FileCheck,
FilePen,
FileSignature,
MessageSquareWarning,
RefreshCw,
ShieldCheck,
Sparkles,
XCircle,
Zap,
} from "lucide-react";
@@ -180,7 +180,7 @@ export function ContractActionsToolbar({
documentGenerated ? (
<RefreshCw size={16} />
) : (
<Sparkles size={16} />
<FileCheck size={16} />
)
}
loading={mutations.generateContract.isPending}
@@ -196,7 +196,7 @@ export function ContractActionsToolbar({
fullWidth
variant="light"
color="orange"
leftSection={<Sparkles size={16} />}
leftSection={<FileCheck size={16} />}
loading={mutations.generateContract.isPending}
onClick={() => mutations.generateContract.mutate()}
>

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Check, ShieldCheck, X } from "lucide-react";
import { AlertTriangle, Check, FileCheck, ShieldCheck, X } from "lucide-react";
import {
Stack,
Group,
@@ -31,6 +31,7 @@ export function ContractApprovalStepsCard({
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] =
useState<Freight.IContractApprovalStep | null>(null);
const [needsGenerateOpen, setNeedsGenerateOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectStepRow, setRejectStepRow] =
useState<Freight.IContractApprovalStep | null>(null);
@@ -47,7 +48,17 @@ export function ContractApprovalStepsCard({
const nextPending = steps.find((s) => s.status === "PENDING");
const summary = formatContractApprovalProgress(contract.status, steps);
// Approvers must review the GENERATED contract document before approving. If
// it has not been generated yet, block the approval and tell staff to generate
// it first (via "Generate contract" in Staff actions) — mirrors the server
// guard so the user sees a clear reason, not a generic failure toast.
const documentGenerated = Boolean(contract.contractGeneratedAt);
const openApprove = (step: Freight.IContractApprovalStep) => {
if (contract.status === "PENDING_APPROVAL" && !documentGenerated) {
setNeedsGenerateOpen(true);
return;
}
setPendingStep(step);
setConfirmOpen(true);
};
@@ -181,6 +192,47 @@ export function ContractApprovalStepsCard({
</Stack>
</Modal>
<Modal
opened={needsGenerateOpen}
onClose={() => setNeedsGenerateOpen(false)}
title={
<Group gap="xs">
<AlertTriangle size={18} color="var(--mantine-color-orange-6)" />
<Text fw={700}>Generate the contract first</Text>
</Group>
}
radius="md"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
The contract document for{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
has not been generated yet. Approvers must review the generated
document before it can be approved.
</Text>
<Text size="sm" c="dimmed">
Use{" "}
<Text span fw={600} c="dark">
Generate contract
</Text>{" "}
in the Staff actions panel edit the articles first if needed then
return here to approve.
</Text>
<Group justify="flex-end">
<Button
color="edr-green"
leftSection={<FileCheck size={16} />}
onClick={() => setNeedsGenerateOpen(false)}
>
Got it
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={rejectOpen}
onClose={closeReject}

View File

@@ -97,6 +97,7 @@ interface LineErrors {
quantity?: string;
hazardousQuantity?: string;
reeferQuantity?: string;
returnQuantity?: string;
units?: string;
}
@@ -135,6 +136,8 @@ interface ContainerLineDraft {
quantity: string;
hazardousQuantity: string;
reeferQuantity: string;
/** Units of this line shipping with empty-container return (contract WITH_RETURN only). */
returnQuantity: string;
units: UnitDraft[];
}
@@ -155,6 +158,7 @@ function emptyLine(size: string): ContainerLineDraft {
quantity: "1",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [emptyUnit()],
};
}
@@ -272,6 +276,14 @@ export default function GlCreateBookingForm() {
}, [contract]);
const isContainer = contract?.freightType === "CONTAINER";
// The contract gates the empty-container return service — like hazardous.
// WITH_RETURN contracts capture a per-line return quantity instead of the
// legacy booking-level toggle; other contracts cannot switch it on.
const contractWithReturn =
isContainer && contract?.equipmentReturn === "WITH_RETURN";
// Legacy contracts (no equipment return chosen at creation) keep the old
// booking-level toggle.
const legacyReturnToggle = isContainer && !contract?.equipmentReturn;
// Intercity shipments ride a passing import/export train staff pick at
// finalize time — no shipment day is chosen and no window gate applies.
const isIntercity = contract?.tradeDirection === "DOMESTIC";
@@ -354,6 +366,7 @@ export default function GlCreateBookingForm() {
quantity: String(Math.max(1, c.quantity)),
hazardousQuantity: String(c.hazardousQuantity ?? 0),
reeferQuantity: String(c.reeferQuantity ?? 0),
returnQuantity: "0",
units: Array.from({ length: Math.max(1, c.quantity) }, emptyUnit),
})),
);
@@ -390,6 +403,7 @@ export default function GlCreateBookingForm() {
quantity: String(qty),
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: Array.from({ length: qty }, emptyUnit),
};
}),
@@ -542,6 +556,8 @@ export default function GlCreateBookingForm() {
quantity: String(imported.length),
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
returnQuantity:
prev.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
@@ -614,9 +630,17 @@ export default function GlCreateBookingForm() {
errs.reeferQuantity = `Can't exceed the ${qty} container(s) in this line.`;
}
}
if (contractWithReturn) {
const w = Number(line.returnQuantity || 0);
if (Number.isNaN(w) || w < 0) {
errs.returnQuantity = "Enter a valid return quantity.";
} else if (w > qty) {
errs.returnQuantity = `Can't exceed the ${qty} container(s) in this line.`;
}
}
return errs;
});
}, [isContainer, contract, containerLines]);
}, [isContainer, contract, containerLines, contractWithReturn]);
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
@@ -653,7 +677,11 @@ export default function GlCreateBookingForm() {
const cargoValid = isContainer
? lineErrors.every(
(e) =>
!e.quantity && !e.units && !e.hazardousQuantity && !e.reeferQuantity,
!e.quantity &&
!e.units &&
!e.hazardousQuantity &&
!e.reeferQuantity &&
!e.returnQuantity,
) &&
unitErrors.every((line) =>
line.every((e) => !e.containerNumber && !e.vgmTons),
@@ -675,8 +703,10 @@ export default function GlCreateBookingForm() {
? { scheduledDate: new Date(scheduledDate).toISOString() }
: {}),
...(notes.trim() ? { notes: notes.trim() } : {}),
// Equipment return is a container concern — bulk keeps the contract default.
...(isContainer
// Equipment return: WITH_RETURN contracts derive it server-side from the
// per-line return quantities; only legacy contracts (no value chosen at
// creation) still send the booking-level toggle. Bulk keeps the default.
...(legacyReturnToggle
? { equipmentReturn: withReturn ? "WITH_RETURN" : "WITHOUT_RETURN" }
: {}),
};
@@ -689,6 +719,9 @@ export default function GlCreateBookingForm() {
quantity: Number(l.quantity),
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
...(contractWithReturn
? { returnQuantity: Number(l.returnQuantity || 0) }
: {}),
units: l.units.map((u) => ({
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
@@ -1168,6 +1201,28 @@ export default function GlCreateBookingForm() {
styles={fieldStyles}
/>
)}
{contractWithReturn && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="With return qty"
description="Containers EDR returns empty"
min={0}
value={line.returnQuantity}
error={
showErrors
? lineErrors[lineIdx]?.returnQuantity
: undefined
}
onChange={(e) =>
patchLine(lineIdx, {
returnQuantity: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
)}
</Group>
<StepLabel>Per-container details</StepLabel>
@@ -1323,7 +1378,9 @@ export default function GlCreateBookingForm() {
</StepCard>
)}
{isContainer ? (
{/* Legacy contracts only — WITH_RETURN contracts capture per-line
return quantities above, WITHOUT_RETURN contracts locked it off. */}
{legacyReturnToggle ? (
<StepCard>
<StepHeader
icon={<Repeat size={22} />}

View File

@@ -0,0 +1,143 @@
import { useEffect, useState } from "react";
import { Alert, Button, Group, List, Modal, Stack, Text } from "@mantine/core";
import { AlertTriangle } from "lucide-react";
/**
* Global API error modal.
*
* The axios client emits every failed request (with a server response) through
* `emitApiError`; this modal — mounted once at the app root — shows the
* SERVER'S actual `message` instead of a generic "Request failed with status
* code NNN". Pages listed in EXCLUDED_PATH_PATTERNS (warehouse, first/last
* mile, onboarding/auth screens) keep their own inline error handling and
* never trigger it.
*/
export interface ApiErrorPayload {
/** Server messages — the `message` field (string or class-validator array). */
messages: string[];
statusCode?: number;
/** API path that failed, shown as small helper text. */
path?: string;
}
type Listener = (payload: ApiErrorPayload) => void;
let listener: Listener | null = null;
/** Current-page path patterns where the global modal must stay silent. */
const EXCLUDED_PATH_PATTERNS = [
/^\/auth/,
/^\/callback/,
/warehouse/i,
/first-mile/i,
/last-mile/i,
/onboard/i,
/register/i,
];
export function isGlobalErrorModalSuppressed(pathname: string): boolean {
return EXCLUDED_PATH_PATTERNS.some((re) => re.test(pathname));
}
export function emitApiError(payload: ApiErrorPayload): void {
if (isGlobalErrorModalSuppressed(window.location.pathname)) return;
listener?.(payload);
}
/** Pull the server `message` out of an axios-style error. */
export function extractApiErrorPayload(error: unknown): ApiErrorPayload | null {
const err = error as {
response?: {
status?: number;
data?: {
message?: string | string[];
error?: string;
statusCode?: number;
path?: string;
};
};
};
const response = err?.response;
if (!response) return null; // network error / cancellation — not ours
const data = response.data;
const raw = data?.message;
const messages = Array.isArray(raw)
? raw.filter((m): m is string => typeof m === "string" && m.length > 0)
: typeof raw === "string" && raw.length > 0
? [raw]
: [];
if (messages.length === 0) {
messages.push(
data?.error ?? `Request failed with status code ${response.status}`,
);
}
return {
messages,
statusCode: data?.statusCode ?? response.status,
path: data?.path,
};
}
export function ApiErrorModal() {
const [payload, setPayload] = useState<ApiErrorPayload | null>(null);
useEffect(() => {
listener = (next) => {
// Don't stack identical messages while the modal is already showing them.
setPayload((current) =>
current && current.messages.join("\n") === next.messages.join("\n")
? current
: next,
);
};
return () => {
listener = null;
};
}, []);
const close = () => setPayload(null);
return (
<Modal
opened={payload !== null}
onClose={close}
centered
radius="md"
title={
<Group gap="xs">
<AlertTriangle size={18} color="var(--mantine-color-red-6)" />
<Text fw={700}>Request failed</Text>
</Group>
}
>
{payload && (
<Stack gap="md">
<Alert color="red" variant="light">
{payload.messages.length === 1 ? (
<Text size="sm">{payload.messages[0]}</Text>
) : (
<List size="sm" spacing={4}>
{payload.messages.map((m, i) => (
<List.Item key={i}>{m}</List.Item>
))}
</List>
)}
</Alert>
{(payload.statusCode || payload.path) && (
<Text size="xs" c="dimmed">
{payload.statusCode ? `Status ${payload.statusCode}` : null}
{payload.statusCode && payload.path ? " · " : null}
{payload.path}
</Text>
)}
<Group justify="flex-end">
<Button variant="default" onClick={close}>
Close
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -201,7 +201,10 @@ const RuleEngineFormDialog = ({
const payload: Record<string, unknown> = {};
for (const field of visibleFields) {
const raw = values[field.name];
// Derived fields always submit their computed value — never stale state.
const raw = field.computeValue
? (field.computeValue(values) ?? "")
: values[field.name];
if (field.type === "multiselect") {
// Always the full replacement list — the API syncs the relation to it.
payload[field.name] = Array.isArray(raw) ? raw : [];
@@ -348,6 +351,7 @@ const RuleEngineFormDialog = ({
}
const isNumber = field.type === "number";
const computed = field.computeValue ? field.computeValue(values) : undefined;
return (
<TextInput
@@ -359,7 +363,8 @@ const RuleEngineFormDialog = ({
// display order) is a non-negative magnitude — reject negatives outright
// rather than letting a typed "-" reach the API.
min={isNumber ? 0 : undefined}
value={String(values[field.name] ?? "")}
disabled={field.disabled || computed !== undefined}
value={String((computed !== undefined ? computed : values[field.name]) ?? "")}
onChange={(e) => {
const next = e.currentTarget.value;
if (isNumber && next.trim().startsWith("-")) return;

View File

@@ -0,0 +1,123 @@
import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import type { BuiltTrainSummary } from "@/services/trainBuilder.service";
export interface EditTrainDetailsModalProps {
/** Train being edited; null closes the modal. */
train: BuiltTrainSummary | null;
onClose: () => void;
}
/**
* Edit a built train's display identity from the list: its name and its fixed
* import/export run numbers. Composition (yard, locomotives, wagons) is edited
* on the detail page. Number collisions come back as a 409 with the owning
* train's code and surface verbatim.
*/
const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) => {
const { toast } = useToast();
const [name, setName] = useState("");
const [importNo, setImportNo] = useState("");
const [exportNo, setExportNo] = useState("");
useEffect(() => {
if (train) {
setName(train.trainName ?? "");
setImportNo(train.importTrainNumber ?? "");
setExportNo(train.exportTrainNumber ?? "");
}
}, [train]);
const update = useMutation(api.trainBuilder.updateDetails.mutationOptions());
const handleSave = async () => {
if (!train) return;
try {
await update.mutateAsync({
id: train.id,
payload: {
trainName: name.trim(),
// Numbers cannot be cleared — only replaced; empty inputs keep the
// current value (legacy trains may have none yet).
...(importNo.trim() ? { importTrainNumber: importNo.trim() } : {}),
...(exportNo.trim() ? { exportTrainNumber: exportNo.trim() } : {}),
},
});
toast({ title: `Train ${train.code} updated` });
onClose();
} catch (err) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Update failed";
toast({
title: "Could not update train",
description: String(message),
variant: "destructive",
});
}
};
return (
<Modal
opened={Boolean(train)}
onClose={onClose}
title={
<Group gap={8}>
<Pencil size={16} />
<Text fw={700}>Edit train {train?.code ?? ""}</Text>
</Group>
}
centered
size="md"
radius="lg"
>
<Stack gap="md">
<TextInput
label="Train name"
placeholder="Optional display name"
value={name}
onChange={(e) => setName(e.currentTarget.value)}
maxLength={100}
radius="md"
/>
<Group grow>
<TextInput
label="Import train no."
placeholder="e.g. 8002"
value={importNo}
onChange={(e) => setImportNo(e.currentTarget.value)}
maxLength={20}
radius="md"
/>
<TextInput
label="Export train no."
placeholder="e.g. 8001"
value={exportNo}
onChange={(e) => setExportNo(e.currentTarget.value)}
maxLength={20}
radius="md"
/>
</Group>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
color="edr-green"
loading={update.isPending}
onClick={handleSave}
>
Save
</Button>
</Group>
</Stack>
</Modal>
);
};
export default EditTrainDetailsModal;

View File

@@ -95,7 +95,13 @@ function usedWeight(schedule: TrainScheduleDetail): number {
);
}
/** Max pull weight across all locomotives on the set (0 when unknown). */
/**
* Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when
* unknown). The API caps at the weakest loco, not the sum of all locos — a
* consist can only pull as hard as its weakest engine. Note: the API also adds
* the consist tare to the used weight when it checks this cap; tare isn't
* available client-side, so this meter compares cargo-only load against pull.
*/
function pullCapacity(schedule: TrainScheduleDetail): number {
const set = schedule.trainSet;
if (!set) return 0;
@@ -105,7 +111,8 @@ function pullCapacity(schedule: TrainScheduleDetail): number {
: set.locomotive
? [set.locomotive]
: [];
return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0);
if (locos.length === 0) return 0;
return Math.min(...locos.map((l) => Number(l.maxPullWeightTons) || 0));
}
export function ScheduleWorkspacePanel({

View File

@@ -19,7 +19,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
const { toast } = useToast();
const available = (wagons ?? []).filter(
(w) => w.status === Freight.WagonStatus.Available || !w.trainId,
(w) => w.status === Freight.WagonStatus.Available && !w.trainId,
);
const yardLabelById = useMemo(

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