From 8b6b1596b54ac8e131b96f039a076ad98560ff75 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 6 Jul 2026 11:37:57 +0000 Subject: [PATCH 01/23] chore: docs to freight --- .../docs/FREIGHT_FLOW_VARIANTS.md | 604 ++++++++++++ .../docs/FREIGHT_MASTER_FLOW.md | 260 ++++++ .../docs/FREIGHT_SYSTEM_FLOW.md | 872 ++++++++++++++++++ 3 files changed, 1736 insertions(+) create mode 100644 apps/edr-freight-api/docs/FREIGHT_FLOW_VARIANTS.md create mode 100644 apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md create mode 100644 apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md diff --git a/apps/edr-freight-api/docs/FREIGHT_FLOW_VARIANTS.md b/apps/edr-freight-api/docs/FREIGHT_FLOW_VARIANTS.md new file mode 100644 index 000000000..53c8b76c3 --- /dev/null +++ b/apps/edr-freight-api/docs/FREIGHT_FLOW_VARIANTS.md @@ -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?
(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
(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
reference, containers, cargo modifiers, files (P)"]:::port + d2["POST /bookings/:id/generate-price
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
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
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
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
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:
§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 1–3) **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
(see §1 phases 1–3)"]):::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
→ 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
→ 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
OPERATION_REQUEST_PENDING here (not from
AWAITING_DOCUMENTS / DOCUMENTS_UNDER_REVIEW)"]):::bad + phys(["Physical execution:
§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
routes + cargo scope + unit rates (NO quantities) (P)"]:::port + c2["generate-price → submit → SUBMITTED
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) →
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:
window + capacity draw-down + pairing"}:::dec + bvx(["rejected: over capacity /
20ft pairing hard-block"]):::bad + b2["Booking created under contract
(bookings.contract_id) (sys)"]:::sys + b1 --> bv + bv -->|"fail"| bvx + bv -->|"ok"| b2 + end + + op(["Booking runs operation + payment
(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
(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
(date + quantities, no per-unit data) (P)"]:::port + r2{"GL booking-request queue (B)"}:::dec + r2x(["reject / customer cancel →
REJECTED / CANCELLED"]):::bad + r3["GL accept → GL creates booking under contract
(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)
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
(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
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
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
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)
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 →
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
(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)
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?
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
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):
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
(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). diff --git a/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md b/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md new file mode 100644 index 000000000..ea68e6938 --- /dev/null +++ b/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md @@ -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
GET /auth/check-availability @Public
POST /otp/send + /otp/verify (P)"]:::port + S1 --> S2{"Identity proofing
(VeriFayda)?"}:::dec + S2 -->|"Yes"| S3["POST /fayda/verification/start →
/callback → /complete
upsert iam.users (verified_by=fayda) (P)"]:::port + S2 -->|"No"| S4 + S3 --> S4["POST /companies/onboarding/start
draft company (placeholder TIN, PENDING) (P)"]:::port + S4 --> S4b["Wizard: PATCH /profile, /onboarding-step,
upload license + docs
GET /onboarding/requirements (P)"]:::port + S4b --> S5["POST /companies/onboarding/complete
re-validate → company+profiles = PENDING (P)"]:::port + S5 --> S6{"Backoffice reviews profile
PATCH /company-profiles/:id/status (B)"}:::dec + S6 -->|"Reject / suspend"| S6x(["SUSPENDED / BLACKLISTED
cannot transact"]):::bad + S6 -->|"Approve"| S7["Mint reference (EX-#####),
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
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
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
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
instantiate approval steps (B)"]:::back + C4 --> C4a{"Approval chain
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
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
under the contract?"}:::dec + CPATH -->|"Path A: transport-only"| CPA["Ops review self-clearance
ops-review → ops-finalize → SELF_CLEARED (B)
then customer books direct
POST /contracts/:id/bookings (P)"]:::port + CPATH -->|"Path B: GENERAL + customs"| CPB["Customer submits BookingRequest
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
(window + capacity draw-down check) (sys)"]:::sys + B0u --> BFLOW + + %% ---- One-time booking ---- + B0["Create BOOKING DRAFT
POST /bookings (reference, containers,
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
waits for partner shipment
(shares a wagon) → rejoins"]):::sys + BCONS -.-> BFLOW + + %% ================= PHASE 3: PRICING & SUBMIT ================= + BFLOW["Configure shipment
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
rule-engine: LIVE rates + surcharges
HAZARDOUS / REEFER / OVERWEIGHT /
SHIPPING_LINE / CONSOLIDATION (P)"]:::port + B1 --> B1w{"weight-limit-rules check"}:::dec + B1w -->|"VGM > maxCapacity"| B1x(["HARD BLOCK (400)
cannot submit"]):::bad + B1w -->|"over maxVgm, within cap"| B1warn["warning + OVERWEIGHT surcharge"]:::sys + B1w -->|"ok"| B2 + B1warn --> B2 + B2["POST /bookings/:id/submit → SUBMITTED
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 →
PAID + schedulingStatus Eligible (B)"]:::back + GOV -->|"No (commercial)"| BI{"Staff intake
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
instantiate approval steps
(set validity window) (B)"]:::back + BA --> BAc{"Approval chain
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
POST /bookings/:id/contract/sign (P)"]:::port + BC2 --> BC3{"Staff counter-sign:
trade direction?"}:::dec + BC3 -->|"IMPORT / EXPORT
(clearance gate, even if customs off)"| CL1 + BC3 -->|"DOMESTIC"| FEXD["counter-sign → FULLY_EXECUTED
enqueue batch (skips clearance + op-request) (sys)(B)"]:::back + FEXD --> FEB + + %% ================= PHASE 6: CUSTOMS CLEARANCE ================= + CL1["AWAITING_DOCUMENTS → customer uploads
POST /bookings/:id/clearance/documents
→ DOCUMENTS_UNDER_REVIEW (P)"]:::port + CL1 --> CL2{"GL reviews each doc
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):
declaration → duty advise → duty slip →
transit permit → delivery/release order →
T1 docs/close → export release (sys)(B)"]:::back + CLph --> OP1 + + %% ================= PHASE 7: OPERATION REQUEST ================= + OP1["clearance/proceed: pick binding schedule day
→ OPERATION_REQUEST_PENDING (P)"]:::port + OP1 --> OP2{"Operations review
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
(day batch pool) (B)"]:::back + OPM -->|"ROAD (truck)"| OP3r["invoice generated →
ROAD_DISPATCH_PENDING (billed by KM) (B)"]:::back + OP3t --> FEB["batch engine offers wagons →
SELECTED_FOR_BATCH (sys)"]:::sys + + %% ================= PHASE 8: INVOICE & PAYMENT ================= + GEXP --> SCH + FEB --> PAY1 + OP3r --> PAY1 + PAY1["Invoice (source=booking, INV-YYYYMMDD-#####, due +14d)
booking invoice starts DRAFT → ISSUED at operation-accept (sys)"]:::sys + PAY1 --> PAY2["Customer pays
POST /billing/my-invoices/:id/pay →
billing.payInvoice → payment-api initiate (P)"]:::port + PAY2 --> PAYp{"Provider result
(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 →
POST /internal/payments/mark-paid →
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
POST /train-scheduling/{container|bulk}/schedules
≥2 locomotives, derive direction (B)"]:::back + SC2 --> SC3["assign-bookings + run-allocation
(wagon_booking_allocations) (B)"]:::back + SC3 --> SC4["pin physical wagons → finalize → SCHEDULED
bookings → Scheduled (B)"]:::back + SC4 -.->|"cancel schedule"| SC4x["bookings back to Eligible (B)"]:::back + SC4x -.-> SC2 + SC4 -.->|"gov preempt / maintenance"| RESCH["reschedule: retained / displaced /
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
firstMile.acceptBooking (READY_TO_TRANSIT) (sys)"]:::sys + FMQ0 -->|"No"| WO1 + FM1 --> FM2["setVehicles → vehicle BUSY, SMS driver,
fleet_events (B)"]:::back + FM2 --> FM3["IN_TRANSIT (needs vehicle) →
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
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
(+ warehouse_loadings) (B)"]:::back + WO6 --> TR1 + + %% ================= PHASE 12: DISPATCH & TRANSIT ================= + TR1["dispatch → DISPATCHED
assign train_number, locos ASSIGNED,
window CLOSED, unpaid reservations EXPIRED (B)"]:::back + TR1 --> TR2["record checkpoints (corridor stations) →
train_checkpoint_events (B)"]:::back + TR2 --> TRC["Customer tracking page
GET /tracking/:consignmentId (JWT) (P)"]:::port + TR2 --> TR3["arrive (final checkpoint) → ARRIVED
bookings IN_TRANSIT, locos+wagons freed,
warehouse arrival automation (B)"]:::back + TR3 --> WD1 + + %% ================= PHASE 13: WAREHOUSE DEST + IMPORT CUSTOMS ================= + WD1["destination warehouse: auto-unload arrived
→ 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 /
UNLOADED_AT_DJIBOUTI_PORT (B)"]:::back + WD3 --> IMP1["Import customs finalization:
upload docs → declaration → notify duties →
duties paid (needs slip) → assign risk →
release-permitted (all gates) (sys)(B)"]:::back + IMP1 --> LMQ + WDX --> ICD["interchange document (handover manifest)
generate-from-schedule → GENERATED →
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
(IMPORT inspection PASSED only) (sys)"]:::sys + LMQ -->|"No"| DE1 + LM1 --> LM2["setVehicles → IN_TRANSIT → DELIVERED
(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
findBlockingInvoice / assertClearanceAllowed (B)"]:::back + DE1x --> DE1p["Customer pays storage/demurrage
warehouse-fee-invoices/:id/pay-online (P)"]:::port + DE1p --> DE1 + DE1 -->|"Yes"| DE2["release order (DO) + gate-clearance →
deliver (B)"]:::back + DE2 --> DE3["Customer approves delivery (saved signature)
POST /warehouse-inventory/bookings/:id/approve-delivery (P)"]:::port + DE3 --> DE4["inventory DELIVERED, POD to cargo,
container freed, capacity released (sys)"]:::sys + DE4 --> DONE(["Booking COMPLETED (done)
operations/complete"]):::good + + %% ================= GLOBAL EXITS ================= + GEXIT(["CANCELLED — POST /bookings/:id/cancel (staff-only)
ONLY from DRAFT, SUBMITTED, PRICE_CHANGED_PENDING_CONFIRM,
CHANGES_REQUESTED, PENDING_APPROVAL, CONTRACT_READY,
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. diff --git a/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md b/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md new file mode 100644 index 000000000..a6fd8bbeb --- /dev/null +++ b/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md @@ -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,
credentials, IAM headers)"] + JWT["JwtGuard (global APP_GUARD)
+ HasActiveDelegationGuard"] + PERM["FreightPermissionGuard
(per-route perms)"] + VP["ValidationPipe
(implicitConversion OFF)"] + RTI["ResponseTransformInterceptor
→ { 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
schema: freight")] + MINIO[("MinIO
object store")] + MQ{{"RabbitMQ"}} + PAY["(red) Payment API :3003
schema: edr_payment"] + end + + P -->|"Bearer token (cookie)
axios interceptor"| CORS + B -->|"Bearer token (cookie)
axios interceptor"| CORS + CORS --> JWT --> PERM --> VP --> DOM + DOM --> RTI + DOM --> PG + DOM --> MINIO + DOM -->|"send-sms / send-email"| MQ + DOM -->|"POST /payments/initiate
x-service-token"| PAY + PAY -->|"payment.succeeded webhook
→ /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 →
Authorization: Bearer [auth-token cookie] + AX->>API: HTTP /api// + 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
(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 →
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
(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,
self-clearance"| bookA["Customer books directly
POST /contracts/:id/bookings (P)"] + cactive -->|"Path B: GENERAL + customs"| breq["Customer submits BookingRequest (P)
→ GL accepts → GL creates booking (B)"] + bookA --> draft2["Booking created under contract"] + breq --> draft2 + + %% Booking spine + draft --> price["Generate price (P)
(rule-engine: rates + surcharges)"] + draft2 --> price + price --> submit["Submit → SUBMITTED (P)"] + submit --> intake["Staff accept → PENDING_APPROVAL (B)
(instantiate approval steps)"] + intake --> appr["Approval chain:
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 →
customer uploads docs (P) →
GL review/finalize (B) → CLEARANCE_READY"] + clr --> opreq["Customer requests operation (P)
(binding schedule date)"] + ready --> opreq + opreq --> oprev{"Operations review (B)"} + oprev -->|"Request changes"| clr + oprev -->|"Accept: train"| inv["Invoice generated →
enters day batch pool"] + oprev -->|"Accept: road/truck"| road["ROAD_DISPATCH_PENDING
(billed by KM)"] + + inv --> pay["Customer pays invoice (P)
→ (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 →
allocate wagons → finalize → SCHEDULED (B)"] + fm{"EXPORT + first-mile?"} + fmleg["First-mile leg: truck pickup →
RECEIVED_TO_PORT (B)"] + wh_in["Warehouse receive → inspect →
STORED → READY_FOR_LOADING → LOADED (B)"] + disp["Dispatch train → DISPATCHED
(locos ASSIGNED, unpaid EXPIRED) (B)"] + track["Checkpoints logged →
tracking events (customer sees) (P)"] + arrive["Arrive → ARRIVED
(bookings IN_TRANSIT, wagons freed) (B)"] + wh_out["Destination warehouse:
unload → inspect → READY_FOR_PICKUP (B)"] + lm{"IMPORT + last-mile?"} + lmleg["Last-mile leg: truck delivery →
DELIVERED (B)"] + imp["Import customs finalization
(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
(warehouse fees must be PAID) (B)"] + deliver --> pod["Customer approves delivery /
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)
(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
→ eSignet authorize URL"] + fstart --> fcb["Fayda redirect → GET /callback (ack)
→ GET /fayda/verification/complete
(PKCE code exchange → upsert iam.users)"] + fcb --> onb + fayda -->|"skip"| onb + + onb["POST /companies/onboarding/start (P)
(draft company, placeholder TIN, PENDING)"] + onb --> wiz["Wizard saves incrementally (P):
PATCH /profile · /onboarding-step ·
upload license & docs"] + wiz --> reqs["GET /companies/onboarding/requirements
(server-driven checklist)"] + reqs --> comp["POST /companies/onboarding/complete
→ profiles + company = PENDING"] + comp --> review["Backoffice approves (B):
PATCH /companies/company-profiles/:id/status
→ mint reference, company → ACTIVE"] + review --> book(["Can now book
(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)
(ops-review → ops-finalize → SELF_CLEARED)"] + a1 --> a2["Customer books directly (P)
POST /contracts/:id/bookings"] + path -->|"Path B: GENERAL + customs"| b1["Customer submits BookingRequest (P)
POST /contracts/:id/booking-requests"] + b1 --> b2["GL queue → accept (B)
(ct:create_booking) → GL creates booking"] + a2 --> cap["createUnderContract:
window + capacity draw-down check"] + b2 --> cap + cap --> spawn(["New Booking under contract
(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()
(only LIVE)"] + rates --> sur{"surcharge triggers"} + sur -->|"HAZARDOUS/REEFER/OVERWEIGHT/
SHIPPING_LINE/CONSOLIDATION"| mods["appliedModifiers →
surcharge line-items"] + mods --> snap["snapshotRates → booking_rate_snapshots
(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 →
instantiateApprovalSteps
(requiredRole/blocksRole/stepOrder)"] + ev --> prio["priority-configs + serviceType bonus +
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 `/` 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
(auto-create leg)"] + fmreq -->|No| skip1["—"] + fmaccept --> fmleg + + subgraph FM["First-mile (EXPORT origin road leg)"] + fmleg["READY_TO_TRANSIT"] --> fmveh["setVehicles → vehicle BUSY,
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
(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)
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
(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:
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.* From b45d180ee41d477048577c187316ab9a5b2eb184 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 6 Jul 2026 15:01:55 +0300 Subject: [PATCH 02/23] fix: ( pnpm ) lock file --- apps/edr-passenger-api/package.json | 3 +- pnpm-lock.yaml | 46 ++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index b3d77d220..dd8058d7d 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -46,6 +46,7 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "dev": "^0.1.5", "dotenv": "^17.4.2", "express": "^4.18.2", "helmet": "^8.0.0", @@ -66,8 +67,8 @@ "@nestjs/schematics": "^11.1.0", "@nestjs/testing": "^11.1.19", "@types/express": "^4.17.21", - "@types/luxon": "^3.7.1", "@types/jest": "^29.5.11", + "@types/luxon": "^3.7.1", "@types/node": "^20.10.6", "@types/qrcode": "^1.5.5", "@types/supertest": "^6.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e278995a..15ccf0e7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -537,6 +537,9 @@ importers: class-validator: specifier: ^0.14.0 version: 0.14.4 + dev: + specifier: ^0.1.5 + version: 0.1.5 dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -5339,6 +5342,9 @@ packages: binary@0.3.0: resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -6142,6 +6148,10 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dev@0.1.5: + resolution: {integrity: sha512-Fix+RToMKpGzEps6m/2HaHQrbp9iGo32hU41Q3LKC+zRy8R03jeu5IAv3S0ZuLXdJy7g8avCayINJMBLXT+5/A==} + hasBin: true + devtools-protocol@0.0.1608973: resolution: {integrity: sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==} @@ -6766,6 +6776,9 @@ packages: resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} engines: {node: '>=20'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@4.0.0: resolution: {integrity: sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==} engines: {node: '>=0.10.0'} @@ -7329,6 +7342,11 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + inotify@1.4.6: + resolution: {integrity: sha512-WW8/uqIA04O3AePQVe/Ms3ZLR0yGamaz8YOEpaXc4WBAGOPZfzu58wWErEPSUYaPyDrJRIeCn6PEIQgC1ZyQ5w==} + engines: {node: '>=0.8'} + os: [linux] + input-format@0.3.14: resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} peerDependencies: @@ -8595,6 +8613,9 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -15461,9 +15482,9 @@ snapshots: dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) @@ -15584,9 +15605,9 @@ snapshots: dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) @@ -17029,6 +17050,10 @@ snapshots: buffers: 0.1.1 chainsaw: 0.1.0 + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -17837,6 +17862,10 @@ snapshots: detect-node-es@1.1.0: {} + dev@0.1.5: + dependencies: + inotify: 1.4.6 + devtools-protocol@0.0.1608973: {} dezalgo@1.0.4: @@ -18737,6 +18766,8 @@ snapshots: transitivePeerDependencies: - supports-color + file-uri-to-path@1.0.0: {} + fill-range@4.0.0: dependencies: extend-shallow: 2.0.1 @@ -19357,6 +19388,11 @@ snapshots: ini@4.1.1: {} + inotify@1.4.6: + dependencies: + bindings: 1.5.0 + nan: 2.28.0 + input-format@0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: prop-types: 15.8.1 @@ -20781,6 +20817,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nan@2.28.0: {} + nanoid@3.3.12: {} nanomatch@1.2.13: From fe9503638bc15bc60cca98ddde9b8a042a958fe6 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 6 Jul 2026 12:27:58 +0000 Subject: [PATCH 03/23] separte handover edr lastmile and customer last mile flow --- .../1980000000000-AddBookingHandovers.ts | 47 +++++++ .../entities/booking-handover.entity.ts | 46 +++++++ .../modules/warehouses/handover.service.ts | 123 ++++++++++++++++++ .../warehouse-inventory.controller.ts | 8 ++ .../warehouses/warehouse-inventory.service.ts | 77 ++++++++++- .../modules/warehouses/warehouses.module.ts | 4 + .../warehouses/ReceiveInventoryModal.tsx | 2 +- .../warehouses/ReleaseOrderModal.tsx | 61 ++++++++- .../src/services/warehouse.service.ts | 8 ++ 9 files changed, 367 insertions(+), 9 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1980000000000-AddBookingHandovers.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/handover.service.ts diff --git a/apps/edr-freight-api/src/migrations/1980000000000-AddBookingHandovers.ts b/apps/edr-freight-api/src/migrations/1980000000000-AddBookingHandovers.ts new file mode 100644 index 000000000..a47ff6297 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1980000000000-AddBookingHandovers.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_handovers;`); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts new file mode 100644 index 000000000..f5a730ea8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts new file mode 100644 index 000000000..52bd318e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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')}`; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 6b30dad1e..aab2fccbb 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -15,6 +15,7 @@ import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; import { SchedulingReadFacade } from './scheduling-read.facade'; import { WarehouseInventoryService } from './warehouse-inventory.service'; +import { HandoverService } from './handover.service'; @ApiTags('warehouse-inventory') @ApiBearerAuth() @@ -23,6 +24,7 @@ export class WarehouseInventoryController { constructor( private readonly inventoryService: WarehouseInventoryService, private readonly scheduling: SchedulingReadFacade, + private readonly handoverService: HandoverService, ) {} @Get() @@ -312,6 +314,12 @@ export class WarehouseInventoryController { 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); + } + @Post(':id/deliver') @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index c9844051b..b16454f9d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -38,6 +38,7 @@ import { WarehouseActivityLogService } from './warehouse-activity-log.service'; import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseLoadingRepository } from './warehouse-loading.repository'; 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). */ const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED']; @@ -342,6 +343,7 @@ export class WarehouseInventoryService { private readonly lastMileService: LastMileService, private readonly notifications: NotificationsService, private readonly signatures: SignaturesService, + private readonly handover: HandoverService, ) {} /** @@ -2080,9 +2082,14 @@ export class WarehouseInventoryService { [item.bookingId], ); 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( - '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 +2144,16 @@ export class WarehouseInventoryService { AND deleted_at IS NULL`, [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( { @@ -2388,7 +2405,9 @@ export class WarehouseInventoryService { return { filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, - buffer: await this.releaseDocuments.htmlToPdfBuffer(html), + // Generic render — NOT the release-order fallback (would mislabel the GRN + // as a "Gate Clearance / Release Order" when Chromium is unavailable). + buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Goods Received Note'), }; } @@ -2462,6 +2481,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 { bookingId, inventoryId: item.id, @@ -2589,7 +2612,9 @@ export class WarehouseInventoryService { return { filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, - buffer: await this.releaseDocuments.htmlToPdfBuffer(html), + // Generic render — NOT the release-order fallback (would mislabel the + // handover as a "Gate Clearance / Release Order" when Chromium is down). + buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Import Goods Handover'), }; } @@ -2601,6 +2626,29 @@ export class WarehouseInventoryService { 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 deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date(); const weight = Number(item.weight) || 0; @@ -2645,6 +2693,27 @@ export class WarehouseInventoryService { }, 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); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index b871d2a36..5f0ce5749 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -15,6 +15,8 @@ import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.en import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.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 { WarehouseLoading } from './entities/warehouse-loading.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity'; @@ -65,6 +67,7 @@ import { WarehousesService } from './warehouses.service'; WarehouseInspectionReport, WarehouseAllocationRule, WarehouseFeeRule, + BookingHandover, ]), BillingModule, DocumentsModule, @@ -113,6 +116,7 @@ import { WarehousesService } from './warehouses.service'; WarehouseSchedulingAdapterService, WarehouseReleaseDocumentService, SchedulingReadFacade, + HandoverService, ], exports: [ WarehousesService, diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 32b0b5a5d..2002f3261 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -2421,7 +2421,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { loading={busyId === r.id} onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))} > - Dispatch + Truck_dispatch )} {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index b962a7393..0471d51c6 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; 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 { useToast } from '@/hooks/use-toast'; @@ -134,6 +134,13 @@ const parseInspectionNote = (notes: string | null | undefined) => { export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) { const { toast } = useToast(); 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 [truckPlateNumber, setTruckPlateNumber] = useState(''); const [trailerPlateNumber, setTrailerPlateNumber] = useState(''); @@ -178,6 +185,44 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const isEntranceLocked = isExitStep; const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt); 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 isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName); 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)} readOnly={isEntranceLocked} /> + {noTruckAssigned && ( + }> + Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below. + + )} {isOvernight && !is24h ? ( @@ -290,7 +301,6 @@ export default function BookingWindowSettingsModal({ color="grape" label="Run 24 hours a day (never pause overnight)" checked={is24h} - disabled={isExport} onChange={(e) => { const checked = e.currentTarget.checked; setForm((f) => { @@ -304,12 +314,11 @@ export default function BookingWindowSettingsModal({ }); }} /> - {!isExport ? ( - - 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. - - ) : null} + + {isExport + ? "If the export lead time lands while the desk is shut, booking opens at the next desk opening instead." + : "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."} + @@ -367,27 +376,43 @@ export default function BookingWindowSettingsModal({ {/* ── Lead time ────────────────────────────────────────────────── */} - - setForm( - (f) => - f && { - ...f, - importWindowLeadDays: v === "" ? "" : Number(v), - }, - ) - } - min={0} - clampBehavior="none" - allowDecimal={false} - /> + {isExport ? ( + + setForm( + (f) => + f && { + ...f, + exportBookingLeadHours: v === "" ? "" : Number(v), + }, + ) + } + min={1} + clampBehavior="none" + allowDecimal={false} + /> + ) : ( + + setForm( + (f) => + f && { + ...f, + importWindowLeadDays: v === "" ? "" : Number(v), + }, + ) + } + min={0} + clampBehavior="none" + allowDecimal={false} + /> + )} + + + )} + + {accepted.length > 0 && ( + <> + + On this train + + + + + + Booking + Customer + Corridor + Status + + + + + {accepted.map((row) => ( + + + + {row.reference ?? row.id.slice(0, 8)} + + + + {row.customer} + + + + + + + {row.status} + + + + + {row.status === "PAID" && ( + + + + )} + {row.status === "IN_TRANSIT" && ( + + + + )} + + + + ))} + +
+
+ + )} + + {candidatesQuery.isError && ( + }> + {parseError(candidatesQuery.error, "Could not load intercity candidates")} + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index fbfd2f998..fce620235 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -324,6 +324,14 @@ export const URL_CONSTANTS = { PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`, FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`, 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) => `/train-scheduling/schedules/${id}/import-loading-bookings`, IMPORT_LOADING_STATUS: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts new file mode 100644 index 000000000..375c12f19 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts @@ -0,0 +1,55 @@ +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, + }); + + 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]); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 249130e61..7ee0f7896 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -48,6 +48,7 @@ import { } from "@/components/trainScheduling/containerPlacement.util"; import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; +import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; @@ -1130,6 +1131,12 @@ export default function TrainScheduleV2DetailPage() { void detailQuery.refetch(); }} /> + {scheduleId ? ( + + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index bc5b7dcb1..f36ce8394 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -111,7 +111,12 @@ export default function TrainScheduleV2ListPage() { const create = useMutation(api.trainScheduling.createSchedule.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); @@ -380,7 +385,11 @@ export default function TrainScheduleV2ListPage() { } try { const created = await create.mutateAsync({ - payload: { routeId, scheduleDate, locomotiveIds }, + payload: { + routeId, + scheduleDate: new Date(scheduleDate).toISOString(), + locomotiveIds, + }, }); toast({ title: "Train schedule created" }); showScheduleWarnings(created.warnings); @@ -570,11 +579,8 @@ export default function TrainScheduleV2ListPage() { { - const raw = e.currentTarget.value; - setScheduleDate(raw ? new Date(raw).toISOString() : ""); - }} + value={scheduleDate} + onChange={(e) => setScheduleDate(e.currentTarget.value)} /> 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< { id: string; freightType?: FreightType }, TrainScheduleDetail diff --git a/apps/edr-freight-web/backoffice/src/services/routes.service.ts b/apps/edr-freight-web/backoffice/src/services/routes.service.ts index 26c340e83..65de13760 100644 --- a/apps/edr-freight-web/backoffice/src/services/routes.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/routes.service.ts @@ -4,6 +4,9 @@ import { URL_CONSTANTS } from '@/constants/URLS'; 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 { id: string; code: string; @@ -23,6 +26,7 @@ export interface RouteMilestone { export interface RouteRecord { id: string; status: RouteStatus; + direction?: RouteDirection; originYardId: string; destinationYardId: string; originYard?: YardRef | null; diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 8c969b06d..3f4c6822f 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -17,6 +17,8 @@ import type { ImportDjiboutiLoadList, ImportDjiboutiOperation, ImportLoadingBookingsResponse, + IntercityAcceptResult, + IntercityCandidatesResult, LoadingStatus, LocomotiveRecord, PinWagonsPayload, @@ -328,6 +330,46 @@ export const trainSchedulingService = { return unwrap(response.data); }, + getIntercityCandidates: async ( + scheduleId: string, + ): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_CANDIDATES(scheduleId), + ); + return unwrap(response.data); + }, + + acceptIntercityBookings: async ( + scheduleId: string, + bookingIds: string[], + ): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_ACCEPT(scheduleId), + { bookingIds }, + ); + return unwrap(response.data); + }, + + loadIntercityBooking: async ( + scheduleId: string, + bookingId: string, + ): Promise => { + await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_LOAD(scheduleId, bookingId), + {}, + ); + }, + + unloadIntercityBooking: async ( + scheduleId: string, + bookingId: string, + ): Promise => { + await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_UNLOAD(scheduleId, bookingId), + {}, + ); + }, + dispatchSchedule: async ( scheduleId: string, ): Promise => { diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 4807d00fb..68ad375d8 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -407,6 +407,7 @@ export interface ScheduleWindowRule { windowDurationHours: number | null; reopenDelayMinutes: number | null; importWindowLeadDays: number | null; + exportBookingLeadHours: number | null; /** Live global values (not snapshotted per schedule) — editor prefill baseline. */ docReviewMinutes: number; paymentWindowMinutes: number; @@ -420,6 +421,7 @@ export interface UpdateScheduleWindowRulePayload { docReviewMinutes?: number; paymentWindowMinutes?: number; importWindowLeadDays?: number; + exportBookingLeadHours?: number; } export interface TrainScheduleDetail { @@ -746,3 +748,49 @@ export interface CompositionRemovalEntry { removedAt: string; notes: string | null; } + +// ── Intercity ride-along ───────────────────────────────────────────────────── +// Intercity (DOMESTIC) bookings have no train of their own — they ride a +// passing import/export schedule whose route milestones contain the booking's +// origin before its destination. Staff accept them at finalize time against +// the train's remaining wagon/weight/length capacity. + +export interface IntercityCapacity { + wagons: number; + weightTons: number; + lengthMeters: number; +} + +export interface IntercityBookingRow { + id: string; + reference: string | null; + status: string; + freightType: FreightType | null; + isGovernment: boolean; + customer: string; + originYardId: string; + destinationYardId: string; + origin: string; + destination: string; + weightTons: number; + paymentDeadline: string | null; + need: IntercityCapacity | null; +} + +export interface IntercityCandidateRow extends IntercityBookingRow { + fits: boolean; +} + +export interface IntercityCandidatesResult { + scheduleId: string; + routeId: string | null; + remaining: IntercityCapacity | null; + candidates: IntercityCandidateRow[]; + accepted: IntercityBookingRow[]; +} + +export interface IntercityAcceptResult { + accepted: string[]; + rejected: Array<{ bookingId: string; reason: string }>; + remaining: IntercityCapacity; +} diff --git a/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts new file mode 100644 index 000000000..17f6174b3 --- /dev/null +++ b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts @@ -0,0 +1,60 @@ +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"; + +function getAuthToken(): string | undefined { + return document.cookie + .split("; ") + .find((row) => row.startsWith("auth-token=")) + ?.split("=")[1]; +} + +// 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. Every phase transition the window + * engine applies (open, doc review, payment, reopen, done) invalidates the + * cached window lists, so the home-page "Booking Windows" card flips the + * moment the backend does — the 60s poll remains only as a fallback. + */ +export function useBookingWindowSocket(enabled: boolean) { + const qc = useQueryClient(); + + useEffect(() => { + if (!enabled) return; + const token = getAuthToken(); + if (!token) return; + + const socket = io(`${SOCKET_ORIGIN}/${BOOKING_WINDOW_WS_NAMESPACE}`, { + auth: { token }, + transports: ["websocket"], + withCredentials: true, + }); + + socket.on( + BOOKING_WINDOW_WS_EVENTS.PHASE, + (_event: BookingWindowPhaseEvent) => { + qc.invalidateQueries({ + queryKey: ["train-scheduling", "myBookingWindows"], + }); + qc.invalidateQueries({ + queryKey: ["train-scheduling", "contractBookingWindows"], + }); + }, + ); + + return () => { + socket.off(); + socket.disconnect(); + }; + }, [enabled, qc]); +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts index dc3f6f7be..5e051cce9 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts @@ -1,12 +1,17 @@ import { useQuery } from "@tanstack/react-query"; import { Freight } from "@edr/types"; import useAuth from "@/hooks/useAuth"; +import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket"; import { api } from "@/services/api"; import { ACTIVE_STATUSES } from "./constants"; export function useMyPortalData(selectedProfileId?: string) { const { user, customer, company } = useAuth(); + // Live booking-window pushes: a phase transition on any lane invalidates the + // windows queries below the moment it happens (poll below is only a fallback). + useBookingWindowSocket(Boolean(user)); + const invoicesQuery = useQuery(api.invoices.listMy.queryOptions()); const myInvoices = invoicesQuery.data ?? []; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 268e8627a..16225cd02 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -211,7 +211,10 @@ export default function ContractDetailPage() { }), enabled: !!id, }); - const bookingWindowOpen = hasOpenWindow(bookingWindows); + // Intercity contracts are never window-gated: the shipment rides a passing + // import/export train that staff assign later, so booking is always open. + const bookingWindowOpen = + contract?.tradeDirection === "DOMESTIC" || hasOpenWindow(bookingWindows); // Draw-down capacity per cargo line (GENERAL contracts only). The backend // excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index a2bc985c0..d064197d5 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -128,8 +128,10 @@ export default function NewShipmentPage() { // Coarse gate: if the customer deep-links here while no booking window is // open, show the same closed-state notice as the contract page instead of the - // form. Still allowed the moment any window isOpenNow. - if (!hasOpenWindow(bookingWindows)) { + // form. Still allowed the moment any window isOpenNow. Intercity contracts + // are never window-gated — the shipment rides a passing train that staff + // pick at finalize time, so booking is always open. + if (contract.tradeDirection !== "DOMESTIC" && !hasOpenWindow(bookingWindows)) { return ( + } + title="Schedule" + description="Intercity shipments have no fixed day." + /> + }> + Your shipment rides the next import/export train passing through your + corridor. Operations assign it to a train with free capacity — you + will be notified when it is accepted and payment is due. + + + ); + } + return ( = [ { value: "import", @@ -46,7 +48,8 @@ export const OPERATION_TYPE_OPTIONS: Array<{ { value: "intercity", label: "Intercity", - description: "Domestic movement between Ethiopian yards.", + description: + "Domestic movement between Ethiopian yards — rides on passing import/export trains, no customs.", }, { value: "import_ff", diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx index 49d04533a..6ce2aed5f 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx @@ -30,7 +30,11 @@ export function Step0OperationType({ }) { const data = OPERATION_TYPE_OPTIONS.filter((opt) => allowedOperations.includes(opt.value), - ).map((opt) => ({ value: opt.value, label: opt.label })); + ).map((opt) => ({ + value: opt.value, + label: opt.label, + disabled: opt.disabled ?? false, + })); return (
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx index a4ec5aeb8..e37daea6c 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx @@ -49,7 +49,11 @@ export function Step1ContractType({ const operationData = OPERATION_TYPE_OPTIONS.filter((opt) => allowedOperations.includes(opt.value), - ).map((opt) => ({ value: opt.value, label: opt.label })); + ).map((opt) => ({ + value: opt.value, + label: opt.label, + disabled: opt.disabled ?? false, + })); const previousContractRef = form.watch("previousContractRef"); const [searchQuery, setSearchQuery] = useState(""); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts index ac182113f..1403cf1b5 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts @@ -19,6 +19,11 @@ export interface ShipmentValidationContext { isHazardous: boolean; isReefer: boolean; unitOfMeasure?: "PER_TON" | "PER_ITEM"; + /** + * Intercity (DOMESTIC) shipments ride a passing import/export train that + * staff pick later, so no shipment day is chosen. Defaults to true. + */ + requiresDate?: boolean; } const containerUnitSchema = z.object({ @@ -54,7 +59,7 @@ const shipmentFormBase = z.object({ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { return shipmentFormBase.superRefine((data, refineCtx) => { - if (!data.scheduledDate.trim()) { + if ((ctx.requiresDate ?? true) && !data.scheduledDate.trim()) { refineCtx.addIssue({ code: "custom", path: ["scheduledDate"], diff --git a/packages/types/src/freight/booking-window-ws.ts b/packages/types/src/freight/booking-window-ws.ts new file mode 100644 index 000000000..6c48f2c4c --- /dev/null +++ b/packages/types/src/freight/booking-window-ws.ts @@ -0,0 +1,41 @@ +/** + * Shared contracts for live booking-window pushes. + * + * The freight API emits one event whenever a schedule's booking-window state + * changes (phase transition, open/close, cycle reopen). Portal home and the + * backoffice GL windows section subscribe and refresh instantly instead of + * waiting for their poll interval. + */ + +/** Booking-window lifecycle phase persisted on a train schedule. */ +export type BookingWindowPhase = + | "PRE_WINDOW" + | "OPEN" + | "DOC_REVIEW" + | "PAYMENT" + | "CLOSED_FOR_DAY" + | "DONE"; + +/** Payload pushed on every booking-window state change. */ +export interface BookingWindowPhaseEvent { + scheduleId: string; + originYardId: string; + destinationYardId: string; + direction: string | null; + phase: BookingWindowPhase; + bookingWindowStatus: string | null; + bookingCycleNo: number; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + scheduledDepartureDate: string | null; +} + +/** Socket.io event names pushed server → client on the booking-windows namespace. */ +export const BOOKING_WINDOW_WS_EVENTS = { + PHASE: "booking-window:phase", +} as const; + +/** Socket.io namespace the booking-window gateway listens on. */ +export const BOOKING_WINDOW_WS_NAMESPACE = "booking-windows"; diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 829ae3217..bf2778575 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -695,8 +695,8 @@ export interface CreateBulkLineDto { export interface CreateBookingUnderContractDto { /** Required for GENERAL multi-route contracts; ONE_TIME auto-selected. */ contractRouteId?: string; - /** Binding shipment day. */ - scheduledDate: string; + /** Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later. */ + scheduledDate?: string; containers?: CreateBookingContainerLineDto[]; bulkLines?: CreateBulkLineDto[]; notes?: string; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 7c8a3c4cd..0b5752265 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -8,6 +8,7 @@ export * from "./etrade"; export * from "./contracts"; export * from "./clearance-files.catalog"; export * from "./notifications"; +export * from "./booking-window-ws"; export enum TradeDirection { IMPORT = "IMPORT", @@ -220,6 +221,24 @@ export enum WagonReadiness { export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC"; +/** + * The only two countries on the EDR line. Yard `country` is constrained to + * these values; route trade direction is derived from the origin/destination + * yard countries (ET→DJ = EXPORT, DJ→ET = IMPORT, same-country = DOMESTIC, + * shown as "Intercity" in UIs and currently disabled for scheduling/contracts). + */ +export enum YardCountry { + ETHIOPIA = "Ethiopia", + DJIBOUTI = "Djibouti", +} + +/** UI label for a schedule/route trade direction (DOMESTIC displays as Intercity). */ +export const TRADE_DIRECTION_LABELS: Record = { + IMPORT: "Import", + EXPORT: "Export", + DOMESTIC: "Intercity", +}; + export enum TrainCheckpointKind { Departed = "DEPARTED", Passed = "PASSED", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e278995a..fadeaca56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15461,9 +15461,9 @@ snapshots: dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) @@ -15584,9 +15584,9 @@ snapshots: dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) From 9b6423392b22a813d1ceb91ecbdb93e7374f1140 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 6 Jul 2026 16:30:48 +0300 Subject: [PATCH 08/23] UAT issues resolution --- .../migration.sql | 3 + .../migration.sql | 5 + .../migration.sql | 2 + apps/edr-passenger-api/prisma/schema.prisma | 6 + apps/edr-passenger-api/src/app.module.ts | 11 - .../modules/currencies/currencies.service.ts | 4 - .../src/modules/packages/packages.dto.ts | 4 + .../src/modules/packages/packages.service.ts | 30 +- .../src/modules/schedules/schedules.dto.ts | 1 + .../modules/schedules/schedules.service.ts | 1 + .../src/modules/search/search.service.ts | 4 + .../backoffice/src/app/coaches/page.tsx | 92 +++--- .../backoffice/src/app/currencies/page.tsx | 115 +++++++- .../src/app/package-bookings/page.tsx | 36 ++- .../backoffice/src/app/packages/page.tsx | 67 +++-- .../backoffice/src/app/schedules/page.tsx | 29 +- .../portal/src/app/booking/review/page.tsx | 19 +- .../portal/src/app/packages/[id]/page.tsx | 271 +++++++++++------- 18 files changed, 467 insertions(+), 233 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260706081216_add_package_booking_adult_child_count/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260706083104_add_price_tier_seat_class_id/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260706131529_add_schedule_is_package_only/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260706081216_add_package_booking_adult_child_count/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260706081216_add_package_booking_adult_child_count/migration.sql new file mode 100644 index 000000000..a43444c92 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260706081216_add_package_booking_adult_child_count/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "PackageBooking" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1, +ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0; diff --git a/apps/edr-passenger-api/prisma/migrations/20260706083104_add_price_tier_seat_class_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260706083104_add_price_tier_seat_class_id/migration.sql new file mode 100644 index 000000000..015402584 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260706083104_add_price_tier_seat_class_id/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "PackagePriceTier" ADD COLUMN "seatClassId" TEXT; + +-- AddForeignKey +ALTER TABLE "PackagePriceTier" ADD CONSTRAINT "PackagePriceTier_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260706131529_add_schedule_is_package_only/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260706131529_add_schedule_is_package_only/migration.sql new file mode 100644 index 000000000..050cfd093 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260706131529_add_schedule_is_package_only/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "TrainSchedule" ADD COLUMN "isPackageOnly" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 83c2b884e..0cd1756fd 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -100,6 +100,7 @@ model SeatClass { fareRules FareRule[] routeFareRules RouteFareRule[] segmentFares SegmentFareRule[] + packagePriceTiers PackagePriceTier[] @@unique([coachTypeId, name]) @@index([coachTypeId]) @@index([coachTypeId, nationalityType, bedPosition]) @@ -367,6 +368,7 @@ model TrainSchedule { onTimePercent Int @default(100) carbonRating String @default("A") notes String? + isPackageOnly Boolean @default(false) train Train @relation(fields: [trainId], references: [id]) route Route? @relation(fields: [routeId], references: [id]) originStation Station @relation("OriginTrips", fields: [originStationId], references: [id]) @@ -1448,6 +1450,7 @@ model TravelPackage { model PackagePriceTier { id String @id @default(uuid()) packageId String + seatClassId String? seatType String label String priceMinor Int @@ -1456,6 +1459,7 @@ model PackagePriceTier { bookedSeats Int @default(0) package TravelPackage @relation(fields: [packageId], references: [id]) + seatClass SeatClass? @relation(fields: [seatClassId], references: [id]) bookings Booking[] packageBookings PackageBooking[] inquiries PackageInquiry[] @@ -1474,6 +1478,8 @@ model PackageBooking { contactPhone String? status BookingStatus @default(PENDING_PAYMENT) passengerCount Int @default(1) + adultCount Int @default(1) + childCount Int @default(0) totalMinor Int currency String @default("ETB") displayCurrency Currency? diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 834c69736..1e41076e7 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -8,7 +8,6 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; import { IamModule as TriaIamModule } from '@tria-plc/iamapi-common/iam.module'; import { DataSeeder } from '@tria-plc/iamapi-common/db/seed/seeder'; -import { EOtpType } from '@tria-plc/iamapi-common'; import { SharedAuthModule } from '@tria-plc/api-common/modules/auth/shared-auth.module'; import { EDR_PASSENGER_APPLICATION, @@ -98,16 +97,6 @@ import { SegmentFareSeeder } from './seed/segment-fare.seeder'; TriaIamModule.forRoot({ applications: [EDR_PASSENGER_APPLICATION], permissions: EDR_PASSENGER_PERMISSIONS, - otpMessages: { - [EOtpType.MFA_LOGIN]: ({ otp }) => - `Your EDR Passenger login code is ${otp}. It will expire in 5 minutes.`, - [EOtpType.VERIFY_PHONE_NUMBER]: ({ otp }) => - `Your EDR Passenger phone verification code is ${otp}. It will expire in 5 minutes.`, - [EOtpType.RESET_PASSWORD]: ({ route }) => - `Reset your EDR Passenger password using this link: ${route}`, - [EOtpType.SET_PASSWORD]: ({ route }) => - `Set your EDR Passenger password using this link: ${route}`, - }, }), SharedAuthModule, PrismaModule, diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts index 2ccc79b70..b2708af5a 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts @@ -32,10 +32,6 @@ export class CurrenciesService { async createCurrency(dto: CreateCurrencyDto) { const { code, name, symbol, baseCurrencyCode = 'ETB', exchangeRate } = dto; - if (!['ETB', 'USD', 'DJF'].includes(code.toUpperCase())) { - throw new BadRequestException('Unsupported currency code'); - } - if (exchangeRate <= 0) { throw new BadRequestException('Exchange rate must be positive'); } diff --git a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts index 0b7a25bba..d7257f179 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts @@ -3,6 +3,9 @@ import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreatePriceTierDto { + @ApiPropertyOptional({ description: 'SeatClass ID to link this tier to a specific seat class' }) + @IsOptional() @IsUUID() seatClassId?: string; + @ApiProperty({ example: 'HSC' }) @IsString() seatType: string; @@ -31,6 +34,7 @@ export class UpdateInquiryStatusDto { } export class UpdatePriceTierDto { + @ApiPropertyOptional() @IsOptional() @IsUUID() seatClassId?: string; @ApiPropertyOptional() @IsOptional() @IsString() seatType?: string; @ApiPropertyOptional() @IsOptional() @IsString() label?: string; @ApiPropertyOptional() @IsOptional() @IsInt() @Min(0) priceMinor?: number; diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index 294273d30..da97dcfa9 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -8,7 +8,7 @@ import { GuestBookingService } from '../bookings/guest-booking.service'; /** Package-specific fare rules */ const PKG_MAX_ADULTS = 5; -const PKG_MAX_CHILDREN = 2; +const PKG_CHILDREN_PER_ADULT = 2; // 2 children allowed per adult const PKG_CHILD_FARE_RATIO = 0.1; function calculatePackageFareBreakdown( @@ -68,7 +68,8 @@ export class PackagesService { if (adultCount < 1) throw new BadRequestException('At least one adult passenger required'); if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`); - if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`); + const maxChildren = adultCount * PKG_CHILDREN_PER_ADULT; + if (childCount > maxChildren) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildren} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`); const passengerCount = adultCount + childCount; const remaining = tier.availableSeats - tier.bookedSeats; @@ -81,14 +82,17 @@ export class PackagesService { ); // Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches - let seatClassId: string | null = null; + let seatClassId: string | null = tier.seatClassId ?? null; + let seatClassName: string | null = null; let coachTypeId: string | null = null; for (const a of pkg.outboundSchedule.coachAssignments) { - const sc = a.coach.coachType?.seatClasses?.find( - (s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) || - tier.seatType.toLowerCase().includes(s.name.toLowerCase()), - ); - if (sc) { seatClassId = sc.id; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; } + const sc = seatClassId + ? a.coach.coachType?.seatClasses?.find((s: any) => s.id === seatClassId) + : a.coach.coachType?.seatClasses?.find( + (s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) || + tier.seatType.toLowerCase().includes(s.name.toLowerCase()), + ); + if (sc) { seatClassId = sc.id; seatClassName = sc.name; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; } } if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) { const first = pkg.outboundSchedule.coachAssignments[0]; @@ -102,6 +106,7 @@ export class PackagesService { tierLabel: tier.label, seatType: tier.seatType, seatClassId, + seatClassName, coachTypeId, adultCount, childCount, @@ -111,7 +116,7 @@ export class PackagesService { pricePerChildMinor: childFareMinor, childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`, maxAdults: PKG_MAX_ADULTS, - maxChildren: PKG_MAX_CHILDREN, + maxChildren: adultCount * PKG_CHILDREN_PER_ADULT, totalMinor, currency: tier.currency, remainingSeats: remaining, @@ -203,7 +208,7 @@ export class PackagesService { const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, include: { - priceTiers: true, + priceTiers: { include: { seatClass: { include: { coachType: true } } } }, outboundSchedule: { include: { originStation: true, @@ -369,7 +374,8 @@ export class PackagesService { if (adultCount < 1) throw new BadRequestException('At least one adult passenger required'); if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`); - if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`); + const maxChildrenBook = adultCount * PKG_CHILDREN_PER_ADULT; + if (childCount > maxChildrenBook) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildrenBook} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`); const passengerCount = adultCount + childCount; const remaining = tier.availableSeats - tier.bookedSeats; @@ -398,6 +404,8 @@ export class PackagesService { contactPhone: dto.contactPhone, promoCode: dto.promoCode, passengerCount, + adultCount, + childCount, totalMinor, currency: 'ETB', displayCurrency, diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 80f3ad13b..3844e57cf 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -59,6 +59,7 @@ export class UpdateScheduleDto { @ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string; @ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus; @ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>; + @ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean; } export class UpdateStopTimeDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 19aa5ed0e..bd7cc7258 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -632,6 +632,7 @@ export class SchedulesService { } if (dto.status) updateData.status = dto.status; + if (dto.isPackageOnly !== undefined) updateData.isPackageOnly = dto.isPackageOnly; if (Object.keys(updateData).length > 0) { await this.prisma.trainSchedule.update({ where: { id }, data: updateData }); diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 621e5487d..2942625ca 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -157,6 +157,7 @@ export class SearchService { const schedules = await this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, + isPackageOnly: false, OR: [ { departureAt: { gte: windowStart, lt: requestedDate } }, { departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } }, @@ -192,6 +193,7 @@ export class SearchService { const schedules = await this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, + isPackageOnly: false, departureAt: { gte: date < now ? now : date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, }, @@ -230,6 +232,7 @@ export class SearchService { this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, + isPackageOnly: false, departureAt: { gte: dayStart, lt: dayEnd }, stopTimes: { some: { stationId: originStationId } }, }, @@ -238,6 +241,7 @@ export class SearchService { this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, + isPackageOnly: false, departureAt: { gte: dayStart, lt: leg2WindowEnd }, }, include: SCHEDULE_INCLUDE, diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index 91eec0082..f97456a3d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -147,6 +147,7 @@ export default function CoachesPage() { const [editingItem, setEditingItem] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null }); const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(''); + const [isBedCoach, setIsBedCoach] = useState(false); const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false); const [exportUtilFormat, setExportUtilFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); @@ -454,6 +455,7 @@ export default function CoachesPage() { onClick: (item: any) => { setEditingItem({ ...item, isCoach: true }); setSelectedCoachTypeId(item.coachTypeId || ''); + setIsBedCoach(!!(item.bedCategory || item.coachType?.name?.toLowerCase().includes('bed'))); setShowModal(true); }, variant: 'secondary' as const, @@ -479,6 +481,7 @@ export default function CoachesPage() { onClick={() => { setEditingItem(null); setSelectedCoachTypeId(''); + setIsBedCoach(false); setSearch(''); setShowModal(true); }} @@ -700,6 +703,7 @@ export default function CoachesPage() { setShowModal(false); setEditingItem(null); setSelectedCoachTypeId(''); + setIsBedCoach(false); }} title={ activeTab === 'types' @@ -779,8 +783,14 @@ export default function CoachesPage() { - - - - -

- Select if this is a bed coach -

-
- -
- - -

- Only applies to bed coaches -

-
- - ) : null; - })()} + {isBedCoach && ( + <> +
+ + +
+
+ + +
+ + )}
@@ -903,6 +894,7 @@ export default function CoachesPage() { setShowModal(false); setEditingItem(null); setSelectedCoachTypeId(''); + setIsBedCoach(false); }} > Cancel diff --git a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx index 8de163da9..4cf393713 100644 --- a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx @@ -2,10 +2,11 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Edit, Loader2, RefreshCw } from 'lucide-react'; +import { Edit, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { apiClient } from '@/lib/api-client'; interface CurrencyRate { @@ -29,6 +30,9 @@ export default function CurrenciesPage() { const [editingRate, setEditingRate] = useState(null); const [rateInput, setRateInput] = useState(''); const [error, setError] = useState(null); + const [showAddModal, setShowAddModal] = useState(false); + const [addForm, setAddForm] = useState({ code: '', name: '', symbol: '', exchangeRate: '' }); + const [deleteConfirm, setDeleteConfirm] = useState(null); const queryClient = useQueryClient(); const { data: currencies = [], isLoading } = useQuery({ @@ -49,6 +53,26 @@ export default function CurrenciesPage() { }, }); + const createMutation = useMutation({ + mutationFn: (data: any) => apiClient.post('/currencies', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['currencies'] }); + setShowAddModal(false); + setAddForm({ code: '', name: '', symbol: '', exchangeRate: '' }); + setError(null); + }, + onError: (err: any) => setError(err.response?.data?.message || 'Failed to add currency'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/currencies/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['currencies'] }); + setDeleteConfirm(null); + }, + onError: (err: any) => setError(err.response?.data?.message || 'Failed to delete currency'), + }); + const syncMutation = useMutation({ mutationFn: () => apiClient.post('/currencies/sync-rates', {}), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['currencies'] }), @@ -121,12 +145,8 @@ export default function CurrenciesPage() { ]; const actions = [ - { - label: 'Edit Rate', - onClick: handleEdit, - variant: 'secondary' as const, - icon: Edit, - }, + { label: 'Edit', onClick: handleEdit, variant: 'secondary' as const, icon: Edit }, + { label: 'Delete', onClick: (c: CurrencyRate) => setDeleteConfirm(c), variant: 'danger' as const, icon: Trash2 }, ]; return ( @@ -138,14 +158,17 @@ export default function CurrenciesPage() { Manage ETB exchange rates for display currencies (DJF, USD)

- syncMutation.mutate()} - loading={syncMutation.isPending} - > - Sync Rates - +
+ { setError(null); setShowAddModal(true); }}>Add Currency + syncMutation.mutate()} + loading={syncMutation.isPending} + > + Sync Rates + +
{error && !editingRate && ( @@ -204,6 +227,68 @@ export default function CurrenciesPage() {

• Rates apply globally; changes take effect immediately on the next booking or fare quote

+ { setShowAddModal(false); setError(null); }} + title="Add Currency" + size="sm" + > +
+ {error && ( +
{error}
+ )} +
+
+ + setAddForm({ ...addForm, code: e.target.value.toUpperCase() })} /> +
+
+ + setAddForm({ ...addForm, symbol: e.target.value })} /> +
+
+
+ + setAddForm({ ...addForm, name: e.target.value })} /> +
+
+ + setAddForm({ ...addForm, exchangeRate: e.target.value })} /> +
+
+ { setShowAddModal(false); setError(null); }}>Cancel + { + if (!addForm.code || !addForm.name || !addForm.symbol || !addForm.exchangeRate) { + setError('All fields are required'); return; + } + const rate = parseFloat(addForm.exchangeRate); + if (isNaN(rate) || rate <= 0) { setError('Exchange rate must be a positive number'); return; } + createMutation.mutate({ code: addForm.code, name: addForm.name, symbol: addForm.symbol, exchangeRate: rate }); + }} + > + Add Currency + +
+
+
+ + setDeleteConfirm(null)} + onConfirm={() => deleteMutation.mutate(deleteConfirm!.id)} + title="Delete Currency" + message={`Delete ${deleteConfirm?.code} (${CURRENCY_META[deleteConfirm?.code ?? '']?.name ?? deleteConfirm?.code})? This will remove the exchange rate record.`} + confirmText="Delete" + isDanger + isLoading={deleteMutation.isPending} + /> + { setEditingRate(null); setError(null); }} diff --git a/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx index 2912eeee3..88301428d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx @@ -214,21 +214,31 @@ export default function PackageBookingsPage() {
- {b.passengers.map((p: any, i: number) => ( -
-
- {i + 1} -
-

{p.passengerName}

-

- {p.dateOfBirth ? new Date(p.dateOfBirth).toLocaleDateString() : ''} - {p.idDocumentType ? ` · ${p.idDocumentType}` : ''} - {p.passportNumber ? ` · ${p.passportNumber}` : ''} -

+ {b.passengers.map((p: any, i: number) => { + const isChild = i >= (b.adultCount ?? b.passengerCount); + return ( +
+
+ {i + 1} +
+

{p.passengerName}

+

+ {p.dateOfBirth ? new Date(p.dateOfBirth).toLocaleDateString() : ''} + {p.idDocumentType ? ` · ${p.idDocumentType}` : ''} + {p.passportNumber ? ` · ${p.passportNumber}` : ''} +

+
+ + {isChild ? 'CHILD' : 'ADULT'} +
-
- ))} + ); + })}
)} diff --git a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx index 601cb17c6..ab0386a19 100644 --- a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx @@ -8,7 +8,7 @@ import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import Modal from '@/components/ui/Modal'; -import { packagesApi, stationsApi, schedulesApi } from '@/lib/api'; +import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; const toLocal = (iso?: string) => { @@ -42,7 +42,7 @@ export default function PackagesPage() { const [deactivateConfirm, setDeactivateConfirm] = useState(null); const [tiersPackage, setTiersPackage] = useState(null); const [editingTier, setEditingTier] = useState(null); - const [tierForm, setTierForm] = useState({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); + const [tierForm, setTierForm] = useState({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); const [deleteTierConfirm, setDeleteTierConfirm] = useState(null); const [tierError, setTierError] = useState(null); const [deletePackageConfirm, setDeletePackageConfirm] = useState(null); @@ -64,8 +64,14 @@ export default function PackagesPage() { queryFn: () => schedulesApi.getAll(), }); + const { data: seatClassesData } = useQuery({ + queryKey: ['seat-classes-all'], + queryFn: () => seatClassesApi.getAll(), + }); + const stations: any[] = stationsData?.items || stationsData?.data || (Array.isArray(stationsData) ? stationsData : []); const schedules: any[] = schedulesData?.items || schedulesData?.data || (Array.isArray(schedulesData) ? schedulesData : []); + const seatClasses: any[] = Array.isArray(seatClassesData) ? seatClassesData : (seatClassesData as any)?.items || (seatClassesData as any)?.data || []; const createMutation = useMutation({ mutationFn: packagesApi.create, @@ -87,7 +93,7 @@ export default function PackagesPage() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setDeactivateConfirm(null); }, }); - const emptyTierForm = { seatType: '', label: '', priceMinor: '', availableSeats: '' }; + const emptyTierForm = { seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }; const addTierMutation = useMutation({ mutationFn: ({ packageId, data }: { packageId: string; data: any }) => packagesApi.addTier(packageId, data), @@ -135,13 +141,19 @@ export default function PackagesPage() { const openEditTier = (tier: any) => { setEditingTier(tier); - setTierForm({ seatType: tier.seatType, label: tier.label, priceMinor: String(tier.priceMinor), availableSeats: String(tier.availableSeats) }); + setTierForm({ seatClassId: tier.seatClassId ?? '', seatType: tier.seatType, label: tier.label, priceMinor: String(tier.priceMinor), availableSeats: String(tier.availableSeats) }); setTierError(null); }; const handleTierSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const payload = { seatType: tierForm.seatType, label: tierForm.label, priceMinor: parseInt(tierForm.priceMinor), availableSeats: parseInt(tierForm.availableSeats) }; + const payload: any = { + seatType: tierForm.seatType, + label: tierForm.label, + priceMinor: parseInt(tierForm.priceMinor), + availableSeats: parseInt(tierForm.availableSeats), + ...(tierForm.seatClassId ? { seatClassId: tierForm.seatClassId } : {}), + }; if (editingTier) { await updateTierMutation.mutateAsync({ tierId: editingTier.id, data: payload }); } else { @@ -267,7 +279,7 @@ export default function PackagesPage() { { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit }, { label: 'Tiers', icon: Layers, variant: 'secondary' as const, - onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }, + onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }, }, { label: 'Activate', icon: CheckCircle, variant: 'primary' as const, @@ -432,8 +444,12 @@ export default function PackagesPage() { {(tiersPackage.priceTiers ?? []).map((t: any) => (
- {t.label} - ({t.seatType}) + {t.seatType} + {t.seatClassId && ( + + {seatClasses.find((sc: any) => sc.id === t.seatClassId)?.coachType.type ?? 'Linked'} + + )}
{formatCurrency(t.priceMinor, 'ETB')} · {t.bookedSeats}/{t.availableSeats} booked
@@ -454,16 +470,31 @@ export default function PackagesPage() {

{editingTier ? 'Edit Tier' : 'Add New Tier'}

-
- - setTierForm((f) => ({ ...f, seatType: e.target.value }))} /> -
-
- - setTierForm((f) => ({ ...f, label: e.target.value }))} /> +
+ +
+
{editingTier && ( - { setEditingTier(null); setTierForm({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }}>Cancel + { setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }}>Cancel )} {editingTier ? 'Update Tier' : 'Add Tier'} diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index bb25577f3..801dc43d1 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -22,6 +22,7 @@ interface Schedule { originStation?: { id: string; name: string }; destinationStation?: { id: string; name: string }; coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>; + isPackageOnly?: boolean; } interface Train { @@ -105,6 +106,7 @@ export default function SchedulesPage() { arrivalAt: '', status: 'SCHEDULED', coachIds: [] as string[], + isPackageOnly: false, }); const [filters, setFilters] = useState({ @@ -279,6 +281,7 @@ export default function SchedulesPage() { departureAt: depLocal.toISOString(), arrivalAt: arrLocal.toISOString(), status: editForm.status, + isPackageOnly: editForm.isPackageOnly, coaches: editForm.coachIds.map((coachId: string, idx: number) => ({ coachId, positionNumber: idx + 1, @@ -336,6 +339,7 @@ export default function SchedulesPage() { arrivalAt: arrStr, status: schedule.status, coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [], + isPackageOnly: schedule.isPackageOnly ?? false, }); setError(null); setShowEditModal(true); @@ -455,9 +459,14 @@ export default function SchedulesPage() { key: 'status', label: 'Status', render: (schedule: Schedule) => ( - - {schedule.status} - +
+ + {schedule.status} + + {schedule.isPackageOnly && ( + PKG + )} +
), }, ] as any; @@ -1040,6 +1049,20 @@ export default function SchedulesPage() {
+
+ setEditForm({ ...editForm, isPackageOnly: e.target.checked })} + className="w-4 h-4 rounded" + /> + +
+
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 359da2236..6e057a3e8 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -277,9 +277,10 @@ export default function ReviewPage() { displayCurrency: displayCurrency, passengers: passengers.map((p) => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; + const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId; return { - seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), - ...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }), + ...(seatId ? { seatId } : {}), + ...(isRoundTrip && (p as any).inboundSeatId ? { returnSeatId: (p as any).inboundSeatId } : {}), passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', @@ -320,9 +321,10 @@ export default function ReviewPage() { displayCurrency: displayCurrency, passengers: passengers.map(p => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; + const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId; return { - seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), - ...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }), + ...(seatId ? { seatId } : {}), + ...(isRoundTrip && (p as any).inboundSeatId ? { returnSeatId: (p as any).inboundSeatId } : {}), passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', @@ -454,6 +456,11 @@ export default function ReviewPage() { const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; const childPassengerCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length; + // For package bookings, passengers are initialized without dateOfBirth so isChild() is + // unreliable. Use the stored adultCount from searchCriteria to determine category by index. + const isPackageChild = (index: number) => + isPackageBooking ? index >= adultPassengerCount : isChild(passengers[index]); + // Per-seat fare captured on the seats page (bed-position-aware, computed locally from // the schedule's own coachTypes/classes) is guaranteed correct for berths, unlike the // backend /search/fare-breakdown call whose seatClassId matching for bed positions can't @@ -485,8 +492,8 @@ export default function ReviewPage() { {passengers.map((p, i) => { const line = fareBreakdown?.passengers?.[i]; - const isChildPassenger = isChild(p); - const isFreeChild = !isPackageBooking && (line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i))); + const isChildPassenger = isPackageChild(i); + const isFreeChild = !isPackageBooking && (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i))); const seatFare = getPassengerSeatFare(p); const passengerTotal = isPackageBooking ? (isChildPassenger ? pkgChildFare : pkgAdultFare) diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index a38515e03..457425578 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -21,6 +21,9 @@ import { Tag, Shield, X, + Star, + Bed, + Armchair, } from "lucide-react"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -58,6 +61,13 @@ interface Schedule { routeStops?: RouteStop[]; } +interface CoachTypeInfo { + id: string; + name: string; + code: string; + type: string; // 'passenger' | 'sleeper' | 'dining' | 'baggage' +} + interface PriceTier { id: string; packageId: string; @@ -67,6 +77,7 @@ interface PriceTier { currency: string; availableSeats: number; bookedSeats: number; + seatClass?: { coachType?: CoachTypeInfo }; } interface PackageDetail { @@ -192,101 +203,157 @@ function JourneyCard({ schedule, label }: { schedule: Schedule; label: string }) ); } -// ─── Price Tiers Panel ──────────────────────────────────────────────────────── +// ─── Coach Type Group Panel (Step 1 + Step 2 inline) ───────────────────────── + +function groupTiersByCoachType(tiers: PriceTier[]): Array<{ + coachTypeId: string; + coachTypeName: string; + coachTypeCode: string; + coachTypeType: string; + tiers: PriceTier[]; + minPrice: number; + currency: string; +}> { + const map = new Map(); + for (const tier of tiers) { + const ct = tier.seatClass?.coachType; + const key = ct?.id ?? `__ungrouped__${tier.seatType}`; + if (!map.has(key)) { + map.set(key, { + coachTypeId: ct?.id ?? key, + coachTypeName: ct?.name ?? tier.seatType, + coachTypeCode: ct?.code ?? '', + coachTypeType: ct?.type ?? 'passenger', + tiers: [], + }); + } + map.get(key)!.tiers.push(tier); + } + return Array.from(map.values()).map((g) => ({ + ...g, + minPrice: Math.min(...g.tiers.map((t) => t.priceMinor)), + currency: g.tiers[0]?.currency ?? 'ETB', + })); +} + +function getCoachIcon(coachTypeType: string) { + const lower = coachTypeType.toLowerCase(); + if (lower.includes('sleeper')) return Star; + if (lower.includes('bed') || lower.includes('sleep')) return Bed; + return Armchair; +} + +// Capitalise first letter of each word, replace underscores with spaces +function formatCoachTypeLabel(type: string): string { + return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} function PriceTiersPanel({ tiers, - selectedTierId, - onSelect, onBookNow, isRoundTrip, }: { tiers: PriceTier[]; - selectedTierId: string | null; - onSelect: (id: string) => void; - onBookNow: () => void; + onBookNow: (coachTypeId: string) => void; isRoundTrip: boolean; }) { + const [selectedId, setSelectedId] = useState(null); const priceMultiplier = isRoundTrip ? 2 : 1; + const groups = groupTiersByCoachType(tiers ?? []); + + if (!tiers?.length) { + return ( +
+

No price tiers available

+
+ ); + } + return ( -
-

- Select Seat Type -

- - {!tiers?.length ? ( -

- No price tiers available -

- ) : ( -
- {tiers.map((tier) => { - const soldOut = tier.availableSeats === 0; - const selected = tier.id === selectedTierId; - return ( -
!soldOut && onSelect(tier.id)} - className={`rounded-xl border-2 p-3.5 transition-all ${ - soldOut - ? "border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed" - : selected - ? "border-primary bg-primary/5" - : "border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm cursor-pointer" - }`} - > - {/* Row 1: radio + full label */} -
-
- {selected &&
} -
-

- {tier.label.trim()} -

-
- - {/* Row 2: seatType badge + seats + price */} -
-
- - {tier.seatType.trim()} - - {soldOut ? ( - - SOLD OUT - - ) : ( - - {tier.availableSeats} left - - )} -
-

- {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} -

-
- - {/* Book Now — shown only when selected */} - {selected && ( - - )} +
+

Select Coach Type

+ {groups.map((group) => { + const CoachIcon = getCoachIcon(group.coachTypeType); + const allSoldOut = group.tiers.every((t) => t.availableSeats === 0); + const isSelected = selectedId === group.coachTypeId; + return ( +
!allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)} + > + {/* Coach type header */} +
+
+
- ); - })} -
- )} +
+

+ {formatCoachTypeLabel(group.coachTypeType)} +

+

+ From {formatPrice(group.minPrice * priceMultiplier, group.currency)} + {allSoldOut && · Sold out} +

+
+ {!allSoldOut && ( +
+ {isSelected && } +
+ )} +
+ + {/* All available classes for this coach type */} +
+ {group.tiers.map((tier) => { + const soldOut = tier.availableSeats === 0; + return ( +
+
+
+

{tier.seatType.trim()}

+

+ {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} + {soldOut ? ( + Sold out + ) : ( + {tier.availableSeats} left + )} +

+
+
+ ); + })} +
+ + {/* Book Now — only when this group is selected */} + {isSelected && !allSoldOut && ( +
+ +
+ )} +
+ ); + })}
); } @@ -294,7 +361,7 @@ function PriceTiersPanel({ // ─── Passenger count picker ────────────────────────────────────────────────── const PKG_MAX_ADULTS = 5; -const PKG_MAX_CHILDREN = 2; +const PKG_CHILDREN_PER_ADULT = 2; const PKG_CHILD_FARE_RATIO = 0.1; function PassengerCountModal({ @@ -335,15 +402,15 @@ function PassengerCountModal({
-

Selected tier

-

{tier.label.trim()}

-

{remaining} seats remaining · {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult

+

Coach type

+

{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}

+

{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · actual class chosen on seat map

{[ { label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: setAdultCount }, - { label: "Children", sub: `Under 5 · max ${PKG_MAX_CHILDREN} · 10% of adult fare`, value: childCount, min: 0, max: Math.min(PKG_MAX_CHILDREN, remaining - adultCount), set: setChildCount }, + { label: "Children", sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 10% of adult fare`, value: childCount, min: 0, max: Math.min(adultCount * PKG_CHILDREN_PER_ADULT, remaining - adultCount), set: setChildCount }, ].map(({ label, sub, value, min, max, set }) => (
@@ -426,7 +493,7 @@ export default function PackageDetailPage() { const id = params?.id as string; const { clearBooking, setSearchCriteria, setSelectedSchedule, setOutboundSchedule, setInboundSchedule, setPassengers, setPackageContext } = useBookingStore(); - const [selectedTierId, setSelectedTierId] = useState(null); + const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(null); const [passengerModalOpen, setPassengerModalOpen] = useState(false); const [bookingContextLoading, setBookingContextLoading] = useState(false); const [bookingContextError, setBookingContextError] = useState(null); @@ -443,17 +510,21 @@ export default function PackageDetailPage() { ? pkg.outboundSchedule.routeStops.map((rs) => rs.station).filter(Boolean) : []; - const selectedTier = pkg?.priceTiers?.find((t) => t.id === selectedTierId); + // For the passenger modal, use the cheapest available tier in the selected coach type group + const groups = pkg ? groupTiersByCoachType(pkg.priceTiers) : []; + const selectedGroup = groups.find((g) => g.coachTypeId === selectedCoachTypeId); + // Representative tier for the modal header (cheapest available) + const representativeTier = selectedGroup?.tiers.find((t) => t.availableSeats > 0) ?? selectedGroup?.tiers[0] ?? null; const isRoundTripPkg = pkg?.journeyType === 'ROUND_TRIP'; const handleBookNow = async (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => { - if (!selectedTier || !pkg) return; + if (!representativeTier || !pkg) return; setBookingContextLoading(true); setBookingContextError(null); try { const ctx: any = await apiClient.get( - `/packages/${id}/booking-context?tierId=${selectedTier.id}&adultCount=${adultCount}&childCount=${childCount}`, + `/packages/${id}/booking-context?tierId=${representativeTier.id}&adultCount=${adultCount}&childCount=${childCount}`, ); clearBooking(); @@ -473,7 +544,7 @@ export default function PackageDetailPage() { duration: s.durationMinutes ? `${Math.floor(s.durationMinutes / 60)}h ${s.durationMinutes % 60}m` : "", baseFareAdult: Math.round(ctx.totalMinor / passengerCount), baseFareChild: 0, - displayCurrency: selectedTier.currency, + displayCurrency: representativeTier.currency, selectedSeatClass: ctx.seatClassId, selectedSeatClassName: ctx.seatClassName ?? "", seatClassName: ctx.seatClassName ?? "", @@ -510,7 +581,7 @@ export default function PackageDetailPage() { ); // Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing - setPackageContext(id, selectedTier.id, selectedTier.priceMinor, pkg.name, departureStationId, departureStationName); + setPackageContext(id, representativeTier.id, representativeTier.priceMinor, pkg.name, departureStationId, departureStationName); router.push("/booking/passengers"); } catch (err: any) { @@ -557,9 +628,9 @@ export default function PackageDetailPage() { return (
{/* Passenger count modal */} - {passengerModalOpen && selectedTier && ( + {passengerModalOpen && representativeTier && ( { setPassengerModalOpen(false); setBookingContextError(null); }} onConfirm={handleBookNow} loading={bookingContextLoading} @@ -751,9 +822,7 @@ export default function PackageDetailPage() {
setPassengerModalOpen(true)} + onBookNow={(coachTypeId) => { setSelectedCoachTypeId(coachTypeId); setPassengerModalOpen(true); }} isRoundTrip={isRoundTripPkg} />
@@ -764,9 +833,7 @@ export default function PackageDetailPage() {
setPassengerModalOpen(true)} + onBookNow={(coachTypeId) => { setSelectedCoachTypeId(coachTypeId); setPassengerModalOpen(true); }} isRoundTrip={isRoundTripPkg} />
From 692d9074d09d70015f18b509ee873563163a0798 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 6 Jul 2026 13:46:16 +0000 Subject: [PATCH 09/23] feat: container/bulk items datatable on warehouse view details Adds a per-container/bulk items view for a booking with derived lifecycle stage (PENDING -> RECEIVED -> GRN -> LOADED -> LEFT -> DELIVERED) and reference badges (booking, contract, last-mile). Backend containerItems() aggregates booking_container_units + customer_truck_containers + inventory; exposed at GET warehouse-inventory/bookings/:id/container-items. Frontend ContainerItemsModal (opened from the View Details eye): stage tabs with counts, ref columns, checkbox multiselect of loadable items -> pick truck -> load (Truck_dispatch), and per-row per-truck Exit Paper. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouse-inventory.controller.ts | 6 + .../warehouses/warehouse-inventory.service.ts | 89 ++++++++ .../warehouses/ContainerItemsModal.tsx | 211 ++++++++++++++++++ .../warehouses/ReceiveInventoryModal.tsx | 10 +- .../src/services/warehouse.service.ts | 24 ++ 5 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index cd1aa432c..26242aeba 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -333,6 +333,12 @@ export class WarehouseInventoryController { 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') @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 8e9457831..6adbed278 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -2308,6 +2308,95 @@ 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 diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx new file mode 100644 index 000000000..168bfd744 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx @@ -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 = { + 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([]); + const [truckId, setTruckId] = useState(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 ( + Container / bulk items {bookingReference ? `· ${bookingReference}` : ''}} + > + setTab(v ?? 'ALL')} mb="sm"> + + {STAGE_TABS.map((t) => { + const count = t.value === 'ALL' ? items.length : items.filter((i) => i.stage === t.value).length; + return ( + {count}}> + {t.label} + + ); + })} + + + + {isLoading ? ( + + + + ) : items.length === 0 ? ( + No container or bulk items on this booking. + ) : ( + + + + + + + Container + Goods + Stage + Truck + Booking + Contract + Last mile + Actions + + + + {visible.map((i) => ( + + + toggle(i.containerNumber)} + disabled={!isLoadable(i)} + /> + + {i.containerNumber} + {i.goods ?? '—'} + {i.stage} + {i.truckPlate ?? '—'} + {i.bookingReference ?? '—'} + {i.contractId ? Contract : '—'} + {i.hasLastMile ? EDR : Self-haul} + + {i.truckAssignmentId && ( + + )} + + + ))} + +
+
+ + {/* Multiselect → load onto a truck */} + + {selected.length} selected + + { + setScheduleId(v); + setSelected([]); + setTab('received'); + }} + disabled={trainOptions.length === 0} + leftSection={} + w={460} + searchable + /> + + + {!scheduleId ? ( + + Pick a train to see the arrived containers/cargoes allocated to it. Items appear here only + after train and wagon allocation. + + ) : ( + <> + setTab(v ?? 'received')}> + + + {received.length} + + } + > + Received + + + {loaded.length} + + } + > + Loaded + + + + + {isLoading ? ( + + + + ) : visible.length === 0 ? ( + + {tab === 'loaded' ? 'Nothing loaded onto this train yet.' : 'No arrived items ready for this train.'} + + ) : ( + + + + + + {tab === 'received' && ( + 0} + onChange={toggleAll} + disabled={selectableVisible.length === 0} + /> + )} + + Container / Cargo + Goods + Weight + Stage + Wagon + Booking + Customer + Inspection + + + {visible.map(renderRow)} +
+
+ )} + + {tab === 'received' && ( + + + {selected.length} selected · only READY_FOR_LOADING items with an allocated wagon can be loaded + + + + )} + + )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index c5545687b..16037b199 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -79,6 +79,42 @@ export interface ContainerItem { hasLastMile: boolean; } +/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */ +export interface LoadableTrain { + scheduleId: string; + trainNumber: string | null; + origin: string | null; + destination: string | null; + status: string; + departureTime: string | null; + readyCount: number; + loadedCount: number; +} + +/** A container/cargo inventory item assigned to a train, with its allocated wagon. */ +export interface TrainLoadableItem { + 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; + loadable: boolean; +} + +export interface TrainLoadResult { + loadedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + const cleanParams = (params: object) => Object.fromEntries( Object.entries(params).filter(([, value]) => value !== undefined && value !== '' && value !== null), @@ -120,6 +156,33 @@ export const warehouseService = { return data?.data ?? data ?? []; }, + // ── Load to Train ───────────────────────────────────────────────────────── + /** Pre-dispatch EXPORT trains with inventory waiting to be loaded. */ + getLoadableTrains: async (): Promise => { + const { data } = await apiClient.get('/warehouse-inventory/loadable-trains'); + return data?.data ?? data ?? []; + }, + + /** Container/cargo items assigned to a train, with allocated wagon + stage. */ + getTrainLoadableItems: async (scheduleId: string): Promise => { + const { data } = await apiClient.get( + `/warehouse-inventory/train/${scheduleId}/loadable-items`, + ); + return data?.data ?? data ?? []; + }, + + /** Load selected inventory items onto their allocated wagons for a train. */ + loadItemsOntoTrain: async ( + scheduleId: string, + inventoryIds: string[], + ): Promise => { + const { data } = await apiClient.post( + `/warehouse-inventory/train/${scheduleId}/load`, + { inventoryIds }, + ); + return data?.data ?? data ?? { loadedCount: 0, skippedCount: 0, results: [] }; + }, + // ── Warehouses ────────────────────────────────────────────────────────── list: (filter?: WarehouseFilter) => apiClient.get(URL_CONSTANTS.WAREHOUSES.BASE, { From 8785d992ab07e641c50d5d867fc5f51603178acc Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 6 Jul 2026 19:00:04 +0000 Subject: [PATCH 14/23] export loading --- .../modules/warehouses/warehouse-inventory.service.ts | 2 +- .../src/pages/warehouses/LoadingQueuePage.tsx | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index f8cc32fe8..bf7139d72 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1179,7 +1179,7 @@ export class WarehouseInventoryService { ct.container_number AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", inv.weight AS "weight", - COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber", inv.inspection_status AS "inspectionStatus", inv.status AS "status", wl.wagon_id AS "wagonId", diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx index f80db55f4..652a26863 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadingQueuePage.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; 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 { PageContainer, PageHeader } from '@/components/page'; @@ -10,6 +10,7 @@ import { VisualEmptyState, formatNumber, } from '@/components/warehouses'; +import { LoadToTrainPanel } from '@/components/warehouses/LoadToTrainPanel'; import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; @@ -116,6 +117,9 @@ export default function LoadingQueuePage() { > Dispatch Queue + }> + Load to Train + {/* Ready to Load — PAID bookings, can be marked Loaded */} @@ -169,6 +173,11 @@ export default function LoadingQueuePage() { )} + + {/* Load to Train — per-train arrived containers/cargoes, multiselect → load onto wagons */} + + + From 36055d0d820429f804f981e81c5c96ca8121eac4 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 6 Jul 2026 22:12:04 +0300 Subject: [PATCH 15/23] Package booking new rules update, cascade delete option updates --- .../modules/bookings/bookings.controller.ts | 5 +- .../src/modules/bookings/bookings.service.ts | 77 +++++-- .../src/modules/fleet/fleet.controller.ts | 20 +- .../src/modules/fleet/fleet.service.ts | 201 ++++++++++++------ .../modules/packages/packages.controller.ts | 5 +- .../src/modules/packages/packages.service.ts | 88 +++++--- .../passengers/passengers.controller.ts | 5 +- .../modules/passengers/passengers.service.ts | 82 +++++-- .../modules/schedules/routes.controller.ts | 3 +- .../src/modules/schedules/routes.service.ts | 80 ++++++- .../modules/schedules/schedules.controller.ts | 3 +- .../modules/schedules/schedules.service.ts | 89 +++++--- .../modules/stations/stations.controller.ts | 5 +- .../src/modules/stations/stations.service.ts | 44 ++-- .../backoffice/src/app/bookings/page.tsx | 26 ++- .../backoffice/src/app/classes/page.tsx | 18 +- .../backoffice/src/app/coaches/page.tsx | 20 +- .../backoffice/src/app/packages/page.tsx | 13 +- .../backoffice/src/app/passengers/page.tsx | 14 +- .../backoffice/src/app/routes/page.tsx | 18 +- .../backoffice/src/app/schedules/page.tsx | 18 +- .../backoffice/src/app/stations/page.tsx | 18 +- .../backoffice/src/app/trains/page.tsx | 18 +- .../src/components/ui/ConfirmDialog.tsx | 27 +++ .../backoffice/src/lib/api/index.ts | 11 +- .../backoffice/src/lib/api/routes.ts | 4 +- .../src/app/booking/confirmation/page.tsx | 41 ++-- .../portal/src/app/booking/payment/page.tsx | 33 +-- .../portal/src/app/booking/review/page.tsx | 63 ++++-- .../portal/src/app/booking/seats/page.tsx | 24 ++- .../portal/src/app/packages/[id]/page.tsx | 43 ++-- 31 files changed, 777 insertions(+), 339 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 96bd4b387..9a2053943 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -470,10 +470,11 @@ export class BookingsController { @ApiOperation({ description: 'Permanently deletes a booking record' }) + @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @ApiResponse({ status: 200, description: 'Booking deleted successfully' }) @ApiResponse({ status: 404, description: 'Booking not found' }) - delete(@Param('id') id: string) { - return this.service.delete(id); + delete(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.delete(id, cascade === 'true'); } @Patch(':id') diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 57097c9cd..a2ea23e09 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -31,9 +31,10 @@ function resolvePackageRoundTripTotal( if (!booking.packageId || booking.bookingType !== 'ROUND_TRIP' || !tierPriceMinor) { return booking.totalMinor; } + // First child per adult is free; additional children pay full adult fare const adultFareMinor = tierPriceMinor * 2; - const childFareMinor = Math.round(adultFareMinor * 0.1); - return adultCount * adultFareMinor + childCount * childFareMinor; + const paidChildren = Math.max(0, childCount - adultCount); + return adultCount * adultFareMinor + paidChildren * adultFareMinor; } function calculateAge(dateOfBirth: Date): number { @@ -557,7 +558,10 @@ export class BookingsService { if (p.category === PassengerCategory.ADULT) { fareMinor = fareCalculation.baseFareMinor; } else if (dto.packageId) { - fareMinor = Math.round(fareCalculation.baseFareMinor * 0.1); + // Free children (first per adult) get fareMinor=0; paid children pay full adult fare. + // passengersWithFares is built in adult-first order so we track paid children by count. + const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length; + fareMinor = childIdx < adultCount ? 0 : fareCalculation.baseFareMinor; } else { if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; } else fareMinor = fareCalculation.baseFareMinor; @@ -704,8 +708,11 @@ export class BookingsService { outboundFareMinor = outboundFare.baseFareMinor; returnFareMinor = returnFare.baseFareMinor; } else if (dto.packageId) { - outboundFareMinor = Math.round(outboundFare.baseFareMinor * 0.1); - returnFareMinor = Math.round(returnFare.baseFareMinor * 0.1); + // Free children (first per adult) get fareMinor=0; paid children pay full adult fare. + const childIdx = passengersWithFares.filter(x => x.category !== PassengerCategory.ADULT).length; + const isFreeChild = childIdx < adultCount; + outboundFareMinor = isFreeChild ? 0 : outboundFare.baseFareMinor; + returnFareMinor = isFreeChild ? 0 : returnFare.baseFareMinor; } else { if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; } else outboundFareMinor = outboundFare.baseFareMinor; @@ -1255,19 +1262,19 @@ export class BookingsService { childCount: number, ) { const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } }); - // For round-trip packages the caller splits the tier price across legs, so - // priceMinor here is already the per-leg amount. Children pay 10% of adult fare. - const childFareMinor = Math.round(tier.priceMinor * 0.1); + // First child per adult travels free (no seat); additional children pay full adult fare. + const freeChildrenCount = Math.min(childCount, adultCount); + const paidChildrenCount = Math.max(0, childCount - adultCount); const adultFareMinor = tier.priceMinor * adultCount; - const childTotalMinor = childFareMinor * childCount; + const childTotalMinor = tier.priceMinor * paidChildrenCount; const totalBaseFareMinor = adultFareMinor + childTotalMinor; return { baseFareMinor: tier.priceMinor, adultCount, adultFareMinor, childCount, - freeChildrenCount: 0, - paidChildrenCount: childCount, + freeChildrenCount, + paidChildrenCount, childFareMinor: childTotalMinor, totalBaseFareMinor, discountMinor: 0, @@ -1555,21 +1562,51 @@ export class BookingsService { }); } - async delete(id: string) { + async delete(id: string, cascade = false) { const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } }); if (!booking) throw new NotFoundException('Booking not found'); - - // Check usage before allowing deletion - const usage = await this.checkBookingUsage(id); - if (usage.isInUse && usage.constraints) { - throw new DeleteOperationException('Booking', booking.bookingRef, usage.constraints); + + if (!cascade) { + const usage = await this.checkBookingUsage(id); + if (usage.isInUse && usage.constraints) { + throw new DeleteOperationException('Booking', booking.bookingRef, usage.constraints); + } } - + await this.seatsService.releaseSeats(booking.id); - + + if (cascade) { + // Delete all child records that reference this booking (no onDelete: Cascade in schema) + const paymentIntent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: id } }); + if (paymentIntent) { + await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: paymentIntent.id } }); + await this.prisma.paymentIntent.delete({ where: { bookingId: id } }); + } + const tickets = await this.prisma.ticket.findMany({ where: { bookingId: id }, select: { id: true } }); + for (const t of tickets) { + await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: t.id } }); + } + await this.prisma.ticket.deleteMany({ where: { bookingId: id } }); + await this.prisma.bookingModification.deleteMany({ where: { bookingId: id } }); + await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: id } }); + await this.prisma.agentBooking.deleteMany({ where: { bookingId: id } }); + const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: id }, select: { id: true } }); + for (const fo of foodOrders) { + await this.prisma.foodOrderItem.deleteMany({ where: { orderId: fo.id } }); + } + await this.prisma.foodOrder.deleteMany({ where: { bookingId: id } }); + await this.prisma.baggageBooking.deleteMany({ where: { bookingId: id } }); + await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: id } }); + const journey = await this.prisma.journey.findUnique({ where: { bookingId: id } }); + if (journey) { + await this.prisma.journeySegment.deleteMany({ where: { journeyId: journey.id } }); + await this.prisma.journey.delete({ where: { bookingId: id } }); + } + } + await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } }); await this.prisma.booking.delete({ where: { id } }); - + return { deleted: true, bookingRef: booking.bookingRef }; } diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index 24f9981fc..7422e45b0 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -76,10 +76,11 @@ export class FleetController { @Delete('classes/:id') @ApiOperation({ summary: 'Delete a class' }) @ApiParam({ name: 'id', description: 'Class UUID' }) + @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @ApiResponse({ status: 200, description: 'Class deleted' }) @ApiResponse({ status: 404, description: 'Class not found' }) - deleteClass(@Param('id') id: string) { - return this.service.deleteClass(id); + deleteClass(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.deleteClass(id, cascade === 'true'); } // Seat Class Endpoints (DEPRECATED - use Classes endpoints instead) @@ -112,10 +113,11 @@ export class FleetController { @Delete('seat-classes/:id') @ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' }) @ApiParam({ name: 'id', description: 'Class UUID' }) + @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @ApiResponse({ status: 200, description: 'Class deleted' }) @ApiResponse({ status: 404, description: 'Class not found' }) - deleteSeatClass(@Param('id') id: string) { - return this.service.deleteClass(id); + deleteSeatClass(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.deleteClass(id, cascade === 'true'); } // Train Endpoints @@ -147,10 +149,11 @@ export class FleetController { @Delete('trains/:id') @ApiOperation({ summary: 'Delete a train service' }) @ApiParam({ name: 'id', description: 'Train UUID' }) + @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @ApiResponse({ status: 200, description: 'Train deleted' }) @ApiResponse({ status: 404, description: 'Train not found' }) - deleteTrain(@Param('id') id: string) { - return this.service.deleteTrain(id); + deleteTrain(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.deleteTrain(id, cascade === 'true'); } @Patch('trains/:id/restore') @@ -309,10 +312,11 @@ export class FleetController { @Delete('coaches/:id') @ApiOperation({ summary: 'Delete a coach' }) @ApiParam({ name: 'id', description: 'Coach UUID' }) + @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @ApiResponse({ status: 200, description: 'Coach deleted successfully' }) @ApiResponse({ status: 404, description: 'Coach not found' }) - deleteCoach(@Param('id') id: string) { - return this.service.deleteCoach(id); + deleteCoach(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.deleteCoach(id, cascade === 'true'); } @Post('assignments') diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index a725b9cd4..dc29f4ecc 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -273,7 +273,7 @@ export class FleetService { }); } - async deleteClass(id: string) { + async deleteClass(id: string, cascade = false) { const seatClass = await this.prisma.seatClass.findUnique({ where: { id }, include: { @@ -284,19 +284,27 @@ export class FleetService { }); if (!seatClass) throw new NotFoundException('Seat class not found'); - const totalFareRules = seatClass.fareRules.length + seatClass.routeFareRules.length + seatClass.segmentFares.length; - const constraints = []; - - if (totalFareRules > 0) { - constraints.push({ - entityName: 'fare rule', - count: totalFareRules, - action: 'delete' as const - }); + if (!cascade) { + const totalFareRules = seatClass.fareRules.length + seatClass.routeFareRules.length + seatClass.segmentFares.length; + const constraints = []; + + if (totalFareRules > 0) { + constraints.push({ + entityName: 'fare rule', + count: totalFareRules, + action: 'delete' as const + }); + } + + if (constraints.length > 0) { + throw new DeleteOperationException('Seat Class', seatClass.name, constraints); + } } - - if (constraints.length > 0) { - throw new DeleteOperationException('Seat Class', seatClass.name, constraints); + + if (cascade) { + await this.prisma.fareRule.deleteMany({ where: { seatClassId: id } }); + await this.prisma.routeFareRule.deleteMany({ where: { seatClassId: id } }); + await this.prisma.segmentFareRule.deleteMany({ where: { seatClassId: id } }); } return this.prisma.seatClass.delete({ where: { id } }); @@ -354,24 +362,78 @@ export class FleetService { }); } - async deleteTrain(id: string) { + async deleteTrain(id: string, cascade = false) { const train = await this.prisma.train.findUnique({ where: { id }, include: { schedules: true }, }); if (!train) throw new NotFoundException('Train not found'); - const constraints = []; - if (train.schedules.length > 0) { - constraints.push({ - entityName: 'schedule', - count: train.schedules.length, - action: 'delete' as const - }); + if (!cascade) { + const constraints = []; + if (train.schedules.length > 0) { + constraints.push({ + entityName: 'schedule', + count: train.schedules.length, + action: 'delete' as const + }); + } + + if (constraints.length > 0) { + throw new DeleteOperationException('Train', `${train.number} (${train.name})`, constraints); + } } - - if (constraints.length > 0) { - throw new DeleteOperationException('Train', `${train.number} (${train.name})`, constraints); + + if (cascade && train.schedules.length > 0) { + const scheduleIds = train.schedules.map((s: any) => s.id); + await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); + await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); + await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); + await this.prisma.menuItem.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); + await this.prisma.journeySegment.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); + + const bookings = await this.prisma.booking.findMany({ + where: { OR: [{ scheduleId: { in: scheduleIds } }, { returnScheduleId: { in: scheduleIds } }] }, + select: { id: true }, + }); + if (bookings.length > 0) { + const bookingIds = bookings.map(b => b.id); + const tickets = await this.prisma.ticket.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); + if (tickets.length > 0) { + await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: { in: tickets.map(t => t.id) } } }); + } + await this.prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } }); + const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); + if (foodOrders.length > 0) { + await this.prisma.foodOrderItem.deleteMany({ where: { orderId: { in: foodOrders.map(o => o.id) } } }); + } + await this.prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } }); + const paymentIntents = await this.prisma.paymentIntent.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); + if (paymentIntents.length > 0) { + await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: { in: paymentIntents.map(p => p.id) } } }); + } + await this.prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.journey.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.booking.deleteMany({ where: { id: { in: bookingIds } } }); + } + + const packages = await this.prisma.travelPackage.findMany({ + where: { OR: [{ outboundScheduleId: { in: scheduleIds } }, { returnScheduleId: { in: scheduleIds } }] }, + select: { id: true }, + }); + if (packages.length > 0) { + const packageIds = packages.map(p => p.id); + await this.prisma.packagePriceTier.deleteMany({ where: { packageId: { in: packageIds } } }); + await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } }); + } + + await this.prisma.trainSchedule.deleteMany({ where: { id: { in: scheduleIds } } }); } return this.prisma.train.delete({ where: { id } }); @@ -477,7 +539,7 @@ export class FleetService { }); } - async deleteCoach(id: string) { + async deleteCoach(id: string, cascade = false) { const coach = await this.prisma.coach.findUnique({ where: { id }, include: { @@ -493,47 +555,60 @@ export class FleetService { }); if (!coach) throw new NotFoundException('Coach not found'); - const constraints = []; - - if ((coach as any).assignments.length > 0) { - constraints.push({ - entityName: 'schedule assignment', - count: (coach as any).assignments.length, - action: 'reassign' as const - }); + if (!cascade) { + const constraints = []; + + if ((coach as any).assignments.length > 0) { + constraints.push({ + entityName: 'schedule assignment', + count: (coach as any).assignments.length, + action: 'reassign' as const + }); + } + + const bookedSeats = (coach as any).seats.filter((seat: any) => seat.bookingSeats.length > 0); + if (bookedSeats.length > 0) { + constraints.push({ + entityName: 'booked seat', + count: bookedSeats.length, + action: 'complete' as const + }); + } + + const blockedSeats = (coach as any).seats.filter((seat: any) => seat.blocks.length > 0); + if (blockedSeats.length > 0) { + constraints.push({ + entityName: 'blocked seat', + count: blockedSeats.length, + action: 'delete' as const + }); + } + + const seatsWithTickets = (coach as any).seats.filter((seat: any) => seat.tickets.length > 0); + if (seatsWithTickets.length > 0) { + constraints.push({ + entityName: 'seat with issued ticket', + count: seatsWithTickets.length, + action: 'complete' as const + }); + } + + if (constraints.length > 0) { + throw new DeleteOperationException('Coach', coach.number, constraints); + } } - const bookedSeats = (coach as any).seats.filter((seat: any) => seat.bookingSeats.length > 0); - if (bookedSeats.length > 0) { - constraints.push({ - entityName: 'booked seat', - count: bookedSeats.length, - action: 'complete' as const - }); + if (cascade) { + const seatIds = (coach as any).seats.map((s: any) => s.id); + if (seatIds.length > 0) { + await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } }); + await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } }); + await this.prisma.ticket.deleteMany({ where: { seatId: { in: seatIds } } }); + } + await this.prisma.coachAssignment.deleteMany({ where: { coachId: id } }); + await this.prisma.routeCoachTemplate.deleteMany({ where: { coachId: id } }); } - - const blockedSeats = (coach as any).seats.filter((seat: any) => seat.blocks.length > 0); - if (blockedSeats.length > 0) { - constraints.push({ - entityName: 'blocked seat', - count: blockedSeats.length, - action: 'delete' as const - }); - } - - const seatsWithTickets = (coach as any).seats.filter((seat: any) => seat.tickets.length > 0); - if (seatsWithTickets.length > 0) { - constraints.push({ - entityName: 'seat with issued ticket', - count: seatsWithTickets.length, - action: 'complete' as const - }); - } - - if (constraints.length > 0) { - throw new DeleteOperationException('Coach', coach.number, constraints); - } - + await this.prisma.seat.deleteMany({ where: { coachId: id } }); return this.prisma.coach.delete({ where: { id } }); diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts index de61f92ca..07e324336 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -142,8 +142,9 @@ export class PackagesController { @UseGuards(IamGuard) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete package (admin)' }) - remove(@Param('id') id: string) { - return this.service.remove(id); + @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' }) + remove(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.remove(id, cascade === 'true'); } @Patch(':id/activate') diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index da97dcfa9..a474b1edf 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -8,9 +8,12 @@ import { GuestBookingService } from '../bookings/guest-booking.service'; /** Package-specific fare rules */ const PKG_MAX_ADULTS = 5; -const PKG_CHILDREN_PER_ADULT = 2; // 2 children allowed per adult -const PKG_CHILD_FARE_RATIO = 0.1; +const PKG_CHILDREN_PER_ADULT = 5; // max 5 children per adult +/** + * First child per adult travels FREE (no seat). + * Additional children beyond one per adult pay the full adult fare. + */ function calculatePackageFareBreakdown( priceMinor: number, isRoundTrip: boolean, @@ -19,9 +22,10 @@ function calculatePackageFareBreakdown( ) { const multiplier = isRoundTrip ? 2 : 1; const adultFareMinor = priceMinor * multiplier; - const childFareMinor = Math.round(adultFareMinor * PKG_CHILD_FARE_RATIO); - const totalMinor = adultCount * adultFareMinor + childCount * childFareMinor; - return { adultFareMinor, childFareMinor, totalMinor, multiplier }; + const freeChildren = Math.min(childCount, adultCount); + const paidChildren = Math.max(0, childCount - adultCount); + const totalMinor = adultCount * adultFareMinor + paidChildren * adultFareMinor; + return { adultFareMinor, freeChildren, paidChildren, totalMinor, multiplier }; } function deriveAge(dateOfBirth: string | Date): number { @@ -71,16 +75,18 @@ export class PackagesService { const maxChildren = adultCount * PKG_CHILDREN_PER_ADULT; if (childCount > maxChildren) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildren} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`); - const passengerCount = adultCount + childCount; const remaining = tier.availableSeats - tier.bookedSeats; - if (passengerCount > remaining) - throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`); - const isRoundTrip = !!pkg.returnScheduleId; - const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown( + const { adultFareMinor, freeChildren, paidChildren, totalMinor } = calculatePackageFareBreakdown( tier.priceMinor, isRoundTrip, adultCount, childCount, ); + // Only adults and paid children need seats; free children travel without a seat + const seatsNeeded = adultCount + paidChildren; + const passengerCount = adultCount + childCount; + if (seatsNeeded > remaining) + throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`); + // Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches let seatClassId: string | null = tier.seatClassId ?? null; let seatClassName: string | null = null; @@ -113,8 +119,10 @@ export class PackagesService { passengerCount, isRoundTrip, pricePerAdultMinor: adultFareMinor, - pricePerChildMinor: childFareMinor, - childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`, + pricePerChildMinor: adultFareMinor, // paid children pay full adult fare + freeChildrenCount: freeChildren, + paidChildrenCount: paidChildren, + childFareNote: `First child per adult travels free (no seat); additional children pay full adult fare`, maxAdults: PKG_MAX_ADULTS, maxChildren: adultCount * PKG_CHILDREN_PER_ADULT, totalMinor, @@ -317,25 +325,31 @@ export class PackagesService { return this.prisma.packagePriceTier.delete({ where: { id: tierId } }); } - async remove(id: string) { - const pkg = await this.prisma.travelPackage.findUnique({ - where: { id }, - include: { bookings: { select: { id: true, status: true } } }, - }); + async remove(id: string, cascade = false) { + const pkg = await this.prisma.travelPackage.findUnique({ where: { id } }); if (!pkg) throw new NotFoundException('Package not found'); - const hasActive = pkg.bookings.some((b) => b.status === 'PENDING_PAYMENT' || b.status === 'CONFIRMED'); - if (hasActive) throw new BadRequestException('Cannot delete a package with active bookings'); - await this.prisma.$transaction(async (tx) => { - const bookingIds = pkg.bookings.map((b) => b.id); - if (bookingIds.length > 0) { - await tx.packageBookingPassenger.deleteMany({ where: { bookingId: { in: bookingIds } } }); - await tx.packagePaymentIntent.deleteMany({ where: { packageBookingId: { in: bookingIds } } }); - await tx.packageBooking.deleteMany({ where: { packageId: id } }); - } - await tx.packagePriceTier.deleteMany({ where: { packageId: id } }); - await tx.travelPackage.delete({ where: { id } }); + const packageBookings = await this.prisma.packageBooking.findMany({ + where: { packageId: id }, + select: { id: true, status: true }, }); + + if (!cascade) { + const hasActive = packageBookings.some(b => b.status === 'PENDING_PAYMENT' || b.status === 'CONFIRMED'); + if (hasActive) throw new BadRequestException('Cannot delete a package with active bookings. Use cascade=true to force delete.'); + } + + const pbIds = packageBookings.map(b => b.id); + if (pbIds.length > 0) { + await this.prisma.packageBookingPassenger.deleteMany({ where: { bookingId: { in: pbIds } } }); + await this.prisma.packagePaymentIntent.deleteMany({ where: { packageBookingId: { in: pbIds } } }); + await this.prisma.packageBooking.deleteMany({ where: { id: { in: pbIds } } }); + } + + // PackageInquiry references packageId and priceTierId + await this.prisma.packageInquiry.deleteMany({ where: { packageId: id } }); + await this.prisma.packagePriceTier.deleteMany({ where: { packageId: id } }); + await this.prisma.travelPackage.delete({ where: { id } }); return { deleted: true }; } @@ -379,14 +393,16 @@ export class PackagesService { const passengerCount = adultCount + childCount; const remaining = tier.availableSeats - tier.bookedSeats; - if (passengerCount > remaining) { - throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`); - } const isRoundTrip = !!pkg.returnScheduleId; - const { adultFareMinor, childFareMinor, totalMinor } = calculatePackageFareBreakdown( + const { adultFareMinor, freeChildren, paidChildren, totalMinor } = calculatePackageFareBreakdown( tier.priceMinor, isRoundTrip, adultCount, childCount, ); + // Only adults and paid children need seats; free children travel without a seat + const seatsNeeded = adultCount + paidChildren; + if (seatsNeeded > remaining) { + throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`); + } const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB @@ -435,7 +451,7 @@ export class PackagesService { }), this.prisma.packagePriceTier.update({ where: { id: dto.priceTierId }, - data: { bookedSeats: { increment: passengerCount } }, + data: { bookedSeats: { increment: seatsNeeded } }, }), ]); @@ -446,8 +462,10 @@ export class PackagesService { adultCount, adultFareMinor, childCount, - childFareMinor, - childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`, + freeChildrenCount: freeChildren, + paidChildrenCount: paidChildren, + paidChildFareMinor: adultFareMinor, + childFareNote: `First child per adult travels free (no seat); additional children pay full adult fare`, totalMinor, currency: 'ETB', displayCurrency, diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index a9fbfc7d2..f0b719386 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -508,10 +508,11 @@ Returns saved passenger details with generated IDs and confirmation.`, summary: 'Delete passenger (admin only)', description: 'Permanently deletes a passenger record and associated data' }) + @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related bookings and data' }) @ApiResponse({ status: 200, description: 'Passenger deleted successfully' }) @ApiResponse({ status: 404, description: 'Passenger not found' }) - deletePassenger(@Param('id') id: string) { - return this.service.deletePassenger(id); + deletePassenger(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.deletePassenger(id, cascade === 'true'); } @Get(':id/usage') diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index d41fcbfed..84be880be 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -432,7 +432,7 @@ export class PassengersService { }; } - async deletePassenger(id: string) { + async deletePassenger(id: string, cascade = false) { // id may be a TravelerProfile.id (from the list endpoint) or a Passenger.id let passenger = await this.prisma.passenger.findUnique({ where: { id }, @@ -451,29 +451,67 @@ export class PassengersService { const passengerId = passenger.id; - // Check usage before allowing deletion - const usage = await this.checkPassengerUsage(passengerId); - if (usage.isInUse && usage.constraints) { - const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`; - throw new DeleteOperationException('Passenger', passengerName, usage.constraints); + if (!cascade) { + const usage = await this.checkPassengerUsage(passengerId); + if (usage.isInUse && usage.constraints) { + const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`; + throw new DeleteOperationException('Passenger', passengerName, usage.constraints); + } } - await this.prisma.$transaction([ - this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId } } }), - this.prisma.loyaltyAccount.deleteMany({ where: { passengerId } }), - this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId } } }), - this.prisma.walletAccount.deleteMany({ where: { passengerId } }), - this.prisma.notification.deleteMany({ where: { passengerId } }), - this.prisma.travelerProfile.deleteMany({ where: { passengerId } }), - this.prisma.savedRoute.deleteMany({ where: { passengerId } }), - this.prisma.packageBooking.deleteMany({ where: { passengerId } }), - this.prisma.ticket.deleteMany({ where: { booking: { passengerId } } }), - this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId } } }), - this.prisma.booking.deleteMany({ where: { passengerId } }), - this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId } } }), - this.prisma.journey.deleteMany({ where: { passengerId } }), - this.prisma.passenger.delete({ where: { id: passengerId } }), - ]); + // Resolve booking IDs first (needed for multi-step child deletion) + const bookings = await this.prisma.booking.findMany({ + where: { passengerId }, + select: { id: true }, + }); + const bookingIds = bookings.map(b => b.id); + + if (bookingIds.length > 0) { + const tickets = await this.prisma.ticket.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); + if (tickets.length > 0) { + await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: { in: tickets.map(t => t.id) } } }); + } + await this.prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } }); + + const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); + if (foodOrders.length > 0) { + await this.prisma.foodOrderItem.deleteMany({ where: { orderId: { in: foodOrders.map(o => o.id) } } }); + } + await this.prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } }); + + const paymentIntents = await this.prisma.paymentIntent.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); + if (paymentIntents.length > 0) { + await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: { in: paymentIntents.map(p => p.id) } } }); + } + await this.prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } }); + + await this.prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.journey.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.booking.deleteMany({ where: { id: { in: bookingIds } } }); + } + + // Package bookings + const packageBookings = await this.prisma.packageBooking.findMany({ where: { passengerId }, select: { id: true } }); + if (packageBookings.length > 0) { + const pbIds = packageBookings.map(pb => pb.id); + await this.prisma.packageBookingPassenger.deleteMany({ where: { bookingId: { in: pbIds } } }); + await this.prisma.packagePaymentIntent.deleteMany({ where: { packageBookingId: { in: pbIds } } }); + await this.prisma.packageBooking.deleteMany({ where: { id: { in: pbIds } } }); + } + + await this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId } } }); + await this.prisma.loyaltyAccount.deleteMany({ where: { passengerId } }); + await this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId } } }); + await this.prisma.walletAccount.deleteMany({ where: { passengerId } }); + await this.prisma.notification.deleteMany({ where: { passengerId } }); + await this.prisma.travelerProfile.deleteMany({ where: { passengerId } }); + await this.prisma.savedRoute.deleteMany({ where: { passengerId } }); + await this.prisma.passenger.delete({ where: { id: passengerId } }); return { deleted: true, passengerId }; } diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts index 1cd382862..f432cf072 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts @@ -51,9 +51,10 @@ Route stops carry distanceKm for fare-by-distance calculations.`, @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Delete a route' }) @ApiParam({ name: 'id', description: 'Route UUID' }) + @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @ApiResponse({ status: 200, description: 'Route deleted' }) @ApiResponse({ status: 404, description: 'Route not found' }) - deleteRoute(@Param('id') id: string) { return this.service.deleteRoute(id); } + deleteRoute(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteRoute(id, cascade === 'true'); } // ── Route Stops ──────────────────────────────────────────────────────────── diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index 479f6b0ca..e459e3663 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -110,7 +110,7 @@ export class RoutesService { }); } - async deleteRoute(id: string) { + async deleteRoute(id: string, cascade = false) { const route = await this.prisma.route.findUnique({ where: { id }, include: { @@ -120,19 +120,77 @@ export class RoutesService { }); if (!route) throw new NotFoundException('Route not found'); - const constraints = []; - if (route.schedules.length > 0) { - constraints.push({ - entityName: 'schedule', - count: route.schedules.length, - action: 'delete' as const - }); + if (!cascade) { + const constraints = []; + if (route.schedules.length > 0) { + constraints.push({ + entityName: 'schedule', + count: route.schedules.length, + action: 'delete' as const + }); + } + + if (constraints.length > 0) { + throw new DeleteOperationException('Route', `${route.code} (${route.name})`, constraints); + } } - if (constraints.length > 0) { - throw new DeleteOperationException('Route', `${route.code} (${route.name})`, constraints); + if (cascade) { + const scheduleIds = route.schedules.map(s => s.id); + if (scheduleIds.length > 0) { + await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); + await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); + await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); + await this.prisma.menuItem.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); + await this.prisma.journeySegment.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); + + const bookings = await this.prisma.booking.findMany({ + where: { OR: [{ scheduleId: { in: scheduleIds } }, { returnScheduleId: { in: scheduleIds } }] }, + select: { id: true }, + }); + if (bookings.length > 0) { + const bookingIds = bookings.map(b => b.id); + const tickets = await this.prisma.ticket.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); + if (tickets.length > 0) { + await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: { in: tickets.map(t => t.id) } } }); + } + await this.prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } }); + const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); + if (foodOrders.length > 0) { + await this.prisma.foodOrderItem.deleteMany({ where: { orderId: { in: foodOrders.map(o => o.id) } } }); + } + await this.prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } }); + const paymentIntents = await this.prisma.paymentIntent.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); + if (paymentIntents.length > 0) { + await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: { in: paymentIntents.map(p => p.id) } } }); + } + await this.prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.journey.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.booking.deleteMany({ where: { id: { in: bookingIds } } }); + } + + const packages = await this.prisma.travelPackage.findMany({ + where: { OR: [{ outboundScheduleId: { in: scheduleIds } }, { returnScheduleId: { in: scheduleIds } }] }, + select: { id: true }, + }); + if (packages.length > 0) { + const packageIds = packages.map(p => p.id); + await this.prisma.packagePriceTier.deleteMany({ where: { packageId: { in: packageIds } } }); + await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } }); + } + + await this.prisma.trainSchedule.deleteMany({ where: { id: { in: scheduleIds } } }); + } + await this.prisma.routeStop.deleteMany({ where: { routeId: id } }); + await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId: id } }); } - + await this.prisma.route.delete({ where: { id } }); return { deleted: true, id }; } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 1a5547d48..3cb95104d 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -113,7 +113,8 @@ export class SchedulesController { @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Delete a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) - deleteSchedule(@Param('id') id: string) { return this.service.deleteSchedule(id); } + @ApiQuery({ name: 'cascade', required: false, type: Boolean }) + deleteSchedule(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteSchedule(id, cascade === 'true'); } @Get(':id/stops') @IsPublic() diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index bd7cc7258..92a0a66db 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -332,7 +332,7 @@ export class SchedulesService { return this.prisma.trainSchedule.update({ where: { id }, data: { status: dto.status } }); } - async deleteSchedule(id: string) { + async deleteSchedule(id: string, cascade = false) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id }, include: { @@ -344,18 +344,20 @@ export class SchedulesService { }); if (!schedule) throw new NotFoundException('Schedule not found'); - const constraints = []; - if ((schedule as any)._count.bookings > 0) { - constraints.push({ - entityName: 'booking', - count: (schedule as any)._count.bookings, - action: 'cancel' as const - }); - } - - if (constraints.length > 0) { - const scheduleName = `${schedule.train.number} (${schedule.originStation.name} → ${schedule.destinationStation.name})`; - throw new DeleteOperationException('Schedule', scheduleName, constraints); + if (!cascade) { + const constraints = []; + if ((schedule as any)._count.bookings > 0) { + constraints.push({ + entityName: 'booking', + count: (schedule as any)._count.bookings, + action: 'cancel' as const + }); + } + + if (constraints.length > 0) { + const scheduleName = `${schedule.train.number} (${schedule.originStation.name} → ${schedule.destinationStation.name})`; + throw new DeleteOperationException('Schedule', scheduleName, constraints); + } } await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } }); @@ -363,30 +365,49 @@ export class SchedulesService { await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: id } }); await this.prisma.menuItem.deleteMany({ where: { scheduleId: id } }); await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } }); - - // Delete travel packages that reference this schedule (required fields cannot be nulled) - // First get packages that reference this schedule - const packagesToDelete = await this.prisma.travelPackage.findMany({ - where: { - OR: [ - { outboundScheduleId: id }, - { returnScheduleId: id } - ] - }, - select: { id: true } + + // Delete bookings and all their children + const bookings = await this.prisma.booking.findMany({ + where: { OR: [{ scheduleId: id }, { returnScheduleId: id }] }, + select: { id: true }, + }); + if (bookings.length > 0) { + const bookingIds = bookings.map(b => b.id); + // Leaf tables first + const tickets = await this.prisma.ticket.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); + if (tickets.length > 0) { + await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: { in: tickets.map(t => t.id) } } }); + } + await this.prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } }); + const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); + if (foodOrders.length > 0) { + await this.prisma.foodOrderItem.deleteMany({ where: { orderId: { in: foodOrders.map(o => o.id) } } }); + } + await this.prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } }); + const paymentIntents = await this.prisma.paymentIntent.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); + if (paymentIntents.length > 0) { + await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: { in: paymentIntents.map(p => p.id) } } }); + } + await this.prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.journey.deleteMany({ where: { bookingId: { in: bookingIds } } }); + await this.prisma.booking.deleteMany({ where: { id: { in: bookingIds } } }); + } + + // Delete travel packages that reference this schedule + const packagesToDelete = await this.prisma.travelPackage.findMany({ + where: { OR: [{ outboundScheduleId: id }, { returnScheduleId: id }] }, + select: { id: true }, }); - - // Delete price tiers first (they have foreign key to packages) if (packagesToDelete.length > 0) { const packageIds = packagesToDelete.map(p => p.id); - await this.prisma.packagePriceTier.deleteMany({ - where: { packageId: { in: packageIds } } - }); - - // Now delete the packages - await this.prisma.travelPackage.deleteMany({ - where: { id: { in: packageIds } } - }); + await this.prisma.packagePriceTier.deleteMany({ where: { packageId: { in: packageIds } } }); + await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } }); } return this.prisma.trainSchedule.delete({ where: { id } }); } diff --git a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts index a89ecd87d..2b1842c2a 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts @@ -136,9 +136,10 @@ export class StationsController { @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Delete station' }) + @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @ApiResponse({ status: 200, description: 'Station deleted successfully' }) @ApiResponse({ status: 404, description: 'Station not found' }) - remove(@Param('id') id: string) { - return this.service.remove(id); + remove(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.remove(id, cascade === 'true'); } } diff --git a/apps/edr-passenger-api/src/modules/stations/stations.service.ts b/apps/edr-passenger-api/src/modules/stations/stations.service.ts index ec2480b21..ff40a30ab 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -96,7 +96,7 @@ export class StationsService { return updatedStation; } - async remove(id: string) { + async remove(id: string, cascade = false) { const station = await this.prisma.station.findUnique({ where: { id }, include: { @@ -107,24 +107,34 @@ export class StationsService { }); if (!station) throw new NotFoundException('Station not found'); - const [routeStopCount, originCount, destCount, stopTimeCount] = await Promise.all([ - this.prisma.routeStop.count({ where: { stationId: id } }), - this.prisma.trainSchedule.count({ where: { originStationId: id } }), - this.prisma.trainSchedule.count({ where: { destinationStationId: id } }), - (station as any)._count.stopTimes as number, - ]); + if (!cascade) { + const [routeStopCount, originCount, destCount, stopTimeCount] = await Promise.all([ + this.prisma.routeStop.count({ where: { stationId: id } }), + this.prisma.trainSchedule.count({ where: { originStationId: id } }), + this.prisma.trainSchedule.count({ where: { destinationStationId: id } }), + (station as any)._count.stopTimes as number, + ]); - const constraints = []; - if (routeStopCount > 0) - constraints.push({ entityName: 'route', count: routeStopCount, action: 'delete' as const }); - const scheduleCount = originCount + destCount; - if (scheduleCount > 0) - constraints.push({ entityName: 'schedule', count: scheduleCount, action: 'delete' as const }); - if (stopTimeCount > 0) - constraints.push({ entityName: 'stop time', count: stopTimeCount, action: 'delete' as const }); + const constraints = []; + if (routeStopCount > 0) + constraints.push({ entityName: 'route', count: routeStopCount, action: 'delete' as const }); + const scheduleCount = originCount + destCount; + if (scheduleCount > 0) + constraints.push({ entityName: 'schedule', count: scheduleCount, action: 'delete' as const }); + if (stopTimeCount > 0) + constraints.push({ entityName: 'stop time', count: stopTimeCount, action: 'delete' as const }); - if (constraints.length > 0) - throw new DeleteOperationException('Station', `${station.name} (${station.code})`, constraints); + if (constraints.length > 0) + throw new DeleteOperationException('Station', `${station.name} (${station.code})`, constraints); + } + + if (cascade) { + await this.prisma.tripStopTime.deleteMany({ where: { stationId: id } }); + await this.prisma.trainSchedule.deleteMany({ + where: { OR: [{ originStationId: id }, { destinationStationId: id }] }, + }); + await this.prisma.routeStop.deleteMany({ where: { stationId: id } }); + } const deleted = await this.prisma.station.delete({ where: { id } }); diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 974c25470..be2785d43 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -38,6 +38,8 @@ function BookingsPageContent() { const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [bookingToDelete, setBookingToDelete] = useState(null); const [deleteError, setDeleteError] = useState(null); + const [deleteCascade, setDeleteCascade] = useState(false); + const [deleteCascadeChecked, setDeleteCascadeChecked] = useState(false); const [successMessage, setSuccessMessage] = useState(''); const [exportModalOpen, setExportModalOpen] = useState(false); const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); @@ -62,17 +64,27 @@ function BookingsPageContent() { }); const deleteMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/bookings/${id}`), + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/bookings/${id}${cascade ? '?cascade=true' : ''}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bookings'] }); setDeleteConfirmOpen(false); setBookingToDelete(null); setDeleteError(null); + setDeleteCascade(false); + setDeleteCascadeChecked(false); setSuccessMessage('Booking deleted successfully'); setTimeout(() => setSuccessMessage(''), 3000); }, onError: (error: any) => { - setDeleteError(error?.response?.data?.message || error?.message || 'Failed to delete booking'); + const msg = error?.response?.data?.message || error?.message || 'Failed to delete booking'; + const isFkError = msg?.includes('Cannot delete') || error?.response?.status === 400; + if (isFkError && !deleteCascade) { + setDeleteCascade(true); + setDeleteCascadeChecked(false); + setDeleteError(Array.isArray(msg) ? msg.join(' ') : msg); + } else { + setDeleteError(Array.isArray(msg) ? msg.join(' ') : msg); + } }, }); @@ -246,7 +258,7 @@ function BookingsPageContent() { const actions = [ { label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye }, - { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, + { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, ]; return ( @@ -493,12 +505,16 @@ function BookingsPageContent() { { setDeleteConfirmOpen(false); setBookingToDelete(null); setDeleteError(null); }} - onConfirm={async () => { if (bookingToDelete) await deleteMutation.mutateAsync(bookingToDelete.id); }} + onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); }} + onConfirm={async () => { if (bookingToDelete) await deleteMutation.mutateAsync({ id: bookingToDelete.id, cascade: deleteCascade && deleteCascadeChecked }); }} title="Delete Booking" message={`Permanently delete booking ${bookingToDelete?.bookingRef}? This cannot be undone and will release all associated seats.`} confirmText="Delete" cancelText="Cancel" isLoading={deleteMutation.isPending} isDanger error={deleteError ?? undefined} + warning={!deleteCascade ? undefined : undefined} + cascadeWarning={deleteCascade ? "This booking has related tickets, payments, or modification records that will also be permanently deleted." : undefined} + cascadeChecked={deleteCascadeChecked} + onCascadeChange={(checked) => setDeleteCascadeChecked(checked)} /> setExportModalOpen(false)} title="Export Bookings" size="md"> diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx index 65671959b..348268ca2 100644 --- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx @@ -15,7 +15,7 @@ export default function ClassesPage() { const [filters, setFilters] = useState({ search: '' }); const [showModal, setShowModal] = useState(false); const [editingClass, setEditingClass] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null; error?: string }>({ isOpen: false, class: null }); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; class: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, class: null }); const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(''); const queryClient = useQueryClient(); @@ -56,13 +56,18 @@ export default function ClassesPage() { }); const deleteMutation = useMutation({ - mutationFn: seatClassesApi.delete, + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => seatClassesApi.delete(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['classes'] }); }, onError: (e: any) => { const msg = e?.response?.data?.message || e?.message || 'Failed to delete class'; - setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + const isFkError = msg?.includes('Cannot delete') || e?.response?.status === 400; + if (isFkError && !deleteConfirm.cascade) { + setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } else { + setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } }, }); @@ -99,7 +104,7 @@ export default function ClassesPage() { const confirmDelete = async () => { if (!deleteConfirm.class) return; try { - await deleteMutation.mutateAsync(deleteConfirm.class.id); + await deleteMutation.mutateAsync({ id: deleteConfirm.class.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); setDeleteConfirm({ isOpen: false, class: null }); } catch { // error is set by onError handler @@ -239,7 +244,10 @@ export default function ClassesPage() { isDanger={true} isLoading={deleteMutation.isPending} error={deleteConfirm.error} - warning="This class may be used by coaches and fare rules. Deleting it may impact seat assignments and pricing." + warning={!deleteConfirm.cascade ? "This class may be used by coaches and fare rules. Deleting it may impact seat assignments and pricing." : undefined} + cascadeWarning={deleteConfirm.cascade ? "This class has related fare rules that will also be permanently deleted." : undefined} + cascadeChecked={deleteConfirm.cascadeChecked} + onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))} /> {/* Add/Edit Modal */} diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index f97456a3d..eb8ebf036 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -145,7 +145,7 @@ export default function CoachesPage() { const [search, setSearch] = useState(''); const [showModal, setShowModal] = useState(false); const [editingItem, setEditingItem] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null }); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, item: null }); const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(''); const [isBedCoach, setIsBedCoach] = useState(false); const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false); @@ -217,7 +217,7 @@ export default function CoachesPage() { }); const deleteCoachMutation = useMutation({ - mutationFn: fleetApi.deleteCoach, + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => fleetApi.deleteCoach(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['coaches'] }); }, @@ -277,12 +277,17 @@ export default function CoachesPage() { if (deleteConfirm.item?.isCoachType) { await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id); } else { - await deleteCoachMutation.mutateAsync(deleteConfirm.item.id); + await deleteCoachMutation.mutateAsync({ id: deleteConfirm.item.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); } setDeleteConfirm({ isOpen: false, item: null }); } catch (err: any) { const msg = err?.response?.data?.message || err?.message || 'Delete failed'; - setDeleteConfirm((prev) => ({ ...prev, error: msg })); + const isFkError = msg?.includes('Cannot delete') || err?.response?.status === 400; + if (isFkError && !deleteConfirm.item?.isCoachType && !deleteConfirm.cascade) { + setDeleteConfirm((prev) => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } else { + setDeleteConfirm((prev) => ({ ...prev, error: msg })); + } } }; @@ -689,11 +694,14 @@ export default function CoachesPage() { isDanger={true} isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending} error={deleteConfirm.error} - warning={ + warning={!deleteConfirm.cascade ? ( deleteConfirm.item?.isCoachType ? 'This coach type may have coaches assigned. Deleting it may impact these systems.' : 'This coach may be assigned to schedules. Deleting it may impact these systems.' - } + ) : undefined} + cascadeWarning={deleteConfirm.cascade ? "This coach has related assignments or seats that will also be permanently deleted." : undefined} + cascadeChecked={deleteConfirm.cascadeChecked} + onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))} /> {/* Add/Edit Modal */} diff --git a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx index ab0386a19..b08be0234 100644 --- a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx @@ -47,6 +47,7 @@ export default function PackagesPage() { const [tierError, setTierError] = useState(null); const [deletePackageConfirm, setDeletePackageConfirm] = useState(null); const [deletePackageError, setDeletePackageError] = useState(null); + const [deletePackageCascade, setDeletePackageCascade] = useState(false); const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ @@ -120,11 +121,12 @@ export default function PackagesPage() { }); const deletePackageMutation = useMutation({ - mutationFn: (id: string) => packagesApi.remove(id), + mutationFn: ({ id, cascade }: { id: string; cascade: boolean }) => packagesApi.remove(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setDeletePackageConfirm(null); setDeletePackageError(null); + setDeletePackageCascade(false); }, onError: (e: any) => setDeletePackageError(e?.response?.data?.message || e?.message || 'Failed to delete package'), }); @@ -293,7 +295,7 @@ export default function PackagesPage() { }, { label: 'Delete', icon: Trash2, variant: 'danger' as const, - onClick: (p: any) => { setDeletePackageError(null); setDeletePackageConfirm(p); }, + onClick: (p: any) => { setDeletePackageError(null); setDeletePackageCascade(false); setDeletePackageConfirm(p); }, }, ]; @@ -522,14 +524,17 @@ export default function PackagesPage() { {/* Delete Package Confirmation */} { setDeletePackageConfirm(null); setDeletePackageError(null); }} - onConfirm={() => deletePackageMutation.mutate(deletePackageConfirm.id)} + onClose={() => { setDeletePackageConfirm(null); setDeletePackageError(null); setDeletePackageCascade(false); }} + onConfirm={() => deletePackageMutation.mutate({ id: deletePackageConfirm.id, cascade: deletePackageCascade })} title="Delete Package" message={`Delete "${deletePackageConfirm?.name}"? This will also remove all price tiers and cannot be undone.`} confirmText="Delete" isDanger isLoading={deletePackageMutation.isPending} error={deletePackageError ?? undefined} + cascadeWarning={deletePackageError ? 'This package has active bookings or related records. Check the box below to force delete everything.' : undefined} + cascadeChecked={deletePackageCascade} + onCascadeChange={setDeletePackageCascade} /> {/* Delete Tier Confirmation */} diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index 9765195e2..cff41ede2 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -40,6 +40,7 @@ export default function PassengersPage() { const [selectedPassenger, setSelectedPassenger] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null }); const [deleteError, setDeleteError] = useState(null); + const [deleteCascade, setDeleteCascade] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false); const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); const [exportDateFrom, setExportDateFrom] = useState(''); @@ -51,11 +52,12 @@ export default function PassengersPage() { const queryClient = useQueryClient(); const deleteMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/passengers/${id}`), + mutationFn: ({ id, cascade }: { id: string; cascade: boolean }) => passengersApi.delete(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['passengers'] }); setDeleteConfirm({ isOpen: false, passenger: null }); setDeleteError(null); + setDeleteCascade(false); }, onError: (error: any) => { setDeleteError(error?.response?.data?.message || error?.message || 'Failed to delete passenger'); @@ -154,7 +156,7 @@ export default function PassengersPage() { const actions = [ { label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye }, - { label: 'Delete', onClick: (p: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, passenger: p }); }, variant: 'danger' as const, icon: Trash2 }, + { label: 'Delete', onClick: (p: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteConfirm({ isOpen: true, passenger: p }); }, variant: 'danger' as const, icon: Trash2 }, ]; return ( @@ -229,18 +231,20 @@ export default function PassengersPage() { { setDeleteConfirm({ isOpen: false, passenger: null }); setDeleteError(null); }} + onClose={() => { setDeleteConfirm({ isOpen: false, passenger: null }); setDeleteError(null); setDeleteCascade(false); }} onConfirm={async () => { if (deleteConfirm.passenger) { - await deleteMutation.mutateAsync(deleteConfirm.passenger.id); + await deleteMutation.mutateAsync({ id: deleteConfirm.passenger.id, cascade: deleteCascade }); } }} title="Delete Passenger" message={`Are you sure you want to delete ${deleteConfirm.passenger?.fullName}?`} confirmText="Delete" isDanger isLoading={deleteMutation.isPending} - warning="This passenger may have active bookings, loyalty points, and wallet balance. Deleting will impact these systems and records." error={deleteError ?? undefined} + cascadeWarning={deleteError ? 'This passenger has related records (bookings, loyalty, wallet). Check the box below to force delete everything.' : undefined} + cascadeChecked={deleteCascade} + onCascadeChange={setDeleteCascade} /> {/* Passenger Details Modal */} diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 43592c4f7..2524c20b0 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -171,7 +171,7 @@ export default function RoutesPage() { const [originStationId, setOriginStationId] = useState(''); const [destinationStationId, setDestinationStationId] = useState(''); const [destinationDistance, setDestinationDistance] = useState(undefined); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string }>({ isOpen: false, route: null }); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null }); const [search, setSearch] = useState(''); const queryClient = useQueryClient(); @@ -207,13 +207,18 @@ export default function RoutesPage() { }); const deleteMutation = useMutation({ - mutationFn: routesApi.delete, + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => routesApi.delete(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routes'] }); }, onError: (e: any) => { const msg = e?.response?.data?.message || e?.message || 'Failed to delete route'; - setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + const isFkError = msg?.includes('Cannot delete') || e?.response?.status === 400; + if (isFkError && !deleteConfirm.cascade) { + setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } else { + setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } }, }); @@ -325,7 +330,7 @@ export default function RoutesPage() { const confirmDelete = async () => { if (!deleteConfirm.route) return; try { - await deleteMutation.mutateAsync(deleteConfirm.route.id); + await deleteMutation.mutateAsync({ id: deleteConfirm.route.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); setDeleteConfirm({ isOpen: false, route: null }); } catch { // error is set by onError handler @@ -471,7 +476,10 @@ export default function RoutesPage() { isDanger={true} isLoading={deleteMutation.isPending} error={deleteConfirm.error} - warning="This route may be referenced by schedules and bookings. Deleting it may impact these systems." + warning={!deleteConfirm.cascade ? "This route may be referenced by schedules and bookings. Deleting it may impact these systems." : undefined} + cascadeWarning={deleteConfirm.cascade ? "This route has related schedules that will also be permanently deleted." : undefined} + cascadeChecked={deleteConfirm.cascadeChecked} + onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))} /> (null); const [selectedSchedules, setSelectedSchedules] = useState>(new Set()); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean; error?: string }>( + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean; error?: string; cascade?: boolean; cascadeChecked?: boolean }>( { isOpen: false, item: null } ); const [error, setError] = useState(null); @@ -196,13 +196,18 @@ export default function SchedulesPage() { }); const deleteScheduleMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/schedules/${id}`), + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/schedules/${id}${cascade ? '?cascade=true' : ''}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['schedules'] }); }, onError: (err: any) => { const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedule'; - setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + const isFkError = msg?.includes('Cannot delete') || err?.response?.status === 400; + if (isFkError && !deleteConfirm.cascade) { + setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } else { + setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } }, }); @@ -310,7 +315,7 @@ export default function SchedulesPage() { const ids = deleteConfirm.item as string[]; await bulkDeleteMutation.mutateAsync(ids); } else if (deleteConfirm.item) { - await deleteScheduleMutation.mutateAsync(deleteConfirm.item.id); + await deleteScheduleMutation.mutateAsync({ id: deleteConfirm.item.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); } setDeleteConfirm({ isOpen: false, item: null }); } catch { @@ -658,7 +663,10 @@ export default function SchedulesPage() { isDanger={true} isLoading={deleteScheduleMutation.isPending || bulkDeleteMutation.isPending} error={deleteConfirm.error} - warning="Schedules with existing bookings cannot be deleted." + warning={!deleteConfirm.cascade ? "Schedules with existing bookings cannot be deleted." : undefined} + cascadeWarning={deleteConfirm.cascade ? "This schedule has related bookings or tickets that will also be permanently deleted." : undefined} + cascadeChecked={deleteConfirm.cascadeChecked} + onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))} /> (null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null; error?: string }>({ isOpen: false, station: null }); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; station: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, station: null }); const [formError, setFormError] = useState(null); const queryClient = useQueryClient(); @@ -46,13 +46,18 @@ export default function StationsPage() { }); const deleteMutation = useMutation({ - mutationFn: stationsApi.delete, + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => stationsApi.delete(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['stations'] }); }, onError: (e: any) => { const msg = e?.response?.data?.message || e?.message || 'Failed to delete station'; - setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + const isFkError = msg?.includes('Cannot delete') || e?.response?.status === 400; + if (isFkError && !deleteConfirm.cascade) { + setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } else { + setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } }, }); @@ -92,7 +97,7 @@ export default function StationsPage() { const confirmDelete = async () => { if (!deleteConfirm.station) return; try { - await deleteMutation.mutateAsync(deleteConfirm.station.id); + await deleteMutation.mutateAsync({ id: deleteConfirm.station.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); setDeleteConfirm({ isOpen: false, station: null }); } catch { // error is set by onError handler @@ -244,7 +249,10 @@ export default function StationsPage() { isDanger={true} isLoading={deleteMutation.isPending} error={deleteConfirm.error} - warning="This station may be referenced by routes, schedules, and bookings. Deleting it may impact these systems." + warning={!deleteConfirm.cascade ? "This station may be referenced by routes, schedules, and bookings. Deleting it may impact these systems." : undefined} + cascadeWarning={deleteConfirm.cascade ? "This station has related records (route stops, schedules, or stop times) that will also be permanently deleted." : undefined} + cascadeChecked={deleteConfirm.cascadeChecked} + onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))} /> {/* Add/Edit Modal */} diff --git a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx index b54615f3e..9dd17dfb8 100644 --- a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx @@ -16,7 +16,7 @@ export default function TrainsPage() { const [showModal, setShowModal] = useState(false); const [editingTrain, setEditingTrain] = useState(null); const [search, setSearch] = useState(''); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null; error?: string }>({ isOpen: false, train: null }); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, train: null }); const queryClient = useQueryClient(); @@ -50,13 +50,18 @@ export default function TrainsPage() { }); const deleteTrainMutation = useMutation({ - mutationFn: (id: string) => fleetApi.deleteTrain(id), + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => fleetApi.deleteTrain(id, cascade), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['trains'] }); }, onError: (error: any) => { const msg = error?.response?.data?.message || error?.message || 'Failed to delete train'; - setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + const isFkError = msg?.includes('Cannot delete') || error?.response?.status === 400; + if (isFkError && !deleteConfirm.cascade) { + setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } else { + setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + } }, }); @@ -77,7 +82,7 @@ export default function TrainsPage() { const confirmDelete = async () => { if (!deleteConfirm.train) return; try { - await deleteTrainMutation.mutateAsync(deleteConfirm.train.id); + await deleteTrainMutation.mutateAsync({ id: deleteConfirm.train.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); setDeleteConfirm({ isOpen: false, train: null }); } catch { // error is set by onError handler @@ -237,7 +242,10 @@ export default function TrainsPage() { isDanger={true} isLoading={deleteTrainMutation.isPending} error={deleteConfirm.error} - warning="This train may be assigned to schedules and trips. Deleting it may impact these systems and associated bookings." + warning={!deleteConfirm.cascade ? "This train may be assigned to schedules and trips. Deleting it may impact these systems and associated bookings." : undefined} + cascadeWarning={deleteConfirm.cascade ? "This train has related schedules that will also be permanently deleted." : undefined} + cascadeChecked={deleteConfirm.cascadeChecked} + onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))} /> {/* Add/Edit Modal */} diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx index 39c68b397..c25863d60 100644 --- a/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/ui/ConfirmDialog.tsx @@ -17,6 +17,9 @@ interface ConfirmDialogProps { isDanger?: boolean; warning?: string; error?: string; + cascadeWarning?: string; + onCascadeChange?: (checked: boolean) => void; + cascadeChecked?: boolean; } export default function ConfirmDialog({ @@ -31,6 +34,9 @@ export default function ConfirmDialog({ isDanger = false, warning, error, + cascadeWarning, + onCascadeChange, + cascadeChecked = false, }: ConfirmDialogProps) { useEffect(() => { if (!isOpen) return; @@ -129,6 +135,26 @@ export default function ConfirmDialog({
); })()} + + {cascadeWarning && ( +
+
+ +

{cascadeWarning}

+
+ +
+ )}
{/* Footer */} @@ -140,6 +166,7 @@ export default function ConfirmDialog({ variant={isDanger ? 'danger' : 'primary'} onClick={onConfirm} loading={isLoading} + disabled={!!cascadeWarning && !cascadeChecked} > {confirmText} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index b44e33f74..f611d3083 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -61,6 +61,7 @@ export const passengersApi = { }, getById: (id: string) => apiClient.get(`/passengers/${id}`), verify: (nationalId: string) => apiClient.post('/passengers/verify-fayda', { nationalId }), + delete: (id: string, cascade?: boolean) => apiClient.delete(`/passengers/${id}${cascade ? '?cascade=true' : ''}`), }; // Stations API export const stationsApi = { @@ -79,7 +80,7 @@ export const stationsApi = { getById: (id: string) => apiClient.get(`/stations/${id}`), create: (data: any) => apiClient.post('/stations', data), update: (id: string, data: any) => apiClient.patch(`/stations/${id}`, data), - delete: (id: string) => apiClient.delete(`/stations/${id}`), + delete: (id: string, cascade?: boolean) => apiClient.delete(`/stations/${id}${cascade ? '?cascade=true' : ''}`), }; // Fleet API @@ -108,11 +109,11 @@ export const fleetApi = { }, createTrain: (data: any) => apiClient.post('/fleet/trains', data), updateTrain: (id: string, data: any) => apiClient.patch(`/fleet/trains/${id}`, data), - deleteTrain: (id: string) => apiClient.delete(`/fleet/trains/${id}`), + deleteTrain: (id: string, cascade?: boolean) => apiClient.delete(`/fleet/trains/${id}${cascade ? '?cascade=true' : ''}`), restoreTrain: (id: string) => apiClient.patch(`/fleet/trains/${id}/restore`, {}), createCoach: (data: any) => apiClient.post('/fleet/coaches', data), updateCoach: (id: string, data: any) => apiClient.patch(`/fleet/coaches/${id}`, data), - deleteCoach: (id: string) => apiClient.delete(`/fleet/coaches/${id}`), + deleteCoach: (id: string, cascade?: boolean) => apiClient.delete(`/fleet/coaches/${id}${cascade ? '?cascade=true' : ''}`), generateSeatMap: (data: any) => apiClient.post('/fleet/seatmap/generate', data), }; @@ -362,7 +363,7 @@ export const seatClassesApi = { getById: (id: string) => apiClient.get(`/fleet/classes/${id}`), create: (data: any) => apiClient.post('/fleet/classes', data), update: (id: string, data: any) => apiClient.patch(`/fleet/classes/${id}`, data), - delete: (id: string) => apiClient.delete(`/fleet/classes/${id}`), + delete: (id: string, cascade?: boolean) => apiClient.delete(`/fleet/classes/${id}${cascade ? '?cascade=true' : ''}`), }; // Food & Dining API @@ -409,7 +410,7 @@ export const packagesApi = { update: (id: string, data: any) => apiClient.patch(`/packages/${id}`, data), activate: (id: string) => apiClient.patch(`/packages/${id}/activate`, {}), deactivate: (id: string) => apiClient.patch(`/packages/${id}/deactivate`, {}), - remove: (id: string) => apiClient.delete(`/packages/${id}`), + remove: (id: string, cascade?: boolean) => apiClient.delete(`/packages/${id}${cascade ? '?cascade=true' : ''}`), getBookings: async (params?: any) => { const cleanParams = Object.fromEntries( Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null) diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/routes.ts b/apps/edr-passenger-web/backoffice/src/lib/api/routes.ts index 80c30e08b..638063b84 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/routes.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/routes.ts @@ -30,8 +30,8 @@ export const routesApi = { return apiClient.patch(`/routes/${id}`, data); }, - delete: (id: string) => { - return apiClient.delete(`/routes/${id}`); + delete: (id: string, cascade?: boolean) => { + return apiClient.delete(`/routes/${id}${cascade ? '?cascade=true' : ''}`); }, getFareRules: (routeId: string) => { diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 6ed06039c..af5748ed7 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -10,7 +10,7 @@ import { apiClient } from '@/lib/api-client'; import { useEffect, useState, useRef } from 'react'; import { CheckCircle, Copy, Train, FileText } from 'lucide-react'; import { format } from 'date-fns'; -import { isChild, calculatePassengerFare } from '@/utils/fare-utils'; +import { isChild, isFirstChild, calculatePassengerFare } from '@/utils/fare-utils'; type BookingWithTicket = { id: string; @@ -27,7 +27,7 @@ type BookingWithTicket = { export default function ConfirmationPage() { const router = useRouter(); - const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName, packageTierPriceMinor } = useBookingStore(); + const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName, packageTierPriceMinor, packageId } = useBookingStore(); // The currency/amount actually confirmed for the payment option the user selected — // null when no payment step ran (e.g. a fully-discounted, zero-amount booking). const { selectedCurrency: paidCurrency, paidAmountMinor } = usePaymentStore(); @@ -338,9 +338,11 @@ export default function ConfirmationPage() { const isPackage = !!packageTierPriceMinor; const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1; const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; - const pkgChildFare = isPackage ? Math.round(pkgAdultFare * 0.1) : 0; + const adultCount = passengers.filter(p => !isChild(p)).length; + const childCount = passengers.filter(p => isChild(p)).length; + const pkgPaidChildrenCount = Math.max(0, childCount - adultCount); const fallback = isPackage - ? passengers.reduce((sum, p) => sum + (isChild(p) ? pkgChildFare : pkgAdultFare), 0) + ? adultCount * pkgAdultFare + pkgPaidChildrenCount * pkgAdultFare : isRoundTrip ? passengers.reduce((sum, p, i) => { const outFare = (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0); @@ -393,20 +395,27 @@ export default function ConfirmationPage() {

Seat(s)

- {isRoundTrip ? ( -
+ {(() => { + const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; + const isFreeChild = packageId + ? index >= adultCount && (index - adultCount) < adultCount + : isChild(passenger) && isFirstChild(passengers, index); + if (isFreeChild) return

; + return isRoundTrip ? ( +
+

+ Outbound: {(passenger as any).outboundCoachNumber && {(passenger as any).outboundCoachNumber}} — {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'} +

+

+ Return: {(passenger as any).inboundCoachNumber && {(passenger as any).inboundCoachNumber}} — {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'} +

+
+ ) : (

- Outbound: {(passenger as any).outboundCoachNumber && {(passenger as any).outboundCoachNumber}} — {(passenger as any).outboundSeatNumber || 'Auto-assigned at boarding'} + {passenger.coachNumber && (Coach {passenger.coachNumber})} — {passenger.seatNumber || 'Auto-assigned at boarding'}

-

- Return: {(passenger as any).inboundCoachNumber && {(passenger as any).inboundCoachNumber}} — {(passenger as any).inboundSeatNumber || 'Auto-assigned at boarding'} -

-
- ) : ( -

- {passenger.coachNumber && (Coach {passenger.coachNumber})} — {passenger.seatNumber || 'Auto-assigned at boarding'} -

- )} + ); + })()}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 8e36d79b6..52ce64a2c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -37,9 +37,6 @@ export default function PaymentPage() { const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; const isPackage = !!packageTierPriceMinor; - const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1; - const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; - const pkgChildFare = isPackage ? Math.round(pkgAdultFare * 0.1) : 0; const displayCurrency = 'ETB' as const; @@ -65,12 +62,16 @@ export default function PaymentPage() { }); // Per-leg totals across all passengers. - // Package: one leg = pkgAdultFare/pkgChildFare (already ×1 per leg; pkgAdultFare already has ×2 for round-trip baked in via pkgRoundTripMultiplier — so per-leg is packageTierPriceMinor). + // First child per adult = FREE (no seat); additional children = full adult fare. const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; const childCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length; + const pkgPaidChildrenCount = Math.max(0, childCount - adultCount); + const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1; + const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; + const pkgChildFare = pkgAdultFare; // paid children pay full adult fare const pkgPerLegAdultFare = isPackage ? packageTierPriceMinor! : 0; - const pkgPerLegChildFare = isPackage ? Math.round(pkgPerLegAdultFare * 0.1) : 0; - const pkgPerLegTotal = isPackage ? adultCount * pkgPerLegAdultFare + childCount * pkgPerLegChildFare : 0; + const pkgPerLegChildFare = pkgPerLegAdultFare; // paid children pay full adult fare per leg + const pkgPerLegTotal = isPackage ? adultCount * pkgPerLegAdultFare + pkgPaidChildrenCount * pkgPerLegChildFare : 0; // Prefer each passenger's own seat fare (set during seat selection) over the schedule's // flat baseFareAdult — bed coaches price Upper/Middle/Lower berths differently, so a @@ -90,7 +91,7 @@ export default function PaymentPage() { }, 0) : 0); const baseFare = isPackage - ? adultCount * pkgAdultFare + childCount * pkgChildFare + ? adultCount * pkgAdultFare + pkgPaidChildrenCount * pkgChildFare : isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, p, i) => { const scheduleFare = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0; const farePerPassenger = (p as any).seatFareMinor ?? scheduleFare; @@ -294,11 +295,15 @@ export default function PaymentPage() {

Fare breakdown

{passengers.map((p, i) => { const isChildPassenger = isChild(p); + // For package bookings: children ordered after adults; first adultCount children are free + const childIndex = i - adultCount; + const isPkgFreeChild = isPackage && isChildPassenger && childIndex >= 0 && childIndex < adultCount; let passengerTotal: number; let isFreeChild = false; if (isPackage) { - passengerTotal = isChildPassenger ? pkgChildFare : pkgAdultFare; + isFreeChild = isPkgFreeChild; + passengerTotal = isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare); } else { // Prefer this passenger's actual seat fare (varies by berth for bed coaches) // over the schedule's flat baseFareAdult. @@ -319,9 +324,9 @@ export default function PaymentPage() { {p.name || `Passenger ${i + 1}`} {isChildPassenger && ( - ({isPackage ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'}) + ({isFreeChild ? 'CHILD - FREE' : 'CHILD - FULL FARE'}) )} @@ -332,19 +337,19 @@ export default function PaymentPage() { {isRoundTrip && (
- Outbound {!isPackage && isFreeChild ? '(Free)' : ''} + Outbound {isFreeChild ? '(Free)' : ''} {formatFare( isPackage - ? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare) + ? (isFreeChild ? 0 : (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)) : calculatePassengerFare(passengers, i, (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0)), displayCurrency )}
- Return {!isPackage && isFreeChild ? '(Free)' : ''} + Return {isFreeChild ? '(Free)' : ''} {formatFare( isPackage - ? (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare) + ? (isFreeChild ? 0 : (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)) : calculatePassengerFare(passengers, i, (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0)), displayCurrency )} diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 6e057a3e8..64afeb82b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -266,6 +266,16 @@ export default function ReviewPage() { } // Build booking request for authenticated users + // For package bookings, free children (first child per adult, no seat assigned) + // are excluded from the passengers array — the backend derives them from adultCount/childCount. + const bookingPassengers = passengers.filter((p, i) => { + if (packageId) { + const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount; + return !isFreePkgChild; + } + return !(isChild(p) && isFirstChild(passengers, i)); + }); + bookingData = { passengerId: passengerId, scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, @@ -275,7 +285,7 @@ export default function ReviewPage() { seatClassId: seatClassId, bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', displayCurrency: displayCurrency, - passengers: passengers.map((p) => { + passengers: bookingPassengers.map((p) => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId; return { @@ -311,6 +321,16 @@ export default function ReviewPage() { if (priceTierId) bookingData.priceTierId = priceTierId; } else { // For guests: send full passenger details array + // For package bookings, free children (first child per adult, no seat assigned) + // are excluded from the passengers array — the backend derives them from adultCount/childCount. + const guestBookingPassengers = passengers.filter((p, i) => { + if (packageId) { + const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount; + return !isFreePkgChild; + } + return !(isChild(p) && isFirstChild(passengers, i)); + }); + bookingData = { scheduleId: isRoundTrip ? outboundSchedule?.id : selectedSchedule?.id, holdId: seatHold?.holdId || '', @@ -319,7 +339,7 @@ export default function ReviewPage() { seatClassId: seatClassId, bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', displayCurrency: displayCurrency, - passengers: passengers.map(p => { + passengers: guestBookingPassengers.map(p => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId; return { @@ -449,12 +469,15 @@ export default function ReviewPage() { const isPackageBooking = packageTierPriceMinor !== null; // packageTierPriceMinor is the per-adult fare for ONE leg. - // Round-trip packages multiply by 2; children pay 10% of the adult fare. - const pkgRoundTripMultiplier = isPackageBooking && isRoundTrip ? 2 : 1; - const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; - const pkgChildFare = isPackageBooking ? Math.round(pkgAdultFare * 0.1) : 0; + // Round-trip packages multiply by 2. + // First child per adult travels FREE; additional children pay full adult fare. const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; const childPassengerCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length; + const pkgRoundTripMultiplier = isPackageBooking && isRoundTrip ? 2 : 1; + const pkgAdultFare = isPackageBooking ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; + const pkgPaidChildrenCount = Math.max(0, childPassengerCount - adultPassengerCount); + // Paid children pay full adult fare + const pkgChildFare = pkgAdultFare; // full fare for paid children // For package bookings, passengers are initialized without dateOfBirth so isChild() is // unreliable. Use the stored adultCount from searchCriteria to determine category by index. @@ -474,7 +497,7 @@ export default function ReviewPage() { }; const total = isPackageBooking - ? adultPassengerCount * pkgAdultFare + childPassengerCount * pkgChildFare + ? adultPassengerCount * pkgAdultFare + pkgPaidChildrenCount * pkgChildFare : passengers.reduce((sum, p, i) => { const isChildPassenger = isChild(p); const line = fareBreakdown?.passengers?.[i]; @@ -484,6 +507,16 @@ export default function ReviewPage() { return sum + (seatFare ?? line?.fareMinor ?? 0); }, 0); + // For package bookings, determine if a child is free (first per adult) or paid. + // Children are ordered after adults in the passengers array (set on package detail page). + const isPkgFreeChild = (index: number) => { + if (!isPackageBooking) return false; + if (!isPackageChild(index)) return false; + // childIndex = position among children (0-based) + const childIndex = index - adultPassengerCount; + return childIndex < adultPassengerCount; // first adultCount children are free + }; + // Shared fare sidebar — rendered in right column (desktop) and inline (mobile) const FareSidebar = () => (
@@ -493,10 +526,12 @@ export default function ReviewPage() { {passengers.map((p, i) => { const line = fareBreakdown?.passengers?.[i]; const isChildPassenger = isPackageChild(i); - const isFreeChild = !isPackageBooking && (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i))); + const isFreeChild = isPackageBooking + ? isPkgFreeChild(i) + : (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i))); const seatFare = getPassengerSeatFare(p); const passengerTotal = isPackageBooking - ? (isChildPassenger ? pkgChildFare : pkgAdultFare) + ? (isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare)) : (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0)); return ( @@ -506,9 +541,9 @@ export default function ReviewPage() { {p.name || `Passenger ${i + 1}`} {isChildPassenger && ( - ({isPackageBooking ? 'CHILD - 10%' : isFreeChild ? 'CHILD - FREE' : 'CHILD'}) + ({isFreeChild ? 'CHILD - FREE' : 'CHILD - FULL FARE'}) )} @@ -817,7 +852,7 @@ export default function ReviewPage() {

Outbound Seat

{(p as any).outboundCoachNumber && {(p as any).outboundCoachNumber} — } - {(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : 'Auto-assign'} + {(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}

{(p as any).outboundSeatId && (

{formatSeatClass(outboundSchedule)}

@@ -827,7 +862,7 @@ export default function ReviewPage() {

Return Seat

{(p as any).inboundCoachNumber && {(p as any).inboundCoachNumber} —} - {(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : 'Auto-assign'} + {(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}

{(p as any).inboundSeatId && (

{formatSeatClass(inboundSchedule)}

@@ -839,7 +874,7 @@ export default function ReviewPage() {

Seat

{p.coachNumber && {p.coachNumber} — } - {p.seatId ? (seatDetails[p.seatId] || 'Loading...') : 'Auto-assign'} + {p.seatId ? (seatDetails[p.seatId] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}

{p.seatId && (

{formatSeatClass(selectedSchedule)}

diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index b1987f588..b0a7e8fd8 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -233,22 +233,26 @@ export default function SeatsPage() { ? (currentJourneyType === "inbound" ? originalFaresRef.current.inbound : originalFaresRef.current.outbound) : originalFaresRef.current.oneWay; - // Child seat allocation rule: let A = adults, C = children (isChild = under 5). - // If C > A, only A - 1 children get their own seat and the rest share with an adult. - // If C <= A, no child gets a separate seat — all of them share with an adult. - // Adults always need their own seat. + // Seat eligibility: + // - All adults always need their own seat. + // - For package bookings: first child per adult travels free with no seat; + // additional children (beyond one per adult) pay full fare and need a seat. + // Free children are pre-marked with a child DOB on the package detail page. + // - For regular bookings: same "first child per adult free" rule applies. + // In both cases: children are identified by isChild() (DOB < 5 years). + // Free children = first `adultCount` children (by position); paid children = the rest. const seatEligibility = useMemo(() => { const adultIndices = passengers.map((_, i) => i).filter((i) => !isChild(passengers[i])); const childIndices = passengers.map((_, i) => i).filter((i) => isChild(passengers[i])); const adultCount = adultIndices.length; - const childCount = childIndices.length; - const eligibleChildCount = childCount > adultCount ? Math.max(adultCount - 1, 0) : 0; - const eligibleChildIndices = childIndices.slice(0, eligibleChildCount); - const eligibleSet = new Set([...adultIndices, ...eligibleChildIndices]); + // First `adultCount` children are free (no seat); the rest are paid (need a seat). + const freeChildIndices = new Set(childIndices.slice(0, adultCount)); + const paidChildIndices = childIndices.slice(adultCount); + const eligibleSet = new Set([...adultIndices, ...paidChildIndices]); - // Children who don't get their own seat share with an adult (round-robin, for display). + // Free children share with an adult (round-robin, for display). const sharingWithAdult = new Map(); - childIndices.slice(eligibleChildCount).forEach((childIdx, offset) => { + Array.from(freeChildIndices).forEach((childIdx, offset) => { const adultIdx = adultIndices[offset % Math.max(adultIndices.length, 1)]; if (adultIdx != null) { sharingWithAdult.set(childIdx, passengers[adultIdx]?.name || `Adult ${adultIdx + 1}`); diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index 457425578..3e4dc04df 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -361,8 +361,7 @@ function PriceTiersPanel({ // ─── Passenger count picker ────────────────────────────────────────────────── const PKG_MAX_ADULTS = 5; -const PKG_CHILDREN_PER_ADULT = 2; -const PKG_CHILD_FARE_RATIO = 0.1; +const PKG_CHILDREN_PER_ADULT = 5; function PassengerCountModal({ tier, @@ -386,8 +385,11 @@ function PassengerCountModal({ const [departureStationId, setDepartureStationId] = useState(''); const [showStationError, setShowStationError] = useState(false); const remaining = tier.availableSeats - tier.bookedSeats; - const childFareMinor = Math.round(tier.priceMinor * PKG_CHILD_FARE_RATIO); - const totalMinor = (adultCount * tier.priceMinor + childCount * childFareMinor) * priceMultiplier; + // First child per adult travels free (no seat); additional children pay full adult fare + const freeChildren = Math.min(childCount, adultCount); + const paidChildren = Math.max(0, childCount - adultCount); + // Only paid children need seats; free children share with an adult + const totalMinor = (adultCount * tier.priceMinor + paidChildren * tier.priceMinor) * priceMultiplier; return ( <> @@ -404,13 +406,13 @@ function PassengerCountModal({

Coach type

{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}

-

{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · actual class chosen on seat map

+

{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)

{[ - { label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: setAdultCount }, - { label: "Children", sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 10% of adult fare`, value: childCount, min: 0, max: Math.min(adultCount * PKG_CHILDREN_PER_ADULT, remaining - adultCount), set: setChildCount }, + { label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: (v: number) => { setAdultCount(v); const newMax = Math.min(v * PKG_CHILDREN_PER_ADULT, v + Math.max(0, remaining - v)); setChildCount(c => Math.min(c, newMax)); } }, + { label: "Children", sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 1st per adult FREE (no seat)`, value: childCount, min: 0, max: Math.min(adultCount * PKG_CHILDREN_PER_ADULT, adultCount + Math.max(0, remaining - adultCount)), set: setChildCount }, ].map(({ label, sub, value, min, max, set }) => (
@@ -433,6 +435,12 @@ function PassengerCountModal({
))} + {freeChildren > 0 && ( +
+ {freeChildren} child{freeChildren > 1 ? 'ren' : ''} travel free (no seat) + ETB 0.00 +
+ )}
Total{priceMultiplier === 2 ? ' (round-trip)' : ''} {formatPrice(totalMinor, tier.currency)} @@ -572,12 +580,21 @@ export default function PackageDetailPage() { } setPassengers( - Array.from({ length: passengerCount }, (_, i) => ({ - name: "", - dateOfBirth: "", - nationality: "ETHIOPIAN", - isPrimaryPassenger: i === 0, - })), + Array.from({ length: passengerCount }, (_, i) => { + // First `adultCount` entries are adults; remaining are children. + // Among children, the first `adultCount` are free (one per adult, no seat). + const isChildPassenger = i >= adultCount; + const childIndex = i - adultCount; // 0-based index among children + const isFreeChild = isChildPassenger && childIndex < adultCount; + return { + name: "", + // Free children travel without a seat — give them a synthetic DOB that + // makes isChild() return true so seat eligibility logic excludes them. + dateOfBirth: isFreeChild ? new Date(Date.now() - 2 * 365.25 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) : "", + nationality: "ETHIOPIAN", + isPrimaryPassenger: i === 0, + }; + }), ); // Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing From 838895897d9652cc86a877a688c7101da45c0d01 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 6 Jul 2026 19:12:14 +0000 Subject: [PATCH 16/23] Marshaling doc fix --- .../billing/documents/styled-pdf.util.ts | 155 +++++++++++++++++- .../train-scheduling.service.ts | 10 +- .../warehouse-release-document.service.ts | 14 ++ 3 files changed, 171 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts index 8eb90172e..b4f168e4e 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts @@ -121,6 +121,146 @@ export function sealOp( 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(//gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'/g, "'") + .replace(/ /gi, " ") + .replace(/[^\x20-\x7e]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Parse a "summary tiles + one + 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(/]*>([\s\S]*?)<\/h1>/i) ?? "Document"); + const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? ""); + const metaRef = htmlToText(pick(/class="meta"[\s\S]*?([\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*([\s\S]*?)<\/span>\s*([\s\S]*?)<\/strong>/gi, + )) { + tiles.push([htmlToText(m[1]), htmlToText(m[2])]); + } + + const thead = pick(/([\s\S]*?)<\/thead>/i) ?? ""; + const headers = [...thead.matchAll(/]*>([\s\S]*?)<\/th>/gi)].map((m) => htmlToText(m[1])); + const tbody = pick(/([\s\S]*?)<\/tbody>/i) ?? ""; + const rows: string[][] = [...tbody.matchAll(/]*>([\s\S]*?)<\/tr>/gi)].map((tr) => + [...tr[1].matchAll(/]*>([\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[] = []; @@ -141,13 +281,22 @@ export function wrapText(text: string, maxChars: number): string[] { return out.length ? out : [""]; } -/** Assemble a single-page A4 PDF from content-stream ops (Helvetica fonts). */ -export function assembleSinglePagePdf(ops: string[]): Buffer { +/** 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 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>", + `<< /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`, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 22dec2a37..1755a18f8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1625,9 +1625,9 @@ export class TrainSchedulingService { performedBy: 'DOCUMENT_GENERATION', }); const html = this.buildImportLoadListHtml(loadList); - // Generic render — NOT the release-order fallback (would mislabel this as a - // gate-clearance / release order when Chromium is unavailable). - const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list'); + // Styled table-aware fallback (marshalling grid) when Chromium is unavailable — + // NOT the release-order fallback (would mislabel this as a gate-clearance order). + const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list'); const reference = loadList.trainNumber ?? loadList.trainScheduleId; return { filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`, @@ -1645,8 +1645,8 @@ export class TrainSchedulingService { } const html = this.buildExportLoadListHtml(schedule); - // Generic render — NOT the release-order fallback (see importLoadListDocument). - const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list'); + // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. + const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list'); const reference = schedule.trainNumber ?? schedule.id; return { filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index 1d8e3d9ea..1d02633d5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { PdfRenderService } from '../billing/documents/pdf-render.service'; +import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; const MIN_VALID_PDF_BYTES = 2_000; @@ -32,6 +33,19 @@ export class WarehouseReleaseDocumentService { return this.pdf.htmlToPdfBuffer(html, { label }); } + /** + * 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 { + 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. From 05b13e84a80eb03812f225cc317b69bb13eac565 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 6 Jul 2026 21:03:08 +0000 Subject: [PATCH 17/23] enhance booking and contract notification systems - Added detailed logging for socket connection events in useBookingWindowSocket. - Introduced new notification types for contract status and schedule updates. - Updated notification visuals to include new icons for contract status. - Enhanced notification href resolution for contract status and schedule updates. - Implemented booking lifecycle notifier service for customer and staff notifications. - Created contract notifier service for managing contract lifecycle notifications. - Added end-to-end tests for booking window socket functionality. --- apps/edr-freight-api/package.json | 1 + .../modules/backoffice/backoffice.service.ts | 21 +- .../booking-lifecycle-notifier.service.ts | 307 ++++++++++++++++++ .../booking-transition.accept.spec.ts | 22 +- .../booking-transition.clearance.spec.ts | 70 +++- .../booking-transition.operation.spec.ts | 54 ++- .../bookings/booking-transition.service.ts | 94 +++++- .../src/modules/bookings/bookings.module.ts | 7 + .../src/modules/bookings/bookings.service.ts | 11 + .../booking-clearance.service.spec.ts | 25 +- .../contracts/booking-clearance.service.ts | 4 + .../contracts/booking-request.service.ts | 6 +- .../clearance-workflow.service.spec.ts | 1 + .../contracts/clearance-workflow.service.ts | 10 + .../contracts/contract-clearance.service.ts | 13 +- .../contracts/contract-notifier.service.ts | 245 ++++++++++++++ .../contracts/contract-transition.service.ts | 36 +- .../src/modules/contracts/contracts.module.ts | 6 + .../contracts/gl-operations.service.ts | 17 +- .../notification-recipients.service.ts | 12 + .../scheduling-reschedule.service.spec.ts | 5 + .../scheduling-reschedule.service.ts | 57 ++++ .../train-scheduling/booking-batch.service.ts | 36 ++ .../booking-notifier.service.ts | 77 ++++- .../booking-window.gateway.spec.ts | 109 +++++++ .../booking-window.gateway.ts | 3 + .../booking-window.service.ts | 61 +++- .../train-scheduling.module.ts | 9 +- .../train-scheduling.service.ts | 88 ++++- .../bookingWindows/useBookingWindowSocket.ts | 12 + .../notifications/notificationConfig.tsx | 26 +- .../bookingWindows/useBookingWindowSocket.ts | 12 + .../notifications/notificationConfig.tsx | 16 + .../components/StatusHero.tsx | 19 +- .../bookings/BookingDetailPage/constants.ts | 39 ++- packages/types/src/freight/index.ts | 7 + packages/types/src/freight/notifications.ts | 4 + pnpm-lock.yaml | 3 + 38 files changed, 1450 insertions(+), 95 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 457786cbe..636bb7e05 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -93,6 +93,7 @@ "@types/supertest": "^6.0.2", "@types/vorpal": "^1.12.8", "jest": "^29.7.0", + "socket.io-client": "^4.8.3", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index 7c7805b28..d49cc304e 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -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 { 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 { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; @@ -40,6 +44,21 @@ export class BackofficeService { 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 { + 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( organizationId: string, dto: CreateOrganizationUserDto, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts new file mode 100644 index 000000000..a4546e301 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -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 { + 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 = {}, + ): 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 = {}, + ): 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`, + }, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index 3c535f450..607a0d7a4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -30,14 +30,32 @@ describe('BookingTransitionService — acceptIntake validity window', () => { ruleEngineService as never, {} as never, // pricingService {} as never, // contractService - {} as never, // invoiceService {} as never, // filesService {} as never, // fileUploadSettingsService {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + {} as never, // invoiceService { 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 }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index 9f9aa5713..72c83e136 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -41,14 +41,32 @@ describe('BookingTransitionService — finalizeClearance gate', () => { {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService - {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + {} as never, // invoiceService { 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 }; } @@ -126,14 +144,32 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( {} as never, {} as never, {} as never, - {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, - {} as never, + {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + {} as never, // invoiceService { 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 }; } @@ -197,14 +233,32 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields {} as never, {} as never, {} as never, - {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, - {} as never, + {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + {} as never, // invoiceService { 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 }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index ea3618a08..9201e4fa9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -3,14 +3,17 @@ import { BookingTransitionService } from './booking-transition.service'; /** * Operation-request review for general-contract drawdown orders: - * - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool. - * - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued. + * - ACCEPT a train order → FULLY_EXECUTED with the invoice ensured; import/ + * 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. */ describe('BookingTransitionService — operation review', () => { function makeService(serviceTypeCode: string) { const booking = { id: 'b-1', + reference: 'BKG-1', status: 'OPERATION_REQUEST_PENDING', originYardId: 'o-1', destinationYardId: 'd-1', @@ -26,6 +29,14 @@ describe('BookingTransitionService — operation review', () => { }; const bookingBatchService = { 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( @@ -33,37 +44,60 @@ describe('BookingTransitionService — operation review', () => { {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService - {} as never, // invoiceService {} as never, // filesService {} as never, // fileUploadSettingsService bookingBatchService as never, bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + invoiceService 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 () => { - const { service, bookingsRepository, bookingBatchService } = + it('ACCEPT of a train order → FULLY_EXECUTED, invoice ensured, batch waits for window cycle', async () => { + const { service, bookingsRepository, bookingBatchService, invoiceService } = makeService('RAIL_CONTAINER'); await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1'); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-1', 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 () => { - const { service, bookingsRepository, bookingBatchService } = + it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enter the batch', async () => { + const { service, bookingsRepository, bookingBatchService, invoiceService } = makeService('ROAD_CONTAINER'); await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1'); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-1', expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }), ); + expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled(); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 06edbf04e..b5c277073 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -4,6 +4,7 @@ import { Inject, Injectable, Logger, + Optional, } from "@nestjs/common"; 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 { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { BookingContractService } from './booking-contract.service'; +import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import { BookingPricingService } from './booking-pricing.service'; import { ContainerValidationService } from './container-validation.service'; import { BookingsRepository } from './bookings.repository'; @@ -26,6 +28,7 @@ import { PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { BookingsService } from './bookings.service'; import { BookingClearanceService } from '../contracts/booking-clearance.service'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service'; import { ContractDocPhase } from '@edr/types'; @@ -53,7 +56,8 @@ export class BookingTransitionService { private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly containerValidationService: ContainerValidationService, - + private readonly notifier: BookingLifecycleNotifierService, + @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} private isPhasedGeneralCustoms(booking: Booking): boolean { @@ -124,6 +128,9 @@ export class BookingTransitionService { const finalBooking = await this.bookingsService.runConsolidationOnSubmit( updated!.id, ); + if (finalBooking.status === "SUBMITTED") { + this.notifier.submittedToStaff(finalBooking); + } return { bookingId: finalBooking.id, status: finalBooking.status, @@ -204,6 +211,9 @@ export class BookingTransitionService { const finalBooking = await this.bookingsService.runConsolidationOnSubmit( updated!.id, ); + if (finalBooking.status === "SUBMITTED") { + this.notifier.submittedToStaff(finalBooking); + } return { bookingId: finalBooking.id, status: finalBooking.status, @@ -233,7 +243,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "CHANGES_REQUESTED", } 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. */ @@ -284,7 +296,9 @@ export class BookingTransitionService { contractValidFrom: validFrom, contractValidUntil: validUntil, } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.accepted(fresh); + return fresh; } async staffReject( @@ -305,7 +319,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "REJECTED", } 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( @@ -394,7 +410,9 @@ export class BookingTransitionService { if (allDone) { 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); @@ -435,7 +453,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "REJECTED", } 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 { @@ -446,7 +466,9 @@ export class BookingTransitionService { status: "SIGNED_CUSTOMER", customerSignedAt: new Date(), } 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 { @@ -456,7 +478,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "IN_TRANSIT", } 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 { @@ -467,7 +491,28 @@ export class BookingTransitionService { status: "COMPLETED", endDate: new Date(), } 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 { @@ -491,7 +536,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "CANCELLED", } 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); } - 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); + if (status === "QUERIED") { + this.notifier.documentQueried(updated, fileKey, note ?? ''); + } if (this.isPhasedGeneralCustoms(updated)) { const allApproved = await this.isClearanceFullyApproved(updated); if (allApproved) { @@ -912,7 +964,9 @@ export class BookingTransitionService { await this.bookingsRepository.update(bookingId, { status: "CLEARANCE_READY", } 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", scheduledDate: date, } 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, { status: "OPERATION_CHANGES_REQUESTED", } 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. @@ -1037,7 +1095,9 @@ export class BookingTransitionService { fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } 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, { @@ -1072,7 +1132,9 @@ export class BookingTransitionService { // batch runs after the window closes + staff document review, never at accept // time. (Legacy pre-migration schedules with no window phase are still served // 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< diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 2cb10ce8e..61dc78e13 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -18,7 +18,10 @@ import { BookingInvoiceService } from './booking-invoice.service'; // import { BookingPaymentService } from './booking-payment.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; +import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.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 { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; @@ -64,6 +67,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; CustomerTruckContainer, ]), BillingModule, + NotificationsModule, + NotificationInboxModule, forwardRef(() => FirstMileModule), forwardRef(() => TrainSchedulingModule), forwardRef(() => ContractsModule), @@ -90,6 +95,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ContainerValidationService, BookingReferenceDataService, BookingPricingService, + BookingLifecycleNotifierService, BookingTransitionService, BookingContractService, BookingInvoiceService, @@ -107,6 +113,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingsRepository, BookingPricingService, BookingInvoiceService, + BookingLifecycleNotifierService, CustomerTruckService, ContainerReceiptService, ], diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index d4791baf7..1fb37dc31 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1388,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; } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 2b0126003..7b5cb77d6 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -85,6 +85,13 @@ function makeService(overrides?: { milestoneService as never, dropdownSettingsService as never, glOperationsService as never, + { + dutyAdvised: jest.fn(), + clearanceReady: jest.fn(), + documentQueried: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + } as never, // notifier ); return { @@ -115,12 +122,18 @@ describe('BookingClearanceService', () => { it('records duty advice when duty applies', async () => { const { service, milestoneService } = makeService(); - await service.adviseDuty('b-general', { - dutyRequired: true, - amount: 1500, - currency: 'ETB', - declarationSerial: 'DS-1', - }); + await service.adviseDuty( + 'b-general', + { + dutyRequired: true, + 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( 'b-general', diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 61ac93925..eeea43a39 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -12,6 +12,7 @@ import { FileUploadSettingsService } from '../file-upload-settings/file-upload-s import { FilesService } from '../files/files.service'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsService } from '../bookings/bookings.service'; +import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { Booking } from '../bookings/entities/booking.entity'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; @@ -100,6 +101,7 @@ export class BookingClearanceService { private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, private readonly glOperationsService: GlOperationsService, + private readonly notifier: BookingLifecycleNotifierService, ) {} private async assertPhasedGeneralCustoms(booking: Booking): Promise { @@ -414,6 +416,7 @@ export class BookingClearanceService { }, userId, ); + this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB'); } return this.bookingsService.findById(bookingId); @@ -441,6 +444,7 @@ export class BookingClearanceService { clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, } as never); + this.notifier.dutySlipUploadedToStaff(booking, 'first'); return this.bookingsService.findById(bookingId); } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 88a4ec725..4752b004f 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -10,6 +10,7 @@ import type { Freight } from '@edr/types'; import { BookingRequestRepository } from './booking-request.repository'; import { ContractsService } from './contracts.service'; import { ContractBookingService } from './contract-booking.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { BookingRequest } from './entities/booking-request.entity'; import { Contract } from './entities/contract.entity'; import { CreateBookingRequestDto } from './dto/create-booking-request.dto'; @@ -26,6 +27,7 @@ export class BookingRequestService { private readonly repo: BookingRequestRepository, private readonly contractsService: ContractsService, private readonly contractBookingService: ContractBookingService, + private readonly notifier: ContractNotifierService, ) {} /** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */ @@ -107,7 +109,7 @@ export class BookingRequestService { }; const reference = await this.generateReference(); - return this.repo.create({ + const request = await this.repo.create({ reference, contractId, requestedByUserId: userId ?? null, @@ -117,6 +119,8 @@ export class BookingRequestService { requestedLines, notes: dto.notes ?? null, } as never); + this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference); + return request; } listForContract(contractId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts index f30b64597..75178d640 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts @@ -48,6 +48,7 @@ function makeService(milestones: ClearanceMilestone[]) { contractsRepository as never, milestoneService as never, bookingsRepository as never, + { clearanceReady: jest.fn() } as never, // notifier ); return { service, milestoneService, contractsRepository, bookingsRepository }; } diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts index 758da27ff..9b17a3e76 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts @@ -9,6 +9,7 @@ import { Contract } from './entities/contract.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { Booking } from '../bookings/entities/booking.entity'; import type { ClearanceMetaState } from './clearance-workflow.types'; import { metaFromBooking } from './clearance-workflow.types'; @@ -34,6 +35,7 @@ export class ClearanceWorkflowService { private readonly contractsRepository: ContractsRepository, private readonly milestoneService: ClearanceMilestoneService, private readonly bookingsRepository: BookingsRepository, + private readonly notifier: BookingLifecycleNotifierService, ) {} boundaryMilestone(tradeDirection: string): string { @@ -264,6 +266,14 @@ export class ClearanceWorkflowService { status: 'CLEARANCE_READY', clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, } 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( diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 2c79ba42f..3d41e68a5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -16,6 +16,7 @@ import { BookingsService } from '../bookings/bookings.service'; import { contractClearanceCodes } from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { GlOperationsService } from './gl-operations.service'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { Contract } from './entities/contract.entity'; @@ -118,6 +119,7 @@ export class ContractClearanceService { private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, private readonly glOperationsService: GlOperationsService, + private readonly notifier: ContractNotifierService, ) {} private isPhasedCustoms(contract: Contract): boolean { @@ -543,7 +545,9 @@ export class ContractClearanceService { 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( @@ -674,6 +678,7 @@ export class ContractClearanceService { status: 'AWAITING_CLEARANCE_DOCUMENTS', clearanceStatus: 'AWAITING_DOCUMENTS', } as never); + this.notifier.clearanceDocumentQueried(contract, fileKey, note ?? ''); if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); } @@ -1009,6 +1014,7 @@ export class ContractClearanceService { }, userId, ); + this.notifier.dutyAdvised(contract, dto.amount, dto.currency ?? 'ETB'); } 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( @@ -1118,6 +1126,7 @@ export class ContractClearanceService { await this.workflowService.markReadyForBooking(contractId); } + this.notifier.preClearanceFinalized(contract); return this.contractsService.findById(contractId); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts new file mode 100644 index 000000000..d4f31e570 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -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 { + 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 = {}, + ): 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 = {}, + ): 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 }, + }, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index e31392666..9cf06c905 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -22,6 +22,7 @@ import { FilesService } from '../files/files.service'; import { SignaturesService } from '../signatures/signatures.service'; import { OtpService } from '../otp/otp.service'; import { ContractPricingService } from './contract-pricing.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService } from './contracts.service'; @@ -66,6 +67,7 @@ export class ContractTransitionService { private readonly pdfService: ContractPdfService, private readonly minioService: MinioService, private readonly otpService: OtpService, + private readonly notifier: ContractNotifierService, ) {} /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ @@ -79,7 +81,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'SUBMITTED', } 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). */ @@ -93,7 +97,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'SUBMITTED', } 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, contractValidUntil: validUntil, } 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, { status: 'CHANGES_REQUESTED', } 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 { @@ -235,7 +245,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'REJECTED', } 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. */ @@ -297,7 +309,11 @@ export class ContractTransitionService { if (Object.keys(updates).length > 0) { 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(), } as never); 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); @@ -605,7 +623,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, updates as never); 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. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index a9f9dcf5d..33a547a9f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -12,6 +12,8 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; import { SignaturesModule } from '../signatures/signatures.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 { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; @@ -19,6 +21,7 @@ import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; import { ContractsRepository } from './contracts.repository'; import { ContractPricingService } from './contract-pricing.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; import { BookingClearanceService } from './booking-clearance.service'; @@ -75,6 +78,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum MinioModule, SignaturesModule, OtpModule, + NotificationsModule, + NotificationInboxModule, CompaniesModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // 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, ContractsRepository, ContractPricingService, + ContractNotifierService, ContractTransitionService, ContractClearanceService, ClearanceWorkflowService, diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 72e2d14d9..181d8b688 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -11,6 +11,7 @@ import { BillingService } from '../billing/billing.service'; import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { FilesService } from '../files/files.service'; 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 { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity'; import { @@ -53,6 +54,7 @@ export class GlOperationsService { private readonly filesService: FilesService, private readonly milestoneService: ClearanceMilestoneService, private readonly billingService: BillingService, + private readonly notifier: BookingLifecycleNotifierService, ) {} private get bookings() { @@ -64,7 +66,11 @@ export class GlOperationsService { } private async getBooking(bookingId: string): Promise { - 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`); return booking; } @@ -447,6 +453,7 @@ export class GlOperationsService { void userId; const summary = await this.finalInvoiceSummary(bookingId); if (!summary) throw new NotFoundException('Final invoice could not be created.'); + this.notifier.finalInvoiceCreated(booking, input.amount, input.currency); return summary; } @@ -455,7 +462,7 @@ export class GlOperationsService { bookingId: string, file: Express.Multer.File, ): Promise<{ uploaded: boolean }> { - await this.getBooking(bookingId); + const booking = await this.getBooking(bookingId); if (!file) throw new BadRequestException('No payment slip uploaded'); const invoice = await this.billingService.findInvoice( @@ -482,6 +489,7 @@ export class GlOperationsService { code: 'final_invoice_slip', file, }); + this.notifier.dutySlipUploadedToStaff(booking, 'final'); return { uploaded: true }; } @@ -490,7 +498,7 @@ export class GlOperationsService { bookingId: string, userId?: string, ): Promise { - await this.getBooking(bookingId); + const booking = await this.getBooking(bookingId); const invoice = await this.billingService.findInvoice( Freight.InvoiceSource.Booking, bookingId, @@ -507,6 +515,7 @@ export class GlOperationsService { ); } await this.billingService.markInvoiceAsPaid(invoice.id); + this.notifier.finalInvoicePaid(booking); } void userId; @@ -575,6 +584,7 @@ export class GlOperationsService { }, userId, ); + this.notifier.secondDutyAdvised(booking, input.amount, input.currency ?? 'ETB'); return { advised: true, skipped: false }; } @@ -605,6 +615,7 @@ export class GlOperationsService { booking.tradeDirection ?? 'IMPORT', ); await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID'); + this.notifier.dutySlipUploadedToStaff(booking, 'second'); return { milestoneCompleted: true }; } diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts index 265e19303..be7964a3d 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts @@ -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]; } } diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts index 905e827e8..289e502f1 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts @@ -78,6 +78,11 @@ describe('SchedulingRescheduleService', () => { bookingsRepository as never, trainSchedulingService as never, schedulingRescheduleRepository as never, + { + rescheduled: jest.fn(), + removedFromTrain: jest.fn(), + maintenanceMoved: jest.fn(), + } as never, // notifier ); }); diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index dd20b100d..a9a3ae246 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -10,6 +10,7 @@ import { BookingsRepository } from '../bookings/bookings.repository'; import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; 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 { SchedulingRescheduleRepository } from './scheduling-reschedule.repository'; @@ -38,6 +39,7 @@ export class SchedulingRescheduleService { private readonly bookingsRepository: BookingsRepository, private readonly trainSchedulingService: TrainSchedulingService, private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository, + private readonly notifier: BookingNotifierService, ) {} /** Preview who is retained, displaced, and readmitted on a schedule. */ @@ -193,9 +195,64 @@ export class SchedulingRescheduleService { 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 }; } + /** + * 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 { + 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 { + try { + return await this.bookingsRepository.findByIdWithFiles(bookingId); + } catch { + return null; + } + } + /** Maintenance shortcut: new departure + rebalance. */ async maintenanceReschedule( scheduleId: string, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index fd9b74d28..889af7cc5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -1517,6 +1517,12 @@ export class BookingBatchService implements OnModuleInit { "PREPAID", ); 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). */ @@ -1548,6 +1554,15 @@ export class BookingBatchService implements OnModuleInit { this.notifier.secured(booking, reason); void this.triggerWagonAllocation(scheduleId); 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 { @@ -1559,6 +1574,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 { + 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 * also clear `trainScheduleId` so the booking is no longer pinned to the train diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 49df19758..f1f63802e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -1,13 +1,23 @@ import { Injectable, Logger } from '@nestjs/common'; +import { + NotificationAudience, + NotificationType, + NotifyInput, +} from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { BATCH_TIMEZONE } from './booking-batch.constants'; @Injectable() export class BookingNotifierService { 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 { 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 = {}, + ): 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 { const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); 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.`; 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 ` + `(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)'); + this.inApp(b, 'Partial allocation offer', msg, { + type: NotificationType.INVOICE_ISSUED, + }); } secured(b: Booking, reason: 'paid' | 'gov'): void { @@ -73,11 +109,13 @@ export class BookingNotifierService { reason === 'gov' ? ' (government)' : '' }.`; void this.notifyContact(b, msg, 'ALLOCATED'); + this.inApp(b, 'Wagon allocated', msg); } expired(b: Booking): void { const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`; void this.notifyContact(b, msg, 'EXPIRED'); + this.inApp(b, 'Payment window expired', msg); } scheduleFull(b: Booking): void { @@ -100,5 +138,42 @@ export class BookingNotifierService { displaced(b: Booking): void { 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'); + 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); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts new file mode 100644 index 000000000..a439e442d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts @@ -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((resolve, reject) => { + client.on('connect', () => resolve()); + client.on('connect_error', (err) => reject(err)); + }); + + const received = new Promise>((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((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'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts index d1c1d34a1..699471d90 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts @@ -41,6 +41,9 @@ export class BookingWindowGateway implements OnGatewayConnection { 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. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 12a78047c..429a03a05 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -2,12 +2,17 @@ import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/com import { Cron } from '@nestjs/schedule'; import { InjectDataSource } from '@nestjs/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 { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { BookingBatchService } from './booking-batch.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; @@ -41,6 +46,7 @@ export class BookingWindowService implements OnModuleInit { private readonly bookingBatchService: BookingBatchService, private readonly trainSchedulingService: TrainSchedulingService, private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, private readonly gateway: BookingWindowGateway, ) {} @@ -380,22 +386,26 @@ export class BookingWindowService implements OnModuleInit { */ private async notifyWindowOpened(schedule: TrainSchedule): Promise { try { - const rows: Array<{ phone: string | null; email: string | null }> = - await this.dataSource.query( - `SELECT DISTINCT - COALESCE(co.contact_person_phone, co.phone) AS phone, - COALESCE(co.email, co.general_manager_email) AS email - FROM freight.contract_routes cr - JOIN freight.contracts c - ON c.id = cr.contract_id - AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') - AND c.deleted_at IS NULL - 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], - ); + const rows: Array<{ + company_id: string; + phone: string | null; + email: string | null; + }> = await this.dataSource.query( + `SELECT DISTINCT + c.company_id, + COALESCE(co.contact_person_phone, co.phone) AS phone, + COALESCE(co.email, co.general_manager_email) AS email + FROM freight.contract_routes cr + JOIN freight.contracts c + ON c.id = cr.contract_id + AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') + AND c.deleted_at IS NULL + 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; const closes = schedule.windowClosesAt @@ -410,6 +420,7 @@ export class BookingWindowService implements OnModuleInit { const seenPhone = new Set(); const seenEmail = new Set(); + const seenCompany = new Set(); for (const r of rows) { if (r.phone && !seenPhone.has(r.phone)) { seenPhone.add(r.phone); @@ -423,9 +434,23 @@ export class BookingWindowService implements OnModuleInit { .directSend('email', r.email, msg) .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( - `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) { this.logger.warn( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index e01e0d629..792fb7c64 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -33,6 +33,7 @@ import { WsAuthService } from '../notification-inbox/ws-auth.service'; import { BookingSplitService } from './booking-split.service'; import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { ContractsModule } from '../contracts/contracts.module'; @Module({ @@ -56,6 +57,7 @@ import { ContractsModule } from '../contracts/contracts.module'; forwardRef(() => BookingsModule), BillingModule, NotificationsModule, + NotificationInboxModule, LocomotivesModule, WagonTypesModule, TrainSetsModule, @@ -76,6 +78,11 @@ import { ContractsModule } from '../contracts/contracts.module'; BookingSplitService, IntercityService, ], - exports: [TrainSchedulingService, BookingBatchService, BookingWindowService], + exports: [ + TrainSchedulingService, + BookingBatchService, + BookingWindowService, + BookingNotifierService, + ], }) export class TrainSchedulingModule {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 16d5052bf..7211d63fd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -12,6 +12,7 @@ import { Injectable, Logger, NotFoundException, + Optional, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; @@ -21,6 +22,7 @@ import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.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 { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; @@ -274,9 +276,45 @@ export class TrainSchedulingService { private readonly warehouseInventoryService: WarehouseInventoryService, private readonly pdfDocuments: WarehouseReleaseDocumentService, private readonly bookingWindowGateway: BookingWindowGateway, + @Optional() private readonly milestoneService?: ClearanceMilestoneService, 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 { + 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 — @@ -1121,26 +1159,32 @@ export class TrainSchedulingService { { country: schedule.destinationCountry }, ); 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 { direction, action: 'IMPORT_AUTO_UNLOAD', status: 'COMPLETED', - result: await this.warehouseInventoryService.autoUnloadArrivedBookings( - scheduleId, - 'SYSTEM_TRAIN_ARRIVAL', - ), + result, }; } 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 { direction, action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD', status: 'COMPLETED', - result: await this.warehouseInventoryService.autoUnloadExportAtDjibouti( - scheduleId, - 'SYSTEM_TRAIN_ARRIVAL', - ), + result, }; } @@ -1443,6 +1487,20 @@ 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); } @@ -1599,6 +1657,13 @@ export class TrainSchedulingService { 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); } @@ -2417,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 warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId); return Object.assign(detail, { warehouseAutomation }); diff --git a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts index 375c12f19..3941c487c 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts @@ -35,6 +35,18 @@ export function useBookingWindowSocket(enabled: boolean = true) { 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) => { diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx b/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx index 9baf4ebf2..1ed61f72b 100644 --- a/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx +++ b/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx @@ -1,6 +1,6 @@ import { NotificationType } from "@edr/types"; 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; @@ -19,6 +19,8 @@ export function resolveNotificationVisual( return { icon: , color: "teal" }; case NotificationType.CLEARANCE_REVIEW: return { icon: , color: "orange" }; + case NotificationType.CONTRACT_STATUS: + return { icon: , color: "indigo" }; default: return { icon: , color: "edr-green" }; } @@ -39,14 +41,32 @@ export function resolveNotificationHref( if (item.link) return item.link; const data = item.data ?? {}; 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"; + } case NotificationType.PAYMENT_RECEIVED: { + const bookingId = asId(data.bookingId); + if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`; const id = asId(data.customerId); 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"; + } + case NotificationType.CONTRACT_STATUS: { + const contractId = asId(data.contractId); + return contractId + ? `/dashboard/contract-requests/${contractId}` + : "/dashboard/contract-requests"; + } default: return null; } diff --git a/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts index 17f6174b3..5b91af82e 100644 --- a/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts +++ b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts @@ -40,6 +40,18 @@ export function useBookingWindowSocket(enabled: boolean) { 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) => { diff --git a/apps/edr-freight-web/portal/src/features/notifications/notificationConfig.tsx b/apps/edr-freight-web/portal/src/features/notifications/notificationConfig.tsx index 42f3294f7..9cde29f77 100644 --- a/apps/edr-freight-web/portal/src/features/notifications/notificationConfig.tsx +++ b/apps/edr-freight-web/portal/src/features/notifications/notificationConfig.tsx @@ -3,6 +3,8 @@ import type { NotificationItemData, NotificationVisual } from "@edr/ui-common"; import { BadgeCheck, Bell, + CalendarClock, + FileSignature, FileWarning, Package, Receipt, @@ -25,6 +27,10 @@ export function resolveNotificationVisual( return { icon: , color: "orange" }; case NotificationType.BOOKING_STATUS: return { icon: , color: "blue" }; + case NotificationType.CONTRACT_STATUS: + return { icon: , color: "indigo" }; + case NotificationType.SCHEDULE_UPDATE: + return { icon: , color: "cyan" }; case NotificationType.INVOICE_ISSUED: return { icon: , color: "violet" }; default: @@ -55,8 +61,18 @@ export function resolveNotificationHref( const id = asId(data.bookingId); return id ? `/bookings/${id}` : null; } + case NotificationType.CONTRACT_STATUS: { + const id = asId(data.contractId); + return id ? `/contracts/${id}` : "/contracts"; + } + case NotificationType.SCHEDULE_UPDATE: { + const id = asId(data.bookingId); + return id ? `/bookings/${id}` : "/bookings/new"; + } case NotificationType.CLEARANCE_DECISION: case NotificationType.DOCUMENT_ACTION: { + const bookingId = asId(data.bookingId); + if (bookingId) return `/bookings/${bookingId}`; const id = asId(data.contractId); return id ? `/contracts/${id}` : "/contracts"; } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx index ac9352653..127901186 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -3,7 +3,7 @@ import { Check, MoveRight } from "lucide-react"; import type { Freight } from "@edr/types"; -import { PROGRESS_STAGES, STATUS_MAP } from "../constants"; +import { ARRIVAL_STAGE, PROGRESS_STAGES, STATUS_MAP, resolveStage } from "../constants"; import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils"; import { SectionCard } from "./layout"; @@ -65,7 +65,18 @@ export function StatusHero({ children?: React.ReactNode; }) { const status = booking.status; - const cfg = STATUS_MAP[status] ?? STATUS_MAP.DRAFT; + const stage = resolveStage(booking); + // The Arrival stage has no booking status of its own — it lights up from the + // train's ARRIVED state, so the headline is overridden here. + const cfg = + stage === ARRIVAL_STAGE + ? { + title: "Train arrived at destination", + description: + "Your shipment reached its destination yard and is being unloaded and prepared for release.", + stage, + } + : (STATUS_MAP[status] ?? STATUS_MAP.DRAFT); const negative = isNegative(status); const draft = isDraftLike(status); @@ -107,7 +118,7 @@ export function StatusHero({ {children ?? ( @@ -139,7 +150,7 @@ function ProgressTracker({ } as React.CSSProperties } > -
+
{PROGRESS_STAGES.map((stage, idx) => { const state = idx < current ? "done" : idx === current ? "active" : "idle"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index 67be567db..2a41a6fa3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -1,6 +1,7 @@ import { ClipboardCheck, FileText, + MapPin, PackageCheck, ShieldCheck, Ship, @@ -52,6 +53,14 @@ export const PROGRESS_STAGES = [ icon: Train, statuses: ["EXPIRED", "IN_TRANSIT"], }, + { + // No booking status maps here: the booking stays IN_TRANSIT until + // delivery, so this stage lights up from the assigned train's own status + // (trainScheduleStatus === "ARRIVED") — see resolveStage. + label: "Arrival", + icon: MapPin, + statuses: [], + }, { label: "Complete", icon: PackageCheck, @@ -59,6 +68,30 @@ export const PROGRESS_STAGES = [ }, ]; +/** Stage index of the Arrival step (train ARRIVED, cargo not yet delivered). */ +export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex( + (s) => s.label === "Arrival", +); + +/** + * Stage for a booking, factoring in the assigned train's operational status: + * a booking is stuck at IN_TRANSIT between dispatch and delivery, so once its + * train has ARRIVED the tracker advances to the Arrival stage. + */ +export function resolveStage(booking: { + status: string; + trainScheduleStatus?: string | null; +}): number { + const base = STATUS_MAP[booking.status]?.stage ?? 0; + if ( + booking.status === "IN_TRANSIT" && + booking.trainScheduleStatus === "ARRIVED" + ) { + return ARRIVAL_STAGE; + } + return base; +} + export const STATUS_MAP: Record< string, { title: string; description: string; stage: number } @@ -209,7 +242,7 @@ export const STATUS_MAP: Record< title: "Contract closed", description: "This general contract is closed — its reserved quantity has been used or its window has elapsed.", - stage: 7, + stage: 8, }, PRICE_CHANGED_PENDING_CONFIRM: { title: "Price changed — confirm to proceed", @@ -235,12 +268,12 @@ export const STATUS_MAP: Record< COMPLETED: { title: "Service complete", description: "Cargo delivered and service successfully terminated.", - stage: 7, + stage: 8, }, DELIVERED: { title: "Service complete", description: "Cargo delivered and service successfully terminated.", - stage: 7, + stage: 8, }, REJECTED: { title: "Booking rejected", diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 0b5752265..c51d838e1 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -438,6 +438,13 @@ export interface IBooking extends BaseEntity { contractReference?: string | null; trainId?: string | null; status: BookingStatus; + /** + * Operational status of the assigned train (DRAFT/SCHEDULED/DISPATCHED/ + * ARRIVED), joined on booking detail. The booking status stays IN_TRANSIT + * from dispatch until delivery, so the portal stepper reads arrival from + * here. + */ + trainScheduleStatus?: TrainScheduleStatus | string | null; /** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */ bookingType?: BookingType; /** General contracts only: when ordering closes (null until active / for one-time). */ diff --git a/packages/types/src/freight/notifications.ts b/packages/types/src/freight/notifications.ts index bdf83a719..75513dcb9 100644 --- a/packages/types/src/freight/notifications.ts +++ b/packages/types/src/freight/notifications.ts @@ -33,6 +33,8 @@ export enum NotificationType { CLEARANCE_DECISION = "CLEARANCE_DECISION", DOCUMENT_ACTION = "DOCUMENT_ACTION", BOOKING_STATUS = "BOOKING_STATUS", + CONTRACT_STATUS = "CONTRACT_STATUS", + SCHEDULE_UPDATE = "SCHEDULE_UPDATE", INVOICE_ISSUED = "INVOICE_ISSUED", // Backoffice-facing (staff) REQUEST_SUBMITTED = "REQUEST_SUBMITTED", @@ -87,6 +89,8 @@ export interface NotificationRecipients { companyProfileId?: string; /** Backoffice: all current employees of this organization. */ organizationId?: string; + /** Backoffice: every current employee across all organizations. */ + allBackoffice?: boolean; } /** Input any subsystem passes to `NotificationInboxService.notify(...)`. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fadeaca56..653fe803c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: jest: specifier: ^29.7.0 version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 supertest: specifier: ^7.0.0 version: 7.2.2 From 96b78fc72eae5b156647ac058e3841c3540cd3ae Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 6 Jul 2026 21:09:56 +0000 Subject: [PATCH 18/23] =?UTF-8?q?Login=20fixed=20=E2=80=94=20freight-api?= =?UTF-8?q?=20boots=20(PgBouncer=20search=5Fpath=20+=20migrations-table=20?= =?UTF-8?q?reconcile).=20Invoice=20PDF=20styled=20(vector=20fallback,=20no?= =?UTF-8?q?=20Docker=20change).=20Marshalling=20doc=20styled=20(table-awar?= =?UTF-8?q?e=20fallback,=20portrait/landscape).=20Load=20to=20Train=20tab?= =?UTF-8?q?=20=E2=80=94=20per-train=20arrived=20containers=20=E2=86=92=20m?= =?UTF-8?q?ultiselect=20=E2=86=92=20load=20onto=20wagons.=20Yard/zone=20li?= =?UTF-8?q?st=20filtered=20by=20freight=20type.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../warehouses/ReceiveInventoryModal.tsx | 60 ++++++++++++++++++- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 83c7fcbcd..3e3877236 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -646,9 +646,15 @@ function TruckEntranceFields({ function LocationSelects({ value, onChange, + allowedYardTypes, + allowedZoneTypes, }: { value: Location; 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( api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }), @@ -674,15 +680,17 @@ function LocationSelects({ () => (yardsQuery.data ?? []) .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})` })), - [yardsQuery.data], + [yardsQuery.data, allowedYardTypes], ); const zoneOptions = useMemo( () => (zonesQuery.data ?? []) .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})` })), - [zonesQuery.data], + [zonesQuery.data, allowedZoneTypes], ); return ( @@ -1760,6 +1768,29 @@ const importLocationTypesForFreight = (freightType: string | null | undefined) = const isImportContainerFreight = (freightType: string | null | undefined) => (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) => { + const yardTypes = new Set(); + const zoneTypes = new Set(); + 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) => !item.currentStatus || item.currentStatus === 'RECEIVED'; @@ -2888,6 +2919,24 @@ export function WarehouseFlowWorkbench({ ); 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(() => { if (enabled) setLocation({ warehouseId: '', yardId: '', zoneId: '' }); }, [enabled, direction]); @@ -2895,7 +2944,12 @@ export function WarehouseFlowWorkbench({ return ( {activeDirection === 'EXPORT' && ( - + )} {direction === 'BOTH' ? ( From ae0e48e6ed482b5be5bc7b14857db3e09c7a8e07 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 7 Jul 2026 08:16:48 +0300 Subject: [PATCH 19/23] UAT - total fare amount display issues resolution --- .../src/app/booking/confirmation/page.tsx | 34 +---- .../portal/src/app/booking/payment/page.tsx | 142 ++++++------------ .../portal/src/app/booking/review/page.tsx | 20 ++- .../portal/src/lib/booking-store.ts | 12 ++ 4 files changed, 84 insertions(+), 124 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index af5748ed7..5d992af93 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -10,7 +10,7 @@ import { apiClient } from '@/lib/api-client'; import { useEffect, useState, useRef } from 'react'; import { CheckCircle, Copy, Train, FileText } from 'lucide-react'; import { format } from 'date-fns'; -import { isChild, isFirstChild, calculatePassengerFare } from '@/utils/fare-utils'; +import { isChild, isFirstChild } from '@/utils/fare-utils'; type BookingWithTicket = { id: string; @@ -27,7 +27,7 @@ type BookingWithTicket = { export default function ConfirmationPage() { const router = useRouter(); - const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName, packageTierPriceMinor, packageId } = useBookingStore(); + const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, searchCriteria, passengers, clearBooking, packageName, packageTierPriceMinor, packageId, reviewedTotalMinor, reviewedPassengerFares } = useBookingStore(); // The currency/amount actually confirmed for the payment option the user selected — // null when no payment step ran (e.g. a fully-discounted, zero-amount booking). const { selectedCurrency: paidCurrency, paidAmountMinor } = usePaymentStore(); @@ -92,11 +92,11 @@ export default function ConfirmationPage() { const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule; // Prefer the amount/currency actually confirmed for the selected payment option; // only fall back to the ETB booking fare when no payment step ran (e.g. $0 total). - const totalFare = paidAmountMinor + const totalFare = reviewedTotalMinor + ?? paidAmountMinor ?? _booking?.totalMinor ?? passengers.reduce((s) => s + (activeSchedule?.baseFareAdult || 0), 0); - const voucherCurrency = paidAmountMinor != null ? paidCurrency : 'ETB'; - const farePerPassenger = Math.round(totalFare / passengers.length); + const voucherCurrency = 'ETB'; const createdAt = _booking?.createdAt || new Date().toISOString(); const status = _booking?.status || 'CONFIRMED'; @@ -137,7 +137,7 @@ export default function ConfirmationPage() { outboundSchedule: outbound, inboundSchedule: inbound, isRoundTrip, - fareMinor: farePerPassenger, + fareMinor: reviewedPassengerFares?.[i]?.fareMinor ?? Math.round(totalFare / passengers.length), currency: voucherCurrency, createdAt, }); @@ -332,28 +332,10 @@ export default function ConfirmationPage() {

Total paid

{(() => { + if (reviewedTotalMinor != null) return `ETB ${(reviewedTotalMinor / 100).toFixed(2)}`; if (paidAmountMinor != null) return `${paidCurrency} ${(paidAmountMinor / 100).toFixed(2)}`; if (_booking?.totalMinor != null) return `ETB ${(_booking.totalMinor / 100).toFixed(2)}`; - // Recompute the same way the payment page does - const isPackage = !!packageTierPriceMinor; - const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1; - const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; - const adultCount = passengers.filter(p => !isChild(p)).length; - const childCount = passengers.filter(p => isChild(p)).length; - const pkgPaidChildrenCount = Math.max(0, childCount - adultCount); - const fallback = isPackage - ? adultCount * pkgAdultFare + pkgPaidChildrenCount * pkgAdultFare - : isRoundTrip - ? passengers.reduce((sum, p, i) => { - const outFare = (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0); - const inFare = (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0); - return sum + calculatePassengerFare(passengers, i, outFare) + calculatePassengerFare(passengers, i, inFare); - }, 0) - : passengers.reduce((sum, p, i) => { - const fare = (p as any).seatFareMinor ?? (selectedSchedule?.baseFareAdult || 0); - return sum + calculatePassengerFare(passengers, i, fare); - }, 0); - return `ETB ${(fallback / 100).toFixed(2)}`; + return 'ETB 0.00'; })()}

diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 52ce64a2c..5366bfefb 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -28,7 +28,7 @@ const getIconForMethod = (methodId: string) => { export default function PaymentPage() { const router = useRouter(); - const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, packageTierPriceMinor } = useBookingStore(); + const { bookingId, pnr, selectedSchedule, outboundSchedule, inboundSchedule, passengers, searchCriteria, packageName, packageTierPriceMinor, reviewedTotalMinor, reviewedPassengerFares } = useBookingStore(); const { setPaymentIntent, updateStatus, setCurrency, setPaidAmount } = usePaymentStore(); const [selectedMethod, setSelectedMethod] = useState(null); const [selectedMethodCurrency, setSelectedMethodCurrency] = useState(null); @@ -36,7 +36,7 @@ export default function PaymentPage() { const [paymentError, setPaymentError] = useState(null); const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; - const isPackage = !!packageTierPriceMinor; + const isPackage = !!packageName; const displayCurrency = 'ETB' as const; @@ -61,66 +61,42 @@ export default function PaymentPage() { enabled: !!bookingId, }); - // Per-leg totals across all passengers. - // First child per adult = FREE (no seat); additional children = full adult fare. - const adultCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; - const childCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length; - const pkgPaidChildrenCount = Math.max(0, childCount - adultCount); - const pkgRoundTripMultiplier = isPackage && isRoundTrip ? 2 : 1; - const pkgAdultFare = isPackage ? packageTierPriceMinor! * pkgRoundTripMultiplier : 0; - const pkgChildFare = pkgAdultFare; // paid children pay full adult fare - const pkgPerLegAdultFare = isPackage ? packageTierPriceMinor! : 0; - const pkgPerLegChildFare = pkgPerLegAdultFare; // paid children pay full adult fare per leg - const pkgPerLegTotal = isPackage ? adultCount * pkgPerLegAdultFare + pkgPaidChildrenCount * pkgPerLegChildFare : 0; + // Per-leg subtotals for the journey header — sum each paying passenger's reviewed fare + // split equally across both legs. This guarantees leg totals are consistent with the + // per-passenger breakdown rows and the overall reviewed total. + const outboundBaseFare = isRoundTrip + ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0) + : 0; + const inboundBaseFare = isRoundTrip + ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0) + : 0; - // Prefer each passenger's own seat fare (set during seat selection) over the schedule's - // flat baseFareAdult — bed coaches price Upper/Middle/Lower berths differently, so a - // single schedule-level fare can't correctly represent every passenger's actual seat. - const outboundBaseFare = isPackage - ? pkgPerLegTotal - : (isRoundTrip && outboundSchedule ? passengers.reduce((sum, p, i) => { - const fare = (p as any).outboundSeatFareMinor ?? (outboundSchedule.baseFareAdult || 0); - return sum + calculatePassengerFare(passengers, i, fare); - }, 0) : 0); - - const inboundBaseFare = isPackage - ? pkgPerLegTotal - : (isRoundTrip && inboundSchedule ? passengers.reduce((sum, p, i) => { - const fare = (p as any).inboundSeatFareMinor ?? (inboundSchedule.baseFareAdult || 0); - return sum + calculatePassengerFare(passengers, i, fare); - }, 0) : 0); - - const baseFare = isPackage - ? adultCount * pkgAdultFare + pkgPaidChildrenCount * pkgChildFare - : isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, p, i) => { - const scheduleFare = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0; - const farePerPassenger = (p as any).seatFareMinor ?? scheduleFare; - return sum + calculatePassengerFare(passengers, i, farePerPassenger); - }, 0); - - // For package bookings the client-side baseFare is authoritative — it applies the - // round-trip multiplier and child pricing correctly, whereas booking.totalMinor in - // the DB may have been stored as a single-leg amount for older bookings. - // For regular bookings the API is the source of truth. - const totalAmountDisplay = isPackage - ? baseFare / 100 - : bookingAmountData != null ? bookingAmountData.amount : null; - const totalAmount = isPackage - ? baseFare - : bookingAmountData != null ? Math.round(bookingAmountData.amount * 100) : baseFare; + // reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display — + // they were computed and shown to the user on the review page, so the Total here must match. + // The API booking-amount is used only as the charge amount sent to the payment provider. + const reviewedTotal = reviewedTotalMinor ?? (reviewedPassengerFares?.reduce((s, f) => s + f.fareMinor, 0) ?? null); + const totalAmountDisplay = reviewedTotal != null ? reviewedTotal / 100 : (bookingAmountData != null ? bookingAmountData.amount : null); + const totalAmount = bookingAmountData != null + ? Math.round(bookingAmountData.amount * 100) + : (reviewedTotal ?? 0); const confirmedCurrency = bookingAmountData?.currency || amountCurrency; - // Persist the amount/currency actually confirmed for the selected payment option so - // downstream screens (e.g. the voucher) use it instead of a default ETB fare. + // Show loading spinner only when the API hasn't responded AND we have no review-page + // total to fall back on — once reviewedTotalMinor is set the button is always enabled. + const awaitingAmount = !isPackage && loadingAmount && totalAmountDisplay === null; + useEffect(() => { - if (isPackage) { + // Always store the reviewed total (minor, ETB) as the paid amount — it's what was + // shown to the user and matches the fare breakdown. The API amount is only used as + // the charge sent to the provider (may differ due to currency conversion). + if (reviewedTotal != null) { setCurrency('ETB'); - setPaidAmount(totalAmount); + setPaidAmount(reviewedTotal); } else if (bookingAmountData != null) { setCurrency(confirmedCurrency as 'ETB' | 'DJF' | 'USD'); - setPaidAmount(totalAmount); + setPaidAmount(Math.round(bookingAmountData.amount * 100)); } - }, [isPackage, bookingAmountData, confirmedCurrency, totalAmount, setCurrency, setPaidAmount]); + }, [bookingAmountData, confirmedCurrency, reviewedTotal, setCurrency, setPaidAmount]); const paymentMutation = useMutation({ mutationFn: async (data: any) => { @@ -290,32 +266,14 @@ export default function PaymentPage() { )} - {/* Fare breakdown — same first-child-free logic as the review page */} + {/* Fare breakdown — sourced directly from review page to guarantee totals match */}

Fare breakdown

{passengers.map((p, i) => { + const reviewed = reviewedPassengerFares?.[i]; const isChildPassenger = isChild(p); - // For package bookings: children ordered after adults; first adultCount children are free - const childIndex = i - adultCount; - const isPkgFreeChild = isPackage && isChildPassenger && childIndex >= 0 && childIndex < adultCount; - - let passengerTotal: number; - let isFreeChild = false; - if (isPackage) { - isFreeChild = isPkgFreeChild; - passengerTotal = isFreeChild ? 0 : (isChildPassenger ? pkgChildFare : pkgAdultFare); - } else { - // Prefer this passenger's actual seat fare (varies by berth for bed coaches) - // over the schedule's flat baseFareAdult. - const outFare = (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0); - const inFare = (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0); - const onewayFare = (p as any).seatFareMinor ?? (selectedSchedule?.baseFareAdult || 0); - const outboundFare = calculatePassengerFare(passengers, i, outFare); - const inboundFare = calculatePassengerFare(passengers, i, inFare); - const oneWayFare = calculatePassengerFare(passengers, i, onewayFare); - passengerTotal = isRoundTrip ? outboundFare + inboundFare : oneWayFare; - isFreeChild = isChildPassenger && isFirstChild(passengers, i); - } + const isFreeChild = reviewed?.isFree ?? (isChildPassenger && isFirstChild(passengers, i)); + const passengerTotal = reviewed?.fareMinor ?? 0; return (
@@ -334,25 +292,15 @@ export default function PaymentPage() { {formatFare(passengerTotal, displayCurrency)}
- {isRoundTrip && ( + {isRoundTrip && !isFreeChild && (
- Outbound {isFreeChild ? '(Free)' : ''} - {formatFare( - isPackage - ? (isFreeChild ? 0 : (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)) - : calculatePassengerFare(passengers, i, (p as any).outboundSeatFareMinor ?? (outboundSchedule?.baseFareAdult || 0)), - displayCurrency - )} + Outbound + {formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}
- Return {isFreeChild ? '(Free)' : ''} - {formatFare( - isPackage - ? (isFreeChild ? 0 : (isChildPassenger ? pkgPerLegChildFare : pkgPerLegAdultFare)) - : calculatePassengerFare(passengers, i, (p as any).inboundSeatFareMinor ?? (inboundSchedule?.baseFareAdult || 0)), - displayCurrency - )} + Return + {formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}
)} @@ -365,7 +313,7 @@ export default function PaymentPage() {
Total - {(!isPackage && (loadingAmount || totalAmountDisplay === null)) ? ( + {awaitingAmount ? ( ) : ( <>{confirmedCurrency} {(totalAmountDisplay ?? 0).toFixed(2)} @@ -381,14 +329,14 @@ export default function PaymentPage() { )}