Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice

This commit is contained in:
natib21
2026-07-07 08:33:25 +00:00
167 changed files with 9826 additions and 1225 deletions

View File

@@ -0,0 +1,604 @@
# EDR Freight — Major Flow Variants (each self-contained)
The single master graph lives in [`FREIGHT_MASTER_FLOW.md`](./FREIGHT_MASTER_FLOW.md). This file breaks the
business logic into **one comprehensive, self-contained diagram per major scenario**, each organised with
phase **subgraphs** so it can be read on its own.
**Axes covered**
| Axis | Values |
| --------------- | -------------------------------------------------------------------------------------------- |
| Origin | **One-time booking** · **General contract** (Path A transport-only / Path B GENERAL+customs) |
| Trade direction | **Export** · **Import** · **Intercity / Domestic** |
| Customs | **With customs** · **Without customs** |
**Legend** — (P) Portal (customer) · (B) Backoffice (staff) · (sys) System/event · (green) rounded = success end · (red) rounded = fail end · <> decision.
**Which diagram do I read?**
```mermaid
flowchart LR
classDef dec fill:#f8fafc,stroke:#475569,color:#111
classDef box fill:#e0e7ff,stroke:#3730a3,color:#111
A{"Origin?"}:::dec
A -->|"one-time"| B{"Trade direction?<br/>(gate is direction-driven, NOT a customs toggle)"}:::dec
A -->|"framework agreement"| C{"Contract type?"}:::dec
B -->|"DOMESTIC"| D1["§1 One-time · DOMESTIC (no gate)"]:::box
B -->|"IMPORT / EXPORT<br/>(with or without customs)"| D2["§2 One-time · IMPORT/EXPORT (gate)"]:::box
C -->|"transport-only (self-clearance)"| D3["§3 Contract · Path A"]:::box
C -->|"GENERAL + customs"| D4["§4 Contract · Path B"]:::box
D1 --> E{"Physical direction?"}:::dec
D2 --> E
D3 --> E
D4 --> E
E -->|"export"| F5["§5 EXPORT operations"]:::box
E -->|"import"| F6["§6 IMPORT operations"]:::box
E -->|"domestic"| F7["§7 INTERCITY operations"]:::box
```
> **How the two halves connect:** §1§4 are the **commercial** journeys (intake → approval → contract →
> clearance → operation → payment). §5§7 are the **physical** journeys (mile legs → warehouse → train →
> delivery). A shipment = one commercial variant **+** one physical variant. Each diagram fully details its
> own half and summarises the other so it stands alone.
---
## §1 — One-time booking · DOMESTIC (no clearance gate)
The commercial lifecycle when the counter-sign gate resolves to **no clearance** — which, in code, means
**trade direction = DOMESTIC** (not a customs toggle). Counter-sign goes straight to `FULLY_EXECUTED` and the
booking is enqueued **directly into the scheduling batch pipeline, skipping the operation-request/clearance
phase**. NOTE: Import/export bookings — _even with customs off_ — do **not** land here; they always hit the
clearance gate (§2, just with a lighter "without customs" document set).
```mermaid
flowchart TD
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
classDef back fill:#fef3c7,stroke:#b45309,color:#111
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
classDef dec fill:#f8fafc,stroke:#475569,color:#111
classDef good fill:#86efac,stroke:#166534,color:#062e14
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
pre(["Company ACTIVE (approved profile)"]):::sys
subgraph DRAFT["1 · Create & price"]
direction TB
d1["POST /bookings → DRAFT<br/>reference, containers, cargo modifiers, files (P)"]:::port
d2["POST /bookings/:id/generate-price<br/>rule-engine: LIVE rates + surcharges (P)"]:::port
dW{"weight-limit-rules"}:::dec
dWx(["HARD BLOCK 400 — VGM > capacity"]):::bad
d3["POST /bookings/:id/submit → SUBMITTED<br/>freeze booking_rate_snapshot (P)"]:::port
dP{"price moved?"}:::dec
d3c["confirm-submit → SUBMITTED (P)"]:::port
d1 --> d2 --> dW
dW -->|"over capacity"| dWx
dW -->|"ok / warn+surcharge"| d3 --> dP
dP -->|"yes"| d3c
dP -->|"no"| out1
d3c --> out1
d1 -.->|"delete draft"| ddx(["removed"]):::bad
d3 -.->|"reject price"| drx(["REJECTED"]):::bad
end
out1[" "]:::sys
subgraph INTAKE["2 · Staff intake & approval"]
direction TB
g{"government?"}:::dec
gexp["governmentExpedite → PAID + Eligible (B)"]:::back
i{"staff/accept | request-changes | reject (B)"}:::dec
ir["CHANGES_REQUESTED (B)"]:::back
irx(["REJECTED"]):::bad
ia["→ PENDING_APPROVAL<br/>instantiate approval steps + validity window (B)"]:::back
ac{"chain: LINE_STAFF → DIRECTOR → CEO (B)"}:::dec
acx(["rejectStep → REJECTED"]):::bad
g -->|"yes"| gexp
g -->|"no"| i
i -->|"request-changes"| ir
i -->|"reject"| irx
i -->|"accept"| ia --> ac
ac -->|"rejectStep"| acx
end
subgraph SIGN["3 · Contract doc & sign (DOMESTIC → no gate)"]
direction TB
s1["contract/generate → CONTRACT_READY (B)"]:::back
s2["customer sign → SIGNED_CUSTOMER (P)"]:::port
s3["staff counter-sign (DOMESTIC) → FULLY_EXECUTED<br/>enqueueScheduleProcessing (no op-request) (B)(sys)"]:::back
s1 --> s2 --> s3
end
subgraph OPPAY["4 · Batch pipeline & payment"]
direction TB
fe["FULLY_EXECUTED enters day batch pool (sys)"]:::sys
b1["batch engine offers wagons → SELECTED_FOR_BATCH<br/>invoice generated (sys)"]:::sys
p1["customer pays → gateway → PAID (P)"]:::port
pexp(["pay window lapses → reservation EXPIRED"]):::bad
fe --> b1 --> p1
p1 -.->|"unpaid"| pexp
end
phys(["Physical execution:<br/>§7 intercity → COMPLETED (done)"]):::good
pre --> DRAFT
out1 --> INTAKE
ac -->|"APPROVED"| SIGN
SIGN --> OPPAY
p1 --> phys
gexp -.->|"gov → PAID/Eligible"| phys
```
> **Road-mode note:** a domestic booking billed by road (truck) instead of rail goes through
> `operation/review` → `ROAD_DISPATCH_PENDING` (the road branch shown in the master graph), not the rail
> batch pool above.
---
## §2 — One-time booking · IMPORT / EXPORT (clearance gate)
Every IMPORT/EXPORT one-time booking traverses the clearance gate — **whether or not customs is enabled**
(the customs flag only selects a heavier vs lighter `clearance_*` document set; both go through
`AWAITING_DOCUMENTS`). Commercial spine as §1 (phases 13) **plus** the gate: counter-sign → `AWAITING_DOCUMENTS`
→ document review loop → `CLEARANCE_READY`, phased ET/DJ actions, then operation-request → payment.
```mermaid
flowchart TD
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
classDef back fill:#fef3c7,stroke:#b45309,color:#111
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
classDef dec fill:#f8fafc,stroke:#475569,color:#111
classDef good fill:#86efac,stroke:#166534,color:#062e14
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
a0(["Booking APPROVED & signed by customer<br/>(see §1 phases 13)"]):::sys
subgraph CS["Counter-sign with customs"]
direction TB
cs1["staff counter-sign (IMPORT/EXPORT) → AWAITING_DOCUMENTS (B)"]:::back
end
subgraph DOCS["5 · Document clearance gate"]
direction TB
x1["customer clearance/documents<br/>→ DOCUMENTS_UNDER_REVIEW (P)"]:::port
x2{"GL clearance/review each doc (B)"}:::dec
x2q["doc Queried → customer re-uploads (B)"]:::back
x3["clearance/finalize (100% approved) → CLEARANCE_READY (B)"]:::back
x1 --> x2
x2 -->|"query"| x2q --> x1
x2 -->|"approve all"| x3
end
subgraph PHASED["6 · Phased ET / DJ clearance (as applicable)"]
direction TB
ph1["upload declaration (serial) (B)"]:::back
ph2["duty/tax advise → customer duty-slip (P)(B)"]:::back
ph3["transit permit (ET) (B)"]:::back
ph4["delivery order / release order (DJ) (B)"]:::back
ph5["T1 docs → T1 close (B)"]:::back
ph6["export release / finalize-pre-clearance (B)"]:::back
ph1 --> ph2 --> ph3 --> ph4 --> ph5 --> ph6
end
subgraph OPPAY2["7 · Operation request & payment"]
direction TB
o1["clearance/proceed → OPERATION_REQUEST_PENDING (P)"]:::port
o2{"operation/review (B)"}:::dec
o2c["OPERATION_CHANGES_REQUESTED (B)"]:::back
om{"mode?"}:::dec
ot["accept=train: invoice → FULLY_EXECUTED<br/>→ batch offer → SELECTED_FOR_BATCH (B)(sys)"]:::back
orr["accept=road: invoice → ROAD_DISPATCH_PENDING (B)"]:::back
p1["customer pays → PAID (sys)(P)"]:::sys
pexp(["pay window lapses → EXPIRED"]):::bad
o1 --> o2
o2 -->|"request-changes"| o2c --> o1
o2 -->|"accept"| om
om -->|"train"| ot --> p1
om -->|"road"| orr --> p1
p1 -.->|"unpaid"| pexp
end
cancel(["CANCELLED — staff-only, only from<br/>OPERATION_REQUEST_PENDING here (not from<br/>AWAITING_DOCUMENTS / DOCUMENTS_UNDER_REVIEW)"]):::bad
phys(["Physical execution:<br/>§5 export · §6 import → COMPLETED (done)"]):::good
a0 --> CS --> DOCS
x3 --> PHASED
ph6 --> OPPAY2
p1 --> phys
o1 -.->|"cancel"| cancel
```
---
## §3 — General contract · Path A (transport-only, self-clearance)
Framework agreement where the customer clears customs independently. After the contract is active and
operations verify self-clearance (`SELF_CLEARED`), the **customer books directly** under the contract; each
booking then runs the operation/payment/physical flow.
```mermaid
flowchart TD
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
classDef back fill:#fef3c7,stroke:#b45309,color:#111
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
classDef dec fill:#f8fafc,stroke:#475569,color:#111
classDef good fill:#86efac,stroke:#166534,color:#062e14
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
pre(["Company ACTIVE"]):::sys
subgraph CTR["1 · Contract lifecycle"]
direction TB
c1["POST /contracts → DRAFT<br/>routes + cargo scope + unit rates (NO quantities) (P)"]:::port
c2["generate-price → submit → SUBMITTED<br/>freeze contract_rate_snapshots (P)"]:::port
c3{"staff/accept | request-changes | reject (B)"}:::dec
c3r["CHANGES_REQUESTED (B)"]:::back
c3x(["Contract REJECTED"]):::bad
c4["→ PENDING_APPROVAL (B)"]:::back
c4a{"approval chain (B)"}:::dec
c4x(["rejectStep → REJECTED"]):::bad
c5["generate-contract → CONTRACT_READY (B)"]:::back
c6["customer sign → SIGNED_CUSTOMER (P)"]:::port
c7["staff counter-sign (IMPORT/EXPORT self-clearance) →<br/>AWAITING_CLEARANCE_DOCUMENTS (B)"]:::back
c1 --> c2 --> c3
c3 -->|"request-changes"| c3r --> c2
c3 -->|"reject"| c3x
c3 -->|"accept"| c4 --> c4a
c4a -->|"reject"| c4x
c4a -->|"approve"| c5 --> c6 --> c7
c7 -.->|"lapse"| cexp(["EXPIRED"]):::bad
c7 -.->|"renew"| cren(["RENEWAL_DRAFT → new cycle"]):::bad
end
subgraph SELF["2 · Self-clearance verification"]
direction TB
o1["customer uploads self-clearance docs (P)"]:::port
o2{"ops-review each doc (B)"}:::dec
o2q["query → re-upload (B)"]:::back
o3["ops-finalize → clearanceStatus SELF_CLEARED (B)"]:::back
o1 --> o2
o2 -->|"query"| o2q --> o1
o2 -->|"approve"| o3
end
subgraph BK["3 · Book directly under contract"]
direction TB
b1["customer POST /contracts/:id/bookings (P)"]:::port
bv{"validate-shipment:<br/>window + capacity draw-down + pairing"}:::dec
bvx(["rejected: over capacity /<br/>20ft pairing hard-block"]):::bad
b2["Booking created under contract<br/>(bookings.contract_id) (sys)"]:::sys
b1 --> bv
bv -->|"fail"| bvx
bv -->|"ok"| b2
end
op(["Booking runs operation + payment<br/>(see §1 phase 4) then §5/§6/§7 → COMPLETED (done)"]):::good
pre --> CTR
c7 --> SELF
o3 --> BK
b2 --> op
```
---
## §4 — General contract · Path B (GENERAL + customs)
Framework agreement **with** customs. The customer cannot book directly — they submit a **BookingRequest**
(date + quantities only); GL Ethiopia accepts it and creates the booking, which then runs **per-booking
phased customs** on the `/contracts/bookings/:bookingId/*` surface.
```mermaid
flowchart TD
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
classDef back fill:#fef3c7,stroke:#b45309,color:#111
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
classDef dec fill:#f8fafc,stroke:#475569,color:#111
classDef good fill:#86efac,stroke:#166534,color:#062e14
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
pre(["Company ACTIVE"]):::sys
subgraph CTR["1 · Contract lifecycle (customs)"]
direction TB
c1["POST /contracts → DRAFT → submit → approve → sign (P)(B)"]:::port
c7["staff counter-sign (GENERAL + customs) → CONTRACT_ACTIVE<br/>(contract clearance cycle SKIPPED — runs per-booking) (B)"]:::back
c1 --> c7
c1 -.->|"reject / lapse"| cx(["REJECTED / EXPIRED"]):::bad
end
subgraph REQ["2 · Booking request → GL creates booking"]
direction TB
r1["customer POST /contracts/:id/booking-requests<br/>(date + quantities, no per-unit data) (P)"]:::port
r2{"GL booking-request queue (B)"}:::dec
r2x(["reject / customer cancel →<br/>REJECTED / CANCELLED"]):::bad
r3["GL accept → GL creates booking under contract<br/>(ct:create_booking) (B)"]:::back
r1 --> r2
r2 -->|"reject/cancel"| r2x
r2 -->|"accept"| r3
end
subgraph GLC["3 · Per-booking GL clearance & milestones"]
direction TB
g1["station-assign (route + bind staff) (B)"]:::back
g2["declaration → duty advise (GREEN/YELLOW/RED risk) (B)"]:::back
g3["customer duty-slip → transit / delivery / release order (P)(B)"]:::back
g4["T1 docs → T1 close (B)"]:::back
g5["final-invoice → customer slip → confirm paid (P)(B)"]:::back
g6["second-duty (post-arrival import) → slip (P)(B)"]:::back
gi["incident reports (photos) as needed (B)"]:::back
g1 --> g2 --> g3 --> g4 --> g5 --> g6
g4 -.-> gi
end
op(["Booking runs operation + payment (see §1 phase 4)<br/>then §5/§6 physical → COMPLETED (done)"]):::good
pre --> CTR
c7 --> REQ
r3 --> GLC
g6 --> op
```
---
## §5 — EXPORT operations (physical execution)
Given a PAID, scheduled **export** booking: optional first-mile road leg → origin warehouse inbound → train
build & dispatch → corridor transit → Djibouti port unload → interchange handover. Cargo leaves the country
at the port; the booking's terminal here is **dispatched/handed-over at Djibouti**.
```mermaid
flowchart TD
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
classDef back fill:#fef3c7,stroke:#b45309,color:#111
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
classDef dec fill:#f8fafc,stroke:#475569,color:#111
classDef good fill:#86efac,stroke:#166534,color:#062e14
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
pre(["Export booking PAID & Eligible<br/>(see §1/§2/§3/§4)"]):::sys
subgraph FM["1 · First-mile (if EXPORT + requested)"]
direction TB
fmq{"first-mile requested?"}:::dec
fm1["leg auto-created firstMile.acceptBooking<br/>READY_TO_TRANSIT (sys)"]:::sys
fm2["setVehicles → vehicle BUSY, SMS driver, fleet_events (B)"]:::back
fm3["IN_TRANSIT (needs vehicle) → RECEIVED_TO_PORT (B)"]:::back
fm4["first-mile invoice (FIRST_MILE fee); distances lock once invoiced (B)"]:::back
fmq -->|"yes"| fm1 --> fm2 --> fm3 --> fm4
fmq -->|"no"| fmskip[" "]:::sys
end
subgraph WH["2 · Origin warehouse inbound"]
direction TB
w1["receive / bulkReceive → RECEIVED<br/>capacity assert, GRN, notify owner SMS (B)"]:::back
w2{"inspection"}:::dec
w2f["FAILED / NEEDS_REVIEW → hold + re-inspect (B)"]:::back
w3["store (allocation rule → yard/zone) → STORED (B)"]:::back
w4["reserve (booking PAID) → RESERVED (B)"]:::back
w5["mark-ready-for-loading (inspection PASSED) → READY_FOR_LOADING (B)"]:::back
w6["load onto wagon → LOADED (+ warehouse_loadings) (B)"]:::back
w1 --> w2
w2 -->|"fail"| w2f --> w2
w2 -->|"PASSED"| w3 --> w4 --> w5 --> w6
end
subgraph SCHED["3 · Train build & schedule"]
direction TB
s1["schedule DRAFT (≥2 locos, derive EXPORT direction) (B)"]:::back
s2["assign-bookings + run-allocation (wagons) (B)"]:::back
s3["pin wagons → finalize → SCHEDULED (bookings Scheduled) (B)"]:::back
s3x["cancel schedule → bookings Eligible (B)"]:::back
s1 --> s2 --> s3
s3 -.->|"cancel"| s3x -.-> s1
s3 -.->|"gov preempt / maintenance"| sr["reschedule: retained/displaced/readmitted (B)"]:::back
sr -.-> s2
end
subgraph RUN["4 · Dispatch → Djibouti"]
direction TB
r1["dispatch → DISPATCHED<br/>train_number, locos ASSIGNED, window CLOSED, unpaid EXPIRED (B)"]:::back
r2["checkpoints (corridor) → train_checkpoint_events (B)"]:::back
rc["customer tracking page GET /tracking/:id (JWT) (P)<br/>NOTE: tracking_events has no writer — timeline empty"]:::port
r3["arrive → ARRIVED (bookings IN_TRANSIT, wagons/locos freed) (B)"]:::back
ru["export/auto-unload-at-djibouti →<br/>UNLOADED_AT_DJIBOUTI_PORT (B)"]:::back
ri["interchange document generate-from-schedule → GENERATED (B)"]:::back
ria{"port acknowledges?"}:::dec
r1 --> r2 --> r3 --> ru --> ri --> ria
r2 -.-> rc
end
done(["Export dispatched & handed over at Djibouti (done)"]):::good
disp(["interchange DISPUTED → remarks / re-issue"]):::bad
pre --> FM
fm4 --> WH
fmskip --> WH
w6 --> SCHED
s3 --> RUN
ria -->|"acknowledge"| done
ria -->|"dispute"| disp
```
---
## §6 — IMPORT operations (physical execution)
Given a PAID, scheduled **import** booking arriving by train from Djibouti: destination warehouse unload →
inspection → import customs finalization → optional last-mile → fee gate-clearance → release → delivery →
**COMPLETED**.
```mermaid
flowchart TD
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
classDef back fill:#fef3c7,stroke:#b45309,color:#111
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
classDef dec fill:#f8fafc,stroke:#475569,color:#111
classDef good fill:#86efac,stroke:#166534,color:#062e14
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
pre(["Import booking PAID & scheduled<br/>(see §1/§2/§4)"]):::sys
subgraph RAIL["1 · Import train arrival"]
direction TB
t1["import train dispatched from Djibouti → DISPATCHED (B)"]:::back
t2["checkpoints → train_checkpoint_events (B)"]:::back
tc["customer tracking page GET /tracking/:id (JWT) (P)<br/>NOTE: tracking_events has no writer — timeline empty"]:::port
t3["arrive → ARRIVED (bookings IN_TRANSIT) → warehouse arrival automation (B)"]:::back
t1 --> t2 --> t3
t2 -.-> tc
end
subgraph WH["2 · Destination warehouse"]
direction TB
w1["import/auto-unload-arrived-bookings (assign WH/yard/zone) → UNLOADED (B)"]:::back
w2{"inspection PASSED?"}:::dec
w2f["FAILED → hold / re-inspect / djibouti-incident report (B)"]:::back
w3["→ READY_FOR_PICKUP (IMPORT) (B)"]:::back
w1 --> w2
w2 -->|"no"| w2f --> w2
w2 -->|"yes"| w3
end
subgraph CUST["3 · Import customs finalization (timestamp-driven)"]
direction TB
u1["upload docs (IM4/IM5/T1_CLOSURE/TRANSIT_PERMIT/…) (B)"]:::back
u2["record declaration serial (B)"]:::back
u3["notify duties/taxes (B)"]:::back
u4["mark duties paid (needs CUSTOMER_PAYMENT_SLIP) (B)"]:::back
u5["assign risk (GREEN/YELLOW/BLUE/RED) (B)"]:::back
u6{"release gates satisfied?<br/>T1 + release permit + declaration + risk + paid"}:::dec
u6x["blocked — missing gate → resolve (B)"]:::back
u7["release-permitted → completedAt (B)"]:::back
u1 --> u2 --> u3 --> u4 --> u5 --> u6
u6 -->|"no"| u6x --> u6
u6 -->|"yes"| u7
end
subgraph LM["4 · Last-mile (if requested)"]
direction TB
lq{"last-mile requested?"}:::dec
l1["leg auto-created lastMile.acceptBooking<br/>READY_TO_TRANSIT (sys)"]:::sys
l2["setVehicles → IN_TRANSIT → DELIVERED (free vehicles) (B)"]:::back
l3["last-mile invoice (LAST_MILE fee) (B)"]:::back
lq -->|"yes"| l1 --> l2 --> l3
lq -->|"no"| lskip[" "]:::sys
end
subgraph DEL["5 · Release & delivery"]
direction TB
d0{"warehouse/storage fees fully PAID?"}:::dec
d0x["gate-clearance BLOCKED (findBlockingInvoice) (B)"]:::back
d0p["customer pays storage/demurrage online (P)"]:::port
d1["release order (DO) + gate-clearance → deliver (B)"]:::back
d2["customer approve-delivery (saved signature) → POD (P)"]:::port
d3["inventory DELIVERED, POD to cargo, container freed (sys)"]:::sys
d0 -->|"no"| d0x --> d0p --> d0
d0 -->|"yes"| d1 --> d2 --> d3
end
ecr(["empty-container-return chain (post-import):<br/>RETURNED → … → HANDOVER_ISSUED → COMPLETED"]):::sys
done(["Booking COMPLETED (done) (operations/complete)"]):::good
pre --> RAIL
t3 --> WH
w3 --> CUST
u7 --> LM
l3 --> DEL
lskip --> DEL
d3 --> done
done -.-> ecr
```
---
## §7 — INTERCITY / DOMESTIC operations (no cross-border customs)
Rail movement **between Ethiopian yards** (e.g. inland dry ports). No import/export customs, no Djibouti
port unload or interchange handover. Optional road mile legs if the service includes door delivery.
```mermaid
flowchart TD
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
classDef back fill:#fef3c7,stroke:#b45309,color:#111
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
classDef dec fill:#f8fafc,stroke:#475569,color:#111
classDef good fill:#86efac,stroke:#166534,color:#062e14
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
pre(["Domestic booking PAID & Eligible<br/>(see §1 — customs OFF)"]):::sys
subgraph FMD["1 · Optional origin pickup (road)"]
direction TB
fq{"door pickup / first-mile requested?"}:::dec
f1["first-mile leg → vehicle assign → RECEIVED_TO_PORT (B)"]:::back
fq -->|"yes"| f1 --> fnext[" "]:::sys
fq -->|"no (drop at origin yard)"| fnext
end
subgraph WHO["2 · Origin warehouse"]
direction TB
w1["receive → RECEIVED (B)"]:::back
w2{"inspection PASSED?"}:::dec
w2f["hold + re-inspect (B)"]:::back
w3["store → STORED → reserve (PAID) → RESERVED (B)"]:::back
w4["ready-for-loading → LOADED onto wagon (B)"]:::back
w1 --> w2
w2 -->|"no"| w2f --> w2
w2 -->|"yes"| w3 --> w4
end
subgraph RUN["3 · Train (ET yard → ET yard)"]
direction TB
s1["schedule DRAFT (direction DOMESTIC) → assign → finalize → SCHEDULED (B)"]:::back
r1["dispatch → DISPATCHED (unpaid EXPIRED) (B)"]:::back
r2["checkpoints → train_checkpoint_events; customer tracking (JWT) (P)(B)"]:::back
r3["arrive → ARRIVED (bookings IN_TRANSIT) (B)"]:::back
s1 --> r1 --> r2 --> r3
end
subgraph WHD["4 · Destination warehouse & delivery"]
direction TB
d1["auto-unload arrived → UNLOADED / RECEIVED (B)"]:::back
di{"inspection PASSED?"}:::dec
dif["hold + re-inspect (B)"]:::back
d2["READY_FOR_PICKUP (B)"]:::back
fee{"storage fees paid?"}:::dec
feex["gate-clearance blocked → customer pays (P)"]:::port
d3["release order → deliver (B)"]:::back
lq{"door delivery / last-mile?"}:::dec
l1["last-mile leg → DELIVERED (B)"]:::back
d4["customer approve-delivery → POD → inventory DELIVERED (P)(sys)"]:::sys
d1 --> di
di -->|"no"| dif --> di
di -->|"yes"| d2 --> fee
fee -->|"no"| feex --> fee
fee -->|"yes"| d3 --> lq
lq -->|"yes"| l1 --> d4
lq -->|"no (pickup at yard)"| d4
end
done(["Booking COMPLETED (done)"]):::good
pre --> FMD
fnext --> WHO
w4 --> RUN
r3 --> WHD
d4 --> done
```
---
## Cross-reference
| Variant | Distinctive gate(s) | Terminal ends unique to it |
| --------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| §1 One-time · DOMESTIC | counter-sign → `FULLY_EXECUTED` (skips clearance **and** op-request; enters batch directly) | HARD BLOCK, price/intake/approval REJECTED, EXPIRED |
| §2 One-time · IMPORT/EXPORT | `AWAITING_DOCUMENTS` → review loop → phased ET/DJ → op-request (gate applies with **or without** customs) | + doc-query loop, CANCELLED (only from OPERATION_REQUEST_PENDING) |
| §3 Contract · Path A | `SELF_CLEARED` via ops-review; customer books direct | contract REJECTED/EXPIRED/RENEWAL, capacity/pairing block |
| §4 Contract · Path B | BookingRequest → GL creates booking; per-booking milestones | BookingRequest REJECTED/CANCELLED |
| §5 Export ops | first-mile → Djibouti unload → interchange handover | dispatched@Djibouti (success), interchange DISPUTED |
| §6 Import ops | import customs finalization gates → last-mile → gate-clearance | COMPLETED (+ empty-container-return chain) |
| §7 Intercity ops | ET→ET rail, no cross-border customs, optional mile legs | COMPLETED |
Full endpoint tables & per-domain state machines: [`FREIGHT_SYSTEM_FLOW.md`](./FREIGHT_SYSTEM_FLOW.md).
Single all-in-one branching graph: [`FREIGHT_MASTER_FLOW.md`](./FREIGHT_MASTER_FLOW.md).

View File

@@ -0,0 +1,260 @@
# EDR Freight — One Master Flow (signup → every end)
Single comprehensive graph of the entire freight business logic: from customer signup through every
branch to every terminal state. Actor-coloured, endpoint-labelled.
**Legend**
(P) Portal (customer) · (B) Backoffice (staff) · (sys) System/auto (event, cron, service-to-service)
Rounded green = success end · Red = failure/terminal end · Diamond = decision · Hexagon = domain event.
```mermaid
flowchart TD
classDef start fill:#e0e7ff,stroke:#3730a3,color:#111
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
classDef back fill:#fef3c7,stroke:#b45309,color:#111
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
classDef dec fill:#f8fafc,stroke:#475569,color:#111
classDef good fill:#86efac,stroke:#166534,color:#062e14
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
%% ================= PHASE 1: IDENTITY & ONBOARDING =================
S0(["Customer visits portal"]):::start
S0 --> S1["Signup via IAM<br/>GET /auth/check-availability @Public<br/>POST /otp/send + /otp/verify (P)"]:::port
S1 --> S2{"Identity proofing<br/>(VeriFayda)?"}:::dec
S2 -->|"Yes"| S3["POST /fayda/verification/start →<br/>/callback → /complete<br/>upsert iam.users (verified_by=fayda) (P)"]:::port
S2 -->|"No"| S4
S3 --> S4["POST /companies/onboarding/start<br/>draft company (placeholder TIN, PENDING) (P)"]:::port
S4 --> S4b["Wizard: PATCH /profile, /onboarding-step,<br/>upload license + docs<br/>GET /onboarding/requirements (P)"]:::port
S4b --> S5["POST /companies/onboarding/complete<br/>re-validate → company+profiles = PENDING (P)"]:::port
S5 --> S6{"Backoffice reviews profile<br/>PATCH /company-profiles/:id/status (B)"}:::dec
S6 -->|"Reject / suspend"| S6x(["SUSPENDED / BLACKLISTED<br/>cannot transact"]):::bad
S6 -->|"Approve"| S7["Mint reference (EX-#####),<br/>company → ACTIVE (B)"]:::back
S7 --> S8{"Start a shipment?"}:::dec
S8 -->|"idle"| S8x(["No booking (dormant account)"]):::bad
S8 -->|"Yes"| MODE
%% ================= PHASE 2: COMMERCIAL ORIGIN =================
MODE{"Booking origin?"}:::dec
MODE -->|"One-time shipment"| B0
MODE -->|"Framework agreement"| C1
%% ---- Contract track ----
C1["Create CONTRACT DRAFT<br/>POST /contracts (routes + cargo scope) (P)"]:::port
C1 --> C1d{"Abandon draft?"}:::dec
C1d -->|"delete"| C1x(["Contract removed (soft-delete)"]):::bad
C1d -->|"continue"| C2["generate-price → submit → SUBMITTED<br/>freeze contract_rate_snapshots (P)"]:::port
C2 --> C2p{"Price changed?"}:::dec
C2p -->|"Yes"| C2c["confirm-submit → SUBMITTED (P)"]:::port
C2p -->|"No"| C3
C2c --> C3
C3{"Staff intake<br/>POST /contracts/:id/staff/* (B)"}:::dec
C3 -->|"request-changes"| C3r["CHANGES_REQUESTED (B)"]:::back
C3r -->|"edit + resubmit"| C2
C3 -->|"reject"| C3x(["Contract REJECTED"]):::bad
C3 -->|"accept"| C4["→ PENDING_APPROVAL<br/>instantiate approval steps (B)"]:::back
C4 --> C4a{"Approval chain<br/>line → director → ceo (B)"}:::dec
C4a -->|"rejectStep"| C4x(["Contract REJECTED"]):::bad
C4a -->|"all approve"| C5["generate-contract → CONTRACT_READY (B)"]:::back
C5 --> C6["Customer sign → SIGNED_CUSTOMER<br/>POST /contracts/:id/contract/sign (P)"]:::port
C6 --> C7{"Staff counter-sign branch"}:::dec
C7 -->|"customs on"| C7a["AWAITING_CLEARANCE_DOCUMENTS (B)"]:::back
C7 -->|"GENERAL"| C7b["CONTRACT_ACTIVE (B)"]:::back
C7 -->|"ONE_TIME"| C7c["FULLY_EXECUTED (B)"]:::back
C7a --> CPATH
C7b --> CPATH
C7c --> CPATH
C7b -.->|"renew"| C7renew(["RENEWAL_DRAFT → new cycle"]):::bad
C7b -.->|"lapse"| C7exp(["Contract EXPIRED"]):::bad
CPATH{"How are shipments booked<br/>under the contract?"}:::dec
CPATH -->|"Path A: transport-only"| CPA["Ops review self-clearance<br/>ops-review → ops-finalize → SELF_CLEARED (B)<br/>then customer books direct<br/>POST /contracts/:id/bookings (P)"]:::port
CPATH -->|"Path B: GENERAL + customs"| CPB["Customer submits BookingRequest<br/>POST /contracts/:id/booking-requests (P)"]:::port
CPB --> CPBq{"GL queue decision (B)"}:::dec
CPBq -->|"reject / customer cancels"| CPBx(["BookingRequest REJECTED / CANCELLED"]):::bad
CPBq -->|"accept → GL creates booking"| B0u
CPA --> B0u["Booking created UNDER contract<br/>(window + capacity draw-down check) (sys)"]:::sys
B0u --> BFLOW
%% ---- One-time booking ----
B0["Create BOOKING DRAFT<br/>POST /bookings (reference, containers,<br/>cargo modifiers, files) (P)"]:::port
B0 --> B0d{"Abandon draft?"}:::dec
B0d -->|"delete"| B0x(["Booking removed (soft-delete)"]):::bad
B0d -->|"continue"| BFLOW
B0 -.->|"consolidation"| BCONS(["PENDING_CONSOLIDATION<br/>waits for partner shipment<br/>(shares a wagon) → rejoins"]):::sys
BCONS -.-> BFLOW
%% ================= PHASE 3: PRICING & SUBMIT =================
BFLOW["Configure shipment<br/>freight type + trade direction"]:::sys
BFLOW --> FT{"Freight type?"}:::dec
FT -->|"CONTAINER"| DIR
FT -->|"BULK"| DIR
DIR{"Trade direction?"}:::dec
DIR -->|"EXPORT"| B1
DIR -->|"IMPORT"| B1
DIR -->|"DOMESTIC"| B1
B1["POST /bookings/:id/generate-price<br/>rule-engine: LIVE rates + surcharges<br/>HAZARDOUS / REEFER / OVERWEIGHT /<br/>SHIPPING_LINE / CONSOLIDATION (P)"]:::port
B1 --> B1w{"weight-limit-rules check"}:::dec
B1w -->|"VGM > maxCapacity"| B1x(["HARD BLOCK (400)<br/>cannot submit"]):::bad
B1w -->|"over maxVgm, within cap"| B1warn["warning + OVERWEIGHT surcharge"]:::sys
B1w -->|"ok"| B2
B1warn --> B2
B2["POST /bookings/:id/submit → SUBMITTED<br/>create booking_rate_snapshot (P)"]:::port
B2 --> B2p{"Price moved since draft?"}:::dec
B2p -->|"Yes → PRICE_CHANGED_PENDING_CONFIRM"| B2c["confirm-submit → SUBMITTED (P)"]:::port
B2p -->|"No"| GOV
B2c --> GOV
B2 -.->|"customer rejects price"| B2x(["Booking REJECTED"]):::bad
%% ================= PHASE 4: INTAKE & APPROVAL =================
GOV{"Government booking?"}:::dec
GOV -->|"Yes"| GEXP["governmentExpedite →<br/>PAID + schedulingStatus Eligible (B)"]:::back
GOV -->|"No (commercial)"| BI{"Staff intake<br/>POST /bookings/:id/staff/* (B)"}:::dec
BI -->|"request-changes"| BIr["CHANGES_REQUESTED (B)"]:::back
BIr -->|"edit + resubmit"| B2
BI -->|"reject"| BIx(["Booking REJECTED"]):::bad
BI -->|"accept"| BA["→ PENDING_APPROVAL<br/>instantiate approval steps<br/>(set validity window) (B)"]:::back
BA --> BAc{"Approval chain<br/>LINE_STAFF → DIRECTOR → CEO (B)"}:::dec
BAc -->|"rejectStep"| BAx(["Booking REJECTED"]):::bad
BAc -->|"all approve → APPROVED"| BC1
%% ================= PHASE 5: CONTRACT DOC & SIGN =================
BC1["contract/generate → CONTRACT_READY (B)"]:::back
BC1 --> BC2["Customer sign → SIGNED_CUSTOMER<br/>POST /bookings/:id/contract/sign (P)"]:::port
BC2 --> BC3{"Staff counter-sign:<br/>trade direction?"}:::dec
BC3 -->|"IMPORT / EXPORT<br/>(clearance gate, even if customs off)"| CL1
BC3 -->|"DOMESTIC"| FEXD["counter-sign → FULLY_EXECUTED<br/>enqueue batch (skips clearance + op-request) (sys)(B)"]:::back
FEXD --> FEB
%% ================= PHASE 6: CUSTOMS CLEARANCE =================
CL1["AWAITING_DOCUMENTS → customer uploads<br/>POST /bookings/:id/clearance/documents<br/>→ DOCUMENTS_UNDER_REVIEW (P)"]:::port
CL1 --> CL2{"GL reviews each doc<br/>clearance/review (B)"}:::dec
CL2 -->|"Query"| CL2q["doc queried → customer re-uploads (B)"]:::back
CL2q --> CL1
CL2 -->|"Approve all"| CL3["finalize (100% approved) → CLEARANCE_READY (B)"]:::back
CL3 --> CLph["Phased ET/DJ (as applicable):<br/>declaration → duty advise → duty slip →<br/>transit permit → delivery/release order →<br/>T1 docs/close → export release (sys)(B)"]:::back
CLph --> OP1
%% ================= PHASE 7: OPERATION REQUEST =================
OP1["clearance/proceed: pick binding schedule day<br/>→ OPERATION_REQUEST_PENDING (P)"]:::port
OP1 --> OP2{"Operations review<br/>POST /bookings/:id/operation/review (B)"}:::dec
OP2 -->|"REQUEST_CHANGES"| OP2c["OPERATION_CHANGES_REQUESTED (B)"]:::back
OP2c --> OP1
OP2 -->|"ACCEPT"| OPM{"Operation mode?"}:::dec
OPM -->|"TRAIN (rail)"| OP3t["invoice generated → FULLY_EXECUTED<br/>(day batch pool) (B)"]:::back
OPM -->|"ROAD (truck)"| OP3r["invoice generated →<br/>ROAD_DISPATCH_PENDING (billed by KM) (B)"]:::back
OP3t --> FEB["batch engine offers wagons →<br/>SELECTED_FOR_BATCH (sys)"]:::sys
%% ================= PHASE 8: INVOICE & PAYMENT =================
GEXP --> SCH
FEB --> PAY1
OP3r --> PAY1
PAY1["Invoice (source=booking, INV-YYYYMMDD-#####, due +14d)<br/>booking invoice starts DRAFT → ISSUED at operation-accept (sys)"]:::sys
PAY1 --> PAY2["Customer pays<br/>POST /billing/my-invoices/:id/pay →<br/>billing.payInvoice → payment-api initiate (P)"]:::port
PAY2 --> PAYp{"Provider result<br/>(Telebirr/CBE/EBirr/Waafi/DMoney/Card/CAC)"}:::dec
PAYp -->|"FAILED"| PAYf["invoice stays OPEN (retry)"]:::sys
PAYf --> PAY2
PAYp -->|"pay window lapses"| PAYexp(["Booking/reservation EXPIRED"]):::bad
PAYp -->|"SUCCEEDED"| PAYok["webhook → payment outbox →<br/>POST /internal/payments/mark-paid →<br/>settleByPaymentId → invoice PAID (sys)"]:::sys
PAYok --> EVT1{{"booking.invoice.paid event"}}:::sys
EVT1 --> PAID["Booking → PAID"]:::sys
PAYok -.->|"post-pay"| PAYref(["REFUNDED (terminal)"]):::bad
PAID --> FMQ0
EVT1 -.->|"if EXPORT + first-mile"| FM1
PAID --> SCH
%% ================= PHASE 9: SCHEDULING & ALLOCATION =================
SCH["schedulingStatus = Eligible (sys)"]:::sys
SCH --> SC2["Train schedule DRAFT<br/>POST /train-scheduling/{container|bulk}/schedules<br/>≥2 locomotives, derive direction (B)"]:::back
SC2 --> SC3["assign-bookings + run-allocation<br/>(wagon_booking_allocations) (B)"]:::back
SC3 --> SC4["pin physical wagons → finalize → SCHEDULED<br/>bookings → Scheduled (B)"]:::back
SC4 -.->|"cancel schedule"| SC4x["bookings back to Eligible (B)"]:::back
SC4x -.-> SC2
SC4 -.->|"gov preempt / maintenance"| RESCH["reschedule: retained / displaced /<br/>readmitted (priority: gov first) (B)"]:::back
RESCH -.-> SC3
SC4 --> FMQ0
%% ================= PHASE 10: FIRST-MILE (export origin road leg) =================
FMQ0{"EXPORT + first-mile requested?"}:::dec
FMQ0 -->|"Yes"| FM1["first-mile leg auto-created<br/>firstMile.acceptBooking (READY_TO_TRANSIT) (sys)"]:::sys
FMQ0 -->|"No"| WO1
FM1 --> FM2["setVehicles → vehicle BUSY, SMS driver,<br/>fleet_events (B)"]:::back
FM2 --> FM3["IN_TRANSIT (needs vehicle) →<br/>RECEIVED_TO_PORT (free vehicles) (B)"]:::back
FM3 --> FM4["first-mile invoice (FIRST_MILE fee) (B)"]:::back
FM4 --> WO1
%% ================= PHASE 11: WAREHOUSE ORIGIN (export) =================
WO1["receive / bulkReceive → RECEIVED<br/>capacity assert, GRN, notify owner (B)"]:::back
WO1 --> WO2{"inspection outcome"}:::dec
WO2 -->|"FAILED / NEEDS_REVIEW"| WO2f["hold + re-inspect (B)"]:::back
WO2f --> WO2
WO2 -->|"PASSED"| WO3["store (allocation rule picks yard/zone) → STORED (B)"]:::back
WO3 --> WO4["reserve (booking PAID) → RESERVED (B)"]:::back
WO4 --> WO5["mark-ready-for-loading → READY_FOR_LOADING (B)"]:::back
WO5 --> WO6["load onto wagon → LOADED<br/>(+ warehouse_loadings) (B)"]:::back
WO6 --> TR1
%% ================= PHASE 12: DISPATCH & TRANSIT =================
TR1["dispatch → DISPATCHED<br/>assign train_number, locos ASSIGNED,<br/>window CLOSED, unpaid reservations EXPIRED (B)"]:::back
TR1 --> TR2["record checkpoints (corridor stations) →<br/>train_checkpoint_events (B)"]:::back
TR2 --> TRC["Customer tracking page<br/>GET /tracking/:consignmentId (JWT) (P)"]:::port
TR2 --> TR3["arrive (final checkpoint) → ARRIVED<br/>bookings IN_TRANSIT, locos+wagons freed,<br/>warehouse arrival automation (B)"]:::back
TR3 --> WD1
%% ================= PHASE 13: WAREHOUSE DEST + IMPORT CUSTOMS =================
WD1["destination warehouse: auto-unload arrived<br/>→ UNLOADED / RECEIVED (B)"]:::back
WD1 --> WD2{"inspection PASSED?"}:::dec
WD2 -->|"No"| WD2f["hold + re-inspect / incident report (B)"]:::back
WD2f --> WD2
WD2 -->|"Yes"| DIRW{"trade direction at destination"}:::dec
DIRW -->|"IMPORT"| WD3["READY_FOR_PICKUP (B)"]:::back
DIRW -->|"EXPORT (Djibouti)"| WDX["auto-unload-at-djibouti → DISPATCHED /<br/>UNLOADED_AT_DJIBOUTI_PORT (B)"]:::back
WD3 --> IMP1["Import customs finalization:<br/>upload docs → declaration → notify duties →<br/>duties paid (needs slip) → assign risk →<br/>release-permitted (all gates) (sys)(B)"]:::back
IMP1 --> LMQ
WDX --> ICD["interchange document (handover manifest)<br/>generate-from-schedule → GENERATED →<br/>ACKNOWLEDGED / DISPUTED (B)"]:::back
ICD --> DE1
%% ================= PHASE 14: LAST-MILE (import destination road leg) =================
LMQ{"IMPORT + last-mile requested?"}:::dec
LMQ -->|"Yes"| LM1["last-mile leg auto-created<br/>(IMPORT inspection PASSED only) (sys)"]:::sys
LMQ -->|"No"| DE1
LM1 --> LM2["setVehicles → IN_TRANSIT → DELIVERED<br/>(free vehicles) (B)"]:::back
LM2 --> LM3["last-mile invoice (LAST_MILE fee) (B)"]:::back
LM3 --> DE1
%% ================= PHASE 15: DELIVERY & COMPLETION =================
DE1{"Warehouse/storage fees fully PAID?"}:::dec
DE1 -->|"No"| DE1x["gate-clearance BLOCKED<br/>findBlockingInvoice / assertClearanceAllowed (B)"]:::back
DE1x --> DE1p["Customer pays storage/demurrage<br/>warehouse-fee-invoices/:id/pay-online (P)"]:::port
DE1p --> DE1
DE1 -->|"Yes"| DE2["release order (DO) + gate-clearance →<br/>deliver (B)"]:::back
DE2 --> DE3["Customer approves delivery (saved signature)<br/>POST /warehouse-inventory/bookings/:id/approve-delivery (P)"]:::port
DE3 --> DE4["inventory DELIVERED, POD to cargo,<br/>container freed, capacity released (sys)"]:::sys
DE4 --> DONE(["Booking COMPLETED (done) <br/>operations/complete"]):::good
%% ================= GLOBAL EXITS =================
GEXIT(["CANCELLED — POST /bookings/:id/cancel (staff-only)<br/>ONLY from DRAFT, SUBMITTED, PRICE_CHANGED_PENDING_CONFIRM,<br/>CHANGES_REQUESTED, PENDING_APPROVAL, CONTRACT_READY,<br/>OPERATION_REQUEST_PENDING"]):::bad
B2 -.->|"cancel"| GEXIT
BA -.->|"cancel"| GEXIT
OP1 -.->|"cancel"| GEXIT
```
---
## Reading notes
- **Solid arrows** = the primary progression. **Dotted arrows** = optional / event-driven / exit hops
(consolidation, reschedule, cancel, the first-mile event branch).
- **Every terminal** is a rounded red or green node:
`SUSPENDED/BLACKLISTED`, `dormant`, `booking/contract removed`, `REJECTED` (customer price, staff intake,
approval step, GL booking-request), `HARD BLOCK` (VGM), `EXPIRED` (pay window / contract), `REFUNDED`,
`RENEWAL_DRAFT`, `CANCELLED`, and the single success end **`COMPLETED (done) `**.
- **Branch axes** captured: identity-proofing (Fayda / skip), origin (one-time vs contract Path A / Path B),
freight type (container / bulk), trade direction (import / export / domestic), customer type
(government expedite vs commercial approval chain), customs on/off, operation mode (rail / road),
provider outcome (success / fail-retry / expire / refund), first-mile (export), last-mile (import),
Djibouti export unload + interchange handover.
- **Actors**: (P) portal customer, (B) backoffice staff, (sys) system (events like `booking.invoice.paid`,
`warehouse.invoice.paid`, auto leg creation, payment webhook/outbox settlement).
> Endpoint-level tables, per-domain state machines, and the payment-microservice sequence live in
> [`FREIGHT_SYSTEM_FLOW.md`](./FREIGHT_SYSTEM_FLOW.md). This file is the single end-to-end picture.

View File

@@ -0,0 +1,872 @@
# EDR Freight System — End-to-End Flow Map (FE → Backend)
> Comprehensive flow documentation for the **Freight Management** slice of the EDR Platform:
> the two frontends (**Customer Portal** + **Backoffice**) and how every action reaches the
> **freight API**, the **payment microservice**, Postgres, MinIO and RabbitMQ.
>
> Generated by mapping all ~45 API modules (~300 routes / 60 controllers) against both FE apps.
> Layered on purpose: a **master business-flow** view first, then **per-domain endpoint-level** charts.
---
## 0. How to read this document
- **Master view** (§3) is the freight journey at business altitude — state transitions across domains.
- **Per-domain views** (§5§13) drop to endpoint altitude — every `Method /path`, its guard, and the FE caller.
- Diagrams are [Mermaid](https://mermaid.js.org). GitHub / VS Code (Markdown Preview Mermaid) render them inline.
- **Legend** used throughout:
- (P) **Portal** = `@edr/freight-portal` (customer users, port `5173`)
- (B) **Backoffice** = `@edr/freight-backoffice` (EDR employees, port `5183`)
- (green) **API** = `@edr/freight-api` (NestJS, port `3001`)
- (red) **Payment** = `@edr/payment-api` (NestJS microservice, port `3003`)
---
## 1. System architecture & apps
| Layer | Package | Port | Base URL / notes |
| ----- | ------- | ---- | ---------------- |
| Customer Portal | `@edr/freight-portal` | 5173 | axios `utils/api.ts`, baseURL `VITE_BASE_API_URL`, React Query |
| Backoffice | `@edr/freight-backoffice` | 5183 | axios `auth/http.ts`, baseURL `${VITE_BASE_API_URL}/api`, React Query |
| Freight API | `@edr/freight-api` | 3001 | NestJS, global prefix `/api`, Postgres schema `freight` |
| Payment API | `@edr/payment-api` | 3003 | NestJS, separate schema `edr_payment`, providers in `@edr/payment-providers` |
| Datastores | — | 5433 | Postgres `edr_freight`; MinIO (files); RabbitMQ (SMS/email/payment events) |
```mermaid
flowchart LR
subgraph FE["Frontends (React + Vite + React Query)"]
P["(P) Customer Portal :5173"]
B["(B) Backoffice :5183"]
end
subgraph EDGE["Freight API edge (NestJS :3001)"]
direction TB
CORS["CORS (reflect origin,<br/>credentials, IAM headers)"]
JWT["JwtGuard (global APP_GUARD)<br/>+ HasActiveDelegationGuard"]
PERM["FreightPermissionGuard<br/>(per-route perms)"]
VP["ValidationPipe<br/>(implicitConversion OFF)"]
RTI["ResponseTransformInterceptor<br/>→ { success, data }"]
HEF["HttpExceptionFilter"]
end
subgraph DOM["Domain modules (~45)"]
direction TB
D1["Identity / Companies / Auth"]
D2["Bookings + Contracts"]
D3["Rule Engine"]
D4["Train Scheduling"]
D5["Warehouse"]
D6["Field Ops (mile/import)"]
D7["Billing"]
D8["Notifications / Inbox"]
end
subgraph INFRA["Backing services"]
PG[("Postgres<br/>schema: freight")]
MINIO[("MinIO<br/>object store")]
MQ{{"RabbitMQ"}}
PAY["(red) Payment API :3003<br/>schema: edr_payment"]
end
P -->|"Bearer token (cookie)<br/>axios interceptor"| CORS
B -->|"Bearer token (cookie)<br/>axios interceptor"| CORS
CORS --> JWT --> PERM --> VP --> DOM
DOM --> RTI
DOM --> PG
DOM --> MINIO
DOM -->|"send-sms / send-email"| MQ
DOM -->|"POST /payments/initiate<br/>x-service-token"| PAY
PAY -->|"payment.succeeded webhook<br/>→ /api/internal/payments/mark-paid"| DOM
PAY -.->|"or RabbitMQ payment events"| MQ --> DOM
D8 -->|"socket.io push"| FE
```
---
## 2. The request pipeline (every FE call)
Both frontends wrap each API method in an `endpoint(service, action, fn)` helper feeding React Query.
The axios client attaches the JWT and transparently refreshes on `401`.
```mermaid
sequenceDiagram
autonumber
participant C as React component / hook
participant Q as React Query
participant AX as axios client (interceptors)
participant API as Freight API (:3001)
participant DB as Postgres
C->>Q: useQuery / useMutation(endpoint)
Q->>AX: call(input)
AX->>AX: request interceptor →<br/>Authorization: Bearer [auth-token cookie]
AX->>API: HTTP /api/<controller>/<path>
API->>API: CORS → JwtGuard → PermissionGuard → ValidationPipe
alt token valid & permitted
API->>DB: repository query (schema freight)
DB-->>API: rows
API->>API: ResponseTransformInterceptor → { success:true, data }
API-->>AX: 200 { success, data }
AX->>AX: backoffice interceptor unwraps .data<br/>(portal returns raw envelope; callers read .data)
AX-->>Q: payload
Q-->>C: data (+ cache by queryKey)
else 401 Unauthorized
API-->>AX: 401
AX->>API: POST /api/auth/refresh-token { refreshToken cookie }
alt refresh ok
API-->>AX: { token, refreshToken }
AX->>AX: set cookies, retry original request (_retry)
AX-->>Q: payload
else refresh fails
AX->>AX: clear cookies →<br/>portal: reject · backoffice: redirect /auth
end
else 4xx/5xx
API->>API: HttpExceptionFilter → { success:false, message }
API-->>AX: error
AX-->>Q: throw → onError toast
end
```
**Auth model (important):** `SharedAuthModule` (`@tria-plc/api-common`) registers `JwtGuard` +
`HasActiveDelegationGuard` as **global `APP_GUARD`s***every* route is JWT-protected unless it
carries `@Public()`. Fine-grained `FreightPermissionGuard([perm])` decorators add permission checks
on staff routes. Explicitly **public** endpoints: `GET /api/files/:fileId`, `POST /api/otp/{send,verify}`,
`GET /api/auth/check-availability`, the `fayda/verification/*` + `/callback` endpoints,
`GET /api/payments/{checkout,receipt/:orderId}`, and the service-to-service `POST /api/internal/payments/mark-paid`.
Real login / JWT issuance lives in the **external IAM package**, not this repo. (Note: `@edr/api-common`'s
`@Public` and `@tria-plc/api-common`'s `@IsPublic` both set the same `"isPublic"` metadata key the guard reads.)
---
## 3. MASTER FLOW — the freight journey (business altitude)
This is the spine. A shipment travels **customer intake → pricing → approval → contract → customs
clearance → operation request → payment → scheduling/allocation → first-mile → warehouse → train →
arrival → warehouse → last-mile → delivery → tracking**, branching on _container vs bulk_,
_import vs export_, _commercial vs government_, and _one-time vs contract_.
```mermaid
flowchart TD
start(["Customer signs up<br/>(IAM + Fayda + company onboarding)"]) --> mode{"Booking origin?"}
mode -->|"One-time shipment"| draft["Create BOOKING (DRAFT) (P)"]
mode -->|"Framework agreement"| cdraft["Create CONTRACT (DRAFT) (P)"]
%% Contract branch
cdraft --> cprice["Generate price → Submit → Approvals → Sign"]
cprice --> cactive{"Contract type / customs?"}
cactive -->|"Path A: transport-only,<br/>self-clearance"| bookA["Customer books directly<br/>POST /contracts/:id/bookings (P)"]
cactive -->|"Path B: GENERAL + customs"| breq["Customer submits BookingRequest (P)<br/>→ GL accepts → GL creates booking (B)"]
bookA --> draft2["Booking created under contract"]
breq --> draft2
%% Booking spine
draft --> price["Generate price (P)<br/>(rule-engine: rates + surcharges)"]
draft2 --> price
price --> submit["Submit → SUBMITTED (P)"]
submit --> intake["Staff accept → PENDING_APPROVAL (B)<br/>(instantiate approval steps)"]
intake --> appr["Approval chain:<br/>LINE_STAFF → DIRECTOR → CEO (B)"]
appr --> gen["Generate contract → CONTRACT_READY (B)"]
gen --> sign["Customer signs (P) → Staff counter-signs (B)"]
sign --> customs{"customsClearingEnabled?"}
customs -->|"No"| ready["FULLY_EXECUTED"]
customs -->|"Yes"| clr["AWAITING_DOCUMENTS →<br/>customer uploads docs (P) →<br/>GL review/finalize (B) → CLEARANCE_READY"]
clr --> opreq["Customer requests operation (P)<br/>(binding schedule date)"]
ready --> opreq
opreq --> oprev{"Operations review (B)"}
oprev -->|"Request changes"| clr
oprev -->|"Accept: train"| inv["Invoice generated →<br/>enters day batch pool"]
oprev -->|"Accept: road/truck"| road["ROAD_DISPATCH_PENDING<br/>(billed by KM)"]
inv --> pay["Customer pays invoice (P)<br/>→ (red) gateway → PAID"]
road --> pay
pay --> gov{"Government?"}
gov -->|"Yes"| expedite["governmentExpedite → PAID/Eligible (B)"]
gov -->|"No"| eligible["schedulingStatus = Eligible"]
expedite --> sched
eligible --> sched
subgraph JOURNEY["Physical movement"]
direction TB
sched["Train schedule: DRAFT → assign bookings →<br/>allocate wagons → finalize → SCHEDULED (B)"]
fm{"EXPORT + first-mile?"}
fmleg["First-mile leg: truck pickup →<br/>RECEIVED_TO_PORT (B)"]
wh_in["Warehouse receive → inspect →<br/>STORED → READY_FOR_LOADING → LOADED (B)"]
disp["Dispatch train → DISPATCHED<br/>(locos ASSIGNED, unpaid EXPIRED) (B)"]
track["Checkpoints logged →<br/>tracking events (customer sees) (P)"]
arrive["Arrive → ARRIVED<br/>(bookings IN_TRANSIT, wagons freed) (B)"]
wh_out["Destination warehouse:<br/>unload → inspect → READY_FOR_PICKUP (B)"]
lm{"IMPORT + last-mile?"}
lmleg["Last-mile leg: truck delivery →<br/>DELIVERED (B)"]
imp["Import customs finalization<br/>(declaration/duty/risk/release) (B)"]
end
sched --> fm
fm -->|"Yes"| fmleg --> wh_in
fm -->|"No"| wh_in
wh_in --> disp --> track --> arrive --> wh_out
wh_out --> imp
imp --> lm
lm -->|"Yes"| lmleg --> deliver
lm -->|"No"| deliver
deliver["Release order + gate clearance<br/>(warehouse fees must be PAID) (B)"]
deliver --> pod["Customer approves delivery /<br/>POD captured → DELIVERED (P)"]
pod --> complete(["Booking COMPLETED"])
track -.->|"public timeline"| custview["Customer tracking page (P)"]
```
**Cross-cutting truth:** almost every hop between domains is fired by a **domain event** (`@OnEvent`),
not a direct call. See §13 for the event web (e.g. `booking.invoice.paid` → advance booking → auto-create
first-mile; `warehouse inspection PASSED` → auto-create last-mile; payment webhook → settle invoice).
---
## 4. Domain map (where each module lives in the journey)
```mermaid
flowchart LR
subgraph INTAKE["Intake & Identity"]
auth[auth / otp / verifayda]
comp[companies]
sig[signatures]
end
subgraph COMMERCIAL["Commercial"]
bk[bookings]
ct[contracts]
re[rule-engine]
end
subgraph OPS["Rail Operations"]
ts[train-scheduling]
resch[scheduling-reschedule]
fleet[trains/wagons/locomotives/wagon-types]
track[tracking]
end
subgraph GROUND["Ground Operations"]
fmlm[first-mile / last-mile]
imp[import-operations / interchange-documents]
dv[drivers / vehicles]
end
subgraph WH["Warehouse"]
wh[warehouses + inventory + loadings + inspection + rules]
whinv[warehouse-fee-invoices]
end
subgraph MONEY["Money"]
bill[billing]
paymod[payment]
payapi[edr-payment-api]
end
subgraph PLATFORM["Platform / Config"]
dd[dropdown-settings]
fus[file-upload-settings]
files[files / minio]
fac[facilities / routes]
notif[notifications / inbox]
ov[overview]
bo[backoffice/IAM]
end
INTAKE --> COMMERCIAL --> OPS --> GROUND --> WH --> MONEY
re -.->|rates/approval/priority| COMMERCIAL
COMMERCIAL -.->|invoices| MONEY
WH -.->|storage invoices| MONEY
GROUND -.->|mile invoices| MONEY
PLATFORM -.-> COMMERCIAL
```
---
## 5. Identity, Access & Onboarding
### 5.1 Flow
```mermaid
flowchart TD
su["Signup (external IAM)"] --> chk["GET /auth/check-availability (P)<br/>(email/phone taken?) @Public"]
chk --> otp["POST /otp/send + /otp/verify (P) @Public"]
otp --> fayda{"Identity proofing?"}
fayda -->|"VeriFayda 2.0"| fstart["POST /fayda/verification/start<br/>→ eSignet authorize URL"]
fstart --> fcb["Fayda redirect → GET /callback (ack)<br/>→ GET /fayda/verification/complete<br/>(PKCE code exchange → upsert iam.users)"]
fcb --> onb
fayda -->|"skip"| onb
onb["POST /companies/onboarding/start (P)<br/>(draft company, placeholder TIN, PENDING)"]
onb --> wiz["Wizard saves incrementally (P):<br/>PATCH /profile · /onboarding-step ·<br/>upload license & docs"]
wiz --> reqs["GET /companies/onboarding/requirements<br/>(server-driven checklist)"]
reqs --> comp["POST /companies/onboarding/complete<br/>→ profiles + company = PENDING"]
comp --> review["Backoffice approves (B):<br/>PATCH /companies/company-profiles/:id/status<br/>→ mint reference, company → ACTIVE"]
review --> book(["Can now book<br/>(assertCompanyProfileApprovedForBooking)"])
```
Company status: `PENDING → ACTIVE` (+ `SUSPENDED`, `BLACKLISTED`). Nationality (`ethiopian`/`foreign`)
drives the required document set. Booking guards elsewhere `403` if the acting profile is not `ACTIVE`.
### 5.2 Endpoints
| Method | Path | Action | Guard | FE |
| --- | --- | --- | --- | --- |
| GET | `/api/auth/check-availability` | email/phone dedupe | `@Public` | (P) auth.service |
| GET | `/api/me` | enriched profile + `permissionKeys` + catalog | JwtGuard | (B) auth/api |
| POST | `/api/otp/send` · `/api/otp/verify` | send / verify 6-digit code | `@Public` | (P) auth.service |
| POST | `/api/fayda/verification/start` | start eSignet session (PKCE) | `@Public` + OptionalJwt | (B) verifayda.service |
| GET | `/api/fayda/verification/complete` | code→identity, upsert `iam.users` | `@Public` | (B) verifayda.service |
| GET | `/api/fayda/verification/status` | current user's Fayda link | JwtGuard | — |
| GET | `/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage |
| GET·PUT | `/api/me/signature` | reusable signature (MinIO, base64) | JwtGuard | (P)(B) signatures.service |
| GET | `/api/test_user1` · `/api/test_user2` | permission-guard demo | `PermissionGuard` | (B) demo pages |
| GET | `/api/companies/getInfo` · `/profile` · `/dashboard` | company info / KPIs | JwtGuard | (P) companies.service |
| POST | `/api/companies/fetch-etrade-info` | pull reg data by TIN | JwtGuard | (P) |
| PATCH | `/api/companies/profile` | update profile (JSONB attrs) | JwtGuard | (P) |
| POST | `/api/companies/onboarding/start` · `/complete` | onboarding lifecycle | JwtGuard | (P) |
| PATCH | `/api/companies/onboarding-step` · `/active-mode` | wizard state / mode switch | JwtGuard | (P) |
| GET | `/api/companies/onboarding/requirements` | server checklist | JwtGuard | (P) |
| POST·GET | `/api/companies/company-profiles/:id/license` | license file up/list | JwtGuard | (P) |
| POST | `/api/companies/company-profiles` · `/company-profile` | add operational profile(s) | JwtGuard | (P) |
| POST·GET·PATCH·DELETE | `/api/companies` (+`/:id`) | company CRUD | `@FreightAdmin` writes | (B) customers.service |
| GET | `/api/companies/stats` | KPI strip | JwtGuard | (B) |
| PATCH | `/api/companies/company-profiles/:id/status` | approve profile → mint ref | `@FreightAdmin` | (B) |
| GET·POST | `/api/companies/:companyId/documents` | company docs (signed URLs) | JwtGuard | (P)(B) |
| GET | `/api/notifications` (+`/unread-count`) | inbox list / unread | JwtGuard | (P)(B) notificationsApi |
| PATCH·POST | `/api/notifications/:id/read` · `/read-all` | mark read (WS re-emit) | JwtGuard | (P)(B) |
| WS | `NOTIFICATION_WS_NAMESPACE` | live push (server→client) | WsAuth handshake | (P)(B) useNotificationSocket |
`notifications` module = SMS/email transport over RabbitMQ (no HTTP routes). Inbox fan-out writes one
`notifications` row + WS push per recipient; **HIGH** priority also emails + SMSs (best-effort).
---
## 6. Bookings — the core state machine
```mermaid
stateDiagram-v2
[*] --> DRAFT: create (P) (staff commercial auto price+submit)
DRAFT --> SUBMITTED: submit (P)
DRAFT --> PRICE_CHANGED_PENDING_CONFIRM: price moved
PRICE_CHANGED_PENDING_CONFIRM --> SUBMITTED: confirm-submit (P)
SUBMITTED --> REJECTED: customer reject (price) (P)
SUBMITTED --> CHANGES_REQUESTED: staff request-changes (B)
CHANGES_REQUESTED --> SUBMITTED: edit + resubmit (P)
SUBMITTED --> PENDING_APPROVAL: staff/accept (B) (instantiate approval steps)
SUBMITTED --> REJECTED: staff/reject (B)
PENDING_APPROVAL --> APPROVED_PENDING_SIGNATURE: line-staff approve (B)
APPROVED_PENDING_SIGNATURE --> APPROVED: director + ceo approve (B)
PENDING_APPROVAL --> REJECTED: rejectStep (B)
APPROVED --> CONTRACT_READY: contract/generate (B)
CONTRACT_READY --> SIGNED_CUSTOMER: customer sign (P)
SIGNED_CUSTOMER --> AWAITING_DOCUMENTS: counter-sign, IMPORT/EXPORT (B)
SIGNED_CUSTOMER --> FULLY_EXECUTED: counter-sign, DOMESTIC (B)
AWAITING_DOCUMENTS --> DOCUMENTS_UNDER_REVIEW: clearance/documents (P)
DOCUMENTS_UNDER_REVIEW --> CLEARANCE_READY: review + finalize (B)
CLEARANCE_READY --> OPERATION_REQUEST_PENDING: clearance/proceed (P)
OPERATION_REQUEST_PENDING --> OPERATION_CHANGES_REQUESTED: review=REQUEST_CHANGES (B)
OPERATION_CHANGES_REQUESTED --> OPERATION_REQUEST_PENDING: re-proceed (P)
OPERATION_REQUEST_PENDING --> ROAD_DISPATCH_PENDING: accept=road (B) (invoice, KM billed)
OPERATION_REQUEST_PENDING --> FULLY_EXECUTED: accept=train (B) (invoice)
FULLY_EXECUTED --> SELECTED_FOR_BATCH: batch engine offers wagons (sys)
SELECTED_FOR_BATCH --> PAID: pay invoice (P)
ROAD_DISPATCH_PENDING --> PAID: pay invoice (P)
PAID --> IN_TRANSIT: operations/start-transit (B)
IN_TRANSIT --> COMPLETED: operations/complete (B)
DRAFT --> PENDING_CONSOLIDATION: requestConsolidation (usually set on submit)
DRAFT --> CANCELLED: cancel (B)
PENDING_APPROVAL --> CANCELLED: cancel (B)
OPERATION_REQUEST_PENDING --> CANCELLED: cancel (B)
note right of PAID
governmentExpedite (B) jumps
gov bookings straight to PAID/Eligible
end note
note left of CANCELLED
cancel is staff-only and allowed ONLY from
DRAFT, SUBMITTED, PRICE_CHANGED_PENDING_CONFIRM,
CHANGES_REQUESTED, PENDING_APPROVAL,
CONTRACT_READY, OPERATION_REQUEST_PENDING
end note
REJECTED --> [*]
CANCELLED --> [*]
COMPLETED --> [*]
```
> **Accuracy notes (verified against code):**
> - Counter-sign branch is keyed on **trade direction**, not a customs flag: `IMPORT`/`EXPORT` →
> `AWAITING_DOCUMENTS` (even when customs is off — a lighter "without customs" clearance doc-set still
> applies); only `DOMESTIC` → `FULLY_EXECUTED`.
> - **Domestic** bookings skip the operation-request/clearance phase entirely — counter-sign enqueues
> them straight into the scheduling batch pipeline (`enqueueScheduleProcessing`).
> - Train `operation/review` accept sets **`FULLY_EXECUTED`** (day batch holding pool). `SELECTED_FOR_BATCH`
> is set **later** by the batch engine when a wagon offer/reservation is made — not at accept.
> - `APPROVED_PENDING_SIGNATURE` is a real intermediate (line-staff approves first, then director+CEO).
> - Full `BOOKING_STATUSES` has 35 values; this diagram is the live commercial subset (legacy statuses
> like `WAGON_ASSIGNED`, `INVOICED`, `PNR_GENERATED` are unused).
Key endpoints (customer (P) / staff (B), `bk:` = `bookings:` perms):
| Method | Path | Action | Guard |
| --- | --- | --- | --- |
| POST | `/api/bookings` | create | in-body (gov→`bk:staff_accept`) |
| PATCH·DELETE | `/api/bookings/:id` | update / soft-delete DRAFT | company-scoped |
| POST | `/api/bookings/:id/generate-price` | price preview (rule-engine) | company-scoped |
| POST | `/api/bookings/:id/submit` · `/confirm-submit` | submit (rate snapshot) | company-scoped |
| POST | `/api/bookings/:id/reject` | customer rejects price | company-scoped |
| POST | `/api/bookings/:id/staff/accept` | → PENDING_APPROVAL | `bk:staff_accept` |
| POST | `/api/bookings/:id/staff/request-changes` · `/staff/reject` | intake outcomes | `bk:request_changes` / `bk:reject` |
| POST | `/api/bookings/:id/approval-steps/:stepId/approve` · `/reject` | approval chain | role perms |
| POST | `/api/bookings/:id/contract/generate` | → CONTRACT_READY | `bk:generate_contract` |
| POST | `/api/bookings/:id/contract/sign` · `/marketing/approve` | sign / counter-sign | in-body / `bk:sign_staff` |
| GET | `/api/bookings/:id/contract/{view,document}` | HTML / PDF | company-scoped |
| GET·POST | `/api/bookings/:id/clearance` (+`/documents`,`/review`,`/finalize`,…) | customs clearance | `bk:*` / `ct:clearance_*` |
| POST | `/api/bookings/:id/clearance/proceed` | request operation | company-scoped |
| POST | `/api/bookings/:id/operation/review` | accept/changes (+invoice) | `bk:operations` |
| POST | `/api/bookings/:id/government-expedite` | gov shortcut → PAID | `bk:staff_accept` |
| POST | `/api/bookings/:id/operations/start-transit` · `/complete` | transit lifecycle | `bk:operations` |
| POST | `/api/bookings/:id/allocate-containers` | assign containers↔vehicles | `allocation:manage` |
| POST·GET·DELETE | `/api/bookings/:id/consolidation` | pair/unpair wagon-share | company-scoped |
| POST | `/api/bookings/:id/customer-truck-assignment` | external truck for pickup | company-scoped |
| GET | `/api/bookings/:id/tracking` | consignment + tracking events | company-scoped |
| GET | `/api/bookings` · `/list-summary` · `/queues/:queue` | lists (branch by perm) | `bk:view` / `bk:clearance_view` |
---
## 7. Contracts — framework agreements & booking paths
```mermaid
stateDiagram-v2
[*] --> DRAFT: create (P)
DRAFT --> SUBMITTED: submit (P) (freeze rate snapshots)
SUBMITTED --> PENDING_APPROVAL: staff/accept (B)
SUBMITTED --> CHANGES_REQUESTED: request-changes (B)
CHANGES_REQUESTED --> SUBMITTED: resubmit (P)
SUBMITTED --> REJECTED: reject (B)
PENDING_APPROVAL --> APPROVED: approve chain (B)
APPROVED --> CONTRACT_READY: contract/generate (B)
CONTRACT_READY --> SIGNED_CUSTOMER: customer sign (P)
SIGNED_CUSTOMER --> AWAITING_CLEARANCE_DOCUMENTS: counter-sign, IMPORT/EXPORT (not GENERAL+customs) (B)
SIGNED_CUSTOMER --> CONTRACT_ACTIVE: counter-sign, GENERAL+customs or DOMESTIC (B)
SIGNED_CUSTOMER --> FULLY_EXECUTED: counter-sign, ONE_TIME DOMESTIC (B)
CONTRACT_ACTIVE --> [*]: renew → RENEWAL_DRAFT
```
> Diagram shows the live path; `CONTRACT_STATUSES` has **24 values** total (adds APPROVED_PENDING_SIGNATURE,
> CLEARANCE_UNDER_REVIEW, CLEARANCE_READY_FOR_BOOKING, ACTIVE_SHIPMENT_IN_PROGRESS, CONTRACT_CLOSED, CANCELLED,
> RENEWAL_SUBMITTED/PENDING_APPROVAL, AMENDMENTS_PROPOSED, ARCHIVED — see §15). **GENERAL+customs skips the
> contract clearance cycle → `CONTRACT_ACTIVE` directly** (clearance runs per-booking, Path B); only IMPORT/EXPORT
> one-time or self-clearance opens the contract-level `AWAITING_CLEARANCE_DOCUMENTS` cycle.
**Two ways a contract spawns shipment bookings:**
```mermaid
flowchart TD
active["Contract ACTIVE / SELF_CLEARED"] --> path{"Contract path"}
path -->|"Path A: transport-only"| a1["Ops reviews self-clearance docs (B)<br/>(ops-review → ops-finalize → SELF_CLEARED)"]
a1 --> a2["Customer books directly (P)<br/>POST /contracts/:id/bookings"]
path -->|"Path B: GENERAL + customs"| b1["Customer submits BookingRequest (P)<br/>POST /contracts/:id/booking-requests"]
b1 --> b2["GL queue → accept (B)<br/>(ct:create_booking) → GL creates booking"]
a2 --> cap["createUnderContract:<br/>window + capacity draw-down check"]
b2 --> cap
cap --> spawn(["New Booking under contract<br/>(bookings.contract_id)"])
```
Contract clearance is **phased** (ET vs DJ permissioned): declaration → duty advice → duty slip →
transit permit → delivery/release order → T1 docs/close → final invoice → incidents. `clearanceStatus`
is a separate axis (`AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY_FOR_BOOKING /
SELF_CLEARED / ACTIVE_SHIPMENT_IN_PROGRESS`). ~60 contract routes total. Per-booking clearance
actions (`duty`, `risk`, `t1-documents`, `t1-close`, `final-invoice`, `second-duty`, `transport-document`,
`station-assign`, `incidents`) hang off `/api/contracts/bookings/:bookingId/*`; note **`declaration` is a
contract-level route** (`/api/contracts/:id/clearance/declaration`), not a booking-scoped one.
### Consignment / Cargo / Container (fleet-side records)
| Entity | Controller | Guard | Lifecycle | Callers |
| --- | --- | --- | --- | --- |
| Consignment | `/api/consignments` (create/list/get) | `FleetView`/`Manage` | `Pending → …` (downstream shipment record; booking `:id/tracking` reads it) | (P) read-only |
| Cargo | `/api/cargoes` (+ `/load` `/unload` `/deliver`) | `FleetView`/`Manage` | `PENDING → LOADED → UNLOADED / DELIVERED` (POD) | (B) cargoService |
| Container | `/api/containers` (+ `/assign-wagon` `/unassign-wagon`) | `FleetView`/`Manage` | `AVAILABLE → LOADED → IN_TRANSIT …` | (B) containerService |
---
## 8. Rule Engine — the pricing & approval brain
`RuleEngineService.evaluate(input)` is injected into bookings & contracts pricing. One pass pulls all
rule tables and returns `{ priorityScore, appliedModifiers, containerWeightResults, warnings,
hardBlocked, requiresDirectorApproval }`.
```mermaid
flowchart TD
ev["evaluate(BookingEvaluationInput)"] --> rates["rates.findLiveRates()<br/>(only LIVE)"]
rates --> sur{"surcharge triggers"}
sur -->|"HAZARDOUS/REEFER/OVERWEIGHT/<br/>SHIPPING_LINE/CONSOLIDATION"| mods["appliedModifiers →<br/>surcharge line-items"]
mods --> snap["snapshotRates → booking_rate_snapshots<br/>(freeze exact rate used)"]
ev --> wlr["weight-limit-rules"]
wlr --> block{"VGM > maxCapacityTons?"}
block -->|Yes| hard["HARD BLOCK (400)"]
block -->|"over maxVgm, within cap"| warn["warning + OVERWEIGHT surcharge"]
ev --> appr["approval-rules →<br/>instantiateApprovalSteps<br/>(requiredRole/blocksRole/stepOrder)"]
ev --> prio["priority-configs + serviceType bonus +<br/>GOVERNMENT_PRIORITY_BONUS → priorityScore"]
prio --> schedorder["train-scheduling orders by priorityScore"]
```
**Rate lifecycle:** `DRAFT → (submit) PENDING_APPROVAL → (CEO approve) LIVE`. Only LIVE rates apply.
| Resource | Base path | Guard | Notes |
| --- | --- | --- | --- |
| approval-rules | `/api/approval-rules` (+`/chain`,`/reorder`,`/:id/move-order`) | `ruleEngine.view/manage(approval-rules)` | ordered chain |
| cargo-types | `/api/cargo-types` | `…(cargo-types)` | portal reads via `bookings/reference-data` |
| container-types | `/api/container-types` | `…(container-types)` | portal indirect |
| priority-configs | `/api/priority-configs` | `…(priority-configs)` | WAGON / CURRENCY bands |
| rates | `/api/rates` (+`/live`,`/:id/submit`,`/approve`) | `…(rates)` | DRAFT→PENDING→LIVE |
| service-types | `/api/service-types` | `…(service-types)` | `includesFirstMile/LastMile` flags |
| shipping-lines | `/api/shipping-lines` | `…(shipping-lines)` | surcharge trigger |
| weight-limit-rules | `/api/weight-limit-rules` | `…(weight-limit-rules)` | VGM hard-block |
| yards | `/api/yards` | `…(yards)` | routes/warehouses read |
**Settings & files** (reads open, writes `@FreightAdmin`):
`/api/dropdown-settings/*`, `/api/file-upload-settings/*` (config that drives portal forms;
server enforces *required-doc presence* at clearance, not size/MIME — those are client-side).
`GET /api/files/:fileId` is `@Public` (browsers load `<img>/<a>` without a bearer); upload is **direct
multipart** (multer memory → `FilesService` → MinIO `putObject`), presigned URLs used only for authenticated
reads (300 s TTL). `routes` (`route_milestones`) and `facilities` are config readers with **no permission
decorator** (behind the global JwtGuard only). NOTE: Note `FacilitiesModule` is never imported into `AppModule`,
so `/api/facilities` is unmounted/dead.
---
## 9. Train Scheduling & Rail Operations
Two consist models: **`trains`** = static fleet inventory (never created by scheduling);
**`train_sets`** = the operational consist scheduling builds per departure (**≥2 locomotives** +
wagon-type slots, physical wagons *pinned* later).
```mermaid
stateDiagram-v2
[*] --> DRAFT: create schedule (B) (≥2 locos, derive direction, freeze window rule)
DRAFT --> DRAFT: assign-bookings / run-allocation / pin-wagons (B)
DRAFT --> SCHEDULED: finalize (B) (bookings → Scheduled, ≥1 booking)
SCHEDULED --> DISPATCHED: dispatch (B) (train_number, locos ASSIGNED, window CLOSED, unpaid EXPIRED)
DISPATCHED --> DISPATCHED: recordCheckpoint (B) (corridor stations)
DISPATCHED --> ARRIVED: arrive (B) (bookings IN_TRANSIT, locos+wagons freed, warehouse arrival automation)
DRAFT --> CANCELLED: cancel (B)
SCHEDULED --> CANCELLED: cancel (B)
ARRIVED --> [*]
```
**Reschedule** (`DRAFT`/`SCHEDULED` only): `preview` merges current + incoming bookings, sorts by
`compareSchedulingPriority` (**government first, then priorityScore**), greedily keeps those that still
fit → *retained*; overflow → *displaced*; commercial displaced are *readmitted* if room remains.
`execute` re-verifies, optionally sets new departure, unassigns displaced, re-assigns final, writes a
`scheduling_events` audit row. Triggers: `GOVERNMENT_PREEMPT`, `TRAIN_MAINTENANCE`.
Endpoint groups (`train-scheduling` prefix, `trainScheduling.view/manage`):
| Group | Representative routes |
| --- | --- |
| Discovery (customer (P), unguarded) | `bookable-schedules`, `available-days`, `available-days-for-cargo`, `my-booking-windows`, `contracts/:id/booking-windows` |
| Board (staff (B)) | `batch-board`, `batch-board/:id`, `eligible-bookings`, `container|bulk/eligible-bookings`, `global-rules` |
| Build | `container|bulk/preview`, `container|bulk/schedules` (create), `:id/assign-bookings`, `:id/assign-unassigned-booking`, `:id/pin-wagons`, `:id/run-allocation`, `:id/run-batch` |
| Composition edits | `:id/wagons/:wagonId` (remove slot), `:id/container-items/:itemId`, `bookings/:bookingId/{mark-paid,expire,move-schedule}` |
| Lifecycle | `schedules/:id/{finalize,dispatch,arrive,booking-window,doc-review-complete}`, `{container\|bulk}/schedules/:id/cancel` |
| Tracking | `schedules/:id/checkpoints` (GET/POST) → writes `train_checkpoint_events` |
| Djibouti import ops | `:id/import-djibouti/*` (gatepass, ready-for-loading, loaded, depart, load-list) |
| Reschedule | `:id/reschedule/preview`, `:id/reschedule/execute`, `:id/maintenance` |
**Fleet master data** (`FleetView/Manage`): `/api/trains` (+`/:trainId/reorder-wagons`), `/api/wagons`
(+`/assign-train`,`/unassign-train`), `/api/locomotives` (+`/decommission`), `/api/wagon-types`
(rule-engine guarded). **Asset records** (unguarded, keyed by *road* `vehicleId`): `/api/maintenance/*`,
`/api/fuel/*`. **`fleet_events`** is an append-only audit read via `drivers/:id/history` &
`vehicles/:id/history`.
**Tracking (customer-facing):** `GET /api/tracking/:consignmentId``tracking_events` timeline
(`location`, `ConsignmentStatus`, `occurredAt`), consumed by portal `TrackingPage`. It is **JWT-guarded**
(no permission decorator — not public). Distinct from staff train **checkpoints** (`train_checkpoint_events`,
written by `recordCheckpoint`/`arrive`). NOTE: **`TrackingService.record()` has no caller anywhere in the
codebase — nothing writes `tracking_events`, so the customer tracking timeline is currently unpopulated;
only the staff `train_checkpoint_events` store is written.**
---
## 10. Warehouse — inventory lifecycle
Hierarchy `Facility → Warehouse → Yard → Zone`; every level tracks weight/volume/container capacity kept
in sync by `applyCapacityDelta`. `warehouse_inventory` carries nullable FKs to `booking`, `cargo`,
`container` — the join point between warehouse and the shipment.
```mermaid
stateDiagram-v2
[*] --> RECEIVED: receive / bulkReceive (B)
[*] --> UNLOADED: auto-unload-arrived (import train) (B)
RECEIVED --> STORED: store (B) (allocation rule picks yard/zone)
UNLOADED --> STORED: store (B)
UNLOADED --> READY_FOR_PICKUP: inspection PASSED + IMPORT (B)
RECEIVED --> READY_FOR_PICKUP: inspection PASSED + IMPORT (B)
STORED --> RESERVED: reserve (B) (booking PAID)
RESERVED --> READY_FOR_LOADING: mark-ready-for-loading (B) (inspection PASSED)
READY_FOR_LOADING --> LOADED: load onto wagon (B) (+ warehouse_loadings)
LOADED --> DISPATCHED: dispatch / bulk-dispatch-export (B)
DISPATCHED --> UNLOADED_AT_DJIBOUTI_PORT: auto-unload-at-djibouti (B)
READY_FOR_PICKUP --> DELIVERED: release → deliver (B) (POD, fees PAID via gate-clearance)
READY_FOR_PICKUP --> STORED: re-store import item (B)
READY_FOR_PICKUP --> DISPATCHED: dispatch out (B)
UNLOADED_AT_DJIBOUTI_PORT --> [*]
DELIVERED --> [*]
```
**Export branch:** receive → STORED → RESERVED (booking PAID) → READY_FOR_LOADING → LOADED → DISPATCHED
→ UNLOADED_AT_DJIBOUTI_PORT. Note `store()` does **not** check inspection — `RECEIVED → STORED` is legal
without it; inspection **PASSED** is enforced only at `mark-ready-for-loading`. **Import branch:** UNLOADED
→ inspect PASSED → READY_FOR_PICKUP (auto-creates
last-mile if requested) → release → DELIVERED. Every transition writes `warehouse_activity_log`.
**Storage billing** is *not* a separate table — `WarehouseInvoiceService` is a thin layer over the
central **billing** module (global `Invoice` rows, `source='warehouse'`, `sourceId=inventoryId`). Fees =
`STORAGE_FEE` + `DEMURRAGE` via `warehouse_fee_rules` (free-days grace, tiers, FX-converted). **Unpaid
warehouse fees block exit:** `gateClearance`/`release` call `findBlockingInvoice` / `assertClearanceAllowed`.
Controller families (84 routes, all called by backoffice `warehouse.service.ts`; **no per-route
permission decorators → behind the global JwtGuard only**): `warehouses` · `warehouse-yards` ·
`warehouse-zones` · `warehouse-inventory` (queries + 25 mutation actions incl. bulk + import/export
queues, and the gate release `warehouse-inventory/:id/gate-clearance`) · `warehouse-loadings` ·
inspection (`…/inspection-reports`) · fee-invoices (`…/generate-fee-invoice`, `warehouse-fee-invoices/*`
incl. `pay`, `pay-online`) · rules (`warehouse-allocation-rules`, `warehouse-fee-rules`,
`warehouse-allocation/preview`).
> Portal touches only: `bookings/:id/warehouse-fee-invoices`, invoice `by-id`/`document`/`receipt`/
> `pay-online`, and `bookings/:bookingId/approve-delivery` (customer signs handover). Everything else (B).
---
## 11. Field Operations — first/last mile & import customs
```mermaid
flowchart TD
binv["booking.invoice.paid event"] --> advance["advanceBookingOnPayment → booking PAID"]
advance --> fmreq{"EXPORT + first-mile requested?"}
fmreq -->|Yes| fmaccept["firstMileService.acceptBooking<br/>(auto-create leg)"]
fmreq -->|No| skip1["—"]
fmaccept --> fmleg
subgraph FM["First-mile (EXPORT origin road leg)"]
fmleg["READY_TO_TRANSIT"] --> fmveh["setVehicles → vehicle BUSY,<br/>SMS driver, fleet_events"]
fmveh --> fmtransit["IN_TRANSIT (needs assigned vehicle)"]
fmtransit --> fmdone["RECEIVED_TO_PORT (free vehicles)"]
fmleg --> fminv["generate-invoice → FIRST_MILE fee<br/>(locks distances once invoiced)"]
end
whrcv["warehouse inspection PASSED (IMPORT only)"] --> lmaccept["lastMileService.acceptBooking(reference)"]
lmaccept --> lmleg
subgraph LM["Last-mile (IMPORT destination road leg)"]
lmleg["READY_TO_TRANSIT"] --> lmtransit["IN_TRANSIT"] --> lmdone["DELIVERED (free vehicles)"]
lmleg --> lminv["generate-invoice → LAST_MILE fee"]
end
subgraph IMP["Import customs finalization (per booking, timestamp-driven)"]
direction TB
up["upload docs (IM4/IM5/T1_CLOSURE/…)"] --> decl["record declaration serial"]
decl --> notify["notify duties/taxes"]
notify --> paid["mark duties paid (needs CUSTOMER_PAYMENT_SLIP)"]
paid --> risk["assign risk (GREEN/YELLOW/BLUE/RED)"]
risk --> rel["release-permitted (asserts T1 + release permit + declaration + risk + paid)"]
end
```
| Module | Base | Guard | Terminal state |
| --- | --- | --- | --- |
| first-mile | `/api/first-mile` (+`/accept/:ref`,`/:id/{vehicles,distances,invoice}`) | `trainScheduling.view/manage` | `RECEIVED_TO_PORT` |
| last-mile | `/api/last-mile` (same shape) | `trainScheduling.view/manage` | `DELIVERED` |
| import-operations | `/api/import-operations/{customs,djibouti-incidents,empty-container-returns}/*` | none (global JwtGuard) | `completedAt` |
| interchange-documents | `/api/interchange-documents` (+`/generate-from-schedule`,`/:id/{acknowledge,dispute,cancel}`) | none | `ACKNOWLEDGED / DISPUTED` |
| drivers | `/api/drivers` (+`/:id/history`) | `FleetView/Manage` | soft-delete |
| vehicles | `/api/vehicles` (+`/:id/history`) | `FleetView/Manage` | soft-delete |
Interchange documents are the **rail↔port handover manifest** — generated from a train schedule,
snapshotting booking/container/cargo lines with per-item `conditionStatus` derived from warehouse
inspection flags. Status `DRAFT → GENERATED → ACKNOWLEDGED | DISPUTED | CANCELLED`. All FE callers (B).
**Mile trigger precision (verified):** first-mile is created only on `booking.invoice.paid`
`advanceBookingOnPayment``firstMile.acceptBooking(bookingId)` when `EXPORT` + first-mile requested.
Last-mile is created **only on IMPORT inspection PASSED** (two call sites: `warehouse-inspection.service`
and the bulk-inspect branch of `warehouse-inventory.service`) — **not** on warehouse *receive*. The IMPORT
constraint is enforced by the warehouse caller, not inside `lastMile.acceptBooking(reference)`. Auto-created
legs enter at **`READY_TO_TRANSIT`** (the `PAYMENT_PENDING` entity default is bypassed).
---
## 12. Billing & Payment
### 12.1 Invoice lifecycle
Invoices are **source-agnostic**`BillingService.generateInvoice()` is the single factory called by
domain services (never a controller): `source ∈ {booking, warehouse, first_mile, last_mile}` (the enum
also defines an unused `demurrage`), numbered `INV-YYYYMMDD-#####`, `dueAt = now + 14d`. **Initial status
varies by source:** booking invoices start `DRAFT` (issued at operation-accept via `billing.updateStatus`);
the `generateInvoice` default is `PENDING`; warehouse + contract-GL invoices start `ISSUED`.
```mermaid
stateDiagram-v2
[*] --> DRAFT: booking invoice (issued at operation-accept)
[*] --> PENDING: generateInvoice default
[*] --> ISSUED: warehouse / contract-GL invoice
DRAFT --> ISSUED: billing.updateStatus
PENDING --> PARTIALLY_PAID: recordPayment (partial, offline)
ISSUED --> PARTIALLY_PAID: recordPayment (partial, offline)
PENDING --> PAID: markInvoiceAsPaid (gateway, full)
ISSUED --> PAID: markInvoiceAsPaid (gateway, full)
PARTIALLY_PAID --> PAID: final payment
PENDING --> EXPIRED: expirePayable (pay window lapses)
ISSUED --> CANCELLED: cancelInvoice (no payments)
PAID --> REFUNDED: markInvoiceAsRefunded (paidAmount>0)
PAID --> [*]
note right of PAID
emits ${source}.invoice.paid
(sources use first_mile / last_mile, underscores)
→ domain listeners advance booking / mile / warehouse
end note
note left of ISSUED
OVERDUE exists in the enum but NO code sets it
(no cron / setter) — effectively unused
end note
```
`OPEN_STATUSES = {Issued, Pending, PartiallyPaid, Overdue}` are payable. Transitions lock the row
(`pessimistic_write`); the event fires **after commit** for self-managed transitions, but **inline before
commit** when the transition is enlisted in a caller-supplied transaction `manager`.
### 12.2 freight-api ↔ payment-api integration
```mermaid
sequenceDiagram
autonumber
participant U as Customer (P)
participant FB as Freight billing (:3001)
participant PA as Payment API (red) (:3003)
participant PV as Provider (Telebirr/Waafi/…)
participant OB as Payment outbox
participant FI as Freight internal ctrl
U->>FB: POST /billing/my-invoices/:id/pay
FB->>FB: payInvoice → validate OPEN, balance>0
FB->>PA: POST /payments/initiate (x-service-token)<br/>service=FREIGHT, referenceId=sourceId, amountMinor
PA-->>FB: { intentId, clientAction (REDIRECT/LAUNCH_APP/COLLECT_OTP) }
FB->>FB: store paymentId on invoice (correlation)
FB-->>U: clientAction → redirect to provider
U->>PV: authorize payment
PV->>PA: webhook POST /webhooks/{provider}
PA->>PA: verify signature, dedupe, intent state machine
PA->>OB: write PaymentEvent (payment.succeeded)
OB->>FI: POST /api/internal/payments/mark-paid (@Public, x-service-token)
Note over OB,FI: or RabbitMQ → PaymentEventsConsumer
FI->>FB: handlePaymentEvent → settleByPaymentId → markInvoiceAsPaid
FB->>FB: emit booking.invoice.paid
FB-->>U: invoice PAID (poll / notification)
```
> NOTE: **Demo shortcut in `payInvoice`:** today, if the provider doesn't settle synchronously, freight
> self-fires `handlePaymentEvent(payment.succeeded)` inline (marked TODO/remove) — invoices settle at
> pay-time without a real webhook. Providers: **Telebirr / CBE_BIRR / EBIRR** (ET), **Waafi / DMoney**
> (DJ), **CARD** (intl), **CAC_BANK** (OTP). Payment DB is a **separate `edr_payment` schema** — no
> cross-schema FKs; `referenceId` is a soft link.
### 12.3 Endpoints
| Method | Path | Facing | Guard |
| --- | --- | --- | --- |
| GET | `/api/billing/invoices` (+`/:id`,`/:id/document`,`/receipt`) | (B) backoffice | `bookings.view` |
| GET | `/api/billing/my-invoices` (+`/:id`,`/document`,`/receipt`) | (P) portal | `@CurrentUser` ownership |
| POST | `/api/billing/my-invoices/:id/pay` | (P) portal | ownership |
| POST | `/api/payments/initiate` | central | JwtGuard (no permission — **not** public) |
| GET | `/api/payments/checkout` | redirect | `@Public` |
| GET | `/api/payments/{summary,all}` | (B) backoffice | `bookings.view` |
| GET | `/api/payments/by-company/:companyId/customer-view` | (B) | none |
| GET | `/api/payments/intents/:bookingId` · `/receipt/:orderId` | reconcile / receipt | none / `@Public` |
| POST | `/api/internal/payments/mark-paid` | (red) service→service | `@Public` (NOTE: currently unauthenticated) |
| POST·GET·PUT | `/api/backoffice/organizations/:orgId/*` | (B) IAM user/role mgmt (NOT billing) | `@FreightAdmin` |
`GET /api/overview*` (7 tabs, `bookings.view`) is the backoffice dashboard aggregator over 11 repos.
---
## 13. Cross-cutting event web (`@OnEvent`)
The domains are stitched together by events, not direct calls. This is why the master flow "just happens".
```mermaid
flowchart LR
pay["Payment webhook / demo shortcut"] --> settle["billing.settleByPaymentId → markInvoiceAsPaid"]
settle --> ev1{{"${source}.invoice.paid"}}
ev1 -->|source=booking| adv["booking-invoice: advanceBookingOnPayment → PAID"]
adv --> fm["firstMile.acceptBooking (EXPORT + requested)"]
adv --> batch["train-scheduling batch: markPaid → allocate"]
ev1 -->|source=first_mile| e2{{"first_mile.invoice.paid"}} --> fmp["NOTE: intended: leg paid=true<br/>(listener typo 'firstmile.invoice.paid' → never fires)"]
ev1 -->|source=last_mile| e3{{"last_mile.invoice.paid"}} --> lmp["last-mile → DELIVERED, paid=true"]
ev1 -->|source=warehouse| e4{{"warehouse.invoice.paid"}} --> whp["settle warehouse fee → unblock gate"]
arrive["train arrive"] --> whauto["warehouse arrival automation:<br/>auto-unload arrived bookings"]
insp["warehouse inspection PASSED (IMPORT)"] --> lmacc["lastMile.acceptBooking → READY_FOR_PICKUP"]
assign["mile setVehicles"] --> veh["vehicle BUSY + SMS driver + fleet_events"]
release["mile terminal / delete"] --> free["vehicle FREE (releaseIfUnused)"]
notify["notification-inbox.notify"] --> ws["WebSocket push (always)"]
notify -->|HIGH priority| smsemail["+ SMS + email via RabbitMQ"]
```
---
## 14. Portal vs Backoffice — who does what
| Capability | (P) Portal (customer) | (B) Backoffice (employee) |
| --- | --- | --- |
| Identity / onboarding | signup, OTP, Fayda, company onboarding, profile | company approval, IAM user/role mgmt |
| Bookings | create, price, submit, sign, upload docs, pay, approve delivery, track | accept, approve chain, generate contract, clearance review, operation review, allocate, dispatch |
| Contracts | create, submit, sign, booking-requests, self-clearance slips | approve, generate, phased clearance, GL booking creation |
| Rule engine | reads only via `bookings/reference-data` | full CRUD (rates approval, approval rules, priorities) |
| Scheduling | discover bookable days/schedules | build/finalize/dispatch/arrive/reschedule trains |
| Warehouse | pay storage fees, view invoices, approve handover | full inventory lifecycle + fees + rules |
| Field ops | (none direct) | first/last mile, import customs, interchange docs, fleet |
| Billing | pay own invoices, download docs/receipts | invoice + payment dashboards, per-customer views |
| Notifications | inbox + WS | inbox + WS |
---
## 15. Status / state reference
| Entity | States (happy path → terminal) |
| --- | --- |
| Company | `PENDING → ACTIVE` (+ SUSPENDED, BLACKLISTED) |
| Booking | `DRAFT → SUBMITTED → PENDING_APPROVAL → APPROVED_PENDING_SIGNATURE → APPROVED → CONTRACT_READY → SIGNED_CUSTOMER → [IMPORT/EXPORT: AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY → OPERATION_REQUEST_PENDING → (train) FULLY_EXECUTED → SELECTED_FOR_BATCH / (road) ROAD_DISPATCH_PENDING] · [DOMESTIC: → FULLY_EXECUTED → SELECTED_FOR_BATCH] → PAID → IN_TRANSIT → COMPLETED` (branches: REJECTED, CANCELLED, CHANGES_REQUESTED, OPERATION_CHANGES_REQUESTED, PENDING_CONSOLIDATION). 35 statuses total; ~10 legacy ones unused. |
| Contract | `DRAFT → SUBMITTED → PENDING_APPROVAL → APPROVED_PENDING_SIGNATURE → APPROVED → CONTRACT_READY → SIGNED_CUSTOMER → AWAITING_CLEARANCE_DOCUMENTS / CONTRACT_ACTIVE / FULLY_EXECUTED` (+ CLEARANCE_UNDER_REVIEW, CLEARANCE_READY_FOR_BOOKING, ACTIVE_SHIPMENT_IN_PROGRESS, CONTRACT_CLOSED, CANCELLED, RENEWAL_DRAFT/SUBMITTED/PENDING_APPROVAL, AMENDMENTS_PROPOSED, ARCHIVED, EXPIRED — **24 total**). Separate `clearanceStatus` axis: NOT_APPLICABLE / AWAITING_DOCUMENTS / DOCUMENTS_UNDER_REVIEW / CLEARANCE_READY_FOR_BOOKING / SELF_CLEARED / ACTIVE_SHIPMENT_IN_PROGRESS. |
| Rate | `DRAFT → PENDING_APPROVAL → LIVE` (+ SUPERSEDED) |
| Train schedule | `DRAFT → SCHEDULED → DISPATCHED → ARRIVED` (+ CANCELLED) |
| Warehouse inventory | export: `RECEIVED → STORED → RESERVED → READY_FOR_LOADING → LOADED → DISPATCHED → UNLOADED_AT_DJIBOUTI_PORT` · import: `UNLOADED → READY_FOR_PICKUP → DELIVERED` (READY_FOR_PICKUP may also → STORED / DISPATCHED) |
| First-mile | `PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → RECEIVED_TO_PORT` (auto-created legs enter at READY_TO_TRANSIT) |
| Last-mile | `PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → DELIVERED` (auto-created legs enter at READY_TO_TRANSIT) |
| Invoice | `DRAFT / PENDING / ISSUED → PARTIALLY_PAID → PAID` (+ EXPIRED, CANCELLED, REFUNDED; **OVERDUE defined but never set**) |
| Interchange doc | `DRAFT → GENERATED → ACKNOWLEDGED / DISPUTED / CANCELLED` |
| Empty container return | `RETURNED → ASSIGNED_STORAGE → DOCUMENTATION_CLEARED → WAGON_ALLOCATED → TRANSPORTED_TO_DJIBOUTI → HANDOVER_ISSUED → COMPLETED` |
---
## 16. Notable gaps & caveats (verified against code)
- **Counter-sign is direction-based, not customs-based** (bookings *and* contracts): `IMPORT`/`EXPORT` open a
clearance gate even with customs off; only `DOMESTIC` skips it. The customs flag only selects the clearance
document set.
- **`SELECTED_FOR_BATCH` is set by the batch engine, not at operation-accept** — train accept sets
`FULLY_EXECUTED` first.
- **`FacilitiesModule` is never imported into `AppModule`** — `/api/facilities` is **unmounted / dead** (not
reachable at all). `tracking`, `maintenance`, `fuel` carry no permission decorator but *are* mounted — behind
the **global JwtGuard**, just not permission-gated.
- **`tracking_events` has no writer anywhere** — `TrackingService.record()` is never called, so the customer
tracking timeline is unpopulated. Only staff `train_checkpoint_events` are written (by `recordCheckpoint`/`arrive`).
- **First-mile paid-flag listener is a dead code path** — `@OnEvent("firstmile.invoice.paid")` (no underscore)
never fires because the emitted event is `first_mile.invoice.paid`; the first-mile `paid` flag is never flipped by settlement.
- **Invoice `OVERDUE` status is never set** — no cron/setter transitions to it, though it is in the enum and `OPEN_STATUSES`.
- `POST /api/internal/payments/mark-paid` is `@Public` with **no service auth** (ServiceAuthGuard removed; noted in code).
- **Demo settlement shortcut** in `billing.payInvoice` bypasses real webhooks (TODO/remove) — invoices settle inline at pay-time.
- Server does **not** validate upload size/MIME (client-side only); it enforces *required-doc presence* at clearance.
- Dead / unwired FE calls: `trains/:id/details` (no route), portal `consignments` service hits `/consignments` without `/api`, and list endpoints `bookings/my`, `queues/:queue`, `by-company/customer-view` have no active caller.
- NOTE: The repo `CLAUDE.md` states auth is stubbed/unwired — **this is stale**: auth is live via `@tria-plc/api-common` (global `JwtGuard` + `HasActiveDelegationGuard` `APP_GUARD`s).
---
*Generated from `apps/edr-freight-api`, `apps/edr-freight-web/{portal,backoffice}`, `apps/edr-payment-api`
on branch `freight/feat/fixes-v1`. Reflects code at scan time; regenerate after major module changes.*

View File

@@ -93,6 +93,7 @@
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/vorpal": "^1.12.8", "@types/vorpal": "^1.12.8",
"jest": "^29.7.0", "jest": "^29.7.0",
"socket.io-client": "^4.8.3",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.1",

View File

@@ -1,20 +1,33 @@
import type { ScheduleTradeDirection } from '@edr/types'; import { YardCountry, type ScheduleTradeDirection } from '@edr/types';
type YardLike = { country?: string | null }; type YardLike = { country?: string | null };
/** Derive booking/schedule trade direction from origin and destination yard countries. */ /**
* Derive trade direction from origin and destination yard countries.
* Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT, same country =
* DOMESTIC (shown as "Intercity"; scheduling/contracts reject it for now).
* Comparison is strict against the YardCountry enum values the yards table is
* constrained to; the trim/case fold only shields legacy rows.
*/
export function deriveTradeDirection( export function deriveTradeDirection(
originYard: YardLike, originYard: YardLike,
destinationYard: YardLike, destinationYard: YardLike,
): ScheduleTradeDirection { ): ScheduleTradeDirection {
const originCountry = originYard.country?.trim().toLowerCase(); const origin = normalizeCountry(originYard.country);
const destinationCountry = destinationYard.country?.trim().toLowerCase(); const destination = normalizeCountry(destinationYard.country);
if (originCountry === 'djibouti') { if (origin === YardCountry.DJIBOUTI && destination === YardCountry.ETHIOPIA) {
return 'IMPORT'; return 'IMPORT';
} }
if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') { if (origin === YardCountry.ETHIOPIA && destination === YardCountry.DJIBOUTI) {
return 'EXPORT'; return 'EXPORT';
} }
return 'DOMESTIC'; return 'DOMESTIC';
} }
function normalizeCountry(country: string | null | undefined): YardCountry | null {
const folded = country?.trim().toLowerCase();
if (folded === YardCountry.ETHIOPIA.toLowerCase()) return YardCountry.ETHIOPIA;
if (folded === YardCountry.DJIBOUTI.toLowerCase()) return YardCountry.DJIBOUTI;
return null;
}

View File

@@ -105,9 +105,14 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
password: process.env.DB_PASSWORD ?? "", password: process.env.DB_PASSWORD ?? "",
database: process.env.DB_NAME ?? "edr_freight", database: process.env.DB_NAME ?? "edr_freight",
schema: "public", schema: "public",
extra: { // The `-c search_path=...` startup option is rejected by transaction-pooling
options: `-c search_path=${APPLICATION_SEARCH_PATH}`, // poolers (e.g. PgBouncer: "unsupported startup parameter in options"). When
}, // behind such a pooler set DB_PGBOUNCER=true and instead make the search_path
// a role default: ALTER ROLE <user> IN DATABASE <db> SET search_path TO
// public,iam,freight,audit;
...(process.env.DB_PGBOUNCER === "true"
? {}
: { extra: { options: `-c search_path=${APPLICATION_SEARCH_PATH}` } }),
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities], entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
autoLoadEntities: true, autoLoadEntities: true,
migrations: [ migrations: [

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Structured import handover records. Replaces the ad-hoc handover notes so a
* booking can carry one handover (single truck) or several (one per truck when
* multiple trucks are used). Timing differs by mile type:
* - SELF_HAUL: generated on first truck arrival, signed before the truck leaves.
* - EDR_LAST_MILE: generated at delivery (after exit); signed on delivery.
*/
export class AddBookingHandovers1980000000000 implements MigrationInterface {
name = 'AddBookingHandovers1980000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.booking_handovers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
truck_assignment_id uuid REFERENCES freight.customer_truck_assignments(id) ON DELETE SET NULL,
truck_plate varchar(32),
mile_type varchar(20) NOT NULL,
reference varchar(100) NOT NULL,
generated_at timestamptz NOT NULL DEFAULT now(),
signed_at timestamptz,
signed_by_user_id uuid,
delivered_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_booking_handovers_booking" ON freight.booking_handovers (booking_id);`,
);
// At most one live handover per (booking, customer truck). EDR trucks (which
// aren't customer_truck_assignments) and per-booking handovers are de-duped
// in the service, since a NULL truck_assignment_id can't be uniquely indexed.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_truck"
ON freight.booking_handovers (booking_id, truck_assignment_id)
WHERE deleted_at IS NULL AND truck_assignment_id IS NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_handovers;`);
}
}

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Yard country becomes a two-value enum (Ethiopia | Djibouti) and every route
* freezes its trade direction from the yard countries:
* Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT,
* same country = DOMESTIC (shown as "Intercity"; disabled for scheduling
* and contracts for now).
*
* Existing yard rows are normalized case-insensitively; anything mentioning
* Djibouti maps there, everything else maps to Ethiopia (the line only serves
* these two countries). A CHECK constraint keeps future writes honest.
*/
export class YardCountryEnumAndRouteDirection1980000000000 implements MigrationInterface {
name = 'YardCountryEnumAndRouteDirection1980000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.yards
SET country = CASE
WHEN lower(trim(country)) LIKE '%djib%' THEN 'Djibouti'
ELSE 'Ethiopia'
END
`);
await queryRunner.query(`
ALTER TABLE freight.yards
DROP CONSTRAINT IF EXISTS chk_yards_country,
ADD CONSTRAINT chk_yards_country CHECK (country IN ('Ethiopia', 'Djibouti'))
`);
await queryRunner.query(`
ALTER TABLE freight.routes
ADD COLUMN IF NOT EXISTS direction varchar(10)
`);
await queryRunner.query(`
UPDATE freight.routes r
SET direction = CASE
WHEN o.country = 'Djibouti' AND d.country = 'Ethiopia' THEN 'IMPORT'
WHEN o.country = 'Ethiopia' AND d.country = 'Djibouti' THEN 'EXPORT'
ELSE 'DOMESTIC'
END
FROM freight.yards o, freight.yards d
WHERE o.id = r.origin_yard_id
AND d.id = r.destination_yard_id
`);
// Orphan origin/destination (deleted yard) — no way to classify; park as
// DOMESTIC, which is blocked everywhere, so nothing can schedule on it.
await queryRunner.query(`
UPDATE freight.routes SET direction = 'DOMESTIC' WHERE direction IS NULL
`);
await queryRunner.query(`
ALTER TABLE freight.routes
ALTER COLUMN direction SET NOT NULL,
DROP CONSTRAINT IF EXISTS chk_routes_direction,
ADD CONSTRAINT chk_routes_direction CHECK (direction IN ('IMPORT', 'EXPORT', 'DOMESTIC'))
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.routes
DROP CONSTRAINT IF EXISTS chk_routes_direction,
DROP COLUMN IF EXISTS direction
`);
await queryRunner.query(`
ALTER TABLE freight.yards DROP CONSTRAINT IF EXISTS chk_yards_country
`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Double-handling fee support. warehouse_fee_rules.basis: how a
* DOUBLE_HANDLING_FEE rule is charged — PER_CONTAINER | PER_TON | PER_ITEM
* (null for the day-based fee types). The PER_TON / PER_ITEM quantity comes from
* the booking's cargo total (cargo_total_weight_vgm, expressed in the cargo's
* unit of measure), so no new booking column is needed.
*/
export class AddDoubleHandlingBasisAndMachinery1990000000000 implements MigrationInterface {
name = 'AddDoubleHandlingBasisAndMachinery1990000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS basis varchar(20)`,
);
// machinery_units is not used (PER_ITEM reads cargo_total_weight_vgm); drop it
// if a prior version of this migration added it.
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS machinery_units`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS basis`);
}
}

View File

@@ -8,7 +8,11 @@ import { hashPassword } from "@tria-plc/api-common/utils/argon";
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm"; import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm";
import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common"; // Subpath imports (not the package root) so ts-jest can resolve them when this
// file lands in a spec's compile graph via the notification recipients chain.
import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity";
import { Organization } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization.entity";
import { UserCredential } from "@tria-plc/iamapi-common/entities/iam/user/user-credential.entity";
import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity";
import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
@@ -40,6 +44,21 @@ export class BackofficeService {
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
) {} ) {}
/**
* IAM user ids of every current employee across all organizations — used by
* the notification recipients resolver's `allBackoffice` selector.
*/
async getAllCurrentEmployeeUserIds(): Promise<string[]> {
const employees = await this.employeeRepository.find({
where: { isCurrent: true },
});
return [
...new Set(
employees.map((e) => e.userId).filter((id): id is string => Boolean(id)),
),
];
}
async createOrganizationUser( async createOrganizationUser(
organizationId: string, organizationId: string,
dto: CreateOrganizationUserDto, dto: CreateOrganizationUserDto,

View File

@@ -1,6 +1,16 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { PdfRenderService } from "./pdf-render.service"; import { PdfRenderService } from "./pdf-render.service";
import {
PdfColor,
assembleSinglePagePdf,
lineOp,
rectOp,
sealOp,
textOp,
textOpRight,
wrapText,
} from "./styled-pdf.util";
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT"; export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
@@ -62,10 +72,136 @@ export class InvoiceDocumentService {
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice"; const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
return { return {
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`, filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }), buffer: await this.pdf.htmlToPdfBuffer(html, {
label: `${model.title} ${kindLabel}`,
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
// summary grid, line-item table, totals) from the model — not a flat
// plain-text dump — so it still reads as a proper invoice document.
fallback: () => this.buildFallbackPdf(model),
}),
}; };
} }
/**
* Vector-drawn styled invoice/receipt used when headless Chromium is
* unavailable. Mirrors the HTML layout closely enough to pass as the same
* document. Single A4 page; long summaries / line lists are capped to fit.
*/
buildFallbackPdf(model: InvoiceDocumentModel): Buffer {
const currency = (cur?: string | null) =>
(cur ?? model.currency) === "ETB" ? "ETB" : (cur ?? model.currency);
const money = (amount: unknown, cur?: string | null) =>
`${Number(amount ?? 0).toLocaleString()} ${currency(cur)}`;
const date = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
const sealText =
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
const showCategory = Boolean(model.categoryHeader);
const ops: string[] = [];
// ── Header ────────────────────────────────────────────────────────────
ops.push(lineOp(36, 806, 559, 806, PdfColor.teal, 2.4));
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", 36, 790, 8.5, "F2", PdfColor.gray));
const titleSize = heading.length > 34 ? 18 : 22;
ops.push(textOp(heading, 36, 762, titleSize, "F2", PdfColor.dark));
ops.push(textOpRight("DOCUMENT NO.", 559, 792, 7.5, "F2", PdfColor.gray));
ops.push(textOpRight(model.documentNumber, 559, 776, 12, "F2", PdfColor.dark));
ops.push(textOpRight(`Issued ${date(model.issuedAt)}`, 559, 762, 8.5, "F1", PdfColor.gray));
ops.push(
textOpRight(
`Status ${model.status}`,
559,
748,
8.5,
"F1",
model.status === "PAID" ? PdfColor.teal : PdfColor.gray,
),
);
ops.push(lineOp(36, 736, 470, 736, PdfColor.line, 1));
// ── Seal ──────────────────────────────────────────────────────────────
ops.push(sealOp(516, 706, 27, sealText.split(/\s+/), PdfColor.teal));
// ── Summary grid (two columns) ────────────────────────────────────────
let y = 700;
const colX = [36, 300];
const colW = 250;
model.summary.slice(0, 16).forEach((row, i) => {
const x = colX[i % 2];
if (i % 2 === 0 && i > 0) y -= 27;
ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray));
ops.push(textOp(this.clip(row.value ?? "-", 44), x, y - 11, 9, "F2", PdfColor.dark));
ops.push(lineOp(x, y - 15, x + colW, y - 15, PdfColor.line, 0.6));
});
y -= 34;
// ── Line-item table ───────────────────────────────────────────────────
const qtyR = 402;
const rateR = 486;
const amtR = 555;
ops.push(rectOp(36, y - 18, 523, 18, PdfColor.shade, PdfColor.line, 0.7));
ops.push(textOp("DESCRIPTION", 40, y - 13, 8, "F2", PdfColor.gray));
if (showCategory) {
ops.push(textOp((model.categoryHeader ?? "").toUpperCase(), 250, y - 13, 8, "F2", PdfColor.gray));
}
ops.push(textOpRight("QTY", qtyR, y - 13, 8, "F2", PdfColor.gray));
ops.push(textOpRight("RATE", rateR, y - 13, 8, "F2", PdfColor.gray));
ops.push(textOpRight("AMOUNT", amtR, y - 13, 8, "F2", PdfColor.gray));
y -= 18;
const descChars = showCategory ? 44 : 66;
for (const item of model.lines) {
if (y < 190) break; // leave room for totals + footer
const descLines = wrapText(item.description ?? "-", descChars).slice(0, 2);
const rowH = Math.max(18, descLines.length * 10 + 8);
ops.push(rectOp(36, y - rowH, 523, rowH, "1 1 1", PdfColor.line, 0.6));
descLines.forEach((line, k) => {
ops.push(textOp(line, 40, y - 12 - k * 10, 8, "F1", PdfColor.dark));
});
if (showCategory) {
ops.push(textOp(this.clip((item.category ?? "").replace(/_/g, " "), 18), 250, y - 12, 8, "F1", PdfColor.dark));
}
ops.push(textOpRight(String(item.quantity ?? 0), qtyR, y - 12, 8, "F1", PdfColor.dark));
ops.push(textOpRight(money(item.unitRate, item.currency), rateR, y - 12, 8, "F1", PdfColor.dark));
ops.push(textOpRight(money(item.amount, item.currency), amtR, y - 12, 8, "F1", PdfColor.dark));
y -= rowH;
}
// ── Totals ────────────────────────────────────────────────────────────
let ty = y - 16;
for (const total of model.totals) {
if (ty < 88) break;
if (total.grand) {
ops.push(lineOp(315, ty + 5, 559, ty + 5, PdfColor.dark, 0.9));
ops.push(textOp(total.label, 320, ty - 9, 11, "F2", PdfColor.dark));
ops.push(textOpRight(money(total.amount), 555, ty - 9, 12, "F2", PdfColor.dark));
ty -= 24;
} else {
ops.push(textOp(total.label, 320, ty - 8, 9.5, "F1", PdfColor.gray));
ops.push(textOpRight(money(total.amount), 555, ty - 8, 10, "F1", PdfColor.dark));
ty -= 17;
}
}
// ── Footer ────────────────────────────────────────────────────────────
ops.push(lineOp(36, 64, 250, 64, PdfColor.dark, 0.8));
ops.push(textOp("Prepared by EDR finance", 36, 52, 7.5, "F1", PdfColor.gray));
ops.push(lineOp(340, 64, 559, 64, PdfColor.dark, 0.8));
ops.push(textOp("Authorized seal / signature", 340, 52, 7.5, "F1", PdfColor.gray));
return assembleSinglePagePdf(ops);
}
/** Truncate to `max` chars with an ellipsis. */
private clip(value: string, max: number): string {
const text = String(value ?? "");
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
}
buildHtml(model: InvoiceDocumentModel): string { buildHtml(model: InvoiceDocumentModel): string {
const esc = (value: unknown) => const esc = (value: unknown) =>
String(value ?? "-") String(value ?? "-")

View File

@@ -0,0 +1,322 @@
/**
* Minimal hand-built PDF primitives shared by the Chromium-less document
* fallbacks (invoices, receipts). These draw a genuine vector layout — boxes,
* rules, right-aligned money, a round seal — so a document still looks like a
* real document when headless Chromium is unavailable, instead of degrading to
* a flat plain-text dump. Coordinates are PDF user space (origin bottom-left,
* A4 = 595 x 842 pt). Fonts: F1 = Helvetica, F2 = Helvetica-Bold.
*/
export const MIN_VALID_PDF_BYTES = 2_000;
/** Colours as PDF "r g b" triples in the 0..1 range. */
export const PdfColor = {
teal: "0.06 0.46 0.43",
dark: "0.06 0.09 0.16",
gray: "0.39 0.45 0.55",
line: "0.80 0.84 0.89",
shade: "0.96 0.97 0.98",
tint: "0.94 0.99 0.98",
} as const;
export function escapePdfText(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/\(/g, "\\(")
.replace(/\)/g, "\\)")
.replace(/[^\x20-\x7e]/g, " ");
}
/** Approximate rendered width of Helvetica text (slightly over-estimated so
* right-aligned text never crosses its column edge). */
export function textWidth(text: string, size: number): number {
return text.length * size * 0.52;
}
export function textOp(
text: string,
x: number,
y: number,
size: number,
font: "F1" | "F2" = "F1",
color: string = PdfColor.dark,
): string {
return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
}
/** Right-align `text` so it ends at `rightX`. */
export function textOpRight(
text: string,
rightX: number,
y: number,
size: number,
font: "F1" | "F2" = "F1",
color: string = PdfColor.dark,
): string {
return textOp(text, rightX - textWidth(text, size), y, size, font, color);
}
export function lineOp(
x1: number,
y1: number,
x2: number,
y2: number,
color: string = PdfColor.line,
width = 0.8,
): string {
return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
}
export function rectOp(
x: number,
y: number,
width: number,
height: number,
fillColor = "1 1 1",
strokeColor: string = PdfColor.line,
lineWidth = 0.7,
): string {
return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`;
}
function circlePath(cx: number, cy: number, r: number): string {
const k = 0.5522847498;
const c = r * k;
return [
`${cx + r} ${cy} m`,
`${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`,
`${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`,
`${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`,
`${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`,
"h",
].join("\n");
}
/** A double-ring round rubber-stamp seal carrying up to three centred lines. */
export function sealOp(
cx: number,
cy: number,
r: number,
lines: string[],
color: string = PdfColor.teal,
): string {
const rows = lines.slice(0, 3);
const ops = [
"q",
`${color} RG`,
`${color} rg`,
"2 w",
circlePath(cx, cy, r),
"S",
"0.7 w",
circlePath(cx, cy, r - 6),
"S",
];
const startY = cy + (rows.length - 1) * 6;
rows.forEach((text, i) => {
const size = i === 0 ? 10 : 7.5;
ops.push(textOpRight(text, cx + textWidth(text, size) / 2, startY - i * 12 - 3, size, "F2", color));
});
ops.push("Q");
return ops.join("\n");
}
/** Hard-truncate to `max` chars (no marker — keeps dense table cells tight). */
export function clipText(value: string, max: number): string {
const t = String(value ?? "");
return t.length > max ? t.slice(0, Math.max(1, max)) : t;
}
/** Strip HTML tags → plain text, decoding the basic entities the doc builders emit. */
export function htmlToText(html: string): string {
return String(html ?? "")
.replace(/<br\s*\/?>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/gi, " ")
.replace(/[^\x20-\x7e]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
/**
* Parse a "summary tiles + one <table> + notice + signature lines" document (the
* marshalling / load-list layout the train-scheduling builders emit) and draw it as a
* styled PDF grid. Used as the Chromium-less fallback so the manifest reads as a real
* document, not a flat text dump. Switches to landscape when the table is wide.
*/
export function buildTabularFallbackPdf(html: string): Buffer {
const pick = (re: RegExp) => html.match(re)?.[1];
const title = htmlToText(pick(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ?? "Document");
const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
const metaRef = htmlToText(pick(/class="meta"[\s\S]*?<strong>([\s\S]*?)<\/strong>/i) ?? "");
const generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? "");
const tiles: Array<[string, string]> = [];
for (const m of html.matchAll(
/class="tile"[^>]*>\s*<span>([\s\S]*?)<\/span>\s*<strong>([\s\S]*?)<\/strong>/gi,
)) {
tiles.push([htmlToText(m[1]), htmlToText(m[2])]);
}
const thead = pick(/<thead>([\s\S]*?)<\/thead>/i) ?? "";
const headers = [...thead.matchAll(/<th[^>]*>([\s\S]*?)<\/th>/gi)].map((m) => htmlToText(m[1]));
const tbody = pick(/<tbody>([\s\S]*?)<\/tbody>/i) ?? "";
const rows: string[][] = [...tbody.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)].map((tr) =>
[...tr[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map((td) => htmlToText(td[1])),
);
const notice = htmlToText(pick(/class="notice"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
const parsedSigs = [...html.matchAll(/class="line"[^>]*>([\s\S]*?)<\/div>/gi)]
.map((m) => htmlToText(m[1]))
.filter(Boolean);
const signatures = parsedSigs.length ? parsedSigs : ["Prepared / date", "Check / date", "Authorization / date"];
const landscape = headers.length > 7;
const page = landscape ? PageSize.landscape : PageSize.portrait;
const M = 32;
const contentW = page.width - M * 2;
const right = page.width - M;
const ops: string[] = [];
// Header
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
if (metaRef) {
ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray));
ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
}
if (generated) {
ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
}
ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
// Summary tiles
let y = page.height - 100;
if (tiles.length) {
const cols = landscape ? 6 : 4;
const tileW = contentW / cols;
const tileH = 32;
tiles.forEach(([label, value], i) => {
const col = i % cols;
if (col === 0 && i > 0) y -= tileH;
const x = M + col * tileW;
ops.push(rectOp(x, y - tileH + 4, tileW - 4, tileH - 4, PdfColor.shade, PdfColor.line, 0.5));
ops.push(textOp(clipText(label.toUpperCase(), Math.floor((tileW - 12) / 3.6)), x + 6, y - 8, 6.5, "F1", PdfColor.gray));
ops.push(textOp(clipText(value, Math.floor((tileW - 12) / 4.4)), x + 6, y - 20, 9, "F2", PdfColor.dark));
});
y -= tileH + 12;
}
// Table
if (headers.length) {
const colW = contentW / headers.length;
const headerH = 16;
const rowH = 14;
const cellChars = Math.max(4, Math.floor(colW / 3.9));
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
headers.forEach((h, c) =>
ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
);
y -= headerH;
let shown = 0;
for (const row of rows) {
if (y < 96) break;
ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4));
headers.forEach((_h, c) => {
if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3));
const cell = row[c] ?? "";
if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark));
});
y -= rowH;
shown += 1;
}
if (shown < rows.length) {
ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
}
}
// Notice (verification clause)
if (notice) {
ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
wrapText(notice, landscape ? 155 : 104)
.slice(0, 2)
.forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
}
// Signatures
const sigW = contentW / signatures.length;
signatures.forEach((s, i) => {
const x = M + i * sigW;
ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7));
ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
});
return assembleSinglePagePdf(ops, page);
}
/** Greedy word-wrap to a maximum character width. */
export function wrapText(text: string, maxChars: number): string[] {
const out: string[] = [];
for (const raw of String(text ?? "").split("\n")) {
const words = raw.split(/\s+/).filter(Boolean);
let line = "";
for (const word of words) {
const next = line ? `${line} ${word}` : word;
if (next.length > maxChars && line) {
out.push(line);
line = word;
} else {
line = next;
}
}
if (line) out.push(line);
}
return out.length ? out : [""];
}
/** A4 page sizes in PDF points. */
export const PageSize = {
portrait: { width: 595, height: 842 },
landscape: { width: 842, height: 595 },
} as const;
/** Assemble a single-page PDF from content-stream ops (Helvetica fonts). Defaults to A4 portrait. */
export function assembleSinglePagePdf(
ops: string[],
page: { width: number; height: number } = PageSize.portrait,
): Buffer {
const stream = ops.join("\n");
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${page.width} ${page.height}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
];
let pdf = "%PDF-1.4\n";
const offsets: number[] = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, "latin1"));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) {
pdf += "% fallback padding\n";
}
const xrefOffset = Buffer.byteLength(pdf, "latin1");
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
for (const offset of offsets.slice(1)) {
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}

View File

@@ -0,0 +1,307 @@
import { Injectable, Logger } from '@nestjs/common';
import {
NotificationAudience,
NotificationType,
NotifyInput,
} from '@edr/types';
import { Booking } from './entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
/**
* Customer + staff notifications for the booking lifecycle: review, clearance
* and operation flow. Every customer event fans out over SMS + email (direct)
* and a persisted in-app notification deep-linking to the booking detail page;
* staff events land in the backoffice inbox. All sends are fire-and-forget and
* never throw — a notification failure must not break a booking transition.
*
* NOTE: the batch/payment-window notifications (pay-now, allocated, expired,
* displaced) are handled separately by {@link BookingNotifierService} in
* train-scheduling.
*/
@Injectable()
export class BookingLifecycleNotifierService {
private readonly logger = new Logger(BookingLifecycleNotifierService.name);
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) {}
private ref(b: Booking): string {
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
}
/** Send SMS + email to the booking's company contact; log-only on failure. */
private async notifyContact(
b: Booking,
message: string,
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`);
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
if (phone) {
try {
await this.notifications.directSend('sms', phone, message);
} catch (err) {
this.logger.warn(`SMS failed for ${this.ref(b)}: ${(err as Error).message}`);
}
}
if (email) {
try {
await this.notifications.directSend('email', email, message);
} catch (err) {
this.logger.warn(`Email failed for ${this.ref(b)}: ${(err as Error).message}`);
}
}
if (!phone && !email) {
this.logger.warn(`No contact on file for ${this.ref(b)} — notification not sent`);
}
}
/** Persist + push an in-app item to all portal users of the booking's company. */
private inApp(
b: Booking,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
if (!b.companyId) return; // government/unlinked bookings have no portal users
void this.inbox.notify({
recipients: { companyId: b.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title,
body,
link: `/bookings/${b.id}`,
data: { bookingId: b.id, reference: b.reference },
...overrides,
});
}
/** Persist + push an in-app item to every backoffice staff user. */
private inAppStaff(
b: Booking,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
void this.inbox.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title,
body,
link: `/dashboard/booking-requests/${b.id}`,
data: { bookingId: b.id, reference: b.reference },
...overrides,
});
}
// ── Customer-facing lifecycle events ───────────────────────────────────────
/** Line staff accepted intake → booking is under approval. */
accepted(b: Booking): void {
const msg =
`Your booking ${b.reference} has been accepted and is now under approval. ` +
`We will notify you once it is approved.`;
void this.notifyContact(b, msg, 'ACCEPTED');
this.inApp(b, 'Booking accepted', msg);
}
/** All approval steps complete → contract generated, ready for customer to sign. */
approved(b: Booking): void {
const msg =
`Your booking ${b.reference} has been approved. ` +
`Please review and sign your contract from the portal.`;
void this.notifyContact(b, msg, 'APPROVED');
this.inApp(b, 'Booking approved', msg);
}
/** Staff rejected the booking (intake or approval step). */
rejected(b: Booking, reason: string): void {
const msg =
`Your booking ${b.reference} was rejected. Reason: ${reason}. ` +
`Please contact us for details.`;
void this.notifyContact(b, msg, 'REJECTED');
this.inApp(b, 'Booking rejected', msg);
}
/** Staff requested changes before approval. */
changesRequested(b: Booking, note: string): void {
const msg =
`Changes were requested on your booking ${b.reference}: ${note}. ` +
`Please update and resubmit from the portal.`;
void this.notifyContact(b, msg, 'CHANGES REQUESTED');
this.inApp(b, 'Booking changes requested', msg);
}
/** A clearance document was queried and needs the customer to re-upload. */
documentQueried(b: Booking, fileKey: string, note: string): void {
const msg =
`A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` +
`${note}. Please re-upload from the portal.`;
void this.notifyContact(b, msg, 'DOCUMENT QUERIED');
this.inApp(b, 'Document queried', msg, {
type: NotificationType.DOCUMENT_ACTION,
});
}
/** Clearance finalized → customer can proceed to request operation. */
clearanceReady(b: Booking): void {
const msg =
`Clearance for booking ${b.reference} is complete. ` +
`You can now proceed to request operation from the portal.`;
void this.notifyContact(b, msg, 'CLEARANCE READY');
this.inApp(b, 'Clearance complete', msg, {
type: NotificationType.CLEARANCE_DECISION,
});
}
/** Operations returned the operation request for changes. */
operationChangesRequested(b: Booking, note: string): void {
const msg =
`Your operation request for booking ${b.reference} needs changes: ${note}. ` +
`Please update and resubmit from the portal.`;
void this.notifyContact(b, msg, 'OPERATION CHANGES REQUESTED');
this.inApp(b, 'Operation request needs changes', msg);
}
/** Operation accepted → invoice ready; await payment / booking window. */
operationAccepted(b: Booking): void {
const msg =
`Your operation request for booking ${b.reference} has been accepted. ` +
`An invoice has been prepared — watch for the payment window to secure your slot.`;
void this.notifyContact(b, msg, 'OPERATION ACCEPTED');
this.inApp(b, 'Operation request accepted', msg);
}
/** Shipment started → in transit. */
inTransit(b: Booking): void {
const msg = `Your shipment for booking ${b.reference} is now in transit.`;
void this.notifyContact(b, msg, 'IN TRANSIT');
this.inApp(b, 'Shipment in transit', msg);
}
/** Shipment delivered → completed. */
completed(b: Booking): void {
const msg = `Your shipment for booking ${b.reference} has been delivered. Thank you.`;
void this.notifyContact(b, msg, 'COMPLETED');
this.inApp(b, 'Shipment delivered', msg);
}
/** Booking cancelled. */
cancelled(b: Booking, reason: string): void {
const msg = `Your booking ${b.reference} has been cancelled. Reason: ${reason}.`;
void this.notifyContact(b, msg, 'CANCELLED');
this.inApp(b, 'Booking cancelled', msg);
}
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax — the customer must pay and upload the slip. */
dutyAdvised(b: Booking, amount: number, currency: string): void {
const msg =
`Duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
void this.notifyContact(b, msg, 'DUTY ADVISED');
this.inApp(b, 'Duty & tax advised', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/** GL advised the post-arrival additional duty round (import). */
secondDutyAdvised(b: Booking, amount: number, currency: string): void {
const msg =
`Additional duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
void this.notifyContact(b, msg, 'SECOND DUTY ADVISED');
this.inApp(b, 'Additional duty & tax advised', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/** GL raised the final (post-offload) invoice — customer pays + uploads slip. */
finalInvoiceCreated(b: Booking, amount: number, currency: string): void {
const msg =
`A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
void this.notifyContact(b, msg, 'FINAL INVOICE');
this.inApp(b, 'Final invoice issued', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
/** GL confirmed the final-invoice payment slip. */
finalInvoicePaid(b: Booking): void {
const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`;
void this.notifyContact(b, msg, 'FINAL INVOICE PAID');
this.inApp(b, 'Final invoice paid', msg, {
type: NotificationType.INVOICE_ISSUED,
});
}
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
/** Customer submitted a booking for review. */
submittedToStaff(b: Booking): void {
this.inAppStaff(
b,
'New booking submitted',
`Booking ${this.ref(b)} was submitted and is awaiting intake review.`,
);
}
/** Customer signed the booking contract. */
customerSignedToStaff(b: Booking): void {
this.inAppStaff(
b,
'Customer signed booking contract',
`The contract for booking ${this.ref(b)} was signed by the customer.`,
);
}
/** Customer requested operation (picked a shipment day). */
operationRequestedToStaff(b: Booking): void {
this.inAppStaff(
b,
'Operation requested',
`Booking ${this.ref(b)} requested operation — review capacity, documents and route.`,
);
}
/** Customer uploaded clearance documents — review is next. */
clearanceDocsUploadedToStaff(b: Booking): void {
this.inAppStaff(
b,
'Clearance documents uploaded',
`Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`,
{
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
},
);
}
/** Customer uploaded a duty/tax payment slip — GL verifies it. */
dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void {
const label =
round === 'final'
? 'final invoice'
: round === 'second'
? 'additional duty & tax'
: 'duty & tax';
this.inAppStaff(
b,
'Payment slip uploaded',
`Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`,
{
type: NotificationType.PAYMENT_RECEIVED,
link: `/dashboard/bookings/${b.id}/clearance`,
},
);
}
}

View File

@@ -30,14 +30,32 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
ruleEngineService as never, ruleEngineService as never,
{} as never, // pricingService {} as never, // pricingService
{} as never, // contractService {} as never, // contractService
{} as never, // invoiceService
{} as never, // filesService {} as never, // filesService
{} as never, // fileUploadSettingsService {} as never, // fileUploadSettingsService
{} as never, // bookingBatchService {} as never, // bookingBatchService
bookingsService as never, bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never, { isPhasedGeneralCustomsBooking: () => false } as never,
{} as never, {} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
accepted: jest.fn(),
approved: jest.fn(),
rejected: jest.fn(),
changesRequested: jest.fn(),
documentQueried: jest.fn(),
clearanceReady: jest.fn(),
operationChangesRequested: jest.fn(),
operationAccepted: jest.fn(),
inTransit: jest.fn(),
completed: jest.fn(),
cancelled: jest.fn(),
submittedToStaff: jest.fn(),
customerSignedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
); );
return { service, bookingsRepository, ruleEngineService }; return { service, bookingsRepository, ruleEngineService };
} }

View File

@@ -41,14 +41,32 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
{} as never, // ruleEngineService {} as never, // ruleEngineService
{} as never, // pricingService {} as never, // pricingService
{} as never, // contractService {} as never, // contractService
{} as never, // invoiceService
filesService as never, filesService as never,
fileUploadSettingsService as never, fileUploadSettingsService as never,
{} as never, // bookingBatchService {} as never, // bookingBatchService
bookingsService as never, bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never, { isPhasedGeneralCustomsBooking: () => false } as never,
{} as never, {} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
accepted: jest.fn(),
approved: jest.fn(),
rejected: jest.fn(),
changesRequested: jest.fn(),
documentQueried: jest.fn(),
clearanceReady: jest.fn(),
operationChangesRequested: jest.fn(),
operationAccepted: jest.fn(),
inTransit: jest.fn(),
completed: jest.fn(),
cancelled: jest.fn(),
submittedToStaff: jest.fn(),
customerSignedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
); );
return { service, bookingsRepository }; return { service, bookingsRepository };
} }
@@ -126,14 +144,32 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never, // invoiceService
filesService as never, filesService as never,
fileUploadSettingsService as never, fileUploadSettingsService as never,
{} as never, {} as never, // bookingBatchService
bookingsService as never, bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never, { isPhasedGeneralCustomsBooking: () => false } as never,
{} as never, {} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
accepted: jest.fn(),
approved: jest.fn(),
rejected: jest.fn(),
changesRequested: jest.fn(),
documentQueried: jest.fn(),
clearanceReady: jest.fn(),
operationChangesRequested: jest.fn(),
operationAccepted: jest.fn(),
inTransit: jest.fn(),
completed: jest.fn(),
cancelled: jest.fn(),
submittedToStaff: jest.fn(),
customerSignedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
); );
return { service, bookingsRepository }; return { service, bookingsRepository };
} }
@@ -197,14 +233,32 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never, // invoiceService
filesService as never, filesService as never,
fileUploadSettingsService as never, fileUploadSettingsService as never,
{} as never, {} as never, // bookingBatchService
bookingsService as never, bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never, { isPhasedGeneralCustomsBooking: () => false } as never,
{} as never, {} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
accepted: jest.fn(),
approved: jest.fn(),
rejected: jest.fn(),
changesRequested: jest.fn(),
documentQueried: jest.fn(),
clearanceReady: jest.fn(),
operationChangesRequested: jest.fn(),
operationAccepted: jest.fn(),
inTransit: jest.fn(),
completed: jest.fn(),
cancelled: jest.fn(),
submittedToStaff: jest.fn(),
customerSignedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
); );
return { service, bookingsRepository, filesService }; return { service, bookingsRepository, filesService };
} }

View File

@@ -3,14 +3,17 @@ import { BookingTransitionService } from './booking-transition.service';
/** /**
* Operation-request review for general-contract drawdown orders: * Operation-request review for general-contract drawdown orders:
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool. * - ACCEPT a train order → FULLY_EXECUTED with the invoice ensured; import/
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued. * domestic bookings wait for their booking-day window cycle (no immediate
* batch enqueue at accept time).
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, never enters the train batch.
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED. * - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
*/ */
describe('BookingTransitionService — operation review', () => { describe('BookingTransitionService — operation review', () => {
function makeService(serviceTypeCode: string) { function makeService(serviceTypeCode: string) {
const booking = { const booking = {
id: 'b-1', id: 'b-1',
reference: 'BKG-1',
status: 'OPERATION_REQUEST_PENDING', status: 'OPERATION_REQUEST_PENDING',
originYardId: 'o-1', originYardId: 'o-1',
destinationYardId: 'd-1', destinationYardId: 'd-1',
@@ -26,6 +29,14 @@ describe('BookingTransitionService — operation review', () => {
}; };
const bookingBatchService = { const bookingBatchService = {
enqueueRouteDayProcessing: jest.fn(), enqueueRouteDayProcessing: jest.fn(),
pickExportSchedule: jest.fn(),
acceptExportBooking: jest.fn(),
};
const invoiceService = {
ensureInvoiceForBooking: jest
.fn()
.mockResolvedValue({ id: 'inv-1', invoiceNumber: 'INV-0001' }),
updateStatus: jest.fn().mockResolvedValue(undefined),
}; };
const service = new BookingTransitionService( const service = new BookingTransitionService(
@@ -33,37 +44,60 @@ describe('BookingTransitionService — operation review', () => {
{} as never, // ruleEngineService {} as never, // ruleEngineService
{} as never, // pricingService {} as never, // pricingService
{} as never, // contractService {} as never, // contractService
{} as never, // invoiceService
{} as never, // filesService {} as never, // filesService
{} as never, // fileUploadSettingsService {} as never, // fileUploadSettingsService
bookingBatchService as never, bookingBatchService as never,
bookingsService as never, bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never, { isPhasedGeneralCustomsBooking: () => false } as never,
{} as never, {} as never, // workflowService
invoiceService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
accepted: jest.fn(),
approved: jest.fn(),
rejected: jest.fn(),
changesRequested: jest.fn(),
documentQueried: jest.fn(),
clearanceReady: jest.fn(),
operationChangesRequested: jest.fn(),
operationAccepted: jest.fn(),
inTransit: jest.fn(),
completed: jest.fn(),
cancelled: jest.fn(),
submittedToStaff: jest.fn(),
customerSignedToStaff: jest.fn(),
operationRequestedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
); );
return { service, bookingsRepository, bookingBatchService }; return { service, bookingsRepository, bookingBatchService, invoiceService };
} }
it('ACCEPT of a train order → FULLY_EXECUTED and enqueues the batch pool', async () => { it('ACCEPT of a train order → FULLY_EXECUTED, invoice ensured, batch waits for window cycle', async () => {
const { service, bookingsRepository, bookingBatchService } = const { service, bookingsRepository, bookingBatchService, invoiceService } =
makeService('RAIL_CONTAINER'); makeService('RAIL_CONTAINER');
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1'); await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
expect(bookingsRepository.update).toHaveBeenCalledWith( expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1', 'b-1',
expect.objectContaining({ status: 'FULLY_EXECUTED' }), expect.objectContaining({ status: 'FULLY_EXECUTED' }),
); );
expect(bookingBatchService.enqueueRouteDayProcessing).toHaveBeenCalledTimes(1); expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
// Import/domestic train bookings are batched by the window cycle later —
// never enqueued directly at accept time.
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
expect(bookingBatchService.acceptExportBooking).not.toHaveBeenCalled();
}); });
it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enqueue', async () => { it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enter the batch', async () => {
const { service, bookingsRepository, bookingBatchService } = const { service, bookingsRepository, bookingBatchService, invoiceService } =
makeService('ROAD_CONTAINER'); makeService('ROAD_CONTAINER');
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1'); await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
expect(bookingsRepository.update).toHaveBeenCalledWith( expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1', 'b-1',
expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }), expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }),
); );
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled(); expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
}); });

View File

@@ -4,6 +4,7 @@ import {
Inject, Inject,
Injectable, Injectable,
Logger, Logger,
Optional,
} from "@nestjs/common"; } from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
@@ -15,6 +16,7 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service'; import { BookingContractService } from './booking-contract.service';
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import { BookingPricingService } from './booking-pricing.service'; import { BookingPricingService } from './booking-pricing.service';
import { ContainerValidationService } from './container-validation.service'; import { ContainerValidationService } from './container-validation.service';
import { BookingsRepository } from './bookings.repository'; import { BookingsRepository } from './bookings.repository';
@@ -26,6 +28,7 @@ import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity'; import { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service'; import { BookingsService } from './bookings.service';
import { BookingClearanceService } from '../contracts/booking-clearance.service'; import { BookingClearanceService } from '../contracts/booking-clearance.service';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service'; import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
import { ContractDocPhase } from '@edr/types'; import { ContractDocPhase } from '@edr/types';
@@ -53,7 +56,8 @@ export class BookingTransitionService {
private readonly workflowService: ClearanceWorkflowService, private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService, private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService, private readonly containerValidationService: ContainerValidationService,
private readonly notifier: BookingLifecycleNotifierService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {} ) {}
private isPhasedGeneralCustoms(booking: Booking): boolean { private isPhasedGeneralCustoms(booking: Booking): boolean {
@@ -124,6 +128,9 @@ export class BookingTransitionService {
const finalBooking = await this.bookingsService.runConsolidationOnSubmit( const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
updated!.id, updated!.id,
); );
if (finalBooking.status === "SUBMITTED") {
this.notifier.submittedToStaff(finalBooking);
}
return { return {
bookingId: finalBooking.id, bookingId: finalBooking.id,
status: finalBooking.status, status: finalBooking.status,
@@ -204,6 +211,9 @@ export class BookingTransitionService {
const finalBooking = await this.bookingsService.runConsolidationOnSubmit( const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
updated!.id, updated!.id,
); );
if (finalBooking.status === "SUBMITTED") {
this.notifier.submittedToStaff(finalBooking);
}
return { return {
bookingId: finalBooking.id, bookingId: finalBooking.id,
status: finalBooking.status, status: finalBooking.status,
@@ -233,7 +243,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: "CHANGES_REQUESTED", status: "CHANGES_REQUESTED",
} as never); } as never);
return this.bookingsService.findById(updated!.id); const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.changesRequested(fresh, note);
return fresh;
} }
/** Auto-create booking approval steps from system rules when none exist yet. */ /** Auto-create booking approval steps from system rules when none exist yet. */
@@ -284,7 +296,9 @@ export class BookingTransitionService {
contractValidFrom: validFrom, contractValidFrom: validFrom,
contractValidUntil: validUntil, contractValidUntil: validUntil,
} as never); } as never);
return this.bookingsService.findById(updated!.id); const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.accepted(fresh);
return fresh;
} }
async staffReject( async staffReject(
@@ -305,7 +319,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: "REJECTED", status: "REJECTED",
} as never); } as never);
return this.bookingsService.findById(updated!.id); const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.rejected(fresh, reason);
return fresh;
} }
async approveStep( async approveStep(
@@ -394,7 +410,9 @@ export class BookingTransitionService {
if (allDone) { if (allDone) {
const generated = await this.contractService.generateContract(bookingId); const generated = await this.contractService.generateContract(bookingId);
return this.bookingsService.findById(generated.id); const fresh = await this.bookingsService.findById(generated.id);
this.notifier.approved(fresh);
return fresh;
} }
return this.bookingsService.findById(bookingId); return this.bookingsService.findById(bookingId);
@@ -435,7 +453,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: "REJECTED", status: "REJECTED",
} as never); } as never);
return this.bookingsService.findById(updated!.id); const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.rejected(fresh, reason);
return fresh;
} }
async customerSign(bookingId: string): Promise<Booking> { async customerSign(bookingId: string): Promise<Booking> {
@@ -446,7 +466,9 @@ export class BookingTransitionService {
status: "SIGNED_CUSTOMER", status: "SIGNED_CUSTOMER",
customerSignedAt: new Date(), customerSignedAt: new Date(),
} as never); } as never);
return this.bookingsService.findById(updated!.id); const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.customerSignedToStaff(fresh);
return fresh;
} }
async startTransit(bookingId: string): Promise<Booking> { async startTransit(bookingId: string): Promise<Booking> {
@@ -456,7 +478,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: "IN_TRANSIT", status: "IN_TRANSIT",
} as never); } as never);
return this.bookingsService.findById(updated!.id); const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.inTransit(fresh);
return fresh;
} }
async complete(bookingId: string): Promise<Booking> { async complete(bookingId: string): Promise<Booking> {
@@ -467,7 +491,28 @@ export class BookingTransitionService {
status: "COMPLETED", status: "COMPLETED",
endDate: new Date(), endDate: new Date(),
} as never); } as never);
return this.bookingsService.findById(updated!.id); const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.completed(fresh);
// Customer tracking: close out the tail milestones so a finished shipment
// never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
// implied by delivery; a storage invoice that was never raised is skipped
// (storage billing does not apply to every shipment). All doc-trigger /
// best-effort — a booking without milestone rows is untouched.
if (this.milestoneService) {
for (const code of ["IMPORT_PROCESS_COMPLETED", "EXIT_NOTE_GENERATED"]) {
try {
await this.milestoneService.completeByDocTrigger({ bookingId }, code);
} catch {
/* tracking must never block completion */
}
}
try {
await this.milestoneService.skipForBooking(bookingId, "STORAGE_INVOICE_RAISED");
} catch {
/* no such milestone row (export / non-customs) — fine */
}
}
return fresh;
} }
async cancel(bookingId: string, reason: string): Promise<Booking> { async cancel(bookingId: string, reason: string): Promise<Booking> {
@@ -491,7 +536,9 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: "CANCELLED", status: "CANCELLED",
} as never); } as never);
return this.bookingsService.findById(updated!.id); const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.cancelled(fresh, reason);
return fresh;
} }
/** /**
@@ -723,7 +770,9 @@ export class BookingTransitionService {
} as never); } as never);
} }
return this.bookingsService.findById(bookingId); const fresh = await this.bookingsService.findById(bookingId);
this.notifier.clearanceDocsUploadedToStaff(fresh);
return fresh;
} }
/** /**
@@ -824,6 +873,9 @@ export class BookingTransitionService {
} }
const updated = await this.bookingsService.findById(bookingId); const updated = await this.bookingsService.findById(bookingId);
if (status === "QUERIED") {
this.notifier.documentQueried(updated, fileKey, note ?? '');
}
if (this.isPhasedGeneralCustoms(updated)) { if (this.isPhasedGeneralCustoms(updated)) {
const allApproved = await this.isClearanceFullyApproved(updated); const allApproved = await this.isClearanceFullyApproved(updated);
if (allApproved) { if (allApproved) {
@@ -912,7 +964,9 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, { await this.bookingsRepository.update(bookingId, {
status: "CLEARANCE_READY", status: "CLEARANCE_READY",
} as never); } as never);
return this.bookingsService.findById(bookingId); const fresh = await this.bookingsService.findById(bookingId);
this.notifier.clearanceReady(fresh);
return fresh;
} }
/** /**
@@ -957,7 +1011,9 @@ export class BookingTransitionService {
status: "OPERATION_REQUEST_PENDING", status: "OPERATION_REQUEST_PENDING",
scheduledDate: date, scheduledDate: date,
} as never); } as never);
return this.bookingsService.findById(bookingId); const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationRequestedToStaff(fresh);
return fresh;
} }
/** /**
@@ -992,7 +1048,9 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, { await this.bookingsRepository.update(bookingId, {
status: "OPERATION_CHANGES_REQUESTED", status: "OPERATION_CHANGES_REQUESTED",
} as never); } as never);
return this.bookingsService.findById(bookingId); const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationChangesRequested(fresh, options.note);
return fresh;
} }
// ACCEPT — enter the batch holding pool. // ACCEPT — enter the batch holding pool.
@@ -1037,7 +1095,9 @@ export class BookingTransitionService {
fullyExecutedAt: now, fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now, lockedAt: booking.lockedAt ?? now,
} as never); } as never);
return this.bookingsService.findById(booking.id); const roadFresh = await this.bookingsService.findById(booking.id);
this.notifier.operationAccepted(roadFresh);
return roadFresh;
} }
await this.bookingsRepository.update(booking.id, { await this.bookingsRepository.update(booking.id, {
@@ -1072,7 +1132,9 @@ export class BookingTransitionService {
// batch runs after the window closes + staff document review, never at accept // batch runs after the window closes + staff document review, never at accept
// time. (Legacy pre-migration schedules with no window phase are still served // time. (Legacy pre-migration schedules with no window phase are still served
// by the periodic legacy fill.) // by the periodic legacy fill.)
return this.bookingsService.findById(booking.id); const trainFresh = await this.bookingsService.findById(booking.id);
this.notifier.operationAccepted(trainFresh);
return trainFresh;
} }
async enrichBookingResponse(booking: Booking): Promise< async enrichBookingResponse(booking: Booking): Promise<

View File

@@ -15,11 +15,13 @@ import {
UnauthorizedException, UnauthorizedException,
UploadedFile, UploadedFile,
UploadedFiles, UploadedFiles,
UseGuards,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { CurrentUser } from '@edr/api-common'; import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards'; import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { BookingStaff, BookingView } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import { import {
@@ -64,6 +66,7 @@ import { ContractViewDto } from './dto/contract-view.dto';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
import { CustomerTruckService } from './customer-truck.service'; import { CustomerTruckService } from './customer-truck.service';
import { GenerateGrnDto } from './dto/generate-grn.dto'; import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service'; import { ContainerReceiptService } from './container-receipt.service';
@@ -194,6 +197,7 @@ export class BookingsController {
} }
@Get("by-company/:companyId/customer-view") @Get("by-company/:companyId/customer-view")
@BookingView()
@ApiOperation({ @ApiOperation({
summary: "List bookings for a company (customer-view shape, backoffice)", summary: "List bookings for a company (customer-view shape, backoffice)",
}) })
@@ -204,6 +208,7 @@ export class BookingsController {
} }
@Get("list-summary") @Get("list-summary")
@BookingView()
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" }) @ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
@ApiOkResponse({ type: BookingListSummaryDto }) @ApiOkResponse({ type: BookingListSummaryDto })
findListSummary(@Query() filter: FilterBookingDto) { findListSummary(@Query() filter: FilterBookingDto) {
@@ -225,6 +230,7 @@ export class BookingsController {
} }
@Get("queues/:queue") @Get("queues/:queue")
@BookingView()
@ApiOperation({ @ApiOperation({
summary: "List bookings for a dashboard queue", summary: "List bookings for a dashboard queue",
description: "Queues: intake, approval, signatures, marketing, finance", description: "Queues: intake, approval, signatures, marketing, finance",
@@ -358,6 +364,33 @@ export class BookingsController {
return this.customerTruckService.removeTruck(id, assignmentId); return this.customerTruckService.removeTruck(id, assignmentId);
} }
@Get(':id/customer-trucks/loadable-containers')
@ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' })
async loadableContainers(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.customerTruckService.getLoadableContainers(id);
}
@Post(':id/customer-trucks/:assignmentId/load')
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
async loadCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@Body() dto: LoadCustomerTruckDto,
@CurrentUser() user: TCurrentUser,
) {
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
throw new ForbiddenException('Only warehouse staff can load a truck');
}
return this.customerTruckService.loadTruck(id, assignmentId, dto);
}
@Post(':id/customer-trucks/:assignmentId/depart') @Post(':id/customer-trucks/:assignmentId/depart')
@ApiOperation({ @ApiOperation({
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
@@ -928,12 +961,18 @@ export class BookingsController {
} }
@Post(":id/contract/sign") @Post(":id/contract/sign")
@UseGuards(JwtGuard)
@ApiOperation({ summary: "Apply digital signature (customer or staff)" }) @ApiOperation({ summary: "Apply digital signature (customer or staff)" })
async signContract( async signContract(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: SignContractDto, @Body() dto: SignContractDto,
@CurrentUser() user: TCurrentUser,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string }, @Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) { ) {
// Staff signature needs the sign permission; customer signs their own booking.
if (dto.role !== "CUSTOMER") {
assertFreightPermission(user, FREIGHT_PERMS.bookings.signStaff);
}
const userId = req.user?.id ?? req.user?.sub; const userId = req.user?.id ?? req.user?.sub;
const booking = await this.contractService.signContract(id, dto, { const booking = await this.contractService.signContract(id, dto, {
signerUserId: userId, signerUserId: userId,

View File

@@ -18,7 +18,10 @@ import { BookingInvoiceService } from './booking-invoice.service';
// import { BookingPaymentService } from './booking-payment.service'; // import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service'; import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import { BookingTransitionService } from './booking-transition.service'; import { BookingTransitionService } from './booking-transition.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingsController } from './bookings.controller'; import { BookingsController } from './bookings.controller';
// import { PayController } from './pay.controller'; // import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository'; import { BookingsRepository } from './bookings.repository';
@@ -64,6 +67,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
CustomerTruckContainer, CustomerTruckContainer,
]), ]),
BillingModule, BillingModule,
NotificationsModule,
NotificationInboxModule,
forwardRef(() => FirstMileModule), forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule), forwardRef(() => TrainSchedulingModule),
forwardRef(() => ContractsModule), forwardRef(() => ContractsModule),
@@ -90,6 +95,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ContainerValidationService, ContainerValidationService,
BookingReferenceDataService, BookingReferenceDataService,
BookingPricingService, BookingPricingService,
BookingLifecycleNotifierService,
BookingTransitionService, BookingTransitionService,
BookingContractService, BookingContractService,
BookingInvoiceService, BookingInvoiceService,
@@ -107,6 +113,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingsRepository, BookingsRepository,
BookingPricingService, BookingPricingService,
BookingInvoiceService, BookingInvoiceService,
BookingLifecycleNotifierService,
CustomerTruckService, CustomerTruckService,
ContainerReceiptService, ContainerReceiptService,
], ],

View File

@@ -176,6 +176,44 @@ export class BookingsService {
} }
/** Resolve trade direction from yard countries; reject client mismatch. */ /** Resolve trade direction from yard countries; reject client mismatch. */
/**
* An intercity corridor is valid when both yards are Ethiopian and at least
* one non-retired route passes the origin strictly before the destination in
* its milestone order — that is the corridor an import/export train can
* serve the booking on.
*/
private async assertIntercityCorridorExists(
originYardId: string,
destinationYardId: string,
): Promise<void> {
const yards = await this.dataSource.getRepository(Yard).find({
where: { id: In([originYardId, destinationYardId]) },
});
if (yards.some((y) => y.country !== 'Ethiopia')) {
throw new BadRequestException(
'Intercity bookings only run between Ethiopian yards',
);
}
const rows: Array<{ id: string }> = await this.dataSource.query(
`SELECT r.id
FROM freight.routes r
JOIN freight.route_milestones mo
ON mo.route_id = r.id AND mo.yard_id = $1 AND mo.deleted_at IS NULL
JOIN freight.route_milestones md
ON md.route_id = r.id AND md.yard_id = $2 AND md.deleted_at IS NULL
WHERE mo.sequence_no < md.sequence_no
AND r.status = 'AVAILABLE'
AND r.deleted_at IS NULL
LIMIT 1`,
[originYardId, destinationYardId],
);
if (rows.length === 0) {
throw new BadRequestException(
'No route passes through this origin and destination in order — intercity service is not available on this corridor',
);
}
}
private async resolveTradeDirectionForBooking( private async resolveTradeDirectionForBooking(
originYardId: string, originYardId: string,
destinationYardId: string, destinationYardId: string,
@@ -607,6 +645,23 @@ export class BookingsService {
dto.tradeDirection, dto.tradeDirection,
); );
// Intercity (DOMESTIC) bookings never get their own train — they ride on a
// passing import/export train, so there is no booking window and no date to
// pin. All we require at creation is that the corridor actually lies on a
// route (origin before destination in some route's milestone order); staff
// accept the booking onto a concrete train at finalize time.
if (tradeDirection === 'DOMESTIC') {
if (dto.scheduledDate || dto.trainScheduleId) {
throw new BadRequestException(
'Intercity bookings cannot pin a date or schedule — staff assign them to a passing train later',
);
}
await this.assertIntercityCorridorExists(
dto.originYardId,
dto.destinationYardId,
);
}
// Stamp the operational profile this booking belongs to (importer/exporter) // Stamp the operational profile this booking belongs to (importer/exporter)
// so the customer portal can scope lists/KPIs to the active mode. Best-effort // so the customer portal can scope lists/KPIs to the active mode. Best-effort
// for non-government bookings with a resolved company; never blocks creation. // for non-government bookings with a resolved company; never blocks creation.
@@ -1333,6 +1388,17 @@ export class BookingsService {
); );
} }
// Surface the assigned train's operational status so the portal stepper
// can show the Arrival stage: the booking status stays IN_TRANSIT from
// dispatch until delivery, so arrival is only knowable from the schedule.
if (booking.trainScheduleId) {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: booking.trainScheduleId } });
(booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus =
schedule?.status ?? null;
}
return booking; return booking;
} }

View File

@@ -38,7 +38,7 @@ export class ContainerReceiptService {
SET received_to_port = true, SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()), received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW() updated_at = NOW()
FROM freight.booking_containers bc, FROM freight.booking_container bc,
freight.customer_truck_containers ctc freight.customer_truck_containers ctc
WHERE bc.id = bcu.booking_container_id WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1 AND bc.booking_id = $1
@@ -60,7 +60,7 @@ export class ContainerReceiptService {
bcu.received_at AS "receivedAt", bcu.received_at AS "receivedAt",
bcu.grn_number AS "grnNumber" bcu.grn_number AS "grnNumber"
FROM freight.booking_container_units bcu FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 WHERE bc.booking_id = $1
AND bcu.deleted_at IS NULL AND bcu.deleted_at IS NULL
@@ -92,7 +92,7 @@ export class ContainerReceiptService {
const pending: ReceivedUnitRow[] = await manager.query( const pending: ReceivedUnitRow[] = await manager.query(
`SELECT bcu.id, bcu.container_number AS "containerNumber" `SELECT bcu.id, bcu.container_number AS "containerNumber"
FROM freight.booking_container_units bcu FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 WHERE bc.booking_id = $1
AND bcu.deleted_at IS NULL AND bcu.deleted_at IS NULL
@@ -109,7 +109,7 @@ export class ContainerReceiptService {
const [{ batches }]: Array<{ batches: string }> = await manager.query( const [{ batches }]: Array<{ batches: string }> = await manager.query(
`SELECT COUNT(DISTINCT bcu.grn_number) AS batches `SELECT COUNT(DISTINCT bcu.grn_number) AS batches
FROM freight.booking_container_units bcu FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`, WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`,
[bookingId], [bookingId],
@@ -129,7 +129,7 @@ export class ContainerReceiptService {
const [{ remaining }]: Array<{ remaining: string }> = await manager.query( const [{ remaining }]: Array<{ remaining: string }> = await manager.query(
`SELECT COUNT(*) AS remaining `SELECT COUNT(*) AS remaining
FROM freight.booking_container_units bcu FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`, WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`,
[bookingId], [bookingId],

View File

@@ -198,6 +198,87 @@ export class CustomerTruckService {
return this.listTrucks(bookingId); return this.listTrucks(bookingId);
} }
/** Booking container numbers not yet loaded onto any truck. */
async getLoadableContainers(bookingId: string): Promise<string[]> {
const [all, assigned] = await Promise.all([
this.bookingContainerNumbers(bookingId),
this.assignedContainerNumbers(bookingId),
]);
const taken = new Set(assigned);
return all.filter((n) => !taken.has(n));
}
/**
* Truck_dispatch (load): assign the selected containers to a truck after it has
* arrived, and set a provisional gross weight from their VGM. The truck is
* weighed for real on departure. Locked once the truck has left.
*/
async loadTruck(
bookingId: string,
assignmentId: string,
dto: { containerNumbers: string[] },
): Promise<CustomerTruckAssignment[]> {
await this.loadBookingGuard(bookingId);
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
if (!assignment || assignment.bookingId !== bookingId) {
throw new NotFoundException('Truck assignment not found for this booking');
}
if (assignment.departedAt) {
throw new ConflictException('This truck has already left — its load is locked');
}
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (!requested.length) {
throw new BadRequestException('Select at least one container to load onto the truck');
}
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
}
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
for (const n of requested) {
if (elsewhere.includes(n)) {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
const grossKg = await this.vgmKgForContainers(bookingId, requested);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId,
bookingId,
containerNumber,
}),
),
);
// Provisional gross from the loaded containers' VGM — overridden by the
// weighed gross on departure.
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
grossWeightKg: grossKg,
});
});
return this.listTrucks(bookingId);
}
private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise<number> {
const [row]: Array<{ kg: string }> = await this.dataSource.query(
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg
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.container_number = ANY($2::varchar[])
AND bcu.deleted_at IS NULL`,
[bookingId, numbers],
);
return Number(row?.kg ?? 0);
}
/** /**
* Mark the truck carrying `containerNumber` as arrived. Called by the warehouse * Mark the truck carrying `containerNumber` as arrived. Called by the warehouse
* receive flow. When every truck on the booking has arrived, the booking-level * receive flow. When every truck on the booking has arrived, the booking-level
@@ -288,7 +369,7 @@ export class CustomerTruckService {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query( const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber" `SELECT bcu.container_number AS "containerNumber"
FROM freight.booking_container_units bcu FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`, WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
[bookingId], [bookingId],

View File

@@ -0,0 +1,13 @@
import { ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */
export class LoadCustomerTruckDto {
@IsArray()
@ArrayMinSize(1)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})
containerNumbers!: string[];
}

View File

@@ -85,6 +85,13 @@ function makeService(overrides?: {
milestoneService as never, milestoneService as never,
dropdownSettingsService as never, dropdownSettingsService as never,
glOperationsService as never, glOperationsService as never,
{
dutyAdvised: jest.fn(),
clearanceReady: jest.fn(),
documentQueried: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
clearanceDocsUploadedToStaff: jest.fn(),
} as never, // notifier
); );
return { return {
@@ -115,12 +122,18 @@ describe('BookingClearanceService', () => {
it('records duty advice when duty applies', async () => { it('records duty advice when duty applies', async () => {
const { service, milestoneService } = makeService(); const { service, milestoneService } = makeService();
await service.adviseDuty('b-general', { await service.adviseDuty(
dutyRequired: true, 'b-general',
amount: 1500, {
currency: 'ETB', dutyRequired: true,
declarationSerial: 'DS-1', amount: 1500,
}); currency: 'ETB',
declarationSerial: 'DS-1',
},
undefined,
// The duty notice attachment is now mandatory when duty applies.
{ fieldname: 'duty_tax_notice' } as Express.Multer.File,
);
expect(milestoneService.adviseDuty).toHaveBeenCalledWith( expect(milestoneService.adviseDuty).toHaveBeenCalledWith(
'b-general', 'b-general',

View File

@@ -12,6 +12,7 @@ import { FileUploadSettingsService } from '../file-upload-settings/file-upload-s
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingsService } from '../bookings/bookings.service'; import { BookingsService } from '../bookings/bookings.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity'; import { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util'; import { clearanceCodesForBooking } from '../bookings/clearance.util';
@@ -100,6 +101,7 @@ export class BookingClearanceService {
private readonly milestoneService: ClearanceMilestoneService, private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService, private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService, private readonly glOperationsService: GlOperationsService,
private readonly notifier: BookingLifecycleNotifierService,
) {} ) {}
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> { private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
@@ -174,7 +176,28 @@ export class BookingClearanceService {
} }
const allApproved = await this.isClearanceFullyApproved(booking); const allApproved = await this.isClearanceFullyApproved(booking);
const milestones = await this.workflowService.listMilestonesForBooking(bookingId); let milestones = await this.workflowService.listMilestonesForBooking(bookingId);
// Self-heal: a booking that has settled its freight payment must have
// FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
// export FCFS booking (linked to its train at booking time) paid via the
// prepaid invoice can leave the milestone PENDING — the clearance "Payment &
// wagon allocation" step then never ticks. Backfill it here so already-stuck
// rows recover without a migration; idempotent (no-op once COMPLETED).
const paymentSettled = milestones.find(
(m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED',
);
if (
paymentSettled &&
paymentSettled.status === 'PENDING' &&
(booking.paymentStatus === 'PAID' || booking.status === 'PAID')
) {
await this.workflowService.completeMilestoneForBooking(
bookingId,
'FREIGHT_PAYMENT_SETTLED',
);
milestones = await this.workflowService.listMilestonesForBooking(bookingId);
}
const phase = this.workflowService.resolvePhaseForBooking(booking, milestones); const phase = this.workflowService.resolvePhaseForBooking(booking, milestones);
const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones); const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones);
const boundary = await this.workflowService.isBoundaryCompleteForBooking( const boundary = await this.workflowService.isBoundaryCompleteForBooking(
@@ -414,6 +437,7 @@ export class BookingClearanceService {
}, },
userId, userId,
); );
this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB');
} }
return this.bookingsService.findById(bookingId); return this.bookingsService.findById(bookingId);
@@ -441,6 +465,7 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never); } as never);
this.notifier.dutySlipUploadedToStaff(booking, 'first');
return this.bookingsService.findById(bookingId); return this.bookingsService.findById(bookingId);
} }

View File

@@ -10,6 +10,7 @@ import type { Freight } from '@edr/types';
import { BookingRequestRepository } from './booking-request.repository'; import { BookingRequestRepository } from './booking-request.repository';
import { ContractsService } from './contracts.service'; import { ContractsService } from './contracts.service';
import { ContractBookingService } from './contract-booking.service'; import { ContractBookingService } from './contract-booking.service';
import { ContractNotifierService } from './contract-notifier.service';
import { BookingRequest } from './entities/booking-request.entity'; import { BookingRequest } from './entities/booking-request.entity';
import { Contract } from './entities/contract.entity'; import { Contract } from './entities/contract.entity';
import { CreateBookingRequestDto } from './dto/create-booking-request.dto'; import { CreateBookingRequestDto } from './dto/create-booking-request.dto';
@@ -26,6 +27,7 @@ export class BookingRequestService {
private readonly repo: BookingRequestRepository, private readonly repo: BookingRequestRepository,
private readonly contractsService: ContractsService, private readonly contractsService: ContractsService,
private readonly contractBookingService: ContractBookingService, private readonly contractBookingService: ContractBookingService,
private readonly notifier: ContractNotifierService,
) {} ) {}
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */ /** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
@@ -107,7 +109,7 @@ export class BookingRequestService {
}; };
const reference = await this.generateReference(); const reference = await this.generateReference();
return this.repo.create({ const request = await this.repo.create({
reference, reference,
contractId, contractId,
requestedByUserId: userId ?? null, requestedByUserId: userId ?? null,
@@ -117,6 +119,8 @@ export class BookingRequestService {
requestedLines, requestedLines,
notes: dto.notes ?? null, notes: dto.notes ?? null,
} as never); } as never);
this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference);
return request;
} }
listForContract(contractId: string): Promise<BookingRequest[]> { listForContract(contractId: string): Promise<BookingRequest[]> {

View File

@@ -6,6 +6,7 @@ import {
CustomsRiskLevel, CustomsRiskLevel,
MilestoneMetadata, MilestoneMetadata,
} from './entities/clearance-milestone.entity'; } from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { Contract } from './entities/contract.entity'; import { Contract } from './entities/contract.entity';
import { import {
HANDOFF_MILESTONES, HANDOFF_MILESTONES,
@@ -85,10 +86,36 @@ export class ClearanceMilestoneService {
} }
async listForBooking(bookingId: string): Promise<ClearanceMilestone[]> { async listForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
return this.repo.find({ const rows = await this.repo.find({
where: { bookingId }, where: { bookingId },
order: { sortOrder: 'ASC' }, order: { sortOrder: 'ASC' },
}); });
// Self-heal: a booking that has settled its freight payment must have
// FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
// export FCFS booking (linked to its train at booking time) paid via the
// prepaid invoice can leave the milestone PENDING — the clearance "Payment &
// wagon allocation" step then never ticks. getClearanceView backfills it, but
// the stepper reads its gating milestones straight from here, so heal here too.
// Idempotent (no-op once COMPLETED); recovers already-stuck rows with no migration.
const paymentSettled = rows.find(
(m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED',
);
if (paymentSettled && paymentSettled.status === 'PENDING') {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
select: { id: true, status: true, paymentStatus: true },
});
if (booking?.paymentStatus === 'PAID' || booking?.status === 'PAID') {
await this.completeForBooking(bookingId, 'FREIGHT_PAYMENT_SETTLED');
return this.repo.find({
where: { bookingId },
order: { sortOrder: 'ASC' },
});
}
}
return rows;
} }
/** /**

View File

@@ -48,6 +48,7 @@ function makeService(milestones: ClearanceMilestone[]) {
contractsRepository as never, contractsRepository as never,
milestoneService as never, milestoneService as never,
bookingsRepository as never, bookingsRepository as never,
{ clearanceReady: jest.fn() } as never, // notifier
); );
return { service, milestoneService, contractsRepository, bookingsRepository }; return { service, milestoneService, contractsRepository, bookingsRepository };
} }

View File

@@ -9,6 +9,7 @@ import { Contract } from './entities/contract.entity';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { Booking } from '../bookings/entities/booking.entity'; import { Booking } from '../bookings/entities/booking.entity';
import type { ClearanceMetaState } from './clearance-workflow.types'; import type { ClearanceMetaState } from './clearance-workflow.types';
import { metaFromBooking } from './clearance-workflow.types'; import { metaFromBooking } from './clearance-workflow.types';
@@ -34,6 +35,7 @@ export class ClearanceWorkflowService {
private readonly contractsRepository: ContractsRepository, private readonly contractsRepository: ContractsRepository,
private readonly milestoneService: ClearanceMilestoneService, private readonly milestoneService: ClearanceMilestoneService,
private readonly bookingsRepository: BookingsRepository, private readonly bookingsRepository: BookingsRepository,
private readonly notifier: BookingLifecycleNotifierService,
) {} ) {}
boundaryMilestone(tradeDirection: string): string { boundaryMilestone(tradeDirection: string): string {
@@ -264,6 +266,14 @@ export class ClearanceWorkflowService {
status: 'CLEARANCE_READY', status: 'CLEARANCE_READY',
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never); } as never);
// Tell the customer clearance is done and operation can be requested. Load
// failure only skips the notice — the status change above already committed.
try {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (booking) this.notifier.clearanceReady(booking);
} catch {
/* notification is best-effort */
}
} }
resolvePhase( resolvePhase(

View File

@@ -119,12 +119,27 @@ export class ContractBookingService {
const generalCustoms = const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
// there is no window and no date — staff accept them onto a train at
// finalize time, so both the window gate and scheduledDate are skipped.
const isIntercity = contract.tradeDirection === 'DOMESTIC';
if (isIntercity && dto.scheduledDate) {
throw new BadRequestException(
'Intercity bookings do not pick a date — staff assign them to a passing train',
);
}
// Every other direction keeps the binding shipment day (the DTO field went
// optional only for intercity).
if (!isIntercity && !dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
// Booking-window gate (config-driven): an operations booking may only be // Booking-window gate (config-driven): an operations booking may only be
// created while the route's booking window is open — import: the day's window // created while the route's booking window is open — import: the day's window
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours); // (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
// export: within exportBookingLeadHours of departure. Customs Path B bookings // export: within exportBookingLeadHours of departure. Customs Path B bookings
// enter clearance first and are scheduled later, so they are not gated here. // enter clearance first and are scheduled later, so they are not gated here.
if (!generalCustoms) { if (!generalCustoms && !isIntercity) {
await this.trainSchedulingService.assertBookingWindowOpen({ await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: route?.originYardId ?? null, originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null, destinationYardId: route?.destinationYardId ?? null,

View File

@@ -16,6 +16,7 @@ import { BookingsService } from '../bookings/bookings.service';
import { contractClearanceCodes } from './contract-clearance.util'; import { contractClearanceCodes } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractNotifierService } from './contract-notifier.service';
import { GlOperationsService } from './gl-operations.service'; import { GlOperationsService } from './gl-operations.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity'; import { Contract } from './entities/contract.entity';
@@ -118,6 +119,7 @@ export class ContractClearanceService {
private readonly milestoneService: ClearanceMilestoneService, private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService, private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService, private readonly glOperationsService: GlOperationsService,
private readonly notifier: ContractNotifierService,
) {} ) {}
private isPhasedCustoms(contract: Contract): boolean { private isPhasedCustoms(contract: Contract): boolean {
@@ -543,7 +545,9 @@ export class ContractClearanceService {
await this.workflowService.onDocumentReviewReopened(contractId); await this.workflowService.onDocumentReviewReopened(contractId);
} }
return this.contractsService.findById(contractId); const updated = await this.contractsService.findById(contractId);
this.notifier.clearanceDocsUploadedToStaff(updated);
return updated;
} }
private async assertRequiredInputsPresent( private async assertRequiredInputsPresent(
@@ -674,6 +678,7 @@ export class ContractClearanceService {
status: 'AWAITING_CLEARANCE_DOCUMENTS', status: 'AWAITING_CLEARANCE_DOCUMENTS',
clearanceStatus: 'AWAITING_DOCUMENTS', clearanceStatus: 'AWAITING_DOCUMENTS',
} as never); } as never);
this.notifier.clearanceDocumentQueried(contract, fileKey, note ?? '');
if (cycle) { if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
} }
@@ -1009,6 +1014,7 @@ export class ContractClearanceService {
}, },
userId, userId,
); );
this.notifier.dutyAdvised(contract, dto.amount, dto.currency ?? 'ETB');
} }
return this.contractsService.findById(contractId); return this.contractsService.findById(contractId);
@@ -1045,7 +1051,9 @@ export class ContractClearanceService {
}); });
} }
return this.contractsService.findById(contractId); const updated = await this.contractsService.findById(contractId);
this.notifier.dutySlipUploadedToStaff(updated);
return updated;
} }
async uploadTransitPermit( async uploadTransitPermit(
@@ -1118,6 +1126,7 @@ export class ContractClearanceService {
await this.workflowService.markReadyForBooking(contractId); await this.workflowService.markReadyForBooking(contractId);
} }
this.notifier.preClearanceFinalized(contract);
return this.contractsService.findById(contractId); return this.contractsService.findById(contractId);
} }

View File

@@ -0,0 +1,245 @@
import { Injectable, Logger } from '@nestjs/common';
import {
NotificationAudience,
NotificationType,
NotifyInput,
} from '@edr/types';
import { Contract } from './entities/contract.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
/**
* Customer + staff notifications for the contract lifecycle. Every customer
* event fans out over three channels: SMS + email (direct, via
* {@link NotificationsService}) and a persisted in-app notification (via
* {@link NotificationInboxService}) that deep-links to the contract detail page.
* Staff events go to the backoffice inbox. All sends are fire-and-forget and
* never throw — a notification failure must not break a contract transition.
*/
@Injectable()
export class ContractNotifierService {
private readonly logger = new Logger(ContractNotifierService.name);
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) {}
private ref(c: Contract): string {
return `${c.reference}${c.isGovernment ? ' (gov)' : ''}`;
}
/** Send SMS + email to the contract's company contact; log-only on failure. */
private async notifyContact(
c: Contract,
message: string,
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(c)}`);
const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null;
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
if (phone) {
try {
await this.notifications.directSend('sms', phone, message);
} catch (err) {
this.logger.warn(`SMS failed for ${this.ref(c)}: ${(err as Error).message}`);
}
}
if (email) {
try {
await this.notifications.directSend('email', email, message);
} catch (err) {
this.logger.warn(`Email failed for ${this.ref(c)}: ${(err as Error).message}`);
}
}
if (!phone && !email) {
this.logger.warn(`No contact on file for ${this.ref(c)} — notification not sent`);
}
}
/** Persist + push an in-app item to all portal users of the contract's company. */
private inApp(
c: Contract,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
if (!c.companyId) return; // government/unlinked contracts have no portal users
void this.inbox.notify({
recipients: { companyId: c.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.CONTRACT_STATUS,
title,
body,
link: `/contracts/${c.id}`,
data: { contractId: c.id, reference: c.reference },
...overrides,
});
}
/** Persist + push an in-app item to every backoffice staff user. */
private inAppStaff(
c: Contract,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
void this.inbox.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title,
body,
link: `/dashboard/contract-requests/${c.id}`,
data: { contractId: c.id, reference: c.reference },
...overrides,
});
}
// ── Customer-facing lifecycle events ───────────────────────────────────────
/** Line staff accepted intake → contract is under approval. */
accepted(c: Contract): void {
const msg =
`Your contract ${c.reference} has been accepted and is now under approval. ` +
`We will notify you once it is approved.`;
void this.notifyContact(c, msg, 'ACCEPTED');
this.inApp(c, 'Contract accepted', msg);
}
/** All approval steps complete → contract approved. */
approved(c: Contract): void {
const msg =
`Your contract ${c.reference} has been approved. ` +
`The final document will be prepared for signing.`;
void this.notifyContact(c, msg, 'APPROVED');
this.inApp(c, 'Contract approved', msg);
}
/** Fully executed (all parties signed) → contract active, customer can book. */
signedActive(c: Contract): void {
const msg =
`Your contract ${c.reference} has been signed and is now active. ` +
`You can start booking shipments from the portal.`;
void this.notifyContact(c, msg, 'SIGNED / ACTIVE');
this.inApp(c, 'Contract active', msg);
}
/** Staff rejected the contract. */
rejected(c: Contract, reason: string): void {
const msg =
`Your contract ${c.reference} was rejected. Reason: ${reason}. ` +
`Please contact us for details.`;
void this.notifyContact(c, msg, 'REJECTED');
this.inApp(c, 'Contract rejected', msg);
}
/** Staff requested changes before approval. */
changesRequested(c: Contract, note: string): void {
const msg =
`Changes were requested on your contract ${c.reference}: ${note}. ` +
`Please update and resubmit from the portal.`;
void this.notifyContact(c, msg, 'CHANGES REQUESTED');
this.inApp(c, 'Contract changes requested', msg);
}
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
dutyAdvised(c: Contract, amount: number, currency: string): void {
const msg =
`Duty & tax of ${amount} ${currency} has been advised for contract ${c.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
void this.notifyContact(c, msg, 'DUTY ADVISED');
this.inApp(c, 'Duty & tax advised', msg, {
type: NotificationType.INVOICE_ISSUED,
link: `/contracts/${c.id}/clearance`,
});
}
/** A clearance document was queried — customer must re-upload it. */
clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void {
const msg =
`A clearance document on contract ${c.reference} needs attention: "${fileKey}". ` +
`${note}. Please re-upload from the portal.`;
void this.notifyContact(c, msg, 'CLEARANCE DOC QUERIED');
this.inApp(c, 'Clearance document queried', msg, {
type: NotificationType.DOCUMENT_ACTION,
link: `/contracts/${c.id}/clearance`,
});
}
/** Import pre-clearance finalized — the process moves to GL Djibouti collection. */
preClearanceFinalized(c: Contract): void {
const msg =
`Pre-clearance for contract ${c.reference} is complete. ` +
`Your shipment is proceeding to document collection in Djibouti.`;
void this.notifyContact(c, msg, 'PRE-CLEARANCE FINALIZED');
this.inApp(c, 'Pre-clearance complete', msg, {
type: NotificationType.CLEARANCE_DECISION,
link: `/contracts/${c.id}/clearance`,
});
}
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
/** Customer submitted a contract for review. */
submittedToStaff(c: Contract): void {
this.inAppStaff(
c,
'New contract submitted',
`Contract ${this.ref(c)} was submitted and is awaiting intake review.`,
);
}
/** Customer signed the contract — staff counter-sign is next. */
customerSignedToStaff(c: Contract): void {
this.inAppStaff(
c,
'Customer signed contract',
`Contract ${this.ref(c)} was signed by the customer and awaits the EDR counter-signature.`,
{ link: `/dashboard/contract-requests/${c.id}/view` },
);
}
/** Customer uploaded clearance documents — GL review is next. */
clearanceDocsUploadedToStaff(c: Contract): void {
this.inAppStaff(
c,
'Clearance documents uploaded',
`Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`,
{
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/contracts/clearance/${c.id}`,
},
);
}
/** Customer uploaded the duty/tax payment slip — GL verifies it. */
dutySlipUploadedToStaff(c: Contract): void {
this.inAppStaff(
c,
'Duty slip uploaded',
`Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`,
{
type: NotificationType.PAYMENT_RECEIVED,
link: `/dashboard/contracts/clearance/${c.id}`,
},
);
}
/** Customer filed a shipment request under a GENERAL customs contract. */
shipmentRequestedToStaff(c: Contract, requestId: string, requestRef: string): void {
this.inAppStaff(
c,
'New shipment request',
`Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`,
{
link: `/dashboard/shipment-requests/${requestId}`,
data: { contractId: c.id, requestId, reference: requestRef },
},
);
}
}

View File

@@ -22,6 +22,7 @@ import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service'; import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service'; import { OtpService } from '../otp/otp.service';
import { ContractPricingService } from './contract-pricing.service'; import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service'; import { ContractsService } from './contracts.service';
@@ -66,6 +67,7 @@ export class ContractTransitionService {
private readonly pdfService: ContractPdfService, private readonly pdfService: ContractPdfService,
private readonly minioService: MinioService, private readonly minioService: MinioService,
private readonly otpService: OtpService, private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService,
) {} ) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -79,7 +81,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, { await this.contractsRepository.update(contractId, {
status: 'SUBMITTED', status: 'SUBMITTED',
} as never); } as never);
return this.contractsService.findById(contractId); const updated = await this.contractsService.findById(contractId);
this.notifier.submittedToStaff(updated);
return updated;
} }
/** Confirm a price change before submit (mirrors booking confirm-submit). */ /** Confirm a price change before submit (mirrors booking confirm-submit). */
@@ -93,7 +97,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, { await this.contractsRepository.update(contractId, {
status: 'SUBMITTED', status: 'SUBMITTED',
} as never); } as never);
return this.contractsService.findById(contractId); const updated = await this.contractsService.findById(contractId);
this.notifier.submittedToStaff(updated);
return updated;
} }
/** /**
@@ -130,7 +136,9 @@ export class ContractTransitionService {
contractValidFrom: validFrom, contractValidFrom: validFrom,
contractValidUntil: validUntil, contractValidUntil: validUntil,
} as never); } as never);
return this.contractsService.findById(contractId); const updated = await this.contractsService.findById(contractId);
this.notifier.accepted(updated);
return updated;
} }
/** /**
@@ -218,7 +226,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, { await this.contractsRepository.update(contractId, {
status: 'CHANGES_REQUESTED', status: 'CHANGES_REQUESTED',
} as never); } as never);
return this.contractsService.findById(contractId); const updated = await this.contractsService.findById(contractId);
this.notifier.changesRequested(updated, note);
return updated;
} }
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> { async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
@@ -235,7 +245,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, { await this.contractsRepository.update(contractId, {
status: 'REJECTED', status: 'REJECTED',
} as never); } as never);
return this.contractsService.findById(contractId); const updated = await this.contractsService.findById(contractId);
this.notifier.rejected(updated, reason);
return updated;
} }
/** Approve one approval step in sequence; → APPROVED when all complete. */ /** Approve one approval step in sequence; → APPROVED when all complete. */
@@ -297,7 +309,11 @@ export class ContractTransitionService {
if (Object.keys(updates).length > 0) { if (Object.keys(updates).length > 0) {
await this.contractsRepository.update(contractId, updates as never); await this.contractsRepository.update(contractId, updates as never);
} }
return this.contractsService.findById(contractId); const updated = await this.contractsService.findById(contractId);
if (allDone) {
this.notifier.approved(updated);
}
return updated;
} }
/** /**
@@ -535,7 +551,9 @@ export class ContractTransitionService {
customerSignedAt: new Date(), customerSignedAt: new Date(),
} as never); } as never);
await this.regenerateContractPdf(contractId, contract.reference); await this.regenerateContractPdf(contractId, contract.reference);
return this.contractsService.findById(contractId); const updated = await this.contractsService.findById(contractId);
this.notifier.customerSignedToStaff(updated);
return updated;
} }
return this.counterSign(contractId, dto, options); return this.counterSign(contractId, dto, options);
@@ -605,7 +623,9 @@ export class ContractTransitionService {
await this.contractsRepository.update(contractId, updates as never); await this.contractsRepository.update(contractId, updates as never);
await this.regenerateContractPdf(contractId, contract.reference); await this.regenerateContractPdf(contractId, contract.reference);
return this.contractsService.findById(contractId); const updated = await this.contractsService.findById(contractId);
this.notifier.signedActive(updated);
return updated;
} }
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */ /** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */

View File

@@ -13,10 +13,12 @@ import {
UnauthorizedException, UnauthorizedException,
UploadedFiles, UploadedFiles,
UploadedFile, UploadedFile,
UseGuards,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { CurrentUser } from '@edr/api-common'; import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express'; import type { Response } from 'express';
import { import {
@@ -242,6 +244,7 @@ export class ContractsController {
} }
@Get('list-summary') @Get('list-summary')
@BookingStaff([FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.contracts.view])
@ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' }) @ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' })
@ApiOkResponse({ type: ContractListSummaryDto }) @ApiOkResponse({ type: ContractListSummaryDto })
findListSummary(@Query() filter: FilterContractDto) { findListSummary(@Query() filter: FilterContractDto) {
@@ -449,14 +452,25 @@ export class ContractsController {
} }
@Post(':id/contract/sign') @Post(':id/contract/sign')
@UseGuards(JwtGuard)
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
signContract( signContract(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignContractDto, @Body() dto: SignContractDto,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: TCurrentUser,
) { ) {
// Each staff signing role maps to the permission that step already requires;
// customers sign their own contract with no permission key.
const signRolePermission: Record<string, string> = {
STAFF: FREIGHT_PERMS.contracts.signStaff,
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
CEO: FREIGHT_PERMS.contracts.approveCeo,
};
if (dto.role !== 'CUSTOMER') {
assertFreightPermission(user, signRolePermission[dto.role]);
}
return this.transitionService.sign(id, dto, { return this.transitionService.sign(id, dto, {
signerUserId: user?.id ?? user?.sub, signerUserId: user?.id,
}); });
} }

View File

@@ -12,6 +12,8 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
import { SignaturesModule } from '../signatures/signatures.module'; import { SignaturesModule } from '../signatures/signatures.module';
import { OtpModule } from '../otp/otp.module'; import { OtpModule } from '../otp/otp.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingsModule } from '../bookings/bookings.module'; import { BookingsModule } from '../bookings/bookings.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
@@ -19,6 +21,7 @@ import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service'; import { ContractsService } from './contracts.service';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
import { ContractPricingService } from './contract-pricing.service'; import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ContractTransitionService } from './contract-transition.service'; import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service'; import { ContractClearanceService } from './contract-clearance.service';
import { BookingClearanceService } from './booking-clearance.service'; import { BookingClearanceService } from './booking-clearance.service';
@@ -75,6 +78,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
MinioModule, MinioModule,
SignaturesModule, SignaturesModule,
OtpModule, OtpModule,
NotificationsModule,
NotificationInboxModule,
CompaniesModule, CompaniesModule,
// BookingsModule provides BookingsRepository/BookingPricingService used by the // BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
@@ -94,6 +99,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractsService, ContractsService,
ContractsRepository, ContractsRepository,
ContractPricingService, ContractPricingService,
ContractNotifierService,
ContractTransitionService, ContractTransitionService,
ContractClearanceService, ContractClearanceService,
ClearanceWorkflowService, ClearanceWorkflowService,

View File

@@ -8,10 +8,14 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common'; import { insertWithGeneratedReference } from '@edr/api-common';
import { YardCountry } from '@edr/types';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service'; import { CompaniesService } from '../companies/companies.service';
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity'; import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity'; import { CompanyStatus } from '../companies/entities/company.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service'; import { MinioService } from '../minio/minio.service';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
@@ -110,6 +114,49 @@ export class ContractsService {
} }
} }
/**
* Every route must match the contract's declared trade direction as derived
* from the yard countries (IMPORT = DJ→ET, EXPORT = ET→DJ, DOMESTIC =
* intercity). Intercity is Ethiopian-domestic only: both yards must be in
* Ethiopia — a Djibouti-internal pair is rejected. Direction mismatches
* (e.g. an export lane on an import contract) are rejected for every kind.
*/
private async assertRoutesMatchDirection(
tradeDirection: string,
routes: CreateContractDto['routes'],
): Promise<void> {
const yardIds = [
...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])),
];
const yards = await this.dataSource
.getRepository(Yard)
.find({ where: yardIds.map((id) => ({ id })) });
const yardById = new Map(yards.map((y) => [y.id, y]));
for (const route of routes) {
const origin = yardById.get(route.originYardId);
const destination = yardById.get(route.destinationYardId);
if (!origin || !destination) {
throw new BadRequestException('Route references a yard that does not exist');
}
const derived = deriveTradeDirection(origin, destination);
if (derived !== tradeDirection) {
throw new BadRequestException(
`Route ${origin.label}${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`,
);
}
if (
derived === 'DOMESTIC' &&
(origin.country !== YardCountry.ETHIOPIA ||
destination.country !== YardCountry.ETHIOPIA)
) {
throw new BadRequestException(
`Route ${origin.label}${destination.label}: intercity service only runs between Ethiopian yards`,
);
}
}
}
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */ /** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
async create( async create(
dto: CreateContractDto, dto: CreateContractDto,
@@ -144,6 +191,7 @@ export class ContractsService {
this.assertCargoScopeShape(dto.freightType, dto.cargoScope); this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
this.assertRouteShape(dto.contractKind, dto.routes); this.assertRouteShape(dto.contractKind, dto.routes);
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
// Stamp the operational profile (importer/exporter) for portal scoping. // Stamp the operational profile (importer/exporter) for portal scoping.
let companyProfileId: string | null = null; let companyProfileId: string | null = null;
@@ -175,6 +223,13 @@ export class ContractsService {
// Customs clearing is owned by the service type, not the customer. // Customs clearing is owned by the service type, not the customer.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId); const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
// Intercity never crosses a border, so a customs-including service type is
// a contradiction — the wizard hides them, the API enforces it.
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
throw new BadRequestException(
'Intercity contracts cannot use a service type that includes customs clearing',
);
}
// An explicit reference is caller-chosen — a collision there is a real // An explicit reference is caller-chosen — a collision there is a real
// conflict and should surface. Auto-generated references retry past a // conflict and should surface. Auto-generated references retry past a
@@ -360,6 +415,12 @@ export class ContractsService {
if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope); if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope);
if (dto.routes) this.assertRouteShape(contractKind, dto.routes); if (dto.routes) this.assertRouteShape(contractKind, dto.routes);
if (dto.routes) {
await this.assertRoutesMatchDirection(
dto.tradeDirection ?? existing.tradeDirection,
dto.routes,
);
}
const updates: Record<string, unknown> = { const updates: Record<string, unknown> = {
contractKind, contractKind,
@@ -385,6 +446,11 @@ export class ContractsService {
const includesCustoms = await this.resolveIncludesCustoms( const includesCustoms = await this.resolveIncludesCustoms(
dto.serviceTypeId ?? existing.serviceTypeId, dto.serviceTypeId ?? existing.serviceTypeId,
); );
if ((dto.tradeDirection ?? existing.tradeDirection) === 'DOMESTIC' && includesCustoms) {
throw new BadRequestException(
'Intercity contracts cannot use a service type that includes customs clearing',
);
}
updates.customsClearingEnabled = includesCustoms; updates.customsClearingEnabled = includesCustoms;
updates.customsClearingAgent = includesCustoms updates.customsClearingAgent = includesCustoms
? null ? null
@@ -501,6 +567,21 @@ export class ContractsService {
); );
} }
// Surface the staff "request changes" note so the portal can show the
// customer what to fix. Degrade to null on lookup failure — a missing note
// must never 500 a contract fetch.
if (contract.status === 'CHANGES_REQUESTED') {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'CHANGES_REQUESTED',
);
contract.latestChangeRequestNote = note?.body ?? null;
} catch {
contract.latestChangeRequestNote = null;
}
}
return contract; return contract;
} }

View File

@@ -120,9 +120,14 @@ export class CreateBookingUnderContractDto {
@IsUUID() @IsUUID()
contractRouteId?: string; contractRouteId?: string;
@ApiProperty({ description: 'Binding shipment day.', example: '2026-07-15' }) @ApiPropertyOptional({
description:
'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.',
example: '2026-07-15',
})
@IsOptional()
@IsDateString() @IsDateString()
scheduledDate!: string; scheduledDate?: string;
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] }) @ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
@IsOptional() @IsOptional()

View File

@@ -260,4 +260,11 @@ export class Contract extends BaseEntity {
* ContractsRepository.attachClearancePhases for list responses. Not a column. * ContractsRepository.attachClearancePhases for list responses. Not a column.
*/ */
clearancePhase?: string | null; clearancePhase?: string | null;
/**
* Body of the most recent CHANGES_REQUESTED review note, attached by
* ContractsService.findById so the portal can show the customer what staff
* asked them to fix. Lives in contract_review_notes, not a column here.
*/
latestChangeRequestNote?: string | null;
} }

View File

@@ -11,6 +11,7 @@ import { BillingService } from '../billing/billing.service';
import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { InvoiceLine } from '../billing/entities/invoice-line.entity';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { Booking } from '../bookings/entities/booking.entity'; import { Booking } from '../bookings/entities/booking.entity';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity'; import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
import { import {
@@ -53,6 +54,7 @@ export class GlOperationsService {
private readonly filesService: FilesService, private readonly filesService: FilesService,
private readonly milestoneService: ClearanceMilestoneService, private readonly milestoneService: ClearanceMilestoneService,
private readonly billingService: BillingService, private readonly billingService: BillingService,
private readonly notifier: BookingLifecycleNotifierService,
) {} ) {}
private get bookings() { private get bookings() {
@@ -64,7 +66,11 @@ export class GlOperationsService {
} }
private async getBooking(bookingId: string): Promise<Booking> { private async getBooking(bookingId: string): Promise<Booking> {
const booking = await this.bookings.findOne({ where: { id: bookingId } }); // company is loaded so customer notifications have a phone/email to target.
const booking = await this.bookings.findOne({
where: { id: bookingId },
relations: { company: true },
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
return booking; return booking;
} }
@@ -447,6 +453,7 @@ export class GlOperationsService {
void userId; void userId;
const summary = await this.finalInvoiceSummary(bookingId); const summary = await this.finalInvoiceSummary(bookingId);
if (!summary) throw new NotFoundException('Final invoice could not be created.'); if (!summary) throw new NotFoundException('Final invoice could not be created.');
this.notifier.finalInvoiceCreated(booking, input.amount, input.currency);
return summary; return summary;
} }
@@ -455,7 +462,7 @@ export class GlOperationsService {
bookingId: string, bookingId: string,
file: Express.Multer.File, file: Express.Multer.File,
): Promise<{ uploaded: boolean }> { ): Promise<{ uploaded: boolean }> {
await this.getBooking(bookingId); const booking = await this.getBooking(bookingId);
if (!file) throw new BadRequestException('No payment slip uploaded'); if (!file) throw new BadRequestException('No payment slip uploaded');
const invoice = await this.billingService.findInvoice( const invoice = await this.billingService.findInvoice(
@@ -482,6 +489,7 @@ export class GlOperationsService {
code: 'final_invoice_slip', code: 'final_invoice_slip',
file, file,
}); });
this.notifier.dutySlipUploadedToStaff(booking, 'final');
return { uploaded: true }; return { uploaded: true };
} }
@@ -490,7 +498,7 @@ export class GlOperationsService {
bookingId: string, bookingId: string,
userId?: string, userId?: string,
): Promise<Freight.ClearanceFinalInvoiceSummary> { ): Promise<Freight.ClearanceFinalInvoiceSummary> {
await this.getBooking(bookingId); const booking = await this.getBooking(bookingId);
const invoice = await this.billingService.findInvoice( const invoice = await this.billingService.findInvoice(
Freight.InvoiceSource.Booking, Freight.InvoiceSource.Booking,
bookingId, bookingId,
@@ -507,6 +515,7 @@ export class GlOperationsService {
); );
} }
await this.billingService.markInvoiceAsPaid(invoice.id); await this.billingService.markInvoiceAsPaid(invoice.id);
this.notifier.finalInvoicePaid(booking);
} }
void userId; void userId;
@@ -575,6 +584,7 @@ export class GlOperationsService {
}, },
userId, userId,
); );
this.notifier.secondDutyAdvised(booking, input.amount, input.currency ?? 'ETB');
return { advised: true, skipped: false }; return { advised: true, skipped: false };
} }
@@ -605,6 +615,7 @@ export class GlOperationsService {
booking.tradeDirection ?? 'IMPORT', booking.tradeDirection ?? 'IMPORT',
); );
await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID'); await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID');
this.notifier.dutySlipUploadedToStaff(booking, 'second');
return { milestoneCompleted: true }; return { milestoneCompleted: true };
} }

View File

@@ -70,6 +70,18 @@ export class NotificationRecipientsService {
} }
} }
if (recipients.allBackoffice) {
try {
for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) {
ids.add(uid);
}
} catch (err) {
this.logger.warn(
`Failed to resolve allBackoffice recipients: ${(err as Error).message}`,
);
}
}
return [...ids]; return [...ids];
} }
} }

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common'; import { BaseEntity } from '@edr/api-common';
import type { ScheduleTradeDirection } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity'; import { Yard } from '../../rule-engine/entities/yard.entity';
@@ -26,6 +27,14 @@ export class Route extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' }) @Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' })
status!: RouteStatus; status!: RouteStatus;
/**
* Trade direction frozen from the yard countries at create/update
* (ET→DJ = EXPORT, DJ→ET = IMPORT, same country = DOMESTIC/"Intercity").
* Consumers (scheduling, booking windows) read this instead of re-deriving.
*/
@Column({ name: 'direction', type: 'varchar', length: 10 })
direction!: ScheduleTradeDirection;
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false }) @OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
milestones?: RouteMilestone[]; milestones?: RouteMilestone[];
} }

View File

@@ -1,6 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity'; import { Yard } from '../rule-engine/entities/yard.entity';
import { CreateRouteDto } from './dto/create-route.dto'; import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto';
@@ -83,6 +84,7 @@ export class RoutesService {
originYardId: validated.originYardId, originYardId: validated.originYardId,
destinationYardId: validated.destinationYardId, destinationYardId: validated.destinationYardId,
status: dto.status ?? 'AVAILABLE', status: dto.status ?? 'AVAILABLE',
direction: validated.direction,
}), }),
); );
@@ -115,6 +117,7 @@ export class RoutesService {
originYardId: milestoneInput?.originYardId ?? existing.originYardId, originYardId: milestoneInput?.originYardId ?? existing.originYardId,
destinationYardId: destinationYardId:
milestoneInput?.destinationYardId ?? existing.destinationYardId, milestoneInput?.destinationYardId ?? existing.destinationYardId,
...(milestoneInput ? { direction: milestoneInput.direction } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}),
}); });
@@ -187,9 +190,18 @@ export class RoutesService {
throw new BadRequestException('Origin and destination yards must be different'); throw new BadRequestException('Origin and destination yards must be different');
} }
const originYardId = normalized[0].yardId;
const destinationYardId = normalized[normalized.length - 1].yardId;
const yardById = new Map(yards.map((yard) => [yard.id, yard]));
const direction = deriveTradeDirection(
yardById.get(originYardId) ?? { country: null },
yardById.get(destinationYardId) ?? { country: null },
);
return { return {
originYardId: normalized[0].yardId, originYardId,
destinationYardId: normalized[normalized.length - 1].yardId, destinationYardId,
direction,
milestones: normalized, milestones: normalized,
}; };
} }

View File

@@ -1,5 +1,6 @@
import { YardCountry } from '@edr/types';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; import { IsBoolean, IsEnum, IsInt, IsOptional, IsUUID, MaxLength, Min, IsString } from 'class-validator';
export class CreateYardDto { export class CreateYardDto {
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 }) @ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
@@ -7,10 +8,9 @@ export class CreateYardDto {
@MaxLength(100) @MaxLength(100)
label!: string; label!: string;
@ApiProperty({ description: 'Country where the yard is located, e.g. Ethiopia, Djibouti', maxLength: 50 }) @ApiProperty({ enum: YardCountry, description: 'Country where the yard is located' })
@IsString() @IsEnum(YardCountry)
@MaxLength(50) country!: YardCountry;
country!: string;
@ApiPropertyOptional({ default: true }) @ApiPropertyOptional({ default: true })
@IsOptional() @IsOptional()

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common'; import { BaseEntity } from '@edr/api-common';
import { YardCountry } from '@edr/types';
import { Column, Entity, Index } from 'typeorm'; import { Column, Entity, Index } from 'typeorm';
@Entity({ schema: 'freight', name: 'yards' }) @Entity({ schema: 'freight', name: 'yards' })
@@ -12,8 +13,11 @@ export class Yard extends BaseEntity {
@Column({ name: 'label', type: 'varchar', length: 100 }) @Column({ name: 'label', type: 'varchar', length: 100 })
label!: string; label!: string;
// Constrained to YardCountry by DTO validation + a DB CHECK constraint;
// route/schedule trade direction is derived from this value. Typed as the
// enum's literal values so plain strings from seeds/queries still fit.
@Column({ name: 'country', type: 'varchar', length: 50 }) @Column({ name: 'country', type: 'varchar', length: 50 })
country!: string; country!: `${YardCountry}`;
@Column({ name: 'is_active', type: 'boolean', default: true }) @Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean; isActive!: boolean;

View File

@@ -78,6 +78,11 @@ describe('SchedulingRescheduleService', () => {
bookingsRepository as never, bookingsRepository as never,
trainSchedulingService as never, trainSchedulingService as never,
schedulingRescheduleRepository as never, schedulingRescheduleRepository as never,
{
rescheduled: jest.fn(),
removedFromTrain: jest.fn(),
maintenanceMoved: jest.fn(),
} as never, // notifier
); );
}); });

View File

@@ -10,6 +10,7 @@ import { BookingsRepository } from '../bookings/bookings.repository';
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util'; import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; 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 { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository'; import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
@@ -38,6 +39,7 @@ export class SchedulingRescheduleService {
private readonly bookingsRepository: BookingsRepository, private readonly bookingsRepository: BookingsRepository,
private readonly trainSchedulingService: TrainSchedulingService, private readonly trainSchedulingService: TrainSchedulingService,
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository, private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
private readonly notifier: BookingNotifierService,
) {} ) {}
/** Preview who is retained, displaced, and readmitted on a schedule. */ /** Preview who is retained, displaced, and readmitted on a schedule. */
@@ -193,9 +195,64 @@ export class SchedulingRescheduleService {
displacedBookingIds: dto.displacedBookingIds, displacedBookingIds: dto.displacedBookingIds,
}); });
// 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);
return { plan, schedule: assignResult }; return { plan, schedule: assignResult };
} }
/**
* Fan out reschedule notifications: bookings that stayed on the train hear the
* new departure date; bookings dropped off the train (staff reschedule, not a
* government pre-empt) hear they were removed. Loads each booking with its
* company so the notifier has a phone/email to reach.
*/
private async notifyRescheduleOutcome(
dto: ExecuteRescheduleDto,
newDeparture: Date | null,
): Promise<void> {
const isMaintenance = dto.trigger === 'TRAIN_MAINTENANCE';
const isGovPreempt = dto.trigger === 'GOVERNMENT_PREEMPT';
if (newDeparture) {
for (const bookingId of dto.finalBookingIds) {
const booking = await this.loadBookingForNotify(bookingId);
if (!booking) continue;
if (isMaintenance) {
this.notifier.maintenanceMoved(booking, newDeparture);
} else {
this.notifier.rescheduled(booking, newDeparture);
}
}
}
// Government pre-empt displacements are already announced by the batch
// displaced() notice — don't double-notify. Staff reschedules are not.
if (!isGovPreempt) {
for (const bookingId of dto.displacedBookingIds) {
const booking = await this.loadBookingForNotify(bookingId);
if (!booking) continue;
this.notifier.removedFromTrain(booking);
}
}
}
private async loadBookingForNotify(bookingId: string): Promise<Booking | null> {
try {
return await this.bookingsRepository.findByIdWithFiles(bookingId);
} catch {
return null;
}
}
/** Maintenance shortcut: new departure + rebalance. */ /** Maintenance shortcut: new departure + rebalance. */
async maintenanceReschedule( async maintenanceReschedule(
scheduleId: string, scheduleId: string,

View File

@@ -302,3 +302,41 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
expect(withEarly?.window?.label).toContain('08:00'); expect(withEarly?.window?.label).toContain('08:00');
}); });
}); });
// Regression: a schedule created INSIDE its own window day must open right away
// when the desk is open, and re-deriving after a settings change (close hour
// extended past "now", or lead pulled so the window day becomes today) must
// yield an immediate open — not tomorrow morning.
describe('computeImportWindowTimes — immediate open inside the window day', () => {
// 19:15:17 EAT on Mon 6 Jul = 16:15:17 UTC
const now = new Date('2026-07-06T16:15:17.000Z');
// Departs Thu 9 Jul ~08:53 EAT
const departure = new Date('2026-07-09T05:53:00.000Z');
const base = { importWindowLeadDays: 3, windowOpenHour: 8, windowDurationHours: 0.05 };
it('desk 823, created 19:15 on the window day → opens NOW', () => {
const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now);
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
});
it('desk 817, created 19:15 (desk shut) → opens next morning 08:00 EAT', () => {
const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 17 }, now);
expect(t.windowOpensAt.toISOString()).toBe('2026-07-07T05:00:00.000Z');
});
it('close hour extended 17 → 23 after hours: re-derive opens NOW', () => {
// Same call restampPendingWindows makes after the global-rules edit.
const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now);
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
});
it('lead 3 → 4 pulls the window day to today: re-derive opens NOW', () => {
const departsJul10 = new Date('2026-07-10T05:53:00.000Z');
const t = computeImportWindowTimes(
departsJul10,
{ ...base, importWindowLeadDays: 4, windowCloseHour: 23 },
now,
);
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
});
});

View File

@@ -282,15 +282,33 @@ export function computeImportWindowTimes(
return { windowOpensAt: opensAt, windowClosesAt: closesAt }; return { windowOpensAt: opensAt, windowClosesAt: closesAt };
} }
/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */ /**
* Export booking window: a single FCFS window from `exportBookingLeadHours`
* before departure until departure. The open honours the daily desk hours —
* when the raw lead instant lands while the desk is shut, the window opens at
* the next desk opening instead (capped at departure, so a config whose desk
* never opens before the train leaves yields a zero-length window rather than
* one that outlives the train).
*/
export function computeExportWindowTimes( export function computeExportWindowTimes(
departure: Date, departure: Date,
cfg: { exportBookingLeadHours: number }, cfg: {
exportBookingLeadHours: number;
windowOpenHour: number;
windowCloseHour: number;
},
): InitialWindowTimes { ): InitialWindowTimes {
return { const rawOpen = new Date(
windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000), departure.getTime() - cfg.exportBookingLeadHours * 3_600_000,
windowClosesAt: departure, );
}; let opensAt = officeHoursOpen(rawOpen, {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
});
if (opensAt.getTime() > departure.getTime()) {
opensAt = departure;
}
return { windowOpensAt: opensAt, windowClosesAt: departure };
} }
/** /**
@@ -421,7 +439,9 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
* after each close, on the same booking day, until departure. This mirrors * after each close, on the same booking day, until departure. This mirrors
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the * `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
* exact windows the engine runs. * exact windows the engine runs.
* EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure. * EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure,
* with the open shifted to the next desk opening when it lands outside office hours
* (same math as `computeExportWindowTimes`).
* *
* `anchorOpensAt` pins the FIRST window's open time to the schedule's stored * `anchorOpensAt` pins the FIRST window's open time to the schedule's stored
* `windowOpensAt` instead of recomputing it from config. Pass it so the board * `windowOpensAt` instead of recomputing it from config. Pass it so the board
@@ -436,8 +456,7 @@ export function listConfigBookingWindows(
): BoardWindow[] { ): BoardWindow[] {
if (direction === 'EXPORT') { if (direction === 'EXPORT') {
const start = const start =
anchorOpensAt ?? anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt;
new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
return [boardWindowFromInterval(start, departure)]; return [boardWindowFromInterval(start, departure)];
} }

View File

@@ -122,6 +122,7 @@ describe('BookingBatchService — PAID reconcile', () => {
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never, trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
{ emitPhase: jest.fn() } as never,
); );
}); });

View File

@@ -40,10 +40,11 @@ import {
import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { BookingSplitService } from './booking-split.service'; import { BookingSplitService } from './booking-split.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util'; import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
/** A train's remaining capacity along the three physical limits the batch enforces. */ /** A train's remaining capacity along the three physical limits the batch enforces. */
interface Capacity { export interface Capacity {
wagons: number; wagons: number;
weightTons: number; weightTons: number;
lengthMeters: number; lengthMeters: number;
@@ -204,6 +205,7 @@ export class BookingBatchService implements OnModuleInit {
private readonly scheduler: SchedulerRegistry, private readonly scheduler: SchedulerRegistry,
private readonly trainSchedulingService: TrainSchedulingService, private readonly trainSchedulingService: TrainSchedulingService,
private readonly billing: BillingService, private readonly billing: BillingService,
private readonly bookingWindowGateway: BookingWindowGateway,
@Optional() private readonly milestoneService?: ClearanceMilestoneService, @Optional() private readonly milestoneService?: ClearanceMilestoneService,
@Optional() private readonly splitService?: BookingSplitService, @Optional() private readonly splitService?: BookingSplitService,
@@ -380,6 +382,18 @@ export class BookingBatchService implements OnModuleInit {
this.logger.log( this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`, `Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
); );
} else {
// Already linked at booking time (export FCFS: the customer books a
// specific train, so allocate() ran up front). allocate() is where the
// payment-settled tracking milestones are written, so on this branch we
// record them here — otherwise a paid, already-linked booking leaves
// FREIGHT_PAYMENT_SETTLED stuck PENDING and the clearance step never ticks.
void this.completeTrackingMilestones(bookingId, [
"WAGON_REQUESTED",
"FREIGHT_PAYMENT_PENDING",
"FREIGHT_PAYMENT_SETTLED",
]);
void this.markWagonAllocatedMilestone(bookingId);
} }
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
@@ -1444,6 +1458,48 @@ export class BookingBatchService implements OnModuleInit {
await this.fillSchedule(booking.trainScheduleId); await this.fillSchedule(booking.trainScheduleId);
} }
// ---- intercity ride-along API ---------------------------------------------
/**
* Remaining capacity budget (wagons / weight / length) for a schedule, and
* the per-booking need calculator — exposed for the intercity accept flow,
* which reserves ride-along bookings onto import/export trains outside the
* batch engine.
*/
async intercityCapacity(scheduleId: string): Promise<{
budget: Capacity;
needFor: (booking: Booking) => Capacity;
} | null> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) return null;
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
}
/**
* Accept an intercity booking onto the given train. Commercial bookings get
* the same pay-window lifecycle as a batch reservation (deadline, invoice
* due-date sync, pay-now notify, settle on the window tick), so payment →
* allocation needs no special path. Government bookings allocate directly.
*/
async acceptIntercity(booking: Booking, scheduleId: string): Promise<void> {
if (booking.isGovernment) {
await this.dataSource
.getRepository(Booking)
.update(booking.id, { trainScheduleId: scheduleId });
booking.trainScheduleId = scheduleId;
await this.allocate(scheduleId, booking, 'gov');
return;
}
await this.reserve(booking, scheduleId);
this.armSettle(scheduleId);
}
// ---- mutations ------------------------------------------------------------ // ---- mutations ------------------------------------------------------------
/** /**
@@ -1473,6 +1529,12 @@ export class BookingBatchService implements OnModuleInit {
"PREPAID", "PREPAID",
); );
await this.notifier.payNow(booking, deadline); await this.notifier.payNow(booking, deadline);
// Customer tracking: a wagon slot is reserved and the freight pay window is
// open. Doc-trigger path — silent no-op for bookings without milestone rows.
void this.completeTrackingMilestones(booking.id, [
"WAGON_REQUESTED",
"FREIGHT_PAYMENT_PENDING",
]);
} }
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */ /** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
@@ -1504,6 +1566,15 @@ export class BookingBatchService implements OnModuleInit {
this.notifier.secured(booking, reason); this.notifier.secured(booking, reason);
void this.triggerWagonAllocation(scheduleId); void this.triggerWagonAllocation(scheduleId);
void this.markWagonAllocatedMilestone(booking.id); void this.markWagonAllocatedMilestone(booking.id);
// Customer tracking: freight payment settled (commercial pay-window path).
// Government allocations don't pay upfront — theirs stay pending.
if (reason === 'paid') {
void this.completeTrackingMilestones(booking.id, [
'WAGON_REQUESTED',
'FREIGHT_PAYMENT_PENDING',
'FREIGHT_PAYMENT_SETTLED',
]);
}
} }
private async markWagonAllocatedMilestone(bookingId: string): Promise<void> { private async markWagonAllocatedMilestone(bookingId: string): Promise<void> {
@@ -1515,6 +1586,27 @@ export class BookingBatchService implements OnModuleInit {
} }
} }
/**
* Complete customer-tracking milestones on lifecycle events via the
* doc-trigger path — a silent no-op for bookings without milestone rows
* (non-customs bookings). Never blocks the batch action.
*/
private async completeTrackingMilestones(
bookingId: string,
codes: string[],
): Promise<void> {
if (!this.milestoneService) return;
for (const code of codes) {
try {
await this.milestoneService.completeByDocTrigger({ bookingId }, code);
} catch (err) {
this.logger.warn(
`Milestone ${code} completion failed for booking ${bookingId}: ${(err as Error).message}`,
);
}
}
}
/** /**
* Expire an unpaid reservation and free its capacity. With day-level pooling we * Expire an unpaid reservation and free its capacity. With day-level pooling we
* also clear `trainScheduleId` so the booking is no longer pinned to the train * also clear `trainScheduleId` so the booking is no longer pinned to the train
@@ -1831,6 +1923,17 @@ export class BookingBatchService implements OnModuleInit {
await this.dataSource await this.dataSource
.getRepository(TrainSchedule) .getRepository(TrainSchedule)
.update(scheduleId, { bookingWindowStatus: status }); .update(scheduleId, { bookingWindowStatus: status });
// Push the change (open / train full / closed) so portal home and GL cards
// flip in real time — FULL in particular happens outside the window tick
// (batch fill, staff mark-paid) and had no live signal before.
try {
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
} catch (err) {
this.logger.warn(
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
);
}
} }
/** No wagon slots left for allocated + reserved bookings. */ /** No wagon slots left for allocated + reserved bookings. */

View File

@@ -1,13 +1,23 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import {
NotificationAudience,
NotificationType,
NotifyInput,
} from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity'; import { Booking } from '../bookings/entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
@Injectable() @Injectable()
export class BookingNotifierService { export class BookingNotifierService {
private readonly logger = new Logger(BookingNotifierService.name); private readonly logger = new Logger(BookingNotifierService.name);
constructor(private readonly notifications: NotificationsService) {} constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) {}
private ref(b: Booking): string { private ref(b: Booking): string {
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
@@ -41,11 +51,34 @@ export class BookingNotifierService {
} }
} }
/** Persist + push an in-app item to all portal users of the booking's company. */
private inApp(
b: Booking,
title: string,
body: string,
overrides: Partial<NotifyInput> = {},
): void {
if (!b.companyId) return; // government/unlinked bookings have no portal users
void this.inbox.notify({
recipients: { companyId: b.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.SCHEDULE_UPDATE,
title,
body,
link: `/bookings/${b.id}`,
data: { bookingId: b.id, reference: b.reference },
...overrides,
});
}
async payNow(b: Booking, deadline: Date): Promise<void> { async payNow(b: Booking, deadline: Date): Promise<void> {
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`; const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
await this.notifyContact(b, msg, 'PAY NOW'); await this.notifyContact(b, msg, 'PAY NOW');
this.inApp(b, 'Payment window open', msg, {
type: NotificationType.INVOICE_ISSUED,
});
} }
/** /**
@@ -66,6 +99,9 @@ export class BookingNotifierService {
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` + `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` +
`(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; `(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)'); await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
this.inApp(b, 'Partial allocation offer', msg, {
type: NotificationType.INVOICE_ISSUED,
});
} }
secured(b: Booking, reason: 'paid' | 'gov'): void { secured(b: Booking, reason: 'paid' | 'gov'): void {
@@ -73,11 +109,13 @@ export class BookingNotifierService {
reason === 'gov' ? ' (government)' : '' reason === 'gov' ? ' (government)' : ''
}.`; }.`;
void this.notifyContact(b, msg, 'ALLOCATED'); void this.notifyContact(b, msg, 'ALLOCATED');
this.inApp(b, 'Wagon allocated', msg);
} }
expired(b: Booking): void { expired(b: Booking): void {
const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`; const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`;
void this.notifyContact(b, msg, 'EXPIRED'); void this.notifyContact(b, msg, 'EXPIRED');
this.inApp(b, 'Payment window expired', msg);
} }
scheduleFull(b: Booking): void { scheduleFull(b: Booking): void {
@@ -100,5 +138,42 @@ export class BookingNotifierService {
displaced(b: Booking): void { displaced(b: Booking): void {
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`; const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
void this.notifyContact(b, msg, 'DISPLACED'); void this.notifyContact(b, msg, 'DISPLACED');
this.inApp(b, 'Booking displaced', msg);
}
/**
* Staff rescheduled the train carrying this booking to a new departure date.
* The booking stays on the train — only the date moved.
*/
rescheduled(b: Booking, newDeparture: Date): void {
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`;
void this.notifyContact(b, msg, 'RESCHEDULED');
this.inApp(b, 'Booking rescheduled', msg);
}
/**
* Booking was removed from its train during a staff reschedule (not a government
* pre-empt). It returns to eligible — the customer must rebook or reschedule.
*/
removedFromTrain(b: Booking): void {
const msg =
`Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` +
`Please rebook or select a new schedule from the portal.`;
void this.notifyContact(b, msg, 'REMOVED FROM TRAIN');
this.inApp(b, 'Removed from train', msg);
}
/**
* The train carrying this booking was moved for maintenance to a new departure
* date. The booking stays on the train — only the date moved.
*/
maintenanceMoved(b: Booking, newDeparture: Date): void {
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
const msg =
`The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` +
`New departure date: ${when}.`;
void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE');
this.inApp(b, 'Train maintenance reschedule', msg);
} }
} }

View File

@@ -0,0 +1,109 @@
import { BOOKING_WINDOW_WS_EVENTS, BOOKING_WINDOW_WS_NAMESPACE } from '@edr/types';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { io, type Socket } from 'socket.io-client';
import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { BookingWindowGateway } from './booking-window.gateway';
import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
/**
* End-to-end proof the booking-window socket works: boots a real Nest app with
* the gateway, connects a real socket.io client to the namespace, emits a phase
* change, and asserts the client receives the exact payload. If this passes,
* any "no live update" report is environmental (stale server process, wrong
* checkout running, client not connecting) — not the gateway.
*/
describe('BookingWindowGateway (e2e)', () => {
let app: INestApplication;
let gateway: BookingWindowGateway;
let client: Socket;
let baseUrl: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
BookingWindowGateway,
// Accept any token — auth plumbing is covered by the real WsAuthService.
{ provide: WsAuthService, useValue: { resolveUserId: async () => 'user-1' } },
],
}).compile();
app = moduleRef.createNestApplication();
await app.listen(0);
const address = app.getHttpServer().address() as { port: number };
baseUrl = `http://127.0.0.1:${address.port}`;
gateway = app.get(BookingWindowGateway);
});
afterAll(async () => {
client?.disconnect();
await app?.close();
});
it('authenticated client receives the phase event with the schedule state', async () => {
client = io(`${baseUrl}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
auth: { token: 'any' },
transports: ['websocket'],
});
await new Promise<void>((resolve, reject) => {
client.on('connect', () => resolve());
client.on('connect_error', (err) => reject(err));
});
const received = new Promise<Record<string, unknown>>((resolve) => {
client.on(BOOKING_WINDOW_WS_EVENTS.PHASE, (payload) => resolve(payload));
});
gateway.emitPhase({
id: 'sched-1',
originStationId: 'yard-a',
destinationStationId: 'yard-b',
direction: 'IMPORT',
windowPhase: 'OPEN',
bookingWindowStatus: 'OPEN',
bookingCycleNo: 2,
windowOpensAt: new Date('2026-07-06T16:15:00Z'),
windowClosesAt: new Date('2026-07-06T16:18:00Z'),
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
scheduledDepartureDate: new Date('2026-07-09T05:53:00Z'),
} as unknown as TrainSchedule);
const payload = await received;
expect(payload).toMatchObject({
scheduleId: 'sched-1',
phase: 'OPEN',
bookingWindowStatus: 'OPEN',
bookingCycleNo: 2,
windowOpensAt: '2026-07-06T16:15:00.000Z',
});
});
it('rejects a client whose token does not resolve to a user', async () => {
const moduleRef = await Test.createTestingModule({
providers: [
BookingWindowGateway,
{ provide: WsAuthService, useValue: { resolveUserId: async () => null } },
],
}).compile();
const rejectingApp = moduleRef.createNestApplication();
await rejectingApp.listen(0);
const addr = rejectingApp.getHttpServer().address() as { port: number };
const rejected = io(`http://127.0.0.1:${addr.port}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
auth: { token: 'bad' },
transports: ['websocket'],
reconnection: false,
});
const outcome = await new Promise<string>((resolve) => {
rejected.on('disconnect', () => resolve('disconnected'));
rejected.on('connect_error', () => resolve('rejected'));
// The server accepts the transport then drops it in handleConnection.
setTimeout(() => resolve(rejected.connected ? 'still-connected' : 'disconnected'), 500);
});
rejected.disconnect();
await rejectingApp.close();
expect(outcome).not.toBe('still-connected');
});
});

View File

@@ -0,0 +1,80 @@
import {
BOOKING_WINDOW_WS_EVENTS,
BOOKING_WINDOW_WS_NAMESPACE,
type BookingWindowPhaseEvent,
} from '@edr/types';
import { Logger } from '@nestjs/common';
import {
OnGatewayConnection,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
/**
* Server → client push for booking-window state changes. Same handshake model
* as the notifications gateway: clients only listen, the token is verified on
* connect. Events are broadcast namespace-wide — window state is route-scoped
* public information for signed-in users, and clients filter/invalidate their
* own queries.
*/
@WebSocketGateway({
namespace: BOOKING_WINDOW_WS_NAMESPACE,
cors: { origin: true, credentials: true },
})
export class BookingWindowGateway implements OnGatewayConnection {
private readonly logger = new Logger(BookingWindowGateway.name);
@WebSocketServer()
private readonly server!: Server;
constructor(private readonly wsAuth: WsAuthService) {}
async handleConnection(socket: Socket): Promise<void> {
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
if (!userId) {
this.logger.debug(`Rejected booking-window handshake ${socket.id}`);
socket.disconnect(true);
return;
}
socket.data.userId = userId;
// Log at info so "is anyone actually connected?" is answerable from the
// API log when diagnosing missing live updates.
this.logger.log(`Booking-window client connected (user ${userId})`);
}
/** Push a schedule's current window state to every connected client. */
emitPhase(schedule: TrainSchedule): void {
const payload: BookingWindowPhaseEvent = {
scheduleId: schedule.id,
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
direction: schedule.direction ?? null,
phase: (schedule.windowPhase ?? 'PRE_WINDOW') as BookingWindowPhaseEvent['phase'],
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
bookingCycleNo: schedule.bookingCycleNo,
windowOpensAt: schedule.windowOpensAt?.toISOString() ?? null,
windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null,
docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null,
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
};
this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);
}
private extractToken(socket: Socket): string | undefined {
const authToken = socket.handshake.auth?.token as string | undefined;
if (authToken) return authToken;
const queryToken = socket.handshake.query?.token;
if (typeof queryToken === 'string') return queryToken;
const header = socket.handshake.headers?.authorization;
if (header?.startsWith('Bearer ')) return header.slice(7);
return undefined;
}
}

View File

@@ -2,13 +2,19 @@ import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/com
import { Cron } from '@nestjs/schedule'; import { Cron } from '@nestjs/schedule';
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types'; import {
NotificationAudience,
NotificationType,
TrainScheduleStatus as TrainScheduleStatusEnum,
} from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity'; import { Booking } from '../bookings/entities/booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { BookingBatchService } from './booking-batch.service'; import { BookingBatchService } from './booking-batch.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants'; import { BATCH_TIMEZONE } from './booking-batch.constants';
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util'; import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
@@ -40,6 +46,8 @@ export class BookingWindowService implements OnModuleInit {
private readonly bookingBatchService: BookingBatchService, private readonly bookingBatchService: BookingBatchService,
private readonly trainSchedulingService: TrainSchedulingService, private readonly trainSchedulingService: TrainSchedulingService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
private readonly gateway: BookingWindowGateway,
) {} ) {}
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
@@ -48,7 +56,10 @@ export class BookingWindowService implements OnModuleInit {
); );
} }
@Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE }) // 10-second cadence: every transition is derived from persisted timestamps
// and applied idempotently, so a finer tick only shrinks the lag between a
// deadline passing and the phase actually moving (was a full minute).
@Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
async tick(): Promise<void> { async tick(): Promise<void> {
if (this.ticking) return; if (this.ticking) return;
this.ticking = true; this.ticking = true;
@@ -89,9 +100,10 @@ export class BookingWindowService implements OnModuleInit {
await this.settleOverdueReservations(); await this.settleOverdueReservations();
// Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick. // Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes
// (30 ticks at the 10-second cadence).
this.tickCount += 1; this.tickCount += 1;
if (this.tickCount % 5 === 0) { if (this.tickCount % 30 === 0) {
await this.bookingBatchService.runBatchFill(); await this.bookingBatchService.runBatchFill();
} }
} finally { } finally {
@@ -151,6 +163,9 @@ export class BookingWindowService implements OnModuleInit {
? await this.advanceExport(schedule, now) ? await this.advanceExport(schedule, now)
: await this.advanceImport(schedule, cfg, now); : await this.advanceImport(schedule, cfg, now);
if (!advanced) return; if (!advanced) return;
// Push the new window state to portal home / backoffice GL sections so
// they refresh instantly instead of waiting out their poll interval.
this.gateway.emitPhase(schedule);
} }
} }
@@ -169,7 +184,9 @@ export class BookingWindowService implements OnModuleInit {
await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
schedule.bookingWindowStatus = 'OPEN'; schedule.bookingWindowStatus = 'OPEN';
} }
await this.notifyWindowOpened(schedule); // Fire-and-forget: a slow SMS/email gateway must not stall the tick loop
// (the `ticking` guard would otherwise delay every schedule's transition).
void this.notifyWindowOpened(schedule);
this.logger.log(`Export booking window opened for schedule ${schedule.id}`); this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
return true; return true;
} }
@@ -216,7 +233,8 @@ export class BookingWindowService implements OnModuleInit {
schedule.bookingWindowStatus = 'OPEN'; schedule.bookingWindowStatus = 'OPEN';
} }
// Only announce the first opening of the day; reopen cycles don't re-notify. // Only announce the first opening of the day; reopen cycles don't re-notify.
if (schedule.bookingCycleNo === 1) await this.notifyWindowOpened(schedule); // Fire-and-forget so a slow SMS/email gateway never stalls the tick loop.
if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
this.logger.log( this.logger.log(
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`, `Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
); );
@@ -368,22 +386,26 @@ export class BookingWindowService implements OnModuleInit {
*/ */
private async notifyWindowOpened(schedule: TrainSchedule): Promise<void> { private async notifyWindowOpened(schedule: TrainSchedule): Promise<void> {
try { try {
const rows: Array<{ phone: string | null; email: string | null }> = const rows: Array<{
await this.dataSource.query( company_id: string;
`SELECT DISTINCT phone: string | null;
COALESCE(co.contact_person_phone, co.phone) AS phone, email: string | null;
COALESCE(co.email, co.general_manager_email) AS email }> = await this.dataSource.query(
FROM freight.contract_routes cr `SELECT DISTINCT
JOIN freight.contracts c c.company_id,
ON c.id = cr.contract_id COALESCE(co.contact_person_phone, co.phone) AS phone,
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') COALESCE(co.email, co.general_manager_email) AS email
AND c.deleted_at IS NULL FROM freight.contract_routes cr
JOIN freight.companies co ON co.id = c.company_id JOIN freight.contracts c
WHERE cr.origin_yard_id = $1 ON c.id = cr.contract_id
AND cr.destination_yard_id = $2 AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
AND cr.deleted_at IS NULL`, AND c.deleted_at IS NULL
[schedule.originStationId, schedule.destinationStationId], JOIN freight.companies co ON co.id = c.company_id
); WHERE cr.origin_yard_id = $1
AND cr.destination_yard_id = $2
AND cr.deleted_at IS NULL`,
[schedule.originStationId, schedule.destinationStationId],
);
if (!rows.length) return; if (!rows.length) return;
const closes = schedule.windowClosesAt const closes = schedule.windowClosesAt
@@ -398,6 +420,7 @@ export class BookingWindowService implements OnModuleInit {
const seenPhone = new Set<string>(); const seenPhone = new Set<string>();
const seenEmail = new Set<string>(); const seenEmail = new Set<string>();
const seenCompany = new Set<string>();
for (const r of rows) { for (const r of rows) {
if (r.phone && !seenPhone.has(r.phone)) { if (r.phone && !seenPhone.has(r.phone)) {
seenPhone.add(r.phone); seenPhone.add(r.phone);
@@ -411,9 +434,23 @@ export class BookingWindowService implements OnModuleInit {
.directSend('email', r.email, msg) .directSend('email', r.email, msg)
.catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`)); .catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`));
} }
// In-app inbox item for every portal user of each eligible company,
// deep-linking to the new-booking page.
if (r.company_id && !seenCompany.has(r.company_id)) {
seenCompany.add(r.company_id);
void this.inbox.notify({
recipients: { companyId: r.company_id },
audience: NotificationAudience.PORTAL,
type: NotificationType.SCHEDULE_UPDATE,
title: 'Booking window open',
body: msg,
link: '/bookings/new',
data: { trainScheduleId: schedule.id },
});
}
} }
this.logger.log( this.logger.log(
`Notified ${seenPhone.size} phone / ${seenEmail.size} email contacts of open window for schedule ${schedule.id}`, `Notified ${seenPhone.size} phone / ${seenEmail.size} email / ${seenCompany.size} companies (in-app) of open window for schedule ${schedule.id}`,
); );
} catch (err) { } catch (err) {
this.logger.warn( this.logger.warn(

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
export class AcceptIntercityBookingsDto {
@ApiProperty({
type: [String],
description:
'Waiting intercity booking ids to accept onto this train, in priority order',
})
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
bookingIds!: string[];
}

View File

@@ -59,4 +59,15 @@ export class UpdateScheduleWindowRuleDto {
@IsInt() @IsInt()
@Min(0) @Min(0)
importWindowLeadDays?: number; importWindowLeadDays?: number;
@ApiPropertyOptional({
example: 24,
description:
'Hours before departure the single FCFS export window opens (EXPORT schedules; re-derives the window start)',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
exportBookingLeadHours?: number;
} }

View File

@@ -0,0 +1,367 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingBatchService, type Capacity } from './booking-batch.service';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
/**
* Intercity (DOMESTIC) ride-along: intercity bookings never get their own
* train — they ride a passing import/export schedule whose route milestones
* contain the booking's origin strictly before its destination.
*
* Flow: the customer books a corridor with no date; at finalize time staff see
* every waiting intercity booking whose corridor lies on the schedule's route,
* with its wagon/weight/length need against the train's remaining capacity;
* accepting reserves it (pay window → payment → allocation, same lifecycle as
* a batch reservation). Cargo is loaded manually when the train reaches the
* booking's origin yard and unloaded at its destination yard.
*/
@Injectable()
export class IntercityService {
private readonly logger = new Logger(IntercityService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly bookingBatchService: BookingBatchService,
) {}
/**
* Waiting intercity bookings this schedule could carry, with the train's
* remaining capacity along all three axes (wagons, weight, length) and each
* booking's need, so staff can pick what fits.
*/
async listCandidates(scheduleId: string) {
const schedule = await this.getSchedule(scheduleId);
const milestoneSeq = await this.routeMilestoneSequence(schedule);
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
const waiting = milestoneSeq
? await this.findWaitingIntercityBookings(milestoneSeq)
: [];
const accepted = await this.findAcceptedIntercityBookings(scheduleId);
return {
scheduleId,
routeId: schedule.routeId ?? null,
remaining: capacity?.budget ?? null,
candidates: waiting.map((booking) => {
const need = capacity?.needFor(booking) ?? null;
return {
...this.mapBooking(booking),
need,
fits: need && capacity ? fits(need, capacity.budget) : false,
};
}),
accepted: accepted.map((booking) => ({
...this.mapBooking(booking),
need: capacity?.needFor(booking) ?? null,
})),
};
}
/**
* Accept selected waiting intercity bookings onto this train, in the given
* order, each re-checked against the shrinking capacity budget. Commercial
* bookings open a pay window (payment → allocation runs on the existing
* settle lifecycle); government bookings allocate immediately.
*/
async acceptBookings(scheduleId: string, bookingIds: string[]) {
if (bookingIds.length === 0) {
throw new BadRequestException('Select at least one intercity booking');
}
const schedule = await this.getSchedule(scheduleId);
const milestoneSeq = await this.routeMilestoneSequence(schedule);
if (!milestoneSeq) {
throw new BadRequestException(
'Schedule has no route milestones — cannot serve intercity corridors',
);
}
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
if (!capacity) {
throw new BadRequestException(
'Schedule has no locomotive/train set — capacity unknown',
);
}
const accepted: string[] = [];
const rejected: Array<{ bookingId: string; reason: string }> = [];
let budget = capacity.budget;
for (const bookingId of bookingIds) {
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId }, relations: { bookingContainers: true } });
if (!booking) {
rejected.push({ bookingId, reason: 'Booking not found' });
continue;
}
const notWaiting = this.whyNotWaiting(booking, milestoneSeq);
if (notWaiting) {
rejected.push({ bookingId, reason: notWaiting });
continue;
}
const need = capacity.needFor(booking);
if (!fits(need, budget)) {
rejected.push({
bookingId,
reason: 'Does not fit the remaining wagon/weight/length capacity',
});
continue;
}
await this.bookingBatchService.acceptIntercity(booking, scheduleId);
budget = subtract(budget, need);
accepted.push(bookingId);
this.logger.log(
`Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`,
);
}
return { accepted, rejected, remaining: budget };
}
/**
* Mark an accepted intercity booking's cargo as loaded. Only allowed while
* the train is physically at the booking's origin yard: either it has not
* departed yet and the booking boards at the train's own origin, or the
* latest recorded checkpoint is at the booking's origin yard.
*/
async loadBooking(scheduleId: string, bookingId: string) {
const { schedule, booking } = await this.getAcceptedBooking(
scheduleId,
bookingId,
);
if (booking.status !== 'PAID') {
throw new BadRequestException(
`Booking must be paid before loading (currently ${booking.status})`,
);
}
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
await this.dataSource
.getRepository(Booking)
.update(bookingId, { status: 'IN_TRANSIT' });
return { bookingId, status: 'IN_TRANSIT' as const };
}
/**
* Mark an intercity booking's cargo as unloaded at its destination yard —
* requires the latest checkpoint to be at that yard. Completes the booking.
*/
async unloadBooking(scheduleId: string, bookingId: string) {
const { schedule, booking } = await this.getAcceptedBooking(
scheduleId,
bookingId,
);
if (booking.status !== 'IN_TRANSIT') {
throw new BadRequestException(
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
);
}
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
await this.dataSource
.getRepository(Booking)
.update(bookingId, { status: 'COMPLETED' });
return { bookingId, status: 'COMPLETED' as const };
}
// ---- helpers ---------------------------------------------------------------
private async getSchedule(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: scheduleId } });
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
return schedule;
}
/**
* yardId → sequenceNo for the schedule's route. Falls back to a two-stop
* origin/destination pseudo-route for legacy schedules without a routeId,
* so an intercity booking exactly matching the train's own corridor still
* qualifies.
*/
private async routeMilestoneSequence(
schedule: TrainSchedule,
): Promise<Map<string, number> | null> {
if (schedule.routeId) {
const milestones = await this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
if (milestones.length >= 2) {
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
}
}
if (schedule.originStationId && schedule.destinationStationId) {
return new Map([
[schedule.originStationId, 1],
[schedule.destinationStationId, 2],
]);
}
return null;
}
/** Waiting = ready intercity bookings not yet on any train, corridor on this route. */
private async findWaitingIntercityBookings(
milestoneSeq: Map<string, number>,
): Promise<Booking[]> {
const pool = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)
.andWhere('booking.train_schedule_id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status = 'APPROVED'))`,
)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
return pool.filter((b) => this.corridorOnRoute(b, milestoneSeq));
}
/** Intercity bookings already reserved/allocated on this schedule. */
private async findAcceptedIntercityBookings(
scheduleId: string,
): Promise<Booking[]> {
return this.dataSource
.getRepository(Booking)
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)
.andWhere('booking.train_schedule_id = :scheduleId', { scheduleId })
.orderBy('booking.created_at', 'ASC')
.getMany();
}
private corridorOnRoute(
booking: Booking,
milestoneSeq: Map<string, number>,
): boolean {
const originSeq = milestoneSeq.get(booking.originYardId);
const destinationSeq = milestoneSeq.get(booking.destinationYardId);
return (
originSeq != null && destinationSeq != null && originSeq < destinationSeq
);
}
private whyNotWaiting(
booking: Booking,
milestoneSeq: Map<string, number>,
): string | null {
if (booking.tradeDirection !== 'DOMESTIC') {
return 'Not an intercity booking';
}
if (booking.trainScheduleId) {
return 'Already assigned to a train';
}
const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED';
if (booking.status !== readyStatus) {
return `Not ready to board (status ${booking.status})`;
}
if (!this.corridorOnRoute(booking, milestoneSeq)) {
return "Corridor is not on this schedule's route";
}
return null;
}
private async getAcceptedBooking(scheduleId: string, bookingId: string) {
const schedule = await this.getSchedule(scheduleId);
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (booking.trainScheduleId !== scheduleId) {
throw new BadRequestException('Booking is not assigned to this schedule');
}
if (booking.tradeDirection !== 'DOMESTIC') {
throw new BadRequestException('Not an intercity booking');
}
return { schedule, booking };
}
/**
* The train is "at" a yard when the latest recorded checkpoint is that yard,
* or — for a booking boarding at the train's own origin — when the train has
* not recorded any checkpoint yet (still sitting at its origin).
*/
private async assertTrainAtYard(
schedule: TrainSchedule,
yardId: string,
side: 'origin' | 'destination',
): Promise<void> {
const latest = await this.dataSource
.getRepository(TrainCheckpointEvent)
.findOne({
where: { trainScheduleId: schedule.id },
order: { occurredAt: 'DESC', createdAt: 'DESC' },
});
if (!latest) {
if (side === 'origin' && schedule.originStationId === yardId) return;
throw new BadRequestException(
'Train has not reached this yard yet — record its checkpoint first',
);
}
if (latest.yardId !== yardId) {
throw new BadRequestException(
`Train's last recorded position is not at the booking's ${side} yard`,
);
}
}
private mapBooking(booking: Booking) {
return {
id: booking.id,
reference: booking.reference,
status: booking.status,
freightType: booking.freightType,
isGovernment: booking.isGovernment,
customer: booking.company?.name ?? 'Unknown customer',
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
origin:
booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
destination:
booking.destinationYard?.label ??
booking.destinationYard?.code ??
'Unknown destination',
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
paymentDeadline: booking.paymentDeadline?.toISOString() ?? null,
};
}
}
function fits(need: Capacity, budget: Capacity): boolean {
return (
need.wagons <= budget.wagons &&
need.weightTons <= budget.weightTons &&
need.lengthMeters <= budget.lengthMeters
);
}
function subtract(budget: Capacity, need: Capacity): Capacity {
return {
wagons: budget.wagons - need.wagons,
weightTons: budget.weightTons - need.weightTons,
lengthMeters: budget.lengthMeters - need.lengthMeters,
};
}

View File

@@ -20,6 +20,7 @@ import {
TrainSchedulingManage, TrainSchedulingManage,
TrainSchedulingView, TrainSchedulingView,
} from "../../common/booking-guards"; } from "../../common/booking-guards";
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
import { AssignBookingsDto } from "./dto/assign-bookings.dto"; import { AssignBookingsDto } from "./dto/assign-bookings.dto";
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto"; import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
@@ -47,6 +48,7 @@ import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
import { TrainSchedulingService } from "./train-scheduling.service"; import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service"; import { BookingBatchService } from "./booking-batch.service";
import { BookingWindowService } from "./booking-window.service"; import { BookingWindowService } from "./booking-window.service";
import { IntercityService } from "./intercity.service";
import { BillingService } from "../billing/billing.service"; import { BillingService } from "../billing/billing.service";
@ApiTags("train-scheduling") @ApiTags("train-scheduling")
@@ -57,6 +59,7 @@ export class TrainSchedulingController {
private readonly trainSchedulingService: TrainSchedulingService, private readonly trainSchedulingService: TrainSchedulingService,
private readonly bookingBatchService: BookingBatchService, private readonly bookingBatchService: BookingBatchService,
private readonly bookingWindowService: BookingWindowService, private readonly bookingWindowService: BookingWindowService,
private readonly intercityService: IntercityService,
private readonly billingService: BillingService, private readonly billingService: BillingService,
) { } ) { }
@@ -406,6 +409,54 @@ export class TrainSchedulingController {
return this.trainSchedulingService.dispatchSchedule(id); return this.trainSchedulingService.dispatchSchedule(id);
} }
@Get("schedules/:id/intercity-candidates")
@TrainSchedulingView()
@ApiOperation({
summary:
"Waiting intercity bookings this train could carry (corridor on route) + remaining wagon/weight/length capacity",
})
getIntercityCandidates(@Param("id", ParseUUIDPipe) id: string) {
return this.intercityService.listCandidates(id);
}
@Post("schedules/:id/intercity/accept")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)",
})
acceptIntercityBookings(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AcceptIntercityBookingsDto,
) {
return this.intercityService.acceptBookings(id, dto.bookingIds);
}
@Post("schedules/:id/intercity/:bookingId/load")
@TrainSchedulingManage()
@ApiOperation({
summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)",
})
loadIntercityBooking(
@Param("id", ParseUUIDPipe) id: string,
@Param("bookingId", ParseUUIDPipe) bookingId: string,
) {
return this.intercityService.loadBooking(id, bookingId);
}
@Post("schedules/:id/intercity/:bookingId/unload")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)",
})
unloadIntercityBooking(
@Param("id", ParseUUIDPipe) id: string,
@Param("bookingId", ParseUUIDPipe) bookingId: string,
) {
return this.intercityService.unloadBooking(id, bookingId);
}
@Get("schedules/:id/import-djibouti") @Get("schedules/:id/import-djibouti")
@TrainSchedulingView() @TrainSchedulingView()
@ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" }) @ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" })

View File

@@ -1,5 +1,6 @@
import { Module, forwardRef } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
import { BillingModule } from '../billing/billing.module'; import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module'; import { BookingsModule } from '../bookings/bookings.module';
@@ -25,10 +26,14 @@ import { TrainSchedulingController } from './train-scheduling.controller';
import { TrainSchedulingService } from './train-scheduling.service'; import { TrainSchedulingService } from './train-scheduling.service';
import { BookingBatchService } from './booking-batch.service'; import { BookingBatchService } from './booking-batch.service';
import { BookingNotifierService } from './booking-notifier.service'; import { BookingNotifierService } from './booking-notifier.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { BookingWindowService } from './booking-window.service'; import { BookingWindowService } from './booking-window.service';
import { IntercityService } from './intercity.service';
import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { BookingSplitService } from './booking-split.service'; import { BookingSplitService } from './booking-split.service';
import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { ContractsModule } from '../contracts/contracts.module'; import { ContractsModule } from '../contracts/contracts.module';
@Module({ @Module({
@@ -46,10 +51,13 @@ import { ContractsModule } from '../contracts/contracts.module';
TrainCheckpointEvent, TrainCheckpointEvent,
ImportDjiboutiOperation, ImportDjiboutiOperation,
BookingBatchOffer, BookingBatchOffer,
// WsAuthService (booking-window gateway handshake) verifies IAM sessions.
Session,
]), ]),
forwardRef(() => BookingsModule), forwardRef(() => BookingsModule),
BillingModule, BillingModule,
NotificationsModule, NotificationsModule,
NotificationInboxModule,
LocomotivesModule, LocomotivesModule,
WagonTypesModule, WagonTypesModule,
TrainSetsModule, TrainSetsModule,
@@ -64,9 +72,17 @@ import { ContractsModule } from '../contracts/contracts.module';
TrainCheckpointEventsRepository, TrainCheckpointEventsRepository,
BookingBatchService, BookingBatchService,
BookingNotifierService, BookingNotifierService,
BookingWindowGateway,
WsAuthService,
BookingWindowService, BookingWindowService,
BookingSplitService, BookingSplitService,
IntercityService,
],
exports: [
TrainSchedulingService,
BookingBatchService,
BookingWindowService,
BookingNotifierService,
], ],
exports: [TrainSchedulingService, BookingBatchService, BookingWindowService],
}) })
export class TrainSchedulingModule {} export class TrainSchedulingModule {}

View File

@@ -154,6 +154,7 @@ describe('TrainSchedulingService', () => {
{ {
htmlToPdfBuffer: jest.fn(), htmlToPdfBuffer: jest.fn(),
} as never, } as never,
{ emitPhase: jest.fn() } as never, // bookingWindowGateway
); );
const defaultFleetWagons = [ const defaultFleetWagons = [

View File

@@ -12,6 +12,7 @@ import {
Injectable, Injectable,
Logger, Logger,
NotFoundException, NotFoundException,
Optional,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
@@ -21,6 +22,7 @@ import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity'; import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { Container } from '../container-management/entities/container.entity'; import { Container } from '../container-management/entities/container.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository'; import { LocomotivesRepository } from '../locomotives/locomotives.repository';
@@ -67,6 +69,7 @@ import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduli
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto'; import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto'; import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
import { type BookingWindowConfig } from './booking-window.config'; import { type BookingWindowConfig } from './booking-window.config';
import { BookingWindowGateway } from './booking-window.gateway';
import { import {
buildCappedWagonPlan, buildCappedWagonPlan,
computeFleetAvailability, computeFleetAvailability,
@@ -272,9 +275,63 @@ export class TrainSchedulingService {
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository, private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
private readonly warehouseInventoryService: WarehouseInventoryService, private readonly warehouseInventoryService: WarehouseInventoryService,
private readonly pdfDocuments: WarehouseReleaseDocumentService, private readonly pdfDocuments: WarehouseReleaseDocumentService,
private readonly bookingWindowGateway: BookingWindowGateway,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
private readonly configService?: ConfigService, private readonly configService?: ConfigService,
) {} ) {}
/**
* Complete customer-tracking clearance milestones for every booking on a
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
* unload, gatepass). Uses the doc-trigger path, which is a silent no-op for
* bookings without milestone rows (non-customs bookings), so this is safe to
* call for every direction and flow. Never blocks the operational action.
*/
private async completeMilestonesForScheduleBookings(
scheduleId: string,
codes: string[],
): Promise<void> {
if (!this.milestoneService || codes.length === 0) return;
try {
const rows: Array<{ booking_id: string }> = await this.dataSource.query(
`SELECT tsb.booking_id
FROM freight.train_schedule_bookings tsb
WHERE tsb.train_schedule_id = $1
AND tsb.deleted_at IS NULL`,
[scheduleId],
);
for (const { booking_id } of rows) {
for (const code of codes) {
await this.milestoneService.completeByDocTrigger(
{ bookingId: booking_id },
code,
);
}
}
} catch (err) {
this.logger.warn(
`Milestone completion (${codes.join(', ')}) failed for schedule ${scheduleId}: ${(err as Error).message}`,
);
}
}
/**
* Push a schedule's current booking-window state over the socket so the
* portal home card and backoffice GL/batch views update in real time —
* used for lifecycle changes outside the window tick (create, cancel,
* finalize, restamp). A push failure must never break the mutation.
*/
private async emitWindowState(scheduleId: string): Promise<void> {
try {
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
} catch (err) {
this.logger.warn(
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
);
}
}
async getEligibleBookings(query: GetEligibleBookingsDto) { async getEligibleBookings(query: GetEligibleBookingsDto) {
// Day-level pooling: when the wizard targets a schedule, surface the whole // Day-level pooling: when the wizard targets a schedule, surface the whole
// (route, EAT day) pool — not just bookings pre-pinned to that train — by // (route, EAT day) pool — not just bookings pre-pinned to that train — by
@@ -408,7 +465,9 @@ export class TrainSchedulingService {
schedule.ruleImportWindowLeadDays ?? schedule.ruleImportWindowLeadDays ??
liveCfg.importWindowLeadDays, liveCfg.importWindowLeadDays,
exportBookingLeadHours: exportBookingLeadHours:
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours, dto.exportBookingLeadHours ??
schedule.ruleExportBookingLeadHours ??
liveCfg.exportBookingLeadHours,
windowOpenHour: windowOpenHour:
dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour, dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
windowCloseHour: windowCloseHour:
@@ -432,6 +491,12 @@ export class TrainSchedulingService {
schedule.direction === 'EXPORT' schedule.direction === 'EXPORT'
? computeExportWindowTimes(schedule.scheduledDepartureDate, merged) ? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
: computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now); : computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now);
if (times.windowOpensAt.getTime() >= times.windowClosesAt.getTime()) {
throw new BadRequestException(
'These settings leave no booking window before departure — with the ' +
'desk hours applied, the window would only open once the train has left.',
);
}
await this.dataSource.getRepository(TrainSchedule).update(id, { await this.dataSource.getRepository(TrainSchedule).update(id, {
windowOpensAt: times.windowOpensAt, windowOpensAt: times.windowOpensAt,
@@ -441,6 +506,7 @@ export class TrainSchedulingService {
this.logger.log( this.logger.log(
`Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`, `Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`,
); );
void this.emitWindowState(id);
const fresh = await this.trainSchedulesRepository.findById(id); const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule; return fresh ?? schedule;
@@ -514,6 +580,7 @@ export class TrainSchedulingService {
`Departure date changed for schedule ${id}${departure.toISOString()} ` + `Departure date changed for schedule ${id}${departure.toISOString()} ` +
`(window reopens ${times.windowOpensAt.toISOString()})`, `(window reopens ${times.windowOpensAt.toISOString()})`,
); );
void this.emitWindowState(id);
const fresh = await this.trainSchedulesRepository.findById(id); const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule; return fresh ?? schedule;
@@ -553,6 +620,9 @@ export class TrainSchedulingService {
...windowRuleSnapshot(cfg), ...windowRuleSnapshot(cfg),
}); });
restamped += 1; restamped += 1;
// New times take effect immediately on every card (the tick then opens
// the window within seconds if the re-derived open is already due).
void this.emitWindowState(s.id);
} }
if (restamped > 0) { if (restamped > 0) {
this.logger.log( this.logger.log(
@@ -686,10 +756,9 @@ export class TrainSchedulingService {
lockedLocomotives.push(locked); lockedLocomotives.push(locked);
} }
const direction = deriveScheduleDirection( // Frozen on the route at create/update from the yard-country enum;
route.originYard ?? { country: null }, // getSchedulableRoute already rejected DOMESTIC (intercity).
route.destinationYard ?? { country: null }, const direction = this.resolveRouteDirection(route);
);
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives); const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
// Effective capacity is capped by the weakest locomotive in the set. // Effective capacity is capped by the weakest locomotive in the set.
@@ -757,6 +826,8 @@ export class TrainSchedulingService {
}); });
const created = await this.getTrainScheduleById(createdScheduleId); const created = await this.getTrainScheduleById(createdScheduleId);
// New window announced — portal home / GL cards pick it up immediately.
void this.emitWindowState(createdScheduleId);
return { ...created, warnings: scheduleWarnings }; return { ...created, warnings: scheduleWarnings };
} }
@@ -1088,26 +1159,32 @@ export class TrainSchedulingService {
{ country: schedule.destinationCountry }, { country: schedule.destinationCountry },
); );
if (direction === 'IMPORT') { if (direction === 'IMPORT') {
const result = await this.warehouseInventoryService.autoUnloadArrivedBookings(
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
);
// Customer tracking: cargo is off the train at the destination yard.
void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
return { return {
direction, direction,
action: 'IMPORT_AUTO_UNLOAD', action: 'IMPORT_AUTO_UNLOAD',
status: 'COMPLETED', status: 'COMPLETED',
result: await this.warehouseInventoryService.autoUnloadArrivedBookings( result,
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
),
}; };
} }
if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) { if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
const result = await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
);
// Customer tracking: cargo is off the train at the Djibouti port.
void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
return { return {
direction, direction,
action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD', action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD',
status: 'COMPLETED', status: 'COMPLETED',
result: await this.warehouseInventoryService.autoUnloadExportAtDjibouti( result,
scheduleId,
'SYSTEM_TRAIN_ARRIVAL',
),
}; };
} }
@@ -1336,6 +1413,8 @@ export class TrainSchedulingService {
} }
}); });
// Finalized — push so portal/GL cards reflect the new state instantly.
void this.emitWindowState(scheduleId);
return this.getTrainScheduleById(scheduleId); return this.getTrainScheduleById(scheduleId);
} }
@@ -1406,6 +1485,22 @@ export class TrainSchedulingService {
); );
} }
// Dispatch closed the window — drop it from portal/GL cards right away.
void this.emitWindowState(scheduleId);
// Customer tracking: cargo is on the departing train — loading milestones
// plus the direction's "departed" handoff milestone.
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
void this.completeMilestonesForScheduleBookings(scheduleId, [
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
// doc-trigger path no-ops it for import bookings.
'CARGO_ARRIVED',
'READY_FOR_LOADING',
'LOADED',
schedule.direction === 'IMPORT'
? 'DEPARTED_FROM_DJIBOUTI'
: 'DEPARTED_TO_DJIBOUTI',
]);
}
return this.getTrainScheduleById(scheduleId); return this.getTrainScheduleById(scheduleId);
} }
@@ -1562,6 +1657,13 @@ export class TrainSchedulingService {
LoadingStatus.Loaded, LoadingStatus.Loaded,
); );
} }
// Customer tracking: staff confirmed cargo is on the wagons (CARGO_ARRIVED
// is the export-side "cargo reached origin yard" step that precedes it).
void this.completeMilestonesForScheduleBookings(scheduleId, [
'CARGO_ARRIVED',
'READY_FOR_LOADING',
'LOADED',
]);
return this.getTrainScheduleById(scheduleId); return this.getTrainScheduleById(scheduleId);
} }
@@ -1625,9 +1727,9 @@ export class TrainSchedulingService {
performedBy: 'DOCUMENT_GENERATION', performedBy: 'DOCUMENT_GENERATION',
}); });
const html = this.buildImportLoadListHtml(loadList); const html = this.buildImportLoadListHtml(loadList);
// Generic render — NOT the release-order fallback (would mislabel this as a // Styled table-aware fallback (marshalling grid) when Chromium is unavailable —
// gate-clearance / release order when Chromium is unavailable). // NOT the release-order fallback (would mislabel this as a gate-clearance order).
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list'); const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list');
const reference = loadList.trainNumber ?? loadList.trainScheduleId; const reference = loadList.trainNumber ?? loadList.trainScheduleId;
return { return {
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`, filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
@@ -1645,8 +1747,8 @@ export class TrainSchedulingService {
} }
const html = this.buildExportLoadListHtml(schedule); const html = this.buildExportLoadListHtml(schedule);
// Generic render — NOT the release-order fallback (see importLoadListDocument). // Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list'); const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list');
const reference = schedule.trainNumber ?? schedule.id; const reference = schedule.trainNumber ?? schedule.id;
return { return {
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`, filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
@@ -2115,6 +2217,7 @@ export class TrainSchedulingService {
await this.dataSource await this.dataSource
.getRepository(TrainSchedule) .getRepository(TrainSchedule)
.update(scheduleId, { bookingWindowStatus: status }); .update(scheduleId, { bookingWindowStatus: status });
void this.emitWindowState(scheduleId);
} }
/** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */ /** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */
@@ -2379,6 +2482,13 @@ export class TrainSchedulingService {
} }
}); });
// Customer tracking: the train reached the corridor's far end.
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
void this.completeMilestonesForScheduleBookings(scheduleId, [
schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI',
]);
}
const detail = await this.getTrainScheduleById(scheduleId); const detail = await this.getTrainScheduleById(scheduleId);
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId); const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
return Object.assign(detail, { warehouseAutomation }); return Object.assign(detail, { warehouseAutomation });
@@ -2455,6 +2565,8 @@ export class TrainSchedulingService {
} }
}); });
// Window retired (DONE) — remove the card from portal/GL lists right away.
void this.emitWindowState(id);
return this.getTrainScheduleById(id); return this.getTrainScheduleById(id);
} }
@@ -2751,7 +2863,16 @@ export class TrainSchedulingService {
take: 1, take: 1,
}); });
return rows[0] ?? null; return rows[0] ?? null;
} catch { } catch (err) {
// A read failure here silently downgrades every booking window to the
// hardcoded defaults (desk 817, duration 3h, lead 3) while the settings
// UI keeps showing the saved row — a maddening mismatch. The usual cause
// is a missing column (migrations not run on this database). Scream.
this.logger.error(
`Failed to read train-scheduling global rules — booking windows are ` +
`running on HARDCODED DEFAULTS (817). Run pending migrations. ` +
`Cause: ${(err as Error).message}`,
);
return null; return null;
} }
} }
@@ -3447,9 +3568,27 @@ export class TrainSchedulingService {
`Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`, `Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`,
); );
} }
// Intercity (same-country) service is not offered yet — only import/export
// trains can be scheduled.
if (this.resolveRouteDirection(route) === 'DOMESTIC') {
throw new BadRequestException(
`Route ${formatRouteLabel(route)} is an intercity route; intercity scheduling is not available yet`,
);
}
return route; return route;
} }
/** Stored route direction, deriving from yard countries for pre-migration rows. */
private resolveRouteDirection(route: Route) {
return (
route.direction ??
deriveScheduleDirection(
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
)
);
}
private mapEligibleBooking(booking: Booking) { private mapEligibleBooking(booking: Booking) {
return { return {
id: booking.id, id: booking.id,
@@ -3762,7 +3901,18 @@ export class TrainSchedulingService {
order: { scheduledDepartureDate: 'ASC' }, order: { scheduledDepartureDate: 'ASC' },
}); });
// A train that has already departed can never be booked, even if the window
// engine hasn't yet flipped its bookingWindowStatus off OPEN. Mirror the
// `scheduled_departure_date >= now()` guard the booking-window SQL uses so a
// past-departure schedule never leaks into the portal day pool, the schedule
// calendar, or the ET GL create-booking gate.
const now = new Date();
return schedules return schedules
.filter(
(s) =>
s.scheduledDepartureDate != null &&
s.scheduledDepartureDate > now,
)
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status)) .filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
.filter((s) => { .filter((s) => {
// Build the full stop list: origin -> milestones (ordered) -> destination // Build the full stop list: origin -> milestones (ordered) -> destination
@@ -4054,6 +4204,7 @@ export class TrainSchedulingService {
: null, : null,
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null, reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null, importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
docReviewMinutes: windowCfg.docReviewMinutes, docReviewMinutes: windowCfg.docReviewMinutes,
paymentWindowMinutes: windowCfg.paymentWindowMinutes, paymentWindowMinutes: windowCfg.paymentWindowMinutes,
}, },

View File

@@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator'; import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator';
import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity'; import { FEE_RULE_BASES, FEE_RULE_TYPES, FeeRuleBasis, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
export class FeeRuleTierDto { export class FeeRuleTierDto {
@ApiProperty({ example: 4 }) @ApiProperty({ example: 4 })
@@ -82,11 +82,19 @@ export class CreateFeeRuleDto {
@Min(0) @Min(0)
freeDays!: number; freeDays!: number;
@ApiProperty() @ApiProperty({ description: 'Day-based fees: rate/day. Double handling: flat rate per basis unit.' })
@IsNumber() @IsNumber()
@Min(0) @Min(0)
ratePerDay!: number; ratePerDay!: number;
@ApiPropertyOptional({
enum: FEE_RULE_BASES,
description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.',
})
@IsOptional()
@IsEnum(FEE_RULE_BASES)
basis?: FeeRuleBasis;
@ApiPropertyOptional({ type: [FeeRuleTierDto] }) @ApiPropertyOptional({ type: [FeeRuleTierDto] })
@IsOptional() @IsOptional()
@IsArray() @IsArray()

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const HANDOVER_MILE_TYPES = ['SELF_HAUL', 'EDR_LAST_MILE'] as const;
export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number];
/**
* One import handover. A booking has a single handover when one truck takes the
* whole booking (`truckAssignmentId` null = per-booking), or one per truck when
* multiple trucks are used. Self-haul handovers are generated on truck arrival
* and signed before the truck leaves; EDR last-mile handovers are generated at
* delivery (after exit).
*/
@Entity({ schema: 'freight', name: 'booking_handovers' })
@Index(['bookingId'])
export class BookingHandover extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
/** Customer self-haul truck this handover belongs to; null = per-booking. */
@Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true })
truckAssignmentId?: string | null;
/** Denormalised plate for display / EDR trucks (which aren't customer trucks). */
@Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true })
truckPlate?: string | null;
@Column({ name: 'mile_type', type: 'varchar', length: 20 })
mileType!: HandoverMileType;
@Column({ name: 'reference', type: 'varchar', length: 100 })
reference!: string;
@Column({ name: 'generated_at', type: 'timestamptz', default: () => 'now()' })
generatedAt!: Date;
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
signedAt?: Date | null;
@Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true })
signedByUserId?: string | null;
/** EDR last-mile: when the goods were delivered to the customer. */
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
deliveredAt?: Date | null;
}

View File

@@ -1,9 +1,23 @@
import { BaseEntity } from '@edr/api-common'; import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm'; import { Column, Entity, Index } from 'typeorm';
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const; export const FEE_RULE_TYPES = [
'STORAGE_FEE',
'DEMURRAGE_FEE',
'DOUBLE_HANDLING_FEE',
'TRUCK_DETENTION_FEE',
] as const;
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number]; export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
/**
* Charge basis for a DOUBLE_HANDLING_FEE rule (flat rate × the chosen quantity):
* - PER_CONTAINER: booking container count
* - PER_TON: cargo total in tonnes (bulk cargo)
* - PER_ITEM: cargo total item count (break-bulk cargo, e.g. machinery)
*/
export const FEE_RULE_BASES = ['PER_CONTAINER', 'PER_TON', 'PER_ITEM'] as const;
export type FeeRuleBasis = (typeof FEE_RULE_BASES)[number];
export interface WarehouseFeeTier { export interface WarehouseFeeTier {
fromDay: number; fromDay: number;
toDay: number | null; toDay: number | null;
@@ -60,6 +74,12 @@ export class WarehouseFeeRule extends BaseEntity {
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }) @Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
ratePerDay!: number; ratePerDay!: number;
// Double-handling only: PER_CONTAINER | PER_TON | PER_MACHINERY. The flat rate
// (rate_per_day, reused as rate-per-unit) is multiplied by the basis quantity;
// free days and tiers do not apply. Null for the day-based fee types.
@Column({ name: 'basis', type: 'varchar', length: 20, nullable: true })
basis?: FeeRuleBasis | null;
@Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" }) @Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" })
tiers!: WarehouseFeeTier[]; tiers!: WarehouseFeeTier[];

View File

@@ -0,0 +1,123 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, EntityManager, IsNull } from 'typeorm';
import { BookingHandover } from './entities/booking-handover.entity';
/**
* Import handover records. A booking has one handover per truck (single truck ⇒
* one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type:
* - SELF_HAUL: generated when the customer truck arrives, signed before it leaves.
* - EDR_LAST_MILE: generated at delivery (after exit).
*/
@Injectable()
export class HandoverService {
private readonly logger = new Logger(HandoverService.name);
constructor(private readonly dataSource: DataSource) {}
list(bookingId: string): Promise<BookingHandover[]> {
return this.dataSource.getRepository(BookingHandover).find({
where: { bookingId },
order: { generatedAt: 'ASC' },
});
}
/**
* Self-haul: ensure a handover exists for a customer truck that just arrived.
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
* when a manager is supplied.
*/
async ensureForArrivedTruck(
bookingId: string,
opts: { truckAssignmentId?: string | null; truckPlate?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await repo.findOne({
where: {
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
},
});
if (existing) return existing;
const reference = await this.generateReference(bookingId, m);
const saved = await repo.save(
repo.create({
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'SELF_HAUL',
reference,
generatedAt: new Date(),
}),
);
this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`);
return saved;
}
/**
* EDR last-mile: generate a handover at delivery (after exit). One per EDR
* truck (by plate) or per booking. Idempotent by (booking, plate).
*/
async ensureAtDelivery(
bookingId: string,
opts: { truckPlate?: string | null; truckAssignmentId?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await repo.findOne({
where: {
bookingId,
truckPlate: opts.truckPlate ?? IsNull(),
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
},
});
if (existing) return existing;
const reference = await this.generateReference(bookingId, m);
return repo.save(
repo.create({
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'EDR_LAST_MILE',
reference,
generatedAt: new Date(),
deliveredAt: new Date(),
}),
);
}
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
await this.dataSource
.getRepository(BookingHandover)
.update(
{ bookingId, signedAt: IsNull() },
{ signedAt: new Date(), signedByUserId: userId ?? null },
);
}
/** True when every handover on the booking is signed (and at least one exists). */
async isFullySigned(bookingId: string): Promise<boolean> {
const repo = this.dataSource.getRepository(BookingHandover);
const [total, unsigned] = await Promise.all([
repo.count({ where: { bookingId } }),
repo.count({ where: { bookingId, signedAt: IsNull() } }),
]);
return total > 0 && unsigned === 0;
}
private async generateReference(bookingId: string, manager: EntityManager): Promise<string> {
const [booking] = await manager.query(
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
const ref = String(booking?.reference ?? bookingId).replace(/^BK-?/i, '');
const count = await manager.getRepository(BookingHandover).count({ where: { bookingId } });
return `HND-${ref}-${String(count + 1).padStart(2, '0')}`;
}
}

View File

@@ -3,7 +3,7 @@ import { ExchangeService } from '@edr/api-common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
interface ItemAttributes { interface ItemAttributes {
@@ -16,6 +16,8 @@ interface ItemAttributes {
containerTypeCode: string | null; containerTypeCode: string | null;
inventoryQuantity: number; inventoryQuantity: number;
bookingContainerCount: number; bookingContainerCount: number;
/** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */
cargoQuantity: number;
facilityId: string | null; facilityId: string | null;
warehouseId: string | null; warehouseId: string | null;
yardId: string | null; yardId: string | null;
@@ -24,6 +26,8 @@ interface ItemAttributes {
export interface FeePreview { export interface FeePreview {
ruleType: FeeRuleType; ruleType: FeeRuleType;
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
basis: FeeRuleBasis | null;
ruleId: string | null; ruleId: string | null;
ruleName: string | null; ruleName: string | null;
freeDays: number; freeDays: number;
@@ -132,7 +136,8 @@ export class WarehouseFeeService {
b.trade_direction AS "tradeDirection", b.trade_direction AS "tradeDirection",
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode", COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode", COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount" COALESCE(container_lines.container_count, 0) AS "bookingContainerCount",
COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity"
FROM freight.warehouse_inventory inv FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id
@@ -283,6 +288,10 @@ export class WarehouseFeeService {
now: Date, now: Date,
billingCurrency: string, billingCurrency: string,
): Promise<FeePreview> { ): Promise<FeePreview> {
// Double handling is a flat charge (rate × basis quantity), not day-based.
if (ruleType === 'DOUBLE_HANDLING_FEE') {
return this.computeDoubleHandling(rule, item, now, billingCurrency);
}
const start = item.arrivedAt ? new Date(item.arrivedAt) : null; const start = item.arrivedAt ? new Date(item.arrivedAt) : null;
const endDate = item.gateClearedAt ?? item.releaseDate ?? now; const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
const endIsOpen = !item.gateClearedAt && !item.releaseDate; const endIsOpen = !item.gateClearedAt && !item.releaseDate;
@@ -321,6 +330,7 @@ export class WarehouseFeeService {
return { return {
ruleType, ruleType,
basis: null,
ruleId: rule?.id ?? null, ruleId: rule?.id ?? null,
ruleName: rule?.name ?? null, ruleName: rule?.name ?? null,
freeDays, freeDays,
@@ -340,13 +350,69 @@ export class WarehouseFeeService {
}; };
} }
/**
* Double handling — a flat one-time charge, not time-based. Amount = rate ×
* the basis quantity: PER_CONTAINER (booking container count), or PER_TON /
* PER_ITEM (the booking cargo total in the cargo's unit of measure — tonnes
* for bulk, item count for break-bulk). No free days, no elapsed days, no tiers.
*/
private async computeDoubleHandling(
rule: WarehouseFeeRule | null,
item: ItemAttributes,
now: Date,
billingCurrency: string,
): Promise<FeePreview> {
const basis: FeeRuleBasis = rule?.basis ?? 'PER_CONTAINER';
const rate = Number(rule?.ratePerDay ?? 0);
const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
const targetCurrency = this.normalizeCurrency(billingCurrency);
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
const containerCount = isContainer
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
: 1;
// PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total,
// which is stored in the cargo's own unit of measure.
const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0);
const quantity = basis === 'PER_CONTAINER' ? containerCount : cargoQuantity;
const sourceAmount = Math.round(rate * quantity * 100) / 100;
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0;
return {
ruleType: 'DOUBLE_HANDLING_FEE',
basis,
ruleId: rule?.id ?? null,
ruleName: rule?.name ?? null,
freeDays: 0,
ratePerDay: convertedRate,
currency: targetCurrency,
ruleCurrency,
billingCurrency: targetCurrency,
startDate: null,
endDate: now.toISOString(),
endIsOpen: false,
elapsedDays: 0,
chargeableDays: 0,
containerCount,
billableUnits: quantity,
amount,
tiers: [],
};
}
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */ /** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> { async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
const item = await this.loadItem(inventoryId); const item = await this.loadItem(inventoryId);
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
const now = new Date(); const now = new Date();
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE']; const byType: FeeRuleType[] = [
'DEMURRAGE_FEE',
'STORAGE_FEE',
'DOUBLE_HANDLING_FEE',
'TRUCK_DETENTION_FEE',
];
return Promise.all( return Promise.all(
byType.map((type) => byType.map((type) =>
this.compute( this.compute(

View File

@@ -15,6 +15,7 @@ import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto';
import { SchedulingReadFacade } from './scheduling-read.facade'; import { SchedulingReadFacade } from './scheduling-read.facade';
import { WarehouseInventoryService } from './warehouse-inventory.service'; import { WarehouseInventoryService } from './warehouse-inventory.service';
import { HandoverService } from './handover.service';
@ApiTags('warehouse-inventory') @ApiTags('warehouse-inventory')
@ApiBearerAuth() @ApiBearerAuth()
@@ -23,6 +24,7 @@ export class WarehouseInventoryController {
constructor( constructor(
private readonly inventoryService: WarehouseInventoryService, private readonly inventoryService: WarehouseInventoryService,
private readonly scheduling: SchedulingReadFacade, private readonly scheduling: SchedulingReadFacade,
private readonly handoverService: HandoverService,
) {} ) {}
@Get() @Get()
@@ -98,6 +100,27 @@ export class WarehouseInventoryController {
return this.inventoryService.loadedExport(); return this.inventoryService.loadedExport();
} }
@Get('loadable-trains')
@ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' })
loadableTrains() {
return this.inventoryService.loadableTrains();
}
@Get('train/:scheduleId/loadable-items')
@ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' })
trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.inventoryService.trainLoadableItems(scheduleId);
}
@Post('train/:scheduleId/load')
@ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' })
loadItemsOntoTrain(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Body() dto: { inventoryIds: string[]; performedBy?: string },
) {
return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], dto.performedBy);
}
@Post('bulk-dispatch-export') @Post('bulk-dispatch-export')
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' }) @ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) { bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
@@ -283,6 +306,19 @@ export class WarehouseInventoryController {
return res.send(buffer); return res.send(buffer);
} }
@Get('customer-truck-exit-paper/:assignmentId')
@ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' })
async truckExitPaper(
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@Res() res: Response,
) {
const { filename, buffer } = await this.inventoryService.truckExitPaper(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') @Get(':id/grn-document')
@ApiOperation({ summary: 'View goods received note PDF' }) @ApiOperation({ summary: 'View goods received note PDF' })
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
@@ -312,6 +348,18 @@ export class WarehouseInventoryController {
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub); return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
} }
@Get('bookings/:bookingId/handovers')
@ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' })
bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.handoverService.list(bookingId);
}
@Get('bookings/:bookingId/container-items')
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.containerItems(bookingId);
}
@Post(':id/deliver') @Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {

View File

@@ -38,6 +38,7 @@ import { WarehouseActivityLogService } from './warehouse-activity-log.service';
import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
import { WarehouseLoadingRepository } from './warehouse-loading.repository'; import { WarehouseLoadingRepository } from './warehouse-loading.repository';
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
import { HandoverService } from './handover.service';
/** Wagon states that may receive a load (besides being part of an existing schedule). */ /** Wagon states that may receive a load (besides being part of an existing schedule). */
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED']; const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
@@ -272,6 +273,45 @@ export interface BulkDispatchResult {
results: { inventoryId: string; status: string; reason?: string }[]; results: { inventoryId: string; status: string; reason?: string }[];
} }
/** An EXPORT train (pre-dispatch schedule) that has inventory waiting to be loaded. */
export interface LoadableTrainRow {
scheduleId: string;
trainNumber: string | null;
origin: string | null;
destination: string | null;
status: string;
departureTime: string | Date | null;
/** Received/ready inventory not yet loaded onto this train. */
readyCount: number;
/** Inventory already loaded onto this train. */
loadedCount: number;
}
/** A warehouse-inventory item (container/cargo) assigned to a train, with its allocated wagon. */
export interface TrainLoadableItemRow {
id: string;
bookingId: string | null;
bookingReference: string | null;
customerName: string | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
grnNumber: string | null;
inspectionStatus: string | null;
status: string;
wagonId: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
/** True only when the item is READY_FOR_LOADING and has an allocated wagon. */
loadable: boolean;
}
export interface TrainLoadResult {
loadedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
export interface AutoUnloadArrivedResult { export interface AutoUnloadArrivedResult {
unloadedCount: number; unloadedCount: number;
skippedCount: number; skippedCount: number;
@@ -342,6 +382,7 @@ export class WarehouseInventoryService {
private readonly lastMileService: LastMileService, private readonly lastMileService: LastMileService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly signatures: SignaturesService, private readonly signatures: SignaturesService,
private readonly handover: HandoverService,
) {} ) {}
/** /**
@@ -928,7 +969,7 @@ export class WarehouseInventoryService {
SET received_to_port = true, SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()), received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW() updated_at = NOW()
FROM freight.booking_containers bc FROM freight.booking_container bc
WHERE bc.id = bcu.booking_container_id WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1 AND bc.booking_id = $1
AND bc.deleted_at IS NULL AND bc.deleted_at IS NULL
@@ -1068,6 +1109,169 @@ export class WarehouseInventoryService {
return this.exportInventoryByStatus('LOADED'); return this.exportInventoryByStatus('LOADED');
} }
// ── Per-train loading (Load to Train tab) ─────────────────────────────────
// Loading follows wagon allocation: staff pick an allocated EXPORT train, see
// the arrived containers/cargoes assigned to it, and load the ready ones onto
// their already-allocated wagons. Reuses the single-item load() machinery.
/** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */
async loadableTrains(): Promise<LoadableTrainRow[]> {
const rows: Array<
LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
`SELECT ts.id AS "scheduleId",
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
oy.country AS "originCountry",
dy.country AS "destinationCountry",
ts.status AS "status",
ts.scheduled_departure_date AS "departureTime",
(SELECT count(*) FROM freight.train_schedule_bookings tsb
JOIN freight.warehouse_inventory inv
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount",
(SELECT count(*) FROM freight.train_schedule_bookings tsb
JOIN freight.warehouse_inventory inv
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
AND inv.status = 'LOADED') AS "loadedCount"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status = ANY($1)
AND EXISTS (
SELECT 1 FROM freight.train_schedule_bookings tsb2
JOIN freight.warehouse_inventory inv2
ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL
WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL
AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
)
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
[['DRAFT', 'SCHEDULED']],
);
return rows
.filter(
(r) =>
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'EXPORT',
)
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({
...rest,
readyCount: Number(rest.readyCount) || 0,
loadedCount: Number(rest.loadedCount) || 0,
}));
}
/**
* Container/cargo inventory items assigned to a train, with the wagon each is
* allocated to. Covers the arrived-but-not-loaded set (RECEIVED..READY_FOR_LOADING)
* plus already-LOADED items, so the "Received" and "Loaded" stage tabs both fill.
*/
async trainLoadableItems(scheduleId: string): Promise<TrainLoadableItemRow[]> {
const rows: Array<Omit<TrainLoadableItemRow, 'loadable'>> = await this.dataSource.query(
`SELECT inv.id AS "id",
inv.booking_id AS "bookingId",
b.reference AS "bookingReference",
company.name AS "customerName",
ct.container_number AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber",
inv.inspection_status AS "inspectionStatus",
inv.status AS "status",
wl.wagon_id AS "wagonId",
wl.wagon_number AS "wagonNumber",
wl.sequence_no AS "sequenceNo"
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
LEFT JOIN LATERAL (
SELECT w.id AS wagon_id, w.wagon_number, tsw.sequence_no
FROM freight.wagon_booking_allocations wba
JOIN freight.train_set_wagons tsw
ON tsw.id = wba.train_set_wagon_id
AND tsw.train_set_id = ts.train_set_id
AND tsw.deleted_at IS NULL
JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL
ORDER BY tsw.sequence_no ASC NULLS LAST
LIMIT 1
) wl ON true
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`,
[scheduleId],
);
return rows.map((r) => ({
...r,
loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId),
}));
}
/**
* Load the selected inventory items onto their allocated wagons for the given
* train. Each item must be assigned to this train, READY_FOR_LOADING, and have
* an allocated wagon; others are skipped with a reason. When every inventory
* item of a booking is loaded, its train_schedule_bookings.loading_status flips
* to LOADED so the train's confirm-loading/dispatch step reflects reality.
*/
async loadItemsOntoTrain(
scheduleId: string,
inventoryIds: string[],
performedBy?: string,
): Promise<TrainLoadResult> {
const result: TrainLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };
const items = await this.trainLoadableItems(scheduleId);
const byId = new Map(items.map((i) => [i.id, i]));
const affectedBookingIds = new Set<string>();
for (const inventoryId of inventoryIds) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ inventoryId, status: 'SKIPPED', reason });
};
const item = byId.get(inventoryId);
if (!item) { skip('Not assigned to this train'); continue; }
if (item.status === 'LOADED') { skip('Already loaded'); continue; }
if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; }
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
try {
await this.load(inventoryId, { wagonId: item.wagonId, loadedBy: performedBy });
result.loadedCount += 1;
result.results.push({ inventoryId, status: 'LOADED' });
if (item.bookingId) affectedBookingIds.add(item.bookingId);
} catch (error) {
skip(error instanceof Error ? error.message : 'Load failed');
}
}
// Flip a booking's train loading_status to LOADED once no un-loaded inventory remains.
for (const bookingId of affectedBookingIds) {
await this.dataSource.query(
`UPDATE freight.train_schedule_bookings tsb
SET loading_status = 'LOADED', updated_at = NOW()
WHERE tsb.train_schedule_id = $1 AND tsb.booking_id = $2 AND tsb.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM freight.warehouse_inventory inv
WHERE inv.booking_id = $2 AND inv.deleted_at IS NULL
AND inv.status NOT IN ('LOADED', 'DISPATCHED')
)`,
[scheduleId, bookingId],
);
}
return result;
}
/** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */ /** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */
private async importQueueByStatuses(statuses: string[]): Promise<ImportUnloadedRow[]> { private async importQueueByStatuses(statuses: string[]): Promise<ImportUnloadedRow[]> {
const rows: Array< const rows: Array<
@@ -1799,7 +2003,7 @@ export class WarehouseInventoryService {
SET received_to_port = true, SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()), received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW() updated_at = NOW()
FROM freight.booking_containers bc, freight.containers cont FROM freight.booking_container bc, freight.containers cont
WHERE bc.id = bcu.booking_container_id WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1 AND bc.booking_id = $1
AND bc.deleted_at IS NULL AND bc.deleted_at IS NULL
@@ -2080,9 +2284,14 @@ export class WarehouseInventoryService {
[item.bookingId], [item.bookingId],
); );
const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) { // Self-haul: the handover must be signed before the exit paper is issued.
// Prefer the structured handover record; fall back to the legacy note.
const handoverSigned =
(await this.handover.isFullySigned(item.bookingId)) ||
Boolean(this.extractCustomerDeliveryApproval(item.notes));
if (usesCustomerTruck && !handoverSigned) {
throw new BadRequestException( throw new BadRequestException(
'Customer must approve delivery (sign the handover) before the exit paper can be generated', 'Customer must sign the handover before the exit paper can be generated',
); );
} }
} }
@@ -2137,6 +2346,16 @@ export class WarehouseInventoryService {
AND deleted_at IS NULL`, AND deleted_at IS NULL`,
[item.bookingId], [item.bookingId],
); );
// Self-haul: generate the per-booking handover on first truck arrival
// (idempotent). It must be signed before the truck leaves.
const [selfHaul]: Array<{ ok: number }> = await manager.query(
`SELECT 1 AS ok FROM freight.bookings
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`,
[item.bookingId],
);
if (selfHaul) {
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
}
} }
await this.activityLog.record( await this.activityLog.record(
{ {
@@ -2231,7 +2450,7 @@ export class WarehouseInventoryService {
FROM freight.customer_truck_containers cc FROM freight.customer_truck_containers cc
JOIN freight.booking_container_units bcu JOIN freight.booking_container_units bcu
ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL
JOIN freight.booking_containers bc JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
AND bc.booking_id = c.booking_id AND bc.booking_id = c.booking_id
WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL
@@ -2291,6 +2510,204 @@ export class WarehouseInventoryService {
}; };
} }
/**
* Per-container (or bulk) items of a booking with their lifecycle stage and
* reference sources — drives the container-level detail datatable (stage tabs,
* multiselect load-to-truck, per-item actions).
*/
async containerItems(bookingId: string): Promise<
Array<{
containerNumber: string;
goods: string | null;
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
grnNumber: string | null;
truckAssignmentId: string | null;
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
}>
> {
const rows: Array<{
containerNumber: string;
goods: string | null;
received: boolean;
grnNumber: string | null;
truckAssignmentId: string | null;
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
delivered: boolean;
}> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
bcu.received_to_port AS received,
bcu.grn_number AS "grnNumber",
ctc.assignment_id AS "truckAssignmentId",
a.plate_number AS "truckPlate",
(a.arrived_at IS NOT NULL) AS "truckArrived",
(a.departed_at IS NOT NULL) AS "truckLeft",
b.reference AS "bookingReference",
b.contract_id AS "contractId",
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
COALESCE(inv.status = 'DELIVERED', false) AS delivered
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
JOIN freight.bookings b ON b.id = bc.booking_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN freight.customer_truck_containers ctc
ON ctc.container_number = bcu.container_number
AND ctc.booking_id = b.id AND ctc.deleted_at IS NULL
LEFT JOIN freight.customer_truck_assignments a
ON a.id = ctc.assignment_id AND a.deleted_at IS NULL
LEFT JOIN freight.containers cont ON cont.container_number = bcu.container_number
LEFT JOIN freight.warehouse_inventory inv
ON inv.container_id = cont.id AND inv.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
ORDER BY bcu.container_number`,
[bookingId],
);
return rows.map((r) => ({
containerNumber: r.containerNumber,
goods: r.goods,
stage: r.delivered
? 'DELIVERED'
: r.truckLeft
? 'LEFT'
: r.truckAssignmentId
? 'LOADED'
: r.grnNumber
? 'GRN'
: r.received
? 'RECEIVED'
: 'PENDING',
grnNumber: r.grnNumber,
truckAssignmentId: r.truckAssignmentId,
truckPlate: r.truckPlate,
truckArrived: r.truckArrived,
truckLeft: r.truckLeft,
bookingReference: r.bookingReference,
contractId: r.contractId,
hasLastMile: r.hasLastMile,
}));
}
/**
* Per-truck exit paper: one paper covering the containers loaded on a specific
* customer truck (used when multiple trucks leave separately). Gated on the
* handover being signed and warehouse fees paid.
*/
async truckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> {
const [truck] = await this.dataSource.query(
`SELECT a.booking_id AS "bookingId", a.plate_number AS "plateNumber",
a.driver_name AS "driverName", a.truck_type AS "truckType",
a.gross_weight_kg AS "grossWeightKg", a.departed_at AS "departedAt",
b.reference AS "bookingReference", company.name AS "customerName"
FROM freight.customer_truck_assignments a
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE a.id = $1 AND a.deleted_at IS NULL`,
[assignmentId],
);
if (!truck) throw new NotFoundException(`Truck assignment ${assignmentId} not found`);
if (!(await this.handover.isFullySigned(truck.bookingId))) {
throw new BadRequestException('Handover must be signed before the exit paper can be generated');
}
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);
const containers: Array<{ containerNumber: string; goods: string | null }> =
await this.dataSource.query(
`SELECT c.container_number AS "containerNumber",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods
FROM freight.customer_truck_containers c
JOIN freight.bookings b ON b.id = c.booking_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
WHERE c.assignment_id = $1 AND c.deleted_at IS NULL
ORDER BY c.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;
customerName: string | null;
plateNumber: string;
driverName: string;
truckType: string;
grossWeightKg: number;
gateOut: string | Date | null;
containers: Array<{ containerNumber: string; goods: string | null }>;
}): string {
const esc = (v: unknown) =>
String(v ?? '-').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const gateOut = data.gateOut ? new Date(data.gateOut).toLocaleString('en-GB') : '-';
const rows: Array<[string, string]> = [
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName ?? '-'],
['Pickup Truck Plate', data.plateNumber],
['Driver', data.driverName],
['Truck Type', data.truckType],
['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} kg`],
['Gate-Out Time', gateOut],
['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'],
];
const containerRows = data.containers.length
? data.containers
.map((c) => `<tr><td>${esc(c.containerNumber)}</td><td>${esc(c.goods)}</td></tr>`)
.join('')
: '<tr><td colspan="2">No containers loaded on this truck.</td></tr>';
return `<!doctype html><html><head><meta charset="utf-8" /><title>Warehouse Exit Paper</title>
<style>
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 24px; }
h1 { font-size: 24px; text-transform: uppercase; margin: 0 0 4px; }
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12px; text-align: left; vertical-align: top; }
th { background: #f8fafc; width: 34%; font-weight: 800; }
.section { margin-top: 18px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .1em; }
.ref strong { font-size: 16px; }
</style></head>
<body>
<div style="color:#064c27;font-weight:800;text-transform:uppercase;">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Release / Exit Paper</h1>
<div class="ref">Document / Release No. <strong>${esc(data.reference)}</strong></div>
<div class="section">Release Particulars</div>
<table><tbody>${rows.map(([l, v]) => `<tr><th>${esc(l)}</th><td>${esc(v)}</td></tr>`).join('')}</tbody></table>
<div class="section">Containers Leaving on This Truck</div>
<table><thead><tr><th style="width:40%">Container Number</th><th>Goods</th></tr></thead>
<tbody>${containerRows}</tbody></table>
</body></html>`;
}
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */ /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query( const [row] = await this.dataSource.query(
@@ -2388,7 +2805,17 @@ export class WarehouseInventoryService {
return { return {
filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html), // Styled fallback titled as a GRN (not a release order) for Chromium-less render.
buffer: await this.releaseDocuments.renderStyledDocument(
html,
{
titleLines: ['GOODS RECEIVED', 'NOTE'],
subtitle: 'OFFICIAL WAREHOUSE GOODS RECEIVED NOTE',
sectionTitle: 'RECEIVED PARTICULARS',
refLabel: 'GRN No.',
},
'Goods Received Note',
),
}; };
} }
@@ -2462,6 +2889,10 @@ export class WarehouseInventoryService {
); );
}); });
// Sign the structured handover record(s) for this booking (self-haul: before
// the truck leaves). Kept alongside the legacy approval note.
await this.handover.signForBooking(bookingId, userId);
return { return {
bookingId, bookingId,
inventoryId: item.id, inventoryId: item.id,
@@ -2589,7 +3020,17 @@ export class WarehouseInventoryService {
return { return {
filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html), // Styled fallback titled as a handover (not a release order) for Chromium-less render.
buffer: await this.releaseDocuments.renderStyledDocument(
html,
{
titleLines: ['IMPORT GOODS', 'HANDOVER', 'DOCUMENT'],
subtitle: 'EDR TO CUSTOMER WAREHOUSE HANDOVER',
sectionTitle: 'HANDOVER PARTICULARS',
refLabel: 'Document / Handover No.',
},
'Import Goods Handover',
),
}; };
} }
@@ -2601,6 +3042,29 @@ export class WarehouseInventoryService {
throw new BadRequestException('A release order must be issued before the goods can be delivered'); throw new BadRequestException('A release order must be issued before the goods can be delivered');
} }
// Self-haul: the customer's own truck delivers — deliver only after the
// handover is signed AND the truck has left the warehouse holding the goods.
if (item.bookingId) {
const [sh]: Array<{ assignedAt: string | null }> = await this.dataSource.query(
`SELECT customer_truck_assigned_at AS "assignedAt"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
if (sh?.assignedAt) {
if (!(await this.handover.isFullySigned(item.bookingId))) {
throw new BadRequestException('Handover must be signed before delivery');
}
const [left]: Array<{ n: string }> = await this.dataSource.query(
`SELECT COUNT(*) AS n FROM freight.customer_truck_assignments
WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`,
[item.bookingId],
);
if (Number(left?.n ?? 0) === 0) {
throw new BadRequestException('Deliver is available only after the customer truck has left');
}
}
}
const receiverName = dto.receiverName.trim(); const receiverName = dto.receiverName.trim();
const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date(); const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date();
const weight = Number(item.weight) || 0; const weight = Number(item.weight) || 0;
@@ -2645,6 +3109,27 @@ export class WarehouseInventoryService {
}, },
manager, manager,
); );
// Handover on delivery. EDR last-mile generates its handover HERE (after
// exit, on delivery). Self-haul handovers were generated on arrival —
// stamp them delivered.
if (item.bookingId) {
const [b]: Array<{ selfHaul: string | null }> = await manager.query(
`SELECT customer_truck_assigned_at AS "selfHaul"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
if (b?.selfHaul) {
await manager.query(
`UPDATE freight.booking_handovers
SET delivered_at = COALESCE(delivered_at, NOW()), updated_at = NOW()
WHERE booking_id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
} else {
await this.handover.ensureAtDelivery(item.bookingId, {}, manager);
}
}
}); });
return this.findById(id); return this.findById(id);

View File

@@ -182,25 +182,38 @@ export class WarehouseInvoiceService {
const items = previews const items = previews
.filter((p) => p.amount > 0) .filter((p) => p.amount > 0)
.map((p) => { .map((p) => {
const feeType: WarehouseFeeType = const days = `${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)`;
p.ruleType === "STORAGE_FEE" const tierSuffix = p.tiers.length ? " using tiered tariff" : ` after ${p.freeDays} free`;
? "STORAGE_FEE" let feeType: WarehouseFeeType;
: isContainer let description: string;
? "CONTAINER_DEMURRAGE" switch (p.ruleType) {
: "BULK_DEMURRAGE"; case "STORAGE_FEE":
feeType = "STORAGE_FEE";
description = `Storage fee - ${days}${tierSuffix}`;
break;
case "DOUBLE_HANDLING_FEE": {
feeType = "DOUBLE_HANDLING";
const unit =
p.basis === "PER_TON"
? "ton(s)"
: p.basis === "PER_ITEM"
? "item(s)"
: "container(s)";
description = `Double handling - ${p.billableUnits} ${unit}`;
break;
}
case "TRUCK_DETENTION_FEE":
feeType = "TRUCK_DETENTION";
description = `Truck detention - ${days}${tierSuffix}`;
break;
default:
feeType = isContainer ? "CONTAINER_DEMURRAGE" : "BULK_DEMURRAGE";
description = `${isContainer ? "Container" : "Bulk"} demurrage - ${days}${tierSuffix}`;
}
return { return {
feeRuleId: p.ruleId, feeRuleId: p.ruleId,
feeType, feeType,
description: description,
p.ruleType === "STORAGE_FEE"
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
? " using tiered tariff"
: ` after ${p.freeDays} free`
}`
: `${isContainer ? "Container" : "Bulk"} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
? " using tiered tariff"
: ` after ${p.freeDays} free`
}`,
quantity: p.billableUnits, quantity: p.billableUnits,
unitRate: p.ratePerDay, unitRate: p.ratePerDay,
amount: p.amount, amount: p.amount,

View File

@@ -26,6 +26,8 @@ export const WAREHOUSE_FEE_TYPES = [
'BULK_DEMURRAGE', 'BULK_DEMURRAGE',
'STORAGE_FEE', 'STORAGE_FEE',
'HANDLING_FEE', 'HANDLING_FEE',
'DOUBLE_HANDLING',
'TRUCK_DETENTION',
] as const; ] as const;
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number]; export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PdfRenderService } from '../billing/documents/pdf-render.service'; import { PdfRenderService } from '../billing/documents/pdf-render.service';
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
const MIN_VALID_PDF_BYTES = 2_000; const MIN_VALID_PDF_BYTES = 2_000;
@@ -32,16 +33,51 @@ export class WarehouseReleaseDocumentService {
return this.pdf.htmlToPdfBuffer(html, { label }); return this.pdf.htmlToPdfBuffer(html, { label });
} }
private htmlToBasicPdfBuffer(html: string): Buffer { /**
* Render a "summary tiles + one table + notice + signatures" document (the
* marshalling / load-list layout) with a STYLED table-aware fallback for when
* Chromium is unavailable — so the manifest draws as a real gridded document
* instead of a flat plain-text dump.
*/
renderTabularDocument(html: string, label = 'Document'): Promise<Buffer> {
return this.pdf.htmlToPdfBuffer(html, {
label,
fallback: (preparedHtml) => buildTabularFallbackPdf(preparedHtml),
});
}
/**
* Render document HTML with a STYLED hand-built fallback (the release layout,
* but with a custom title + section heading) for when Chromium is unavailable.
* Handover / GRN use this so their fallback looks like a proper document —
* not a plain-text dump, and not mislabelled as a release order.
*/
renderStyledDocument(
html: string,
fallbackOpts: { titleLines?: string[]; subtitle?: string; sectionTitle?: string; refLabel?: string },
label = 'Document',
): Promise<Buffer> {
return this.pdf.htmlToPdfBuffer(html, {
label,
fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml, fallbackOpts),
});
}
private htmlToBasicPdfBuffer(
html: string,
opts?: { titleLines?: string[]; subtitle?: string; sectionTitle?: string; refLabel?: string },
): Buffer {
const doc = this.extractReleaseDocument(html); const doc = this.extractReleaseDocument(html);
const titleLines = (opts?.titleLines ?? ['WAREHOUSE GATE', 'CLEARANCE / RELEASE', 'ORDER']).slice(0, 3);
const subtitle = opts?.subtitle ?? 'OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION';
const sectionTitle = opts?.sectionTitle ?? 'RELEASE PARTICULARS';
const refLabel = opts?.refLabel ?? 'Document / Release No.';
const body: string[] = [ const body: string[] = [
this.lineOp(36, 810, 559, 810, '0 0 0', 2.2), this.lineOp(36, 810, 559, 810, '0 0 0', 2.2),
this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'), this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'),
this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'), ...titleLines.map((line, i) => this.textOp(line, 36, 764 - i * 22, 24, 'F2', '0.02 0.08 0.16')),
this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'), this.textOp(subtitle, 36, 764 - titleLines.length * 22 + 2, 8.5, 'F1', '0.25 0.34 0.45'),
this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'), this.textOp(refLabel, 424, 781, 8.5, 'F1', '0.15 0.22 0.32'),
this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'),
this.textOp('Document / Release No.', 424, 781, 8.5, 'F1', '0.15 0.22 0.32'),
this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'), this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'),
this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'), this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'),
this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2), this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2),
@@ -50,7 +86,7 @@ export class WarehouseReleaseDocumentService {
...this.wrapLines(doc.notice, 68) ...this.wrapLines(doc.notice, 68)
.slice(0, 4) .slice(0, 4)
.map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')), .map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')),
this.textOp('RELEASE PARTICULARS', 36, 604, 10, 'F2', '0.08 0.32 0.18'), this.textOp(sectionTitle, 36, 604, 10, 'F2', '0.08 0.32 0.18'),
]; ];
let y = 586; let y = 586;

View File

@@ -15,6 +15,8 @@ import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.en
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity';
import { BookingHandover } from './entities/booking-handover.entity';
import { HandoverService } from './handover.service';
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
import { WarehouseLoading } from './entities/warehouse-loading.entity'; import { WarehouseLoading } from './entities/warehouse-loading.entity';
import { WarehouseYard } from './entities/warehouse-yard.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity';
@@ -65,6 +67,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseInspectionReport, WarehouseInspectionReport,
WarehouseAllocationRule, WarehouseAllocationRule,
WarehouseFeeRule, WarehouseFeeRule,
BookingHandover,
]), ]),
BillingModule, BillingModule,
DocumentsModule, DocumentsModule,
@@ -113,6 +116,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseSchedulingAdapterService, WarehouseSchedulingAdapterService,
WarehouseReleaseDocumentService, WarehouseReleaseDocumentService,
SchedulingReadFacade, SchedulingReadFacade,
HandoverService,
], ],
exports: [ exports: [
WarehousesService, WarehousesService,

View File

@@ -20,8 +20,8 @@ const COMPANY_TIN = 'FLMDEMO001';
const COMPANY_EMAIL = 'first-last-mile-demo@edr.local'; const COMPANY_EMAIL = 'first-last-mile-demo@edr.local';
const YARDS = [ const YARDS = [
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 },
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 },
]; ];
const CONTAINER_TYPES = [ const CONTAINER_TYPES = [

View File

@@ -28,17 +28,22 @@ const COMPANY_EMAIL = "train-scheduling-demo@edr.local";
const COMPANY_TIN = "1234567890"; const COMPANY_TIN = "1234567890";
const YARDS = [ const YARDS = [
{ code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 }, {
code: "DJIBOUTI",
label: "Djibouti",
country: "Djibouti" as const,
displayOrder: 1,
},
{ {
code: "ADDIS_ABABA", code: "ADDIS_ABABA",
label: "Addis Ababa", label: "Addis Ababa",
country: "Ethiopia", country: "Ethiopia" as const,
displayOrder: 2, displayOrder: 2,
}, },
{ {
code: "DIRE_DAWA", code: "DIRE_DAWA",
label: "Dire Dawa", label: "Dire Dawa",
country: "Ethiopia", country: "Ethiopia" as const,
displayOrder: 3, displayOrder: 3,
}, },
]; ];

View File

@@ -1,30 +1,20 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { import {
Application,
Organization, Organization,
OrganizationConfiguration, OrganizationConfiguration,
Permission, Permission,
Position,
PositionPermission,
PositionType,
Role,
RolePermission,
Unit, Unit,
} from "@tria-plc/iamapi-common"; } from "@tria-plc/iamapi-common";
import { DataSource, EntityManager, In } from "typeorm"; import { DataSource, EntityManager } from "typeorm";
import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum";
import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry";
import { import {
EDR_FREIGHT_POSITIONS, EDR_FREIGHT_APPLICATION,
EDR_FREIGHT_ROLES, EDR_FREIGHT_PERMISSIONS,
type FreightSeedPosition,
type FreightSeedRole,
} from "./edr-freight.seed"; } from "./edr-freight.seed";
const EDR_UNIT_KEY = "edr_freight_hq"; const EDR_UNIT_KEY = "edr_freight_app";
const EDR_UNIT_NAME = { en: "EDR Freight HQ" }; const EDR_UNIT_NAME = { en: "EDR Freight App" };
const EDR_POSITION_TYPE_KEY = "edr_freight_role";
const EDR_POSITION_TYPE_NAME = { en: "EDR Freight Role" };
const EDR_ORG_KEY = "edr_freight"; const EDR_ORG_KEY = "edr_freight";
const EDR_ORG_NAME = { en: "EDR Freight" }; const EDR_ORG_NAME = { en: "EDR Freight" };
@@ -51,22 +41,15 @@ export class EdrOrgSeeder {
const organization = await this.ensureOrganization(manager); const organization = await this.ensureOrganization(manager);
await this.ensureOrganizationConfiguration(manager, organization.id); await this.ensureOrganizationConfiguration(manager, organization.id);
await this.ensureRoles(manager, EDR_FREIGHT_ROLES); await this.ensureDefaultUnit(manager, organization.id);
await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES);
await this.ensureSuperAdminPermissions(manager);
// Positions-as-roles: seed operational positions and grant their const application = await this.ensureApplication(manager);
// permissions via PositionPermission (not Role/RolePermission). await this.ensurePermissions(manager, application.id);
const unit = await this.ensureDefaultUnit(manager, organization.id);
const positionType = await this.ensureDefaultPositionType(manager, unit.id); // Roles, positions and their permission links are intentionally NOT
await this.ensurePositions( // seeded for now — only the application-scoped permission catalog,
manager, // mirroring how the default IAM seed relates permissions to their
organization.id, // application. Grants are assigned later through the IAM UI.
unit.id,
positionType.id,
EDR_FREIGHT_POSITIONS,
);
await this.ensurePositionPermissions(manager, unit.id, EDR_FREIGHT_POSITIONS);
}); });
this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`);
@@ -128,111 +111,6 @@ export class EdrOrgSeeder {
); );
} }
private async ensureRoles(manager: EntityManager, seedRoles: FreightSeedRole[]) {
await manager.getRepository(Role).upsert(
seedRoles.map(({ key, name }) => ({ key, name })),
{
conflictPaths: { key: true },
},
);
this.logger.log(
`Ensured EDR roles '${seedRoles.map((role) => role.key).join("', '")}'`,
);
}
private async ensureRolePermissions(
manager: EntityManager,
seedRoles: FreightSeedRole[],
) {
const permissionKeys = [...new Set(seedRoles.flatMap((role) => role.permissionKeys))];
if (!permissionKeys.length) {
this.logger.log("No EDR role permissions configured; skipping role-permission links");
return;
}
const roleRepository = manager.getRepository(Role);
const rolePermissionRepository = manager.getRepository(RolePermission);
const roles = await roleRepository.find({
where: { key: In(seedRoles.map((role) => role.key)) },
select: { id: true, key: true },
});
const seededPermissions = await manager.getRepository(Permission).find({
where: { key: In(permissionKeys) },
select: { id: true, key: true },
});
const roleByKey = new Map(roles.map((role) => [role.key, role]));
const permissionByKey = new Map(
seededPermissions.map((permission) => [permission.key, permission]),
);
const rolePermissions = seedRoles.flatMap((role) => {
const seededRole = roleByKey.get(role.key);
if (!seededRole) {
throw new Error(`missing_role:${role.key}`);
}
return role.permissionKeys.map((permissionKey) => {
const seededPermission = permissionByKey.get(permissionKey);
if (!seededPermission) {
throw new Error(`missing_permission:${permissionKey}`);
}
return {
roleId: seededRole.id,
permissionId: seededPermission.id,
};
});
});
await rolePermissionRepository.upsert(rolePermissions, {
conflictPaths: { roleId: true, permissionId: true },
});
this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`);
}
private async ensureSuperAdminPermissions(manager: EntityManager) {
const role = await manager.getRepository(Role).findOne({
where: { key: ERoleKey.SUPER_ADMIN },
select: { id: true, key: true },
});
if (!role) {
this.logger.warn(
`Role ${ERoleKey.SUPER_ADMIN} not found; skipping booking/rule-engine super_admin links`,
);
return;
}
const permissions = await manager.getRepository(Permission).find({
where: { key: In(BOOKING_RULE_ENGINE_PERMISSION_KEYS) },
select: { id: true, key: true },
});
if (!permissions.length) {
this.logger.warn('No booking/rule-engine permissions found for super_admin');
return;
}
await manager.getRepository(RolePermission).upsert(
permissions.map((permission) => ({
roleId: role.id,
permissionId: permission.id,
})),
{ conflictPaths: { roleId: true, permissionId: true } },
);
this.logger.log(
`Ensured ${permissions.length} booking+rule-engine permissions on super_admin`,
);
}
private async ensureDefaultUnit( private async ensureDefaultUnit(
manager: EntityManager, manager: EntityManager,
organizationId: string, organizationId: string,
@@ -258,120 +136,51 @@ export class EdrOrgSeeder {
return { id: unit.id }; return { id: unit.id };
} }
private async ensureDefaultPositionType( private async ensureApplication(
manager: EntityManager, manager: EntityManager,
unitId: string,
): Promise<{ id: string }> { ): Promise<{ id: string }> {
const positionTypeRepository = manager.getRepository(PositionType); const applicationRepository = manager.getRepository(Application);
// PositionType has no unique constraint on (key, unitId); find-then-insert. const application = await applicationRepository.findOne({
let positionType = await positionTypeRepository.findOne({ where: { key: EDR_FREIGHT_APPLICATION.key },
where: { key: EDR_POSITION_TYPE_KEY, unitId },
select: { id: true }, select: { id: true },
}); });
if (!positionType) { if (!application?.id) {
const insertResult = await positionTypeRepository.insert({ const insertResult = await applicationRepository.insert({
key: EDR_POSITION_TYPE_KEY, id: EDR_FREIGHT_APPLICATION.id,
name: EDR_POSITION_TYPE_NAME, key: EDR_FREIGHT_APPLICATION.key,
isSystem: true, name: { ...EDR_FREIGHT_APPLICATION.name },
unitId,
}); });
this.logger.log(`Seeded EDR position type '${EDR_POSITION_TYPE_KEY}'`); this.logger.log(`Seeded EDR application '${EDR_FREIGHT_APPLICATION.key}'`);
return { id: insertResult.identifiers[0]?.id as string }; return { id: insertResult.identifiers[0]?.id as string };
} }
this.logger.log(`Ensured EDR position type '${EDR_POSITION_TYPE_KEY}'`); this.logger.log(`Ensured EDR application '${EDR_FREIGHT_APPLICATION.key}'`);
return { id: positionType.id }; return { id: application.id };
} }
private async ensurePositions( private async ensurePermissions(
manager: EntityManager, manager: EntityManager,
organizationId: string, applicationId: string,
unitId: string,
positionTypeId: string,
seedPositions: FreightSeedPosition[],
) { ) {
await manager.getRepository(Position).upsert( const permissionRepository = manager.getRepository(Permission);
seedPositions.map(({ key, name, rank }) => ({
key, // Upsert by key so reruns are idempotent; applicationId ties every
name, // permission to the EDR Freight application (also backfills rows that
rank, // were previously seeded without the relation).
organizationId, await permissionRepository.upsert(
unitId, EDR_FREIGHT_PERMISSIONS.map((permission) => ({
positionTypeId, id: permission.id,
key: permission.key,
name: { ...permission.name },
applicationId,
})), })),
{ { conflictPaths: { key: true } },
conflictPaths: { key: true, unitId: true },
},
); );
this.logger.log( this.logger.log(
`Ensured ${seedPositions.length} EDR positions '${seedPositions `Ensured ${EDR_FREIGHT_PERMISSIONS.length} permissions on application '${EDR_FREIGHT_APPLICATION.key}'`,
.map((position) => position.key)
.join("', '")}'`,
);
}
private async ensurePositionPermissions(
manager: EntityManager,
unitId: string,
seedPositions: FreightSeedPosition[],
) {
const permissionKeys = [
...new Set(seedPositions.flatMap((position) => position.permissionKeys)),
];
if (!permissionKeys.length) {
this.logger.log(
"No EDR position permissions configured; skipping position-permission links",
);
return;
}
const positions = await manager.getRepository(Position).find({
where: { key: In(seedPositions.map((position) => position.key)), unitId },
select: { id: true, key: true },
});
const seededPermissions = await manager.getRepository(Permission).find({
where: { key: In(permissionKeys) },
select: { id: true, key: true },
});
const positionByKey = new Map(
positions.map((position) => [position.key, position]),
);
const permissionByKey = new Map(
seededPermissions.map((permission) => [permission.key, permission]),
);
const positionPermissions = seedPositions.flatMap((position) => {
const seededPosition = positionByKey.get(position.key);
if (!seededPosition) {
throw new Error(`missing_position:${position.key}`);
}
return position.permissionKeys.map((permissionKey) => {
const seededPermission = permissionByKey.get(permissionKey);
if (!seededPermission) {
throw new Error(`missing_permission:${permissionKey}`);
}
return {
positionId: seededPosition.id as string,
permissionId: seededPermission.id,
};
});
});
await manager.getRepository(PositionPermission).upsert(positionPermissions, {
conflictPaths: { positionId: true, permissionId: true },
});
this.logger.log(
`Ensured ${positionPermissions.length} EDR position-permission links`,
); );
} }
} }

View File

@@ -60,6 +60,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'), perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'), perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'), perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
perm('a1000001-0001-4000-8000-000000000024', 'edr_freight_app:bookings:create', 'Create booking'),
]; ];
/** /**
@@ -117,11 +118,209 @@ export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [
perm('c1000001-0001-4000-8000-000000000001', 'edr_freight_app:allocation:manage', 'Allocate containers to vehicles'), perm('c1000001-0001-4000-8000-000000000001', 'edr_freight_app:allocation:manage', 'Allocate containers to vehicles'),
]; ];
/**
* Advanced backoffice resources — full CRUD + workflow-action keys.
* See docs/rbac/freight-backoffice-permissions.md. Additive only: the existing
* bookings/contracts/rule-engine/allocation keys above are unchanged.
*/
// C. Customers
export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [
perm('d1a00001-0001-4000-8000-000000000001', 'edr_freight_app:customers:view', 'View customers'),
perm('d1a00001-0001-4000-8000-000000000002', 'edr_freight_app:customers:create', 'Create customer'),
perm('d1a00001-0001-4000-8000-000000000003', 'edr_freight_app:customers:update', 'Update customer'),
perm('d1a00001-0001-4000-8000-000000000004', 'edr_freight_app:customers:deactivate', 'Deactivate customer'),
perm('d1a00001-0001-4000-8000-000000000005', 'edr_freight_app:customers:verify', 'Verify customer (KYC/Fayda)'),
];
// D. Finance — payments + invoices
export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
perm('d2a00001-0001-4000-8000-000000000001', 'edr_freight_app:payments:view', 'View payments'),
perm('d2a00001-0001-4000-8000-000000000002', 'edr_freight_app:payments:verify', 'Verify/settle payment'),
perm('d2a00001-0001-4000-8000-000000000003', 'edr_freight_app:payments:refund', 'Refund payment'),
perm('d2b00001-0001-4000-8000-000000000001', 'edr_freight_app:invoices:view', 'View invoices'),
perm('d2b00001-0001-4000-8000-000000000002', 'edr_freight_app:invoices:create', 'Generate invoice'),
perm('d2b00001-0001-4000-8000-000000000003', 'edr_freight_app:invoices:cancel', 'Cancel invoice'),
perm('d2b00001-0001-4000-8000-000000000004', 'edr_freight_app:invoices:export', 'Download invoice document'),
];
// E. First / last mile operations
export const MILE_PERMISSIONS: FreightPermissionSeed[] = [
perm('d3a00001-0001-4000-8000-000000000001', 'edr_freight_app:first_mile:view', 'View first-mile'),
perm('d3a00001-0001-4000-8000-000000000002', 'edr_freight_app:first_mile:accept', 'Accept first-mile request'),
perm('d3a00001-0001-4000-8000-000000000003', 'edr_freight_app:first_mile:create', 'Create first-mile'),
perm('d3a00001-0001-4000-8000-000000000004', 'edr_freight_app:first_mile:update', 'Update first-mile'),
perm('d3a00001-0001-4000-8000-000000000005', 'edr_freight_app:first_mile:delete', 'Delete first-mile'),
perm('d3a00001-0001-4000-8000-000000000006', 'edr_freight_app:first_mile:assign_vehicles', 'Assign first-mile vehicles'),
perm('d3a00001-0001-4000-8000-000000000007', 'edr_freight_app:first_mile:set_distances', 'Set first-mile distances'),
perm('d3a00001-0001-4000-8000-000000000008', 'edr_freight_app:first_mile:generate_invoice', 'Generate first-mile invoice'),
perm('d3b00001-0001-4000-8000-000000000001', 'edr_freight_app:last_mile:view', 'View last-mile'),
perm('d3b00001-0001-4000-8000-000000000002', 'edr_freight_app:last_mile:accept', 'Accept last-mile request'),
perm('d3b00001-0001-4000-8000-000000000003', 'edr_freight_app:last_mile:create', 'Create last-mile'),
perm('d3b00001-0001-4000-8000-000000000004', 'edr_freight_app:last_mile:update', 'Update last-mile'),
perm('d3b00001-0001-4000-8000-000000000005', 'edr_freight_app:last_mile:delete', 'Delete last-mile'),
perm('d3b00001-0001-4000-8000-000000000006', 'edr_freight_app:last_mile:assign_vehicles', 'Assign last-mile vehicles'),
perm('d3b00001-0001-4000-8000-000000000007', 'edr_freight_app:last_mile:set_distances', 'Set last-mile distances'),
perm('d3b00001-0001-4000-8000-000000000008', 'edr_freight_app:last_mile:generate_invoice', 'Generate last-mile invoice'),
];
// F. Fleet — rail assets (splits the flat fleet:view/manage)
export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
perm('e1a00001-0001-4000-8000-000000000001', 'edr_freight_app:locomotives:view', 'View locomotives'),
perm('e1a00001-0001-4000-8000-000000000002', 'edr_freight_app:locomotives:create', 'Create locomotive'),
perm('e1a00001-0001-4000-8000-000000000003', 'edr_freight_app:locomotives:update', 'Update locomotive'),
perm('e1a00001-0001-4000-8000-000000000004', 'edr_freight_app:locomotives:delete', 'Delete locomotive'),
perm('e1b00001-0001-4000-8000-000000000001', 'edr_freight_app:wagons:view', 'View wagons'),
perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'),
perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'),
perm('e1b00001-0001-4000-8000-000000000004', 'edr_freight_app:wagons:delete', 'Delete wagon'),
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
perm('e1c00001-0001-4000-8000-000000000004', 'edr_freight_app:trains:delete', 'Delete train'),
perm('e1c00001-0001-4000-8000-000000000005', 'edr_freight_app:trains:assign_wagons', 'Assign wagons to train'),
perm('e1d00001-0001-4000-8000-000000000001', 'edr_freight_app:routes:view', 'View routes'),
perm('e1d00001-0001-4000-8000-000000000002', 'edr_freight_app:routes:create', 'Create route'),
perm('e1d00001-0001-4000-8000-000000000003', 'edr_freight_app:routes:update', 'Update route'),
perm('e1d00001-0001-4000-8000-000000000004', 'edr_freight_app:routes:delete', 'Delete route'),
perm('e1e00001-0001-4000-8000-000000000001', 'edr_freight_app:containers:view', 'View containers'),
perm('e1e00001-0001-4000-8000-000000000002', 'edr_freight_app:containers:create', 'Create container'),
perm('e1e00001-0001-4000-8000-000000000003', 'edr_freight_app:containers:update', 'Update container'),
perm('e1e00001-0001-4000-8000-000000000004', 'edr_freight_app:containers:delete', 'Delete container'),
perm('e1f00001-0001-4000-8000-000000000001', 'edr_freight_app:cargoes:view', 'View cargoes'),
perm('e1f00001-0001-4000-8000-000000000002', 'edr_freight_app:cargoes:create', 'Create cargo'),
perm('e1f00001-0001-4000-8000-000000000003', 'edr_freight_app:cargoes:update', 'Update cargo'),
perm('e1f00001-0001-4000-8000-000000000004', 'edr_freight_app:cargoes:delete', 'Delete cargo'),
];
// G. Fleet — road & telemetry
export const FLEET_ROAD_PERMISSIONS: FreightPermissionSeed[] = [
perm('e2a00001-0001-4000-8000-000000000001', 'edr_freight_app:vehicles:view', 'View vehicles'),
perm('e2a00001-0001-4000-8000-000000000002', 'edr_freight_app:vehicles:create', 'Create vehicle'),
perm('e2a00001-0001-4000-8000-000000000003', 'edr_freight_app:vehicles:update', 'Update vehicle'),
perm('e2a00001-0001-4000-8000-000000000004', 'edr_freight_app:vehicles:delete', 'Delete vehicle'),
perm('e2b00001-0001-4000-8000-000000000001', 'edr_freight_app:drivers:view', 'View drivers'),
perm('e2b00001-0001-4000-8000-000000000002', 'edr_freight_app:drivers:create', 'Create driver'),
perm('e2b00001-0001-4000-8000-000000000003', 'edr_freight_app:drivers:update', 'Update driver'),
perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete driver'),
perm('e2c00001-0001-4000-8000-000000000001', 'edr_freight_app:tracking:view', 'Track vehicles'),
perm('e2d00001-0001-4000-8000-000000000001', 'edr_freight_app:fuel:view', 'View fuel purchases'),
perm('e2d00001-0001-4000-8000-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'),
perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update fuel purchase'),
perm('e2d00001-0001-4000-8000-000000000004', 'edr_freight_app:fuel:delete', 'Delete fuel purchase'),
perm('e2d00001-0001-4000-8000-000000000005', 'edr_freight_app:fuel:approve', 'Approve fuel purchase'),
perm('e2e00001-0001-4000-8000-000000000001', 'edr_freight_app:maintenance:view', 'View maintenance'),
perm('e2e00001-0001-4000-8000-000000000002', 'edr_freight_app:maintenance:create', 'Create maintenance'),
perm('e2e00001-0001-4000-8000-000000000003', 'edr_freight_app:maintenance:update', 'Update maintenance'),
perm('e2e00001-0001-4000-8000-000000000004', 'edr_freight_app:maintenance:delete', 'Delete maintenance'),
perm('e2e00001-0001-4000-8000-000000000005', 'edr_freight_app:maintenance:complete', 'Complete maintenance'),
perm('e2f00001-0001-4000-8000-000000000001', 'edr_freight_app:fleet_reports:view', 'View fleet financial reports'),
perm('e2f00001-0001-4000-8000-000000000002', 'edr_freight_app:fleet_reports:export', 'Export fleet financial reports'),
perm('e2000001-0001-4000-8000-000000000001', 'edr_freight_app:fleet_dashboard:view', 'View fleet dashboard'),
];
// H. Warehouse management
export const WAREHOUSE_PERMISSIONS: FreightPermissionSeed[] = [
perm('f1000001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_dashboard:view', 'View warehouse dashboard'),
perm('f1a00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouses:view', 'View warehouses'),
perm('f1a00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouses:create', 'Create warehouse'),
perm('f1a00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouses:update', 'Update warehouse'),
perm('f1a00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouses:delete', 'Delete warehouse'),
perm('f1b00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_yards:view', 'View warehouse yards'),
perm('f1b00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_yards:create', 'Create warehouse yard'),
perm('f1b00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_yards:update', 'Update warehouse yard'),
perm('f1b00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_yards:delete', 'Delete warehouse yard'),
perm('f1c00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_zones:view', 'View warehouse zones'),
perm('f1c00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_zones:create', 'Create warehouse zone'),
perm('f1c00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_zones:update', 'Update warehouse zone'),
perm('f1d00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_allocation_rules:view', 'View allocation rules'),
perm('f1d00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_allocation_rules:create', 'Create allocation rule'),
perm('f1d00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_allocation_rules:update', 'Update allocation rule'),
perm('f1d00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_allocation_rules:delete', 'Delete allocation rule'),
perm('f1e00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_fee_rules:view', 'View fee rules'),
perm('f1e00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_fee_rules:create', 'Create fee rule'),
perm('f1e00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_fee_rules:update', 'Update fee rule'),
perm('f1e00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_fee_rules:delete', 'Delete fee rule'),
perm('f1f00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_inspection_reports:view', 'View inspection reports'),
perm('f1f00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_inspection_reports:create', 'Create inspection report'),
perm('f1f00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_inspection_reports:update', 'Update inspection report'),
];
// I. Port & terminal — inventory movement + interchange + fee invoices
export const PORT_TERMINAL_PERMISSIONS: FreightPermissionSeed[] = [
perm('f2a00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_inventory:view', 'View terminal inventory'),
perm('f2a00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_inventory:receive', 'Receive inventory'),
perm('f2a00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_inventory:move', 'Move/store/reserve inventory'),
perm('f2a00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_inventory:load', 'Load inventory'),
perm('f2a00001-0001-4000-8000-000000000005', 'edr_freight_app:warehouse_inventory:unload', 'Unload inventory'),
perm('f2a00001-0001-4000-8000-000000000006', 'edr_freight_app:warehouse_inventory:dispatch', 'Dispatch inventory'),
perm('f2a00001-0001-4000-8000-000000000007', 'edr_freight_app:warehouse_inventory:gate_pass', 'Gate-clearance inventory'),
perm('f2a00001-0001-4000-8000-000000000008', 'edr_freight_app:warehouse_inventory:release', 'Release inventory'),
perm('f2a00001-0001-4000-8000-000000000009', 'edr_freight_app:warehouse_inventory:deliver', 'Deliver inventory'),
perm('f2a00001-0001-4000-8000-00000000000a', 'edr_freight_app:warehouse_inventory:inspect', 'Inspect inventory'),
perm('f2b00001-0001-4000-8000-000000000001', 'edr_freight_app:interchange_documents:view', 'View interchange documents'),
perm('f2b00001-0001-4000-8000-000000000002', 'edr_freight_app:interchange_documents:generate', 'Generate interchange document'),
perm('f2b00001-0001-4000-8000-000000000003', 'edr_freight_app:interchange_documents:acknowledge', 'Acknowledge interchange document'),
perm('f2b00001-0001-4000-8000-000000000004', 'edr_freight_app:interchange_documents:dispute', 'Dispute interchange document'),
perm('f2b00001-0001-4000-8000-000000000005', 'edr_freight_app:interchange_documents:cancel', 'Cancel interchange document'),
perm('f2c00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_fee_invoices:view', 'View warehouse fee invoices'),
perm('f2c00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_fee_invoices:generate', 'Generate warehouse fee invoice'),
perm('f2c00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_fee_invoices:cancel', 'Cancel warehouse fee invoice'),
perm('f2c00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_fee_invoices:pay', 'Pay warehouse fee invoice'),
];
// E'. Train-scheduling finer actions (augment existing view/manage)
export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
perm('a2a00001-0001-4000-8000-000000000001', 'edr_freight_app:train_scheduling:create', 'Create train schedule'),
perm('a2a00001-0001-4000-8000-000000000002', 'edr_freight_app:train_scheduling:update', 'Update train schedule'),
perm('a2a00001-0001-4000-8000-000000000003', 'edr_freight_app:train_scheduling:cancel', 'Cancel train schedule'),
perm('a2a00001-0001-4000-8000-000000000004', 'edr_freight_app:train_scheduling:reschedule', 'Reschedule train'),
perm('a2a00001-0001-4000-8000-000000000005', 'edr_freight_app:train_scheduling:rules_manage', 'Manage global scheduling rules'),
];
// L. Administration & settings (split from the coarse admin umbrella)
export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
perm('b3a00001-0001-4000-8000-000000000001', 'edr_freight_app:config:contract_validity:view', 'View contract validity periods'),
perm('b3a00001-0001-4000-8000-000000000002', 'edr_freight_app:config:contract_validity:manage', 'Manage contract validity periods'),
perm('b4a00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:file_upload:view', 'View file-upload settings'),
perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'),
perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'),
perm('b4b00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:dropdown:manage', 'Manage dropdown settings'),
];
// M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment
// / hierarchy_* / position_types:view keys are seeded separately in edr-freight.seed.ts.
export const STAFF_IAM_PERMISSIONS: FreightPermissionSeed[] = [
perm('c2a00001-0001-4000-8000-000000000001', 'edr_freight_app:staff:roles:view', 'View roles'),
perm('c2a00001-0001-4000-8000-000000000002', 'edr_freight_app:staff:roles:create', 'Create role'),
perm('c2a00001-0001-4000-8000-000000000003', 'edr_freight_app:staff:roles:update', 'Update role'),
perm('c2a00001-0001-4000-8000-000000000004', 'edr_freight_app:staff:roles:delete', 'Delete role'),
perm('c2b00001-0001-4000-8000-000000000001', 'edr_freight_app:staff:permissions:view', 'View permission assignments'),
perm('c2b00001-0001-4000-8000-000000000002', 'edr_freight_app:staff:permissions:assign', 'Assign permissions'),
perm('c2c00001-0001-4000-8000-000000000001', 'edr_freight_app:position_types:create', 'Create position type'),
perm('c2c00001-0001-4000-8000-000000000002', 'edr_freight_app:position_types:update', 'Update position type'),
perm('c2c00001-0001-4000-8000-000000000003', 'edr_freight_app:position_types:delete', 'Delete position type'),
];
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
...CUSTOMER_PERMISSIONS,
...FINANCE_PERMISSIONS,
...MILE_PERMISSIONS,
...FLEET_RAIL_PERMISSIONS,
...FLEET_ROAD_PERMISSIONS,
...WAREHOUSE_PERMISSIONS,
...PORT_TERMINAL_PERMISSIONS,
...SCHEDULING_EXTRA_PERMISSIONS,
...CONFIG_SETTINGS_PERMISSIONS,
...STAFF_IAM_PERMISSIONS,
];
export const BOOKING_RULE_ENGINE_PERMISSIONS = [ export const BOOKING_RULE_ENGINE_PERMISSIONS = [
...BOOKING_PERMISSIONS, ...BOOKING_PERMISSIONS,
...CONTRACT_PERMISSIONS, ...CONTRACT_PERMISSIONS,
...RULE_ENGINE_PERMISSIONS, ...RULE_ENGINE_PERMISSIONS,
...GAP_CONTROLLER_PERMISSIONS, ...GAP_CONTROLLER_PERMISSIONS,
...ADVANCED_BACKOFFICE_PERMISSIONS,
]; ];
export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map( export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map(
@@ -131,6 +330,7 @@ export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIO
export const FREIGHT_PERMS = { export const FREIGHT_PERMS = {
bookings: { bookings: {
view: 'edr_freight_app:bookings:view', view: 'edr_freight_app:bookings:view',
create: 'edr_freight_app:bookings:create',
clearanceView: 'edr_freight_app:bookings:clearance_view', clearanceView: 'edr_freight_app:bookings:clearance_view',
staffAccept: 'edr_freight_app:bookings:staff_accept', staffAccept: 'edr_freight_app:bookings:staff_accept',
requestChanges: 'edr_freight_app:bookings:request_changes', requestChanges: 'edr_freight_app:bookings:request_changes',
@@ -168,6 +368,11 @@ export const FREIGHT_PERMS = {
trainScheduling: { trainScheduling: {
view: 'edr_freight_app:train_scheduling:view', view: 'edr_freight_app:train_scheduling:view',
manage: 'edr_freight_app:train_scheduling:manage', manage: 'edr_freight_app:train_scheduling:manage',
create: 'edr_freight_app:train_scheduling:create',
update: 'edr_freight_app:train_scheduling:update',
cancel: 'edr_freight_app:train_scheduling:cancel',
reschedule: 'edr_freight_app:train_scheduling:reschedule',
rulesManage: 'edr_freight_app:train_scheduling:rules_manage',
}, },
fleet: { fleet: {
view: 'edr_freight_app:fleet:view', view: 'edr_freight_app:fleet:view',
@@ -183,6 +388,244 @@ export const FREIGHT_PERMS = {
allocation: { allocation: {
manage: 'edr_freight_app:allocation:manage', manage: 'edr_freight_app:allocation:manage',
}, },
customers: {
view: 'edr_freight_app:customers:view',
create: 'edr_freight_app:customers:create',
update: 'edr_freight_app:customers:update',
deactivate: 'edr_freight_app:customers:deactivate',
verify: 'edr_freight_app:customers:verify',
},
payments: {
view: 'edr_freight_app:payments:view',
verify: 'edr_freight_app:payments:verify',
refund: 'edr_freight_app:payments:refund',
},
invoices: {
view: 'edr_freight_app:invoices:view',
create: 'edr_freight_app:invoices:create',
cancel: 'edr_freight_app:invoices:cancel',
export: 'edr_freight_app:invoices:export',
},
firstMile: {
view: 'edr_freight_app:first_mile:view',
accept: 'edr_freight_app:first_mile:accept',
create: 'edr_freight_app:first_mile:create',
update: 'edr_freight_app:first_mile:update',
delete: 'edr_freight_app:first_mile:delete',
assignVehicles: 'edr_freight_app:first_mile:assign_vehicles',
setDistances: 'edr_freight_app:first_mile:set_distances',
generateInvoice: 'edr_freight_app:first_mile:generate_invoice',
},
lastMile: {
view: 'edr_freight_app:last_mile:view',
accept: 'edr_freight_app:last_mile:accept',
create: 'edr_freight_app:last_mile:create',
update: 'edr_freight_app:last_mile:update',
delete: 'edr_freight_app:last_mile:delete',
assignVehicles: 'edr_freight_app:last_mile:assign_vehicles',
setDistances: 'edr_freight_app:last_mile:set_distances',
generateInvoice: 'edr_freight_app:last_mile:generate_invoice',
},
locomotives: {
view: 'edr_freight_app:locomotives:view',
create: 'edr_freight_app:locomotives:create',
update: 'edr_freight_app:locomotives:update',
delete: 'edr_freight_app:locomotives:delete',
},
wagons: {
view: 'edr_freight_app:wagons:view',
create: 'edr_freight_app:wagons:create',
update: 'edr_freight_app:wagons:update',
delete: 'edr_freight_app:wagons:delete',
},
trains: {
view: 'edr_freight_app:trains:view',
create: 'edr_freight_app:trains:create',
update: 'edr_freight_app:trains:update',
delete: 'edr_freight_app:trains:delete',
assignWagons: 'edr_freight_app:trains:assign_wagons',
},
routes: {
view: 'edr_freight_app:routes:view',
create: 'edr_freight_app:routes:create',
update: 'edr_freight_app:routes:update',
delete: 'edr_freight_app:routes:delete',
},
containers: {
view: 'edr_freight_app:containers:view',
create: 'edr_freight_app:containers:create',
update: 'edr_freight_app:containers:update',
delete: 'edr_freight_app:containers:delete',
},
cargoes: {
view: 'edr_freight_app:cargoes:view',
create: 'edr_freight_app:cargoes:create',
update: 'edr_freight_app:cargoes:update',
delete: 'edr_freight_app:cargoes:delete',
},
vehicles: {
view: 'edr_freight_app:vehicles:view',
create: 'edr_freight_app:vehicles:create',
update: 'edr_freight_app:vehicles:update',
delete: 'edr_freight_app:vehicles:delete',
},
drivers: {
view: 'edr_freight_app:drivers:view',
create: 'edr_freight_app:drivers:create',
update: 'edr_freight_app:drivers:update',
delete: 'edr_freight_app:drivers:delete',
},
tracking: {
view: 'edr_freight_app:tracking:view',
},
fuel: {
view: 'edr_freight_app:fuel:view',
create: 'edr_freight_app:fuel:create',
update: 'edr_freight_app:fuel:update',
delete: 'edr_freight_app:fuel:delete',
approve: 'edr_freight_app:fuel:approve',
},
maintenance: {
view: 'edr_freight_app:maintenance:view',
create: 'edr_freight_app:maintenance:create',
update: 'edr_freight_app:maintenance:update',
delete: 'edr_freight_app:maintenance:delete',
complete: 'edr_freight_app:maintenance:complete',
},
fleetReports: {
view: 'edr_freight_app:fleet_reports:view',
export: 'edr_freight_app:fleet_reports:export',
},
fleetDashboard: {
view: 'edr_freight_app:fleet_dashboard:view',
},
warehouseDashboard: {
view: 'edr_freight_app:warehouse_dashboard:view',
},
warehouses: {
view: 'edr_freight_app:warehouses:view',
create: 'edr_freight_app:warehouses:create',
update: 'edr_freight_app:warehouses:update',
delete: 'edr_freight_app:warehouses:delete',
},
warehouseYards: {
view: 'edr_freight_app:warehouse_yards:view',
create: 'edr_freight_app:warehouse_yards:create',
update: 'edr_freight_app:warehouse_yards:update',
delete: 'edr_freight_app:warehouse_yards:delete',
},
warehouseZones: {
view: 'edr_freight_app:warehouse_zones:view',
create: 'edr_freight_app:warehouse_zones:create',
update: 'edr_freight_app:warehouse_zones:update',
},
warehouseAllocationRules: {
view: 'edr_freight_app:warehouse_allocation_rules:view',
create: 'edr_freight_app:warehouse_allocation_rules:create',
update: 'edr_freight_app:warehouse_allocation_rules:update',
delete: 'edr_freight_app:warehouse_allocation_rules:delete',
},
warehouseFeeRules: {
view: 'edr_freight_app:warehouse_fee_rules:view',
create: 'edr_freight_app:warehouse_fee_rules:create',
update: 'edr_freight_app:warehouse_fee_rules:update',
delete: 'edr_freight_app:warehouse_fee_rules:delete',
},
warehouseInspectionReports: {
view: 'edr_freight_app:warehouse_inspection_reports:view',
create: 'edr_freight_app:warehouse_inspection_reports:create',
update: 'edr_freight_app:warehouse_inspection_reports:update',
},
warehouseInventory: {
view: 'edr_freight_app:warehouse_inventory:view',
receive: 'edr_freight_app:warehouse_inventory:receive',
move: 'edr_freight_app:warehouse_inventory:move',
load: 'edr_freight_app:warehouse_inventory:load',
unload: 'edr_freight_app:warehouse_inventory:unload',
dispatch: 'edr_freight_app:warehouse_inventory:dispatch',
gatePass: 'edr_freight_app:warehouse_inventory:gate_pass',
release: 'edr_freight_app:warehouse_inventory:release',
deliver: 'edr_freight_app:warehouse_inventory:deliver',
inspect: 'edr_freight_app:warehouse_inventory:inspect',
},
interchangeDocuments: {
view: 'edr_freight_app:interchange_documents:view',
generate: 'edr_freight_app:interchange_documents:generate',
acknowledge: 'edr_freight_app:interchange_documents:acknowledge',
dispute: 'edr_freight_app:interchange_documents:dispute',
cancel: 'edr_freight_app:interchange_documents:cancel',
},
warehouseFeeInvoices: {
view: 'edr_freight_app:warehouse_fee_invoices:view',
generate: 'edr_freight_app:warehouse_fee_invoices:generate',
cancel: 'edr_freight_app:warehouse_fee_invoices:cancel',
pay: 'edr_freight_app:warehouse_fee_invoices:pay',
},
config: {
contractValidity: {
view: 'edr_freight_app:config:contract_validity:view',
manage: 'edr_freight_app:config:contract_validity:manage',
},
},
settings: {
fileUpload: {
view: 'edr_freight_app:settings:file_upload:view',
manage: 'edr_freight_app:settings:file_upload:manage',
},
dropdown: {
view: 'edr_freight_app:settings:dropdown:view',
manage: 'edr_freight_app:settings:dropdown:manage',
},
},
staff: {
roles: {
view: 'edr_freight_app:staff:roles:view',
create: 'edr_freight_app:staff:roles:create',
update: 'edr_freight_app:staff:roles:update',
delete: 'edr_freight_app:staff:roles:delete',
},
permissions: {
view: 'edr_freight_app:staff:permissions:view',
assign: 'edr_freight_app:staff:permissions:assign',
},
// Seeded in edr-freight.seed.ts (EDR_FREIGHT_PERMISSIONS) — surfaced here for gating.
employeeRegistration: {
view: 'edr_freight_app:employee_registration:view',
create: 'edr_freight_app:employee_registration:create',
update: 'edr_freight_app:employee_registration:update',
activate: 'edr_freight_app:employee_registration:activate',
deactivate: 'edr_freight_app:employee_registration:deactivate',
},
roleAssignment: {
view: 'edr_freight_app:role_assignment:view',
assign: 'edr_freight_app:role_assignment:assign',
replace: 'edr_freight_app:role_assignment:replace',
},
hierarchyUnits: {
view: 'edr_freight_app:hierarchy_units:view',
create: 'edr_freight_app:hierarchy_units:create',
update: 'edr_freight_app:hierarchy_units:update',
delete: 'edr_freight_app:hierarchy_units:delete',
},
hierarchyPositions: {
view: 'edr_freight_app:hierarchy_positions:view',
create: 'edr_freight_app:hierarchy_positions:create',
update: 'edr_freight_app:hierarchy_positions:update',
delete: 'edr_freight_app:hierarchy_positions:delete',
changeParent: 'edr_freight_app:hierarchy_positions:change_parent',
},
hierarchyEmployeeAssignment: {
view: 'edr_freight_app:hierarchy_employee_assignment:view',
invite: 'edr_freight_app:hierarchy_employee_assignment:invite',
assign: 'edr_freight_app:hierarchy_employee_assignment:assign',
},
positionTypes: {
view: 'edr_freight_app:position_types:view',
create: 'edr_freight_app:position_types:create',
update: 'edr_freight_app:position_types:update',
delete: 'edr_freight_app:position_types:delete',
},
},
} as const; } as const;
const allRuleEngineViewKeys = () => const allRuleEngineViewKeys = () =>
@@ -326,12 +769,11 @@ export const POSITION_PERMISSION_PRESETS = {
]), ]),
} as const; } as const;
/** Derive the module bucket from the resource segment of a permission key. */
const moduleOf = (key: string): string => key.split(':')[1] ?? 'other';
export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({
key: p.key, key: p.key,
label: p.name.en, label: p.name.en,
module: p.key.includes(':bookings:') module: moduleOf(p.key),
? 'bookings'
: p.key.includes(':contracts:')
? 'contracts'
: 'rule_engine',
})); }));

View File

@@ -16,8 +16,8 @@ const COMPANY_TIN = 'PAIDMILE001';
const COMPANY_EMAIL = 'paid-mile-demo@edr.local'; const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
const YARDS = [ const YARDS = [
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 },
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 },
]; ];
const CONTAINER_TYPES = [ const CONTAINER_TYPES = [

View File

@@ -618,6 +618,9 @@ const DashboardShell = () => {
return ( return (
<FreightDashboardLayout <FreightDashboardLayout
sidebarSections={sidebarSections} sidebarSections={sidebarSections}
// GL Ethiopia / GL Djibouti are locked to a single clearance page — no
// sidebar (or mobile burger) at all; the page renders full width.
hideSidebar={Boolean(glClearanceHome)}
activeHref={location.pathname} activeHref={location.pathname}
onNavigate={navigate} onNavigate={navigate}
enableThemeToggle enableThemeToggle

View File

@@ -19,6 +19,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { CountdownTimer } from "@edr/ui-common"; import { CountdownTimer } from "@edr/ui-common";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { StaffBookingWindow } from "@/types/trainScheduling"; import type { StaffBookingWindow } from "@/types/trainScheduling";
@@ -224,6 +225,9 @@ function WindowCard({ w }: { w: StaffBookingWindow }) {
* Hidden when nothing is pending. * Hidden when nothing is pending.
*/ */
export function GlUpcomingWindowsSection() { export function GlUpcomingWindowsSection() {
// Live pushes flip cards the moment the window engine transitions a phase;
// the 60s poll below stays only as a fallback.
useBookingWindowSocket();
const { data, isLoading } = useQuery( const { data, isLoading } = useQuery(
api.trainScheduling.allBookingWindows.queryOptions({ api.trainScheduling.allBookingWindows.queryOptions({
refetchInterval: 60_000, refetchInterval: 60_000,

View File

@@ -39,6 +39,8 @@ export interface FreightDashboardHeaderProps {
onToggleTheme: () => void; onToggleTheme: () => void;
mobileOpened: boolean; mobileOpened: boolean;
onToggleMobile: () => void; onToggleMobile: () => void;
/** Hide the mobile burger when the shell has no sidebar to open. */
hideSidebarBurger?: boolean;
} }
// Every header control is a consistent 36px frosted chip — same language as the // Every header control is a consistent 36px frosted chip — same language as the
@@ -57,6 +59,7 @@ const FreightDashboardHeader = ({
onToggleTheme, onToggleTheme,
mobileOpened, mobileOpened,
onToggleMobile, onToggleMobile,
hideSidebarBurger = false,
}: FreightDashboardHeaderProps) => { }: FreightDashboardHeaderProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -88,13 +91,15 @@ const FreightDashboardHeader = ({
{/* Left: burger (mobile) + search — the search now occupies the slot {/* Left: burger (mobile) + search — the search now occupies the slot
the page title used to hold; each page owns its own title. */} the page title used to hold; each page owns its own title. */}
<Group gap={12} wrap="nowrap" align="center" style={{ minWidth: 0 }}> <Group gap={12} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
<Burger {!hideSidebarBurger && (
opened={mobileOpened} <Burger
onClick={onToggleMobile} opened={mobileOpened}
hiddenFrom="sm" onClick={onToggleMobile}
size="sm" hiddenFrom="sm"
aria-label="Toggle sidebar" size="sm"
/> aria-label="Toggle sidebar"
/>
)}
<Group <Group
gap={8} gap={8}
align="center" align="center"

View File

@@ -23,6 +23,8 @@ function getInitialTheme(): Theme {
export interface FreightDashboardLayoutProps { export interface FreightDashboardLayoutProps {
sidebarSections: SidebarSection[]; sidebarSections: SidebarSection[];
/** Render the shell with no navbar at all (used by GL clearance-only users). */
hideSidebar?: boolean;
activeHref?: string; activeHref?: string;
onNavigate?: (href: string) => void; onNavigate?: (href: string) => void;
headerRight?: ReactNode; headerRight?: ReactNode;
@@ -36,6 +38,7 @@ export interface FreightDashboardLayoutProps {
const FreightDashboardLayout = ({ const FreightDashboardLayout = ({
sidebarSections, sidebarSections,
hideSidebar = false,
activeHref = "", activeHref = "",
onNavigate, onNavigate,
headerRight, headerRight,
@@ -75,11 +78,17 @@ const FreightDashboardLayout = ({
padding={0} padding={0}
className="bg-edr-bg" className="bg-edr-bg"
header={{ height: HEADER_HEIGHT }} header={{ height: HEADER_HEIGHT }}
navbar={{ // When the sidebar is hidden the navbar slot is dropped entirely so Main
width: NAVBAR_WIDTH, // spans the full viewport width (GL clearance-only users).
breakpoint: "sm", navbar={
collapsed: { mobile: !mobileOpened }, hideSidebar
}} ? undefined
: {
width: NAVBAR_WIDTH,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened },
}
}
> >
<FreightDashboardHeader <FreightDashboardHeader
pageMeta={pageMeta} pageMeta={pageMeta}
@@ -93,14 +102,17 @@ const FreightDashboardLayout = ({
onToggleTheme={toggleTheme} onToggleTheme={toggleTheme}
mobileOpened={mobileOpened} mobileOpened={mobileOpened}
onToggleMobile={toggleMobile} onToggleMobile={toggleMobile}
hideSidebarBurger={hideSidebar}
/> />
<FreightSidebar {!hideSidebar && (
sections={sidebarSections} <FreightSidebar
activeHref={activeHref} sections={sidebarSections}
onNavigate={navigate} activeHref={activeHref}
onClose={closeMobile} onNavigate={navigate}
/> onClose={closeMobile}
/>
)}
<AppShell.Main> <AppShell.Main>
{/* Internal scroll keeps the fixed-viewport model the dashboard pages {/* Internal scroll keeps the fixed-viewport model the dashboard pages

View File

@@ -32,6 +32,7 @@ const DEFAULTS = {
docReviewMinutes: 30, docReviewMinutes: 30,
paymentWindowMinutes: 60, paymentWindowMinutes: 60,
importWindowLeadDays: 3, importWindowLeadDays: 3,
exportBookingLeadHours: 24,
}; };
/** 12-hour label for an EAT hour 023, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */ /** 12-hour label for an EAT hour 023, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
@@ -53,6 +54,7 @@ interface FormState {
docReviewMinutes: number | ""; docReviewMinutes: number | "";
paymentWindowMinutes: number | ""; paymentWindowMinutes: number | "";
importWindowLeadDays: number | ""; importWindowLeadDays: number | "";
exportBookingLeadHours: number | "";
} }
function parseError(error: unknown, fallback: string): string { function parseError(error: unknown, fallback: string): string {
@@ -112,6 +114,8 @@ export default function BookingWindowSettingsModal({
r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes, r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes,
importWindowLeadDays: importWindowLeadDays:
r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays, r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays,
exportBookingLeadHours:
r?.exportBookingLeadHours ?? DEFAULTS.exportBookingLeadHours,
}); });
}, [opened, schedule]); }, [opened, schedule]);
@@ -142,15 +146,20 @@ export default function BookingWindowSettingsModal({
const doc = Number(form.docReviewMinutes); const doc = Number(form.docReviewMinutes);
const pay = Number(form.paymentWindowMinutes); const pay = Number(form.paymentWindowMinutes);
const lead = Number(form.importWindowLeadDays); const lead = Number(form.importWindowLeadDays);
const exportLead = Number(form.exportBookingLeadHours);
const leadInvalid = isExport
? form.exportBookingLeadHours === "" ||
!Number.isFinite(exportLead) ||
exportLead < 1
: form.importWindowLeadDays === "" || !Number.isFinite(lead);
if ( if (
form.windowDurationHours === "" || form.windowDurationHours === "" ||
form.docReviewMinutes === "" || form.docReviewMinutes === "" ||
form.paymentWindowMinutes === "" || form.paymentWindowMinutes === "" ||
form.importWindowLeadDays === "" ||
!Number.isFinite(duration) || !Number.isFinite(duration) ||
!Number.isFinite(doc) || !Number.isFinite(doc) ||
!Number.isFinite(pay) || !Number.isFinite(pay) ||
!Number.isFinite(lead) leadInvalid
) { ) {
toast({ toast({
title: "Fill every field before saving", title: "Fill every field before saving",
@@ -164,7 +173,9 @@ export default function BookingWindowSettingsModal({
windowDurationHours: duration, windowDurationHours: duration,
docReviewMinutes: doc, docReviewMinutes: doc,
paymentWindowMinutes: pay, paymentWindowMinutes: pay,
importWindowLeadDays: lead, ...(isExport
? { exportBookingLeadHours: exportLead }
: { importWindowLeadDays: lead }),
}; };
try { try {
@@ -223,8 +234,10 @@ export default function BookingWindowSettingsModal({
<Stack gap="lg"> <Stack gap="lg">
{isExport ? ( {isExport ? (
<Alert variant="light" color="blue" icon={<Info size={16} />}> <Alert variant="light" color="blue" icon={<Info size={16} />}>
Export schedules use a single FCFS lead window the daily desk Export schedules use a single first-come-first-served window: it
hours below don't apply, only the lead time does. opens the export lead time before departure shifted to the next
desk opening if that lands outside desk hours and stays open
until departure. Cycle timing below doesn't apply.
</Alert> </Alert>
) : null} ) : null}
@@ -263,7 +276,6 @@ export default function BookingWindowSettingsModal({
} }
allowDeselect={false} allowDeselect={false}
comboboxProps={{ withinPortal: true }} comboboxProps={{ withinPortal: true }}
disabled={isExport}
/> />
<Select <Select
label="Closes" label="Closes"
@@ -275,7 +287,6 @@ export default function BookingWindowSettingsModal({
} }
allowDeselect={false} allowDeselect={false}
comboboxProps={{ withinPortal: true }} comboboxProps={{ withinPortal: true }}
disabled={isExport}
/> />
</Group> </Group>
{isOvernight && !is24h ? ( {isOvernight && !is24h ? (
@@ -290,7 +301,6 @@ export default function BookingWindowSettingsModal({
color="grape" color="grape"
label="Run 24 hours a day (never pause overnight)" label="Run 24 hours a day (never pause overnight)"
checked={is24h} checked={is24h}
disabled={isExport}
onChange={(e) => { onChange={(e) => {
const checked = e.currentTarget.checked; const checked = e.currentTarget.checked;
setForm((f) => { setForm((f) => {
@@ -304,12 +314,11 @@ export default function BookingWindowSettingsModal({
}); });
}} }}
/> />
{!isExport ? ( <Text size="xs" c="dimmed" mt={6}>
<Text size="xs" c="dimmed" mt={6}> {isExport
A not-yet-full train pauses at the close hour and resumes the next ? "If the export lead time lands while the desk is shut, booking opens at the next desk opening instead."
morning at the open hour, every day until it fills or departs. : "A not-yet-full train pauses at the close hour and resumes the next morning at the open hour, every day until it fills or departs."}
</Text> </Text>
) : null}
</Box> </Box>
<Divider /> <Divider />
@@ -367,27 +376,43 @@ export default function BookingWindowSettingsModal({
<Divider /> <Divider />
{/* ── Lead time ────────────────────────────────────────────────── */} {/* ── Lead time ────────────────────────────────────────────────── */}
<NumberInput {isExport ? (
label={isExport ? "Booking lead (days)" : "Window lead (days)"} <NumberInput
description={ label="Export booking lead (hours)"
isExport description="How many hours before departure the export booking window opens"
? "How many days before departure export booking opens" value={form.exportBookingLeadHours}
: "How many days before departure the booking window starts" onChange={(v) =>
} setForm(
value={form.importWindowLeadDays} (f) =>
onChange={(v) => f && {
setForm( ...f,
(f) => exportBookingLeadHours: v === "" ? "" : Number(v),
f && { },
...f, )
importWindowLeadDays: v === "" ? "" : Number(v), }
}, min={1}
) clampBehavior="none"
} allowDecimal={false}
min={0} />
clampBehavior="none" ) : (
allowDecimal={false} <NumberInput
/> label="Window lead (days)"
description="How many days before departure the booking window starts"
value={form.importWindowLeadDays}
onChange={(v) =>
setForm(
(f) =>
f && {
...f,
importWindowLeadDays: v === "" ? "" : Number(v),
},
)
}
min={0}
clampBehavior="none"
allowDecimal={false}
/>
)}
<Group justify="flex-end" mt="xs"> <Group justify="flex-end" mt="xs">
<Button variant="default" onClick={onClose} disabled={save.isPending}> <Button variant="default" onClick={onClose} disabled={save.isPending}>

View File

@@ -0,0 +1,376 @@
import { useState } from "react";
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Loader,
Paper,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
IntercityBookingRow,
IntercityCapacity,
} from "@/types/trainScheduling";
const parseError = (error: unknown, fallback: string) => {
const message = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
if (Array.isArray(message)) return message.join("; ");
return message || (error as Error)?.message || fallback;
};
function fmt(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1);
}
function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
if (!capacity) {
return (
<Text size="sm" c="dimmed">
Capacity unknown schedule has no locomotive/train set yet.
</Text>
);
}
return (
<Group gap="xs">
<Badge variant="light" color={capacity.wagons > 0 ? "teal" : "red"}>
{fmt(capacity.wagons)} wagons free
</Badge>
<Badge variant="light" color={capacity.weightTons > 0 ? "teal" : "red"}>
{fmt(capacity.weightTons)} t free
</Badge>
<Badge variant="light" color={capacity.lengthMeters > 0 ? "teal" : "red"}>
{fmt(capacity.lengthMeters)} m free
</Badge>
</Group>
);
}
function NeedCells({ need }: { need: IntercityCapacity | null }) {
if (!need) return <Table.Td colSpan={3}></Table.Td>;
return (
<>
<Table.Td>{fmt(need.wagons)}</Table.Td>
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
</>
);
}
function CorridorCell({ row }: { row: IntercityBookingRow }) {
return (
<Group gap={6} wrap="nowrap">
<Text size="sm">{row.origin}</Text>
<ArrowRight size={13} />
<Text size="sm">{row.destination}</Text>
</Group>
);
}
/**
* Intercity ride-along desk for one import/export schedule: waiting intercity
* bookings whose corridor lies on this train's route, checked against the
* remaining wagon/weight/length budget. Accepting opens the customer's pay
* window; after payment the booking is allocated. Loading/unloading is
* confirmed manually when the train is physically at the booking's origin /
* destination yard (the server validates against recorded checkpoints).
*/
export function IntercityRideAlongPanel({
scheduleId,
direction,
}: {
scheduleId: string;
direction: string | null | undefined;
}) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
const candidatesQuery = useQuery(
api.trainScheduling.intercityCandidates.queryOptions({
input: { scheduleId },
refetchInterval: 60_000,
}),
);
const invalidate = () =>
queryClient.invalidateQueries({
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
});
const accept = useMutation(
api.trainScheduling.acceptIntercityBookings.mutationOptions({
onSuccess: (result) => {
setSelected([]);
void invalidate();
if (result.accepted.length > 0) {
toast({
title: `${result.accepted.length} intercity booking(s) accepted`,
description: "Customers have been asked to pay.",
});
}
for (const r of result.rejected) {
toast({
title: "Booking skipped",
description: r.reason,
variant: "destructive",
});
}
},
onError: (err) =>
toast({
title: "Accept failed",
description: parseError(err, "Could not accept intercity bookings"),
variant: "destructive",
}),
}),
);
const load = useMutation(
api.trainScheduling.loadIntercityBooking.mutationOptions({
onSuccess: () => {
void invalidate();
toast({ title: "Cargo loaded" });
},
onError: (err) =>
toast({
title: "Load failed",
description: parseError(err, "Could not confirm loading"),
variant: "destructive",
}),
}),
);
const unload = useMutation(
api.trainScheduling.unloadIntercityBooking.mutationOptions({
onSuccess: () => {
void invalidate();
toast({ title: "Cargo unloaded — booking completed" });
},
onError: (err) =>
toast({
title: "Unload failed",
description: parseError(err, "Could not confirm unloading"),
variant: "destructive",
}),
}),
);
// Intercity bookings only ride import/export trains.
if (direction !== "IMPORT" && direction !== "EXPORT") return null;
const data = candidatesQuery.data;
const candidates = data?.candidates ?? [];
const accepted = data?.accepted ?? [];
if (candidatesQuery.isLoading) {
return (
<Paper withBorder radius="lg" p="lg" mt="md">
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading intercity ride-along bookings
</Text>
</Group>
</Paper>
);
}
if (candidates.length === 0 && accepted.length === 0) return null;
return (
<Paper withBorder radius="lg" p="lg" mt="md">
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Group gap="xs">
<TrainFront size={18} />
<Text fw={700}>Intercity ride-along</Text>
</Group>
<CapacityBadges capacity={data?.remaining ?? null} />
</Group>
{candidates.length > 0 && (
<>
<Text size="sm" c="dimmed">
Waiting intercity bookings whose corridor lies on this train's
route. Accepting opens the customer's payment window against the
free capacity above.
</Text>
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="xs" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th w={36} />
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Corridor</Table.Th>
<Table.Th>Wagons</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Length</Table.Th>
<Table.Th>Fits</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{candidates.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Checkbox
size="xs"
checked={selected.includes(row.id)}
onChange={(e) =>
setSelected((prev) =>
e.currentTarget.checked
? [...prev, row.id]
: prev.filter((id) => id !== row.id),
)
}
/>
</Table.Td>
<Table.Td>
<Group gap={6}>
<Text size="sm" fw={600}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
{row.isGovernment && (
<Badge size="xs" variant="light" color="grape">
GOV
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
</Table.Td>
<Table.Td>
<CorridorCell row={row} />
</Table.Td>
<NeedCells need={row.need} />
<Table.Td>
{row.fits ? (
<Badge size="sm" variant="light" color="teal">
Fits
</Badge>
) : (
<Tooltip label="Exceeds the remaining wagon/weight/length budget">
<Badge size="sm" variant="light" color="red">
No room
</Badge>
</Tooltip>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Group justify="flex-end">
<Button
size="xs"
color="edr-green"
loading={accept.isPending}
disabled={selected.length === 0}
onClick={() => accept.mutate({ scheduleId, bookingIds: selected })}
>
Accept {selected.length > 0 ? `${selected.length} ` : ""}onto this train
</Button>
</Group>
</>
)}
{accepted.length > 0 && (
<>
<Text size="sm" fw={600}>
On this train
</Text>
<Table.ScrollContainer minWidth={680}>
<Table verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Corridor</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{accepted.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Text size="sm" fw={600}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
</Table.Td>
<Table.Td>
<CorridorCell row={row} />
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">
{row.status}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end">
{row.status === "PAID" && (
<Tooltip label="Train must be at the booking's origin yard">
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
loading={load.isPending}
onClick={() =>
load.mutate({ scheduleId, bookingId: row.id })
}
>
Load
</Button>
</Tooltip>
)}
{row.status === "IN_TRANSIT" && (
<Tooltip label="Train must be at the booking's destination yard">
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
loading={unload.isPending}
onClick={() =>
unload.mutate({ scheduleId, bookingId: row.id })
}
>
Unload
</Button>
</Tooltip>
)}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</>
)}
{candidatesQuery.isError && (
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
{parseError(candidatesQuery.error, "Could not load intercity candidates")}
</Alert>
)}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,211 @@
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Tabs,
Text,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileText } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import {
warehouseService,
type ContainerItem,
type ContainerItemStage,
} from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface ContainerItemsModalProps {
opened: boolean;
onClose: () => void;
bookingId: string | null;
bookingReference?: string | null;
}
const STAGE_TABS: Array<{ value: string; label: string }> = [
{ value: 'ALL', label: 'All' },
{ value: 'RECEIVED', label: 'Received' },
{ value: 'GRN', label: "GRN'd" },
{ value: 'LOADED', label: 'Loaded' },
{ value: 'LEFT', label: 'Left' },
{ value: 'DELIVERED', label: 'Delivered' },
];
const STAGE_COLOR: Record<ContainerItemStage, string> = {
PENDING: 'gray',
RECEIVED: 'blue',
GRN: 'teal',
LOADED: 'grape',
LEFT: 'orange',
DELIVERED: 'green',
};
/** Loadable = not yet on a truck (before LOADED). */
const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN';
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [tab, setTab] = useState('ALL');
const [selected, setSelected] = useState<string[]>([]);
const [truckId, setTruckId] = useState<string | null>(null);
const itemsKey = ['container-items', bookingId];
const { data: items = [], isLoading } = useQuery({
queryKey: itemsKey,
queryFn: () => warehouseService.getContainerItems(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const { data: trucks = [] } = useQuery({
queryKey: ['ci-trucks', bookingId],
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const visible = useMemo(
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
[items, tab],
);
const truckOptions = trucks
.filter((t) => !(t as { departedAt?: string }).departedAt)
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
const loadMutation = useMutation({
mutationFn: () => warehouseService.loadTruck(bookingId as string, truckId as string, selected),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: itemsKey });
setSelected([]);
toast({ title: 'Containers loaded onto truck' });
},
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
const openExitPaper = async (assignmentId: string, plate: string) => {
try {
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
openPdfBlob(res.data, `exit-${plate}.pdf`);
} catch (e) {
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
}
};
const toggle = (n: string) => setSelected((s) => (s.includes(n) ? s.filter((x) => x !== n) : [...s, n]));
return (
<Modal
opened={opened}
onClose={onClose}
centered
size="90%"
title={<Text fw={700}>Container / bulk items {bookingReference ? `· ${bookingReference}` : ''}</Text>}
>
<Tabs value={tab} onChange={(v) => setTab(v ?? 'ALL')} mb="sm">
<Tabs.List>
{STAGE_TABS.map((t) => {
const count = t.value === 'ALL' ? items.length : items.filter((i) => i.stage === t.value).length;
return (
<Tabs.Tab key={t.value} value={t.value} rightSection={<Badge size="xs" variant="light">{count}</Badge>}>
{t.label}
</Tabs.Tab>
);
})}
</Tabs.List>
</Tabs>
{isLoading ? (
<Group justify="center" py="lg">
<Loader />
</Group>
) : items.length === 0 ? (
<Alert color="gray" variant="light">No container or bulk items on this booking.</Alert>
) : (
<Stack gap="sm">
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th />
<Table.Th>Container</Table.Th>
<Table.Th>Goods</Table.Th>
<Table.Th>Stage</Table.Th>
<Table.Th>Truck</Table.Th>
<Table.Th>Booking</Table.Th>
<Table.Th>Contract</Table.Th>
<Table.Th>Last mile</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{visible.map((i) => (
<Table.Tr key={i.containerNumber}>
<Table.Td>
<Checkbox
checked={selected.includes(i.containerNumber)}
onChange={() => toggle(i.containerNumber)}
disabled={!isLoadable(i)}
/>
</Table.Td>
<Table.Td><Text fw={600}>{i.containerNumber}</Text></Table.Td>
<Table.Td>{i.goods ?? '—'}</Table.Td>
<Table.Td><Badge color={STAGE_COLOR[i.stage]} variant="light">{i.stage}</Badge></Table.Td>
<Table.Td>{i.truckPlate ?? '—'}</Table.Td>
<Table.Td>{i.bookingReference ?? '—'}</Table.Td>
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
<Table.Td ta="right">
{i.truckAssignmentId && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<FileText size={13} />}
onClick={() => openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')}
>
Exit Paper
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{/* Multiselect → load onto a truck */}
<Group justify="space-between" align="flex-end">
<Text size="sm" c="dimmed">{selected.length} selected</Text>
<Group gap="sm" align="flex-end">
<Select
label="Load onto truck"
placeholder={truckOptions.length ? 'Select truck' : 'No arrived truck'}
data={truckOptions}
value={truckId}
onChange={setTruckId}
disabled={truckOptions.length === 0}
w={260}
/>
<Button
color="edr-green"
disabled={selected.length === 0 || !truckId}
loading={loadMutation.isPending}
onClick={() => loadMutation.mutate()}
>
Load selected
</Button>
</Group>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -28,6 +28,8 @@ interface FeePreviewModalProps {
const LABELS: Record<string, { label: string; color: string }> = { const LABELS: Record<string, { label: string; color: string }> = {
DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' }, DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' },
STORAGE_FEE: { label: 'Storage', color: 'teal' }, STORAGE_FEE: { label: 'Storage', color: 'teal' },
DOUBLE_HANDLING_FEE: { label: 'Double Handling', color: 'grape' },
TRUCK_DETENTION_FEE: { label: 'Truck Detention Cost', color: 'blue' },
}; };
function fmtDate(iso: string | null) { function fmtDate(iso: string | null) {

View File

@@ -0,0 +1,252 @@
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Loader,
Select,
Stack,
Table,
Tabs,
Text,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TrainFront } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService, type TrainLoadableItem } from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
const STAGE_COLOR: Record<string, string> = {
RECEIVED: 'blue',
STORED: 'gray',
RESERVED: 'grape',
READY_FOR_LOADING: 'teal',
LOADED: 'green',
};
const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} kg`);
/**
* Load to Train — pick an allocated EXPORT train, see the arrived containers/cargoes
* assigned to it (stage tabs), multiselect the ready ones and load them onto their
* already-allocated wagons. Loading follows train + wagon allocation: only items
* that are READY_FOR_LOADING and have an allocated wagon are selectable.
*/
export function LoadToTrainPanel() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [scheduleId, setScheduleId] = useState<string | null>(null);
const [tab, setTab] = useState('received');
const [selected, setSelected] = useState<string[]>([]);
const trainsKey = ['loadable-trains'];
const { data: trains = [], isLoading: trainsLoading } = useQuery({
queryKey: trainsKey,
queryFn: () => warehouseService.getLoadableTrains(),
});
const itemsKey = ['train-loadable-items', scheduleId];
const { data: items = [], isLoading } = useQuery({
queryKey: itemsKey,
queryFn: () => warehouseService.getTrainLoadableItems(scheduleId as string),
enabled: Boolean(scheduleId),
});
const received = useMemo(() => items.filter((i) => i.status !== 'LOADED'), [items]);
const loaded = useMemo(() => items.filter((i) => i.status === 'LOADED'), [items]);
const visible = tab === 'loaded' ? loaded : received;
const trainOptions = trains.map((t) => ({
value: t.scheduleId,
label:
`${t.trainNumber ?? t.scheduleId.slice(0, 8)}` +
(t.origin || t.destination ? ` · ${t.origin ?? '?'}${t.destination ?? '?'}` : '') +
` · ${t.readyCount} ready / ${t.loadedCount} loaded`,
}));
const selectableVisible = visible.filter((i) => i.loadable);
const allSelected =
selectableVisible.length > 0 && selectableVisible.every((i) => selected.includes(i.id));
const toggle = (id: string) =>
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
const toggleAll = () =>
setSelected((s) =>
allSelected
? s.filter((id) => !selectableVisible.some((i) => i.id === id))
: Array.from(new Set([...s, ...selectableVisible.map((i) => i.id)])),
);
const loadMutation = useMutation({
mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId as string, selected),
onSuccess: (r) => {
queryClient.invalidateQueries({ queryKey: itemsKey });
queryClient.invalidateQueries({ queryKey: trainsKey });
setSelected([]);
toast({
title: 'Loaded onto train',
description: `Loaded ${r.loadedCount} item(s); skipped ${r.skippedCount}.`,
});
},
onError: (e) =>
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
const renderRow = (i: TrainLoadableItem) => (
<Table.Tr key={i.id}>
<Table.Td>
<Checkbox
checked={selected.includes(i.id)}
onChange={() => toggle(i.id)}
disabled={!i.loadable}
/>
</Table.Td>
<Table.Td>
<Text fw={600}>{i.containerNumber ?? i.cargoType ?? '—'}</Text>
</Table.Td>
<Table.Td>{i.cargoType ?? '—'}</Table.Td>
<Table.Td>{weight(i.weight)}</Table.Td>
<Table.Td>
<Badge color={STAGE_COLOR[i.status] ?? 'gray'} variant="light">
{i.status.replace(/_/g, ' ')}
</Badge>
</Table.Td>
<Table.Td>
{i.wagonNumber ? (
<Badge variant="outline" color="indigo">
{i.wagonNumber}
</Badge>
) : (
<Text size="xs" c="red">
Not allocated
</Text>
)}
</Table.Td>
<Table.Td>{i.bookingReference ?? '—'}</Table.Td>
<Table.Td>{i.customerName ?? '—'}</Table.Td>
<Table.Td>
{i.inspectionStatus ? (
<Badge size="xs" variant="light" color={i.inspectionStatus === 'PASSED' ? 'green' : 'orange'}>
{i.inspectionStatus}
</Badge>
) : (
'—'
)}
</Table.Td>
</Table.Tr>
);
return (
<Stack gap="md">
<Group align="flex-end" justify="space-between">
<Select
label="Train"
description="Allocated EXPORT trains awaiting loading"
placeholder={trainsLoading ? 'Loading trains…' : trainOptions.length ? 'Select a train' : 'No trains to load'}
data={trainOptions}
value={scheduleId}
onChange={(v) => {
setScheduleId(v);
setSelected([]);
setTab('received');
}}
disabled={trainOptions.length === 0}
leftSection={<TrainFront size={16} />}
w={460}
searchable
/>
</Group>
{!scheduleId ? (
<Alert color="gray" variant="light">
Pick a train to see the arrived containers/cargoes allocated to it. Items appear here only
after train and wagon allocation.
</Alert>
) : (
<>
<Tabs value={tab} onChange={(v) => setTab(v ?? 'received')}>
<Tabs.List>
<Tabs.Tab
value="received"
rightSection={
<Badge size="xs" variant="light" color="blue">
{received.length}
</Badge>
}
>
Received
</Tabs.Tab>
<Tabs.Tab
value="loaded"
rightSection={
<Badge size="xs" variant="light" color="green">
{loaded.length}
</Badge>
}
>
Loaded
</Tabs.Tab>
</Tabs.List>
</Tabs>
{isLoading ? (
<Group justify="center" py="lg">
<Loader />
</Group>
) : visible.length === 0 ? (
<Alert color="gray" variant="light">
{tab === 'loaded' ? 'Nothing loaded onto this train yet.' : 'No arrived items ready for this train.'}
</Alert>
) : (
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>
{tab === 'received' && (
<Checkbox
checked={allSelected}
indeterminate={!allSelected && selected.length > 0}
onChange={toggleAll}
disabled={selectableVisible.length === 0}
/>
)}
</Table.Th>
<Table.Th>Container / Cargo</Table.Th>
<Table.Th>Goods</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Stage</Table.Th>
<Table.Th>Wagon</Table.Th>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Inspection</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{visible.map(renderRow)}</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
{tab === 'received' && (
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{selected.length} selected · only READY_FOR_LOADING items with an allocated wagon can be loaded
</Text>
<Button
color="edr-green"
leftSection={<TrainFront size={16} />}
disabled={selected.length === 0}
loading={loadMutation.isPending}
onClick={() => loadMutation.mutate()}
>
Load {selected.length || ''} onto train
</Button>
</Group>
)}
</>
)}
</Stack>
);
}

View File

@@ -61,6 +61,8 @@ import type {
} from '@/types/warehouse'; } from '@/types/warehouse';
import { BookingSelect } from './BookingSelect'; import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal'; import { DeliverInventoryModal } from './DeliverInventoryModal';
import { TruckDispatchModal } from './TruckDispatchModal';
import { ContainerItemsModal } from './ContainerItemsModal';
import { FeePreviewModal } from './FeePreviewModal'; import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal'; import { InspectionReportModal } from './InspectionReportModal';
import { InventoryDetailModal } from './InventoryDetailModal'; import { InventoryDetailModal } from './InventoryDetailModal';
@@ -644,9 +646,15 @@ function TruckEntranceFields({
function LocationSelects({ function LocationSelects({
value, value,
onChange, onChange,
allowedYardTypes,
allowedZoneTypes,
}: { }: {
value: Location; value: Location;
onChange: (next: Location) => void; onChange: (next: Location) => void;
/** When non-empty, only yards of these types are offered (matched to freight). */
allowedYardTypes?: string[];
/** When non-empty, only zones of these types are offered. */
allowedZoneTypes?: string[];
}) { }) {
const warehousesQuery = useQuery( const warehousesQuery = useQuery(
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }), api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
@@ -672,15 +680,17 @@ function LocationSelects({
() => () =>
(yardsQuery.data ?? []) (yardsQuery.data ?? [])
.filter((y) => y.status === 'ACTIVE') .filter((y) => y.status === 'ACTIVE')
.filter((y) => !allowedYardTypes?.length || allowedYardTypes.includes((y as { type?: string }).type ?? ''))
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })), .map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
[yardsQuery.data], [yardsQuery.data, allowedYardTypes],
); );
const zoneOptions = useMemo( const zoneOptions = useMemo(
() => () =>
(zonesQuery.data ?? []) (zonesQuery.data ?? [])
.filter((z) => z.status === 'ACTIVE') .filter((z) => z.status === 'ACTIVE')
.filter((z) => !allowedZoneTypes?.length || allowedZoneTypes.includes((z as { type?: string }).type ?? ''))
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })), .map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
[zonesQuery.data], [zonesQuery.data, allowedZoneTypes],
); );
return ( return (
@@ -1758,6 +1768,29 @@ const importLocationTypesForFreight = (freightType: string | null | undefined) =
const isImportContainerFreight = (freightType: string | null | undefined) => const isImportContainerFreight = (freightType: string | null | undefined) =>
(freightType ?? '').toUpperCase() === 'CONTAINER'; (freightType ?? '').toUpperCase() === 'CONTAINER';
/**
* Yard/zone types valid for the freight being received — used to filter the receive
* location pickers so the yard list matches the cargo. Container freight → container
* yards only; bulk / break-bulk → bulk, general-cargo, hazardous, or cold-storage.
* Union across the given freight types; empty input → no restriction (show all).
*/
const yardZoneTypesForFreights = (freightTypes: Array<string | null | undefined>) => {
const yardTypes = new Set<string>();
const zoneTypes = new Set<string>();
for (const freightType of freightTypes) {
const normalized = (freightType ?? '').toUpperCase();
if (!normalized) continue;
if (normalized === 'CONTAINER') {
yardTypes.add('CONTAINER_YARD');
zoneTypes.add('CONTAINER_ZONE');
} else {
['BULK_YARD', 'GENERAL_CARGO_YARD', 'HAZARDOUS_YARD', 'COLD_STORAGE_YARD'].forEach((t) => yardTypes.add(t));
['BULK_ZONE', 'GENERAL_CARGO_ZONE', 'HAZARDOUS_ZONE', 'COLD_STORAGE_ZONE'].forEach((t) => zoneTypes.add(t));
}
}
return { yardTypes: [...yardTypes], zoneTypes: [...zoneTypes] };
};
const isImportUnloadPending = (item: ImportTrainItem) => const isImportUnloadPending = (item: ImportTrainItem) =>
!item.currentStatus || item.currentStatus === 'RECEIVED'; !item.currentStatus || item.currentStatus === 'RECEIVED';
@@ -2151,7 +2184,6 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
); );
const storeMutation = useMutation(api.warehouses.store.mutationOptions()); const storeMutation = useMutation(api.warehouses.store.mutationOptions());
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions()); const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions());
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [inspectId, setInspectId] = useState<string | null>(null); const [inspectId, setInspectId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
@@ -2160,6 +2192,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null); const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null); const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null); const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
const [loadTruckItem, setLoadTruckItem] = useState<WarehouseInventoryItem | null>(null);
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
const allSelected = rows.length > 0 && selected.size === rows.length; const allSelected = rows.length > 0 && selected.size === rows.length;
const someSelected = selected.size > 0 && !allSelected; const someSelected = selected.size > 0 && !allSelected;
@@ -2375,7 +2409,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
<Table.Td ta="right"> <Table.Td ta="right">
<Group gap="xs" justify="flex-end" wrap="nowrap"> <Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="View details" withArrow> <Tooltip label="View details" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => setViewItem(toInventoryItem(r))}> <ActionIcon variant="subtle" color="gray" onClick={() => setContainerItemsItem(toInventoryItem(r))}>
<Eye size={16} /> <Eye size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -2419,9 +2453,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
variant="light" variant="light"
color="green" color="green"
loading={busyId === r.id} loading={busyId === r.id}
onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))} onClick={() => setLoadTruckItem(toInventoryItem(r))}
> >
Dispatch Truck_dispatch
</Button> </Button>
)} )}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
@@ -2493,6 +2527,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
/> />
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} /> <ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} /> <DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
<TruckDispatchModal
opened={Boolean(loadTruckItem)}
onClose={() => setLoadTruckItem(null)}
bookingId={loadTruckItem?.booking?.id ?? null}
bookingReference={loadTruckItem?.booking?.reference ?? null}
/>
<ContainerItemsModal
opened={Boolean(containerItemsItem)}
onClose={() => setContainerItemsItem(null)}
bookingId={containerItemsItem?.booking?.id ?? null}
bookingReference={containerItemsItem?.booking?.reference ?? null}
/>
</Stack> </Stack>
); );
} }
@@ -2873,6 +2919,24 @@ export function WarehouseFlowWorkbench({
); );
const activeDirection = direction === 'BOTH' ? tab : direction; const activeDirection = direction === 'BOTH' ? tab : direction;
// Match the yard/zone list to the freight being received (container → container
// yards, etc). Same query key as the export tab, so React Query dedupes it.
const { data: eligibleForLocation = [] } = useQuery(
api.warehouses.eligibleBookings.queryOptions({
input: { direction: activeDirection },
enabled: enabled && activeDirection === 'EXPORT',
}),
);
const { yardTypes: allowedYardTypes, zoneTypes: allowedZoneTypes } = useMemo(
() =>
yardZoneTypesForFreights(
eligibleForLocation
.filter((r) => r.direction === activeDirection)
.map((r) => r.freightType),
),
[eligibleForLocation, activeDirection],
);
useEffect(() => { useEffect(() => {
if (enabled) setLocation({ warehouseId: '', yardId: '', zoneId: '' }); if (enabled) setLocation({ warehouseId: '', yardId: '', zoneId: '' });
}, [enabled, direction]); }, [enabled, direction]);
@@ -2880,7 +2944,12 @@ export function WarehouseFlowWorkbench({
return ( return (
<Stack gap="md"> <Stack gap="md">
{activeDirection === 'EXPORT' && ( {activeDirection === 'EXPORT' && (
<LocationSelects value={location} onChange={setLocation} /> <LocationSelects
value={location}
onChange={setLocation}
allowedYardTypes={allowedYardTypes}
allowedZoneTypes={allowedZoneTypes}
/>
)} )}
{direction === 'BOTH' ? ( {direction === 'BOTH' ? (

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react'; import { Info, Scale } from 'lucide-react';
import { useMutation } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api'; import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
@@ -134,6 +134,13 @@ const parseInspectionNote = (notes: string | null | undefined) => {
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) { export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
const { toast } = useToast(); const { toast } = useToast();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
const bookingId = item?.booking?.id;
// Customer self-haul trucks assigned to this booking via the portal.
const { data: customerTrucks = [] } = useQuery({
queryKey: ['release-customer-trucks', bookingId],
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const [reference, setReference] = useState(''); const [reference, setReference] = useState('');
const [truckPlateNumber, setTruckPlateNumber] = useState(''); const [truckPlateNumber, setTruckPlateNumber] = useState('');
const [trailerPlateNumber, setTrailerPlateNumber] = useState(''); const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
@@ -178,6 +185,44 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const isEntranceLocked = isExitStep; const isEntranceLocked = isExitStep;
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt); const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber); const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
// Registered trucks for THIS booking, from both sources: EDR last-mile
// (truckPrefill) and the customer portal (customer_truck_assignments).
const assignedTruckOptions = [
...(truckPrefill?.truckPlateNumber
? [
{
value: truckPrefill.truckPlateNumber,
label: `Last-mile · ${truckPrefill.truckPlateNumber}`,
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
driverName: truckPrefill.driverName ?? '',
driverPhone: truckPrefill.driverPhone ?? '',
truckType: truckPrefill.truckType ?? '',
},
]
: []),
...customerTrucks.map((t) => ({
value: t.plateNumber,
label: `Customer · ${t.plateNumber}${t.driverName}`,
trailerPlate: '',
driverName: t.driverName,
driverPhone: '',
truckType: t.truckType,
})),
];
const truckSelectOptions = [
...assignedTruckOptions,
...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({
value: t.value,
label: t.label,
trailerPlate: t.trailerPlate,
driverName: '',
driverPhone: '',
truckType: '',
})),
];
// Neither a last-mile truck nor a customer truck has been assigned yet.
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill; const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName); const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight); const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
@@ -286,18 +331,26 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
onChange={(e) => setReference(e.currentTarget.value)} onChange={(e) => setReference(e.currentTarget.value)}
readOnly={isEntranceLocked} readOnly={isEntranceLocked}
/> />
{noTruckAssigned && (
<Alert color="orange" variant="light" icon={<Info size={16} />}>
Truck is not assigned yet assign a last-mile or customer truck, or enter the plate manually below.
</Alert>
)}
<Select <Select
label="Registered first / last-mile truck" label="Registered first / last-mile truck"
placeholder="Select truck or type plate manually below" placeholder="Select truck or type plate manually below"
searchable searchable
clearable clearable
data={REGISTERED_FIRST_LAST_MILE_TRUCKS} data={truckSelectOptions}
disabled={isTruckIdentityLocked} disabled={isTruckIdentityLocked}
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null} value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => { onChange={(value) => {
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value); const truck = truckSelectOptions.find((row) => row.value === value);
setTruckPlateNumber(truck?.value ?? ''); setTruckPlateNumber(truck?.value ?? '');
setTrailerPlateNumber(truck?.trailerPlate ?? ''); setTrailerPlateNumber(truck?.trailerPlate ?? '');
if (truck?.driverName) setDriverName(truck.driverName);
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
if (truck?.truckType) setTruckType(truck.truckType);
}} }}
/> />
<Group grow> <Group grow>

View File

@@ -0,0 +1,147 @@
import { Alert, Badge, Button, Group, Loader, Modal, MultiSelect, Stack, Text } from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileText, Truck } from 'lucide-react';
import { useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface TruckDispatchModalProps {
opened: boolean;
onClose: () => void;
bookingId: string | null;
bookingReference?: string | null;
}
/**
* Truck_dispatch: after a self-haul truck arrives, staff select which of the
* booking's containers ride each truck. The loaded set drives the truck's gross
* weight; the truck is weighed for real on departure.
*/
export function TruckDispatchModal({ opened, onClose, bookingId, bookingReference }: TruckDispatchModalProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [selectedByTruck, setSelectedByTruck] = useState<Record<string, string[]>>({});
const trucksKey = ['td-customer-trucks', bookingId];
const loadableKey = ['td-loadable', bookingId];
const { data: trucks = [], isLoading: trucksLoading } = useQuery({
queryKey: trucksKey,
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const { data: loadable = [], isLoading: loadableLoading } = useQuery({
queryKey: loadableKey,
queryFn: () => warehouseService.getLoadableContainers(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const loadMutation = useMutation({
mutationFn: ({ assignmentId, containerNumbers }: { assignmentId: string; containerNumbers: string[] }) =>
warehouseService.loadTruck(bookingId as string, assignmentId, containerNumbers),
onSuccess: (_res, vars) => {
queryClient.invalidateQueries({ queryKey: trucksKey });
queryClient.invalidateQueries({ queryKey: loadableKey });
setSelectedByTruck((s) => ({ ...s, [vars.assignmentId]: [] }));
toast({ title: 'Truck loaded' });
},
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
const openTruckExitPaper = async (assignmentId: string, plate: string) => {
try {
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
openPdfBlob(res.data, `exit-${plate}.pdf`);
} catch (e) {
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
}
};
return (
<Modal
opened={opened}
onClose={onClose}
centered
size="lg"
title={
<Group gap={8}>
<Truck size={18} />
<Text fw={700}>Truck_dispatch load containers {bookingReference ? `· ${bookingReference}` : ''}</Text>
</Group>
}
>
{trucksLoading || loadableLoading ? (
<Group justify="center" py="lg">
<Loader />
</Group>
) : trucks.length === 0 ? (
<Alert color="orange" variant="light">
No customer truck is assigned to this booking yet.
</Alert>
) : (
<Stack gap="md">
{trucks.map((t) => {
const alreadyLoaded = (t.containers ?? []).map((c) => c.containerNumber);
// Options = still-loadable + this truck's own already-loaded (so they stay visible).
const options = Array.from(new Set([...loadable, ...alreadyLoaded]));
const selected = selectedByTruck[t.id] ?? alreadyLoaded;
const departed = Boolean(t.arrivedAt) && Boolean((t as { departedAt?: string }).departedAt);
return (
<Stack
key={t.id}
gap={8}
style={{ border: '1px solid #EEF2F6', borderRadius: 12, padding: 14 }}
>
<Group justify="space-between">
<Text fw={700}>{t.plateNumber}</Text>
<Group gap={6}>
<Text size="sm" c="dimmed">{t.driverName} · {t.truckType}</Text>
{t.arrivedAt ? <Badge color="green" variant="light">Arrived</Badge> : <Badge color="orange" variant="light">Not arrived</Badge>}
</Group>
</Group>
<MultiSelect
label="Containers on this truck"
placeholder="Select containers"
data={options}
value={selected}
onChange={(v) => setSelectedByTruck((s) => ({ ...s, [t.id]: v }))}
searchable
disabled={departed || !t.arrivedAt}
nothingFoundMessage="No loadable containers"
/>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="light"
color="orange"
leftSection={<FileText size={14} />}
onClick={() => openTruckExitPaper(t.id, t.plateNumber)}
>
Exit Paper
</Button>
<Button
size="xs"
color="edr-green"
disabled={departed || !t.arrivedAt || (selectedByTruck[t.id] ?? alreadyLoaded).length === 0}
loading={loadMutation.isPending}
onClick={() =>
loadMutation.mutate({
assignmentId: t.id,
containerNumbers: selectedByTruck[t.id] ?? alreadyLoaded,
})
}
>
Load truck
</Button>
</Group>
</Stack>
);
})}
</Stack>
)}
</Modal>
);
}

View File

@@ -324,6 +324,14 @@ export const URL_CONSTANTS = {
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`, PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`, FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`, DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
INTERCITY_CANDIDATES: (id: string) =>
`/train-scheduling/schedules/${id}/intercity-candidates`,
INTERCITY_ACCEPT: (id: string) =>
`/train-scheduling/schedules/${id}/intercity/accept`,
INTERCITY_LOAD: (id: string, bookingId: string) =>
`/train-scheduling/schedules/${id}/intercity/${bookingId}/load`,
INTERCITY_UNLOAD: (id: string, bookingId: string) =>
`/train-scheduling/schedules/${id}/intercity/${bookingId}/unload`,
IMPORT_LOADING_BOOKINGS: (id: string) => IMPORT_LOADING_BOOKINGS: (id: string) =>
`/train-scheduling/schedules/${id}/import-loading-bookings`, `/train-scheduling/schedules/${id}/import-loading-bookings`,
IMPORT_LOADING_STATUS: (id: string) => IMPORT_LOADING_STATUS: (id: string) =>

View File

@@ -0,0 +1,67 @@
import {
BOOKING_WINDOW_WS_EVENTS,
BOOKING_WINDOW_WS_NAMESPACE,
type BookingWindowPhaseEvent,
} from "@edr/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { io } from "socket.io-client";
import { API_BASE_URL } from "@/constants/apiConfig";
import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
// The socket namespace lives at the server root, not under the `/api` REST
// prefix — strip a trailing `/api` if the base URL carries one.
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
/**
* Subscribes to live booking-window pushes for staff. Every phase transition
* the window engine applies invalidates the GL windows carousel and the batch
* board, so both flip the moment the backend does — polling stays only as a
* fallback.
*/
export function useBookingWindowSocket(enabled: boolean = true) {
const qc = useQueryClient();
useEffect(() => {
if (!enabled) return;
const token = getCookie(AUTH_TOKEN_COOKIE);
if (!token) return;
const socket = io(`${SOCKET_ORIGIN}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
auth: { token },
transports: ["websocket"],
withCredentials: true,
});
// Deliberate console breadcrumbs: "live updates not arriving" is only
// diagnosable from the browser when connect/reject outcomes are visible.
socket.on("connect", () =>
console.debug("[booking-windows] socket connected", socket.id),
);
socket.on("connect_error", (err) =>
console.warn("[booking-windows] socket connect failed:", err.message),
);
socket.on("disconnect", (reason) =>
console.debug("[booking-windows] socket disconnected:", reason),
);
socket.on(
BOOKING_WINDOW_WS_EVENTS.PHASE,
(_event: BookingWindowPhaseEvent) => {
qc.invalidateQueries({
queryKey: ["train-scheduling", "all-booking-windows"],
});
qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
});
},
);
return () => {
socket.off();
socket.disconnect();
};
}, [enabled, qc]);
}

View File

@@ -1,6 +1,6 @@
import { NotificationType } from "@edr/types"; import { NotificationType } from "@edr/types";
import type { NotificationItemData, NotificationVisual } from "@edr/ui-common"; import type { NotificationItemData, NotificationVisual } from "@edr/ui-common";
import { Bell, ClipboardCheck, Inbox, Wallet } from "lucide-react"; import { Bell, ClipboardCheck, FileSignature, Inbox, Wallet } from "lucide-react";
const ICON_SIZE = 17; const ICON_SIZE = 17;
@@ -19,6 +19,8 @@ export function resolveNotificationVisual(
return { icon: <Wallet size={ICON_SIZE} />, color: "teal" }; return { icon: <Wallet size={ICON_SIZE} />, color: "teal" };
case NotificationType.CLEARANCE_REVIEW: case NotificationType.CLEARANCE_REVIEW:
return { icon: <ClipboardCheck size={ICON_SIZE} />, color: "orange" }; return { icon: <ClipboardCheck size={ICON_SIZE} />, color: "orange" };
case NotificationType.CONTRACT_STATUS:
return { icon: <FileSignature size={ICON_SIZE} />, color: "indigo" };
default: default:
return { icon: <Bell size={ICON_SIZE} />, color: "edr-green" }; return { icon: <Bell size={ICON_SIZE} />, color: "edr-green" };
} }
@@ -39,14 +41,32 @@ export function resolveNotificationHref(
if (item.link) return item.link; if (item.link) return item.link;
const data = item.data ?? {}; const data = item.data ?? {};
switch (item.type) { switch (item.type) {
case NotificationType.REQUEST_SUBMITTED: case NotificationType.REQUEST_SUBMITTED: {
const bookingId = asId(data.bookingId);
if (bookingId) return `/dashboard/booking-requests/${bookingId}`;
const contractId = asId(data.contractId);
if (contractId) return `/dashboard/contract-requests/${contractId}`;
return "/dashboard/booking-requests"; return "/dashboard/booking-requests";
}
case NotificationType.PAYMENT_RECEIVED: { case NotificationType.PAYMENT_RECEIVED: {
const bookingId = asId(data.bookingId);
if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`;
const id = asId(data.customerId); const id = asId(data.customerId);
return id ? `/dashboard/customers/${id}` : "/dashboard/customers"; return id ? `/dashboard/customers/${id}` : "/dashboard/customers";
} }
case NotificationType.CLEARANCE_REVIEW: case NotificationType.CLEARANCE_REVIEW: {
const bookingId = asId(data.bookingId);
if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`;
const contractId = asId(data.contractId);
if (contractId) return `/dashboard/contracts/clearance/${contractId}`;
return "/dashboard/arrival-queue"; return "/dashboard/arrival-queue";
}
case NotificationType.CONTRACT_STATUS: {
const contractId = asId(data.contractId);
return contractId
? `/dashboard/contract-requests/${contractId}`
: "/dashboard/contract-requests";
}
default: default:
return null; return null;
} }

View File

@@ -4,6 +4,7 @@ import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export const FREIGHT_PERMS = { export const FREIGHT_PERMS = {
bookings: { bookings: {
view: "edr_freight_app:bookings:view", view: "edr_freight_app:bookings:view",
create: "edr_freight_app:bookings:create",
clearanceView: "edr_freight_app:bookings:clearance_view", clearanceView: "edr_freight_app:bookings:clearance_view",
staffAccept: "edr_freight_app:bookings:staff_accept", staffAccept: "edr_freight_app:bookings:staff_accept",
requestChanges: "edr_freight_app:bookings:request_changes", requestChanges: "edr_freight_app:bookings:request_changes",
@@ -41,6 +42,11 @@ export const FREIGHT_PERMS = {
trainScheduling: { trainScheduling: {
view: "edr_freight_app:train_scheduling:view", view: "edr_freight_app:train_scheduling:view",
manage: "edr_freight_app:train_scheduling:manage", manage: "edr_freight_app:train_scheduling:manage",
create: "edr_freight_app:train_scheduling:create",
update: "edr_freight_app:train_scheduling:update",
cancel: "edr_freight_app:train_scheduling:cancel",
reschedule: "edr_freight_app:train_scheduling:reschedule",
rulesManage: "edr_freight_app:train_scheduling:rules_manage",
}, },
fleet: { fleet: {
view: "edr_freight_app:fleet:view", view: "edr_freight_app:fleet:view",
@@ -50,6 +56,243 @@ export const FREIGHT_PERMS = {
allocation: { allocation: {
manage: "edr_freight_app:allocation:manage", manage: "edr_freight_app:allocation:manage",
}, },
customers: {
view: "edr_freight_app:customers:view",
create: "edr_freight_app:customers:create",
update: "edr_freight_app:customers:update",
deactivate: "edr_freight_app:customers:deactivate",
verify: "edr_freight_app:customers:verify",
},
payments: {
view: "edr_freight_app:payments:view",
verify: "edr_freight_app:payments:verify",
refund: "edr_freight_app:payments:refund",
},
invoices: {
view: "edr_freight_app:invoices:view",
create: "edr_freight_app:invoices:create",
cancel: "edr_freight_app:invoices:cancel",
export: "edr_freight_app:invoices:export",
},
firstMile: {
view: "edr_freight_app:first_mile:view",
accept: "edr_freight_app:first_mile:accept",
create: "edr_freight_app:first_mile:create",
update: "edr_freight_app:first_mile:update",
delete: "edr_freight_app:first_mile:delete",
assignVehicles: "edr_freight_app:first_mile:assign_vehicles",
setDistances: "edr_freight_app:first_mile:set_distances",
generateInvoice: "edr_freight_app:first_mile:generate_invoice",
},
lastMile: {
view: "edr_freight_app:last_mile:view",
accept: "edr_freight_app:last_mile:accept",
create: "edr_freight_app:last_mile:create",
update: "edr_freight_app:last_mile:update",
delete: "edr_freight_app:last_mile:delete",
assignVehicles: "edr_freight_app:last_mile:assign_vehicles",
setDistances: "edr_freight_app:last_mile:set_distances",
generateInvoice: "edr_freight_app:last_mile:generate_invoice",
},
locomotives: {
view: "edr_freight_app:locomotives:view",
create: "edr_freight_app:locomotives:create",
update: "edr_freight_app:locomotives:update",
delete: "edr_freight_app:locomotives:delete",
},
wagons: {
view: "edr_freight_app:wagons:view",
create: "edr_freight_app:wagons:create",
update: "edr_freight_app:wagons:update",
delete: "edr_freight_app:wagons:delete",
},
trains: {
view: "edr_freight_app:trains:view",
create: "edr_freight_app:trains:create",
update: "edr_freight_app:trains:update",
delete: "edr_freight_app:trains:delete",
assignWagons: "edr_freight_app:trains:assign_wagons",
},
routes: {
view: "edr_freight_app:routes:view",
create: "edr_freight_app:routes:create",
update: "edr_freight_app:routes:update",
delete: "edr_freight_app:routes:delete",
},
containers: {
view: "edr_freight_app:containers:view",
create: "edr_freight_app:containers:create",
update: "edr_freight_app:containers:update",
delete: "edr_freight_app:containers:delete",
},
cargoes: {
view: "edr_freight_app:cargoes:view",
create: "edr_freight_app:cargoes:create",
update: "edr_freight_app:cargoes:update",
delete: "edr_freight_app:cargoes:delete",
},
vehicles: {
view: "edr_freight_app:vehicles:view",
create: "edr_freight_app:vehicles:create",
update: "edr_freight_app:vehicles:update",
delete: "edr_freight_app:vehicles:delete",
},
drivers: {
view: "edr_freight_app:drivers:view",
create: "edr_freight_app:drivers:create",
update: "edr_freight_app:drivers:update",
delete: "edr_freight_app:drivers:delete",
},
tracking: {
view: "edr_freight_app:tracking:view",
},
fuel: {
view: "edr_freight_app:fuel:view",
create: "edr_freight_app:fuel:create",
update: "edr_freight_app:fuel:update",
delete: "edr_freight_app:fuel:delete",
approve: "edr_freight_app:fuel:approve",
},
maintenance: {
view: "edr_freight_app:maintenance:view",
create: "edr_freight_app:maintenance:create",
update: "edr_freight_app:maintenance:update",
delete: "edr_freight_app:maintenance:delete",
complete: "edr_freight_app:maintenance:complete",
},
fleetReports: {
view: "edr_freight_app:fleet_reports:view",
export: "edr_freight_app:fleet_reports:export",
},
fleetDashboard: {
view: "edr_freight_app:fleet_dashboard:view",
},
warehouseDashboard: {
view: "edr_freight_app:warehouse_dashboard:view",
},
warehouses: {
view: "edr_freight_app:warehouses:view",
create: "edr_freight_app:warehouses:create",
update: "edr_freight_app:warehouses:update",
delete: "edr_freight_app:warehouses:delete",
},
warehouseYards: {
view: "edr_freight_app:warehouse_yards:view",
create: "edr_freight_app:warehouse_yards:create",
update: "edr_freight_app:warehouse_yards:update",
delete: "edr_freight_app:warehouse_yards:delete",
},
warehouseZones: {
view: "edr_freight_app:warehouse_zones:view",
create: "edr_freight_app:warehouse_zones:create",
update: "edr_freight_app:warehouse_zones:update",
},
warehouseAllocationRules: {
view: "edr_freight_app:warehouse_allocation_rules:view",
create: "edr_freight_app:warehouse_allocation_rules:create",
update: "edr_freight_app:warehouse_allocation_rules:update",
delete: "edr_freight_app:warehouse_allocation_rules:delete",
},
warehouseFeeRules: {
view: "edr_freight_app:warehouse_fee_rules:view",
create: "edr_freight_app:warehouse_fee_rules:create",
update: "edr_freight_app:warehouse_fee_rules:update",
delete: "edr_freight_app:warehouse_fee_rules:delete",
},
warehouseInspectionReports: {
view: "edr_freight_app:warehouse_inspection_reports:view",
create: "edr_freight_app:warehouse_inspection_reports:create",
update: "edr_freight_app:warehouse_inspection_reports:update",
},
warehouseInventory: {
view: "edr_freight_app:warehouse_inventory:view",
receive: "edr_freight_app:warehouse_inventory:receive",
move: "edr_freight_app:warehouse_inventory:move",
load: "edr_freight_app:warehouse_inventory:load",
unload: "edr_freight_app:warehouse_inventory:unload",
dispatch: "edr_freight_app:warehouse_inventory:dispatch",
gatePass: "edr_freight_app:warehouse_inventory:gate_pass",
release: "edr_freight_app:warehouse_inventory:release",
deliver: "edr_freight_app:warehouse_inventory:deliver",
inspect: "edr_freight_app:warehouse_inventory:inspect",
},
interchangeDocuments: {
view: "edr_freight_app:interchange_documents:view",
generate: "edr_freight_app:interchange_documents:generate",
acknowledge: "edr_freight_app:interchange_documents:acknowledge",
dispute: "edr_freight_app:interchange_documents:dispute",
cancel: "edr_freight_app:interchange_documents:cancel",
},
warehouseFeeInvoices: {
view: "edr_freight_app:warehouse_fee_invoices:view",
generate: "edr_freight_app:warehouse_fee_invoices:generate",
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
pay: "edr_freight_app:warehouse_fee_invoices:pay",
},
config: {
contractValidity: {
view: "edr_freight_app:config:contract_validity:view",
manage: "edr_freight_app:config:contract_validity:manage",
},
},
settings: {
fileUpload: {
view: "edr_freight_app:settings:file_upload:view",
manage: "edr_freight_app:settings:file_upload:manage",
},
dropdown: {
view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage",
},
},
staff: {
roles: {
view: "edr_freight_app:staff:roles:view",
create: "edr_freight_app:staff:roles:create",
update: "edr_freight_app:staff:roles:update",
delete: "edr_freight_app:staff:roles:delete",
},
permissions: {
view: "edr_freight_app:staff:permissions:view",
assign: "edr_freight_app:staff:permissions:assign",
},
employeeRegistration: {
view: "edr_freight_app:employee_registration:view",
create: "edr_freight_app:employee_registration:create",
update: "edr_freight_app:employee_registration:update",
activate: "edr_freight_app:employee_registration:activate",
deactivate: "edr_freight_app:employee_registration:deactivate",
},
roleAssignment: {
view: "edr_freight_app:role_assignment:view",
assign: "edr_freight_app:role_assignment:assign",
replace: "edr_freight_app:role_assignment:replace",
},
hierarchyUnits: {
view: "edr_freight_app:hierarchy_units:view",
create: "edr_freight_app:hierarchy_units:create",
update: "edr_freight_app:hierarchy_units:update",
delete: "edr_freight_app:hierarchy_units:delete",
},
hierarchyPositions: {
view: "edr_freight_app:hierarchy_positions:view",
create: "edr_freight_app:hierarchy_positions:create",
update: "edr_freight_app:hierarchy_positions:update",
delete: "edr_freight_app:hierarchy_positions:delete",
changeParent: "edr_freight_app:hierarchy_positions:change_parent",
},
hierarchyEmployeeAssignment: {
view: "edr_freight_app:hierarchy_employee_assignment:view",
invite: "edr_freight_app:hierarchy_employee_assignment:invite",
assign: "edr_freight_app:hierarchy_employee_assignment:assign",
},
positionTypes: {
view: "edr_freight_app:position_types:view",
create: "edr_freight_app:position_types:create",
update: "edr_freight_app:position_types:update",
delete: "edr_freight_app:position_types:delete",
},
},
} as const; } as const;
const slugToResourceKey = (slug: RuleEngineResourceSlug): string => const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>

View File

@@ -60,3 +60,5 @@ createRoot(rootElement).render(
</MantineProvider> </MantineProvider>
</QueryClientProvider> </QueryClientProvider>
); );
// run

View File

@@ -48,6 +48,7 @@ import {
} from "@/components/trainScheduling/containerPlacement.util"; } from "@/components/trainScheduling/containerPlacement.util";
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
@@ -1130,6 +1131,12 @@ export default function TrainScheduleV2DetailPage() {
void detailQuery.refetch(); void detailQuery.refetch();
}} }}
/> />
{scheduleId ? (
<IntercityRideAlongPanel
scheduleId={scheduleId}
direction={schedule.direction}
/>
) : null}
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>

View File

@@ -111,7 +111,12 @@ export default function TrainScheduleV2ListPage() {
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions()); const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions()); const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
const activeRoutes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]); // Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
// API rejects them, so keep them out of the picker entirely.
const activeRoutes = useMemo(
() => (routesQuery.data ?? []).filter((r) => r.direction !== "DOMESTIC"),
[routesQuery.data],
);
const selectedRoute = activeRoutes.find((r) => r.id === routeId); const selectedRoute = activeRoutes.find((r) => r.id === routeId);
@@ -380,7 +385,11 @@ export default function TrainScheduleV2ListPage() {
} }
try { try {
const created = await create.mutateAsync({ const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveIds }, payload: {
routeId,
scheduleDate: new Date(scheduleDate).toISOString(),
locomotiveIds,
},
}); });
toast({ title: "Train schedule created" }); toast({ title: "Train schedule created" });
showScheduleWarnings(created.warnings); showScheduleWarnings(created.warnings);
@@ -570,11 +579,8 @@ export default function TrainScheduleV2ListPage() {
<TextInput <TextInput
label="Departure date" label="Departure date"
type="datetime-local" type="datetime-local"
value={scheduleDate ? scheduleDate.slice(0, 16) : ""} value={scheduleDate}
onChange={(e) => { onChange={(e) => setScheduleDate(e.currentTarget.value)}
const raw = e.currentTarget.value;
setScheduleDate(raw ? new Date(raw).toISOString() : "");
}}
/> />
<MultiSelect <MultiSelect
label="Locomotives" label="Locomotives"

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Badge, Button, Card, Group, Tabs, Text } from '@mantine/core'; import { Badge, Button, Card, Group, Tabs, Text } from '@mantine/core';
import { CreditCard, Eye, Truck } from 'lucide-react'; import { CreditCard, Eye, Truck, TrainFront } from 'lucide-react';
import { DataTable, type ColumnDef } from '@edr/ui-common'; import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page'; import { PageContainer, PageHeader } from '@/components/page';
@@ -10,6 +10,7 @@ import {
VisualEmptyState, VisualEmptyState,
formatNumber, formatNumber,
} from '@/components/warehouses'; } from '@/components/warehouses';
import { LoadToTrainPanel } from '@/components/warehouses/LoadToTrainPanel';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api'; import { api } from '@/services/api';
@@ -116,6 +117,9 @@ export default function LoadingQueuePage() {
> >
Dispatch Queue Dispatch Queue
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="load-train" leftSection={<TrainFront size={14} />}>
Load to Train
</Tabs.Tab>
</Tabs.List> </Tabs.List>
{/* Ready to Load — PAID bookings, can be marked Loaded */} {/* Ready to Load — PAID bookings, can be marked Loaded */}
@@ -169,6 +173,11 @@ export default function LoadingQueuePage() {
<InventoryWorkbench items={loadedItems} isLoading={loadedLoading} /> <InventoryWorkbench items={loadedItems} isLoading={loadedLoading} />
)} )}
</Tabs.Panel> </Tabs.Panel>
{/* Load to Train — per-train arrived containers/cargoes, multiselect → load onto wagons */}
<Tabs.Panel value="load-train" pt="md">
<LoadToTrainPanel />
</Tabs.Panel>
</Tabs> </Tabs>
</Card> </Card>
</PageContainer> </PageContainer>

View File

@@ -32,7 +32,21 @@ import {
useFeeRules, useFeeRules,
} from '@/hooks/useWarehouses'; } from '@/hooks/useWarehouses';
import { api } from '@/services/api'; import { api } from '@/services/api';
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse'; import {
FEE_RULE_BASES,
FEE_RULE_BASIS_LABELS,
FEE_RULE_TYPES,
FEE_RULE_TYPE_LABELS,
type FeeRuleBasis,
type FeeRuleType,
} from '@/types/warehouse';
const RULE_TYPE_COLOR: Record<FeeRuleType, string> = {
STORAGE_FEE: 'teal',
DEMURRAGE_FEE: 'orange',
DOUBLE_HANDLING_FEE: 'grape',
TRUCK_DETENTION_FEE: 'blue',
};
import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options'; import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options';
const FREIGHT = [ const FREIGHT = [
@@ -353,6 +367,7 @@ function FeeRules() {
const [form, setForm] = useState({ const [form, setForm] = useState({
name: '', name: '',
ruleType: 'DEMURRAGE_FEE' as FeeRuleType, ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
basis: 'PER_CONTAINER' as FeeRuleBasis,
freightType: '', freightType: '',
tradeDirection: '', tradeDirection: '',
cargoTypeCode: '', cargoTypeCode: '',
@@ -367,11 +382,15 @@ function FeeRules() {
const containerTypeOptions = codeOptions(containerTypes); const containerTypeOptions = codeOptions(containerTypes);
const isBulkRule = form.freightType === 'BULK'; const isBulkRule = form.freightType === 'BULK';
const isContainerRule = form.freightType === 'CONTAINER'; const isContainerRule = form.freightType === 'CONTAINER';
// Double handling is a flat per-unit charge (basis × rate), not day-based:
// no free days, no progressive tiers.
const isDoubleHandling = form.ruleType === 'DOUBLE_HANDLING_FEE';
const resetForm = () => const resetForm = () =>
setForm({ setForm({
name: '', name: '',
ruleType: 'DEMURRAGE_FEE', ruleType: 'DEMURRAGE_FEE',
basis: 'PER_CONTAINER',
freightType: '', freightType: '',
tradeDirection: '', tradeDirection: '',
cargoTypeCode: '', cargoTypeCode: '',
@@ -440,10 +459,12 @@ function FeeRules() {
tradeDirection: clean(form.tradeDirection) ?? null, tradeDirection: clean(form.tradeDirection) ?? null,
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null, cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null, containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
freeDays: form.freeDays, // Double handling: flat basis × rate — no free days, no tiers.
freeDays: isDoubleHandling ? 0 : form.freeDays,
ratePerDay: form.ratePerDay, ratePerDay: form.ratePerDay,
currency: form.currency || 'USD', currency: form.currency || 'USD',
...(tiers.length ? { tiers } : {}), ...(isDoubleHandling ? { basis: form.basis } : {}),
...(!isDoubleHandling && tiers.length ? { tiers } : {}),
}; };
try { try {
@@ -514,8 +535,8 @@ function FeeRules() {
{rules.map((rule) => ( {rules.map((rule) => (
<Table.Tr key={rule.id}> <Table.Tr key={rule.id}>
<Table.Td> <Table.Td>
<Badge color={rule.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light"> <Badge color={RULE_TYPE_COLOR[rule.ruleType] ?? 'gray'} variant="light">
{rule.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'} {FEE_RULE_TYPE_LABELS[rule.ruleType] ?? rule.ruleType}
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td>{rule.name}</Table.Td> <Table.Td>{rule.name}</Table.Td>
@@ -577,7 +598,7 @@ function FeeRules() {
label="Rule type" label="Rule type"
data={FEE_RULE_TYPES.map((type) => ({ data={FEE_RULE_TYPES.map((type) => ({
value: type, value: type,
label: type === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage', label: FEE_RULE_TYPE_LABELS[type],
}))} }))}
value={form.ruleType} value={form.ruleType}
onChange={(value) => onChange={(value) =>
@@ -637,14 +658,26 @@ function FeeRules() {
/> />
)} )}
<Group grow> <Group grow>
{isDoubleHandling ? (
<Select
label="Basis"
data={FEE_RULE_BASES.map((b) => ({ value: b, label: FEE_RULE_BASIS_LABELS[b] }))}
value={form.basis}
onChange={(value) =>
setForm((f) => ({ ...f, basis: selectValue(value, 'PER_CONTAINER') as FeeRuleBasis }))
}
allowDeselect={false}
/>
) : (
<NumberInput
label="Free days"
min={0}
value={form.freeDays}
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
/>
)}
<NumberInput <NumberInput
label="Free days" label={isDoubleHandling ? 'Rate / unit' : 'Rate / day'}
min={0}
value={form.freeDays}
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
/>
<NumberInput
label="Rate / day"
min={0} min={0}
value={form.ratePerDay} value={form.ratePerDay}
onChange={(value) => setForm((f) => ({ ...f, ratePerDay: numberValue(value) }))} onChange={(value) => setForm((f) => ({ ...f, ratePerDay: numberValue(value) }))}
@@ -657,6 +690,13 @@ function FeeRules() {
allowDeselect={false} allowDeselect={false}
/> />
</Group> </Group>
{isDoubleHandling && (
<Text size="xs" c="dimmed">
Flat charge the rate is multiplied by the selected basis (
{FEE_RULE_BASIS_LABELS[form.basis]}). No free days or progressive tiers.
</Text>
)}
{!isDoubleHandling && (
<Stack gap="xs"> <Stack gap="xs">
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
@@ -702,6 +742,7 @@ function FeeRules() {
</Text> </Text>
)} )}
</Stack> </Stack>
)}
<Group justify="flex-end" mt="sm"> <Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}> <Button variant="default" onClick={() => setOpen(false)}>
Cancel Cancel

View File

@@ -593,6 +593,52 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS, () => TRAIN_SCHEDULING_INVALIDATIONS,
), ),
intercityCandidates: endpoint<
{ scheduleId: string },
import("@/types/trainScheduling").IntercityCandidatesResult
>(
"train-scheduling",
"intercity-candidates",
({ scheduleId }) => trainSchedulingService.getIntercityCandidates(scheduleId),
({ scheduleId }) => ["train-scheduling", "intercity-candidates", scheduleId],
),
acceptIntercityBookings: endpoint<
{ scheduleId: string; bookingIds: string[] },
import("@/types/trainScheduling").IntercityAcceptResult
>(
"train-scheduling",
"intercity-accept",
({ scheduleId, bookingIds }) =>
trainSchedulingService.acceptIntercityBookings(scheduleId, bookingIds),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
loadIntercityBooking: endpoint<
{ scheduleId: string; bookingId: string },
void
>(
"train-scheduling",
"intercity-load",
({ scheduleId, bookingId }) =>
trainSchedulingService.loadIntercityBooking(scheduleId, bookingId),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
unloadIntercityBooking: endpoint<
{ scheduleId: string; bookingId: string },
void
>(
"train-scheduling",
"intercity-unload",
({ scheduleId, bookingId }) =>
trainSchedulingService.unloadIntercityBooking(scheduleId, bookingId),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
cancelSchedule: endpoint< cancelSchedule: endpoint<
{ id: string; freightType?: FreightType }, { id: string; freightType?: FreightType },
TrainScheduleDetail TrainScheduleDetail

View File

@@ -4,6 +4,9 @@ import { URL_CONSTANTS } from '@/constants/URLS';
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING'; export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
/** Frozen from yard countries: ET→DJ EXPORT, DJ→ET IMPORT, same country DOMESTIC (intercity, disabled). */
export type RouteDirection = 'IMPORT' | 'EXPORT' | 'DOMESTIC';
export interface YardRef { export interface YardRef {
id: string; id: string;
code: string; code: string;
@@ -23,6 +26,7 @@ export interface RouteMilestone {
export interface RouteRecord { export interface RouteRecord {
id: string; id: string;
status: RouteStatus; status: RouteStatus;
direction?: RouteDirection;
originYardId: string; originYardId: string;
destinationYardId: string; destinationYardId: string;
originYard?: YardRef | null; originYard?: YardRef | null;

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