From 8b6b1596b54ac8e131b96f039a076ad98560ff75 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 6 Jul 2026 11:37:57 +0000 Subject: [PATCH 01/39] 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 998a6801ab1d53ea3782723769138ac056ce8b33 Mon Sep 17 00:00:00 2001 From: natib21 Date: Mon, 6 Jul 2026 12:05:52 +0000 Subject: [PATCH 02/39] fleet --- apps/edr-freight-api/src/app.module.ts | 6 + .../1950000000000-AddVehicleCompliance.ts | 59 ++ .../migrations/1960000000000-AddIncidents.ts | 46 + .../1970000000000-AddMaintenanceDepth.ts | 93 ++ .../1980000000000-AddProcurement.ts | 73 ++ .../compliance/compliance.controller.ts | 53 ++ .../modules/compliance/compliance.module.ts | 16 + .../compliance/compliance.repository.ts | 26 + .../modules/compliance/compliance.service.ts | 184 ++++ .../dto/create-compliance-record.dto.ts | 55 ++ .../entities/compliance-record.entity.ts | 46 + .../incidents/dto/create-incident.dto.ts | 48 + .../incidents/dto/update-incident.dto.ts | 52 ++ .../incidents/entities/incident.entity.ts | 76 ++ .../modules/incidents/incidents.controller.ts | 69 ++ .../src/modules/incidents/incidents.module.ts | 14 + .../modules/incidents/incidents.repository.ts | 15 + .../modules/incidents/incidents.service.ts | 95 ++ .../dto/create-maintenance-depth.dto.ts | 171 ++++ .../maintenance/entities/part.entity.ts | 27 + .../maintenance/entities/warranty.entity.ts | 29 + .../maintenance/entities/work-order.entity.ts | 55 ++ .../maintenance/maintenance-depth.service.ts | 99 +++ .../maintenance/maintenance.controller.ts | 103 ++- .../modules/maintenance/maintenance.module.ts | 22 +- .../modules/maintenance/part.repository.ts | 27 + .../maintenance/warranty.repository.ts | 24 + .../maintenance/work-order.repository.ts | 25 + .../procurement/dto/procurement.dto.ts | 193 ++++ .../entities/asset-acquisition.entity.ts | 64 ++ .../entities/asset-disposal.entity.ts | 31 + .../procurement/entities/vendor.entity.ts | 34 + .../procurement/procurement.controller.ts | 98 +++ .../modules/procurement/procurement.module.ts | 16 + .../procurement/procurement.repository.ts | 102 +++ .../procurement/procurement.service.ts | 143 +++ .../vehicles/entities/vehicle.entity.ts | 17 + apps/edr-freight-web/backoffice/src/App.tsx | 60 ++ .../src/pages/fleet/CompliancePage.tsx | 343 ++++++++ .../src/pages/fleet/IncidentsPage.tsx | 399 +++++++++ .../src/pages/fleet/ProcurementPage.tsx | 665 ++++++++++++++ .../src/pages/fleet/WorkOrdersPage.tsx | 830 ++++++++++++++++++ .../src/services/compliance.service.ts | 73 ++ .../src/services/incidents.service.ts | 87 ++ .../src/services/maintenance-depth.service.ts | 130 +++ .../src/services/procurement.service.ts | 138 +++ 46 files changed, 5026 insertions(+), 5 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1950000000000-AddVehicleCompliance.ts create mode 100644 apps/edr-freight-api/src/migrations/1960000000000-AddIncidents.ts create mode 100644 apps/edr-freight-api/src/migrations/1970000000000-AddMaintenanceDepth.ts create mode 100644 apps/edr-freight-api/src/migrations/1980000000000-AddProcurement.ts create mode 100644 apps/edr-freight-api/src/modules/compliance/compliance.controller.ts create mode 100644 apps/edr-freight-api/src/modules/compliance/compliance.module.ts create mode 100644 apps/edr-freight-api/src/modules/compliance/compliance.repository.ts create mode 100644 apps/edr-freight-api/src/modules/compliance/compliance.service.ts create mode 100644 apps/edr-freight-api/src/modules/compliance/dto/create-compliance-record.dto.ts create mode 100644 apps/edr-freight-api/src/modules/compliance/entities/compliance-record.entity.ts create mode 100644 apps/edr-freight-api/src/modules/incidents/dto/create-incident.dto.ts create mode 100644 apps/edr-freight-api/src/modules/incidents/dto/update-incident.dto.ts create mode 100644 apps/edr-freight-api/src/modules/incidents/entities/incident.entity.ts create mode 100644 apps/edr-freight-api/src/modules/incidents/incidents.controller.ts create mode 100644 apps/edr-freight-api/src/modules/incidents/incidents.module.ts create mode 100644 apps/edr-freight-api/src/modules/incidents/incidents.repository.ts create mode 100644 apps/edr-freight-api/src/modules/incidents/incidents.service.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance-depth.dto.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/entities/part.entity.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/entities/warranty.entity.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/entities/work-order.entity.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/maintenance-depth.service.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/part.repository.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/warranty.repository.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/work-order.repository.ts create mode 100644 apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts create mode 100644 apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts create mode 100644 apps/edr-freight-api/src/modules/procurement/entities/asset-disposal.entity.ts create mode 100644 apps/edr-freight-api/src/modules/procurement/entities/vendor.entity.ts create mode 100644 apps/edr-freight-api/src/modules/procurement/procurement.controller.ts create mode 100644 apps/edr-freight-api/src/modules/procurement/procurement.module.ts create mode 100644 apps/edr-freight-api/src/modules/procurement/procurement.repository.ts create mode 100644 apps/edr-freight-api/src/modules/procurement/procurement.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/IncidentsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/ProcurementPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/WorkOrdersPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/compliance.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/incidents.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/maintenance-depth.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/procurement.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index b090624ff..1c99acbb5 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -78,6 +78,9 @@ import { VehiclesModule } from "./modules/vehicles/vehicles.module"; import { DriversModule } from "./modules/drivers/drivers.module"; import { FuelModule } from "./modules/fuel/fuel.module"; import { MaintenanceModule } from "./modules/maintenance/maintenance.module"; +import { ComplianceModule } from "./modules/compliance/compliance.module"; +import { IncidentsModule } from "./modules/incidents/incidents.module"; +import { ProcurementModule } from "./modules/procurement/procurement.module"; import { FirstMileModule } from "./modules/first-mile/first-mile.module"; import { LastMileModule } from "./modules/last-mile/last-mile.module"; import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; @@ -145,6 +148,9 @@ import { LoggerMiddleware } from "./logger.middleware"; DriversModule, FuelModule, MaintenanceModule, + ComplianceModule, + IncidentsModule, + ProcurementModule, FirstMileModule, LastMileModule, InterchangeDocumentsModule, diff --git a/apps/edr-freight-api/src/migrations/1950000000000-AddVehicleCompliance.ts b/apps/edr-freight-api/src/migrations/1950000000000-AddVehicleCompliance.ts new file mode 100644 index 000000000..f83f0a236 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1950000000000-AddVehicleCompliance.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Vehicle Compliance & Expiry Alerts. + * - Adds expiry-tracking columns to freight.vehicles. + * - Creates freight.compliance_records for per-document compliance tracking. + */ +export class AddVehicleCompliance1950000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Vehicle expiry / compliance columns. + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS vin VARCHAR, + ADD COLUMN IF NOT EXISTS ownership VARCHAR, + ADD COLUMN IF NOT EXISTS insurance_expiry DATE, + ADD COLUMN IF NOT EXISTS registration_expiry DATE, + ADD COLUMN IF NOT EXISTS next_inspection_date DATE; + `); + + // Compliance records table. + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.compliance_records ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vehicle_id UUID NOT NULL REFERENCES freight.vehicles(id), + type VARCHAR NOT NULL, + document_number VARCHAR, + issued_date DATE, + expiry_date DATE NOT NULL, + status VARCHAR NOT NULL DEFAULT 'VALID', + notes TEXT, + 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_compliance_records_vehicle_id ON freight.compliance_records(vehicle_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_compliance_records_expiry_date ON freight.compliance_records(expiry_date);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_compliance_records_type ON freight.compliance_records(type);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.compliance_records CASCADE;`); + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS vin, + DROP COLUMN IF EXISTS ownership, + DROP COLUMN IF EXISTS insurance_expiry, + DROP COLUMN IF EXISTS registration_expiry, + DROP COLUMN IF EXISTS next_inspection_date; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1960000000000-AddIncidents.ts b/apps/edr-freight-api/src/migrations/1960000000000-AddIncidents.ts new file mode 100644 index 000000000..dbb2994c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1960000000000-AddIncidents.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Accident & Incident register for the fleet. Tracks accidents, breakdowns, + * traffic violations, thefts and other incidents against a vehicle, driver + * and/or booking, with severity, damage estimate, insurance claim tracking and + * a lifecycle status. Queried by driver_id for per-driver incident history. + */ +export class AddIncidents1960000000000 implements MigrationInterface { + name = 'AddIncidents1960000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.incidents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + vehicle_id uuid, + driver_id uuid, + booking_id uuid, + type varchar NOT NULL, + severity varchar NOT NULL, + occurred_at timestamptz NOT NULL, + location varchar, + description text NOT NULL, + damage_estimate numeric(14,2), + status varchar NOT NULL DEFAULT 'REPORTED', + insurance_claim_number varchar, + reported_by varchar + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_INCIDENTS_DRIVER" + ON freight.incidents (driver_id, occurred_at) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_INCIDENTS_VEHICLE" + ON freight.incidents (vehicle_id, occurred_at) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.incidents`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1970000000000-AddMaintenanceDepth.ts b/apps/edr-freight-api/src/migrations/1970000000000-AddMaintenanceDepth.ts new file mode 100644 index 000000000..87b52ceff --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1970000000000-AddMaintenanceDepth.ts @@ -0,0 +1,93 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMaintenanceDepth1970000000000 implements MigrationInterface { + name = 'AddMaintenanceDepth1970000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.work_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vehicle_id UUID NOT NULL, + title VARCHAR NOT NULL, + description TEXT, + status VARCHAR NOT NULL DEFAULT 'OPEN', + priority VARCHAR NOT NULL DEFAULT 'MEDIUM', + assigned_to VARCHAR, + opened_at TIMESTAMPTZ NOT NULL DEFAULT now(), + closed_at TIMESTAMPTZ, + labor_cost NUMERIC(14, 2), + parts_cost NUMERIC(14, 2), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.parts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR NOT NULL, + sku VARCHAR, + category VARCHAR, + quantity_in_stock INT NOT NULL DEFAULT 0, + reorder_level INT NOT NULL DEFAULT 0, + unit_cost NUMERIC(14, 2), + location VARCHAR, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warranties ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vehicle_id UUID NOT NULL, + component VARCHAR NOT NULL, + provider VARCHAR, + start_date DATE, + expiry_date DATE NOT NULL, + coverage_notes TEXT, + 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_work_orders_vehicle_id_status" ON freight.work_orders (vehicle_id, status)`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_parts_category" ON freight.parts (category)`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warranties_vehicle_id_expiry_date" ON freight.warranties (vehicle_id, expiry_date)`, + ); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.work_orders + ADD CONSTRAINT "FK_work_orders_vehicle_id" + FOREIGN KEY (vehicle_id) REFERENCES freight.vehicles(id) ON DELETE CASCADE; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.warranties + ADD CONSTRAINT "FK_warranties_vehicle_id" + FOREIGN KEY (vehicle_id) REFERENCES freight.vehicles(id) ON DELETE CASCADE; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.warranties CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.parts CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.work_orders CASCADE`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1980000000000-AddProcurement.ts b/apps/edr-freight-api/src/migrations/1980000000000-AddProcurement.ts new file mode 100644 index 000000000..6d4304aaf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1980000000000-AddProcurement.ts @@ -0,0 +1,73 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddProcurement1980000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.vendors ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + name varchar NOT NULL, + type varchar, + contact_person varchar, + phone varchar, + email varchar, + address varchar, + is_active boolean NOT NULL DEFAULT true + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.asset_acquisitions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + vehicle_id uuid, + vendor_id uuid, + acquisition_type varchar NOT NULL, + acquisition_date date NOT NULL, + cost numeric(14,2), + useful_life_months integer, + salvage_value numeric(14,2), + lease_start date, + lease_end date, + monthly_payment numeric(14,2), + status varchar NOT NULL DEFAULT 'ACTIVE', + notes text + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_asset_acquisitions_vehicle_date + ON freight.asset_acquisitions(vehicle_id, acquisition_date); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.asset_disposals ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + vehicle_id uuid NOT NULL, + disposal_date date NOT NULL, + method varchar NOT NULL, + sale_price numeric(14,2), + buyer varchar, + notes text + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_asset_disposals_vehicle_date + ON freight.asset_disposals(vehicle_id, disposal_date); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.asset_disposals CASCADE;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.asset_acquisitions CASCADE;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.vendors CASCADE;`); + } +} diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts new file mode 100644 index 000000000..2a5715647 --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts @@ -0,0 +1,53 @@ +import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { ComplianceService } from './compliance.service'; +import { + CreateComplianceRecordDto, + UpdateComplianceRecordDto, +} from './dto/create-compliance-record.dto'; +import { ComplianceType } from './entities/compliance-record.entity'; + +@ApiTags('Vehicle Compliance') +@Controller('compliance') +export class ComplianceController { + constructor(private readonly complianceService: ComplianceService) {} + + @Post() + @ApiOperation({ summary: 'Create a compliance record' }) + create(@Body() dto: CreateComplianceRecordDto) { + return this.complianceService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List compliance records' }) + findAll( + @Query('vehicleId') vehicleId?: string, + @Query('type') type?: ComplianceType, + ) { + return this.complianceService.findAll({ vehicleId, type }); + } + + @Get('alerts') + @ApiOperation({ summary: 'List overdue / due-soon compliance & expiry alerts' }) + getAlerts() { + return this.complianceService.getAlerts(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a compliance record by ID' }) + findOne(@Param('id') id: string) { + return this.complianceService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a compliance record' }) + update(@Param('id') id: string, @Body() dto: UpdateComplianceRecordDto) { + return this.complianceService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Soft-delete a compliance record' }) + remove(@Param('id') id: string) { + return this.complianceService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.module.ts b/apps/edr-freight-api/src/modules/compliance/compliance.module.ts new file mode 100644 index 000000000..1477fbc8b --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/compliance.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ComplianceRecord } from './entities/compliance-record.entity'; +import { Vehicle } from '../vehicles/entities/vehicle.entity'; +import { Driver } from '../drivers/entities/driver.entity'; +import { ComplianceService } from './compliance.service'; +import { ComplianceRepository } from './compliance.repository'; +import { ComplianceController } from './compliance.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([ComplianceRecord, Vehicle, Driver])], + providers: [ComplianceService, ComplianceRepository], + controllers: [ComplianceController], + exports: [ComplianceService], +}) +export class ComplianceModule {} diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.repository.ts b/apps/edr-freight-api/src/modules/compliance/compliance.repository.ts new file mode 100644 index 000000000..e9764f8a4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/compliance.repository.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository, FindOptionsWhere } from 'typeorm'; +import { ComplianceRecord, ComplianceType } from './entities/compliance-record.entity'; + +@Injectable() +export class ComplianceRepository extends BaseRepository { + constructor( + @InjectRepository(ComplianceRecord) + private readonly complianceRepository: Repository, + ) { + super(complianceRepository); + } + + async findWithFilters(filter: { vehicleId?: string; type?: ComplianceType } = {}) { + const where: FindOptionsWhere = {}; + if (filter.vehicleId) where.vehicleId = filter.vehicleId; + if (filter.type) where.type = filter.type; + + return this.complianceRepository.find({ + where, + order: { expiryDate: 'ASC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.service.ts b/apps/edr-freight-api/src/modules/compliance/compliance.service.ts new file mode 100644 index 000000000..ec6e2a803 --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/compliance.service.ts @@ -0,0 +1,184 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, IsNull, Repository } from 'typeorm'; +import { ComplianceRepository } from './compliance.repository'; +import { + ComplianceRecord, + ComplianceStatus, + ComplianceType, +} from './entities/compliance-record.entity'; +import { + CreateComplianceRecordDto, + UpdateComplianceRecordDto, +} from './dto/create-compliance-record.dto'; +import { Vehicle } from '../vehicles/entities/vehicle.entity'; +import { Driver } from '../drivers/entities/driver.entity'; + +const DUE_SOON_DAYS = 30; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export type AlertSeverity = 'OVERDUE' | 'DUE_SOON'; + +export interface ComplianceAlert { + vehicleId: string; + vehiclePlate?: string; + kind: string; + label: string; + expiryDate: string; + daysUntil: number; + severity: AlertSeverity; +} + +@Injectable() +export class ComplianceService { + constructor( + private readonly complianceRepository: ComplianceRepository, + @InjectRepository(Vehicle) + private readonly vehicleRepo: Repository, + @InjectRepository(Driver) + private readonly driverRepo: Repository, + ) {} + + async create(dto: CreateComplianceRecordDto): Promise { + return this.complianceRepository.create({ + ...dto, + status: dto.status ?? this.deriveStatus(dto.expiryDate), + }); + } + + async findAll(filter: { vehicleId?: string; type?: ComplianceType } = {}) { + return this.complianceRepository.findWithFilters(filter); + } + + async findById(id: string): Promise { + const record = await this.complianceRepository.findById(id); + if (!record) { + throw new NotFoundException(`Compliance record ${id} not found`); + } + return record; + } + + async update(id: string, dto: UpdateComplianceRecordDto): Promise { + await this.findById(id); + const nextExpiry = dto.expiryDate; + const updated = await this.complianceRepository.update(id, { + ...dto, + // Re-derive status when expiry changes and the caller didn't set it explicitly. + status: dto.status ?? (nextExpiry ? this.deriveStatus(nextExpiry) : undefined), + }); + return updated!; + } + + async remove(id: string): Promise { + await this.findById(id); + await this.complianceRepository.softDelete(id); + } + + /** + * Flat list of compliance items that are overdue or due within 30 days. + * Combines the compliance_records table with the vehicle expiry columns + * (insurance / registration / next inspection) and assigned-driver license + * expiry. `new Date()` is fine here — this is the NestJS API runtime. + */ + async getAlerts(): Promise { + const now = new Date(); + const alerts: ComplianceAlert[] = []; + + const vehicles = await this.vehicleRepo.find({ where: { deletedAt: IsNull() } }); + const vehicleById = new Map(vehicles.map((v) => [v.id, v])); + const plateOf = (v?: Vehicle) => v?.plateNumber ?? v?.code ?? undefined; + + // 1. Compliance records + const records = await this.complianceRepository.findWithFilters(); + for (const record of records) { + const computed = this.computeSeverity(record.expiryDate, now); + if (!computed) continue; + const vehicle = vehicleById.get(record.vehicleId); + alerts.push({ + vehicleId: record.vehicleId, + vehiclePlate: plateOf(vehicle), + kind: record.type, + label: record.documentNumber + ? `${record.type} · ${record.documentNumber}` + : record.type, + expiryDate: record.expiryDate, + daysUntil: computed.daysUntil, + severity: computed.severity, + }); + } + + // 2. Vehicle-level expiry columns + const vehicleFields: { field: keyof Vehicle; kind: string; label: string }[] = [ + { field: 'insuranceExpiry', kind: 'INSURANCE', label: 'Insurance' }, + { field: 'registrationExpiry', kind: 'REGISTRATION', label: 'Registration' }, + { field: 'nextInspectionDate', kind: 'INSPECTION', label: 'Inspection' }, + ]; + for (const vehicle of vehicles) { + for (const { field, kind, label } of vehicleFields) { + const value = vehicle[field] as string | undefined; + if (!value) continue; + const computed = this.computeSeverity(value, now); + if (!computed) continue; + alerts.push({ + vehicleId: vehicle.id, + vehiclePlate: plateOf(vehicle), + kind, + label, + expiryDate: value, + daysUntil: computed.daysUntil, + severity: computed.severity, + }); + } + } + + // 3. Assigned-driver license expiry + const driverIds = [ + ...new Set(vehicles.map((v) => v.assignedDriverId).filter((id): id is string => !!id)), + ]; + if (driverIds.length > 0) { + const drivers = await this.driverRepo.find({ where: { id: In(driverIds) } }); + const driverById = new Map(drivers.map((d) => [d.id, d])); + for (const vehicle of vehicles) { + if (!vehicle.assignedDriverId) continue; + const driver = driverById.get(vehicle.assignedDriverId); + if (!driver?.licenseExpiryDate) continue; + const expiry = + driver.licenseExpiryDate instanceof Date + ? driver.licenseExpiryDate.toISOString().slice(0, 10) + : String(driver.licenseExpiryDate); + const computed = this.computeSeverity(expiry, now); + if (!computed) continue; + alerts.push({ + vehicleId: vehicle.id, + vehiclePlate: plateOf(vehicle), + kind: 'DRIVER_LICENSE', + label: `Driver License · ${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), + expiryDate: expiry, + daysUntil: computed.daysUntil, + severity: computed.severity, + }); + } + } + + return alerts.sort((a, b) => a.daysUntil - b.daysUntil); + } + + private computeSeverity( + expiryDate: string, + now: Date, + ): { daysUntil: number; severity: AlertSeverity } | null { + const daysUntil = Math.ceil((new Date(expiryDate).getTime() - now.getTime()) / MS_PER_DAY); + if (daysUntil < 0) return { daysUntil, severity: 'OVERDUE' }; + if (daysUntil <= DUE_SOON_DAYS) return { daysUntil, severity: 'DUE_SOON' }; + return null; + } + + private deriveStatus(expiryDate: string): ComplianceStatus { + const daysUntil = Math.ceil( + (new Date(expiryDate).getTime() - Date.now()) / MS_PER_DAY, + ); + if (daysUntil < 0) return ComplianceStatus.EXPIRED; + if (daysUntil <= DUE_SOON_DAYS) return ComplianceStatus.EXPIRING; + return ComplianceStatus.VALID; + } +} diff --git a/apps/edr-freight-api/src/modules/compliance/dto/create-compliance-record.dto.ts b/apps/edr-freight-api/src/modules/compliance/dto/create-compliance-record.dto.ts new file mode 100644 index 000000000..8b716ef15 --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/dto/create-compliance-record.dto.ts @@ -0,0 +1,55 @@ +import { IsUUID, IsString, IsDateString, IsOptional, IsEnum } from 'class-validator'; +import { ComplianceType, ComplianceStatus } from '../entities/compliance-record.entity'; + +export class CreateComplianceRecordDto { + @IsUUID() + vehicleId!: string; + + @IsEnum(ComplianceType) + type!: ComplianceType; + + @IsOptional() + @IsString() + documentNumber?: string; + + @IsOptional() + @IsDateString() + issuedDate?: string; + + @IsDateString() + expiryDate!: string; + + @IsOptional() + @IsEnum(ComplianceStatus) + status?: ComplianceStatus; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateComplianceRecordDto { + @IsOptional() + @IsEnum(ComplianceType) + type?: ComplianceType; + + @IsOptional() + @IsString() + documentNumber?: string; + + @IsOptional() + @IsDateString() + issuedDate?: string; + + @IsOptional() + @IsDateString() + expiryDate?: string; + + @IsOptional() + @IsEnum(ComplianceStatus) + status?: ComplianceStatus; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/compliance/entities/compliance-record.entity.ts b/apps/edr-freight-api/src/modules/compliance/entities/compliance-record.entity.ts new file mode 100644 index 000000000..04355c1f9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/entities/compliance-record.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +export enum ComplianceType { + INSPECTION = 'INSPECTION', + INSURANCE = 'INSURANCE', + ROADWORTHINESS = 'ROADWORTHINESS', + PERMIT = 'PERMIT', + TAX = 'TAX', +} + +export enum ComplianceStatus { + VALID = 'VALID', + EXPIRING = 'EXPIRING', + EXPIRED = 'EXPIRED', +} + +@Entity({ name: 'compliance_records', schema: 'freight' }) +@Index(['vehicleId', 'expiryDate']) +export class ComplianceRecord extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false, nullable: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'type', type: 'varchar' }) + type!: ComplianceType; + + @Column({ name: 'document_number', type: 'varchar', nullable: true }) + documentNumber?: string; + + @Column({ name: 'issued_date', type: 'date', nullable: true }) + issuedDate?: string; + + @Column({ name: 'expiry_date', type: 'date' }) + expiryDate!: string; + + @Column({ name: 'status', type: 'varchar', default: ComplianceStatus.VALID }) + status!: ComplianceStatus; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/incidents/dto/create-incident.dto.ts b/apps/edr-freight-api/src/modules/incidents/dto/create-incident.dto.ts new file mode 100644 index 000000000..5d76885b4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/dto/create-incident.dto.ts @@ -0,0 +1,48 @@ +import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator'; +import { IncidentType, IncidentSeverity, IncidentStatus } from '../entities/incident.entity'; + +export class CreateIncidentDto { + @IsOptional() + @IsUUID() + vehicleId?: string; + + @IsOptional() + @IsUUID() + driverId?: string; + + @IsOptional() + @IsUUID() + bookingId?: string; + + @IsEnum(IncidentType) + type!: IncidentType; + + @IsEnum(IncidentSeverity) + severity!: IncidentSeverity; + + @IsDateString() + occurredAt!: string; + + @IsOptional() + @IsString() + location?: string; + + @IsString() + description!: string; + + @IsOptional() + @IsNumber() + damageEstimate?: number; + + @IsOptional() + @IsEnum(IncidentStatus) + status?: IncidentStatus; + + @IsOptional() + @IsString() + insuranceClaimNumber?: string; + + @IsOptional() + @IsString() + reportedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/incidents/dto/update-incident.dto.ts b/apps/edr-freight-api/src/modules/incidents/dto/update-incident.dto.ts new file mode 100644 index 000000000..b45d478e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/dto/update-incident.dto.ts @@ -0,0 +1,52 @@ +import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator'; +import { IncidentType, IncidentSeverity, IncidentStatus } from '../entities/incident.entity'; + +export class UpdateIncidentDto { + @IsOptional() + @IsUUID() + vehicleId?: string; + + @IsOptional() + @IsUUID() + driverId?: string; + + @IsOptional() + @IsUUID() + bookingId?: string; + + @IsOptional() + @IsEnum(IncidentType) + type?: IncidentType; + + @IsOptional() + @IsEnum(IncidentSeverity) + severity?: IncidentSeverity; + + @IsOptional() + @IsDateString() + occurredAt?: string; + + @IsOptional() + @IsString() + location?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsNumber() + damageEstimate?: number; + + @IsOptional() + @IsEnum(IncidentStatus) + status?: IncidentStatus; + + @IsOptional() + @IsString() + insuranceClaimNumber?: string; + + @IsOptional() + @IsString() + reportedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/incidents/entities/incident.entity.ts b/apps/edr-freight-api/src/modules/incidents/entities/incident.entity.ts new file mode 100644 index 000000000..2c71cc8a3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/entities/incident.entity.ts @@ -0,0 +1,76 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { Driver } from '../../drivers/entities/driver.entity'; + +export enum IncidentType { + ACCIDENT = 'ACCIDENT', + BREAKDOWN = 'BREAKDOWN', + TRAFFIC_VIOLATION = 'TRAFFIC_VIOLATION', + THEFT = 'THEFT', + OTHER = 'OTHER', +} + +export enum IncidentSeverity { + MINOR = 'MINOR', + MODERATE = 'MODERATE', + MAJOR = 'MAJOR', + CRITICAL = 'CRITICAL', +} + +export enum IncidentStatus { + REPORTED = 'REPORTED', + UNDER_REVIEW = 'UNDER_REVIEW', + CLAIM_FILED = 'CLAIM_FILED', + RESOLVED = 'RESOLVED', + CLOSED = 'CLOSED', +} + +@Entity({ name: 'incidents', schema: 'freight' }) +@Index(['driverId', 'occurredAt']) +@Index(['vehicleId', 'occurredAt']) +export class Incident extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string; + + @ManyToOne(() => Vehicle, { eager: false, nullable: true }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle; + + @Column({ name: 'driver_id', type: 'uuid', nullable: true }) + driverId?: string; + + @ManyToOne(() => Driver, { eager: false, nullable: true }) + @JoinColumn({ name: 'driver_id' }) + driver?: Driver; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string; + + @Column({ name: 'type', type: 'varchar' }) + type!: IncidentType; + + @Column({ name: 'severity', type: 'varchar' }) + severity!: IncidentSeverity; + + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + @Column({ name: 'location', type: 'varchar', nullable: true }) + location?: string; + + @Column({ name: 'description', type: 'text' }) + description!: string; + + @Column({ name: 'damage_estimate', type: 'numeric', precision: 14, scale: 2, nullable: true }) + damageEstimate?: number; + + @Column({ name: 'status', type: 'varchar', default: IncidentStatus.REPORTED }) + status!: IncidentStatus; + + @Column({ name: 'insurance_claim_number', type: 'varchar', nullable: true }) + insuranceClaimNumber?: string; + + @Column({ name: 'reported_by', type: 'varchar', nullable: true }) + reportedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts b/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts new file mode 100644 index 000000000..ab6d5ef08 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts @@ -0,0 +1,69 @@ +import { + Controller, + Post, + Get, + Patch, + Delete, + Body, + Param, + Query, +} from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { IncidentsService } from './incidents.service'; +import { CreateIncidentDto } from './dto/create-incident.dto'; +import { UpdateIncidentDto } from './dto/update-incident.dto'; +import { IncidentStatus, IncidentType } from './entities/incident.entity'; + +@ApiTags('Accident & Incident Management') +@Controller('incidents') +export class IncidentsController { + constructor(private readonly incidentsService: IncidentsService) {} + + @Post() + @ApiOperation({ summary: 'Report an incident' }) + async create(@Body() dto: CreateIncidentDto) { + return this.incidentsService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List incidents (optionally filtered)' }) + async findAll( + @Query('vehicleId') vehicleId?: string, + @Query('driverId') driverId?: string, + @Query('status') status?: IncidentStatus, + @Query('type') type?: IncidentType, + ) { + return this.incidentsService.findAll({ vehicleId, driverId, status, type }); + } + + @Get('driver/:driverId/stats') + @ApiOperation({ summary: 'Get incident statistics for a driver' }) + async statsForDriver(@Param('driverId') driverId: string) { + return this.incidentsService.statsForDriver(driverId); + } + + @Get('driver/:driverId') + @ApiOperation({ summary: 'List incidents for a driver (incident history)' }) + async findByDriver(@Param('driverId') driverId: string) { + return this.incidentsService.findByDriver(driverId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get an incident by id' }) + async findById(@Param('id') id: string) { + return this.incidentsService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update an incident' }) + async update(@Param('id') id: string, @Body() dto: UpdateIncidentDto) { + return this.incidentsService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete an incident' }) + async remove(@Param('id') id: string) { + await this.incidentsService.remove(id); + return { success: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.module.ts b/apps/edr-freight-api/src/modules/incidents/incidents.module.ts new file mode 100644 index 000000000..872fbaab1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/incidents.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Incident } from './entities/incident.entity'; +import { IncidentsService } from './incidents.service'; +import { IncidentsRepository } from './incidents.repository'; +import { IncidentsController } from './incidents.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Incident])], + providers: [IncidentsService, IncidentsRepository], + controllers: [IncidentsController], + exports: [IncidentsService], +}) +export class IncidentsModule {} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.repository.ts b/apps/edr-freight-api/src/modules/incidents/incidents.repository.ts new file mode 100644 index 000000000..1d9f17770 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/incidents.repository.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository } from 'typeorm'; +import { Incident } from './entities/incident.entity'; + +@Injectable() +export class IncidentsRepository extends BaseRepository { + constructor( + @InjectRepository(Incident) + incidentRepository: Repository, + ) { + super(incidentRepository); + } +} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.service.ts b/apps/edr-freight-api/src/modules/incidents/incidents.service.ts new file mode 100644 index 000000000..ea28a96e2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/incidents.service.ts @@ -0,0 +1,95 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { FindOptionsWhere } from 'typeorm'; +import { IncidentsRepository } from './incidents.repository'; +import { + Incident, + IncidentStatus, + IncidentType, +} from './entities/incident.entity'; +import { CreateIncidentDto } from './dto/create-incident.dto'; +import { UpdateIncidentDto } from './dto/update-incident.dto'; + +export interface IncidentFilter { + vehicleId?: string; + driverId?: string; + status?: IncidentStatus; + type?: IncidentType; +} + +export interface DriverIncidentStats { + total: number; + byType: Record; + lastIncidentAt: Date | null; +} + +@Injectable() +export class IncidentsService { + constructor(private readonly incidentsRepository: IncidentsRepository) {} + + async create(dto: CreateIncidentDto): Promise { + return this.incidentsRepository.create({ + ...dto, + occurredAt: new Date(dto.occurredAt), + }); + } + + async findAll(filter: IncidentFilter = {}): Promise { + const where: FindOptionsWhere = {}; + if (filter.vehicleId) where.vehicleId = filter.vehicleId; + if (filter.driverId) where.driverId = filter.driverId; + if (filter.status) where.status = filter.status; + if (filter.type) where.type = filter.type; + + return this.incidentsRepository.findAll({ + where, + order: { occurredAt: 'DESC' }, + }); + } + + async findByDriver(driverId: string): Promise { + return this.incidentsRepository.findAll({ + where: { driverId }, + order: { occurredAt: 'DESC' }, + }); + } + + async findById(id: string): Promise { + const incident = await this.incidentsRepository.findById(id); + if (!incident) { + throw new NotFoundException(`Incident ${id} not found`); + } + return incident; + } + + async update(id: string, dto: UpdateIncidentDto): Promise { + await this.findById(id); + const updated = await this.incidentsRepository.update(id, { + ...dto, + occurredAt: dto.occurredAt ? new Date(dto.occurredAt) : undefined, + }); + return updated!; + } + + async remove(id: string): Promise { + await this.findById(id); + await this.incidentsRepository.softDelete(id); + } + + async statsForDriver(driverId: string): Promise { + const incidents = await this.incidentsRepository.findAll({ + where: { driverId }, + order: { occurredAt: 'DESC' }, + }); + + const byType: Record = {}; + for (const incident of incidents) { + byType[incident.type] = (byType[incident.type] || 0) + 1; + } + + return { + total: incidents.length, + byType, + lastIncidentAt: incidents.length > 0 ? incidents[0].occurredAt : null, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance-depth.dto.ts b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance-depth.dto.ts new file mode 100644 index 000000000..56c886a74 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance-depth.dto.ts @@ -0,0 +1,171 @@ +import { + IsUUID, + IsString, + IsDateString, + IsNumber, + IsInt, + IsOptional, + IsEnum, + Min, +} from 'class-validator'; +import { WorkOrderStatus, WorkOrderPriority } from '../entities/work-order.entity'; + +export class CreateWorkOrderDto { + @IsUUID() + vehicleId!: string; + + @IsString() + title!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(WorkOrderStatus) + status?: WorkOrderStatus; + + @IsOptional() + @IsEnum(WorkOrderPriority) + priority?: WorkOrderPriority; + + @IsOptional() + @IsString() + assignedTo?: string; + + @IsOptional() + @IsDateString() + openedAt?: string; + + @IsOptional() + @IsDateString() + closedAt?: string; + + @IsOptional() + @IsNumber() + laborCost?: number; + + @IsOptional() + @IsNumber() + partsCost?: number; +} + +export class UpdateWorkOrderDto { + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(WorkOrderStatus) + status?: WorkOrderStatus; + + @IsOptional() + @IsEnum(WorkOrderPriority) + priority?: WorkOrderPriority; + + @IsOptional() + @IsString() + assignedTo?: string; + + @IsOptional() + @IsDateString() + closedAt?: string; + + @IsOptional() + @IsNumber() + laborCost?: number; + + @IsOptional() + @IsNumber() + partsCost?: number; +} + +export class CreatePartDto { + @IsString() + name!: string; + + @IsOptional() + @IsString() + sku?: string; + + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsInt() + @Min(0) + quantityInStock?: number; + + @IsOptional() + @IsInt() + @Min(0) + reorderLevel?: number; + + @IsOptional() + @IsNumber() + unitCost?: number; + + @IsOptional() + @IsString() + location?: string; +} + +export class UpdatePartDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + sku?: string; + + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsInt() + @Min(0) + quantityInStock?: number; + + @IsOptional() + @IsInt() + @Min(0) + reorderLevel?: number; + + @IsOptional() + @IsNumber() + unitCost?: number; + + @IsOptional() + @IsString() + location?: string; +} + +export class CreateWarrantyDto { + @IsUUID() + vehicleId!: string; + + @IsString() + component!: string; + + @IsOptional() + @IsString() + provider?: string; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsDateString() + expiryDate!: string; + + @IsOptional() + @IsString() + coverageNotes?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/part.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/part.entity.ts new file mode 100644 index 000000000..caa478d88 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/part.entity.ts @@ -0,0 +1,27 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, Index } from 'typeorm'; + +@Entity({ name: 'parts', schema: 'freight' }) +@Index(['category']) +export class Part extends BaseEntity { + @Column({ name: 'name', type: 'varchar' }) + name!: string; + + @Column({ name: 'sku', type: 'varchar', nullable: true }) + sku?: string; + + @Column({ name: 'category', type: 'varchar', nullable: true }) + category?: string; // includes 'TIRE' — doubles as tire inventory + + @Column({ name: 'quantity_in_stock', type: 'int', default: 0 }) + quantityInStock!: number; + + @Column({ name: 'reorder_level', type: 'int', default: 0 }) + reorderLevel!: number; + + @Column({ name: 'unit_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + unitCost?: number; + + @Column({ name: 'location', type: 'varchar', nullable: true }) + location?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/warranty.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/warranty.entity.ts new file mode 100644 index 000000000..56c44fcbd --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/warranty.entity.ts @@ -0,0 +1,29 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ name: 'warranties', schema: 'freight' }) +@Index(['vehicleId', 'expiryDate']) +export class Warranty extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'component', type: 'varchar' }) + component!: string; + + @Column({ name: 'provider', type: 'varchar', nullable: true }) + provider?: string; + + @Column({ name: 'start_date', type: 'date', nullable: true }) + startDate?: string; + + @Column({ name: 'expiry_date', type: 'date' }) + expiryDate!: string; + + @Column({ name: 'coverage_notes', type: 'text', nullable: true }) + coverageNotes?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/work-order.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/work-order.entity.ts new file mode 100644 index 000000000..224b74f74 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/work-order.entity.ts @@ -0,0 +1,55 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +export enum WorkOrderStatus { + OPEN = 'OPEN', + IN_PROGRESS = 'IN_PROGRESS', + COMPLETED = 'COMPLETED', + CANCELLED = 'CANCELLED', +} + +export enum WorkOrderPriority { + LOW = 'LOW', + MEDIUM = 'MEDIUM', + HIGH = 'HIGH', + URGENT = 'URGENT', +} + +@Entity({ name: 'work_orders', schema: 'freight' }) +@Index(['vehicleId', 'status']) +export class WorkOrder extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'title', type: 'varchar' }) + title!: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string; + + @Column({ name: 'status', type: 'varchar', default: WorkOrderStatus.OPEN }) + status!: WorkOrderStatus; + + @Column({ name: 'priority', type: 'varchar', default: WorkOrderPriority.MEDIUM }) + priority!: WorkOrderPriority; + + @Column({ name: 'assigned_to', type: 'varchar', nullable: true }) + assignedTo?: string; + + @Column({ name: 'opened_at', type: 'timestamptz' }) + openedAt!: Date; + + @Column({ name: 'closed_at', type: 'timestamptz', nullable: true }) + closedAt?: Date; + + @Column({ name: 'labor_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + laborCost?: number; + + @Column({ name: 'parts_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + partsCost?: number; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-depth.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-depth.service.ts new file mode 100644 index 000000000..212fe909a --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-depth.service.ts @@ -0,0 +1,99 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { WorkOrderRepository } from './work-order.repository'; +import { PartRepository } from './part.repository'; +import { WarrantyRepository } from './warranty.repository'; +import { WorkOrder, WorkOrderStatus } from './entities/work-order.entity'; +import { Part } from './entities/part.entity'; +import { Warranty } from './entities/warranty.entity'; +import { + CreateWorkOrderDto, + UpdateWorkOrderDto, + CreatePartDto, + UpdatePartDto, + CreateWarrantyDto, +} from './dto/create-maintenance-depth.dto'; + +@Injectable() +export class MaintenanceDepthService { + constructor( + private readonly workOrderRepository: WorkOrderRepository, + private readonly partRepository: PartRepository, + private readonly warrantyRepository: WarrantyRepository, + ) {} + + // ---- Work Orders ---- + + async createWorkOrder(dto: CreateWorkOrderDto): Promise { + return this.workOrderRepository.create({ + ...dto, + openedAt: dto.openedAt ? new Date(dto.openedAt) : new Date(), + closedAt: dto.closedAt ? new Date(dto.closedAt) : undefined, + }); + } + + async findWorkOrders(filters: { vehicleId?: string; status?: WorkOrderStatus }) { + return this.workOrderRepository.findFiltered(filters); + } + + async findWorkOrderById(id: string): Promise { + const workOrder = await this.workOrderRepository.findById(id); + if (!workOrder) throw new NotFoundException(`Work order ${id} not found`); + return workOrder; + } + + async updateWorkOrder(id: string, dto: UpdateWorkOrderDto): Promise { + await this.findWorkOrderById(id); + const updated = await this.workOrderRepository.update(id, { + ...dto, + closedAt: dto.closedAt ? new Date(dto.closedAt) : undefined, + }); + return updated!; + } + + async deleteWorkOrder(id: string): Promise<{ id: string; deleted: boolean }> { + await this.findWorkOrderById(id); + await this.workOrderRepository.softDelete(id); + return { id, deleted: true }; + } + + // ---- Parts / Tires ---- + + async createPart(dto: CreatePartDto): Promise { + return this.partRepository.create({ ...dto }); + } + + async findParts(filters: { category?: string; lowStock?: boolean }) { + return this.partRepository.findFiltered(filters); + } + + async updatePart(id: string, dto: UpdatePartDto): Promise { + const part = await this.partRepository.findById(id); + if (!part) throw new NotFoundException(`Part ${id} not found`); + const updated = await this.partRepository.update(id, { ...dto }); + return updated!; + } + + async deletePart(id: string): Promise<{ id: string; deleted: boolean }> { + const part = await this.partRepository.findById(id); + if (!part) throw new NotFoundException(`Part ${id} not found`); + await this.partRepository.softDelete(id); + return { id, deleted: true }; + } + + // ---- Warranties ---- + + async createWarranty(dto: CreateWarrantyDto): Promise { + return this.warrantyRepository.create({ ...dto }); + } + + async findWarranties(filters: { vehicleId?: string }) { + return this.warrantyRepository.findFiltered(filters); + } + + async deleteWarranty(id: string): Promise<{ id: string; deleted: boolean }> { + const warranty = await this.warrantyRepository.findById(id); + if (!warranty) throw new NotFoundException(`Warranty ${id} not found`); + await this.warrantyRepository.softDelete(id); + return { id, deleted: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts index de5ff08f2..9fadea59b 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts @@ -1,12 +1,24 @@ -import { Controller, Post, Get, Patch, Body, Param } from '@nestjs/common'; +import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { MaintenanceService } from './maintenance.service'; +import { MaintenanceDepthService } from './maintenance-depth.service'; import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; +import { + CreateWorkOrderDto, + UpdateWorkOrderDto, + CreatePartDto, + UpdatePartDto, + CreateWarrantyDto, +} from './dto/create-maintenance-depth.dto'; +import { WorkOrderStatus } from './entities/work-order.entity'; @ApiTags('Maintenance Management') @Controller('maintenance') export class MaintenanceController { - constructor(private readonly maintenanceService: MaintenanceService) {} + constructor( + private readonly maintenanceService: MaintenanceService, + private readonly maintenanceDepthService: MaintenanceDepthService, + ) {} @Post('schedules') @ApiOperation({ summary: 'Schedule maintenance' }) @@ -49,4 +61,91 @@ export class MaintenanceController { async getStats(@Param('vehicleId') vehicleId: string) { return this.maintenanceService.getVehicleMaintenanceStats(vehicleId); } + + // ---- Work Orders ---- + + @Post('work-orders') + @ApiOperation({ summary: 'Create work order' }) + async createWorkOrder(@Body() dto: CreateWorkOrderDto) { + return this.maintenanceDepthService.createWorkOrder(dto); + } + + @Get('work-orders') + @ApiOperation({ summary: 'List work orders' }) + async listWorkOrders( + @Query('vehicleId') vehicleId?: string, + @Query('status') status?: WorkOrderStatus, + ) { + return this.maintenanceDepthService.findWorkOrders({ vehicleId, status }); + } + + @Get('work-orders/:id') + @ApiOperation({ summary: 'Get work order' }) + async getWorkOrder(@Param('id') id: string) { + return this.maintenanceDepthService.findWorkOrderById(id); + } + + @Patch('work-orders/:id') + @ApiOperation({ summary: 'Update work order' }) + async updateWorkOrder(@Param('id') id: string, @Body() dto: UpdateWorkOrderDto) { + return this.maintenanceDepthService.updateWorkOrder(id, dto); + } + + @Delete('work-orders/:id') + @ApiOperation({ summary: 'Delete work order' }) + async deleteWorkOrder(@Param('id') id: string) { + return this.maintenanceDepthService.deleteWorkOrder(id); + } + + // ---- Parts / Tires ---- + + @Post('parts') + @ApiOperation({ summary: 'Create part' }) + async createPart(@Body() dto: CreatePartDto) { + return this.maintenanceDepthService.createPart(dto); + } + + @Get('parts') + @ApiOperation({ summary: 'List parts / tire inventory' }) + async listParts( + @Query('category') category?: string, + @Query('lowStock') lowStock?: string, + ) { + return this.maintenanceDepthService.findParts({ + category, + lowStock: lowStock === 'true', + }); + } + + @Patch('parts/:id') + @ApiOperation({ summary: 'Update part' }) + async updatePart(@Param('id') id: string, @Body() dto: UpdatePartDto) { + return this.maintenanceDepthService.updatePart(id, dto); + } + + @Delete('parts/:id') + @ApiOperation({ summary: 'Delete part' }) + async deletePart(@Param('id') id: string) { + return this.maintenanceDepthService.deletePart(id); + } + + // ---- Warranties ---- + + @Post('warranties') + @ApiOperation({ summary: 'Create warranty' }) + async createWarranty(@Body() dto: CreateWarrantyDto) { + return this.maintenanceDepthService.createWarranty(dto); + } + + @Get('warranties') + @ApiOperation({ summary: 'List warranties' }) + async listWarranties(@Query('vehicleId') vehicleId?: string) { + return this.maintenanceDepthService.findWarranties({ vehicleId }); + } + + @Delete('warranties/:id') + @ApiOperation({ summary: 'Delete warranty' }) + async deleteWarranty(@Param('id') id: string) { + return this.maintenanceDepthService.deleteWarranty(id); + } } diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts index a0227a733..8f4fe1d0b 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts @@ -2,14 +2,30 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MaintenanceSchedule } from './entities/maintenance-schedule.entity'; import { MaintenanceCost } from './entities/maintenance-cost.entity'; +import { WorkOrder } from './entities/work-order.entity'; +import { Part } from './entities/part.entity'; +import { Warranty } from './entities/warranty.entity'; import { MaintenanceService } from './maintenance.service'; +import { MaintenanceDepthService } from './maintenance-depth.service'; import { MaintenanceRepository } from './maintenance.repository'; +import { WorkOrderRepository } from './work-order.repository'; +import { PartRepository } from './part.repository'; +import { WarrantyRepository } from './warranty.repository'; import { MaintenanceController } from './maintenance.controller'; @Module({ - imports: [TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost])], - providers: [MaintenanceService, MaintenanceRepository], + imports: [ + TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]), + ], + providers: [ + MaintenanceService, + MaintenanceDepthService, + MaintenanceRepository, + WorkOrderRepository, + PartRepository, + WarrantyRepository, + ], controllers: [MaintenanceController], - exports: [MaintenanceService], + exports: [MaintenanceService, MaintenanceDepthService], }) export class MaintenanceModule {} diff --git a/apps/edr-freight-api/src/modules/maintenance/part.repository.ts b/apps/edr-freight-api/src/modules/maintenance/part.repository.ts new file mode 100644 index 000000000..d6b221332 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/part.repository.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository } from 'typeorm'; +import { Part } from './entities/part.entity'; + +@Injectable() +export class PartRepository extends BaseRepository { + constructor( + @InjectRepository(Part) + private readonly partRepository: Repository, + ) { + super(partRepository); + } + + async findFiltered(filters: { category?: string; lowStock?: boolean }) { + const qb = this.partRepository.createQueryBuilder('part'); + if (filters.category) { + qb.andWhere('part.category = :category', { category: filters.category }); + } + if (filters.lowStock) { + qb.andWhere('part.quantityInStock <= part.reorderLevel'); + } + qb.orderBy('part.name', 'ASC'); + return qb.getMany(); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/warranty.repository.ts b/apps/edr-freight-api/src/modules/maintenance/warranty.repository.ts new file mode 100644 index 000000000..e59bd358d --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/warranty.repository.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository, FindOptionsWhere } from 'typeorm'; +import { Warranty } from './entities/warranty.entity'; + +@Injectable() +export class WarrantyRepository extends BaseRepository { + constructor( + @InjectRepository(Warranty) + private readonly warrantyRepository: Repository, + ) { + super(warrantyRepository); + } + + async findFiltered(filters: { vehicleId?: string }) { + const where: FindOptionsWhere = {}; + if (filters.vehicleId) where.vehicleId = filters.vehicleId; + return this.warrantyRepository.find({ + where, + order: { expiryDate: 'ASC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/work-order.repository.ts b/apps/edr-freight-api/src/modules/maintenance/work-order.repository.ts new file mode 100644 index 000000000..057f1fa6d --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/work-order.repository.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository, FindOptionsWhere } from 'typeorm'; +import { WorkOrder, WorkOrderStatus } from './entities/work-order.entity'; + +@Injectable() +export class WorkOrderRepository extends BaseRepository { + constructor( + @InjectRepository(WorkOrder) + private readonly workOrderRepository: Repository, + ) { + super(workOrderRepository); + } + + async findFiltered(filters: { vehicleId?: string; status?: WorkOrderStatus }) { + const where: FindOptionsWhere = {}; + if (filters.vehicleId) where.vehicleId = filters.vehicleId; + if (filters.status) where.status = filters.status; + return this.workOrderRepository.find({ + where, + order: { openedAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts b/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts new file mode 100644 index 000000000..943d79296 --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts @@ -0,0 +1,193 @@ +import { + IsUUID, + IsString, + IsDateString, + IsNumber, + IsInt, + IsOptional, + IsEnum, + IsBoolean, +} from 'class-validator'; +import { VendorType } from '../entities/vendor.entity'; +import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity'; +import { DisposalMethod } from '../entities/asset-disposal.entity'; + +export class CreateVendorDto { + @IsString() + name!: string; + + @IsOptional() + @IsEnum(VendorType) + type?: VendorType; + + @IsOptional() + @IsString() + contactPerson?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + email?: string; + + @IsOptional() + @IsString() + address?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateVendorDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsEnum(VendorType) + type?: VendorType; + + @IsOptional() + @IsString() + contactPerson?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + email?: string; + + @IsOptional() + @IsString() + address?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class CreateAcquisitionDto { + @IsOptional() + @IsUUID() + vehicleId?: string; + + @IsOptional() + @IsUUID() + vendorId?: string; + + @IsEnum(AcquisitionType) + acquisitionType!: AcquisitionType; + + @IsDateString() + acquisitionDate!: string; + + @IsOptional() + @IsNumber() + cost?: number; + + @IsOptional() + @IsInt() + usefulLifeMonths?: number; + + @IsOptional() + @IsNumber() + salvageValue?: number; + + @IsOptional() + @IsDateString() + leaseStart?: string; + + @IsOptional() + @IsDateString() + leaseEnd?: string; + + @IsOptional() + @IsNumber() + monthlyPayment?: number; + + @IsOptional() + @IsEnum(AcquisitionStatus) + status?: AcquisitionStatus; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateAcquisitionDto { + @IsOptional() + @IsUUID() + vehicleId?: string; + + @IsOptional() + @IsUUID() + vendorId?: string; + + @IsOptional() + @IsEnum(AcquisitionType) + acquisitionType?: AcquisitionType; + + @IsOptional() + @IsDateString() + acquisitionDate?: string; + + @IsOptional() + @IsNumber() + cost?: number; + + @IsOptional() + @IsInt() + usefulLifeMonths?: number; + + @IsOptional() + @IsNumber() + salvageValue?: number; + + @IsOptional() + @IsDateString() + leaseStart?: string; + + @IsOptional() + @IsDateString() + leaseEnd?: string; + + @IsOptional() + @IsNumber() + monthlyPayment?: number; + + @IsOptional() + @IsEnum(AcquisitionStatus) + status?: AcquisitionStatus; + + @IsOptional() + @IsString() + notes?: string; +} + +export class CreateDisposalDto { + @IsUUID() + vehicleId!: string; + + @IsDateString() + disposalDate!: string; + + @IsEnum(DisposalMethod) + method!: DisposalMethod; + + @IsOptional() + @IsNumber() + salePrice?: number; + + @IsOptional() + @IsString() + buyer?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts b/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts new file mode 100644 index 000000000..d4f781c15 --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts @@ -0,0 +1,64 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { Vendor } from './vendor.entity'; + +export enum AcquisitionType { + PURCHASE = 'PURCHASE', + LEASE = 'LEASE', + RENTAL = 'RENTAL', +} + +export enum AcquisitionStatus { + ACTIVE = 'ACTIVE', + LEASE_EXPIRING = 'LEASE_EXPIRING', + DISPOSED = 'DISPOSED', +} + +@Entity({ name: 'asset_acquisitions', schema: 'freight' }) +@Index(['vehicleId', 'acquisitionDate']) +export class AssetAcquisition extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string; + + @ManyToOne(() => Vehicle, { eager: false, nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle; + + @Column({ name: 'vendor_id', type: 'uuid', nullable: true }) + vendorId?: string; + + @ManyToOne(() => Vendor, { eager: false, nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'vendor_id' }) + vendor?: Vendor; + + @Column({ name: 'acquisition_type', type: 'varchar' }) + acquisitionType!: AcquisitionType; + + @Column({ name: 'acquisition_date', type: 'date' }) + acquisitionDate!: string; + + @Column({ name: 'cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + cost?: number; + + @Column({ name: 'useful_life_months', type: 'int', nullable: true }) + usefulLifeMonths?: number; + + @Column({ name: 'salvage_value', type: 'numeric', precision: 14, scale: 2, nullable: true }) + salvageValue?: number; + + @Column({ name: 'lease_start', type: 'date', nullable: true }) + leaseStart?: string; + + @Column({ name: 'lease_end', type: 'date', nullable: true }) + leaseEnd?: string; + + @Column({ name: 'monthly_payment', type: 'numeric', precision: 14, scale: 2, nullable: true }) + monthlyPayment?: number; + + @Column({ name: 'status', type: 'varchar', default: AcquisitionStatus.ACTIVE }) + status!: AcquisitionStatus; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/procurement/entities/asset-disposal.entity.ts b/apps/edr-freight-api/src/modules/procurement/entities/asset-disposal.entity.ts new file mode 100644 index 000000000..301e3ec1c --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/entities/asset-disposal.entity.ts @@ -0,0 +1,31 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, Index } from 'typeorm'; + +export enum DisposalMethod { + SALE = 'SALE', + SCRAP = 'SCRAP', + RETURN_LEASE = 'RETURN_LEASE', + TRADE_IN = 'TRADE_IN', +} + +@Entity({ name: 'asset_disposals', schema: 'freight' }) +@Index(['vehicleId', 'disposalDate']) +export class AssetDisposal extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @Column({ name: 'disposal_date', type: 'date' }) + disposalDate!: string; + + @Column({ name: 'method', type: 'varchar' }) + method!: DisposalMethod; + + @Column({ name: 'sale_price', type: 'numeric', precision: 14, scale: 2, nullable: true }) + salePrice?: number; + + @Column({ name: 'buyer', nullable: true }) + buyer?: string; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/procurement/entities/vendor.entity.ts b/apps/edr-freight-api/src/modules/procurement/entities/vendor.entity.ts new file mode 100644 index 000000000..cbe394d16 --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/entities/vendor.entity.ts @@ -0,0 +1,34 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column } from 'typeorm'; + +export enum VendorType { + DEALER = 'DEALER', + LEASING = 'LEASING', + PARTS = 'PARTS', + SERVICE = 'SERVICE', + OTHER = 'OTHER', +} + +@Entity({ name: 'vendors', schema: 'freight' }) +export class Vendor extends BaseEntity { + @Column({ name: 'name' }) + name!: string; + + @Column({ name: 'type', type: 'varchar', nullable: true }) + type?: VendorType; + + @Column({ name: 'contact_person', nullable: true }) + contactPerson?: string; + + @Column({ name: 'phone', nullable: true }) + phone?: string; + + @Column({ name: 'email', nullable: true }) + email?: string; + + @Column({ name: 'address', nullable: true }) + address?: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts b/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts new file mode 100644 index 000000000..e5c69f37c --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts @@ -0,0 +1,98 @@ +import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { ProcurementService } from './procurement.service'; +import { + CreateVendorDto, + UpdateVendorDto, + CreateAcquisitionDto, + UpdateAcquisitionDto, + CreateDisposalDto, +} from './dto/procurement.dto'; + +@ApiTags('Procurement & Asset Lifecycle') +@Controller('procurement') +export class ProcurementController { + constructor(private readonly procurementService: ProcurementService) {} + + // ---- Vendors ---- + @Post('vendors') + @ApiOperation({ summary: 'Create a vendor' }) + async createVendor(@Body() dto: CreateVendorDto) { + return this.procurementService.createVendor(dto); + } + + @Get('vendors') + @ApiOperation({ summary: 'List vendors' }) + async listVendors() { + return this.procurementService.listVendors(); + } + + @Patch('vendors/:id') + @ApiOperation({ summary: 'Update a vendor' }) + async updateVendor(@Param('id') id: string, @Body() dto: UpdateVendorDto) { + return this.procurementService.updateVendor(id, dto); + } + + @Delete('vendors/:id') + @ApiOperation({ summary: 'Delete a vendor' }) + async deleteVendor(@Param('id') id: string) { + return this.procurementService.deleteVendor(id); + } + + // ---- Acquisitions ---- + @Post('acquisitions') + @ApiOperation({ summary: 'Create an asset acquisition' }) + async createAcquisition(@Body() dto: CreateAcquisitionDto) { + return this.procurementService.createAcquisition(dto); + } + + @Get('acquisitions') + @ApiOperation({ summary: 'List asset acquisitions (optionally filtered by vehicleId)' }) + async listAcquisitions(@Query('vehicleId') vehicleId?: string) { + return this.procurementService.listAcquisitions(vehicleId); + } + + @Get('acquisitions/:id') + @ApiOperation({ summary: 'Get an asset acquisition by id' }) + async getAcquisition(@Param('id') id: string) { + return this.procurementService.getAcquisition(id); + } + + @Patch('acquisitions/:id') + @ApiOperation({ summary: 'Update an asset acquisition' }) + async updateAcquisition(@Param('id') id: string, @Body() dto: UpdateAcquisitionDto) { + return this.procurementService.updateAcquisition(id, dto); + } + + @Delete('acquisitions/:id') + @ApiOperation({ summary: 'Delete an asset acquisition' }) + async deleteAcquisition(@Param('id') id: string) { + return this.procurementService.deleteAcquisition(id); + } + + // ---- Disposals ---- + @Post('disposals') + @ApiOperation({ summary: 'Create an asset disposal' }) + async createDisposal(@Body() dto: CreateDisposalDto) { + return this.procurementService.createDisposal(dto); + } + + @Get('disposals') + @ApiOperation({ summary: 'List asset disposals' }) + async listDisposals() { + return this.procurementService.listDisposals(); + } + + @Delete('disposals/:id') + @ApiOperation({ summary: 'Delete an asset disposal' }) + async deleteDisposal(@Param('id') id: string) { + return this.procurementService.deleteDisposal(id); + } + + // ---- Lifecycle ---- + @Get('lifecycle/:vehicleId') + @ApiOperation({ summary: 'Get asset lifecycle (acquisition, disposal, depreciation) for a vehicle' }) + async lifecycle(@Param('vehicleId') vehicleId: string) { + return this.procurementService.lifecycle(vehicleId); + } +} diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.module.ts b/apps/edr-freight-api/src/modules/procurement/procurement.module.ts new file mode 100644 index 000000000..d4b0d8315 --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Vendor } from './entities/vendor.entity'; +import { AssetAcquisition } from './entities/asset-acquisition.entity'; +import { AssetDisposal } from './entities/asset-disposal.entity'; +import { ProcurementService } from './procurement.service'; +import { ProcurementRepository } from './procurement.repository'; +import { ProcurementController } from './procurement.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Vendor, AssetAcquisition, AssetDisposal])], + providers: [ProcurementService, ProcurementRepository], + controllers: [ProcurementController], + exports: [ProcurementService], +}) +export class ProcurementModule {} diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.repository.ts b/apps/edr-freight-api/src/modules/procurement/procurement.repository.ts new file mode 100644 index 000000000..1a049d52b --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.repository.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { DeepPartial, Repository } from 'typeorm'; +import { Vendor } from './entities/vendor.entity'; +import { AssetAcquisition } from './entities/asset-acquisition.entity'; +import { AssetDisposal } from './entities/asset-disposal.entity'; + +@Injectable() +export class ProcurementRepository extends BaseRepository { + constructor( + @InjectRepository(AssetAcquisition) + private readonly acquisitionRepository: Repository, + @InjectRepository(Vendor) + private readonly vendorRepository: Repository, + @InjectRepository(AssetDisposal) + private readonly disposalRepository: Repository, + ) { + super(acquisitionRepository); + } + + // ---- Vendors ---- + async createVendor(data: DeepPartial): Promise { + const vendor = this.vendorRepository.create(data); + return this.vendorRepository.save(vendor); + } + + async findVendors(): Promise { + return this.vendorRepository.find({ order: { createdAt: 'DESC' } }); + } + + async updateVendor(id: string, data: DeepPartial): Promise { + await this.vendorRepository.update(id, data as never); + return this.vendorRepository.findOneBy({ id }); + } + + async softDeleteVendor(id: string): Promise { + await this.vendorRepository.softDelete(id); + } + + // ---- Acquisitions ---- + async createAcquisition(data: DeepPartial): Promise { + const acquisition = this.acquisitionRepository.create(data); + return this.acquisitionRepository.save(acquisition); + } + + async findAcquisitions(vehicleId?: string): Promise { + return this.acquisitionRepository.find({ + where: vehicleId ? { vehicleId } : {}, + relations: ['vehicle', 'vendor'], + order: { acquisitionDate: 'DESC' }, + }); + } + + async findAcquisitionById(id: string): Promise { + return this.acquisitionRepository.findOne({ + where: { id }, + relations: ['vehicle', 'vendor'], + }); + } + + async updateAcquisition( + id: string, + data: DeepPartial, + ): Promise { + await this.acquisitionRepository.update(id, data as never); + return this.findAcquisitionById(id); + } + + async softDeleteAcquisition(id: string): Promise { + await this.acquisitionRepository.softDelete(id); + } + + async findLatestAcquisitionByVehicle(vehicleId: string): Promise { + return this.acquisitionRepository.findOne({ + where: { vehicleId }, + relations: ['vehicle', 'vendor'], + order: { acquisitionDate: 'DESC' }, + }); + } + + // ---- Disposals ---- + async createDisposal(data: DeepPartial): Promise { + const disposal = this.disposalRepository.create(data); + return this.disposalRepository.save(disposal); + } + + async findDisposals(): Promise { + return this.disposalRepository.find({ order: { disposalDate: 'DESC' } }); + } + + async softDeleteDisposal(id: string): Promise { + await this.disposalRepository.softDelete(id); + } + + async findLatestDisposalByVehicle(vehicleId: string): Promise { + return this.disposalRepository.findOne({ + where: { vehicleId }, + order: { disposalDate: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.service.ts b/apps/edr-freight-api/src/modules/procurement/procurement.service.ts new file mode 100644 index 000000000..e799d5ff9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.service.ts @@ -0,0 +1,143 @@ +import { Injectable } from '@nestjs/common'; +import { ProcurementRepository } from './procurement.repository'; +import { Vendor } from './entities/vendor.entity'; +import { AssetAcquisition } from './entities/asset-acquisition.entity'; +import { AssetDisposal } from './entities/asset-disposal.entity'; +import { + CreateVendorDto, + UpdateVendorDto, + CreateAcquisitionDto, + UpdateAcquisitionDto, + CreateDisposalDto, +} from './dto/procurement.dto'; + +export interface DepreciationResult { + method: 'STRAIGHT_LINE'; + cost: number; + salvageValue: number; + usefulLifeMonths: number; + monthsElapsed: number; + monthlyDepreciation: number; + bookValue: number; +} + +export interface LifecycleResult { + vehicleId: string; + acquisition: AssetAcquisition | null; + disposal: AssetDisposal | null; + depreciation: DepreciationResult | null; +} + +@Injectable() +export class ProcurementService { + constructor(private readonly procurementRepository: ProcurementRepository) {} + + // ---- Vendors ---- + async createVendor(dto: CreateVendorDto): Promise { + return this.procurementRepository.createVendor(dto); + } + + async listVendors(): Promise { + return this.procurementRepository.findVendors(); + } + + async updateVendor(id: string, dto: UpdateVendorDto): Promise { + return this.procurementRepository.updateVendor(id, dto); + } + + async deleteVendor(id: string): Promise<{ success: boolean }> { + await this.procurementRepository.softDeleteVendor(id); + return { success: true }; + } + + // ---- Acquisitions ---- + async createAcquisition(dto: CreateAcquisitionDto): Promise { + return this.procurementRepository.createAcquisition(dto); + } + + async listAcquisitions(vehicleId?: string): Promise { + return this.procurementRepository.findAcquisitions(vehicleId); + } + + async getAcquisition(id: string): Promise { + return this.procurementRepository.findAcquisitionById(id); + } + + async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise { + return this.procurementRepository.updateAcquisition(id, dto); + } + + async deleteAcquisition(id: string): Promise<{ success: boolean }> { + await this.procurementRepository.softDeleteAcquisition(id); + return { success: true }; + } + + // ---- Disposals ---- + async createDisposal(dto: CreateDisposalDto): Promise { + return this.procurementRepository.createDisposal(dto); + } + + async listDisposals(): Promise { + return this.procurementRepository.findDisposals(); + } + + async deleteDisposal(id: string): Promise<{ success: boolean }> { + await this.procurementRepository.softDeleteDisposal(id); + return { success: true }; + } + + // ---- Lifecycle ---- + async lifecycle(vehicleId: string): Promise { + const acquisition = await this.procurementRepository.findLatestAcquisitionByVehicle(vehicleId); + const disposal = await this.procurementRepository.findLatestDisposalByVehicle(vehicleId); + + return { + vehicleId, + acquisition, + disposal, + depreciation: this.computeStraightLineDepreciation(acquisition), + }; + } + + /** + * Straight-line depreciation. Requires a cost and a positive useful life. + * monthlyDep = (cost - salvageValue) / usefulLifeMonths + * bookValue = cost - monthlyDep * monthsElapsedSinceAcquisition, floored at salvageValue. + */ + private computeStraightLineDepreciation( + acquisition: AssetAcquisition | null, + ): DepreciationResult | null { + if (!acquisition) return null; + + const cost = acquisition.cost != null ? Number(acquisition.cost) : null; + const usefulLifeMonths = + acquisition.usefulLifeMonths != null ? Number(acquisition.usefulLifeMonths) : null; + + if (cost == null || usefulLifeMonths == null || usefulLifeMonths <= 0) { + return null; + } + + const salvageValue = acquisition.salvageValue != null ? Number(acquisition.salvageValue) : 0; + const monthlyDepreciation = (cost - salvageValue) / usefulLifeMonths; + + const acquiredAt = new Date(acquisition.acquisitionDate); + const now = new Date(); + const monthsElapsed = Math.max( + 0, + (now.getFullYear() - acquiredAt.getFullYear()) * 12 + + (now.getMonth() - acquiredAt.getMonth()), + ); + + const bookValue = Math.max(cost - monthlyDepreciation * monthsElapsed, salvageValue); + + return { + method: 'STRAIGHT_LINE', + cost, + salvageValue, + usefulLifeMonths, + monthsElapsed, + monthlyDepreciation, + bookValue, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index 416cddee6..a1299c481 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -88,4 +88,21 @@ export class Vehicle extends BaseEntity { @Column({ name: 'location_id', type: 'uuid', nullable: true }) locationId?: string; + + // --- Compliance / expiry tracking --- + @Column({ name: 'vin', type: 'varchar', nullable: true }) + vin?: string; + + /** Owned | Leased | Rented */ + @Column({ name: 'ownership', type: 'varchar', nullable: true }) + ownership?: string; + + @Column({ name: 'insurance_expiry', type: 'date', nullable: true }) + insuranceExpiry?: string; + + @Column({ name: 'registration_expiry', type: 'date', nullable: true }) + registrationExpiry?: string; + + @Column({ name: 'next_inspection_date', type: 'date', nullable: true }) + nextInspectionDate?: string; } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 92784cd38..611a06691 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -93,6 +93,10 @@ import { MaintenancePage } from "./pages/fleet/MaintenancePage"; import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage"; import { FleetDashboard } from "./pages/fleet/FleetDashboard"; import { TrackingPage } from "./pages/fleet/TrackingPage"; +import CompliancePage from "./pages/fleet/CompliancePage"; +import IncidentsPage from "./pages/fleet/IncidentsPage"; +import WorkOrdersPage from "./pages/fleet/WorkOrdersPage"; +import ProcurementPage from "./pages/fleet/ProcurementPage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; @@ -290,6 +294,30 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.fleet.view, }, + { + label: "Work Orders", + href: "/dashboard/work-orders", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Compliance & Alerts", + href: "/dashboard/compliance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Incidents", + href: "/dashboard/incidents", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Procurement", + href: "/dashboard/procurement", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, { label: "Financial Reports", href: "/dashboard/financial-reports", @@ -1058,6 +1086,38 @@ const App = () => { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + severity === "OVERDUE" ? "red" : "yellow"; + +const statusColor = (status: ComplianceRecord["status"]) => { + if (status === "EXPIRED") return "red"; + if (status === "EXPIRING") return "yellow"; + return "green"; +}; + +const formatDate = (value?: string | null) => + value ? new Date(value).toLocaleDateString() : "—"; + +const emptyForm = { + vehicleId: "", + type: "INSPECTION" as ComplianceType, + documentNumber: "", + issuedDate: "", + expiryDate: new Date().toISOString().split("T")[0], + notes: "", +}; + +export default function CompliancePage() { + const { toast } = useToast(); + const qc = useQueryClient(); + const [modalOpen, setModalOpen] = useState(false); + const [formData, setFormData] = useState(emptyForm); + + const { data: vehiclesData } = useQuery({ + queryKey: ["vehicles", "compliance-select"], + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, + }); + + const { data: alerts = [], isLoading: isLoadingAlerts } = useQuery({ + queryKey: ["compliance", "alerts"], + queryFn: async () => { + const res = await complianceService.getAlerts(); + return res.data || []; + }, + }); + + const { data: records = [], isLoading: isLoadingRecords } = useQuery({ + queryKey: ["compliance"], + queryFn: async () => { + const res = await complianceService.list(); + return res.data || []; + }, + }); + + const createMutation = useMutation({ + mutationFn: async (data: typeof formData) => { + const res = await complianceService.create({ + vehicleId: data.vehicleId, + type: data.type, + expiryDate: data.expiryDate, + documentNumber: data.documentNumber || undefined, + issuedDate: data.issuedDate || undefined, + notes: data.notes || undefined, + }); + return res.data; + }, + onSuccess: () => { + toast({ title: "Compliance record created" }); + setModalOpen(false); + setFormData(emptyForm); + qc.invalidateQueries({ queryKey: ["compliance"] }); + qc.invalidateQueries({ queryKey: ["compliance", "alerts"] }); + }, + onError: (error: any) => { + toast({ + title: "Error creating record", + description: + error?.response?.data?.message || "Failed to create compliance record", + variant: "destructive", + }); + }, + }); + + const vehicleOptions = + vehiclesData?.map((v: VehicleType) => ({ + value: v.id, + label: `${v.plateNumber ?? v.code ?? v.id} - ${v.manufacturer ?? ""} ${v.model ?? ""}`.trim(), + })) || []; + + const vehicleLabel = (record: ComplianceRecord) => + record.vehicle?.plateNumber || + vehiclesData?.find((v) => v.id === record.vehicleId)?.plateNumber || + record.vehicleId; + + const overdueCount = (alerts as ComplianceAlert[]).filter( + (a) => a.severity === "OVERDUE", + ).length; + const dueSoonCount = (alerts as ComplianceAlert[]).filter( + (a) => a.severity === "DUE_SOON", + ).length; + + return ( + + + + + Compliance & Alerts + + + + {/* Alerts */} + + + Expiry Alerts + {overdueCount > 0 && ( + + {overdueCount} overdue + + )} + {dueSoonCount > 0 && ( + + {dueSoonCount} due soon + + )} + + + {isLoadingAlerts ? ( + + + + ) : (alerts as ComplianceAlert[]).length === 0 ? ( + + + No compliance items are overdue or due soon. All clear. + + + ) : ( + + {(alerts as ComplianceAlert[]).map((alert, index) => ( + + + + + {alert.severity === "OVERDUE" ? "Overdue" : "Due Soon"} + + + {alert.daysUntil < 0 + ? `${Math.abs(alert.daysUntil)}d ago` + : `in ${alert.daysUntil}d`} + + + {alert.label} + + {alert.vehiclePlate || alert.vehicleId} + + + Expires {formatDate(alert.expiryDate)} + + + + ))} + + )} + + {/* Records */} + + Compliance Records + + + + + + Vehicle + Type + Document # + Issued + Expiry + Status + + + + {isLoadingRecords ? ( + + + + + + + + ) : (records as ComplianceRecord[]).length === 0 ? ( + + + + No compliance records yet. + + + + ) : null} + {(records as ComplianceRecord[]).map((record) => ( + + {vehicleLabel(record)} + + + {record.type} + + + {record.documentNumber || "—"} + {formatDate(record.issuedDate)} + {formatDate(record.expiryDate)} + + + {record.status} + + + + ))} + +
+
+ + {/* Modal */} + setModalOpen(false)} + title="New Compliance Record" + size="lg" + > + + ({ value: t, label: t }))} + value={formData.type} + onChange={(val) => + setFormData({ ...formData, type: (val as ComplianceType) || "INSPECTION" }) + } + required + /> + + + setFormData({ ...formData, documentNumber: e.currentTarget.value }) + } + /> + + + setFormData({ ...formData, issuedDate: e.currentTarget.value }) + } + /> + + + setFormData({ ...formData, expiryDate: e.currentTarget.value }) + } + required + /> + + setFormData({ ...formData, notes: e.currentTarget.value })} + /> + + + + + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/IncidentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/IncidentsPage.tsx new file mode 100644 index 000000000..2d947b73a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/IncidentsPage.tsx @@ -0,0 +1,399 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Button, + Card, + Container, + Group, + Loader, + Modal, + NumberInput, + Select, + Stack, + Table, + Text, + Textarea, + TextInput, + Title, + Badge, + Grid, +} from "@mantine/core"; +import { Plus } from "lucide-react"; +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { useToast } from "@/hooks/use-toast"; +import { + incidentsService, + type Incident, + type IncidentSeverity, + type IncidentStatus, + type IncidentType, + type SaveIncidentPayload, +} from "@/services/incidents.service"; +import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service"; +import { driversService, type Driver as DriverType } from "@/services/drivers.service"; + +const TYPE_OPTIONS: IncidentType[] = [ + "ACCIDENT", + "BREAKDOWN", + "TRAFFIC_VIOLATION", + "THEFT", + "OTHER", +]; +const SEVERITY_OPTIONS: IncidentSeverity[] = ["MINOR", "MODERATE", "MAJOR", "CRITICAL"]; + +const TYPE_COLORS: Record = { + ACCIDENT: "red", + BREAKDOWN: "orange", + TRAFFIC_VIOLATION: "yellow", + THEFT: "grape", + OTHER: "gray", +}; + +const SEVERITY_COLORS: Record = { + MINOR: "gray", + MODERATE: "yellow", + MAJOR: "orange", + CRITICAL: "red", +}; + +const STATUS_COLORS: Record = { + REPORTED: "blue", + UNDER_REVIEW: "yellow", + CLAIM_FILED: "grape", + RESOLVED: "teal", + CLOSED: "gray", +}; + +const OPEN_STATUSES: IncidentStatus[] = ["REPORTED", "UNDER_REVIEW", "CLAIM_FILED"]; + +const formatMoney = (value: unknown) => + `ETB ${(Number(value) || 0).toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + +const initialForm = { + type: "ACCIDENT" as IncidentType, + severity: "MINOR" as IncidentSeverity, + occurredAt: new Date().toISOString().split("T")[0], + vehicleId: "", + driverId: "", + location: "", + description: "", + damageEstimate: undefined as number | undefined, + reportedBy: "", +}; + +export default function IncidentsPage() { + const { toast } = useToast(); + const qc = useQueryClient(); + const [modalOpen, setModalOpen] = useState(false); + const [formData, setFormData] = useState(initialForm); + + // Fetch vehicles + const { data: vehiclesData } = useQuery({ + queryKey: ["vehicles", "incidents-select"], + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, + }); + + // Fetch drivers + const { data: driversData } = useQuery({ + queryKey: ["drivers", "incidents-select"], + queryFn: async () => { + const res = await driversService.getAll({ limit: 1000 }); + return res.data || []; + }, + }); + + // Fetch incidents + const { data: incidentsData = [], isLoading } = useQuery({ + queryKey: ["incidents"], + queryFn: async () => { + const res = await incidentsService.getAll(); + return res.data || []; + }, + }); + + const createMutation = useMutation({ + mutationFn: async (data: typeof formData) => { + const payload: SaveIncidentPayload = { + type: data.type, + severity: data.severity, + occurredAt: new Date(data.occurredAt).toISOString(), + description: data.description, + }; + if (data.vehicleId) payload.vehicleId = data.vehicleId; + if (data.driverId) payload.driverId = data.driverId; + if (data.location) payload.location = data.location; + if (data.damageEstimate != null) payload.damageEstimate = Number(data.damageEstimate); + if (data.reportedBy) payload.reportedBy = data.reportedBy; + const res = await incidentsService.create(payload); + return res.data; + }, + onSuccess: () => { + toast({ title: "Incident reported" }); + setModalOpen(false); + setFormData(initialForm); + qc.invalidateQueries({ queryKey: ["incidents"] }); + }, + onError: (error: any) => { + toast({ + title: "Error reporting incident", + description: error?.response?.data?.message || "Failed to report incident", + variant: "destructive", + }); + }, + }); + + const vehicleOptions = + vehiclesData?.map((v: VehicleType) => ({ + value: v.id, + label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`, + })) || []; + + const driverOptions = + driversData?.map((d: DriverType) => ({ + value: d.id, + label: `${d.firstName} ${d.lastName}${d.licenseNumber ? ` (${d.licenseNumber})` : ""}`, + })) || []; + + const incidents = incidentsData as Incident[]; + const totalCount = incidents.length; + const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length; + const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length; + const resolvedCount = incidents.filter((i) => i.status === "RESOLVED").length; + + const vehicleLabel = (incident: Incident) => + incident.vehicle?.plateNumber || + incident.vehicle?.registrationNumber || + incident.vehicleId || + "—"; + + const driverLabel = (incident: Incident) => { + if (incident.driver) { + const name = `${incident.driver.firstName ?? ""} ${incident.driver.lastName ?? ""}`.trim(); + if (name) return name; + } + return incident.driverId || "—"; + }; + + return ( + + + + + Accidents & Incidents + + + + {/* Stats Cards */} + + + + + Total Incidents + + + {totalCount} + + + + + + + Open + + + {openCount} + + + + + + + Under Review + + + {underReviewCount} + + + + + + + Resolved + + + {resolvedCount} + + + + + + {/* Incidents Table */} + + + + + Date + Type + Severity + Vehicle + Driver + Damage + Status + + + + {isLoading ? ( + + + + + + + + ) : incidents.length === 0 ? ( + + + + No incidents recorded yet. + + + + ) : null} + {incidents.map((incident) => ( + + {new Date(incident.occurredAt).toLocaleDateString()} + + + {incident.type.replace(/_/g, " ")} + + + + + {incident.severity} + + + {vehicleLabel(incident)} + {driverLabel(incident)} + + {incident.damageEstimate != null ? formatMoney(incident.damageEstimate) : "—"} + + + + {incident.status.replace(/_/g, " ")} + + + + ))} + +
+
+ + {/* Modal */} + setModalOpen(false)} title="Report Incident" size="lg"> + + ({ value: s, label: s }))} + value={formData.severity} + onChange={(val) => + setFormData({ ...formData, severity: (val as IncidentSeverity) || "MINOR" }) + } + required + /> + + setFormData({ ...formData, occurredAt: e.currentTarget.value })} + required + /> + + setFormData({ ...formData, driverId: val || "" })} + clearable + searchable + /> + +