diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 2b3b855cb..6fdaab48b 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -1,5 +1,7 @@ # Copy to .env for local/docker compose (not committed). PORT=3001 +# GT06 GPS tracker TCP listener port (raw TCP, must be reachable by tracker SIMs). 0 disables. +GT06_TCP_PORT=5023 DB_HOST=localhost DB_PORT=5433 DB_USER=postgres diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index f9107ed23..d88029a80 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -40,4 +40,6 @@ RUN addgroup --system --gid 1001 nodejs \ COPY --from=deployer --chown=nestjs:nodejs /deploy . USER nestjs EXPOSE 3001 +# GT06 GPS tracker TCP listener (raw TCP, not HTTP). Change via GT06_TCP_PORT. +EXPOSE 5023 CMD ["node", "dist/main.js"] 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 → DRAFTreference, containers, cargo modifiers, files (P)"]:::port + d2["POST /bookings/:id/generate-pricerule-engine: LIVE rates + surcharges (P)"]:::port + dW{"weight-limit-rules"}:::dec + dWx(["HARD BLOCK 400 — VGM > capacity"]):::bad + d3["POST /bookings/:id/submit → SUBMITTEDfreeze 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_APPROVALinstantiate 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_EXECUTEDenqueueScheduleProcessing (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_BATCHinvoice 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 fromOPERATION_REQUEST_PENDING here (not fromAWAITING_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 → DRAFTroutes + cargo scope + unit rates (NO quantities) (P)"]:::port + c2["generate-price → submit → SUBMITTEDfreeze 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.acceptBookingREADY_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 → RECEIVEDcapacity 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 → DISPATCHEDtrain_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.acceptBookingREADY_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 IAMGET /auth/check-availability @PublicPOST /otp/send + /otp/verify (P)"]:::port + S1 --> S2{"Identity proofing(VeriFayda)?"}:::dec + S2 -->|"Yes"| S3["POST /fayda/verification/start →/callback → /completeupsert iam.users (verified_by=fayda) (P)"]:::port + S2 -->|"No"| S4 + S3 --> S4["POST /companies/onboarding/startdraft company (placeholder TIN, PENDING) (P)"]:::port + S4 --> S4b["Wizard: PATCH /profile, /onboarding-step,upload license + docsGET /onboarding/requirements (P)"]:::port + S4b --> S5["POST /companies/onboarding/completere-validate → company+profiles = PENDING (P)"]:::port + S5 --> S6{"Backoffice reviews profilePATCH /company-profiles/:id/status (B)"}:::dec + S6 -->|"Reject / suspend"| S6x(["SUSPENDED / BLACKLISTEDcannot 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 DRAFTPOST /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 → SUBMITTEDfreeze 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 intakePOST /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_APPROVALinstantiate approval steps (B)"]:::back + C4 --> C4a{"Approval chainline → 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_CUSTOMERPOST /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 bookedunder the contract?"}:::dec + CPATH -->|"Path A: transport-only"| CPA["Ops review self-clearanceops-review → ops-finalize → SELF_CLEARED (B)then customer books directPOST /contracts/:id/bookings (P)"]:::port + CPATH -->|"Path B: GENERAL + customs"| CPB["Customer submits BookingRequestPOST /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 DRAFTPOST /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_CONSOLIDATIONwaits for partner shipment(shares a wagon) → rejoins"]):::sys + BCONS -.-> BFLOW + + %% ================= PHASE 3: PRICING & SUBMIT ================= + BFLOW["Configure shipmentfreight 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-pricerule-engine: LIVE rates + surchargesHAZARDOUS / 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 → SUBMITTEDcreate 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 intakePOST /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_APPROVALinstantiate approval steps(set validity window) (B)"]:::back + BA --> BAc{"Approval chainLINE_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_CUSTOMERPOST /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_EXECUTEDenqueue batch (skips clearance + op-request) (sys)(B)"]:::back + FEXD --> FEB + + %% ================= PHASE 6: CUSTOMS CLEARANCE ================= + CL1["AWAITING_DOCUMENTS → customer uploadsPOST /bookings/:id/clearance/documents→ DOCUMENTS_UNDER_REVIEW (P)"]:::port + CL1 --> CL2{"GL reviews each docclearance/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 reviewPOST /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 paysPOST /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 DRAFTPOST /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 → SCHEDULEDbookings → 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-createdfirstMile.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 → RECEIVEDcapacity 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 → DISPATCHEDassign 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 pageGET /tracking/:consignmentId (JWT) (P)"]:::port + TR2 --> TR3["arrive (final checkpoint) → ARRIVEDbookings 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 BLOCKEDfindBlockingInvoice / assertClearanceAllowed (B)"]:::back + DE1x --> DE1p["Customer pays storage/demurragewarehouse-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[("Postgresschema: freight")] + MINIO[("MinIOobject store")] + MQ{{"RabbitMQ"}} + PAY["(red) Payment API :3003schema: 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/initiatex-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 directlyPOST /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.* diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 39eed76ea..636bb7e05 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -50,9 +50,11 @@ "@nestjs/mapped-types": "^2.1.1", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", + "@nestjs/platform-socket.io": "^11.1.27", "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^11.4.2", "@nestjs/typeorm": "^11.0.1", + "@nestjs/websockets": "^11.1.27", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz", "amqp-connection-manager": "^5.0.0", @@ -71,6 +73,7 @@ "puppeteer": "^24.2.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.3", "typeorm": "^0.3.30" }, "devDependencies": { @@ -90,6 +93,7 @@ "@types/supertest": "^6.0.2", "@types/vorpal": "^1.12.8", "jest": "^29.7.0", + "socket.io-client": "^4.8.3", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c2a3a5abb..e10fb7e47 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -35,6 +35,7 @@ import { CompaniesModule } from "./modules/companies/companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; +import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { OtpModule } from "./modules/otp/otp.module"; @@ -79,6 +80,10 @@ 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 { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.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"; @@ -126,6 +131,7 @@ import { LoggerMiddleware } from "./logger.middleware"; TrackingModule, BillingModule, NotificationsModule, + NotificationInboxModule, FileUploadSettingsModule, DropdownSettingsModule, OtpModule, @@ -146,6 +152,10 @@ import { LoggerMiddleware } from "./logger.middleware"; DriversModule, FuelModule, MaintenanceModule, + ComplianceModule, + IncidentsModule, + ProcurementModule, + GpsTrackingModule, FirstMileModule, LastMileModule, InterchangeDocumentsModule, diff --git a/apps/edr-freight-api/src/common/derive-trade-direction.util.ts b/apps/edr-freight-api/src/common/derive-trade-direction.util.ts index b8a6f8afb..58b2d733a 100644 --- a/apps/edr-freight-api/src/common/derive-trade-direction.util.ts +++ b/apps/edr-freight-api/src/common/derive-trade-direction.util.ts @@ -1,20 +1,33 @@ -import type { ScheduleTradeDirection } from '@edr/types'; +import { YardCountry, type ScheduleTradeDirection } from '@edr/types'; type YardLike = { country?: string | null }; -/** Derive booking/schedule trade direction from origin and destination yard countries. */ +/** + * Derive trade direction from origin and destination yard countries. + * Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT, same country = + * DOMESTIC (shown as "Intercity"; scheduling/contracts reject it for now). + * Comparison is strict against the YardCountry enum values the yards table is + * constrained to; the trim/case fold only shields legacy rows. + */ export function deriveTradeDirection( originYard: YardLike, destinationYard: YardLike, ): ScheduleTradeDirection { - const originCountry = originYard.country?.trim().toLowerCase(); - const destinationCountry = destinationYard.country?.trim().toLowerCase(); + const origin = normalizeCountry(originYard.country); + const destination = normalizeCountry(destinationYard.country); - if (originCountry === 'djibouti') { + if (origin === YardCountry.DJIBOUTI && destination === YardCountry.ETHIOPIA) { return 'IMPORT'; } - if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') { + if (origin === YardCountry.ETHIOPIA && destination === YardCountry.DJIBOUTI) { return 'EXPORT'; } return 'DOMESTIC'; } + +function normalizeCountry(country: string | null | undefined): YardCountry | null { + const folded = country?.trim().toLowerCase(); + if (folded === YardCountry.ETHIOPIA.toLowerCase()) return YardCountry.ETHIOPIA; + if (folded === YardCountry.DJIBOUTI.toLowerCase()) return YardCountry.DJIBOUTI; + return null; +} 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/1950000000000-CreateNotifications.ts b/apps/edr-freight-api/src/migrations/1950000000000-CreateNotifications.ts new file mode 100644 index 000000000..c5c608a95 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1950000000000-CreateNotifications.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * In-app notification inbox. One row per recipient per logical notification; + * producers fan out by inserting many rows. Indexed for the two hot queries: + * unread-count (recipient + is_read) and the newest-first list (recipient + + * created_at). Enum-like columns are stored as varchar to avoid PG enum churn. + */ +export class CreateNotifications1950000000000 implements MigrationInterface { + name = "CreateNotifications1950000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.notifications ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + recipient_user_id uuid NOT NULL, + audience varchar(20) NOT NULL, + type varchar(48) NOT NULL DEFAULT 'GENERIC', + title varchar(200) NOT NULL, + body text NOT NULL, + link varchar, + data jsonb, + priority varchar(12) NOT NULL DEFAULT 'NORMAL', + is_read boolean NOT NULL DEFAULT false, + read_at timestamptz, + channels_sent jsonb, + 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_NOTIFICATIONS_RECIPIENT_UNREAD" + ON freight.notifications (recipient_user_id, is_read) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_NOTIFICATIONS_RECIPIENT_CREATED" + ON freight.notifications (recipient_user_id, created_at) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_NOTIFICATIONS_RECIPIENT_CREATED"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_NOTIFICATIONS_RECIPIENT_UNREAD"`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.notifications`); + } +} 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/migrations/1980000000000-YardCountryEnumAndRouteDirection.ts b/apps/edr-freight-api/src/migrations/1980000000000-YardCountryEnumAndRouteDirection.ts new file mode 100644 index 000000000..3274b4d1c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1980000000000-YardCountryEnumAndRouteDirection.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Yard country becomes a two-value enum (Ethiopia | Djibouti) and every route + * freezes its trade direction from the yard countries: + * Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT, + * same country = DOMESTIC (shown as "Intercity"; disabled for scheduling + * and contracts for now). + * + * Existing yard rows are normalized case-insensitively; anything mentioning + * Djibouti maps there, everything else maps to Ethiopia (the line only serves + * these two countries). A CHECK constraint keeps future writes honest. + */ +export class YardCountryEnumAndRouteDirection1980000000000 implements MigrationInterface { + name = 'YardCountryEnumAndRouteDirection1980000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.yards + SET country = CASE + WHEN lower(trim(country)) LIKE '%djib%' THEN 'Djibouti' + ELSE 'Ethiopia' + END + `); + await queryRunner.query(` + ALTER TABLE freight.yards + DROP CONSTRAINT IF EXISTS chk_yards_country, + ADD CONSTRAINT chk_yards_country CHECK (country IN ('Ethiopia', 'Djibouti')) + `); + + await queryRunner.query(` + ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS direction varchar(10) + `); + await queryRunner.query(` + UPDATE freight.routes r + SET direction = CASE + WHEN o.country = 'Djibouti' AND d.country = 'Ethiopia' THEN 'IMPORT' + WHEN o.country = 'Ethiopia' AND d.country = 'Djibouti' THEN 'EXPORT' + ELSE 'DOMESTIC' + END + FROM freight.yards o, freight.yards d + WHERE o.id = r.origin_yard_id + AND d.id = r.destination_yard_id + `); + // Orphan origin/destination (deleted yard) — no way to classify; park as + // DOMESTIC, which is blocked everywhere, so nothing can schedule on it. + await queryRunner.query(` + UPDATE freight.routes SET direction = 'DOMESTIC' WHERE direction IS NULL + `); + await queryRunner.query(` + ALTER TABLE freight.routes + ALTER COLUMN direction SET NOT NULL, + DROP CONSTRAINT IF EXISTS chk_routes_direction, + ADD CONSTRAINT chk_routes_direction CHECK (direction IN ('IMPORT', 'EXPORT', 'DOMESTIC')) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.routes + DROP CONSTRAINT IF EXISTS chk_routes_direction, + DROP COLUMN IF EXISTS direction + `); + await queryRunner.query(` + ALTER TABLE freight.yards DROP CONSTRAINT IF EXISTS chk_yards_country + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1990000000000-AddVehiclePricePerKm.ts b/apps/edr-freight-api/src/migrations/1990000000000-AddVehiclePricePerKm.ts new file mode 100644 index 000000000..7767cd796 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1990000000000-AddVehiclePricePerKm.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Per-km haulage rate on a vehicle (mainly trucks) plus the currency it's + * quoted in (ETB | USD, default ETB). + */ +export class AddVehiclePricePerKm1990000000000 implements MigrationInterface { + name = "AddVehiclePricePerKm1990000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS price_per_km numeric(14,2), + ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS price_per_km, + DROP COLUMN IF EXISTS currency + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2000000000000-AddGpsTracking.ts b/apps/edr-freight-api/src/migrations/2000000000000-AddGpsTracking.ts new file mode 100644 index 000000000..3d440c678 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000000-AddGpsTracking.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * GPS tracking: physical trackers (gps_devices, one denormalized latest fix per + * device for the live map) + append-only fix history (gps_positions). + */ +export class AddGpsTracking2000000000000 implements MigrationInterface { + name = "AddGpsTracking2000000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.gps_devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + imei varchar(20) NOT NULL UNIQUE, + name varchar, + vehicle_id uuid REFERENCES freight.vehicles(id), + status varchar(16) NOT NULL DEFAULT 'REGISTERED', + last_seen_at timestamptz, + last_lat numeric(10,6), + last_lng numeric(10,6), + last_speed numeric(6,2), + last_course int, + last_fix_at timestamptz, + voltage_level int, + gsm_level int, + 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_GPS_DEVICES_VEHICLE" + ON freight.gps_devices (vehicle_id) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.gps_positions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + device_id uuid NOT NULL, + imei varchar(20) NOT NULL, + vehicle_id uuid, + lat numeric(10,6) NOT NULL, + lng numeric(10,6) NOT NULL, + speed numeric(6,2) NOT NULL DEFAULT 0, + course int NOT NULL DEFAULT 0, + satellites int NOT NULL DEFAULT 0, + positioned boolean NOT NULL DEFAULT false, + gps_time timestamptz NOT NULL, + alarm int NOT NULL DEFAULT 0, + 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_GPS_POSITIONS_DEVICE_TIME" + ON freight.gps_positions (device_id, gps_time) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME" + ON freight.gps_positions (vehicle_id, gps_time) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_positions`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_devices`); + } +} diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index 7c7805b28..d49cc304e 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -8,7 +8,11 @@ import { hashPassword } from "@tria-plc/api-common/utils/argon"; import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm"; -import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common"; +// Subpath imports (not the package root) so ts-jest can resolve them when this +// file lands in a spec's compile graph via the notification recipients chain. +import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity"; +import { Organization } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization.entity"; +import { UserCredential } from "@tria-plc/iamapi-common/entities/iam/user/user-credential.entity"; import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; @@ -40,6 +44,21 @@ export class BackofficeService { private readonly dataSource: DataSource, ) {} + /** + * IAM user ids of every current employee across all organizations — used by + * the notification recipients resolver's `allBackoffice` selector. + */ + async getAllCurrentEmployeeUserIds(): Promise { + const employees = await this.employeeRepository.find({ + where: { isCurrent: true }, + }); + return [ + ...new Set( + employees.map((e) => e.userId).filter((id): id is string => Boolean(id)), + ), + ]; + } + async createOrganizationUser( organizationId: string, dto: CreateOrganizationUserDto, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts new file mode 100644 index 000000000..a4546e301 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -0,0 +1,307 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + NotificationAudience, + NotificationType, + NotifyInput, +} from '@edr/types'; + +import { Booking } from './entities/booking.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; + +/** + * Customer + staff notifications for the booking lifecycle: review, clearance + * and operation flow. Every customer event fans out over SMS + email (direct) + * and a persisted in-app notification deep-linking to the booking detail page; + * staff events land in the backoffice inbox. All sends are fire-and-forget and + * never throw — a notification failure must not break a booking transition. + * + * NOTE: the batch/payment-window notifications (pay-now, allocated, expired, + * displaced) are handled separately by {@link BookingNotifierService} in + * train-scheduling. + */ +@Injectable() +export class BookingLifecycleNotifierService { + private readonly logger = new Logger(BookingLifecycleNotifierService.name); + + constructor( + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} + + private ref(b: Booking): string { + return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; + } + + /** Send SMS + email to the booking's company contact; log-only on failure. */ + private async notifyContact( + b: Booking, + message: string, + logLabel: string, + ): Promise { + this.logger.log(`${logLabel} — ${this.ref(b)}`); + const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null; + const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; + + if (phone) { + try { + await this.notifications.directSend('sms', phone, message); + } catch (err) { + this.logger.warn(`SMS failed for ${this.ref(b)}: ${(err as Error).message}`); + } + } + if (email) { + try { + await this.notifications.directSend('email', email, message); + } catch (err) { + this.logger.warn(`Email failed for ${this.ref(b)}: ${(err as Error).message}`); + } + } + if (!phone && !email) { + this.logger.warn(`No contact on file for ${this.ref(b)} — notification not sent`); + } + } + + /** Persist + push an in-app item to all portal users of the booking's company. */ + private inApp( + b: Booking, + title: string, + body: string, + overrides: Partial = {}, + ): void { + if (!b.companyId) return; // government/unlinked bookings have no portal users + void this.inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title, + body, + link: `/bookings/${b.id}`, + data: { bookingId: b.id, reference: b.reference }, + ...overrides, + }); + } + + /** Persist + push an in-app item to every backoffice staff user. */ + private inAppStaff( + b: Booking, + title: string, + body: string, + overrides: Partial = {}, + ): void { + void this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: `/dashboard/booking-requests/${b.id}`, + data: { bookingId: b.id, reference: b.reference }, + ...overrides, + }); + } + + // ── Customer-facing lifecycle events ─────────────────────────────────────── + + /** Line staff accepted intake → booking is under approval. */ + accepted(b: Booking): void { + const msg = + `Your booking ${b.reference} has been accepted and is now under approval. ` + + `We will notify you once it is approved.`; + void this.notifyContact(b, msg, 'ACCEPTED'); + this.inApp(b, 'Booking accepted', msg); + } + + /** All approval steps complete → contract generated, ready for customer to sign. */ + approved(b: Booking): void { + const msg = + `Your booking ${b.reference} has been approved. ` + + `Please review and sign your contract from the portal.`; + void this.notifyContact(b, msg, 'APPROVED'); + this.inApp(b, 'Booking approved', msg); + } + + /** Staff rejected the booking (intake or approval step). */ + rejected(b: Booking, reason: string): void { + const msg = + `Your booking ${b.reference} was rejected. Reason: ${reason}. ` + + `Please contact us for details.`; + void this.notifyContact(b, msg, 'REJECTED'); + this.inApp(b, 'Booking rejected', msg); + } + + /** Staff requested changes before approval. */ + changesRequested(b: Booking, note: string): void { + const msg = + `Changes were requested on your booking ${b.reference}: ${note}. ` + + `Please update and resubmit from the portal.`; + void this.notifyContact(b, msg, 'CHANGES REQUESTED'); + this.inApp(b, 'Booking changes requested', msg); + } + + /** A clearance document was queried and needs the customer to re-upload. */ + documentQueried(b: Booking, fileKey: string, note: string): void { + const msg = + `A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` + + `${note}. Please re-upload from the portal.`; + void this.notifyContact(b, msg, 'DOCUMENT QUERIED'); + this.inApp(b, 'Document queried', msg, { + type: NotificationType.DOCUMENT_ACTION, + }); + } + + /** Clearance finalized → customer can proceed to request operation. */ + clearanceReady(b: Booking): void { + const msg = + `Clearance for booking ${b.reference} is complete. ` + + `You can now proceed to request operation from the portal.`; + void this.notifyContact(b, msg, 'CLEARANCE READY'); + this.inApp(b, 'Clearance complete', msg, { + type: NotificationType.CLEARANCE_DECISION, + }); + } + + /** Operations returned the operation request for changes. */ + operationChangesRequested(b: Booking, note: string): void { + const msg = + `Your operation request for booking ${b.reference} needs changes: ${note}. ` + + `Please update and resubmit from the portal.`; + void this.notifyContact(b, msg, 'OPERATION CHANGES REQUESTED'); + this.inApp(b, 'Operation request needs changes', msg); + } + + /** Operation accepted → invoice ready; await payment / booking window. */ + operationAccepted(b: Booking): void { + const msg = + `Your operation request for booking ${b.reference} has been accepted. ` + + `An invoice has been prepared — watch for the payment window to secure your slot.`; + void this.notifyContact(b, msg, 'OPERATION ACCEPTED'); + this.inApp(b, 'Operation request accepted', msg); + } + + /** Shipment started → in transit. */ + inTransit(b: Booking): void { + const msg = `Your shipment for booking ${b.reference} is now in transit.`; + void this.notifyContact(b, msg, 'IN TRANSIT'); + this.inApp(b, 'Shipment in transit', msg); + } + + /** Shipment delivered → completed. */ + completed(b: Booking): void { + const msg = `Your shipment for booking ${b.reference} has been delivered. Thank you.`; + void this.notifyContact(b, msg, 'COMPLETED'); + this.inApp(b, 'Shipment delivered', msg); + } + + /** Booking cancelled. */ + cancelled(b: Booking, reason: string): void { + const msg = `Your booking ${b.reference} has been cancelled. Reason: ${reason}.`; + void this.notifyContact(b, msg, 'CANCELLED'); + this.inApp(b, 'Booking cancelled', msg); + } + + // ── Clearance milestones needing customer action ────────────────────────── + + /** GL advised duty & tax — the customer must pay and upload the slip. */ + dutyAdvised(b: Booking, amount: number, currency: string): void { + const msg = + `Duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` + + `Please pay and upload the payment slip from the portal.`; + void this.notifyContact(b, msg, 'DUTY ADVISED'); + this.inApp(b, 'Duty & tax advised', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** GL advised the post-arrival additional duty round (import). */ + secondDutyAdvised(b: Booking, amount: number, currency: string): void { + const msg = + `Additional duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` + + `Please pay and upload the payment slip from the portal.`; + void this.notifyContact(b, msg, 'SECOND DUTY ADVISED'); + this.inApp(b, 'Additional duty & tax advised', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** GL raised the final (post-offload) invoice — customer pays + uploads slip. */ + finalInvoiceCreated(b: Booking, amount: number, currency: string): void { + const msg = + `A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` + + `Please pay and upload the payment slip from the portal.`; + void this.notifyContact(b, msg, 'FINAL INVOICE'); + this.inApp(b, 'Final invoice issued', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** GL confirmed the final-invoice payment slip. */ + finalInvoicePaid(b: Booking): void { + const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`; + void this.notifyContact(b, msg, 'FINAL INVOICE PAID'); + this.inApp(b, 'Final invoice paid', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + // ── Staff-facing (backoffice inbox) ──────────────────────────────────────── + + /** Customer submitted a booking for review. */ + submittedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'New booking submitted', + `Booking ${this.ref(b)} was submitted and is awaiting intake review.`, + ); + } + + /** Customer signed the booking contract. */ + customerSignedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'Customer signed booking contract', + `The contract for booking ${this.ref(b)} was signed by the customer.`, + ); + } + + /** Customer requested operation (picked a shipment day). */ + operationRequestedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'Operation requested', + `Booking ${this.ref(b)} requested operation — review capacity, documents and route.`, + ); + } + + /** Customer uploaded clearance documents — review is next. */ + clearanceDocsUploadedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'Clearance documents uploaded', + `Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`, + { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/bookings/${b.id}/clearance`, + }, + ); + } + + /** Customer uploaded a duty/tax payment slip — GL verifies it. */ + dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void { + const label = + round === 'final' + ? 'final invoice' + : round === 'second' + ? 'additional duty & tax' + : 'duty & tax'; + this.inAppStaff( + b, + 'Payment slip uploaded', + `Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`, + { + type: NotificationType.PAYMENT_RECEIVED, + link: `/dashboard/bookings/${b.id}/clearance`, + }, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index 3c535f450..607a0d7a4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -30,14 +30,32 @@ describe('BookingTransitionService — acceptIntake validity window', () => { ruleEngineService as never, {} as never, // pricingService {} as never, // contractService - {} as never, // invoiceService {} as never, // filesService {} as never, // fileUploadSettingsService {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); return { service, bookingsRepository, ruleEngineService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index 9f9aa5713..72c83e136 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -41,14 +41,32 @@ describe('BookingTransitionService — finalizeClearance gate', () => { {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService - {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); return { service, bookingsRepository }; } @@ -126,14 +144,32 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( {} as never, {} as never, {} as never, - {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, - {} as never, + {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); return { service, bookingsRepository }; } @@ -197,14 +233,32 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields {} as never, {} as never, {} as never, - {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, - {} as never, + {} as never, // bookingBatchService bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); return { service, bookingsRepository, filesService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index ea3618a08..9201e4fa9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -3,14 +3,17 @@ import { BookingTransitionService } from './booking-transition.service'; /** * Operation-request review for general-contract drawdown orders: - * - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool. - * - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued. + * - ACCEPT a train order → FULLY_EXECUTED with the invoice ensured; import/ + * domestic bookings wait for their booking-day window cycle (no immediate + * batch enqueue at accept time). + * - ACCEPT a road order → ROAD_DISPATCH_PENDING, never enters the train batch. * - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED. */ describe('BookingTransitionService — operation review', () => { function makeService(serviceTypeCode: string) { const booking = { id: 'b-1', + reference: 'BKG-1', status: 'OPERATION_REQUEST_PENDING', originYardId: 'o-1', destinationYardId: 'd-1', @@ -26,6 +29,14 @@ describe('BookingTransitionService — operation review', () => { }; const bookingBatchService = { enqueueRouteDayProcessing: jest.fn(), + pickExportSchedule: jest.fn(), + acceptExportBooking: jest.fn(), + }; + const invoiceService = { + ensureInvoiceForBooking: jest + .fn() + .mockResolvedValue({ id: 'inv-1', invoiceNumber: 'INV-0001' }), + updateStatus: jest.fn().mockResolvedValue(undefined), }; const service = new BookingTransitionService( @@ -33,37 +44,60 @@ describe('BookingTransitionService — operation review', () => { {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService - {} as never, // invoiceService {} as never, // filesService {} as never, // fileUploadSettingsService bookingBatchService as never, bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, - {} as never, + {} as never, // workflowService + invoiceService as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); - return { service, bookingsRepository, bookingBatchService }; + return { service, bookingsRepository, bookingBatchService, invoiceService }; } - it('ACCEPT of a train order → FULLY_EXECUTED and enqueues the batch pool', async () => { - const { service, bookingsRepository, bookingBatchService } = + it('ACCEPT of a train order → FULLY_EXECUTED, invoice ensured, batch waits for window cycle', async () => { + const { service, bookingsRepository, bookingBatchService, invoiceService } = makeService('RAIL_CONTAINER'); await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1'); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-1', expect.objectContaining({ status: 'FULLY_EXECUTED' }), ); - expect(bookingBatchService.enqueueRouteDayProcessing).toHaveBeenCalledTimes(1); + expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); + // Import/domestic train bookings are batched by the window cycle later — + // never enqueued directly at accept time. + expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled(); + expect(bookingBatchService.acceptExportBooking).not.toHaveBeenCalled(); }); - it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enqueue', async () => { - const { service, bookingsRepository, bookingBatchService } = + it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enter the batch', async () => { + const { service, bookingsRepository, bookingBatchService, invoiceService } = makeService('ROAD_CONTAINER'); await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1'); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-1', expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }), ); + expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled(); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 06edbf04e..b5c277073 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -4,6 +4,7 @@ import { Inject, Injectable, Logger, + Optional, } from "@nestjs/common"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; @@ -15,6 +16,7 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { FilesService } from '../files/files.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { BookingContractService } from './booking-contract.service'; +import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import { BookingPricingService } from './booking-pricing.service'; import { ContainerValidationService } from './container-validation.service'; import { BookingsRepository } from './bookings.repository'; @@ -26,6 +28,7 @@ import { PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { BookingsService } from './bookings.service'; import { BookingClearanceService } from '../contracts/booking-clearance.service'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service'; import { ContractDocPhase } from '@edr/types'; @@ -53,7 +56,8 @@ export class BookingTransitionService { private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly containerValidationService: ContainerValidationService, - + private readonly notifier: BookingLifecycleNotifierService, + @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} private isPhasedGeneralCustoms(booking: Booking): boolean { @@ -124,6 +128,9 @@ export class BookingTransitionService { const finalBooking = await this.bookingsService.runConsolidationOnSubmit( updated!.id, ); + if (finalBooking.status === "SUBMITTED") { + this.notifier.submittedToStaff(finalBooking); + } return { bookingId: finalBooking.id, status: finalBooking.status, @@ -204,6 +211,9 @@ export class BookingTransitionService { const finalBooking = await this.bookingsService.runConsolidationOnSubmit( updated!.id, ); + if (finalBooking.status === "SUBMITTED") { + this.notifier.submittedToStaff(finalBooking); + } return { bookingId: finalBooking.id, status: finalBooking.status, @@ -233,7 +243,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "CHANGES_REQUESTED", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.changesRequested(fresh, note); + return fresh; } /** Auto-create booking approval steps from system rules when none exist yet. */ @@ -284,7 +296,9 @@ export class BookingTransitionService { contractValidFrom: validFrom, contractValidUntil: validUntil, } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.accepted(fresh); + return fresh; } async staffReject( @@ -305,7 +319,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "REJECTED", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.rejected(fresh, reason); + return fresh; } async approveStep( @@ -394,7 +410,9 @@ export class BookingTransitionService { if (allDone) { const generated = await this.contractService.generateContract(bookingId); - return this.bookingsService.findById(generated.id); + const fresh = await this.bookingsService.findById(generated.id); + this.notifier.approved(fresh); + return fresh; } return this.bookingsService.findById(bookingId); @@ -435,7 +453,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "REJECTED", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.rejected(fresh, reason); + return fresh; } async customerSign(bookingId: string): Promise { @@ -446,7 +466,9 @@ export class BookingTransitionService { status: "SIGNED_CUSTOMER", customerSignedAt: new Date(), } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.customerSignedToStaff(fresh); + return fresh; } async startTransit(bookingId: string): Promise { @@ -456,7 +478,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "IN_TRANSIT", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.inTransit(fresh); + return fresh; } async complete(bookingId: string): Promise { @@ -467,7 +491,28 @@ export class BookingTransitionService { status: "COMPLETED", endDate: new Date(), } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.completed(fresh); + // Customer tracking: close out the tail milestones so a finished shipment + // never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are + // implied by delivery; a storage invoice that was never raised is skipped + // (storage billing does not apply to every shipment). All doc-trigger / + // best-effort — a booking without milestone rows is untouched. + if (this.milestoneService) { + for (const code of ["IMPORT_PROCESS_COMPLETED", "EXIT_NOTE_GENERATED"]) { + try { + await this.milestoneService.completeByDocTrigger({ bookingId }, code); + } catch { + /* tracking must never block completion */ + } + } + try { + await this.milestoneService.skipForBooking(bookingId, "STORAGE_INVOICE_RAISED"); + } catch { + /* no such milestone row (export / non-customs) — fine */ + } + } + return fresh; } async cancel(bookingId: string, reason: string): Promise { @@ -491,7 +536,9 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: "CANCELLED", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.cancelled(fresh, reason); + return fresh; } /** @@ -723,7 +770,9 @@ export class BookingTransitionService { } as never); } - return this.bookingsService.findById(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.clearanceDocsUploadedToStaff(fresh); + return fresh; } /** @@ -824,6 +873,9 @@ export class BookingTransitionService { } const updated = await this.bookingsService.findById(bookingId); + if (status === "QUERIED") { + this.notifier.documentQueried(updated, fileKey, note ?? ''); + } if (this.isPhasedGeneralCustoms(updated)) { const allApproved = await this.isClearanceFullyApproved(updated); if (allApproved) { @@ -912,7 +964,9 @@ export class BookingTransitionService { await this.bookingsRepository.update(bookingId, { status: "CLEARANCE_READY", } as never); - return this.bookingsService.findById(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.clearanceReady(fresh); + return fresh; } /** @@ -957,7 +1011,9 @@ export class BookingTransitionService { status: "OPERATION_REQUEST_PENDING", scheduledDate: date, } as never); - return this.bookingsService.findById(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.operationRequestedToStaff(fresh); + return fresh; } /** @@ -992,7 +1048,9 @@ export class BookingTransitionService { await this.bookingsRepository.update(bookingId, { status: "OPERATION_CHANGES_REQUESTED", } as never); - return this.bookingsService.findById(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.operationChangesRequested(fresh, options.note); + return fresh; } // ACCEPT — enter the batch holding pool. @@ -1037,7 +1095,9 @@ export class BookingTransitionService { fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); - return this.bookingsService.findById(booking.id); + const roadFresh = await this.bookingsService.findById(booking.id); + this.notifier.operationAccepted(roadFresh); + return roadFresh; } await this.bookingsRepository.update(booking.id, { @@ -1072,7 +1132,9 @@ export class BookingTransitionService { // batch runs after the window closes + staff document review, never at accept // time. (Legacy pre-migration schedules with no window phase are still served // by the periodic legacy fill.) - return this.bookingsService.findById(booking.id); + const trainFresh = await this.bookingsService.findById(booking.id); + this.notifier.operationAccepted(trainFresh); + return trainFresh; } async enrichBookingResponse(booking: Booking): Promise< diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index eda249868..444ac578a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -15,11 +15,13 @@ import { UnauthorizedException, UploadedFile, UploadedFiles, + UseGuards, UseInterceptors, } from '@nestjs/common'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { BookingStaff } from '../../common/booking-guards'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { BookingStaff, BookingView } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import { @@ -195,6 +197,7 @@ export class BookingsController { } @Get("by-company/:companyId/customer-view") + @BookingView() @ApiOperation({ summary: "List bookings for a company (customer-view shape, backoffice)", }) @@ -205,6 +208,7 @@ export class BookingsController { } @Get("list-summary") + @BookingView() @ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" }) @ApiOkResponse({ type: BookingListSummaryDto }) findListSummary(@Query() filter: FilterBookingDto) { @@ -226,6 +230,7 @@ export class BookingsController { } @Get("queues/:queue") + @BookingView() @ApiOperation({ summary: "List bookings for a dashboard queue", description: "Queues: intake, approval, signatures, marketing, finance", @@ -956,12 +961,18 @@ export class BookingsController { } @Post(":id/contract/sign") + @UseGuards(JwtGuard) @ApiOperation({ summary: "Apply digital signature (customer or staff)" }) async signContract( @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, + @CurrentUser() user: TCurrentUser, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { + // Staff signature needs the sign permission; customer signs their own booking. + if (dto.role !== "CUSTOMER") { + assertFreightPermission(user, FREIGHT_PERMS.bookings.signStaff); + } const userId = req.user?.id ?? req.user?.sub; const booking = await this.contractService.signContract(id, dto, { signerUserId: userId, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 2cb10ce8e..61dc78e13 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -18,7 +18,10 @@ import { BookingInvoiceService } from './booking-invoice.service'; // import { BookingPaymentService } from './booking-payment.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; +import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import { BookingTransitionService } from './booking-transition.service'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { BookingsController } from './bookings.controller'; // import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; @@ -64,6 +67,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; CustomerTruckContainer, ]), BillingModule, + NotificationsModule, + NotificationInboxModule, forwardRef(() => FirstMileModule), forwardRef(() => TrainSchedulingModule), forwardRef(() => ContractsModule), @@ -90,6 +95,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ContainerValidationService, BookingReferenceDataService, BookingPricingService, + BookingLifecycleNotifierService, BookingTransitionService, BookingContractService, BookingInvoiceService, @@ -107,6 +113,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingsRepository, BookingPricingService, BookingInvoiceService, + BookingLifecycleNotifierService, CustomerTruckService, ContainerReceiptService, ], diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 7a6169b7b..1fb37dc31 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -176,6 +176,44 @@ export class BookingsService { } /** Resolve trade direction from yard countries; reject client mismatch. */ + /** + * An intercity corridor is valid when both yards are Ethiopian and at least + * one non-retired route passes the origin strictly before the destination in + * its milestone order — that is the corridor an import/export train can + * serve the booking on. + */ + private async assertIntercityCorridorExists( + originYardId: string, + destinationYardId: string, + ): Promise { + const yards = await this.dataSource.getRepository(Yard).find({ + where: { id: In([originYardId, destinationYardId]) }, + }); + if (yards.some((y) => y.country !== 'Ethiopia')) { + throw new BadRequestException( + 'Intercity bookings only run between Ethiopian yards', + ); + } + const rows: Array<{ id: string }> = await this.dataSource.query( + `SELECT r.id + FROM freight.routes r + JOIN freight.route_milestones mo + ON mo.route_id = r.id AND mo.yard_id = $1 AND mo.deleted_at IS NULL + JOIN freight.route_milestones md + ON md.route_id = r.id AND md.yard_id = $2 AND md.deleted_at IS NULL + WHERE mo.sequence_no < md.sequence_no + AND r.status = 'AVAILABLE' + AND r.deleted_at IS NULL + LIMIT 1`, + [originYardId, destinationYardId], + ); + if (rows.length === 0) { + throw new BadRequestException( + 'No route passes through this origin and destination in order — intercity service is not available on this corridor', + ); + } + } + private async resolveTradeDirectionForBooking( originYardId: string, destinationYardId: string, @@ -607,6 +645,23 @@ export class BookingsService { dto.tradeDirection, ); + // Intercity (DOMESTIC) bookings never get their own train — they ride on a + // passing import/export train, so there is no booking window and no date to + // pin. All we require at creation is that the corridor actually lies on a + // route (origin before destination in some route's milestone order); staff + // accept the booking onto a concrete train at finalize time. + if (tradeDirection === 'DOMESTIC') { + if (dto.scheduledDate || dto.trainScheduleId) { + throw new BadRequestException( + 'Intercity bookings cannot pin a date or schedule — staff assign them to a passing train later', + ); + } + await this.assertIntercityCorridorExists( + dto.originYardId, + dto.destinationYardId, + ); + } + // Stamp the operational profile this booking belongs to (importer/exporter) // so the customer portal can scope lists/KPIs to the active mode. Best-effort // for non-government bookings with a resolved company; never blocks creation. @@ -1333,6 +1388,17 @@ export class BookingsService { ); } + // Surface the assigned train's operational status so the portal stepper + // can show the Arrival stage: the booking status stays IN_TRANSIT from + // dispatch until delivery, so arrival is only knowable from the schedule. + if (booking.trainScheduleId) { + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: booking.trainScheduleId } }); + (booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus = + schedule?.status ?? null; + } + return booking; } diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 88871f8ad..42186dd8e 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -33,6 +33,11 @@ import { ETradeService } from "./services/etrade.service"; CompanyDashboardRepository, ETradeService, ], - exports: [CompaniesService], + exports: [ + CompaniesService, + // Consumed by NotificationInboxModule for portal recipient targeting. + ExternalProfileRepository, + CompanyProfileRepository, + ], }) export class CompaniesModule { } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 62be578bb..fe679627b 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -334,9 +334,28 @@ export class CompaniesService { const company = await this.companiesRepo.findById(id); if (!company) throw new NotFoundException(`Company ${id} not found`); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); + for (const profile of company.companyProfiles) { + profile.businessLicenseFiles = await this.signLicenseFiles( + profile.businessLicenseFiles, + ); + } return company; } + /** + * Business-license files are stored as raw, unsigned MinIO URLs (see + * `BusinessLicenseFile` on `CompanyProfile`) — a browser can't fetch them + * directly. Sign each one with a short-lived URL before it reaches a response. + */ + private async signLicenseFiles( + files?: BusinessLicenseFile[] | null, + ): Promise { + if (!files?.length) return []; + return Promise.all( + files.map(async (f) => ({ ...f, url: await this.filesService.signUrl(f.url) })), + ); + } + /** * Validate an explicitly-chosen company profile for a booking: it must belong * to the booking's company and be Active. Used for government bookings (staff 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/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 2b0126003..7b5cb77d6 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -85,6 +85,13 @@ function makeService(overrides?: { milestoneService as never, dropdownSettingsService as never, glOperationsService as never, + { + dutyAdvised: jest.fn(), + clearanceReady: jest.fn(), + documentQueried: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + } as never, // notifier ); return { @@ -115,12 +122,18 @@ describe('BookingClearanceService', () => { it('records duty advice when duty applies', async () => { const { service, milestoneService } = makeService(); - await service.adviseDuty('b-general', { - dutyRequired: true, - amount: 1500, - currency: 'ETB', - declarationSerial: 'DS-1', - }); + await service.adviseDuty( + 'b-general', + { + dutyRequired: true, + amount: 1500, + currency: 'ETB', + declarationSerial: 'DS-1', + }, + undefined, + // The duty notice attachment is now mandatory when duty applies. + { fieldname: 'duty_tax_notice' } as Express.Multer.File, + ); expect(milestoneService.adviseDuty).toHaveBeenCalledWith( 'b-general', diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 61ac93925..59dad2248 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -12,6 +12,7 @@ import { FileUploadSettingsService } from '../file-upload-settings/file-upload-s import { FilesService } from '../files/files.service'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsService } from '../bookings/bookings.service'; +import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { Booking } from '../bookings/entities/booking.entity'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; @@ -100,6 +101,7 @@ export class BookingClearanceService { private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, private readonly glOperationsService: GlOperationsService, + private readonly notifier: BookingLifecycleNotifierService, ) {} private async assertPhasedGeneralCustoms(booking: Booking): Promise { @@ -174,7 +176,28 @@ export class BookingClearanceService { } const allApproved = await this.isClearanceFullyApproved(booking); - const milestones = await this.workflowService.listMilestonesForBooking(bookingId); + let milestones = await this.workflowService.listMilestonesForBooking(bookingId); + + // Self-heal: a booking that has settled its freight payment must have + // FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an + // export FCFS booking (linked to its train at booking time) paid via the + // prepaid invoice can leave the milestone PENDING — the clearance "Payment & + // wagon allocation" step then never ticks. Backfill it here so already-stuck + // rows recover without a migration; idempotent (no-op once COMPLETED). + const paymentSettled = milestones.find( + (m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED', + ); + if ( + paymentSettled && + paymentSettled.status === 'PENDING' && + (booking.paymentStatus === 'PAID' || booking.status === 'PAID') + ) { + await this.workflowService.completeMilestoneForBooking( + bookingId, + 'FREIGHT_PAYMENT_SETTLED', + ); + milestones = await this.workflowService.listMilestonesForBooking(bookingId); + } const phase = this.workflowService.resolvePhaseForBooking(booking, milestones); const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones); const boundary = await this.workflowService.isBoundaryCompleteForBooking( @@ -414,6 +437,7 @@ export class BookingClearanceService { }, userId, ); + this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB'); } return this.bookingsService.findById(bookingId); @@ -441,6 +465,7 @@ export class BookingClearanceService { clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, } as never); + this.notifier.dutySlipUploadedToStaff(booking, 'first'); return this.bookingsService.findById(bookingId); } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 88a4ec725..4752b004f 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -10,6 +10,7 @@ import type { Freight } from '@edr/types'; import { BookingRequestRepository } from './booking-request.repository'; import { ContractsService } from './contracts.service'; import { ContractBookingService } from './contract-booking.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { BookingRequest } from './entities/booking-request.entity'; import { Contract } from './entities/contract.entity'; import { CreateBookingRequestDto } from './dto/create-booking-request.dto'; @@ -26,6 +27,7 @@ export class BookingRequestService { private readonly repo: BookingRequestRepository, private readonly contractsService: ContractsService, private readonly contractBookingService: ContractBookingService, + private readonly notifier: ContractNotifierService, ) {} /** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */ @@ -107,7 +109,7 @@ export class BookingRequestService { }; const reference = await this.generateReference(); - return this.repo.create({ + const request = await this.repo.create({ reference, contractId, requestedByUserId: userId ?? null, @@ -117,6 +119,8 @@ export class BookingRequestService { requestedLines, notes: dto.notes ?? null, } as never); + this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference); + return request; } listForContract(contractId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index e033f8e9b..4a58e50be 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -6,6 +6,7 @@ import { CustomsRiskLevel, MilestoneMetadata, } from './entities/clearance-milestone.entity'; +import { Booking } from '../bookings/entities/booking.entity'; import { Contract } from './entities/contract.entity'; import { HANDOFF_MILESTONES, @@ -85,10 +86,36 @@ export class ClearanceMilestoneService { } async listForBooking(bookingId: string): Promise { - return this.repo.find({ + const rows = await this.repo.find({ where: { bookingId }, order: { sortOrder: 'ASC' }, }); + + // Self-heal: a booking that has settled its freight payment must have + // FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an + // export FCFS booking (linked to its train at booking time) paid via the + // prepaid invoice can leave the milestone PENDING — the clearance "Payment & + // wagon allocation" step then never ticks. getClearanceView backfills it, but + // the stepper reads its gating milestones straight from here, so heal here too. + // Idempotent (no-op once COMPLETED); recovers already-stuck rows with no migration. + const paymentSettled = rows.find( + (m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED', + ); + if (paymentSettled && paymentSettled.status === 'PENDING') { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: bookingId }, + select: { id: true, status: true, paymentStatus: true }, + }); + if (booking?.paymentStatus === 'PAID' || booking?.status === 'PAID') { + await this.completeForBooking(bookingId, 'FREIGHT_PAYMENT_SETTLED'); + return this.repo.find({ + where: { bookingId }, + order: { sortOrder: 'ASC' }, + }); + } + } + + return rows; } /** diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts index f30b64597..75178d640 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts @@ -48,6 +48,7 @@ function makeService(milestones: ClearanceMilestone[]) { contractsRepository as never, milestoneService as never, bookingsRepository as never, + { clearanceReady: jest.fn() } as never, // notifier ); return { service, milestoneService, contractsRepository, bookingsRepository }; } diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts index 758da27ff..9b17a3e76 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts @@ -9,6 +9,7 @@ import { Contract } from './entities/contract.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { Booking } from '../bookings/entities/booking.entity'; import type { ClearanceMetaState } from './clearance-workflow.types'; import { metaFromBooking } from './clearance-workflow.types'; @@ -34,6 +35,7 @@ export class ClearanceWorkflowService { private readonly contractsRepository: ContractsRepository, private readonly milestoneService: ClearanceMilestoneService, private readonly bookingsRepository: BookingsRepository, + private readonly notifier: BookingLifecycleNotifierService, ) {} boundaryMilestone(tradeDirection: string): string { @@ -264,6 +266,14 @@ export class ClearanceWorkflowService { status: 'CLEARANCE_READY', clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, } as never); + // Tell the customer clearance is done and operation can be requested. Load + // failure only skips the notice — the status change above already committed. + try { + const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); + if (booking) this.notifier.clearanceReady(booking); + } catch { + /* notification is best-effort */ + } } resolvePhase( diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 873daaf15..5e80b1301 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -119,12 +119,27 @@ export class ContractBookingService { const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); + // Intercity (DOMESTIC) bookings ride on a passing import/export train: + // there is no window and no date — staff accept them onto a train at + // finalize time, so both the window gate and scheduledDate are skipped. + const isIntercity = contract.tradeDirection === 'DOMESTIC'; + if (isIntercity && dto.scheduledDate) { + throw new BadRequestException( + 'Intercity bookings do not pick a date — staff assign them to a passing train', + ); + } + // Every other direction keeps the binding shipment day (the DTO field went + // optional only for intercity). + if (!isIntercity && !dto.scheduledDate) { + throw new BadRequestException('A binding shipment day is required'); + } + // Booking-window gate (config-driven): an operations booking may only be // created while the route's booking window is open — import: the day's window // (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours); // export: within exportBookingLeadHours of departure. Customs Path B bookings // enter clearance first and are scheduled later, so they are not gated here. - if (!generalCustoms) { + if (!generalCustoms && !isIntercity) { await this.trainSchedulingService.assertBookingWindowOpen({ originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 2c79ba42f..3d41e68a5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -16,6 +16,7 @@ import { BookingsService } from '../bookings/bookings.service'; import { contractClearanceCodes } from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { GlOperationsService } from './gl-operations.service'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { Contract } from './entities/contract.entity'; @@ -118,6 +119,7 @@ export class ContractClearanceService { private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, private readonly glOperationsService: GlOperationsService, + private readonly notifier: ContractNotifierService, ) {} private isPhasedCustoms(contract: Contract): boolean { @@ -543,7 +545,9 @@ export class ContractClearanceService { await this.workflowService.onDocumentReviewReopened(contractId); } - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.clearanceDocsUploadedToStaff(updated); + return updated; } private async assertRequiredInputsPresent( @@ -674,6 +678,7 @@ export class ContractClearanceService { status: 'AWAITING_CLEARANCE_DOCUMENTS', clearanceStatus: 'AWAITING_DOCUMENTS', } as never); + this.notifier.clearanceDocumentQueried(contract, fileKey, note ?? ''); if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); } @@ -1009,6 +1014,7 @@ export class ContractClearanceService { }, userId, ); + this.notifier.dutyAdvised(contract, dto.amount, dto.currency ?? 'ETB'); } return this.contractsService.findById(contractId); @@ -1045,7 +1051,9 @@ export class ContractClearanceService { }); } - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.dutySlipUploadedToStaff(updated); + return updated; } async uploadTransitPermit( @@ -1118,6 +1126,7 @@ export class ContractClearanceService { await this.workflowService.markReadyForBooking(contractId); } + this.notifier.preClearanceFinalized(contract); return this.contractsService.findById(contractId); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts new file mode 100644 index 000000000..d4f31e570 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -0,0 +1,245 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + NotificationAudience, + NotificationType, + NotifyInput, +} from '@edr/types'; + +import { Contract } from './entities/contract.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; + +/** + * Customer + staff notifications for the contract lifecycle. Every customer + * event fans out over three channels: SMS + email (direct, via + * {@link NotificationsService}) and a persisted in-app notification (via + * {@link NotificationInboxService}) that deep-links to the contract detail page. + * Staff events go to the backoffice inbox. All sends are fire-and-forget and + * never throw — a notification failure must not break a contract transition. + */ +@Injectable() +export class ContractNotifierService { + private readonly logger = new Logger(ContractNotifierService.name); + + constructor( + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} + + private ref(c: Contract): string { + return `${c.reference}${c.isGovernment ? ' (gov)' : ''}`; + } + + /** Send SMS + email to the contract's company contact; log-only on failure. */ + private async notifyContact( + c: Contract, + message: string, + logLabel: string, + ): Promise { + this.logger.log(`${logLabel} — ${this.ref(c)}`); + const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null; + const email = c.company?.email ?? c.company?.generalManagerEmail ?? null; + + if (phone) { + try { + await this.notifications.directSend('sms', phone, message); + } catch (err) { + this.logger.warn(`SMS failed for ${this.ref(c)}: ${(err as Error).message}`); + } + } + if (email) { + try { + await this.notifications.directSend('email', email, message); + } catch (err) { + this.logger.warn(`Email failed for ${this.ref(c)}: ${(err as Error).message}`); + } + } + if (!phone && !email) { + this.logger.warn(`No contact on file for ${this.ref(c)} — notification not sent`); + } + } + + /** Persist + push an in-app item to all portal users of the contract's company. */ + private inApp( + c: Contract, + title: string, + body: string, + overrides: Partial = {}, + ): void { + if (!c.companyId) return; // government/unlinked contracts have no portal users + void this.inbox.notify({ + recipients: { companyId: c.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.CONTRACT_STATUS, + title, + body, + link: `/contracts/${c.id}`, + data: { contractId: c.id, reference: c.reference }, + ...overrides, + }); + } + + /** Persist + push an in-app item to every backoffice staff user. */ + private inAppStaff( + c: Contract, + title: string, + body: string, + overrides: Partial = {}, + ): void { + void this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: `/dashboard/contract-requests/${c.id}`, + data: { contractId: c.id, reference: c.reference }, + ...overrides, + }); + } + + // ── Customer-facing lifecycle events ─────────────────────────────────────── + + /** Line staff accepted intake → contract is under approval. */ + accepted(c: Contract): void { + const msg = + `Your contract ${c.reference} has been accepted and is now under approval. ` + + `We will notify you once it is approved.`; + void this.notifyContact(c, msg, 'ACCEPTED'); + this.inApp(c, 'Contract accepted', msg); + } + + /** All approval steps complete → contract approved. */ + approved(c: Contract): void { + const msg = + `Your contract ${c.reference} has been approved. ` + + `The final document will be prepared for signing.`; + void this.notifyContact(c, msg, 'APPROVED'); + this.inApp(c, 'Contract approved', msg); + } + + /** Fully executed (all parties signed) → contract active, customer can book. */ + signedActive(c: Contract): void { + const msg = + `Your contract ${c.reference} has been signed and is now active. ` + + `You can start booking shipments from the portal.`; + void this.notifyContact(c, msg, 'SIGNED / ACTIVE'); + this.inApp(c, 'Contract active', msg); + } + + /** Staff rejected the contract. */ + rejected(c: Contract, reason: string): void { + const msg = + `Your contract ${c.reference} was rejected. Reason: ${reason}. ` + + `Please contact us for details.`; + void this.notifyContact(c, msg, 'REJECTED'); + this.inApp(c, 'Contract rejected', msg); + } + + /** Staff requested changes before approval. */ + changesRequested(c: Contract, note: string): void { + const msg = + `Changes were requested on your contract ${c.reference}: ${note}. ` + + `Please update and resubmit from the portal.`; + void this.notifyContact(c, msg, 'CHANGES REQUESTED'); + this.inApp(c, 'Contract changes requested', msg); + } + + // ── Clearance milestones needing customer action ────────────────────────── + + /** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */ + dutyAdvised(c: Contract, amount: number, currency: string): void { + const msg = + `Duty & tax of ${amount} ${currency} has been advised for contract ${c.reference}. ` + + `Please pay and upload the payment slip from the portal.`; + void this.notifyContact(c, msg, 'DUTY ADVISED'); + this.inApp(c, 'Duty & tax advised', msg, { + type: NotificationType.INVOICE_ISSUED, + link: `/contracts/${c.id}/clearance`, + }); + } + + /** A clearance document was queried — customer must re-upload it. */ + clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void { + const msg = + `A clearance document on contract ${c.reference} needs attention: "${fileKey}". ` + + `${note}. Please re-upload from the portal.`; + void this.notifyContact(c, msg, 'CLEARANCE DOC QUERIED'); + this.inApp(c, 'Clearance document queried', msg, { + type: NotificationType.DOCUMENT_ACTION, + link: `/contracts/${c.id}/clearance`, + }); + } + + /** Import pre-clearance finalized — the process moves to GL Djibouti collection. */ + preClearanceFinalized(c: Contract): void { + const msg = + `Pre-clearance for contract ${c.reference} is complete. ` + + `Your shipment is proceeding to document collection in Djibouti.`; + void this.notifyContact(c, msg, 'PRE-CLEARANCE FINALIZED'); + this.inApp(c, 'Pre-clearance complete', msg, { + type: NotificationType.CLEARANCE_DECISION, + link: `/contracts/${c.id}/clearance`, + }); + } + + // ── Staff-facing (backoffice inbox) ──────────────────────────────────────── + + /** Customer submitted a contract for review. */ + submittedToStaff(c: Contract): void { + this.inAppStaff( + c, + 'New contract submitted', + `Contract ${this.ref(c)} was submitted and is awaiting intake review.`, + ); + } + + /** Customer signed the contract — staff counter-sign is next. */ + customerSignedToStaff(c: Contract): void { + this.inAppStaff( + c, + 'Customer signed contract', + `Contract ${this.ref(c)} was signed by the customer and awaits the EDR counter-signature.`, + { link: `/dashboard/contract-requests/${c.id}/view` }, + ); + } + + /** Customer uploaded clearance documents — GL review is next. */ + clearanceDocsUploadedToStaff(c: Contract): void { + this.inAppStaff( + c, + 'Clearance documents uploaded', + `Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`, + { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/contracts/clearance/${c.id}`, + }, + ); + } + + /** Customer uploaded the duty/tax payment slip — GL verifies it. */ + dutySlipUploadedToStaff(c: Contract): void { + this.inAppStaff( + c, + 'Duty slip uploaded', + `Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`, + { + type: NotificationType.PAYMENT_RECEIVED, + link: `/dashboard/contracts/clearance/${c.id}`, + }, + ); + } + + /** Customer filed a shipment request under a GENERAL customs contract. */ + shipmentRequestedToStaff(c: Contract, requestId: string, requestRef: string): void { + this.inAppStaff( + c, + 'New shipment request', + `Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`, + { + link: `/dashboard/shipment-requests/${requestId}`, + data: { contractId: c.id, requestId, reference: requestRef }, + }, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index e31392666..9cf06c905 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -22,6 +22,7 @@ import { FilesService } from '../files/files.service'; import { SignaturesService } from '../signatures/signatures.service'; import { OtpService } from '../otp/otp.service'; import { ContractPricingService } from './contract-pricing.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService } from './contracts.service'; @@ -66,6 +67,7 @@ export class ContractTransitionService { private readonly pdfService: ContractPdfService, private readonly minioService: MinioService, private readonly otpService: OtpService, + private readonly notifier: ContractNotifierService, ) {} /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ @@ -79,7 +81,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'SUBMITTED', } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.submittedToStaff(updated); + return updated; } /** Confirm a price change before submit (mirrors booking confirm-submit). */ @@ -93,7 +97,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'SUBMITTED', } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.submittedToStaff(updated); + return updated; } /** @@ -130,7 +136,9 @@ export class ContractTransitionService { contractValidFrom: validFrom, contractValidUntil: validUntil, } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.accepted(updated); + return updated; } /** @@ -218,7 +226,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'CHANGES_REQUESTED', } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.changesRequested(updated, note); + return updated; } async reject(contractId: string, reason: string, actorId: string): Promise { @@ -235,7 +245,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.rejected(updated, reason); + return updated; } /** Approve one approval step in sequence; → APPROVED when all complete. */ @@ -297,7 +309,11 @@ export class ContractTransitionService { if (Object.keys(updates).length > 0) { await this.contractsRepository.update(contractId, updates as never); } - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + if (allDone) { + this.notifier.approved(updated); + } + return updated; } /** @@ -535,7 +551,9 @@ export class ContractTransitionService { customerSignedAt: new Date(), } as never); await this.regenerateContractPdf(contractId, contract.reference); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.customerSignedToStaff(updated); + return updated; } return this.counterSign(contractId, dto, options); @@ -605,7 +623,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, updates as never); await this.regenerateContractPdf(contractId, contract.reference); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.signedActive(updated); + return updated; } /** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index a22c7cad4..8591a54d8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -13,10 +13,12 @@ import { UnauthorizedException, UploadedFiles, UploadedFile, + UseGuards, UseInterceptors, } from '@nestjs/common'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; import { @@ -242,6 +244,7 @@ export class ContractsController { } @Get('list-summary') + @BookingStaff([FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.contracts.view]) @ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' }) @ApiOkResponse({ type: ContractListSummaryDto }) findListSummary(@Query() filter: FilterContractDto) { @@ -449,14 +452,25 @@ export class ContractsController { } @Post(':id/contract/sign') + @UseGuards(JwtGuard) @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) signContract( @Param('id', ParseUUIDPipe) id: string, @Body() dto: SignContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { + // Each staff signing role maps to the permission that step already requires; + // customers sign their own contract with no permission key. + const signRolePermission: Record = { + STAFF: FREIGHT_PERMS.contracts.signStaff, + DIRECTOR: FREIGHT_PERMS.contracts.approveDirector, + CEO: FREIGHT_PERMS.contracts.approveCeo, + }; + if (dto.role !== 'CUSTOMER') { + assertFreightPermission(user, signRolePermission[dto.role]); + } return this.transitionService.sign(id, dto, { - signerUserId: user?.id ?? user?.sub, + signerUserId: user?.id, }); } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index a9f9dcf5d..33a547a9f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -12,6 +12,8 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { OtpModule } from '../otp/otp.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { BookingsModule } from '../bookings/bookings.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; @@ -19,6 +21,7 @@ import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; import { ContractsRepository } from './contracts.repository'; import { ContractPricingService } from './contract-pricing.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; import { BookingClearanceService } from './booking-clearance.service'; @@ -75,6 +78,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum MinioModule, SignaturesModule, OtpModule, + NotificationsModule, + NotificationInboxModule, CompaniesModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). @@ -94,6 +99,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractsService, ContractsRepository, ContractPricingService, + ContractNotifierService, ContractTransitionService, ContractClearanceService, ClearanceWorkflowService, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 73360f7f5..aaf064bff 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -8,10 +8,14 @@ import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { insertWithGeneratedReference } from '@edr/api-common'; +import { YardCountry } from '@edr/types'; + +import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { CompaniesService } from '../companies/companies.service'; import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity'; import { CompanyStatus } from '../companies/entities/company.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; import { ContractsRepository } from './contracts.repository'; @@ -110,6 +114,49 @@ export class ContractsService { } } + /** + * Every route must match the contract's declared trade direction as derived + * from the yard countries (IMPORT = DJ→ET, EXPORT = ET→DJ, DOMESTIC = + * intercity). Intercity is Ethiopian-domestic only: both yards must be in + * Ethiopia — a Djibouti-internal pair is rejected. Direction mismatches + * (e.g. an export lane on an import contract) are rejected for every kind. + */ + private async assertRoutesMatchDirection( + tradeDirection: string, + routes: CreateContractDto['routes'], + ): Promise { + const yardIds = [ + ...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])), + ]; + const yards = await this.dataSource + .getRepository(Yard) + .find({ where: yardIds.map((id) => ({ id })) }); + const yardById = new Map(yards.map((y) => [y.id, y])); + + for (const route of routes) { + const origin = yardById.get(route.originYardId); + const destination = yardById.get(route.destinationYardId); + if (!origin || !destination) { + throw new BadRequestException('Route references a yard that does not exist'); + } + const derived = deriveTradeDirection(origin, destination); + if (derived !== tradeDirection) { + throw new BadRequestException( + `Route ${origin.label} → ${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`, + ); + } + if ( + derived === 'DOMESTIC' && + (origin.country !== YardCountry.ETHIOPIA || + destination.country !== YardCountry.ETHIOPIA) + ) { + throw new BadRequestException( + `Route ${origin.label} → ${destination.label}: intercity service only runs between Ethiopian yards`, + ); + } + } + } + /** Create a new contract (DRAFT) with its routes and cargo-scope rows. */ async create( dto: CreateContractDto, @@ -144,6 +191,7 @@ export class ContractsService { this.assertCargoScopeShape(dto.freightType, dto.cargoScope); this.assertRouteShape(dto.contractKind, dto.routes); + await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes); // Stamp the operational profile (importer/exporter) for portal scoping. let companyProfileId: string | null = null; @@ -175,6 +223,13 @@ export class ContractsService { // Customs clearing is owned by the service type, not the customer. const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId); + // Intercity never crosses a border, so a customs-including service type is + // a contradiction — the wizard hides them, the API enforces it. + if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) { + throw new BadRequestException( + 'Intercity contracts cannot use a service type that includes customs clearing', + ); + } // An explicit reference is caller-chosen — a collision there is a real // conflict and should surface. Auto-generated references retry past a @@ -360,6 +415,12 @@ export class ContractsService { if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope); if (dto.routes) this.assertRouteShape(contractKind, dto.routes); + if (dto.routes) { + await this.assertRoutesMatchDirection( + dto.tradeDirection ?? existing.tradeDirection, + dto.routes, + ); + } const updates: Record = { contractKind, @@ -385,6 +446,11 @@ export class ContractsService { const includesCustoms = await this.resolveIncludesCustoms( dto.serviceTypeId ?? existing.serviceTypeId, ); + if ((dto.tradeDirection ?? existing.tradeDirection) === 'DOMESTIC' && includesCustoms) { + throw new BadRequestException( + 'Intercity contracts cannot use a service type that includes customs clearing', + ); + } updates.customsClearingEnabled = includesCustoms; updates.customsClearingAgent = includesCustoms ? null @@ -501,6 +567,21 @@ export class ContractsService { ); } + // Surface the staff "request changes" note so the portal can show the + // customer what to fix. Degrade to null on lookup failure — a missing note + // must never 500 a contract fetch. + if (contract.status === 'CHANGES_REQUESTED') { + try { + const note = await this.contractsRepository.findLatestReviewNote( + contract.id, + 'CHANGES_REQUESTED', + ); + contract.latestChangeRequestNote = note?.body ?? null; + } catch { + contract.latestChangeRequestNote = null; + } + } + return contract; } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index ac404c9df..e3130da95 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -120,9 +120,14 @@ export class CreateBookingUnderContractDto { @IsUUID() contractRouteId?: string; - @ApiProperty({ description: 'Binding shipment day.', example: '2026-07-15' }) + @ApiPropertyOptional({ + description: + 'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.', + example: '2026-07-15', + }) + @IsOptional() @IsDateString() - scheduledDate!: string; + scheduledDate?: string; @ApiPropertyOptional({ type: [CreateBookingContainerLineDto] }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index 07d08d3c0..0b0fab41b 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -260,4 +260,11 @@ export class Contract extends BaseEntity { * ContractsRepository.attachClearancePhases for list responses. Not a column. */ clearancePhase?: string | null; + + /** + * Body of the most recent CHANGES_REQUESTED review note, attached by + * ContractsService.findById so the portal can show the customer what staff + * asked them to fix. Lives in contract_review_notes, not a column here. + */ + latestChangeRequestNote?: string | null; } diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 72e2d14d9..181d8b688 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -11,6 +11,7 @@ import { BillingService } from '../billing/billing.service'; import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { FilesService } from '../files/files.service'; import { Booking } from '../bookings/entities/booking.entity'; +import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity'; import { @@ -53,6 +54,7 @@ export class GlOperationsService { private readonly filesService: FilesService, private readonly milestoneService: ClearanceMilestoneService, private readonly billingService: BillingService, + private readonly notifier: BookingLifecycleNotifierService, ) {} private get bookings() { @@ -64,7 +66,11 @@ export class GlOperationsService { } private async getBooking(bookingId: string): Promise { - const booking = await this.bookings.findOne({ where: { id: bookingId } }); + // company is loaded so customer notifications have a phone/email to target. + const booking = await this.bookings.findOne({ + where: { id: bookingId }, + relations: { company: true }, + }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); return booking; } @@ -447,6 +453,7 @@ export class GlOperationsService { void userId; const summary = await this.finalInvoiceSummary(bookingId); if (!summary) throw new NotFoundException('Final invoice could not be created.'); + this.notifier.finalInvoiceCreated(booking, input.amount, input.currency); return summary; } @@ -455,7 +462,7 @@ export class GlOperationsService { bookingId: string, file: Express.Multer.File, ): Promise<{ uploaded: boolean }> { - await this.getBooking(bookingId); + const booking = await this.getBooking(bookingId); if (!file) throw new BadRequestException('No payment slip uploaded'); const invoice = await this.billingService.findInvoice( @@ -482,6 +489,7 @@ export class GlOperationsService { code: 'final_invoice_slip', file, }); + this.notifier.dutySlipUploadedToStaff(booking, 'final'); return { uploaded: true }; } @@ -490,7 +498,7 @@ export class GlOperationsService { bookingId: string, userId?: string, ): Promise { - await this.getBooking(bookingId); + const booking = await this.getBooking(bookingId); const invoice = await this.billingService.findInvoice( Freight.InvoiceSource.Booking, bookingId, @@ -507,6 +515,7 @@ export class GlOperationsService { ); } await this.billingService.markInvoiceAsPaid(invoice.id); + this.notifier.finalInvoicePaid(booking); } void userId; @@ -575,6 +584,7 @@ export class GlOperationsService { }, userId, ); + this.notifier.secondDutyAdvised(booking, input.amount, input.currency ?? 'ETB'); return { advised: true, skipped: false }; } @@ -605,6 +615,7 @@ export class GlOperationsService { booking.tradeDirection ?? 'IMPORT', ); await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID'); + this.notifier.dutySlipUploadedToStaff(booking, 'second'); return { milestoneCompleted: true }; } diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts index b4da558e2..e6b00dce5 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts @@ -8,8 +8,11 @@ import { Body, Query, ParseUUIDPipe, + UploadedFiles, + UseInterceptors, } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { FleetManage, FleetView } from '../../common/booking-guards'; import { DriversService } from './drivers.service'; import { CreateDriverDto } from './dto/create-driver.dto'; @@ -65,6 +68,31 @@ export class DriversController { return this.fleetHistory.getDriverHistory(id); } + @Post(':id/documents') + @FleetManage() + @ApiConsumes('multipart/form-data') + @UseInterceptors(AnyFilesInterceptor()) + @ApiOperation({ summary: 'Upload driver documents (code driver_docs)' }) + uploadDocuments( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.driversService.uploadDocuments(id, files ?? []); + } + + @Get(':id/documents') + @ApiOperation({ summary: "List a driver's documents" }) + listDocuments(@Param('id', ParseUUIDPipe) id: string) { + return this.driversService.listDocuments(id); + } + + @Delete(':id/documents/:fileId') + @FleetManage() + @ApiOperation({ summary: 'Delete a driver document' }) + removeDocument(@Param('fileId', ParseUUIDPipe) fileId: string) { + return this.driversService.removeDocument(fileId); + } + @Patch(':id') @FleetManage() @ApiOperation({ summary: 'Update a driver' }) diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.module.ts b/apps/edr-freight-api/src/modules/drivers/drivers.module.ts index 9e685dcd6..1a6e29e15 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.module.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Driver } from './entities/driver.entity'; import { DriversService } from './drivers.service'; import { DriversController } from './drivers.controller'; +import { FilesModule } from '../files/files.module'; @Module({ - imports: [TypeOrmModule.forFeature([Driver])], + imports: [TypeOrmModule.forFeature([Driver]), FilesModule], providers: [DriversService], controllers: [DriversController], exports: [DriversService], diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts index 6e7c1f69c..e4fa992e8 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts @@ -6,6 +6,11 @@ import { UpdateDriverDto } from './dto/update-driver.dto'; import { Driver, DriverStatus } from './entities/driver.entity'; import { FleetHistoryService } from '../fleet-history/fleet-history.service'; import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; +import { FilesService } from '../files/files.service'; + +/** Resource + code the driver-documents upload area is stored under. */ +const DRIVER_DOCS_RESOURCE = 'driver'; +const DRIVER_DOCS_CODE = 'driver_docs'; @Injectable() export class DriversService { @@ -13,8 +18,37 @@ export class DriversService { @InjectRepository(Driver) private readonly driverRepo: Repository, private readonly history: FleetHistoryService, + private readonly filesService: FilesService, ) {} + /** Upload one or more driver documents (code "driver_docs"). */ + async uploadDocuments(driverId: string, files: Express.Multer.File[]) { + const driver = await this.driverRepo.findOneBy({ id: driverId }); + if (!driver) throw new NotFoundException(`Driver ${driverId} not found`); + if (!files?.length) throw new BadRequestException('No files provided'); + return Promise.all( + files.map((file) => + this.filesService.upload({ + resourceId: driverId, + resource: DRIVER_DOCS_RESOURCE, + code: DRIVER_DOCS_CODE, + file, + }), + ), + ); + } + + /** List a driver's uploaded documents (code "driver_docs"). */ + async listDocuments(driverId: string) { + const all = await this.filesService.findByResource(driverId, DRIVER_DOCS_RESOURCE); + return all.filter((f) => f.code === DRIVER_DOCS_CODE); + } + + /** Delete a single driver document by file id. */ + async removeDocument(fileId: string): Promise { + await this.filesService.remove(fileId); + } + async create(dto: CreateDriverDto): Promise { if (dto.faydaVerified !== true) { throw new BadRequestException( diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index a5c641dd7..4966d7ff9 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -119,6 +119,11 @@ export class FilesService { return record; } + /** Soft-delete a stored file row by id (object bytes are left in MinIO). */ + async remove(id: string): Promise { + await this.filesRepository.softDelete(id); + } + findByResource(resourceId: string, resource: string): Promise { return this.filesRepository.findByResource(resourceId, resource); } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts index f4935a87b..aa618cdb7 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { Freight } from '@edr/types'; @@ -66,13 +66,37 @@ export class FirstMileInvoiceService { return null; } + // Reject mixed-currency truck sets — a single invoice can only be one + // currency, and amounts across currencies can't be summed. + const billableTrucks = (record.vehicleAssignments ?? []).filter( + (a) => Number(a.distanceKm) > 0, + ); + const currencies = [ + ...new Set( + billableTrucks + .map((a) => (a.vehicle as { currency?: string } | undefined)?.currency) + .filter((c): c is string => Boolean(c)), + ), + ]; + if (currencies.length > 1) { + throw new BadRequestException( + `Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`, + ); + } + + // Currency follows the truck (price/km is quoted per vehicle), falling back + // to the booking's currency, then ETB. + const truckCurrency = + (record.vehicle as { currency?: string } | undefined)?.currency || + (record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency; + return this.billing.generateInvoice({ source: 'first_mile' as Freight.InvoiceSource, sourceId: record.id, type: 'DELIVERY_FEE', companyId: fm.booking!.companyId, companyProfileId: fm.booking!.companyProfileId || '', - currency: fm.booking!.paymentCurrency || 'ETB', + currency: truckCurrency || fm.booking!.paymentCurrency || 'ETB', lines: [ { chargeType: 'DELIVERY', diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 5c12d94ee..00dbb80cc 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -640,10 +640,24 @@ export class FirstMileService { { distanceKm: d.distanceKm }, ); } - const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0); + + // Billing is per truck: amount = Σ (truck distance × truck price/km). The + // per-vehicle rate + currency live on the vehicle, so we ignore the legacy + // FIRST_MILE flat rate and any client-sent amount. `remainingPayment` param + // kept only for signature back-compat. + void remainingPayment; + const assignments = await this.dataSource.manager.find(FirstMileVehicleAssignment, { + where: { firstMileId: id }, + relations: { vehicle: true }, + }); + const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0); + const amount = assignments.reduce( + (s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0), + 0, + ); await this.firstMileRepository.update(id, { exactKm: total, - ...(remainingPayment != null ? { remainingPayment } : {}), + remainingPayment: amount, } as any); return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/gps-tracking/dto/gps-device.dto.ts b/apps/edr-freight-api/src/modules/gps-tracking/dto/gps-device.dto.ts new file mode 100644 index 000000000..933699f0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/dto/gps-device.dto.ts @@ -0,0 +1,24 @@ +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +export class RegisterDeviceDto { + @IsString() + imei!: string; + + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsUUID() + vehicleId?: string; +} + +export class UpdateDeviceDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsUUID() + vehicleId?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-device.entity.ts b/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-device.entity.ts new file mode 100644 index 000000000..ac5f7dbc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-device.entity.ts @@ -0,0 +1,55 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +/** + * A physical GPS tracker (GT06). Identified by IMEI, optionally bound to a + * vehicle. Carries the denormalized latest fix so the live map reads one row + * per device without scanning position history. + */ +@Entity({ name: 'gps_devices', schema: 'freight' }) +@Index(['vehicleId']) +export class GpsDevice extends BaseEntity { + @Column({ name: 'imei', type: 'varchar', length: 20, unique: true }) + imei!: string; + + @Column({ name: 'name', type: 'varchar', nullable: true }) + name?: string | null; + + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @ManyToOne(() => Vehicle, { nullable: true, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle | null; + + /** ONLINE once a packet arrives; OFFLINE when stale (derived on read). */ + @Column({ name: 'status', type: 'varchar', length: 16, default: 'REGISTERED' }) + status!: string; + + @Column({ name: 'last_seen_at', type: 'timestamptz', nullable: true }) + lastSeenAt?: Date | null; + + // ── Denormalized latest fix ── + @Column({ name: 'last_lat', type: 'numeric', precision: 10, scale: 6, nullable: true }) + lastLat?: number | null; + + @Column({ name: 'last_lng', type: 'numeric', precision: 10, scale: 6, nullable: true }) + lastLng?: number | null; + + @Column({ name: 'last_speed', type: 'numeric', precision: 6, scale: 2, nullable: true }) + lastSpeed?: number | null; + + @Column({ name: 'last_course', type: 'int', nullable: true }) + lastCourse?: number | null; + + @Column({ name: 'last_fix_at', type: 'timestamptz', nullable: true }) + lastFixAt?: Date | null; + + @Column({ name: 'voltage_level', type: 'int', nullable: true }) + voltageLevel?: number | null; + + @Column({ name: 'gsm_level', type: 'int', nullable: true }) + gsmLevel?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-position.entity.ts b/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-position.entity.ts new file mode 100644 index 000000000..8c63bb78f --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-position.entity.ts @@ -0,0 +1,43 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +/** One GPS fix from a tracker (append-only history). */ +@Entity({ name: 'gps_positions', schema: 'freight' }) +@Index(['deviceId', 'gpsTime']) +@Index(['vehicleId', 'gpsTime']) +export class GpsPosition extends BaseEntity { + @Column({ name: 'device_id', type: 'uuid' }) + deviceId!: string; + + @Column({ name: 'imei', type: 'varchar', length: 20 }) + imei!: string; + + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @Column({ name: 'lat', type: 'numeric', precision: 10, scale: 6 }) + lat!: number; + + @Column({ name: 'lng', type: 'numeric', precision: 10, scale: 6 }) + lng!: number; + + @Column({ name: 'speed', type: 'numeric', precision: 6, scale: 2, default: 0 }) + speed!: number; + + @Column({ name: 'course', type: 'int', default: 0 }) + course!: number; + + @Column({ name: 'satellites', type: 'int', default: 0 }) + satellites!: number; + + @Column({ name: 'positioned', type: 'boolean', default: false }) + positioned!: boolean; + + /** Fix time reported by the device (UTC). */ + @Column({ name: 'gps_time', type: 'timestamptz' }) + gpsTime!: Date; + + /** Non-zero when the fix came in via an alarm packet. */ + @Column({ name: 'alarm', type: 'int', default: 0 }) + alarm!: number; +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts new file mode 100644 index 000000000..e380e541e --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts @@ -0,0 +1,66 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { FleetManage, FleetView } from '../../common/booking-guards'; +import { GpsTrackingService } from './gps-tracking.service'; +import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto'; + +@ApiTags('gps-tracking') +@ApiBearerAuth() +@Controller('gps') +@FleetView() +export class GpsTrackingController { + constructor(private readonly gps: GpsTrackingService) {} + + @Get('positions/latest') + @ApiOperation({ summary: 'Latest fix per device (live map feed)' }) + latest() { + return this.gps.latest(); + } + + @Get('positions/:vehicleId/history') + @ApiOperation({ summary: 'Position history for a vehicle' }) + history( + @Param('vehicleId', ParseUUIDPipe) vehicleId: string, + @Query('limit') limit?: string, + ) { + return this.gps.history(vehicleId, limit ? parseInt(limit, 10) : undefined); + } + + @Get('devices') + @ApiOperation({ summary: 'List GPS trackers' }) + listDevices() { + return this.gps.listDevices(); + } + + @Post('devices') + @FleetManage() + @ApiOperation({ summary: 'Register a GPS tracker' }) + register(@Body() dto: RegisterDeviceDto) { + return this.gps.registerDevice(dto); + } + + @Patch('devices/:id') + @FleetManage() + @ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) { + return this.gps.updateDevice(id, dto); + } + + @Delete('devices/:id') + @FleetManage() + @ApiOperation({ summary: 'Delete a GPS tracker' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.gps.removeDevice(id); + } +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts new file mode 100644 index 000000000..da527fff0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { GpsDevice } from './entities/gps-device.entity'; +import { GpsPosition } from './entities/gps-position.entity'; +import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository'; +import { GpsTrackingService } from './gps-tracking.service'; +import { GpsTrackingController } from './gps-tracking.controller'; +import { Gt06Server } from './gt06/gt06.server'; + +@Module({ + imports: [TypeOrmModule.forFeature([GpsDevice, GpsPosition])], + controllers: [GpsTrackingController], + providers: [GpsDeviceRepository, GpsPositionRepository, GpsTrackingService, Gt06Server], + exports: [GpsTrackingService], +}) +export class GpsTrackingModule {} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.repository.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.repository.ts new file mode 100644 index 000000000..326ef66ac --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.repository.ts @@ -0,0 +1,29 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { GpsDevice } from './entities/gps-device.entity'; +import { GpsPosition } from './entities/gps-position.entity'; + +@Injectable() +export class GpsDeviceRepository extends BaseRepository { + constructor( + @InjectRepository(GpsDevice) repository: Repository, + ) { + super(repository); + } + + findByImei(imei: string): Promise { + return this.repository.findOne({ where: { imei } }); + } +} + +@Injectable() +export class GpsPositionRepository extends BaseRepository { + constructor( + @InjectRepository(GpsPosition) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.service.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.service.ts new file mode 100644 index 000000000..b5bea3a3f --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.service.ts @@ -0,0 +1,124 @@ +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; + +import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository'; +import { GpsDevice } from './entities/gps-device.entity'; +import { Gt06Gps, Gt06Status } from './gt06/gt06.codec'; + +/** A device is considered ONLINE if seen within this window. */ +const ONLINE_WINDOW_MS = 5 * 60 * 1000; + +@Injectable() +export class GpsTrackingService { + private readonly logger = new Logger(GpsTrackingService.name); + + constructor( + private readonly devices: GpsDeviceRepository, + private readonly positions: GpsPositionRepository, + ) {} + + private isOnline(d: GpsDevice): boolean { + return Boolean(d.lastSeenAt && Date.now() - new Date(d.lastSeenAt).getTime() < ONLINE_WINDOW_MS); + } + + /** Find the device for an IMEI, auto-registering it on first contact. */ + private async ensureDevice(imei: string): Promise { + const existing = await this.devices.findByImei(imei); + if (existing) return existing; + this.logger.log(`Auto-registering new GPS tracker ${imei}`); + return this.devices.create({ imei, status: 'REGISTERED', lastSeenAt: new Date() }); + } + + // ── Ingestion (called by the TCP server) ── + + async handleLogin(imei: string): Promise { + const device = await this.ensureDevice(imei); + await this.devices.update(device.id, { lastSeenAt: new Date(), status: 'ONLINE' }); + } + + async handleHeartbeat(imei: string, status: Gt06Status): Promise { + const device = await this.ensureDevice(imei); + await this.devices.update(device.id, { + lastSeenAt: new Date(), + status: 'ONLINE', + voltageLevel: status.voltageLevel, + gsmLevel: status.gsmLevel, + }); + } + + async handleFix(imei: string, gps: Gt06Gps, alarm = 0, status?: Gt06Status): Promise { + const device = await this.ensureDevice(imei); + const now = new Date(); + await this.devices.update(device.id, { + lastSeenAt: now, + status: 'ONLINE', + lastLat: gps.latitude, + lastLng: gps.longitude, + lastSpeed: gps.speed, + lastCourse: gps.course, + lastFixAt: new Date(gps.time), + ...(status ? { voltageLevel: status.voltageLevel, gsmLevel: status.gsmLevel } : {}), + }); + await this.positions.create({ + deviceId: device.id, + imei, + vehicleId: device.vehicleId ?? null, + lat: gps.latitude, + lng: gps.longitude, + speed: gps.speed, + course: gps.course, + satellites: gps.satellites, + positioned: gps.positioned, + gpsTime: new Date(gps.time), + alarm, + }); + } + + // ── Queries / management (REST) ── + + private decorate(d: GpsDevice) { + return { ...d, online: this.isOnline(d) }; + } + + async listDevices() { + const rows = await this.devices.findAll({ relations: { vehicle: true }, order: { createdAt: 'DESC' } }); + return rows.map((d) => this.decorate(d)); + } + + /** Live map feed — devices that have at least one fix. */ + async latest() { + const rows = await this.devices.findAll({ relations: { vehicle: true } }); + return rows.filter((d) => d.lastLat != null && d.lastLng != null).map((d) => this.decorate(d)); + } + + async history(vehicleId: string, limit = 200) { + return this.positions.findAll({ + where: { vehicleId }, + order: { gpsTime: 'DESC' }, + take: Math.min(limit, 1000), + }); + } + + async registerDevice(dto: { imei: string; name?: string; vehicleId?: string | null }) { + const existing = await this.devices.findByImei(dto.imei); + if (existing) throw new BadRequestException(`A device with IMEI ${dto.imei} already exists`); + return this.devices.create({ + imei: dto.imei, + name: dto.name ?? null, + vehicleId: dto.vehicleId ?? null, + status: 'REGISTERED', + }); + } + + async updateDevice(id: string, dto: { name?: string; vehicleId?: string | null }) { + const updated = await this.devices.update(id, { + ...(dto.name !== undefined ? { name: dto.name } : {}), + ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), + }); + if (!updated) throw new NotFoundException(`GPS device ${id} not found`); + return updated; + } + + async removeDevice(id: string): Promise { + await this.devices.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts b/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts new file mode 100644 index 000000000..d54f906a2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts @@ -0,0 +1,207 @@ +/** + * GT06 GPS-tracker protocol codec. + * + * Frame: 0x78 0x78 | len(1) | protocol(1) | content(N) | serial(2) | crc(2) | 0x0D 0x0A + * `len` counts protocol..crc (= 5 + N). CRC-ITU (CRC-16/X.25) is computed over + * len..serial (inclusive) and equals the 2 crc bytes. + */ + +const START = 0x7878; +const STOP = 0x0d0a; + +export const GT06_PROTOCOL = { + LOGIN: 0x01, + LOCATION: 0x12, + HEARTBEAT: 0x13, + STRING: 0x15, + ALARM: 0x16, + ADDRESS_BY_PHONE: 0x1a, + SERVER_COMMAND: 0x80, +} as const; + +/** CRC-16/X.25 (a.k.a. CRC-ITU) used by GT06 — reflected, poly 0x8408, init/xorout 0xFFFF. */ +export function crcItu(bytes: Buffer): number { + let fcs = 0xffff; + for (const b of bytes) { + fcs ^= b; + for (let i = 0; i < 8; i++) { + fcs = fcs & 1 ? (fcs >> 1) ^ 0x8408 : fcs >> 1; + } + } + return (~fcs) & 0xffff; +} + +export interface Gt06Gps { + time: string; // ISO (UTC) + satellites: number; + latitude: number; + longitude: number; + speed: number; // km/h + course: number; // 0-360 + positioned: boolean; +} + +export interface Gt06Lbs { + mcc: number; + mnc: number; + lac: number; + cellId: number; +} + +export interface Gt06Status { + terminalInfo: number; + voltageLevel: number; + gsmLevel: number; + alarm: number; // former byte of alarm/language + charging: boolean; + accOn: boolean; + gpsTracking: boolean; + oilCut: boolean; +} + +export type Gt06Packet = + | { type: 'login'; protocol: number; serial: number; imei: string } + | { type: 'location'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs } + | { type: 'heartbeat'; protocol: number; serial: number; status: Gt06Status } + | { type: 'alarm'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs; status: Gt06Status } + | { type: 'unknown'; protocol: number; serial: number }; + +/** Terminal ID (8 BCD bytes) → 15-digit IMEI (drops the leading pad nibble). */ +function decodeImei(buf: Buffer): string { + return buf.toString('hex').replace(/^0/, ''); +} + +function decodeDateTime(buf: Buffer, off: number): string { + const year = 2000 + buf[off]; + const month = buf[off + 1]; + const day = buf[off + 2]; + const hour = buf[off + 3]; + const min = buf[off + 4]; + const sec = buf[off + 5]; + return new Date(Date.UTC(year, month - 1, day, hour, min, sec)).toISOString(); +} + +/** Convert a GT06 lat/long raw uint32 to decimal degrees (magnitude only). */ +function rawToDegrees(raw: number): number { + return raw / 30000 / 60; +} + +function decodeGps(buf: Buffer, off: number): Gt06Gps { + const time = decodeDateTime(buf, off); + const lenSat = buf[off + 6]; + const satellites = lenSat & 0x0f; + const latRaw = buf.readUInt32BE(off + 7); + const lonRaw = buf.readUInt32BE(off + 11); + const speed = buf[off + 15]; + const cs = buf.readUInt16BE(off + 16); + const hi = (cs >> 8) & 0xff; + const positioned = Boolean(hi & 0x10); // BYTE_1 Bit4 + const isWest = Boolean(hi & 0x08); // BYTE_1 Bit3 (1 = West) + const isNorth = Boolean(hi & 0x04); // BYTE_1 Bit2 (1 = North) + const course = cs & 0x03ff; // BYTE_1 Bit1-0 + BYTE_2 + let latitude = rawToDegrees(latRaw); + let longitude = rawToDegrees(lonRaw); + if (!isNorth) latitude = -latitude; + if (isWest) longitude = -longitude; + return { time, satellites, latitude, longitude, speed, course, positioned }; +} + +function decodeStatus(buf: Buffer, off: number): Gt06Status { + const terminalInfo = buf[off]; + const voltageLevel = buf[off + 1]; + const gsmLevel = buf[off + 2]; + const alarm = buf[off + 3]; // alarm/language former byte + return { + terminalInfo, + voltageLevel, + gsmLevel, + alarm, + oilCut: Boolean(terminalInfo & 0x80), + gpsTracking: Boolean(terminalInfo & 0x40), + charging: Boolean(terminalInfo & 0x04), + accOn: Boolean(terminalInfo & 0x02), + }; +} + +function decodeLbs(buf: Buffer, off: number): Gt06Lbs { + return { + mcc: buf.readUInt16BE(off), + mnc: buf[off + 2], + lac: buf.readUInt16BE(off + 3), + cellId: buf.readUIntBE(off + 5, 3), + }; +} + +function decodeFrame(frame: Buffer): Gt06Packet | null { + // frame = 78 78 len ...content... serial(2) crc(2) 0D 0A + const len = frame[2]; + const protocol = frame[3]; + const serialOff = 3 + (len - 4); // after protocol + content, before serial(2)+crc(2) + const serial = frame.readUInt16BE(serialOff); + const contentOff = 4; // start of content (after protocol) + + switch (protocol) { + case GT06_PROTOCOL.LOGIN: + return { type: 'login', protocol, serial, imei: decodeImei(frame.subarray(contentOff, contentOff + 8)) }; + case GT06_PROTOCOL.LOCATION: + return { type: 'location', protocol, serial, gps: decodeGps(frame, contentOff), lbs: decodeLbs(frame, contentOff + 18) }; + case GT06_PROTOCOL.HEARTBEAT: + return { type: 'heartbeat', protocol, serial, status: decodeStatus(frame, contentOff) }; + case GT06_PROTOCOL.ALARM: { + const gps = decodeGps(frame, contentOff); + // content: date(6)+lenSat(1)+lat(4)+lng(4)+speed(1)+course(2)=18, lbsLen(1), lbs(8), status(1+1+1+2) + const lbs = decodeLbs(frame, contentOff + 18 + 1); + const status = decodeStatus(frame, contentOff + 18 + 1 + 8); + return { type: 'alarm', protocol, serial, gps, lbs, status }; + } + default: + return { type: 'unknown', protocol, serial }; + } +} + +/** + * Pull all complete frames out of a stream buffer. Returns the decoded packets + * (skipping CRC-failed ones) and the trailing bytes that form a partial frame. + */ +export function parseStream(buffer: Buffer): { packets: Gt06Packet[]; rest: Buffer } { + const packets: Gt06Packet[] = []; + let i = 0; + while (i + 5 <= buffer.length) { + if (buffer.readUInt16BE(i) !== START) { + i += 1; // resync + continue; + } + const len = buffer[i + 2]; + const frameLen = 2 + 1 + len + 2; // start + lenByte + (protocol..crc) + stop + if (i + frameLen > buffer.length) break; // incomplete + const frame = buffer.subarray(i, i + frameLen); + if (frame.readUInt16BE(frameLen - 2) === STOP) { + // CRC over len..serial (frame[2 .. frameLen-4]); crc bytes are frameLen-4..frameLen-3. + const crcCalc = crcItu(frame.subarray(2, frameLen - 4)); + const crcRecv = frame.readUInt16BE(frameLen - 4); + if (crcCalc === crcRecv) { + const pkt = decodeFrame(frame); + if (pkt) packets.push(pkt); + } + i += frameLen; + } else { + i += 1; // bad frame, resync + } + } + return { packets, rest: buffer.subarray(i) }; +} + +/** Build a server → terminal ACK (login/heartbeat/alarm) echoing the serial. */ +export function buildAck(protocol: number, serial: number): Buffer { + const body = Buffer.alloc(3); // protocol + serial(2) + body[0] = protocol; + body.writeUInt16BE(serial, 1); + const len = body.length + 2; // + crc(2) + const forCrc = Buffer.concat([Buffer.from([len]), body]); + const crc = crcItu(forCrc); + return Buffer.concat([ + Buffer.from([0x78, 0x78, len]), + body, + Buffer.from([(crc >> 8) & 0xff, crc & 0xff, 0x0d, 0x0a]), + ]); +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts b/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts new file mode 100644 index 000000000..245bfcfcf --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts @@ -0,0 +1,96 @@ +import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common'; +import * as net from 'net'; + +import { GpsTrackingService } from '../gps-tracking.service'; +import { buildAck, GT06_PROTOCOL, parseStream } from './gt06.codec'; + +interface Session { + buffer: Buffer; + imei: string | null; +} + +const MAX_BUFFER = 64 * 1024; + +/** + * Raw TCP listener for GT06 GPS trackers. Trackers open a socket, send a login + * (IMEI), then stream location/heartbeat/alarm packets; we decode, persist via + * {@link GpsTrackingService}, and ACK login/heartbeat/alarm so the device keeps + * the connection alive. Disabled when GT06_TCP_PORT=0. + */ +@Injectable() +export class Gt06Server implements OnApplicationBootstrap, OnModuleDestroy { + private readonly logger = new Logger(Gt06Server.name); + private server?: net.Server; + private readonly sessions = new Map(); + + constructor(private readonly gps: GpsTrackingService) {} + + onApplicationBootstrap(): void { + const port = Number(process.env.GT06_TCP_PORT ?? 5023); + if (!port) { + this.logger.log('GT06 TCP listener disabled (GT06_TCP_PORT=0)'); + return; + } + this.server = net.createServer((socket) => this.onConnection(socket)); + this.server.on('error', (err) => this.logger.error(`GT06 server error: ${String(err)}`)); + this.server.listen(port, () => this.logger.log(`GT06 GPS tracker listener on tcp/${port}`)); + } + + onModuleDestroy(): void { + for (const socket of this.sessions.keys()) socket.destroy(); + this.sessions.clear(); + this.server?.close(); + } + + private onConnection(socket: net.Socket): void { + this.sessions.set(socket, { buffer: Buffer.alloc(0), imei: null }); + socket.on('data', (chunk) => void this.onData(socket, chunk)); + socket.on('error', () => this.sessions.delete(socket)); + socket.on('close', () => this.sessions.delete(socket)); + } + + private async onData(socket: net.Socket, chunk: Buffer): Promise { + const session = this.sessions.get(socket); + if (!session) return; + session.buffer = Buffer.concat([session.buffer, chunk]); + if (session.buffer.length > MAX_BUFFER) session.buffer = Buffer.alloc(0); // drop garbage + + const { packets, rest } = parseStream(session.buffer); + session.buffer = rest; + + for (const pkt of packets) { + try { + await this.handle(socket, session, pkt); + } catch (err) { + this.logger.error(`Failed to handle GT06 packet (${pkt.type}): ${String(err)}`); + } + } + } + + private async handle( + socket: net.Socket, + session: Session, + pkt: ReturnType['packets'][number], + ): Promise { + switch (pkt.type) { + case 'login': + session.imei = pkt.imei; + await this.gps.handleLogin(pkt.imei); + socket.write(buildAck(GT06_PROTOCOL.LOGIN, pkt.serial)); + break; + case 'heartbeat': + if (session.imei) await this.gps.handleHeartbeat(session.imei, pkt.status); + socket.write(buildAck(GT06_PROTOCOL.HEARTBEAT, pkt.serial)); + break; + case 'location': + if (session.imei) await this.gps.handleFix(session.imei, pkt.gps); + break; + case 'alarm': + if (session.imei) await this.gps.handleFix(session.imei, pkt.gps, pkt.status.alarm, pkt.status); + socket.write(buildAck(GT06_PROTOCOL.ALARM, pkt.serial)); + break; + default: + break; + } + } +} 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/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts index 7b14f6887..8a6ec0779 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { Freight } from '@edr/types'; @@ -63,6 +63,30 @@ export class LastMileInvoiceService { return null; } + // Reject mixed-currency truck sets — a single invoice can only be one + // currency, and amounts across currencies can't be summed. + const billableTrucks = (record.vehicleAssignments ?? []).filter( + (a) => Number(a.distanceKm) > 0, + ); + const currencies = [ + ...new Set( + billableTrucks + .map((a) => (a.vehicle as { currency?: string } | undefined)?.currency) + .filter((c): c is string => Boolean(c)), + ), + ]; + if (currencies.length > 1) { + throw new BadRequestException( + `Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`, + ); + } + + // Currency follows the truck (price/km is quoted per vehicle), falling back + // to the booking's currency, then ETB. + const truckCurrency = + (record.vehicle as { currency?: string } | undefined)?.currency || + (record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency; + // Generate invoice with remainingPayment as totalAmount const input: GenerateInvoiceInput = { source: 'last_mile' as Freight.InvoiceSource, @@ -70,7 +94,7 @@ export class LastMileInvoiceService { type: 'DELIVERY_FEE', companyId: lm.booking!.companyId, companyProfileId: lm.booking!.companyProfileId || '', - currency: lm.booking!.paymentCurrency || 'ETB', + currency: truckCurrency || lm.booking!.paymentCurrency || 'ETB', lines: [ { chargeType: 'DELIVERY', diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 9c6433b11..15b8df3e7 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -521,10 +521,24 @@ export class LastMileService { { distanceKm: d.distanceKm }, ); } - const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0); + + // Billing is per truck: amount = Σ (truck distance × truck price/km). The + // per-vehicle rate + currency live on the vehicle, so we ignore the legacy + // LAST_MILE flat rate and any client-sent amount. `remainingPayment` param + // kept only for signature back-compat. + void remainingPayment; + const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, { + where: { lastMileId: id }, + relations: { vehicle: true }, + }); + const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0); + const amount = assignments.reduce( + (s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0), + 0, + ); await this.lastMileRepository.update(id, { exactKm: total, - ...(remainingPayment != null ? { remainingPayment } : {}), + remainingPayment: amount, } as any); return this.findById(id); } 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/notification-inbox/dto/list-notifications-query.dto.ts b/apps/edr-freight-api/src/modules/notification-inbox/dto/list-notifications-query.dto.ts new file mode 100644 index 000000000..a92dcef16 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/dto/list-notifications-query.dto.ts @@ -0,0 +1,30 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, Max, Min } from "class-validator"; + +export class ListNotificationsQueryDto { + @ApiPropertyOptional({ + description: "Filter by read state. Omit to return all.", + }) + @IsOptional() + @Transform(({ value }) => + value === "true" ? true : value === "false" ? false : value, + ) + @IsBoolean() + isRead?: boolean; + + @ApiPropertyOptional({ minimum: 1, default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/entities/notification.entity.ts b/apps/edr-freight-api/src/modules/notification-inbox/entities/notification.entity.ts new file mode 100644 index 000000000..eddb7940b --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/entities/notification.entity.ts @@ -0,0 +1,64 @@ +import { BaseEntity } from "@edr/api-common"; +import { + NotificationAudience, + NotificationChannelsSent, + NotificationPriority, + NotificationType, +} from "@edr/types"; +import { Column, Entity, Index } from "typeorm"; + +/** + * A single persisted in-app notification addressed to one IAM user. Producers + * fan a logical notification out to N recipients by inserting one row per + * resolved user id (see NotificationInboxService.notify). + */ +@Entity({ schema: "freight", name: "notifications" }) +@Index("IDX_NOTIFICATIONS_RECIPIENT_UNREAD", ["recipientUserId", "isRead"]) +@Index("IDX_NOTIFICATIONS_RECIPIENT_CREATED", ["recipientUserId", "createdAt"]) +export class Notification extends BaseEntity { + @Column({ name: "recipient_user_id", type: "uuid" }) + recipientUserId!: string; + + @Column({ name: "audience", type: "varchar", length: 20 }) + audience!: NotificationAudience; + + @Column({ + name: "type", + type: "varchar", + length: 48, + default: NotificationType.GENERIC, + }) + type!: NotificationType; + + @Column({ name: "title", type: "varchar", length: 200 }) + title!: string; + + @Column({ name: "body", type: "text" }) + body!: string; + + /** Deep-link path within the app the item points to (e.g. `/contracts/:id`). */ + @Column({ name: "link", type: "varchar", nullable: true }) + link?: string | null; + + /** Arbitrary structured payload (bookingId, invoiceId, contractId, …). */ + @Column({ name: "data", type: "jsonb", nullable: true }) + data?: Record | null; + + @Column({ + name: "priority", + type: "varchar", + length: 12, + default: NotificationPriority.NORMAL, + }) + priority!: NotificationPriority; + + @Column({ name: "is_read", type: "boolean", default: false }) + isRead!: boolean; + + @Column({ name: "read_at", type: "timestamptz", nullable: true }) + readAt?: Date | null; + + /** Per-channel fan-out outcome for HIGH-priority items (email/SMS). */ + @Column({ name: "channels_sent", type: "jsonb", nullable: true }) + channelsSent?: NotificationChannelsSent | null; +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts new file mode 100644 index 000000000..1d7fd27fc --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts @@ -0,0 +1,79 @@ +import { CurrentUser } from "@edr/api-common"; +import { + NotificationAudience, + NotificationPriority, + NotificationType, +} from "@edr/types"; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { + AuthUserPayload, + resolveAuthUserId, +} from "../../common/resolve-auth-user-id"; +import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto"; +import { NotificationInboxService } from "./notification-inbox.service"; + +@ApiTags("notifications") +@Controller("notifications") +export class NotificationInboxController { + constructor(private readonly service: NotificationInboxService) {} + + @Get() + @ApiOperation({ summary: "List my notifications (paginated, newest first)" }) + list( + @CurrentUser() user: AuthUserPayload, + @Query() query: ListNotificationsQueryDto, + ) { + return this.service.list(resolveAuthUserId(user), query); + } + + @Get("unread-count") + @ApiOperation({ summary: "Count my unread notifications" }) + unreadCount(@CurrentUser() user: AuthUserPayload) { + return this.service.unreadCount(resolveAuthUserId(user)); + } + + @Patch(":id/read") + @ApiOperation({ summary: "Mark one of my notifications as read" }) + markRead( + @CurrentUser() user: AuthUserPayload, + @Param("id", ParseUUIDPipe) id: string, + ) { + return this.service.markRead(id, resolveAuthUserId(user)); + } + + @Post("read-all") + @ApiOperation({ summary: "Mark all my notifications as read" }) + markAllRead(@CurrentUser() user: AuthUserPayload) { + return this.service.markAllRead(resolveAuthUserId(user)); + } + + // TODO: remove before merge — dev/verification helper only. + @Post("test") + @ApiOperation({ + summary: "[dev] Send a test notification to the current user", + }) + sendTest( + @CurrentUser() user: AuthUserPayload, + @Body() + body: { + audience?: NotificationAudience; + type?: NotificationType; + priority?: NotificationPriority; + title?: string; + message?: string; + }, + ) { + return this.service.sendTestToUser(resolveAuthUserId(user), body ?? {}); + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts new file mode 100644 index 000000000..4981a9486 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts @@ -0,0 +1,37 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { BackofficeModule } from "../backoffice/backoffice.module"; +import { CompaniesModule } from "../companies/companies.module"; +import { NotificationsModule } from "../notifications/notifications.module"; +import { Notification } from "./entities/notification.entity"; +import { NotificationInboxController } from "./notification-inbox.controller"; +import { NotificationInboxRepository } from "./notification-inbox.repository"; +import { NotificationInboxService } from "./notification-inbox.service"; +import { NotificationRecipientsService } from "./notification-recipients.service"; +import { NotificationsGateway } from "./notifications.gateway"; +import { WsAuthService } from "./ws-auth.service"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Notification, User, Session]), + // ExternalProfileRepository + CompanyProfileRepository (portal targeting) + CompaniesModule, + // BackofficeService.getOrganizationEmployees (staff targeting) + BackofficeModule, + // EmailClientService + SmsClientService (HIGH-priority fan-out) + NotificationsModule, + ], + controllers: [NotificationInboxController], + providers: [ + NotificationInboxRepository, + NotificationRecipientsService, + NotificationsGateway, + WsAuthService, + NotificationInboxService, + ], + exports: [NotificationInboxService], +}) +export class NotificationInboxModule {} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.repository.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.repository.ts new file mode 100644 index 000000000..a3842c9e3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.repository.ts @@ -0,0 +1,59 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { FindOptionsWhere, Repository } from "typeorm"; + +import { Notification } from "./entities/notification.entity"; + +@Injectable() +export class NotificationInboxRepository extends BaseRepository { + constructor( + @InjectRepository(Notification) + repo: Repository, + ) { + super(repo); + } + + /** Newest-first page of a recipient's notifications, optionally read-filtered. */ + async findForRecipient( + userId: string, + opts: { page?: number; limit?: number; isRead?: boolean } = {}, + ): Promise<[Notification[], number]> { + const page = opts.page && opts.page > 0 ? opts.page : 1; + const limit = opts.limit && opts.limit > 0 ? opts.limit : 20; + const where: FindOptionsWhere = { recipientUserId: userId }; + if (typeof opts.isRead === "boolean") { + where.isRead = opts.isRead; + } + return this.repository.findAndCount({ + where, + order: { createdAt: "DESC" }, + skip: (page - 1) * limit, + take: limit, + }); + } + + async countUnread(userId: string): Promise { + return this.repository.count({ + where: { recipientUserId: userId, isRead: false }, + }); + } + + /** Mark a single notification read (scoped to its recipient). Returns true if it changed. */ + async markRead(id: string, userId: string): Promise { + const result = await this.repository.update( + { id, recipientUserId: userId, isRead: false }, + { isRead: true, readAt: new Date() }, + ); + return (result.affected ?? 0) > 0; + } + + /** Mark all of a recipient's unread notifications read. Returns the count updated. */ + async markAllRead(userId: string): Promise { + const result = await this.repository.update( + { recipientUserId: userId, isRead: false }, + { isRead: true, readAt: new Date() }, + ); + return result.affected ?? 0; + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts new file mode 100644 index 000000000..97ee0380e --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts @@ -0,0 +1,239 @@ +import { + NotificationAudience, + NotificationChannels, + NotificationChannelsSent, + NotificationDto, + NotificationListResult, + NotificationPriority, + NotificationType, + NotifyInput, +} from "@edr/types"; +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { Repository } from "typeorm"; + +import { EmailClientService } from "../notifications/email-client.service"; +import { SmsClientService } from "../notifications/sms-client.service"; +import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto"; +import { Notification } from "./entities/notification.entity"; +import { NotificationInboxRepository } from "./notification-inbox.repository"; +import { NotificationRecipientsService } from "./notification-recipients.service"; +import { NotificationsGateway } from "./notifications.gateway"; + +/** + * The single entry point subsystems use for in-app notifications. Call + * {@link notify}; everything else (reads, mark-read) backs the REST controller. + * + * `notify` is deliberately fault-tolerant: it never throws into the caller so a + * notification failure can't roll back or break the business transaction that + * triggered it. Failures are logged. + */ +@Injectable() +export class NotificationInboxService { + private readonly logger = new Logger(NotificationInboxService.name); + + constructor( + private readonly repo: NotificationInboxRepository, + private readonly recipients: NotificationRecipientsService, + private readonly gateway: NotificationsGateway, + private readonly emailClient: EmailClientService, + private readonly smsClient: SmsClientService, + @InjectRepository(User) + private readonly users: Repository, + ) {} + + /** + * Fan a logical notification out to every resolved recipient: persist one row + * each, push it live over WebSocket, and (for HIGH priority) also queue + * email/SMS via the existing clients. + */ + async notify(input: NotifyInput): Promise { + try { + const userIds = await this.recipients.resolve(input.recipients); + if (userIds.length === 0) { + this.logger.debug( + `notify(${input.type}) resolved 0 recipients — skipped`, + ); + return; + } + const priority = input.priority ?? NotificationPriority.NORMAL; + + for (const userId of userIds) { + await this.deliverToUser(userId, input, priority); + } + } catch (err) { + this.logger.error( + `notify failed: ${(err as Error).message}`, + (err as Error).stack, + ); + } + } + + async list( + userId: string, + query: ListNotificationsQueryDto, + ): Promise { + const [items, count] = await this.repo.findForRecipient(userId, { + page: query.page, + limit: query.limit, + isRead: query.isRead, + }); + const unreadCount = await this.repo.countUnread(userId); + return { items: items.map((n) => this.toDto(n)), count, unreadCount }; + } + + async unreadCount(userId: string): Promise<{ unreadCount: number }> { + return { unreadCount: await this.repo.countUnread(userId) }; + } + + async markRead( + id: string, + userId: string, + ): Promise<{ success: boolean; unreadCount: number }> { + const success = await this.repo.markRead(id, userId); + const unreadCount = await this.repo.countUnread(userId); + this.gateway.emitUnreadCount(userId, unreadCount); + return { success, unreadCount }; + } + + async markAllRead( + userId: string, + ): Promise<{ updated: number; unreadCount: number }> { + const updated = await this.repo.markAllRead(userId); + const unreadCount = await this.repo.countUnread(userId); + this.gateway.emitUnreadCount(userId, unreadCount); + return { updated, unreadCount }; + } + + /** [dev/verification only] Send a canned notification straight to one user. */ + async sendTestToUser( + userId: string, + body: { + audience?: NotificationAudience; + type?: NotificationType; + priority?: NotificationPriority; + title?: string; + message?: string; + }, + ): Promise { + const entity = await this.repo.create({ + recipientUserId: userId, + audience: body.audience ?? NotificationAudience.BACKOFFICE, + type: body.type ?? NotificationType.GENERIC, + title: body.title ?? "Test notification", + body: body.message ?? "This is a test in-app notification.", + priority: body.priority ?? NotificationPriority.NORMAL, + isRead: false, + }); + const dto = this.toDto(entity); + this.gateway.emitNew(userId, dto, await this.repo.countUnread(userId)); + return dto; + } + + private async deliverToUser( + userId: string, + input: NotifyInput, + priority: NotificationPriority, + ): Promise { + const entity = await this.repo.create({ + recipientUserId: userId, + audience: input.audience, + type: input.type, + title: input.title, + body: input.body, + link: input.link ?? null, + data: input.data ?? null, + priority, + isRead: false, + }); + + const unreadCount = await this.repo.countUnread(userId); + this.gateway.emitNew(userId, this.toDto(entity), unreadCount); + + const channels = this.resolveChannels(input, priority); + if (channels.email || channels.sms) { + const channelsSent = await this.fanOut(userId, input, channels); + if (channelsSent) { + await this.repo.update(entity.id, { channelsSent }); + } + } + } + + /** + * Decide which outbound channels to use. An explicit `input.channels` + * selection wins; otherwise fall back to priority (HIGH ⇒ email + SMS). + */ + private resolveChannels( + input: NotifyInput, + priority: NotificationPriority, + ): Required { + if (input.channels) { + return { + email: input.channels.email === true, + sms: input.channels.sms === true, + }; + } + const high = priority === NotificationPriority.HIGH; + return { email: high, sms: high }; + } + + /** + * Best-effort email/SMS fan-out for the requested channels. Skips a channel + * the recipient has no address for. Never throws. + */ + private async fanOut( + userId: string, + input: NotifyInput, + channels: Required, + ): Promise { + try { + const user = await this.users.findOne({ + where: { id: userId } as never, + }); + if (!user) return null; + + const sent: NotificationChannelsSent = {}; + const text = `${input.title}\n\n${input.body}`; + + if (channels.email && user.email) { + const res = await this.emailClient.sendEmail({ + to: user.email, + subject: input.title, + text, + }); + sent.email = res.queued; + } + if (channels.sms && user.phoneNumber) { + const res = await this.smsClient.sendSms({ + to: user.phoneNumber, + message: text, + }); + sent.sms = res.queued; + } + return Object.keys(sent).length ? sent : null; + } catch (err) { + this.logger.warn( + `fan-out failed for user ${userId}: ${(err as Error).message}`, + ); + return null; + } + } + + private toDto(n: Notification): NotificationDto { + return { + id: n.id, + recipientUserId: n.recipientUserId, + audience: n.audience, + type: n.type, + title: n.title, + body: n.body, + link: n.link ?? null, + data: n.data ?? null, + priority: n.priority, + isRead: n.isRead, + readAt: n.readAt ? new Date(n.readAt).toISOString() : null, + createdAt: new Date(n.createdAt).toISOString(), + }; + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts new file mode 100644 index 000000000..be7964a3d --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts @@ -0,0 +1,87 @@ +import { NotificationRecipients } from "@edr/types"; +import { Injectable, Logger } from "@nestjs/common"; + +import { BackofficeService } from "../backoffice/backoffice.service"; +import { CompanyProfileRepository } from "../companies/company-profile.repository"; +import { ExternalProfileRepository } from "../companies/external-profile.repository"; + +/** + * Turns a {@link NotificationRecipients} selector into a de-duplicated set of + * IAM user ids. + * + * - `userIds` → honored as-is. + * - `companyId` → all portal users linked to the company (external_profiles). + * - `companyProfileId` → resolved to its company, then to that company's users. + * - `organizationId` → all current employees of the org (backoffice staff). + * + * NOTE: permission-scoped staff targeting is intentionally unsupported — freight + * has no "users-by-permission" lookup. Target explicit userIds or an org instead. + */ +@Injectable() +export class NotificationRecipientsService { + private readonly logger = new Logger(NotificationRecipientsService.name); + + constructor( + private readonly externalProfiles: ExternalProfileRepository, + private readonly companyProfiles: CompanyProfileRepository, + private readonly backoffice: BackofficeService, + ) {} + + async resolve(recipients: NotificationRecipients): Promise { + const ids = new Set(); + + for (const id of recipients.userIds ?? []) { + if (id) ids.add(id); + } + + let companyId = recipients.companyId; + if (!companyId && recipients.companyProfileId) { + const profile = await this.companyProfiles.findById( + recipients.companyProfileId, + ); + companyId = profile?.companyId ?? undefined; + } + if (companyId) { + const profiles = await this.externalProfiles.findByCompanyId(companyId); + for (const p of profiles) { + if (p.userId) ids.add(p.userId); + } + } + + if (recipients.organizationId) { + try { + const { items } = await this.backoffice.getOrganizationEmployees( + recipients.organizationId, + {}, + ); + for (const employee of items as Array<{ + user?: { id?: string }; + userId?: string; + }>) { + const uid = employee?.user?.id ?? employee?.userId; + if (uid) ids.add(uid); + } + } catch (err) { + this.logger.warn( + `Failed to resolve org recipients for ${recipients.organizationId}: ${ + (err as Error).message + }`, + ); + } + } + + if (recipients.allBackoffice) { + try { + for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) { + ids.add(uid); + } + } catch (err) { + this.logger.warn( + `Failed to resolve allBackoffice recipients: ${(err as Error).message}`, + ); + } + } + + return [...ids]; + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts new file mode 100644 index 000000000..c14dcbaa5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts @@ -0,0 +1,75 @@ +import { + NOTIFICATION_WS_EVENTS, + NOTIFICATION_WS_NAMESPACE, + NotificationDto, +} from "@edr/types"; +import { Logger } from "@nestjs/common"; +import { + OnGatewayConnection, + WebSocketGateway, + WebSocketServer, +} from "@nestjs/websockets"; +import { Server, Socket } from "socket.io"; + +import { WsAuthService } from "./ws-auth.service"; + +/** + * Server → client push for in-app notifications. Clients only *listen* (no + * `@SubscribeMessage` handlers), so the global HTTP JwtGuard never applies here; + * the handshake is authenticated in `handleConnection` and each socket joins a + * private `user:` room the service targets. + */ +@WebSocketGateway({ + namespace: NOTIFICATION_WS_NAMESPACE, + cors: { origin: true, credentials: true }, +}) +export class NotificationsGateway implements OnGatewayConnection { + private readonly logger = new Logger(NotificationsGateway.name); + + @WebSocketServer() + private readonly server!: Server; + + constructor(private readonly wsAuth: WsAuthService) {} + + async handleConnection(socket: Socket): Promise { + const userId = await this.wsAuth.resolveUserId(this.extractToken(socket)); + if (!userId) { + this.logger.debug(`Rejected notifications handshake ${socket.id}`); + socket.disconnect(true); + return; + } + socket.data.userId = userId; + await socket.join(this.room(userId)); + } + + /** Push a freshly-created notification + the new unread count to a user. */ + emitNew(userId: string, notification: NotificationDto, unreadCount: number): void { + const room = this.server.to(this.room(userId)); + room.emit(NOTIFICATION_WS_EVENTS.NEW, notification); + room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount); + } + + /** Push only an updated unread count (e.g. after a read on another tab). */ + emitUnreadCount(userId: string, unreadCount: number): void { + this.server + .to(this.room(userId)) + .emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount); + } + + private room(userId: string): string { + return `user:${userId}`; + } + + private extractToken(socket: Socket): string | undefined { + const authToken = socket.handshake.auth?.token as string | undefined; + if (authToken) return authToken; + + const queryToken = socket.handshake.query?.token; + if (typeof queryToken === "string") return queryToken; + + const header = socket.handshake.headers?.authorization; + if (header?.startsWith("Bearer ")) return header.slice(7); + + return undefined; + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/ws-auth.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/ws-auth.service.ts new file mode 100644 index 000000000..11c178e31 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/ws-auth.service.ts @@ -0,0 +1,51 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { verifyToken } from "@tria-plc/api-common/utils/token"; +import { ESessionStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity"; + +/** + * Authenticates a WebSocket handshake by mirroring the HTTP JwtGuard: the access + * token payload is only a *session* pointer (`{ id: }`), not the + * user — so we verify the signature (`verifyToken`), then load the IAM session + * and require it to be ACTIVE and unexpired, and read the real user id out of + * `session.userInfo`. There is no context-free verifier in the auth package, so + * this lookup is unavoidable; using the typed `Session` entity (rather than raw + * SQL) keeps it column-rename-safe and consistent with the package's own model. + * + * Returns the IAM user id, or null for any invalid/expired/revoked/malformed token. + */ +@Injectable() +export class WsAuthService { + private readonly logger = new Logger(WsAuthService.name); + + constructor( + @InjectRepository(Session) + private readonly sessions: Repository, + ) {} + + async resolveUserId(token?: string): Promise { + if (!token) return null; + try { + const payload = verifyToken(token) as { id?: string }; + const sessionId = payload?.id; + if (!sessionId) return null; + + const session = await this.sessions.findOne({ + where: { id: sessionId }, + }); + if (!session) return null; + if (session.status !== ESessionStatus.ACTIVE) return null; + if (!session.expiryTime || new Date(session.expiryTime) <= new Date()) { + return null; + } + + return session.userInfo?.id ?? null; + } catch (err) { + this.logger.debug(`WS auth rejected: ${(err as Error).message}`); + return null; + } + } +} 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/routes/entities/route.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts index 9c25778dc..a79a54503 100644 --- a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts +++ b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts @@ -1,4 +1,5 @@ import { BaseEntity } from '@edr/api-common'; +import type { ScheduleTradeDirection } from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Yard } from '../../rule-engine/entities/yard.entity'; @@ -26,6 +27,14 @@ export class Route extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' }) status!: RouteStatus; + /** + * Trade direction frozen from the yard countries at create/update + * (ET→DJ = EXPORT, DJ→ET = IMPORT, same country = DOMESTIC/"Intercity"). + * Consumers (scheduling, booking windows) read this instead of re-deriving. + */ + @Column({ name: 'direction', type: 'varchar', length: 10 }) + direction!: ScheduleTradeDirection; + @OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false }) milestones?: RouteMilestone[]; } diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts index 937ffb0ae..34d007c63 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.service.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; import { CreateRouteDto } from './dto/create-route.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto'; @@ -83,6 +84,7 @@ export class RoutesService { originYardId: validated.originYardId, destinationYardId: validated.destinationYardId, status: dto.status ?? 'AVAILABLE', + direction: validated.direction, }), ); @@ -115,6 +117,7 @@ export class RoutesService { originYardId: milestoneInput?.originYardId ?? existing.originYardId, destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId, + ...(milestoneInput ? { direction: milestoneInput.direction } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), }); @@ -187,9 +190,18 @@ export class RoutesService { throw new BadRequestException('Origin and destination yards must be different'); } + const originYardId = normalized[0].yardId; + const destinationYardId = normalized[normalized.length - 1].yardId; + const yardById = new Map(yards.map((yard) => [yard.id, yard])); + const direction = deriveTradeDirection( + yardById.get(originYardId) ?? { country: null }, + yardById.get(destinationYardId) ?? { country: null }, + ); + return { - originYardId: normalized[0].yardId, - destinationYardId: normalized[normalized.length - 1].yardId, + originYardId, + destinationYardId, + direction, milestones: normalized, }; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts index 38f2bc58b..53583e3e8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts @@ -1,5 +1,6 @@ +import { YardCountry } from '@edr/types'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsEnum, IsInt, IsOptional, IsUUID, MaxLength, Min, IsString } from 'class-validator'; export class CreateYardDto { @ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 }) @@ -7,10 +8,9 @@ export class CreateYardDto { @MaxLength(100) label!: string; - @ApiProperty({ description: 'Country where the yard is located, e.g. Ethiopia, Djibouti', maxLength: 50 }) - @IsString() - @MaxLength(50) - country!: string; + @ApiProperty({ enum: YardCountry, description: 'Country where the yard is located' }) + @IsEnum(YardCountry) + country!: YardCountry; @ApiPropertyOptional({ default: true }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts index 249aa1847..3f7f1ae97 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts @@ -1,4 +1,5 @@ import { BaseEntity } from '@edr/api-common'; +import { YardCountry } from '@edr/types'; import { Column, Entity, Index } from 'typeorm'; @Entity({ schema: 'freight', name: 'yards' }) @@ -12,8 +13,11 @@ export class Yard extends BaseEntity { @Column({ name: 'label', type: 'varchar', length: 100 }) label!: string; + // Constrained to YardCountry by DTO validation + a DB CHECK constraint; + // route/schedule trade direction is derived from this value. Typed as the + // enum's literal values so plain strings from seeds/queries still fit. @Column({ name: 'country', type: 'varchar', length: 50 }) - country!: string; + country!: `${YardCountry}`; @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts index 905e827e8..289e502f1 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts @@ -78,6 +78,11 @@ describe('SchedulingRescheduleService', () => { bookingsRepository as never, trainSchedulingService as never, schedulingRescheduleRepository as never, + { + rescheduled: jest.fn(), + removedFromTrain: jest.fn(), + maintenanceMoved: jest.fn(), + } as never, // notifier ); }); diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index dd20b100d..a9a3ae246 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -10,6 +10,7 @@ import { BookingsRepository } from '../bookings/bookings.repository'; import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { BookingNotifierService } from '../train-scheduling/booking-notifier.service'; import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto'; import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository'; @@ -38,6 +39,7 @@ export class SchedulingRescheduleService { private readonly bookingsRepository: BookingsRepository, private readonly trainSchedulingService: TrainSchedulingService, private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository, + private readonly notifier: BookingNotifierService, ) {} /** Preview who is retained, displaced, and readmitted on a schedule. */ @@ -193,9 +195,64 @@ export class SchedulingRescheduleService { displacedBookingIds: dto.displacedBookingIds, }); + // Notify affected customers (SMS + email). Best-effort — a notification + // failure must never fail the reschedule, so each send is fire-and-forget + // inside the notifier. Government pre-empt already notifies via the batch + // displaced() path, so skip removed-from-train notices for that trigger. + // Use the new departure date when the reschedule moved it (the in-memory + // `schedule` still holds the pre-update date). + const effectiveDeparture = dto.newDepartureDate + ? new Date(dto.newDepartureDate) + : schedule.scheduledDepartureDate; + await this.notifyRescheduleOutcome(dto, effectiveDeparture); + return { plan, schedule: assignResult }; } + /** + * Fan out reschedule notifications: bookings that stayed on the train hear the + * new departure date; bookings dropped off the train (staff reschedule, not a + * government pre-empt) hear they were removed. Loads each booking with its + * company so the notifier has a phone/email to reach. + */ + private async notifyRescheduleOutcome( + dto: ExecuteRescheduleDto, + newDeparture: Date | null, + ): Promise { + const isMaintenance = dto.trigger === 'TRAIN_MAINTENANCE'; + const isGovPreempt = dto.trigger === 'GOVERNMENT_PREEMPT'; + + if (newDeparture) { + for (const bookingId of dto.finalBookingIds) { + const booking = await this.loadBookingForNotify(bookingId); + if (!booking) continue; + if (isMaintenance) { + this.notifier.maintenanceMoved(booking, newDeparture); + } else { + this.notifier.rescheduled(booking, newDeparture); + } + } + } + + // Government pre-empt displacements are already announced by the batch + // displaced() notice — don't double-notify. Staff reschedules are not. + if (!isGovPreempt) { + for (const bookingId of dto.displacedBookingIds) { + const booking = await this.loadBookingForNotify(bookingId); + if (!booking) continue; + this.notifier.removedFromTrain(booking); + } + } + } + + private async loadBookingForNotify(bookingId: string): Promise { + try { + return await this.bookingsRepository.findByIdWithFiles(bookingId); + } catch { + return null; + } + } + /** Maintenance shortcut: new departure + rebalance. */ async maintenanceReschedule( scheduleId: string, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts index 4e1770dfb..ba0995679 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -302,3 +302,41 @@ describe('batch-window board windows (config-driven booking cycles)', () => { expect(withEarly?.window?.label).toContain('08:00'); }); }); + +// Regression: a schedule created INSIDE its own window day must open right away +// when the desk is open, and re-deriving after a settings change (close hour +// extended past "now", or lead pulled so the window day becomes today) must +// yield an immediate open — not tomorrow morning. +describe('computeImportWindowTimes — immediate open inside the window day', () => { + // 19:15:17 EAT on Mon 6 Jul = 16:15:17 UTC + const now = new Date('2026-07-06T16:15:17.000Z'); + // Departs Thu 9 Jul ~08:53 EAT + const departure = new Date('2026-07-09T05:53:00.000Z'); + const base = { importWindowLeadDays: 3, windowOpenHour: 8, windowDurationHours: 0.05 }; + + it('desk 8–23, created 19:15 on the window day → opens NOW', () => { + const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now); + expect(t.windowOpensAt.getTime()).toBe(now.getTime()); + }); + + it('desk 8–17, created 19:15 (desk shut) → opens next morning 08:00 EAT', () => { + const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 17 }, now); + expect(t.windowOpensAt.toISOString()).toBe('2026-07-07T05:00:00.000Z'); + }); + + it('close hour extended 17 → 23 after hours: re-derive opens NOW', () => { + // Same call restampPendingWindows makes after the global-rules edit. + const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now); + expect(t.windowOpensAt.getTime()).toBe(now.getTime()); + }); + + it('lead 3 → 4 pulls the window day to today: re-derive opens NOW', () => { + const departsJul10 = new Date('2026-07-10T05:53:00.000Z'); + const t = computeImportWindowTimes( + departsJul10, + { ...base, importWindowLeadDays: 4, windowCloseHour: 23 }, + now, + ); + expect(t.windowOpensAt.getTime()).toBe(now.getTime()); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index dfbcfcb6d..0fdacc572 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -282,15 +282,33 @@ export function computeImportWindowTimes( return { windowOpensAt: opensAt, windowClosesAt: closesAt }; } -/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */ +/** + * Export booking window: a single FCFS window from `exportBookingLeadHours` + * before departure until departure. The open honours the daily desk hours — + * when the raw lead instant lands while the desk is shut, the window opens at + * the next desk opening instead (capped at departure, so a config whose desk + * never opens before the train leaves yields a zero-length window rather than + * one that outlives the train). + */ export function computeExportWindowTimes( departure: Date, - cfg: { exportBookingLeadHours: number }, + cfg: { + exportBookingLeadHours: number; + windowOpenHour: number; + windowCloseHour: number; + }, ): InitialWindowTimes { - return { - windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000), - windowClosesAt: departure, - }; + const rawOpen = new Date( + departure.getTime() - cfg.exportBookingLeadHours * 3_600_000, + ); + let opensAt = officeHoursOpen(rawOpen, { + windowOpenHour: cfg.windowOpenHour, + windowCloseHour: cfg.windowCloseHour, + }); + if (opensAt.getTime() > departure.getTime()) { + opensAt = departure; + } + return { windowOpensAt: opensAt, windowClosesAt: departure }; } /** @@ -421,7 +439,9 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow { * after each close, on the same booking day, until departure. This mirrors * `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the * exact windows the engine runs. - * EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure. + * EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure, + * with the open shifted to the next desk opening when it lands outside office hours + * (same math as `computeExportWindowTimes`). * * `anchorOpensAt` pins the FIRST window's open time to the schedule's stored * `windowOpensAt` instead of recomputing it from config. Pass it so the board @@ -436,8 +456,7 @@ export function listConfigBookingWindows( ): BoardWindow[] { if (direction === 'EXPORT') { const start = - anchorOpensAt ?? - new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000); + anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt; return [boardWindowFromInterval(start, departure)]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 4282e045a..8f11cfc95 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -122,6 +122,7 @@ describe('BookingBatchService — PAID reconcile', () => { { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { emitPhase: jest.fn() } as never, ); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index e6af6c4a0..e2e339e9b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -40,10 +40,11 @@ import { import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; +import { BookingWindowGateway } from './booking-window.gateway'; import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util'; /** A train's remaining capacity along the three physical limits the batch enforces. */ -interface Capacity { +export interface Capacity { wagons: number; weightTons: number; lengthMeters: number; @@ -204,6 +205,7 @@ export class BookingBatchService implements OnModuleInit { private readonly scheduler: SchedulerRegistry, private readonly trainSchedulingService: TrainSchedulingService, private readonly billing: BillingService, + private readonly bookingWindowGateway: BookingWindowGateway, @Optional() private readonly milestoneService?: ClearanceMilestoneService, @Optional() private readonly splitService?: BookingSplitService, @@ -380,6 +382,18 @@ export class BookingBatchService implements OnModuleInit { this.logger.log( `Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`, ); + } else { + // Already linked at booking time (export FCFS: the customer books a + // specific train, so allocate() ran up front). allocate() is where the + // payment-settled tracking milestones are written, so on this branch we + // record them here — otherwise a paid, already-linked booking leaves + // FREIGHT_PAYMENT_SETTLED stuck PENDING and the clearance step never ticks. + void this.completeTrackingMilestones(bookingId, [ + "WAGON_REQUESTED", + "FREIGHT_PAYMENT_PENDING", + "FREIGHT_PAYMENT_SETTLED", + ]); + void this.markWagonAllocatedMilestone(bookingId); } const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( @@ -1444,6 +1458,48 @@ export class BookingBatchService implements OnModuleInit { await this.fillSchedule(booking.trainScheduleId); } + // ---- intercity ride-along API --------------------------------------------- + + /** + * Remaining capacity budget (wagons / weight / length) for a schedule, and + * the per-booking need calculator — exposed for the intercity accept flow, + * which reserves ride-along bookings onto import/export trains outside the + * batch engine. + */ + async intercityCapacity(scheduleId: string): Promise<{ + budget: Capacity; + needFor: (booking: Booking) => Capacity; + } | null> { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !locomotive) return null; + const rules = await this.loadGlobalRules(); + const wagonLengths = await this.loadWagonLengths(); + const limits = await this.capacityLimits(locomotive, rules); + const budget = await this.remainingCapacity(schedule, limits, wagonLengths); + return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) }; + } + + /** + * Accept an intercity booking onto the given train. Commercial bookings get + * the same pay-window lifecycle as a batch reservation (deadline, invoice + * due-date sync, pay-now notify, settle on the window tick), so payment → + * allocation needs no special path. Government bookings allocate directly. + */ + async acceptIntercity(booking: Booking, scheduleId: string): Promise { + if (booking.isGovernment) { + await this.dataSource + .getRepository(Booking) + .update(booking.id, { trainScheduleId: scheduleId }); + booking.trainScheduleId = scheduleId; + await this.allocate(scheduleId, booking, 'gov'); + return; + } + await this.reserve(booking, scheduleId); + this.armSettle(scheduleId); + } + // ---- mutations ------------------------------------------------------------ /** @@ -1473,6 +1529,12 @@ export class BookingBatchService implements OnModuleInit { "PREPAID", ); await this.notifier.payNow(booking, deadline); + // Customer tracking: a wagon slot is reserved and the freight pay window is + // open. Doc-trigger path — silent no-op for bookings without milestone rows. + void this.completeTrackingMilestones(booking.id, [ + "WAGON_REQUESTED", + "FREIGHT_PAYMENT_PENDING", + ]); } /** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */ @@ -1504,6 +1566,15 @@ export class BookingBatchService implements OnModuleInit { this.notifier.secured(booking, reason); void this.triggerWagonAllocation(scheduleId); void this.markWagonAllocatedMilestone(booking.id); + // Customer tracking: freight payment settled (commercial pay-window path). + // Government allocations don't pay upfront — theirs stay pending. + if (reason === 'paid') { + void this.completeTrackingMilestones(booking.id, [ + 'WAGON_REQUESTED', + 'FREIGHT_PAYMENT_PENDING', + 'FREIGHT_PAYMENT_SETTLED', + ]); + } } private async markWagonAllocatedMilestone(bookingId: string): Promise { @@ -1515,6 +1586,27 @@ export class BookingBatchService implements OnModuleInit { } } + /** + * Complete customer-tracking milestones on lifecycle events via the + * doc-trigger path — a silent no-op for bookings without milestone rows + * (non-customs bookings). Never blocks the batch action. + */ + private async completeTrackingMilestones( + bookingId: string, + codes: string[], + ): Promise { + if (!this.milestoneService) return; + for (const code of codes) { + try { + await this.milestoneService.completeByDocTrigger({ bookingId }, code); + } catch (err) { + this.logger.warn( + `Milestone ${code} completion failed for booking ${bookingId}: ${(err as Error).message}`, + ); + } + } + } + /** * Expire an unpaid reservation and free its capacity. With day-level pooling we * also clear `trainScheduleId` so the booking is no longer pinned to the train @@ -1831,6 +1923,17 @@ export class BookingBatchService implements OnModuleInit { await this.dataSource .getRepository(TrainSchedule) .update(scheduleId, { bookingWindowStatus: status }); + // Push the change (open / train full / closed) so portal home and GL cards + // flip in real time — FULL in particular happens outside the window tick + // (batch fill, staff mark-paid) and had no live signal before. + try { + const fresh = await this.trainSchedulesRepository.findById(scheduleId); + if (fresh) this.bookingWindowGateway.emitPhase(fresh); + } catch (err) { + this.logger.warn( + `Booking-window push failed for ${scheduleId}: ${(err as Error).message}`, + ); + } } /** No wagon slots left for allocated + reserved bookings. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 49df19758..f1f63802e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -1,13 +1,23 @@ import { Injectable, Logger } from '@nestjs/common'; +import { + NotificationAudience, + NotificationType, + NotifyInput, +} from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { BATCH_TIMEZONE } from './booking-batch.constants'; @Injectable() export class BookingNotifierService { private readonly logger = new Logger(BookingNotifierService.name); - constructor(private readonly notifications: NotificationsService) {} + constructor( + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} private ref(b: Booking): string { return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; @@ -41,11 +51,34 @@ export class BookingNotifierService { } } + /** Persist + push an in-app item to all portal users of the booking's company. */ + private inApp( + b: Booking, + title: string, + body: string, + overrides: Partial = {}, + ): void { + if (!b.companyId) return; // government/unlinked bookings have no portal users + void this.inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title, + body, + link: `/bookings/${b.id}`, + data: { bookingId: b.id, reference: b.reference }, + ...overrides, + }); + } + async payNow(b: Booking, deadline: Date): Promise { const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW'); + this.inApp(b, 'Payment window open', msg, { + type: NotificationType.INVOICE_ISSUED, + }); } /** @@ -66,6 +99,9 @@ export class BookingNotifierService { `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` + `(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)'); + this.inApp(b, 'Partial allocation offer', msg, { + type: NotificationType.INVOICE_ISSUED, + }); } secured(b: Booking, reason: 'paid' | 'gov'): void { @@ -73,11 +109,13 @@ export class BookingNotifierService { reason === 'gov' ? ' (government)' : '' }.`; void this.notifyContact(b, msg, 'ALLOCATED'); + this.inApp(b, 'Wagon allocated', msg); } expired(b: Booking): void { const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`; void this.notifyContact(b, msg, 'EXPIRED'); + this.inApp(b, 'Payment window expired', msg); } scheduleFull(b: Booking): void { @@ -100,5 +138,42 @@ export class BookingNotifierService { displaced(b: Booking): void { const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`; void this.notifyContact(b, msg, 'DISPLACED'); + this.inApp(b, 'Booking displaced', msg); + } + + /** + * Staff rescheduled the train carrying this booking to a new departure date. + * The booking stays on the train — only the date moved. + */ + rescheduled(b: Booking, newDeparture: Date): void { + const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); + const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`; + void this.notifyContact(b, msg, 'RESCHEDULED'); + this.inApp(b, 'Booking rescheduled', msg); + } + + /** + * Booking was removed from its train during a staff reschedule (not a government + * pre-empt). It returns to eligible — the customer must rebook or reschedule. + */ + removedFromTrain(b: Booking): void { + const msg = + `Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` + + `Please rebook or select a new schedule from the portal.`; + void this.notifyContact(b, msg, 'REMOVED FROM TRAIN'); + this.inApp(b, 'Removed from train', msg); + } + + /** + * The train carrying this booking was moved for maintenance to a new departure + * date. The booking stays on the train — only the date moved. + */ + maintenanceMoved(b: Booking, newDeparture: Date): void { + const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); + const msg = + `The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` + + `New departure date: ${when}.`; + void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE'); + this.inApp(b, 'Train maintenance reschedule', msg); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts new file mode 100644 index 000000000..a439e442d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts @@ -0,0 +1,109 @@ +import { BOOKING_WINDOW_WS_EVENTS, BOOKING_WINDOW_WS_NAMESPACE } from '@edr/types'; +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { io, type Socket } from 'socket.io-client'; + +import { WsAuthService } from '../notification-inbox/ws-auth.service'; +import { BookingWindowGateway } from './booking-window.gateway'; +import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; + +/** + * End-to-end proof the booking-window socket works: boots a real Nest app with + * the gateway, connects a real socket.io client to the namespace, emits a phase + * change, and asserts the client receives the exact payload. If this passes, + * any "no live update" report is environmental (stale server process, wrong + * checkout running, client not connecting) — not the gateway. + */ +describe('BookingWindowGateway (e2e)', () => { + let app: INestApplication; + let gateway: BookingWindowGateway; + let client: Socket; + let baseUrl: string; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + BookingWindowGateway, + // Accept any token — auth plumbing is covered by the real WsAuthService. + { provide: WsAuthService, useValue: { resolveUserId: async () => 'user-1' } }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.listen(0); + const address = app.getHttpServer().address() as { port: number }; + baseUrl = `http://127.0.0.1:${address.port}`; + gateway = app.get(BookingWindowGateway); + }); + + afterAll(async () => { + client?.disconnect(); + await app?.close(); + }); + + it('authenticated client receives the phase event with the schedule state', async () => { + client = io(`${baseUrl}/${BOOKING_WINDOW_WS_NAMESPACE}`, { + auth: { token: 'any' }, + transports: ['websocket'], + }); + await new Promise((resolve, reject) => { + client.on('connect', () => resolve()); + client.on('connect_error', (err) => reject(err)); + }); + + const received = new Promise>((resolve) => { + client.on(BOOKING_WINDOW_WS_EVENTS.PHASE, (payload) => resolve(payload)); + }); + + gateway.emitPhase({ + id: 'sched-1', + originStationId: 'yard-a', + destinationStationId: 'yard-b', + direction: 'IMPORT', + windowPhase: 'OPEN', + bookingWindowStatus: 'OPEN', + bookingCycleNo: 2, + windowOpensAt: new Date('2026-07-06T16:15:00Z'), + windowClosesAt: new Date('2026-07-06T16:18:00Z'), + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + scheduledDepartureDate: new Date('2026-07-09T05:53:00Z'), + } as unknown as TrainSchedule); + + const payload = await received; + expect(payload).toMatchObject({ + scheduleId: 'sched-1', + phase: 'OPEN', + bookingWindowStatus: 'OPEN', + bookingCycleNo: 2, + windowOpensAt: '2026-07-06T16:15:00.000Z', + }); + }); + + it('rejects a client whose token does not resolve to a user', async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + BookingWindowGateway, + { provide: WsAuthService, useValue: { resolveUserId: async () => null } }, + ], + }).compile(); + const rejectingApp = moduleRef.createNestApplication(); + await rejectingApp.listen(0); + const addr = rejectingApp.getHttpServer().address() as { port: number }; + + const rejected = io(`http://127.0.0.1:${addr.port}/${BOOKING_WINDOW_WS_NAMESPACE}`, { + auth: { token: 'bad' }, + transports: ['websocket'], + reconnection: false, + }); + const outcome = await new Promise((resolve) => { + rejected.on('disconnect', () => resolve('disconnected')); + rejected.on('connect_error', () => resolve('rejected')); + // The server accepts the transport then drops it in handleConnection. + setTimeout(() => resolve(rejected.connected ? 'still-connected' : 'disconnected'), 500); + }); + rejected.disconnect(); + await rejectingApp.close(); + expect(outcome).not.toBe('still-connected'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts new file mode 100644 index 000000000..699471d90 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts @@ -0,0 +1,80 @@ +import { + BOOKING_WINDOW_WS_EVENTS, + BOOKING_WINDOW_WS_NAMESPACE, + type BookingWindowPhaseEvent, +} from '@edr/types'; +import { Logger } from '@nestjs/common'; +import { + OnGatewayConnection, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; + +import { WsAuthService } from '../notification-inbox/ws-auth.service'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; + +/** + * Server → client push for booking-window state changes. Same handshake model + * as the notifications gateway: clients only listen, the token is verified on + * connect. Events are broadcast namespace-wide — window state is route-scoped + * public information for signed-in users, and clients filter/invalidate their + * own queries. + */ +@WebSocketGateway({ + namespace: BOOKING_WINDOW_WS_NAMESPACE, + cors: { origin: true, credentials: true }, +}) +export class BookingWindowGateway implements OnGatewayConnection { + private readonly logger = new Logger(BookingWindowGateway.name); + + @WebSocketServer() + private readonly server!: Server; + + constructor(private readonly wsAuth: WsAuthService) {} + + async handleConnection(socket: Socket): Promise { + const userId = await this.wsAuth.resolveUserId(this.extractToken(socket)); + if (!userId) { + this.logger.debug(`Rejected booking-window handshake ${socket.id}`); + socket.disconnect(true); + return; + } + socket.data.userId = userId; + // Log at info so "is anyone actually connected?" is answerable from the + // API log when diagnosing missing live updates. + this.logger.log(`Booking-window client connected (user ${userId})`); + } + + /** Push a schedule's current window state to every connected client. */ + emitPhase(schedule: TrainSchedule): void { + const payload: BookingWindowPhaseEvent = { + scheduleId: schedule.id, + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + direction: schedule.direction ?? null, + phase: (schedule.windowPhase ?? 'PRE_WINDOW') as BookingWindowPhaseEvent['phase'], + bookingWindowStatus: schedule.bookingWindowStatus ?? null, + bookingCycleNo: schedule.bookingCycleNo, + windowOpensAt: schedule.windowOpensAt?.toISOString() ?? null, + windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null, + docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null, + paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null, + scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null, + }; + this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload); + } + + private extractToken(socket: Socket): string | undefined { + const authToken = socket.handshake.auth?.token as string | undefined; + if (authToken) return authToken; + + const queryToken = socket.handshake.query?.token; + if (typeof queryToken === 'string') return queryToken; + + const header = socket.handshake.headers?.authorization; + if (header?.startsWith('Bearer ')) return header.slice(7); + + return undefined; + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 449c67219..429a03a05 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -2,13 +2,19 @@ import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/com import { Cron } from '@nestjs/schedule'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; -import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types'; +import { + NotificationAudience, + NotificationType, + TrainScheduleStatus as TrainScheduleStatusEnum, +} from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { BookingBatchService } from './booking-batch.service'; +import { BookingWindowGateway } from './booking-window.gateway'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; import { BATCH_TIMEZONE } from './booking-batch.constants'; import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util'; @@ -40,6 +46,8 @@ export class BookingWindowService implements OnModuleInit { private readonly bookingBatchService: BookingBatchService, private readonly trainSchedulingService: TrainSchedulingService, private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + private readonly gateway: BookingWindowGateway, ) {} async onModuleInit(): Promise { @@ -48,7 +56,10 @@ export class BookingWindowService implements OnModuleInit { ); } - @Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE }) + // 10-second cadence: every transition is derived from persisted timestamps + // and applied idempotently, so a finer tick only shrinks the lag between a + // deadline passing and the phase actually moving (was a full minute). + @Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE }) async tick(): Promise { if (this.ticking) return; this.ticking = true; @@ -89,9 +100,10 @@ export class BookingWindowService implements OnModuleInit { await this.settleOverdueReservations(); - // Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick. + // Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes + // (30 ticks at the 10-second cadence). this.tickCount += 1; - if (this.tickCount % 5 === 0) { + if (this.tickCount % 30 === 0) { await this.bookingBatchService.runBatchFill(); } } finally { @@ -151,6 +163,9 @@ export class BookingWindowService implements OnModuleInit { ? await this.advanceExport(schedule, now) : await this.advanceImport(schedule, cfg, now); if (!advanced) return; + // Push the new window state to portal home / backoffice GL sections so + // they refresh instantly instead of waiting out their poll interval. + this.gateway.emitPhase(schedule); } } @@ -169,7 +184,9 @@ export class BookingWindowService implements OnModuleInit { await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); schedule.bookingWindowStatus = 'OPEN'; } - await this.notifyWindowOpened(schedule); + // Fire-and-forget: a slow SMS/email gateway must not stall the tick loop + // (the `ticking` guard would otherwise delay every schedule's transition). + void this.notifyWindowOpened(schedule); this.logger.log(`Export booking window opened for schedule ${schedule.id}`); return true; } @@ -216,7 +233,8 @@ export class BookingWindowService implements OnModuleInit { schedule.bookingWindowStatus = 'OPEN'; } // Only announce the first opening of the day; reopen cycles don't re-notify. - if (schedule.bookingCycleNo === 1) await this.notifyWindowOpened(schedule); + // Fire-and-forget so a slow SMS/email gateway never stalls the tick loop. + if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule); this.logger.log( `Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`, ); @@ -368,22 +386,26 @@ export class BookingWindowService implements OnModuleInit { */ private async notifyWindowOpened(schedule: TrainSchedule): Promise { try { - const rows: Array<{ phone: string | null; email: string | null }> = - await this.dataSource.query( - `SELECT DISTINCT - COALESCE(co.contact_person_phone, co.phone) AS phone, - COALESCE(co.email, co.general_manager_email) AS email - FROM freight.contract_routes cr - JOIN freight.contracts c - ON c.id = cr.contract_id - AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') - AND c.deleted_at IS NULL - JOIN freight.companies co ON co.id = c.company_id - WHERE cr.origin_yard_id = $1 - AND cr.destination_yard_id = $2 - AND cr.deleted_at IS NULL`, - [schedule.originStationId, schedule.destinationStationId], - ); + const rows: Array<{ + company_id: string; + phone: string | null; + email: string | null; + }> = await this.dataSource.query( + `SELECT DISTINCT + c.company_id, + COALESCE(co.contact_person_phone, co.phone) AS phone, + COALESCE(co.email, co.general_manager_email) AS email + FROM freight.contract_routes cr + JOIN freight.contracts c + ON c.id = cr.contract_id + AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') + AND c.deleted_at IS NULL + JOIN freight.companies co ON co.id = c.company_id + WHERE cr.origin_yard_id = $1 + AND cr.destination_yard_id = $2 + AND cr.deleted_at IS NULL`, + [schedule.originStationId, schedule.destinationStationId], + ); if (!rows.length) return; const closes = schedule.windowClosesAt @@ -398,6 +420,7 @@ export class BookingWindowService implements OnModuleInit { const seenPhone = new Set(); const seenEmail = new Set(); + const seenCompany = new Set(); for (const r of rows) { if (r.phone && !seenPhone.has(r.phone)) { seenPhone.add(r.phone); @@ -411,9 +434,23 @@ export class BookingWindowService implements OnModuleInit { .directSend('email', r.email, msg) .catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`)); } + // In-app inbox item for every portal user of each eligible company, + // deep-linking to the new-booking page. + if (r.company_id && !seenCompany.has(r.company_id)) { + seenCompany.add(r.company_id); + void this.inbox.notify({ + recipients: { companyId: r.company_id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title: 'Booking window open', + body: msg, + link: '/bookings/new', + data: { trainScheduleId: schedule.id }, + }); + } } this.logger.log( - `Notified ${seenPhone.size} phone / ${seenEmail.size} email contacts of open window for schedule ${schedule.id}`, + `Notified ${seenPhone.size} phone / ${seenEmail.size} email / ${seenCompany.size} companies (in-app) of open window for schedule ${schedule.id}`, ); } catch (err) { this.logger.warn( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/accept-intercity-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/accept-intercity-bookings.dto.ts new file mode 100644 index 000000000..bf1cec26d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/accept-intercity-bookings.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator'; + +export class AcceptIntercityBookingsDto { + @ApiProperty({ + type: [String], + description: + 'Waiting intercity booking ids to accept onto this train, in priority order', + }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + bookingIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts index 9232de29e..a0371178f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts @@ -59,4 +59,15 @@ export class UpdateScheduleWindowRuleDto { @IsInt() @Min(0) importWindowLeadDays?: number; + + @ApiPropertyOptional({ + example: 24, + description: + 'Hours before departure the single FCFS export window opens (EXPORT schedules; re-derives the window start)', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + exportBookingLeadHours?: number; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts new file mode 100644 index 000000000..659eea456 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -0,0 +1,367 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { RouteMilestone } from '../routes/entities/route-milestone.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { BookingBatchService, type Capacity } from './booking-batch.service'; +import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; + +/** + * Intercity (DOMESTIC) ride-along: intercity bookings never get their own + * train — they ride a passing import/export schedule whose route milestones + * contain the booking's origin strictly before its destination. + * + * Flow: the customer books a corridor with no date; at finalize time staff see + * every waiting intercity booking whose corridor lies on the schedule's route, + * with its wagon/weight/length need against the train's remaining capacity; + * accepting reserves it (pay window → payment → allocation, same lifecycle as + * a batch reservation). Cargo is loaded manually when the train reaches the + * booking's origin yard and unloaded at its destination yard. + */ +@Injectable() +export class IntercityService { + private readonly logger = new Logger(IntercityService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly bookingBatchService: BookingBatchService, + ) {} + + /** + * Waiting intercity bookings this schedule could carry, with the train's + * remaining capacity along all three axes (wagons, weight, length) and each + * booking's need, so staff can pick what fits. + */ + async listCandidates(scheduleId: string) { + const schedule = await this.getSchedule(scheduleId); + const milestoneSeq = await this.routeMilestoneSequence(schedule); + const capacity = await this.bookingBatchService.intercityCapacity(scheduleId); + + const waiting = milestoneSeq + ? await this.findWaitingIntercityBookings(milestoneSeq) + : []; + const accepted = await this.findAcceptedIntercityBookings(scheduleId); + + return { + scheduleId, + routeId: schedule.routeId ?? null, + remaining: capacity?.budget ?? null, + candidates: waiting.map((booking) => { + const need = capacity?.needFor(booking) ?? null; + return { + ...this.mapBooking(booking), + need, + fits: need && capacity ? fits(need, capacity.budget) : false, + }; + }), + accepted: accepted.map((booking) => ({ + ...this.mapBooking(booking), + need: capacity?.needFor(booking) ?? null, + })), + }; + } + + /** + * Accept selected waiting intercity bookings onto this train, in the given + * order, each re-checked against the shrinking capacity budget. Commercial + * bookings open a pay window (payment → allocation runs on the existing + * settle lifecycle); government bookings allocate immediately. + */ + async acceptBookings(scheduleId: string, bookingIds: string[]) { + if (bookingIds.length === 0) { + throw new BadRequestException('Select at least one intercity booking'); + } + const schedule = await this.getSchedule(scheduleId); + const milestoneSeq = await this.routeMilestoneSequence(schedule); + if (!milestoneSeq) { + throw new BadRequestException( + 'Schedule has no route milestones — cannot serve intercity corridors', + ); + } + const capacity = await this.bookingBatchService.intercityCapacity(scheduleId); + if (!capacity) { + throw new BadRequestException( + 'Schedule has no locomotive/train set — capacity unknown', + ); + } + + const accepted: string[] = []; + const rejected: Array<{ bookingId: string; reason: string }> = []; + let budget = capacity.budget; + + for (const bookingId of bookingIds) { + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId }, relations: { bookingContainers: true } }); + if (!booking) { + rejected.push({ bookingId, reason: 'Booking not found' }); + continue; + } + const notWaiting = this.whyNotWaiting(booking, milestoneSeq); + if (notWaiting) { + rejected.push({ bookingId, reason: notWaiting }); + continue; + } + const need = capacity.needFor(booking); + if (!fits(need, budget)) { + rejected.push({ + bookingId, + reason: 'Does not fit the remaining wagon/weight/length capacity', + }); + continue; + } + await this.bookingBatchService.acceptIntercity(booking, scheduleId); + budget = subtract(budget, need); + accepted.push(bookingId); + this.logger.log( + `Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`, + ); + } + + return { accepted, rejected, remaining: budget }; + } + + /** + * Mark an accepted intercity booking's cargo as loaded. Only allowed while + * the train is physically at the booking's origin yard: either it has not + * departed yet and the booking boards at the train's own origin, or the + * latest recorded checkpoint is at the booking's origin yard. + */ + async loadBooking(scheduleId: string, bookingId: string) { + const { schedule, booking } = await this.getAcceptedBooking( + scheduleId, + bookingId, + ); + if (booking.status !== 'PAID') { + throw new BadRequestException( + `Booking must be paid before loading (currently ${booking.status})`, + ); + } + await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); + await this.dataSource + .getRepository(Booking) + .update(bookingId, { status: 'IN_TRANSIT' }); + return { bookingId, status: 'IN_TRANSIT' as const }; + } + + /** + * Mark an intercity booking's cargo as unloaded at its destination yard — + * requires the latest checkpoint to be at that yard. Completes the booking. + */ + async unloadBooking(scheduleId: string, bookingId: string) { + const { schedule, booking } = await this.getAcceptedBooking( + scheduleId, + bookingId, + ); + if (booking.status !== 'IN_TRANSIT') { + throw new BadRequestException( + `Booking must be loaded/in transit before unloading (currently ${booking.status})`, + ); + } + await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination'); + await this.dataSource + .getRepository(Booking) + .update(bookingId, { status: 'COMPLETED' }); + return { bookingId, status: 'COMPLETED' as const }; + } + + // ---- helpers --------------------------------------------------------------- + + private async getSchedule(scheduleId: string): Promise { + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: scheduleId } }); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + return schedule; + } + + /** + * yardId → sequenceNo for the schedule's route. Falls back to a two-stop + * origin/destination pseudo-route for legacy schedules without a routeId, + * so an intercity booking exactly matching the train's own corridor still + * qualifies. + */ + private async routeMilestoneSequence( + schedule: TrainSchedule, + ): Promise | null> { + if (schedule.routeId) { + const milestones = await this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } }); + if (milestones.length >= 2) { + return new Map(milestones.map((m) => [m.yardId, m.sequenceNo])); + } + } + if (schedule.originStationId && schedule.destinationStationId) { + return new Map([ + [schedule.originStationId, 1], + [schedule.destinationStationId, 2], + ]); + } + return null; + } + + /** Waiting = ready intercity bookings not yet on any train, corridor on this route. */ + private async findWaitingIntercityBookings( + milestoneSeq: Map, + ): Promise { + const pool = await this.dataSource + .getRepository(Booking) + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .where(`booking.trade_direction = 'DOMESTIC'`) + .andWhere('booking.train_schedule_id IS NULL') + .andWhere( + `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') + OR (booking.is_government = true AND booking.status = 'APPROVED'))`, + ) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + + return pool.filter((b) => this.corridorOnRoute(b, milestoneSeq)); + } + + /** Intercity bookings already reserved/allocated on this schedule. */ + private async findAcceptedIntercityBookings( + scheduleId: string, + ): Promise { + return this.dataSource + .getRepository(Booking) + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .where(`booking.trade_direction = 'DOMESTIC'`) + .andWhere('booking.train_schedule_id = :scheduleId', { scheduleId }) + .orderBy('booking.created_at', 'ASC') + .getMany(); + } + + private corridorOnRoute( + booking: Booking, + milestoneSeq: Map, + ): boolean { + const originSeq = milestoneSeq.get(booking.originYardId); + const destinationSeq = milestoneSeq.get(booking.destinationYardId); + return ( + originSeq != null && destinationSeq != null && originSeq < destinationSeq + ); + } + + private whyNotWaiting( + booking: Booking, + milestoneSeq: Map, + ): string | null { + if (booking.tradeDirection !== 'DOMESTIC') { + return 'Not an intercity booking'; + } + if (booking.trainScheduleId) { + return 'Already assigned to a train'; + } + const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED'; + if (booking.status !== readyStatus) { + return `Not ready to board (status ${booking.status})`; + } + if (!this.corridorOnRoute(booking, milestoneSeq)) { + return "Corridor is not on this schedule's route"; + } + return null; + } + + private async getAcceptedBooking(scheduleId: string, bookingId: string) { + const schedule = await this.getSchedule(scheduleId); + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + if (booking.trainScheduleId !== scheduleId) { + throw new BadRequestException('Booking is not assigned to this schedule'); + } + if (booking.tradeDirection !== 'DOMESTIC') { + throw new BadRequestException('Not an intercity booking'); + } + return { schedule, booking }; + } + + /** + * The train is "at" a yard when the latest recorded checkpoint is that yard, + * or — for a booking boarding at the train's own origin — when the train has + * not recorded any checkpoint yet (still sitting at its origin). + */ + private async assertTrainAtYard( + schedule: TrainSchedule, + yardId: string, + side: 'origin' | 'destination', + ): Promise { + const latest = await this.dataSource + .getRepository(TrainCheckpointEvent) + .findOne({ + where: { trainScheduleId: schedule.id }, + order: { occurredAt: 'DESC', createdAt: 'DESC' }, + }); + + if (!latest) { + if (side === 'origin' && schedule.originStationId === yardId) return; + throw new BadRequestException( + 'Train has not reached this yard yet — record its checkpoint first', + ); + } + if (latest.yardId !== yardId) { + throw new BadRequestException( + `Train's last recorded position is not at the booking's ${side} yard`, + ); + } + } + + private mapBooking(booking: Booking) { + return { + id: booking.id, + reference: booking.reference, + status: booking.status, + freightType: booking.freightType, + isGovernment: booking.isGovernment, + customer: booking.company?.name ?? 'Unknown customer', + originYardId: booking.originYardId, + destinationYardId: booking.destinationYardId, + origin: + booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin', + destination: + booking.destinationYard?.label ?? + booking.destinationYard?.code ?? + 'Unknown destination', + weightTons: Number(booking.cargoTotalWeightVgm ?? 0), + paymentDeadline: booking.paymentDeadline?.toISOString() ?? null, + }; + } +} + +function fits(need: Capacity, budget: Capacity): boolean { + return ( + need.wagons <= budget.wagons && + need.weightTons <= budget.weightTons && + need.lengthMeters <= budget.lengthMeters + ); +} + +function subtract(budget: Capacity, need: Capacity): Capacity { + return { + wagons: budget.wagons - need.wagons, + weightTons: budget.weightTons - need.weightTons, + lengthMeters: budget.lengthMeters - need.lengthMeters, + }; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index fb5ab66d5..1648bd957 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -20,6 +20,7 @@ import { TrainSchedulingManage, TrainSchedulingView, } from "../../common/booking-guards"; +import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto"; import { AssignBookingsDto } from "./dto/assign-bookings.dto"; import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto"; import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; @@ -47,6 +48,7 @@ import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; import { BookingWindowService } from "./booking-window.service"; +import { IntercityService } from "./intercity.service"; import { BillingService } from "../billing/billing.service"; @ApiTags("train-scheduling") @@ -57,6 +59,7 @@ export class TrainSchedulingController { private readonly trainSchedulingService: TrainSchedulingService, private readonly bookingBatchService: BookingBatchService, private readonly bookingWindowService: BookingWindowService, + private readonly intercityService: IntercityService, private readonly billingService: BillingService, ) { } @@ -406,6 +409,54 @@ export class TrainSchedulingController { return this.trainSchedulingService.dispatchSchedule(id); } + @Get("schedules/:id/intercity-candidates") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Waiting intercity bookings this train could carry (corridor on route) + remaining wagon/weight/length capacity", + }) + getIntercityCandidates(@Param("id", ParseUUIDPipe) id: string) { + return this.intercityService.listCandidates(id); + } + + @Post("schedules/:id/intercity/accept") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", + }) + acceptIntercityBookings( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: AcceptIntercityBookingsDto, + ) { + return this.intercityService.acceptBookings(id, dto.bookingIds); + } + + @Post("schedules/:id/intercity/:bookingId/load") + @TrainSchedulingManage() + @ApiOperation({ + summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)", + }) + loadIntercityBooking( + @Param("id", ParseUUIDPipe) id: string, + @Param("bookingId", ParseUUIDPipe) bookingId: string, + ) { + return this.intercityService.loadBooking(id, bookingId); + } + + @Post("schedules/:id/intercity/:bookingId/unload") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", + }) + unloadIntercityBooking( + @Param("id", ParseUUIDPipe) id: string, + @Param("bookingId", ParseUUIDPipe) bookingId: string, + ) { + return this.intercityService.unloadBooking(id, bookingId); + } + @Get("schedules/:id/import-djibouti") @TrainSchedulingView() @ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 7e281a72f..792fb7c64 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -1,5 +1,6 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; @@ -25,10 +26,14 @@ import { TrainSchedulingController } from './train-scheduling.controller'; import { TrainSchedulingService } from './train-scheduling.service'; import { BookingBatchService } from './booking-batch.service'; import { BookingNotifierService } from './booking-notifier.service'; +import { BookingWindowGateway } from './booking-window.gateway'; import { BookingWindowService } from './booking-window.service'; +import { IntercityService } from './intercity.service'; +import { WsAuthService } from '../notification-inbox/ws-auth.service'; import { BookingSplitService } from './booking-split.service'; import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { ContractsModule } from '../contracts/contracts.module'; @Module({ @@ -46,10 +51,13 @@ import { ContractsModule } from '../contracts/contracts.module'; TrainCheckpointEvent, ImportDjiboutiOperation, BookingBatchOffer, + // WsAuthService (booking-window gateway handshake) verifies IAM sessions. + Session, ]), forwardRef(() => BookingsModule), BillingModule, NotificationsModule, + NotificationInboxModule, LocomotivesModule, WagonTypesModule, TrainSetsModule, @@ -64,9 +72,17 @@ import { ContractsModule } from '../contracts/contracts.module'; TrainCheckpointEventsRepository, BookingBatchService, BookingNotifierService, + BookingWindowGateway, + WsAuthService, BookingWindowService, BookingSplitService, + IntercityService, + ], + exports: [ + TrainSchedulingService, + BookingBatchService, + BookingWindowService, + BookingNotifierService, ], - exports: [TrainSchedulingService, BookingBatchService, BookingWindowService], }) export class TrainSchedulingModule {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index b449669ec..6aecd94a8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -154,6 +154,7 @@ describe('TrainSchedulingService', () => { { htmlToPdfBuffer: jest.fn(), } as never, + { emitPhase: jest.fn() } as never, // bookingWindowGateway ); const defaultFleetWagons = [ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 1755a18f8..67eac7bd1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -12,6 +12,7 @@ import { Injectable, Logger, NotFoundException, + Optional, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; @@ -21,6 +22,7 @@ import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; @@ -67,6 +69,7 @@ import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduli import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto'; import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto'; import { type BookingWindowConfig } from './booking-window.config'; +import { BookingWindowGateway } from './booking-window.gateway'; import { buildCappedWagonPlan, computeFleetAvailability, @@ -272,9 +275,63 @@ export class TrainSchedulingService { private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository, private readonly warehouseInventoryService: WarehouseInventoryService, private readonly pdfDocuments: WarehouseReleaseDocumentService, + private readonly bookingWindowGateway: BookingWindowGateway, + @Optional() private readonly milestoneService?: ClearanceMilestoneService, private readonly configService?: ConfigService, ) {} + /** + * Complete customer-tracking clearance milestones for every booking on a + * schedule when a physical lifecycle event fires (dispatch, arrive, load, + * unload, gatepass). Uses the doc-trigger path, which is a silent no-op for + * bookings without milestone rows (non-customs bookings), so this is safe to + * call for every direction and flow. Never blocks the operational action. + */ + private async completeMilestonesForScheduleBookings( + scheduleId: string, + codes: string[], + ): Promise { + if (!this.milestoneService || codes.length === 0) return; + try { + const rows: Array<{ booking_id: string }> = await this.dataSource.query( + `SELECT tsb.booking_id + FROM freight.train_schedule_bookings tsb + WHERE tsb.train_schedule_id = $1 + AND tsb.deleted_at IS NULL`, + [scheduleId], + ); + for (const { booking_id } of rows) { + for (const code of codes) { + await this.milestoneService.completeByDocTrigger( + { bookingId: booking_id }, + code, + ); + } + } + } catch (err) { + this.logger.warn( + `Milestone completion (${codes.join(', ')}) failed for schedule ${scheduleId}: ${(err as Error).message}`, + ); + } + } + + /** + * Push a schedule's current booking-window state over the socket so the + * portal home card and backoffice GL/batch views update in real time — + * used for lifecycle changes outside the window tick (create, cancel, + * finalize, restamp). A push failure must never break the mutation. + */ + private async emitWindowState(scheduleId: string): Promise { + try { + const fresh = await this.trainSchedulesRepository.findById(scheduleId); + if (fresh) this.bookingWindowGateway.emitPhase(fresh); + } catch (err) { + this.logger.warn( + `Booking-window push failed for ${scheduleId}: ${(err as Error).message}`, + ); + } + } + async getEligibleBookings(query: GetEligibleBookingsDto) { // Day-level pooling: when the wizard targets a schedule, surface the whole // (route, EAT day) pool — not just bookings pre-pinned to that train — by @@ -408,7 +465,9 @@ export class TrainSchedulingService { schedule.ruleImportWindowLeadDays ?? liveCfg.importWindowLeadDays, exportBookingLeadHours: - schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours, + dto.exportBookingLeadHours ?? + schedule.ruleExportBookingLeadHours ?? + liveCfg.exportBookingLeadHours, windowOpenHour: dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour, windowCloseHour: @@ -432,6 +491,12 @@ export class TrainSchedulingService { schedule.direction === 'EXPORT' ? computeExportWindowTimes(schedule.scheduledDepartureDate, merged) : computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now); + if (times.windowOpensAt.getTime() >= times.windowClosesAt.getTime()) { + throw new BadRequestException( + 'These settings leave no booking window before departure — with the ' + + 'desk hours applied, the window would only open once the train has left.', + ); + } await this.dataSource.getRepository(TrainSchedule).update(id, { windowOpensAt: times.windowOpensAt, @@ -441,6 +506,7 @@ export class TrainSchedulingService { this.logger.log( `Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`, ); + void this.emitWindowState(id); const fresh = await this.trainSchedulesRepository.findById(id); return fresh ?? schedule; @@ -514,6 +580,7 @@ export class TrainSchedulingService { `Departure date changed for schedule ${id} → ${departure.toISOString()} ` + `(window reopens ${times.windowOpensAt.toISOString()})`, ); + void this.emitWindowState(id); const fresh = await this.trainSchedulesRepository.findById(id); return fresh ?? schedule; @@ -553,6 +620,9 @@ export class TrainSchedulingService { ...windowRuleSnapshot(cfg), }); restamped += 1; + // New times take effect immediately on every card (the tick then opens + // the window within seconds if the re-derived open is already due). + void this.emitWindowState(s.id); } if (restamped > 0) { this.logger.log( @@ -686,10 +756,9 @@ export class TrainSchedulingService { lockedLocomotives.push(locked); } - const direction = deriveScheduleDirection( - route.originYard ?? { country: null }, - route.destinationYard ?? { country: null }, - ); + // Frozen on the route at create/update from the yard-country enum; + // getSchedulableRoute already rejected DOMESTIC (intercity). + const direction = this.resolveRouteDirection(route); const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives); // Effective capacity is capped by the weakest locomotive in the set. @@ -757,6 +826,8 @@ export class TrainSchedulingService { }); const created = await this.getTrainScheduleById(createdScheduleId); + // New window announced — portal home / GL cards pick it up immediately. + void this.emitWindowState(createdScheduleId); return { ...created, warnings: scheduleWarnings }; } @@ -1088,26 +1159,32 @@ export class TrainSchedulingService { { country: schedule.destinationCountry }, ); if (direction === 'IMPORT') { + const result = await this.warehouseInventoryService.autoUnloadArrivedBookings( + scheduleId, + 'SYSTEM_TRAIN_ARRIVAL', + ); + // Customer tracking: cargo is off the train at the destination yard. + void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']); return { direction, action: 'IMPORT_AUTO_UNLOAD', status: 'COMPLETED', - result: await this.warehouseInventoryService.autoUnloadArrivedBookings( - scheduleId, - 'SYSTEM_TRAIN_ARRIVAL', - ), + result, }; } if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) { + const result = await this.warehouseInventoryService.autoUnloadExportAtDjibouti( + scheduleId, + 'SYSTEM_TRAIN_ARRIVAL', + ); + // Customer tracking: cargo is off the train at the Djibouti port. + void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']); return { direction, action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD', status: 'COMPLETED', - result: await this.warehouseInventoryService.autoUnloadExportAtDjibouti( - scheduleId, - 'SYSTEM_TRAIN_ARRIVAL', - ), + result, }; } @@ -1336,6 +1413,8 @@ export class TrainSchedulingService { } }); + // Finalized — push so portal/GL cards reflect the new state instantly. + void this.emitWindowState(scheduleId); return this.getTrainScheduleById(scheduleId); } @@ -1406,6 +1485,22 @@ export class TrainSchedulingService { ); } + // Dispatch closed the window — drop it from portal/GL cards right away. + void this.emitWindowState(scheduleId); + // Customer tracking: cargo is on the departing train — loading milestones + // plus the direction's "departed" handoff milestone. + if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') { + void this.completeMilestonesForScheduleBookings(scheduleId, [ + // CARGO_ARRIVED is export-only (cargo reached the origin yard) — the + // doc-trigger path no-ops it for import bookings. + 'CARGO_ARRIVED', + 'READY_FOR_LOADING', + 'LOADED', + schedule.direction === 'IMPORT' + ? 'DEPARTED_FROM_DJIBOUTI' + : 'DEPARTED_TO_DJIBOUTI', + ]); + } return this.getTrainScheduleById(scheduleId); } @@ -1562,6 +1657,13 @@ export class TrainSchedulingService { LoadingStatus.Loaded, ); } + // Customer tracking: staff confirmed cargo is on the wagons (CARGO_ARRIVED + // is the export-side "cargo reached origin yard" step that precedes it). + void this.completeMilestonesForScheduleBookings(scheduleId, [ + 'CARGO_ARRIVED', + 'READY_FOR_LOADING', + 'LOADED', + ]); return this.getTrainScheduleById(scheduleId); } @@ -2115,6 +2217,7 @@ export class TrainSchedulingService { await this.dataSource .getRepository(TrainSchedule) .update(scheduleId, { bookingWindowStatus: status }); + void this.emitWindowState(scheduleId); } /** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */ @@ -2379,6 +2482,13 @@ export class TrainSchedulingService { } }); + // Customer tracking: the train reached the corridor's far end. + if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') { + void this.completeMilestonesForScheduleBookings(scheduleId, [ + schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI', + ]); + } + const detail = await this.getTrainScheduleById(scheduleId); const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId); return Object.assign(detail, { warehouseAutomation }); @@ -2455,6 +2565,8 @@ export class TrainSchedulingService { } }); + // Window retired (DONE) — remove the card from portal/GL lists right away. + void this.emitWindowState(id); return this.getTrainScheduleById(id); } @@ -2751,7 +2863,16 @@ export class TrainSchedulingService { take: 1, }); return rows[0] ?? null; - } catch { + } catch (err) { + // A read failure here silently downgrades every booking window to the + // hardcoded defaults (desk 8–17, duration 3h, lead 3) while the settings + // UI keeps showing the saved row — a maddening mismatch. The usual cause + // is a missing column (migrations not run on this database). Scream. + this.logger.error( + `Failed to read train-scheduling global rules — booking windows are ` + + `running on HARDCODED DEFAULTS (8–17). Run pending migrations. ` + + `Cause: ${(err as Error).message}`, + ); return null; } } @@ -3447,9 +3568,27 @@ export class TrainSchedulingService { `Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`, ); } + // Intercity (same-country) service is not offered yet — only import/export + // trains can be scheduled. + if (this.resolveRouteDirection(route) === 'DOMESTIC') { + throw new BadRequestException( + `Route ${formatRouteLabel(route)} is an intercity route; intercity scheduling is not available yet`, + ); + } return route; } + /** Stored route direction, deriving from yard countries for pre-migration rows. */ + private resolveRouteDirection(route: Route) { + return ( + route.direction ?? + deriveScheduleDirection( + route.originYard ?? { country: null }, + route.destinationYard ?? { country: null }, + ) + ); + } + private mapEligibleBooking(booking: Booking) { return { id: booking.id, @@ -3762,7 +3901,18 @@ export class TrainSchedulingService { order: { scheduledDepartureDate: 'ASC' }, }); + // A train that has already departed can never be booked, even if the window + // engine hasn't yet flipped its bookingWindowStatus off OPEN. Mirror the + // `scheduled_departure_date >= now()` guard the booking-window SQL uses so a + // past-departure schedule never leaks into the portal day pool, the schedule + // calendar, or the ET GL create-booking gate. + const now = new Date(); return schedules + .filter( + (s) => + s.scheduledDepartureDate != null && + s.scheduledDepartureDate > now, + ) .filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status)) .filter((s) => { // Build the full stop list: origin -> milestones (ordered) -> destination @@ -4054,6 +4204,7 @@ export class TrainSchedulingService { : null, reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null, importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null, + exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null, docReviewMinutes: windowCfg.docReviewMinutes, paymentWindowMinutes: windowCfg.paymentWindowMinutes, }, diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index 9158ecca0..33d441ecb 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -65,4 +65,12 @@ export class CreateVehicleDto { @IsOptional() @IsUUID() locationId?: string; + + @IsOptional() + @IsNumber() + pricePerKm?: number; + + @IsOptional() + @IsString() + currency?: string; } 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..534019bc4 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,29 @@ export class Vehicle extends BaseEntity { @Column({ name: 'location_id', type: 'uuid', nullable: true }) locationId?: string; + + // --- Haulage pricing --- + @Column({ name: 'price_per_km', type: 'numeric', precision: 14, scale: 2, nullable: true }) + pricePerKm?: number; + + /** Currency for pricePerKm: ETB | USD */ + @Column({ name: 'currency', type: 'varchar', length: 8, default: 'ETB' }) + currency?: 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-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts index 6b0465112..b67f1f572 100644 --- a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -20,8 +20,8 @@ const COMPANY_TIN = 'FLMDEMO001'; const COMPANY_EMAIL = 'first-last-mile-demo@edr.local'; const YARDS = [ - { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, - { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, + { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 }, + { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 }, ]; const CONTAINER_TYPES = [ diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index a619c7c05..be5e1c87d 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -28,17 +28,22 @@ const COMPANY_EMAIL = "train-scheduling-demo@edr.local"; const COMPANY_TIN = "1234567890"; const YARDS = [ - { code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 }, + { + code: "DJIBOUTI", + label: "Djibouti", + country: "Djibouti" as const, + displayOrder: 1, + }, { code: "ADDIS_ABABA", label: "Addis Ababa", - country: "Ethiopia", + country: "Ethiopia" as const, displayOrder: 2, }, { code: "DIRE_DAWA", label: "Dire Dawa", - country: "Ethiopia", + country: "Ethiopia" as const, displayOrder: 3, }, ]; diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index cb379890a..6b8529225 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -1,30 +1,20 @@ import { Injectable, Logger } from "@nestjs/common"; import { + Application, Organization, OrganizationConfiguration, Permission, - Position, - PositionPermission, - PositionType, - Role, - RolePermission, Unit, } from "@tria-plc/iamapi-common"; -import { DataSource, EntityManager, In } from "typeorm"; +import { DataSource, EntityManager } from "typeorm"; -import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum"; -import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry"; import { - EDR_FREIGHT_POSITIONS, - EDR_FREIGHT_ROLES, - type FreightSeedPosition, - type FreightSeedRole, + EDR_FREIGHT_APPLICATION, + EDR_FREIGHT_PERMISSIONS, } from "./edr-freight.seed"; -const EDR_UNIT_KEY = "edr_freight_hq"; -const EDR_UNIT_NAME = { en: "EDR Freight HQ" }; -const EDR_POSITION_TYPE_KEY = "edr_freight_role"; -const EDR_POSITION_TYPE_NAME = { en: "EDR Freight Role" }; +const EDR_UNIT_KEY = "edr_freight_app"; +const EDR_UNIT_NAME = { en: "EDR Freight App" }; const EDR_ORG_KEY = "edr_freight"; const EDR_ORG_NAME = { en: "EDR Freight" }; @@ -51,22 +41,15 @@ export class EdrOrgSeeder { const organization = await this.ensureOrganization(manager); await this.ensureOrganizationConfiguration(manager, organization.id); - await this.ensureRoles(manager, EDR_FREIGHT_ROLES); - await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES); - await this.ensureSuperAdminPermissions(manager); + await this.ensureDefaultUnit(manager, organization.id); - // Positions-as-roles: seed operational positions and grant their - // permissions via PositionPermission (not Role/RolePermission). - const unit = await this.ensureDefaultUnit(manager, organization.id); - const positionType = await this.ensureDefaultPositionType(manager, unit.id); - await this.ensurePositions( - manager, - organization.id, - unit.id, - positionType.id, - EDR_FREIGHT_POSITIONS, - ); - await this.ensurePositionPermissions(manager, unit.id, EDR_FREIGHT_POSITIONS); + const application = await this.ensureApplication(manager); + await this.ensurePermissions(manager, application.id); + + // Roles, positions and their permission links are intentionally NOT + // seeded for now — only the application-scoped permission catalog, + // mirroring how the default IAM seed relates permissions to their + // application. Grants are assigned later through the IAM UI. }); this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); @@ -128,111 +111,6 @@ export class EdrOrgSeeder { ); } - private async ensureRoles(manager: EntityManager, seedRoles: FreightSeedRole[]) { - await manager.getRepository(Role).upsert( - seedRoles.map(({ key, name }) => ({ key, name })), - { - conflictPaths: { key: true }, - }, - ); - - this.logger.log( - `Ensured EDR roles '${seedRoles.map((role) => role.key).join("', '")}'`, - ); - } - - private async ensureRolePermissions( - manager: EntityManager, - seedRoles: FreightSeedRole[], - ) { - const permissionKeys = [...new Set(seedRoles.flatMap((role) => role.permissionKeys))]; - - if (!permissionKeys.length) { - this.logger.log("No EDR role permissions configured; skipping role-permission links"); - return; - } - - const roleRepository = manager.getRepository(Role); - const rolePermissionRepository = manager.getRepository(RolePermission); - - const roles = await roleRepository.find({ - where: { key: In(seedRoles.map((role) => role.key)) }, - select: { id: true, key: true }, - }); - const seededPermissions = await manager.getRepository(Permission).find({ - where: { key: In(permissionKeys) }, - select: { id: true, key: true }, - }); - - const roleByKey = new Map(roles.map((role) => [role.key, role])); - const permissionByKey = new Map( - seededPermissions.map((permission) => [permission.key, permission]), - ); - - const rolePermissions = seedRoles.flatMap((role) => { - const seededRole = roleByKey.get(role.key); - - if (!seededRole) { - throw new Error(`missing_role:${role.key}`); - } - - return role.permissionKeys.map((permissionKey) => { - const seededPermission = permissionByKey.get(permissionKey); - - if (!seededPermission) { - throw new Error(`missing_permission:${permissionKey}`); - } - - return { - roleId: seededRole.id, - permissionId: seededPermission.id, - }; - }); - }); - - await rolePermissionRepository.upsert(rolePermissions, { - conflictPaths: { roleId: true, permissionId: true }, - }); - - this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`); - } - - private async ensureSuperAdminPermissions(manager: EntityManager) { - const role = await manager.getRepository(Role).findOne({ - where: { key: ERoleKey.SUPER_ADMIN }, - select: { id: true, key: true }, - }); - - if (!role) { - this.logger.warn( - `Role ${ERoleKey.SUPER_ADMIN} not found; skipping booking/rule-engine super_admin links`, - ); - return; - } - - const permissions = await manager.getRepository(Permission).find({ - where: { key: In(BOOKING_RULE_ENGINE_PERMISSION_KEYS) }, - select: { id: true, key: true }, - }); - - if (!permissions.length) { - this.logger.warn('No booking/rule-engine permissions found for super_admin'); - return; - } - - await manager.getRepository(RolePermission).upsert( - permissions.map((permission) => ({ - roleId: role.id, - permissionId: permission.id, - })), - { conflictPaths: { roleId: true, permissionId: true } }, - ); - - this.logger.log( - `Ensured ${permissions.length} booking+rule-engine permissions on super_admin`, - ); - } - private async ensureDefaultUnit( manager: EntityManager, organizationId: string, @@ -258,120 +136,51 @@ export class EdrOrgSeeder { return { id: unit.id }; } - private async ensureDefaultPositionType( + private async ensureApplication( manager: EntityManager, - unitId: string, ): Promise<{ id: string }> { - const positionTypeRepository = manager.getRepository(PositionType); + const applicationRepository = manager.getRepository(Application); - // PositionType has no unique constraint on (key, unitId); find-then-insert. - let positionType = await positionTypeRepository.findOne({ - where: { key: EDR_POSITION_TYPE_KEY, unitId }, + const application = await applicationRepository.findOne({ + where: { key: EDR_FREIGHT_APPLICATION.key }, select: { id: true }, }); - if (!positionType) { - const insertResult = await positionTypeRepository.insert({ - key: EDR_POSITION_TYPE_KEY, - name: EDR_POSITION_TYPE_NAME, - isSystem: true, - unitId, + if (!application?.id) { + const insertResult = await applicationRepository.insert({ + id: EDR_FREIGHT_APPLICATION.id, + key: EDR_FREIGHT_APPLICATION.key, + name: { ...EDR_FREIGHT_APPLICATION.name }, }); - this.logger.log(`Seeded EDR position type '${EDR_POSITION_TYPE_KEY}'`); + this.logger.log(`Seeded EDR application '${EDR_FREIGHT_APPLICATION.key}'`); return { id: insertResult.identifiers[0]?.id as string }; } - this.logger.log(`Ensured EDR position type '${EDR_POSITION_TYPE_KEY}'`); - return { id: positionType.id }; + this.logger.log(`Ensured EDR application '${EDR_FREIGHT_APPLICATION.key}'`); + return { id: application.id }; } - private async ensurePositions( + private async ensurePermissions( manager: EntityManager, - organizationId: string, - unitId: string, - positionTypeId: string, - seedPositions: FreightSeedPosition[], + applicationId: string, ) { - await manager.getRepository(Position).upsert( - seedPositions.map(({ key, name, rank }) => ({ - key, - name, - rank, - organizationId, - unitId, - positionTypeId, + const permissionRepository = manager.getRepository(Permission); + + // Upsert by key so reruns are idempotent; applicationId ties every + // permission to the EDR Freight application (also backfills rows that + // were previously seeded without the relation). + await permissionRepository.upsert( + EDR_FREIGHT_PERMISSIONS.map((permission) => ({ + id: permission.id, + key: permission.key, + name: { ...permission.name }, + applicationId, })), - { - conflictPaths: { key: true, unitId: true }, - }, + { conflictPaths: { key: true } }, ); this.logger.log( - `Ensured ${seedPositions.length} EDR positions '${seedPositions - .map((position) => position.key) - .join("', '")}'`, - ); - } - - private async ensurePositionPermissions( - manager: EntityManager, - unitId: string, - seedPositions: FreightSeedPosition[], - ) { - const permissionKeys = [ - ...new Set(seedPositions.flatMap((position) => position.permissionKeys)), - ]; - - if (!permissionKeys.length) { - this.logger.log( - "No EDR position permissions configured; skipping position-permission links", - ); - return; - } - - const positions = await manager.getRepository(Position).find({ - where: { key: In(seedPositions.map((position) => position.key)), unitId }, - select: { id: true, key: true }, - }); - const seededPermissions = await manager.getRepository(Permission).find({ - where: { key: In(permissionKeys) }, - select: { id: true, key: true }, - }); - - const positionByKey = new Map( - positions.map((position) => [position.key, position]), - ); - const permissionByKey = new Map( - seededPermissions.map((permission) => [permission.key, permission]), - ); - - const positionPermissions = seedPositions.flatMap((position) => { - const seededPosition = positionByKey.get(position.key); - - if (!seededPosition) { - throw new Error(`missing_position:${position.key}`); - } - - return position.permissionKeys.map((permissionKey) => { - const seededPermission = permissionByKey.get(permissionKey); - - if (!seededPermission) { - throw new Error(`missing_permission:${permissionKey}`); - } - - return { - positionId: seededPosition.id as string, - permissionId: seededPermission.id, - }; - }); - }); - - await manager.getRepository(PositionPermission).upsert(positionPermissions, { - conflictPaths: { positionId: true, permissionId: true }, - }); - - this.logger.log( - `Ensured ${positionPermissions.length} EDR position-permission links`, + `Ensured ${EDR_FREIGHT_PERMISSIONS.length} permissions on application '${EDR_FREIGHT_APPLICATION.key}'`, ); } } diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index e5e3bb139..1aef33801 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -512,6 +512,32 @@ const CONTRACT_INTAKE_SETTINGS: OnboardingDocumentSetting[] = [ const CLEARANCE_DESCRIPTION = "Operation/clearance documents collected after contract execution, by operation, freight type and customs."; +// ── Driver documents ──────────────────────────────────────────────────────── +// Configurable upload area (code "driver_docs") attached to a driver profile — +// license, national ID, contracts, training certificates, etc. +const DRIVER_DOCUMENT_FIELDS: OnboardingField[] = [ + { + fileKey: "driver_docs", + fileLabel: "Driver documents", + helpText: "License, national ID, contracts, training certificates, etc.", + isRequired: false, + isMultiple: true, + maxFiles: 20, + allowedExtensions: ["pdf", "jpg", "jpeg", "png", "doc", "docx"], + maxSizeMb: 10, + displayOrder: 1, + }, +]; + +const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ + { + code: "driver_docs", + label: "Driver documents", + entity: "driver", + fields: DRIVER_DOCUMENT_FIELDS, + }, +]; + @Injectable() export class FileUploadSettingsSeeder { private readonly logger = new Logger(FileUploadSettingsSeeder.name); @@ -549,6 +575,11 @@ export class FileUploadSettingsSeeder { description: "Commercial/framework documents attached at contract submission.", })), + ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: + "Documents uploaded against a driver profile (license, ID, contracts, etc.).", + })), ]; for (const documentSetting of allSettings) { diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index d70cb5fb4..d4d85e84c 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -60,6 +60,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'), perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'), perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'), + perm('a1000001-0001-4000-8000-000000000024', 'edr_freight_app:bookings:create', 'Create booking'), ]; /** @@ -117,11 +118,209 @@ export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [ perm('c1000001-0001-4000-8000-000000000001', 'edr_freight_app:allocation:manage', 'Allocate containers to vehicles'), ]; +/** + * Advanced backoffice resources — full CRUD + workflow-action keys. + * See docs/rbac/freight-backoffice-permissions.md. Additive only: the existing + * bookings/contracts/rule-engine/allocation keys above are unchanged. + */ + +// C. Customers +export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [ + perm('d1a00001-0001-4000-8000-000000000001', 'edr_freight_app:customers:view', 'View customers'), + perm('d1a00001-0001-4000-8000-000000000002', 'edr_freight_app:customers:create', 'Create customer'), + perm('d1a00001-0001-4000-8000-000000000003', 'edr_freight_app:customers:update', 'Update customer'), + perm('d1a00001-0001-4000-8000-000000000004', 'edr_freight_app:customers:deactivate', 'Deactivate customer'), + perm('d1a00001-0001-4000-8000-000000000005', 'edr_freight_app:customers:verify', 'Verify customer (KYC/Fayda)'), +]; + +// D. Finance — payments + invoices +export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ + perm('d2a00001-0001-4000-8000-000000000001', 'edr_freight_app:payments:view', 'View payments'), + perm('d2a00001-0001-4000-8000-000000000002', 'edr_freight_app:payments:verify', 'Verify/settle payment'), + perm('d2a00001-0001-4000-8000-000000000003', 'edr_freight_app:payments:refund', 'Refund payment'), + perm('d2b00001-0001-4000-8000-000000000001', 'edr_freight_app:invoices:view', 'View invoices'), + perm('d2b00001-0001-4000-8000-000000000002', 'edr_freight_app:invoices:create', 'Generate invoice'), + perm('d2b00001-0001-4000-8000-000000000003', 'edr_freight_app:invoices:cancel', 'Cancel invoice'), + perm('d2b00001-0001-4000-8000-000000000004', 'edr_freight_app:invoices:export', 'Download invoice document'), +]; + +// E. First / last mile operations +export const MILE_PERMISSIONS: FreightPermissionSeed[] = [ + perm('d3a00001-0001-4000-8000-000000000001', 'edr_freight_app:first_mile:view', 'View first-mile'), + perm('d3a00001-0001-4000-8000-000000000002', 'edr_freight_app:first_mile:accept', 'Accept first-mile request'), + perm('d3a00001-0001-4000-8000-000000000003', 'edr_freight_app:first_mile:create', 'Create first-mile'), + perm('d3a00001-0001-4000-8000-000000000004', 'edr_freight_app:first_mile:update', 'Update first-mile'), + perm('d3a00001-0001-4000-8000-000000000005', 'edr_freight_app:first_mile:delete', 'Delete first-mile'), + perm('d3a00001-0001-4000-8000-000000000006', 'edr_freight_app:first_mile:assign_vehicles', 'Assign first-mile vehicles'), + perm('d3a00001-0001-4000-8000-000000000007', 'edr_freight_app:first_mile:set_distances', 'Set first-mile distances'), + perm('d3a00001-0001-4000-8000-000000000008', 'edr_freight_app:first_mile:generate_invoice', 'Generate first-mile invoice'), + perm('d3b00001-0001-4000-8000-000000000001', 'edr_freight_app:last_mile:view', 'View last-mile'), + perm('d3b00001-0001-4000-8000-000000000002', 'edr_freight_app:last_mile:accept', 'Accept last-mile request'), + perm('d3b00001-0001-4000-8000-000000000003', 'edr_freight_app:last_mile:create', 'Create last-mile'), + perm('d3b00001-0001-4000-8000-000000000004', 'edr_freight_app:last_mile:update', 'Update last-mile'), + perm('d3b00001-0001-4000-8000-000000000005', 'edr_freight_app:last_mile:delete', 'Delete last-mile'), + perm('d3b00001-0001-4000-8000-000000000006', 'edr_freight_app:last_mile:assign_vehicles', 'Assign last-mile vehicles'), + perm('d3b00001-0001-4000-8000-000000000007', 'edr_freight_app:last_mile:set_distances', 'Set last-mile distances'), + perm('d3b00001-0001-4000-8000-000000000008', 'edr_freight_app:last_mile:generate_invoice', 'Generate last-mile invoice'), +]; + +// F. Fleet — rail assets (splits the flat fleet:view/manage) +export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ + perm('e1a00001-0001-4000-8000-000000000001', 'edr_freight_app:locomotives:view', 'View locomotives'), + perm('e1a00001-0001-4000-8000-000000000002', 'edr_freight_app:locomotives:create', 'Create locomotive'), + perm('e1a00001-0001-4000-8000-000000000003', 'edr_freight_app:locomotives:update', 'Update locomotive'), + perm('e1a00001-0001-4000-8000-000000000004', 'edr_freight_app:locomotives:delete', 'Delete locomotive'), + perm('e1b00001-0001-4000-8000-000000000001', 'edr_freight_app:wagons:view', 'View wagons'), + perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'), + perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'), + perm('e1b00001-0001-4000-8000-000000000004', 'edr_freight_app:wagons:delete', 'Delete wagon'), + perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'), + perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'), + perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'), + perm('e1c00001-0001-4000-8000-000000000004', 'edr_freight_app:trains:delete', 'Delete train'), + perm('e1c00001-0001-4000-8000-000000000005', 'edr_freight_app:trains:assign_wagons', 'Assign wagons to train'), + perm('e1d00001-0001-4000-8000-000000000001', 'edr_freight_app:routes:view', 'View routes'), + perm('e1d00001-0001-4000-8000-000000000002', 'edr_freight_app:routes:create', 'Create route'), + perm('e1d00001-0001-4000-8000-000000000003', 'edr_freight_app:routes:update', 'Update route'), + perm('e1d00001-0001-4000-8000-000000000004', 'edr_freight_app:routes:delete', 'Delete route'), + perm('e1e00001-0001-4000-8000-000000000001', 'edr_freight_app:containers:view', 'View containers'), + perm('e1e00001-0001-4000-8000-000000000002', 'edr_freight_app:containers:create', 'Create container'), + perm('e1e00001-0001-4000-8000-000000000003', 'edr_freight_app:containers:update', 'Update container'), + perm('e1e00001-0001-4000-8000-000000000004', 'edr_freight_app:containers:delete', 'Delete container'), + perm('e1f00001-0001-4000-8000-000000000001', 'edr_freight_app:cargoes:view', 'View cargoes'), + perm('e1f00001-0001-4000-8000-000000000002', 'edr_freight_app:cargoes:create', 'Create cargo'), + perm('e1f00001-0001-4000-8000-000000000003', 'edr_freight_app:cargoes:update', 'Update cargo'), + perm('e1f00001-0001-4000-8000-000000000004', 'edr_freight_app:cargoes:delete', 'Delete cargo'), +]; + +// G. Fleet — road & telemetry +export const FLEET_ROAD_PERMISSIONS: FreightPermissionSeed[] = [ + perm('e2a00001-0001-4000-8000-000000000001', 'edr_freight_app:vehicles:view', 'View vehicles'), + perm('e2a00001-0001-4000-8000-000000000002', 'edr_freight_app:vehicles:create', 'Create vehicle'), + perm('e2a00001-0001-4000-8000-000000000003', 'edr_freight_app:vehicles:update', 'Update vehicle'), + perm('e2a00001-0001-4000-8000-000000000004', 'edr_freight_app:vehicles:delete', 'Delete vehicle'), + perm('e2b00001-0001-4000-8000-000000000001', 'edr_freight_app:drivers:view', 'View drivers'), + perm('e2b00001-0001-4000-8000-000000000002', 'edr_freight_app:drivers:create', 'Create driver'), + perm('e2b00001-0001-4000-8000-000000000003', 'edr_freight_app:drivers:update', 'Update driver'), + perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete driver'), + perm('e2c00001-0001-4000-8000-000000000001', 'edr_freight_app:tracking:view', 'Track vehicles'), + perm('e2d00001-0001-4000-8000-000000000001', 'edr_freight_app:fuel:view', 'View fuel purchases'), + perm('e2d00001-0001-4000-8000-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'), + perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update fuel purchase'), + perm('e2d00001-0001-4000-8000-000000000004', 'edr_freight_app:fuel:delete', 'Delete fuel purchase'), + perm('e2d00001-0001-4000-8000-000000000005', 'edr_freight_app:fuel:approve', 'Approve fuel purchase'), + perm('e2e00001-0001-4000-8000-000000000001', 'edr_freight_app:maintenance:view', 'View maintenance'), + perm('e2e00001-0001-4000-8000-000000000002', 'edr_freight_app:maintenance:create', 'Create maintenance'), + perm('e2e00001-0001-4000-8000-000000000003', 'edr_freight_app:maintenance:update', 'Update maintenance'), + perm('e2e00001-0001-4000-8000-000000000004', 'edr_freight_app:maintenance:delete', 'Delete maintenance'), + perm('e2e00001-0001-4000-8000-000000000005', 'edr_freight_app:maintenance:complete', 'Complete maintenance'), + perm('e2f00001-0001-4000-8000-000000000001', 'edr_freight_app:fleet_reports:view', 'View fleet financial reports'), + perm('e2f00001-0001-4000-8000-000000000002', 'edr_freight_app:fleet_reports:export', 'Export fleet financial reports'), + perm('e2000001-0001-4000-8000-000000000001', 'edr_freight_app:fleet_dashboard:view', 'View fleet dashboard'), +]; + +// H. Warehouse management +export const WAREHOUSE_PERMISSIONS: FreightPermissionSeed[] = [ + perm('f1000001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_dashboard:view', 'View warehouse dashboard'), + perm('f1a00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouses:view', 'View warehouses'), + perm('f1a00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouses:create', 'Create warehouse'), + perm('f1a00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouses:update', 'Update warehouse'), + perm('f1a00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouses:delete', 'Delete warehouse'), + perm('f1b00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_yards:view', 'View warehouse yards'), + perm('f1b00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_yards:create', 'Create warehouse yard'), + perm('f1b00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_yards:update', 'Update warehouse yard'), + perm('f1b00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_yards:delete', 'Delete warehouse yard'), + perm('f1c00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_zones:view', 'View warehouse zones'), + perm('f1c00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_zones:create', 'Create warehouse zone'), + perm('f1c00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_zones:update', 'Update warehouse zone'), + perm('f1d00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_allocation_rules:view', 'View allocation rules'), + perm('f1d00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_allocation_rules:create', 'Create allocation rule'), + perm('f1d00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_allocation_rules:update', 'Update allocation rule'), + perm('f1d00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_allocation_rules:delete', 'Delete allocation rule'), + perm('f1e00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_fee_rules:view', 'View fee rules'), + perm('f1e00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_fee_rules:create', 'Create fee rule'), + perm('f1e00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_fee_rules:update', 'Update fee rule'), + perm('f1e00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_fee_rules:delete', 'Delete fee rule'), + perm('f1f00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_inspection_reports:view', 'View inspection reports'), + perm('f1f00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_inspection_reports:create', 'Create inspection report'), + perm('f1f00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_inspection_reports:update', 'Update inspection report'), +]; + +// I. Port & terminal — inventory movement + interchange + fee invoices +export const PORT_TERMINAL_PERMISSIONS: FreightPermissionSeed[] = [ + perm('f2a00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_inventory:view', 'View terminal inventory'), + perm('f2a00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_inventory:receive', 'Receive inventory'), + perm('f2a00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_inventory:move', 'Move/store/reserve inventory'), + perm('f2a00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_inventory:load', 'Load inventory'), + perm('f2a00001-0001-4000-8000-000000000005', 'edr_freight_app:warehouse_inventory:unload', 'Unload inventory'), + perm('f2a00001-0001-4000-8000-000000000006', 'edr_freight_app:warehouse_inventory:dispatch', 'Dispatch inventory'), + perm('f2a00001-0001-4000-8000-000000000007', 'edr_freight_app:warehouse_inventory:gate_pass', 'Gate-clearance inventory'), + perm('f2a00001-0001-4000-8000-000000000008', 'edr_freight_app:warehouse_inventory:release', 'Release inventory'), + perm('f2a00001-0001-4000-8000-000000000009', 'edr_freight_app:warehouse_inventory:deliver', 'Deliver inventory'), + perm('f2a00001-0001-4000-8000-00000000000a', 'edr_freight_app:warehouse_inventory:inspect', 'Inspect inventory'), + perm('f2b00001-0001-4000-8000-000000000001', 'edr_freight_app:interchange_documents:view', 'View interchange documents'), + perm('f2b00001-0001-4000-8000-000000000002', 'edr_freight_app:interchange_documents:generate', 'Generate interchange document'), + perm('f2b00001-0001-4000-8000-000000000003', 'edr_freight_app:interchange_documents:acknowledge', 'Acknowledge interchange document'), + perm('f2b00001-0001-4000-8000-000000000004', 'edr_freight_app:interchange_documents:dispute', 'Dispute interchange document'), + perm('f2b00001-0001-4000-8000-000000000005', 'edr_freight_app:interchange_documents:cancel', 'Cancel interchange document'), + perm('f2c00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_fee_invoices:view', 'View warehouse fee invoices'), + perm('f2c00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_fee_invoices:generate', 'Generate warehouse fee invoice'), + perm('f2c00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_fee_invoices:cancel', 'Cancel warehouse fee invoice'), + perm('f2c00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_fee_invoices:pay', 'Pay warehouse fee invoice'), +]; + +// E'. Train-scheduling finer actions (augment existing view/manage) +export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ + perm('a2a00001-0001-4000-8000-000000000001', 'edr_freight_app:train_scheduling:create', 'Create train schedule'), + perm('a2a00001-0001-4000-8000-000000000002', 'edr_freight_app:train_scheduling:update', 'Update train schedule'), + perm('a2a00001-0001-4000-8000-000000000003', 'edr_freight_app:train_scheduling:cancel', 'Cancel train schedule'), + perm('a2a00001-0001-4000-8000-000000000004', 'edr_freight_app:train_scheduling:reschedule', 'Reschedule train'), + perm('a2a00001-0001-4000-8000-000000000005', 'edr_freight_app:train_scheduling:rules_manage', 'Manage global scheduling rules'), +]; + +// L. Administration & settings (split from the coarse admin umbrella) +export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ + perm('b3a00001-0001-4000-8000-000000000001', 'edr_freight_app:config:contract_validity:view', 'View contract validity periods'), + perm('b3a00001-0001-4000-8000-000000000002', 'edr_freight_app:config:contract_validity:manage', 'Manage contract validity periods'), + perm('b4a00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:file_upload:view', 'View file-upload settings'), + perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'), + perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'), + perm('b4b00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:dropdown:manage', 'Manage dropdown settings'), +]; + +// M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment +// / hierarchy_* / position_types:view keys are seeded separately in edr-freight.seed.ts. +export const STAFF_IAM_PERMISSIONS: FreightPermissionSeed[] = [ + perm('c2a00001-0001-4000-8000-000000000001', 'edr_freight_app:staff:roles:view', 'View roles'), + perm('c2a00001-0001-4000-8000-000000000002', 'edr_freight_app:staff:roles:create', 'Create role'), + perm('c2a00001-0001-4000-8000-000000000003', 'edr_freight_app:staff:roles:update', 'Update role'), + perm('c2a00001-0001-4000-8000-000000000004', 'edr_freight_app:staff:roles:delete', 'Delete role'), + perm('c2b00001-0001-4000-8000-000000000001', 'edr_freight_app:staff:permissions:view', 'View permission assignments'), + perm('c2b00001-0001-4000-8000-000000000002', 'edr_freight_app:staff:permissions:assign', 'Assign permissions'), + perm('c2c00001-0001-4000-8000-000000000001', 'edr_freight_app:position_types:create', 'Create position type'), + perm('c2c00001-0001-4000-8000-000000000002', 'edr_freight_app:position_types:update', 'Update position type'), + perm('c2c00001-0001-4000-8000-000000000003', 'edr_freight_app:position_types:delete', 'Delete position type'), +]; + +export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ + ...CUSTOMER_PERMISSIONS, + ...FINANCE_PERMISSIONS, + ...MILE_PERMISSIONS, + ...FLEET_RAIL_PERMISSIONS, + ...FLEET_ROAD_PERMISSIONS, + ...WAREHOUSE_PERMISSIONS, + ...PORT_TERMINAL_PERMISSIONS, + ...SCHEDULING_EXTRA_PERMISSIONS, + ...CONFIG_SETTINGS_PERMISSIONS, + ...STAFF_IAM_PERMISSIONS, +]; + export const BOOKING_RULE_ENGINE_PERMISSIONS = [ ...BOOKING_PERMISSIONS, ...CONTRACT_PERMISSIONS, ...RULE_ENGINE_PERMISSIONS, ...GAP_CONTROLLER_PERMISSIONS, + ...ADVANCED_BACKOFFICE_PERMISSIONS, ]; export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map( @@ -131,6 +330,7 @@ export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIO export const FREIGHT_PERMS = { bookings: { view: 'edr_freight_app:bookings:view', + create: 'edr_freight_app:bookings:create', clearanceView: 'edr_freight_app:bookings:clearance_view', staffAccept: 'edr_freight_app:bookings:staff_accept', requestChanges: 'edr_freight_app:bookings:request_changes', @@ -168,6 +368,11 @@ export const FREIGHT_PERMS = { trainScheduling: { view: 'edr_freight_app:train_scheduling:view', manage: 'edr_freight_app:train_scheduling:manage', + create: 'edr_freight_app:train_scheduling:create', + update: 'edr_freight_app:train_scheduling:update', + cancel: 'edr_freight_app:train_scheduling:cancel', + reschedule: 'edr_freight_app:train_scheduling:reschedule', + rulesManage: 'edr_freight_app:train_scheduling:rules_manage', }, fleet: { view: 'edr_freight_app:fleet:view', @@ -183,6 +388,244 @@ export const FREIGHT_PERMS = { allocation: { manage: 'edr_freight_app:allocation:manage', }, + customers: { + view: 'edr_freight_app:customers:view', + create: 'edr_freight_app:customers:create', + update: 'edr_freight_app:customers:update', + deactivate: 'edr_freight_app:customers:deactivate', + verify: 'edr_freight_app:customers:verify', + }, + payments: { + view: 'edr_freight_app:payments:view', + verify: 'edr_freight_app:payments:verify', + refund: 'edr_freight_app:payments:refund', + }, + invoices: { + view: 'edr_freight_app:invoices:view', + create: 'edr_freight_app:invoices:create', + cancel: 'edr_freight_app:invoices:cancel', + export: 'edr_freight_app:invoices:export', + }, + firstMile: { + view: 'edr_freight_app:first_mile:view', + accept: 'edr_freight_app:first_mile:accept', + create: 'edr_freight_app:first_mile:create', + update: 'edr_freight_app:first_mile:update', + delete: 'edr_freight_app:first_mile:delete', + assignVehicles: 'edr_freight_app:first_mile:assign_vehicles', + setDistances: 'edr_freight_app:first_mile:set_distances', + generateInvoice: 'edr_freight_app:first_mile:generate_invoice', + }, + lastMile: { + view: 'edr_freight_app:last_mile:view', + accept: 'edr_freight_app:last_mile:accept', + create: 'edr_freight_app:last_mile:create', + update: 'edr_freight_app:last_mile:update', + delete: 'edr_freight_app:last_mile:delete', + assignVehicles: 'edr_freight_app:last_mile:assign_vehicles', + setDistances: 'edr_freight_app:last_mile:set_distances', + generateInvoice: 'edr_freight_app:last_mile:generate_invoice', + }, + locomotives: { + view: 'edr_freight_app:locomotives:view', + create: 'edr_freight_app:locomotives:create', + update: 'edr_freight_app:locomotives:update', + delete: 'edr_freight_app:locomotives:delete', + }, + wagons: { + view: 'edr_freight_app:wagons:view', + create: 'edr_freight_app:wagons:create', + update: 'edr_freight_app:wagons:update', + delete: 'edr_freight_app:wagons:delete', + }, + trains: { + view: 'edr_freight_app:trains:view', + create: 'edr_freight_app:trains:create', + update: 'edr_freight_app:trains:update', + delete: 'edr_freight_app:trains:delete', + assignWagons: 'edr_freight_app:trains:assign_wagons', + }, + routes: { + view: 'edr_freight_app:routes:view', + create: 'edr_freight_app:routes:create', + update: 'edr_freight_app:routes:update', + delete: 'edr_freight_app:routes:delete', + }, + containers: { + view: 'edr_freight_app:containers:view', + create: 'edr_freight_app:containers:create', + update: 'edr_freight_app:containers:update', + delete: 'edr_freight_app:containers:delete', + }, + cargoes: { + view: 'edr_freight_app:cargoes:view', + create: 'edr_freight_app:cargoes:create', + update: 'edr_freight_app:cargoes:update', + delete: 'edr_freight_app:cargoes:delete', + }, + vehicles: { + view: 'edr_freight_app:vehicles:view', + create: 'edr_freight_app:vehicles:create', + update: 'edr_freight_app:vehicles:update', + delete: 'edr_freight_app:vehicles:delete', + }, + drivers: { + view: 'edr_freight_app:drivers:view', + create: 'edr_freight_app:drivers:create', + update: 'edr_freight_app:drivers:update', + delete: 'edr_freight_app:drivers:delete', + }, + tracking: { + view: 'edr_freight_app:tracking:view', + }, + fuel: { + view: 'edr_freight_app:fuel:view', + create: 'edr_freight_app:fuel:create', + update: 'edr_freight_app:fuel:update', + delete: 'edr_freight_app:fuel:delete', + approve: 'edr_freight_app:fuel:approve', + }, + maintenance: { + view: 'edr_freight_app:maintenance:view', + create: 'edr_freight_app:maintenance:create', + update: 'edr_freight_app:maintenance:update', + delete: 'edr_freight_app:maintenance:delete', + complete: 'edr_freight_app:maintenance:complete', + }, + fleetReports: { + view: 'edr_freight_app:fleet_reports:view', + export: 'edr_freight_app:fleet_reports:export', + }, + fleetDashboard: { + view: 'edr_freight_app:fleet_dashboard:view', + }, + warehouseDashboard: { + view: 'edr_freight_app:warehouse_dashboard:view', + }, + warehouses: { + view: 'edr_freight_app:warehouses:view', + create: 'edr_freight_app:warehouses:create', + update: 'edr_freight_app:warehouses:update', + delete: 'edr_freight_app:warehouses:delete', + }, + warehouseYards: { + view: 'edr_freight_app:warehouse_yards:view', + create: 'edr_freight_app:warehouse_yards:create', + update: 'edr_freight_app:warehouse_yards:update', + delete: 'edr_freight_app:warehouse_yards:delete', + }, + warehouseZones: { + view: 'edr_freight_app:warehouse_zones:view', + create: 'edr_freight_app:warehouse_zones:create', + update: 'edr_freight_app:warehouse_zones:update', + }, + warehouseAllocationRules: { + view: 'edr_freight_app:warehouse_allocation_rules:view', + create: 'edr_freight_app:warehouse_allocation_rules:create', + update: 'edr_freight_app:warehouse_allocation_rules:update', + delete: 'edr_freight_app:warehouse_allocation_rules:delete', + }, + warehouseFeeRules: { + view: 'edr_freight_app:warehouse_fee_rules:view', + create: 'edr_freight_app:warehouse_fee_rules:create', + update: 'edr_freight_app:warehouse_fee_rules:update', + delete: 'edr_freight_app:warehouse_fee_rules:delete', + }, + warehouseInspectionReports: { + view: 'edr_freight_app:warehouse_inspection_reports:view', + create: 'edr_freight_app:warehouse_inspection_reports:create', + update: 'edr_freight_app:warehouse_inspection_reports:update', + }, + warehouseInventory: { + view: 'edr_freight_app:warehouse_inventory:view', + receive: 'edr_freight_app:warehouse_inventory:receive', + move: 'edr_freight_app:warehouse_inventory:move', + load: 'edr_freight_app:warehouse_inventory:load', + unload: 'edr_freight_app:warehouse_inventory:unload', + dispatch: 'edr_freight_app:warehouse_inventory:dispatch', + gatePass: 'edr_freight_app:warehouse_inventory:gate_pass', + release: 'edr_freight_app:warehouse_inventory:release', + deliver: 'edr_freight_app:warehouse_inventory:deliver', + inspect: 'edr_freight_app:warehouse_inventory:inspect', + }, + interchangeDocuments: { + view: 'edr_freight_app:interchange_documents:view', + generate: 'edr_freight_app:interchange_documents:generate', + acknowledge: 'edr_freight_app:interchange_documents:acknowledge', + dispute: 'edr_freight_app:interchange_documents:dispute', + cancel: 'edr_freight_app:interchange_documents:cancel', + }, + warehouseFeeInvoices: { + view: 'edr_freight_app:warehouse_fee_invoices:view', + generate: 'edr_freight_app:warehouse_fee_invoices:generate', + cancel: 'edr_freight_app:warehouse_fee_invoices:cancel', + pay: 'edr_freight_app:warehouse_fee_invoices:pay', + }, + config: { + contractValidity: { + view: 'edr_freight_app:config:contract_validity:view', + manage: 'edr_freight_app:config:contract_validity:manage', + }, + }, + settings: { + fileUpload: { + view: 'edr_freight_app:settings:file_upload:view', + manage: 'edr_freight_app:settings:file_upload:manage', + }, + dropdown: { + view: 'edr_freight_app:settings:dropdown:view', + manage: 'edr_freight_app:settings:dropdown:manage', + }, + }, + staff: { + roles: { + view: 'edr_freight_app:staff:roles:view', + create: 'edr_freight_app:staff:roles:create', + update: 'edr_freight_app:staff:roles:update', + delete: 'edr_freight_app:staff:roles:delete', + }, + permissions: { + view: 'edr_freight_app:staff:permissions:view', + assign: 'edr_freight_app:staff:permissions:assign', + }, + // Seeded in edr-freight.seed.ts (EDR_FREIGHT_PERMISSIONS) — surfaced here for gating. + employeeRegistration: { + view: 'edr_freight_app:employee_registration:view', + create: 'edr_freight_app:employee_registration:create', + update: 'edr_freight_app:employee_registration:update', + activate: 'edr_freight_app:employee_registration:activate', + deactivate: 'edr_freight_app:employee_registration:deactivate', + }, + roleAssignment: { + view: 'edr_freight_app:role_assignment:view', + assign: 'edr_freight_app:role_assignment:assign', + replace: 'edr_freight_app:role_assignment:replace', + }, + hierarchyUnits: { + view: 'edr_freight_app:hierarchy_units:view', + create: 'edr_freight_app:hierarchy_units:create', + update: 'edr_freight_app:hierarchy_units:update', + delete: 'edr_freight_app:hierarchy_units:delete', + }, + hierarchyPositions: { + view: 'edr_freight_app:hierarchy_positions:view', + create: 'edr_freight_app:hierarchy_positions:create', + update: 'edr_freight_app:hierarchy_positions:update', + delete: 'edr_freight_app:hierarchy_positions:delete', + changeParent: 'edr_freight_app:hierarchy_positions:change_parent', + }, + hierarchyEmployeeAssignment: { + view: 'edr_freight_app:hierarchy_employee_assignment:view', + invite: 'edr_freight_app:hierarchy_employee_assignment:invite', + assign: 'edr_freight_app:hierarchy_employee_assignment:assign', + }, + positionTypes: { + view: 'edr_freight_app:position_types:view', + create: 'edr_freight_app:position_types:create', + update: 'edr_freight_app:position_types:update', + delete: 'edr_freight_app:position_types:delete', + }, + }, } as const; const allRuleEngineViewKeys = () => @@ -326,12 +769,11 @@ export const POSITION_PERMISSION_PRESETS = { ]), } as const; +/** Derive the module bucket from the resource segment of a permission key. */ +const moduleOf = (key: string): string => key.split(':')[1] ?? 'other'; + export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ key: p.key, label: p.name.en, - module: p.key.includes(':bookings:') - ? 'bookings' - : p.key.includes(':contracts:') - ? 'contracts' - : 'rule_engine', + module: moduleOf(p.key), })); diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index 708afb86b..732653a9d 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -16,8 +16,8 @@ const COMPANY_TIN = 'PAIDMILE001'; const COMPANY_EMAIL = 'paid-mile-demo@edr.local'; const YARDS = [ - { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, - { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, + { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 }, + { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 }, ]; const CONTAINER_TYPES = [ diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 72c782e11..b89807b72 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -22,6 +22,7 @@ "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.100.11", "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz", + "@vis.gl/react-google-maps": "^1.8.3", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -33,6 +34,7 @@ "react-hot-toast": "^2.6.0", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", + "socket.io-client": "^4.8.3", "sonner": "^2.0.7", "stream-browserify": "^3.0.0", "tailwind-merge": "^3.6.0", @@ -43,6 +45,7 @@ "@edr/eslint-config": "workspace:*", "@edr/tsconfig": "workspace:*", "@tailwindcss/vite": "^4.3.0", + "@types/google.maps": "^3.65.2", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.2", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 263e19af0..fbdd908c6 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -94,6 +94,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"; @@ -291,6 +295,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", @@ -590,6 +618,9 @@ const DashboardShell = () => { return ( { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + setValues((current) => ({ ...current, [field.name]: next })) + } + error={error} + > + + {(field.options ?? []).map((o) => ( + + ))} + + + ); + } + if (field.type === "select") { return ( void; mobileOpened: boolean; onToggleMobile: () => void; + /** Hide the mobile burger when the shell has no sidebar to open. */ + hideSidebarBurger?: boolean; } // Every header control is a consistent 36px frosted chip — same language as the @@ -57,6 +59,7 @@ const FreightDashboardHeader = ({ onToggleTheme, mobileOpened, onToggleMobile, + hideSidebarBurger = false, }: FreightDashboardHeaderProps) => { const navigate = useNavigate(); @@ -88,13 +91,15 @@ const FreightDashboardHeader = ({ {/* Left: burger (mobile) + search — the search now occupies the slot the page title used to hold; each page owns its own title. */} - + {!hideSidebarBurger && ( + + )} - - - - - - - + {enableThemeToggle && ( void; headerRight?: ReactNode; @@ -36,6 +38,7 @@ export interface FreightDashboardLayoutProps { const FreightDashboardLayout = ({ sidebarSections, + hideSidebar = false, activeHref = "", onNavigate, headerRight, @@ -75,11 +78,17 @@ const FreightDashboardLayout = ({ padding={0} className="bg-edr-bg" header={{ height: HEADER_HEIGHT }} - navbar={{ - width: NAVBAR_WIDTH, - breakpoint: "sm", - collapsed: { mobile: !mobileOpened }, - }} + // When the sidebar is hidden the navbar slot is dropped entirely so Main + // spans the full viewport width (GL clearance-only users). + navbar={ + hideSidebar + ? undefined + : { + width: NAVBAR_WIDTH, + breakpoint: "sm", + collapsed: { mobile: !mobileOpened }, + } + } > - + {!hideSidebar && ( + + )} {/* Internal scroll keeps the fixed-viewport model the dashboard pages diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx index 2e32dcf45..ec3774ce6 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx @@ -32,6 +32,7 @@ const DEFAULTS = { docReviewMinutes: 30, paymentWindowMinutes: 60, importWindowLeadDays: 3, + exportBookingLeadHours: 24, }; /** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */ @@ -53,6 +54,7 @@ interface FormState { docReviewMinutes: number | ""; paymentWindowMinutes: number | ""; importWindowLeadDays: number | ""; + exportBookingLeadHours: number | ""; } function parseError(error: unknown, fallback: string): string { @@ -112,6 +114,8 @@ export default function BookingWindowSettingsModal({ r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes, importWindowLeadDays: r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays, + exportBookingLeadHours: + r?.exportBookingLeadHours ?? DEFAULTS.exportBookingLeadHours, }); }, [opened, schedule]); @@ -142,15 +146,20 @@ export default function BookingWindowSettingsModal({ const doc = Number(form.docReviewMinutes); const pay = Number(form.paymentWindowMinutes); const lead = Number(form.importWindowLeadDays); + const exportLead = Number(form.exportBookingLeadHours); + const leadInvalid = isExport + ? form.exportBookingLeadHours === "" || + !Number.isFinite(exportLead) || + exportLead < 1 + : form.importWindowLeadDays === "" || !Number.isFinite(lead); if ( form.windowDurationHours === "" || form.docReviewMinutes === "" || form.paymentWindowMinutes === "" || - form.importWindowLeadDays === "" || !Number.isFinite(duration) || !Number.isFinite(doc) || !Number.isFinite(pay) || - !Number.isFinite(lead) + leadInvalid ) { toast({ title: "Fill every field before saving", @@ -164,7 +173,9 @@ export default function BookingWindowSettingsModal({ windowDurationHours: duration, docReviewMinutes: doc, paymentWindowMinutes: pay, - importWindowLeadDays: lead, + ...(isExport + ? { exportBookingLeadHours: exportLead } + : { importWindowLeadDays: lead }), }; try { @@ -223,8 +234,10 @@ export default function BookingWindowSettingsModal({ {isExport ? ( }> - Export schedules use a single FCFS lead window — the daily desk - hours below don't apply, only the lead time does. + Export schedules use a single first-come-first-served window: it + opens the export lead time before departure — shifted to the next + desk opening if that lands outside desk hours — and stays open + until departure. Cycle timing below doesn't apply. ) : null} @@ -263,7 +276,6 @@ export default function BookingWindowSettingsModal({ } allowDeselect={false} comboboxProps={{ withinPortal: true }} - disabled={isExport} /> {isOvernight && !is24h ? ( @@ -290,7 +301,6 @@ export default function BookingWindowSettingsModal({ color="grape" label="Run 24 hours a day (never pause overnight)" checked={is24h} - disabled={isExport} onChange={(e) => { const checked = e.currentTarget.checked; setForm((f) => { @@ -304,12 +314,11 @@ export default function BookingWindowSettingsModal({ }); }} /> - {!isExport ? ( - - A not-yet-full train pauses at the close hour and resumes the next - morning at the open hour, every day until it fills or departs. - - ) : null} + + {isExport + ? "If the export lead time lands while the desk is shut, booking opens at the next desk opening instead." + : "A not-yet-full train pauses at the close hour and resumes the next morning at the open hour, every day until it fills or departs."} + @@ -367,27 +376,43 @@ export default function BookingWindowSettingsModal({ {/* ── Lead time ────────────────────────────────────────────────── */} - - setForm( - (f) => - f && { - ...f, - importWindowLeadDays: v === "" ? "" : Number(v), - }, - ) - } - min={0} - clampBehavior="none" - allowDecimal={false} - /> + {isExport ? ( + + setForm( + (f) => + f && { + ...f, + exportBookingLeadHours: v === "" ? "" : Number(v), + }, + ) + } + min={1} + clampBehavior="none" + allowDecimal={false} + /> + ) : ( + + setForm( + (f) => + f && { + ...f, + importWindowLeadDays: v === "" ? "" : Number(v), + }, + ) + } + min={0} + clampBehavior="none" + allowDecimal={false} + /> + )} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx new file mode 100644 index 000000000..275e2d9c9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx @@ -0,0 +1,376 @@ +import { useState } from "react"; +import { + Alert, + Badge, + Button, + Checkbox, + Group, + Loader, + Paper, + Stack, + Table, + Text, + Tooltip, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react"; + +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; +import type { + IntercityBookingRow, + IntercityCapacity, +} from "@/types/trainScheduling"; + +const parseError = (error: unknown, fallback: string) => { + const message = (error as { response?: { data?: { message?: string | string[] } } }) + ?.response?.data?.message; + if (Array.isArray(message)) return message.join("; "); + return message || (error as Error)?.message || fallback; +}; + +function fmt(n: number): string { + return Number.isInteger(n) ? String(n) : n.toFixed(1); +} + +function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) { + if (!capacity) { + return ( + + Capacity unknown — schedule has no locomotive/train set yet. + + ); + } + return ( + + 0 ? "teal" : "red"}> + {fmt(capacity.wagons)} wagons free + + 0 ? "teal" : "red"}> + {fmt(capacity.weightTons)} t free + + 0 ? "teal" : "red"}> + {fmt(capacity.lengthMeters)} m free + + + ); +} + +function NeedCells({ need }: { need: IntercityCapacity | null }) { + if (!need) return —; + return ( + <> + {fmt(need.wagons)} + {fmt(need.weightTons)} t + {fmt(need.lengthMeters)} m + > + ); +} + +function CorridorCell({ row }: { row: IntercityBookingRow }) { + return ( + + {row.origin} + + {row.destination} + + ); +} + +/** + * Intercity ride-along desk for one import/export schedule: waiting intercity + * bookings whose corridor lies on this train's route, checked against the + * remaining wagon/weight/length budget. Accepting opens the customer's pay + * window; after payment the booking is allocated. Loading/unloading is + * confirmed manually when the train is physically at the booking's origin / + * destination yard (the server validates against recorded checkpoints). + */ +export function IntercityRideAlongPanel({ + scheduleId, + direction, +}: { + scheduleId: string; + direction: string | null | undefined; +}) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [selected, setSelected] = useState([]); + + const candidatesQuery = useQuery( + api.trainScheduling.intercityCandidates.queryOptions({ + input: { scheduleId }, + refetchInterval: 60_000, + }), + ); + + const invalidate = () => + queryClient.invalidateQueries({ + queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }), + }); + + const accept = useMutation( + api.trainScheduling.acceptIntercityBookings.mutationOptions({ + onSuccess: (result) => { + setSelected([]); + void invalidate(); + if (result.accepted.length > 0) { + toast({ + title: `${result.accepted.length} intercity booking(s) accepted`, + description: "Customers have been asked to pay.", + }); + } + for (const r of result.rejected) { + toast({ + title: "Booking skipped", + description: r.reason, + variant: "destructive", + }); + } + }, + onError: (err) => + toast({ + title: "Accept failed", + description: parseError(err, "Could not accept intercity bookings"), + variant: "destructive", + }), + }), + ); + + const load = useMutation( + api.trainScheduling.loadIntercityBooking.mutationOptions({ + onSuccess: () => { + void invalidate(); + toast({ title: "Cargo loaded" }); + }, + onError: (err) => + toast({ + title: "Load failed", + description: parseError(err, "Could not confirm loading"), + variant: "destructive", + }), + }), + ); + + const unload = useMutation( + api.trainScheduling.unloadIntercityBooking.mutationOptions({ + onSuccess: () => { + void invalidate(); + toast({ title: "Cargo unloaded — booking completed" }); + }, + onError: (err) => + toast({ + title: "Unload failed", + description: parseError(err, "Could not confirm unloading"), + variant: "destructive", + }), + }), + ); + + // Intercity bookings only ride import/export trains. + if (direction !== "IMPORT" && direction !== "EXPORT") return null; + + const data = candidatesQuery.data; + const candidates = data?.candidates ?? []; + const accepted = data?.accepted ?? []; + + if (candidatesQuery.isLoading) { + return ( + + + + + Loading intercity ride-along bookings… + + + + ); + } + + if (candidates.length === 0 && accepted.length === 0) return null; + + return ( + + + + + + Intercity ride-along + + + + + {candidates.length > 0 && ( + <> + + Waiting intercity bookings whose corridor lies on this train's + route. Accepting opens the customer's payment window against the + free capacity above. + + + + + + + Booking + Customer + Corridor + Wagons + Weight + Length + Fits + + + + {candidates.map((row) => ( + + + + setSelected((prev) => + e.currentTarget.checked + ? [...prev, row.id] + : prev.filter((id) => id !== row.id), + ) + } + /> + + + + + {row.reference ?? row.id.slice(0, 8)} + + {row.isGovernment && ( + + GOV + + )} + + + + {row.customer} + + + + + + + {row.fits ? ( + + Fits + + ) : ( + + + No room + + + )} + + + ))} + + + + + accept.mutate({ scheduleId, bookingIds: selected })} + > + Accept {selected.length > 0 ? `${selected.length} ` : ""}onto this train + + + > + )} + + {accepted.length > 0 && ( + <> + + On this train + + + + + + Booking + Customer + Corridor + Status + + + + + {accepted.map((row) => ( + + + + {row.reference ?? row.id.slice(0, 8)} + + + + {row.customer} + + + + + + + {row.status} + + + + + {row.status === "PAID" && ( + + } + loading={load.isPending} + onClick={() => + load.mutate({ scheduleId, bookingId: row.id }) + } + > + Load + + + )} + {row.status === "IN_TRANSIT" && ( + + } + loading={unload.isPending} + onClick={() => + unload.mutate({ scheduleId, bookingId: row.id }) + } + > + Unload + + + )} + + + + ))} + + + + > + )} + + {candidatesQuery.isError && ( + }> + {parseError(candidatesQuery.error, "Could not load intercity candidates")} + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index fbfd2f998..fce620235 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -324,6 +324,14 @@ export const URL_CONSTANTS = { PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`, FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`, DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`, + INTERCITY_CANDIDATES: (id: string) => + `/train-scheduling/schedules/${id}/intercity-candidates`, + INTERCITY_ACCEPT: (id: string) => + `/train-scheduling/schedules/${id}/intercity/accept`, + INTERCITY_LOAD: (id: string, bookingId: string) => + `/train-scheduling/schedules/${id}/intercity/${bookingId}/load`, + INTERCITY_UNLOAD: (id: string, bookingId: string) => + `/train-scheduling/schedules/${id}/intercity/${bookingId}/unload`, IMPORT_LOADING_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/import-loading-bookings`, IMPORT_LOADING_STATUS: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts new file mode 100644 index 000000000..3941c487c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts @@ -0,0 +1,67 @@ +import { + BOOKING_WINDOW_WS_EVENTS, + BOOKING_WINDOW_WS_NAMESPACE, + type BookingWindowPhaseEvent, +} from "@edr/types"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { io } from "socket.io-client"; + +import { API_BASE_URL } from "@/constants/apiConfig"; +import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; + +// The socket namespace lives at the server root, not under the `/api` REST +// prefix — strip a trailing `/api` if the base URL carries one. +const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, ""); + +/** + * Subscribes to live booking-window pushes for staff. Every phase transition + * the window engine applies invalidates the GL windows carousel and the batch + * board, so both flip the moment the backend does — polling stays only as a + * fallback. + */ +export function useBookingWindowSocket(enabled: boolean = true) { + const qc = useQueryClient(); + + useEffect(() => { + if (!enabled) return; + const token = getCookie(AUTH_TOKEN_COOKIE); + if (!token) return; + + const socket = io(`${SOCKET_ORIGIN}/${BOOKING_WINDOW_WS_NAMESPACE}`, { + auth: { token }, + transports: ["websocket"], + withCredentials: true, + }); + + // Deliberate console breadcrumbs: "live updates not arriving" is only + // diagnosable from the browser when connect/reject outcomes are visible. + socket.on("connect", () => + console.debug("[booking-windows] socket connected", socket.id), + ); + socket.on("connect_error", (err) => + console.warn("[booking-windows] socket connect failed:", err.message), + ); + socket.on("disconnect", (reason) => + console.debug("[booking-windows] socket disconnected:", reason), + ); + + socket.on( + BOOKING_WINDOW_WS_EVENTS.PHASE, + (_event: BookingWindowPhaseEvent) => { + qc.invalidateQueries({ + queryKey: ["train-scheduling", "all-booking-windows"], + }); + qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), + }); + }, + ); + + return () => { + socket.off(); + socket.disconnect(); + }; + }, [enabled, qc]); +} diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/NotificationBellContainer.tsx b/apps/edr-freight-web/backoffice/src/features/notifications/NotificationBellContainer.tsx new file mode 100644 index 000000000..aed40d951 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/notifications/NotificationBellContainer.tsx @@ -0,0 +1,129 @@ +import type { NotificationDto, NotificationListResult } from "@edr/types"; +import { + NotificationBell, + NotificationDrawer, + NotificationToast, + type NotificationItemData, +} from "@edr/ui-common"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { useNavigate } from "react-router-dom"; + +import { + resolveNotificationHref, + resolveNotificationVisual, +} from "./notificationConfig"; +import { + useInfiniteNotifications, + useMarkAllRead, + useMarkRead, + useUnreadCount, +} from "./useNotifications"; +import { useNotificationSocket } from "./useNotificationSocket"; + +/** Map a server notification into the shared presentational item shape. */ +function toItem(n: NotificationDto): NotificationItemData { + return { + id: n.id, + type: n.type, + title: n.title, + body: n.body, + createdAt: n.createdAt, + isRead: n.isRead, + priority: n.priority, + link: n.link, + data: n.data, + }; +} + +/** Flatten an infinite query's pages into the drawer's item shape. */ +function toItems( + data: { pages: NotificationListResult[] } | undefined, +): NotificationItemData[] { + return (data?.pages ?? []).flatMap((p) => p.items).map(toItem); +} + +/** + * Wires react-query (infinite unread/read lists) + the notification WebSocket + * into the shared bell + drawer. Lists are only fetched while the drawer is + * open; the badge is driven by the lightweight unread-count query + socket. + */ +export default function NotificationBellContainer({ + enabled = true, +}: { + enabled?: boolean; +}) { + const navigate = useNavigate(); + const [opened, setOpened] = useState(false); + + const unreadQ = useInfiniteNotifications(false, enabled && opened); + const readQ = useInfiniteNotifications(true, enabled && opened); + const unread = useUnreadCount(enabled); + const markRead = useMarkRead(); + const markAllRead = useMarkAllRead(); + + const unreadItems = toItems(unreadQ.data); + const readItems = toItems(readQ.data); + const unreadCount = unread.data ?? 0; + + const handleItemClick = (item: NotificationItemData) => { + if (!item.isRead) markRead.mutate(item.id); + const href = resolveNotificationHref(item); + setOpened(false); + if (href) navigate(href); + }; + + // Live push → rich toast that reuses the same registry + click action. + useNotificationSocket(enabled, (n) => { + const item = toItem(n); + toast.custom( + (t) => ( + { + toast.dismiss(t.id); + handleItemClick(item); + }} + onDismiss={() => toast.dismiss(t.id)} + /> + ), + { duration: 6000 }, + ); + }); + + return ( + <> + setOpened(true)} + /> + setOpened(false)} + unread={unreadItems} + read={readItems} + unreadCount={unreadCount} + loading={opened && (unreadQ.isLoading || readQ.isLoading)} + hasMoreUnread={unreadQ.hasNextPage} + hasMoreRead={readQ.hasNextPage} + loadingMoreUnread={unreadQ.isFetchingNextPage} + loadingMoreRead={readQ.isFetchingNextPage} + onLoadMoreUnread={() => { + if (unreadQ.hasNextPage && !unreadQ.isFetchingNextPage) { + void unreadQ.fetchNextPage(); + } + }} + onLoadMoreRead={() => { + if (readQ.hasNextPage && !readQ.isFetchingNextPage) { + void readQ.fetchNextPage(); + } + }} + onItemClick={handleItemClick} + onMarkAllRead={() => markAllRead.mutate()} + resolveVisual={resolveNotificationVisual} + /> + > + ); +} diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx b/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx new file mode 100644 index 000000000..1ed61f72b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/notifications/notificationConfig.tsx @@ -0,0 +1,73 @@ +import { NotificationType } from "@edr/types"; +import type { NotificationItemData, NotificationVisual } from "@edr/ui-common"; +import { Bell, ClipboardCheck, FileSignature, Inbox, Wallet } from "lucide-react"; + +const ICON_SIZE = 17; + +/** + * Backoffice notification registry. Maps a notification `type` → icon + Mantine + * color, and `type`/`data` → an in-app deep link. This is the single place to + * customize how each staff-facing notification looks and where it goes. + */ +export function resolveNotificationVisual( + item: NotificationItemData, +): NotificationVisual { + switch (item.type) { + case NotificationType.REQUEST_SUBMITTED: + return { icon: , color: "blue" }; + case NotificationType.PAYMENT_RECEIVED: + return { icon: , color: "teal" }; + case NotificationType.CLEARANCE_REVIEW: + return { icon: , color: "orange" }; + case NotificationType.CONTRACT_STATUS: + return { icon: , color: "indigo" }; + default: + return { icon: , color: "edr-green" }; + } +} + +function asId(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * Resolve where clicking a notification navigates. Prefers an explicit + * server-provided `link`, else derives a `/dashboard/*` route from `type` + + * `data`. Returns `null` when there's nowhere sensible to go. + */ +export function resolveNotificationHref( + item: NotificationItemData, +): string | null { + if (item.link) return item.link; + const data = item.data ?? {}; + switch (item.type) { + case NotificationType.REQUEST_SUBMITTED: { + const bookingId = asId(data.bookingId); + if (bookingId) return `/dashboard/booking-requests/${bookingId}`; + const contractId = asId(data.contractId); + if (contractId) return `/dashboard/contract-requests/${contractId}`; + return "/dashboard/booking-requests"; + } + case NotificationType.PAYMENT_RECEIVED: { + const bookingId = asId(data.bookingId); + if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`; + const id = asId(data.customerId); + return id ? `/dashboard/customers/${id}` : "/dashboard/customers"; + } + case NotificationType.CLEARANCE_REVIEW: { + const bookingId = asId(data.bookingId); + if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`; + const contractId = asId(data.contractId); + if (contractId) return `/dashboard/contracts/clearance/${contractId}`; + return "/dashboard/arrival-queue"; + } + case NotificationType.CONTRACT_STATUS: { + const contractId = asId(data.contractId); + return contractId + ? `/dashboard/contract-requests/${contractId}` + : "/dashboard/contract-requests"; + } + default: + return null; + } +} diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/notificationsApi.ts b/apps/edr-freight-web/backoffice/src/features/notifications/notificationsApi.ts new file mode 100644 index 000000000..1982aa238 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/notifications/notificationsApi.ts @@ -0,0 +1,37 @@ +import type { NotificationListResult } from "@edr/types"; + +import { api } from "@/auth/http"; + +export interface ListNotificationsParams { + page?: number; + limit?: number; + isRead?: boolean; +} + +/** + * Backoffice notification REST calls. The backoffice axios `api` response + * interceptor already unwraps the `{ success, data }` envelope, so `.data` here + * is the payload itself. + */ +export const notificationsApi = { + list: async ( + params: ListNotificationsParams = {}, + ): Promise => { + const { data } = await api.get("/notifications", { + params, + }); + return data; + }, + unreadCount: async (): Promise => { + const { data } = await api.get<{ unreadCount: number }>( + "/notifications/unread-count", + ); + return data.unreadCount; + }, + markRead: async (id: string): Promise => { + await api.patch(`/notifications/${id}/read`); + }, + markAllRead: async (): Promise => { + await api.post("/notifications/read-all"); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/useNotificationSocket.ts b/apps/edr-freight-web/backoffice/src/features/notifications/useNotificationSocket.ts new file mode 100644 index 000000000..080424d0a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/notifications/useNotificationSocket.ts @@ -0,0 +1,58 @@ +import { + NOTIFICATION_WS_EVENTS, + NOTIFICATION_WS_NAMESPACE, + type NotificationDto, +} from "@edr/types"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import { io } from "socket.io-client"; + +import { API_BASE_URL } from "@/constants/apiConfig"; +import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies"; + +import { NOTIFICATIONS_KEY, UNREAD_KEY } from "./useNotifications"; + +// The socket namespace lives at the server root, not under the `/api` REST +// prefix — strip a trailing `/api` if the base URL carries one. +const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, ""); + +/** + * Subscribes to live notification pushes for the signed-in staff user. New + * items invalidate the cached lists + fire `onNew` (the host shows a rich + * toast); unread-count pushes update the badge. + */ +export function useNotificationSocket( + enabled: boolean, + onNew?: (notification: NotificationDto) => void, +) { + const qc = useQueryClient(); + const onNewRef = useRef(onNew); + onNewRef.current = onNew; + + useEffect(() => { + if (!enabled) return; + const token = getCookie(AUTH_TOKEN_COOKIE); + if (!token) return; + + const socket = io(`${SOCKET_ORIGIN}/${NOTIFICATION_WS_NAMESPACE}`, { + auth: { token }, + transports: ["websocket"], + withCredentials: true, + }); + + socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => { + qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); + qc.invalidateQueries({ queryKey: UNREAD_KEY }); + onNewRef.current?.(n); + }); + + socket.on(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, (count: number) => { + if (typeof count === "number") qc.setQueryData(UNREAD_KEY, count); + }); + + return () => { + socket.off(); + socket.disconnect(); + }; + }, [enabled, qc]); +} diff --git a/apps/edr-freight-web/backoffice/src/features/notifications/useNotifications.ts b/apps/edr-freight-web/backoffice/src/features/notifications/useNotifications.ts new file mode 100644 index 000000000..9d488089d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/notifications/useNotifications.ts @@ -0,0 +1,64 @@ +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; + +import { notificationsApi } from "./notificationsApi"; + +export const NOTIFICATIONS_KEY = ["notifications"] as const; +export const UNREAD_KEY = ["notifications", "unread"] as const; + +const PAGE_SIZE = 20; + +/** + * Paginated (infinite) notifications for one read-state. Drives a drawer + * section; call `fetchNextPage` as the user scrolls. Each page carries the + * server `count` so we know when to stop. + */ +export function useInfiniteNotifications(isRead: boolean, enabled = true) { + return useInfiniteQuery({ + queryKey: [...NOTIFICATIONS_KEY, "list", { isRead }], + queryFn: ({ pageParam }) => + notificationsApi.list({ page: pageParam, limit: PAGE_SIZE, isRead }), + initialPageParam: 1, + getNextPageParam: (lastPage, allPages) => { + const loaded = allPages.reduce((sum, p) => sum + p.items.length, 0); + return loaded < lastPage.count ? allPages.length + 1 : undefined; + }, + enabled, + }); +} + +export function useUnreadCount(enabled = true) { + return useQuery({ + queryKey: UNREAD_KEY, + queryFn: () => notificationsApi.unreadCount(), + enabled, + // WebSocket keeps this fresh; poll as a fallback if the socket drops. + refetchInterval: 60_000, + }); +} + +export function useMarkRead() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => notificationsApi.markRead(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); + qc.invalidateQueries({ queryKey: UNREAD_KEY }); + }, + }); +} + +export function useMarkAllRead() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => notificationsApi.markAllRead(), + onSuccess: () => { + qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); + qc.invalidateQueries({ queryKey: UNREAD_KEY }); + }, + }); +} diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 0abb91e99..c8bfa7c84 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -4,6 +4,7 @@ import type { RuleEngineResourceSlug } from "@/types/rule-engine"; export const FREIGHT_PERMS = { bookings: { view: "edr_freight_app:bookings:view", + create: "edr_freight_app:bookings:create", clearanceView: "edr_freight_app:bookings:clearance_view", staffAccept: "edr_freight_app:bookings:staff_accept", requestChanges: "edr_freight_app:bookings:request_changes", @@ -41,6 +42,11 @@ export const FREIGHT_PERMS = { trainScheduling: { view: "edr_freight_app:train_scheduling:view", manage: "edr_freight_app:train_scheduling:manage", + create: "edr_freight_app:train_scheduling:create", + update: "edr_freight_app:train_scheduling:update", + cancel: "edr_freight_app:train_scheduling:cancel", + reschedule: "edr_freight_app:train_scheduling:reschedule", + rulesManage: "edr_freight_app:train_scheduling:rules_manage", }, fleet: { view: "edr_freight_app:fleet:view", @@ -50,6 +56,243 @@ export const FREIGHT_PERMS = { allocation: { manage: "edr_freight_app:allocation:manage", }, + customers: { + view: "edr_freight_app:customers:view", + create: "edr_freight_app:customers:create", + update: "edr_freight_app:customers:update", + deactivate: "edr_freight_app:customers:deactivate", + verify: "edr_freight_app:customers:verify", + }, + payments: { + view: "edr_freight_app:payments:view", + verify: "edr_freight_app:payments:verify", + refund: "edr_freight_app:payments:refund", + }, + invoices: { + view: "edr_freight_app:invoices:view", + create: "edr_freight_app:invoices:create", + cancel: "edr_freight_app:invoices:cancel", + export: "edr_freight_app:invoices:export", + }, + firstMile: { + view: "edr_freight_app:first_mile:view", + accept: "edr_freight_app:first_mile:accept", + create: "edr_freight_app:first_mile:create", + update: "edr_freight_app:first_mile:update", + delete: "edr_freight_app:first_mile:delete", + assignVehicles: "edr_freight_app:first_mile:assign_vehicles", + setDistances: "edr_freight_app:first_mile:set_distances", + generateInvoice: "edr_freight_app:first_mile:generate_invoice", + }, + lastMile: { + view: "edr_freight_app:last_mile:view", + accept: "edr_freight_app:last_mile:accept", + create: "edr_freight_app:last_mile:create", + update: "edr_freight_app:last_mile:update", + delete: "edr_freight_app:last_mile:delete", + assignVehicles: "edr_freight_app:last_mile:assign_vehicles", + setDistances: "edr_freight_app:last_mile:set_distances", + generateInvoice: "edr_freight_app:last_mile:generate_invoice", + }, + locomotives: { + view: "edr_freight_app:locomotives:view", + create: "edr_freight_app:locomotives:create", + update: "edr_freight_app:locomotives:update", + delete: "edr_freight_app:locomotives:delete", + }, + wagons: { + view: "edr_freight_app:wagons:view", + create: "edr_freight_app:wagons:create", + update: "edr_freight_app:wagons:update", + delete: "edr_freight_app:wagons:delete", + }, + trains: { + view: "edr_freight_app:trains:view", + create: "edr_freight_app:trains:create", + update: "edr_freight_app:trains:update", + delete: "edr_freight_app:trains:delete", + assignWagons: "edr_freight_app:trains:assign_wagons", + }, + routes: { + view: "edr_freight_app:routes:view", + create: "edr_freight_app:routes:create", + update: "edr_freight_app:routes:update", + delete: "edr_freight_app:routes:delete", + }, + containers: { + view: "edr_freight_app:containers:view", + create: "edr_freight_app:containers:create", + update: "edr_freight_app:containers:update", + delete: "edr_freight_app:containers:delete", + }, + cargoes: { + view: "edr_freight_app:cargoes:view", + create: "edr_freight_app:cargoes:create", + update: "edr_freight_app:cargoes:update", + delete: "edr_freight_app:cargoes:delete", + }, + vehicles: { + view: "edr_freight_app:vehicles:view", + create: "edr_freight_app:vehicles:create", + update: "edr_freight_app:vehicles:update", + delete: "edr_freight_app:vehicles:delete", + }, + drivers: { + view: "edr_freight_app:drivers:view", + create: "edr_freight_app:drivers:create", + update: "edr_freight_app:drivers:update", + delete: "edr_freight_app:drivers:delete", + }, + tracking: { + view: "edr_freight_app:tracking:view", + }, + fuel: { + view: "edr_freight_app:fuel:view", + create: "edr_freight_app:fuel:create", + update: "edr_freight_app:fuel:update", + delete: "edr_freight_app:fuel:delete", + approve: "edr_freight_app:fuel:approve", + }, + maintenance: { + view: "edr_freight_app:maintenance:view", + create: "edr_freight_app:maintenance:create", + update: "edr_freight_app:maintenance:update", + delete: "edr_freight_app:maintenance:delete", + complete: "edr_freight_app:maintenance:complete", + }, + fleetReports: { + view: "edr_freight_app:fleet_reports:view", + export: "edr_freight_app:fleet_reports:export", + }, + fleetDashboard: { + view: "edr_freight_app:fleet_dashboard:view", + }, + warehouseDashboard: { + view: "edr_freight_app:warehouse_dashboard:view", + }, + warehouses: { + view: "edr_freight_app:warehouses:view", + create: "edr_freight_app:warehouses:create", + update: "edr_freight_app:warehouses:update", + delete: "edr_freight_app:warehouses:delete", + }, + warehouseYards: { + view: "edr_freight_app:warehouse_yards:view", + create: "edr_freight_app:warehouse_yards:create", + update: "edr_freight_app:warehouse_yards:update", + delete: "edr_freight_app:warehouse_yards:delete", + }, + warehouseZones: { + view: "edr_freight_app:warehouse_zones:view", + create: "edr_freight_app:warehouse_zones:create", + update: "edr_freight_app:warehouse_zones:update", + }, + warehouseAllocationRules: { + view: "edr_freight_app:warehouse_allocation_rules:view", + create: "edr_freight_app:warehouse_allocation_rules:create", + update: "edr_freight_app:warehouse_allocation_rules:update", + delete: "edr_freight_app:warehouse_allocation_rules:delete", + }, + warehouseFeeRules: { + view: "edr_freight_app:warehouse_fee_rules:view", + create: "edr_freight_app:warehouse_fee_rules:create", + update: "edr_freight_app:warehouse_fee_rules:update", + delete: "edr_freight_app:warehouse_fee_rules:delete", + }, + warehouseInspectionReports: { + view: "edr_freight_app:warehouse_inspection_reports:view", + create: "edr_freight_app:warehouse_inspection_reports:create", + update: "edr_freight_app:warehouse_inspection_reports:update", + }, + warehouseInventory: { + view: "edr_freight_app:warehouse_inventory:view", + receive: "edr_freight_app:warehouse_inventory:receive", + move: "edr_freight_app:warehouse_inventory:move", + load: "edr_freight_app:warehouse_inventory:load", + unload: "edr_freight_app:warehouse_inventory:unload", + dispatch: "edr_freight_app:warehouse_inventory:dispatch", + gatePass: "edr_freight_app:warehouse_inventory:gate_pass", + release: "edr_freight_app:warehouse_inventory:release", + deliver: "edr_freight_app:warehouse_inventory:deliver", + inspect: "edr_freight_app:warehouse_inventory:inspect", + }, + interchangeDocuments: { + view: "edr_freight_app:interchange_documents:view", + generate: "edr_freight_app:interchange_documents:generate", + acknowledge: "edr_freight_app:interchange_documents:acknowledge", + dispute: "edr_freight_app:interchange_documents:dispute", + cancel: "edr_freight_app:interchange_documents:cancel", + }, + warehouseFeeInvoices: { + view: "edr_freight_app:warehouse_fee_invoices:view", + generate: "edr_freight_app:warehouse_fee_invoices:generate", + cancel: "edr_freight_app:warehouse_fee_invoices:cancel", + pay: "edr_freight_app:warehouse_fee_invoices:pay", + }, + config: { + contractValidity: { + view: "edr_freight_app:config:contract_validity:view", + manage: "edr_freight_app:config:contract_validity:manage", + }, + }, + settings: { + fileUpload: { + view: "edr_freight_app:settings:file_upload:view", + manage: "edr_freight_app:settings:file_upload:manage", + }, + dropdown: { + view: "edr_freight_app:settings:dropdown:view", + manage: "edr_freight_app:settings:dropdown:manage", + }, + }, + staff: { + roles: { + view: "edr_freight_app:staff:roles:view", + create: "edr_freight_app:staff:roles:create", + update: "edr_freight_app:staff:roles:update", + delete: "edr_freight_app:staff:roles:delete", + }, + permissions: { + view: "edr_freight_app:staff:permissions:view", + assign: "edr_freight_app:staff:permissions:assign", + }, + employeeRegistration: { + view: "edr_freight_app:employee_registration:view", + create: "edr_freight_app:employee_registration:create", + update: "edr_freight_app:employee_registration:update", + activate: "edr_freight_app:employee_registration:activate", + deactivate: "edr_freight_app:employee_registration:deactivate", + }, + roleAssignment: { + view: "edr_freight_app:role_assignment:view", + assign: "edr_freight_app:role_assignment:assign", + replace: "edr_freight_app:role_assignment:replace", + }, + hierarchyUnits: { + view: "edr_freight_app:hierarchy_units:view", + create: "edr_freight_app:hierarchy_units:create", + update: "edr_freight_app:hierarchy_units:update", + delete: "edr_freight_app:hierarchy_units:delete", + }, + hierarchyPositions: { + view: "edr_freight_app:hierarchy_positions:view", + create: "edr_freight_app:hierarchy_positions:create", + update: "edr_freight_app:hierarchy_positions:update", + delete: "edr_freight_app:hierarchy_positions:delete", + changeParent: "edr_freight_app:hierarchy_positions:change_parent", + }, + hierarchyEmployeeAssignment: { + view: "edr_freight_app:hierarchy_employee_assignment:view", + invite: "edr_freight_app:hierarchy_employee_assignment:invite", + assign: "edr_freight_app:hierarchy_employee_assignment:assign", + }, + positionTypes: { + view: "edr_freight_app:position_types:view", + create: "edr_freight_app:position_types:create", + update: "edr_freight_app:position_types:update", + delete: "edr_freight_app:position_types:delete", + }, + }, } as const; const slugToResourceKey = (slug: RuleEngineResourceSlug): string => diff --git a/apps/edr-freight-web/backoffice/src/main.tsx b/apps/edr-freight-web/backoffice/src/main.tsx index 8c467156e..9cf9f6c35 100644 --- a/apps/edr-freight-web/backoffice/src/main.tsx +++ b/apps/edr-freight-web/backoffice/src/main.tsx @@ -60,3 +60,5 @@ createRoot(rootElement).render( ); + +// run \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx new file mode 100644 index 000000000..008fee25b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx @@ -0,0 +1,343 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Badge, + Button, + Card, + Container, + Grid, + Group, + Loader, + Modal, + Select, + Stack, + Table, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { Plus, AlertTriangle } from "lucide-react"; +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { useToast } from "@/hooks/use-toast"; +import { + complianceService, + type ComplianceAlert, + type ComplianceRecord, + type ComplianceType, +} from "@/services/compliance.service"; +import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service"; + +const COMPLIANCE_TYPES: ComplianceType[] = [ + "INSPECTION", + "INSURANCE", + "ROADWORTHINESS", + "PERMIT", + "TAX", +]; + +const severityColor = (severity: ComplianceAlert["severity"]) => + 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 + } + onClick={() => setModalOpen(true)} + color="edr-green" + > + New Record + + + + {/* 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" + > + + setFormData({ ...formData, vehicleId: val || "" })} + searchable + required + /> + + ({ 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 })} + /> + + + setModalOpen(false)}> + Cancel + + createMutation.mutate(formData)} + loading={createMutation.isPending} + disabled={!formData.vehicleId || !formData.expiryDate} + > + Create Record + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx index d5e8655be..c97d7c418 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx @@ -1,9 +1,10 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ActionIcon, Badge, + Button, Card, Center, Container, @@ -17,11 +18,18 @@ import { Timeline, Title, } from "@mantine/core"; +import { SmartFileInput } from "@edr/ui-common"; +import type { IFileUploadSetting } from "@edr/types/freight"; import { ArrowLeft, + Download, + Eye, + FileText, History, Route, ShieldCheck, + Trash2, + Upload, Truck, User, } from "lucide-react"; @@ -29,6 +37,8 @@ import { import { driversService } from "@/services/drivers.service"; import { vehiclesService } from "@/services/vehicles.service"; import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service"; +import { fileUploadSettingsService } from "@/services/fileUploadSettings.service"; +import { useToast } from "@/hooks/use-toast"; const fmtDate = (iso?: string | null) => { if (!iso) return "—"; @@ -55,6 +65,178 @@ const Loading = () => ( ); +const fmtSize = (bytes: number) => { + if (!bytes) return "—"; + const kb = bytes / 1024; + return kb < 1024 ? `${kb.toFixed(0)} KB` : `${(kb / 1024).toFixed(1)} MB`; +}; + +/** Upload-area setting code configured on the File Settings page. */ +const DRIVER_DOCS_CODE = "driver_docs"; +// Field key the FALLBACK setting is keyed on (used only when the "driver_docs" +// upload area hasn't been configured in File Settings yet). +const DRIVER_DOCS_KEY = "driver_docs"; +/** Fallback single-field setting so the dropzone still works before an admin + * configures the "driver_docs" area in File Settings. */ +const DRIVER_DOCS_FALLBACK: IFileUploadSetting = { + id: "driver-docs-setting", + createdAt: "", + updatedAt: "", + deletedAt: null, + code: "driver_docs", + label: "Driver documents", + description: null, + entity: "other", + fields: [ + { + id: "driver-docs-field", + createdAt: "", + updatedAt: "", + deletedAt: null, + settingId: "driver-docs-setting", + fileKey: DRIVER_DOCS_KEY, + fileLabel: "Upload driver document(s)", + helpText: "License, national ID, contracts, training certificates, etc.", + isRequired: false, + isMultiple: true, + maxFiles: 20, + allowedExtensions: ["pdf", "png", "jpg", "jpeg", "doc", "docx"], + maxSizeMb: 10, + order: 1, + }, + ], +}; + +/** Driver documents upload + view area (files stored under code "driver_docs"). */ +const DriverDocuments = ({ driverId }: { driverId: string }) => { + const { toast } = useToast(); + const qc = useQueryClient(); + // Files selected per configured field key (SmartFileInput is multi-field). + const [selectedMap, setSelectedMap] = useState>({}); + const selectedFiles = Object.values(selectedMap).flatMap((v) => + Array.isArray(v) ? v : v ? [v] : [], + ); + + // Upload-area configuration from the File Settings page (code "driver_docs"). + // Falls back to a default field until an admin configures it there. + const { data: setting } = useQuery({ + queryKey: ["file-upload-setting", DRIVER_DOCS_CODE], + queryFn: () => fileUploadSettingsService.getByCode(DRIVER_DOCS_CODE), + retry: false, + }); + const activeSetting = setting ?? DRIVER_DOCS_FALLBACK; + + const { data: docs = [], isLoading } = useQuery({ + queryKey: ["driver", driverId, "documents"], + queryFn: () => driversService.listDocuments(driverId).then((r) => r.data ?? []), + enabled: Boolean(driverId), + }); + + const uploadMutation = useMutation({ + mutationFn: (files: File[]) => driversService.uploadDocuments(driverId, files), + onSuccess: () => { + toast({ title: "Documents uploaded" }); + void qc.invalidateQueries({ queryKey: ["driver", driverId, "documents"] }); + }, + onError: (err: unknown) => { + const description = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? + "Upload failed"; + toast({ title: "Upload failed", description, variant: "destructive" }); + }, + }); + + const removeMutation = useMutation({ + mutationFn: (fileId: string) => driversService.removeDocument(driverId, fileId), + onSuccess: () => { + toast({ title: "Document deleted" }); + void qc.invalidateQueries({ queryKey: ["driver", driverId, "documents"] }); + }, + onError: () => toast({ title: "Delete failed", variant: "destructive" }), + }); + + // /files/:id is a public inline-serving route; open directly for preview/download. + const fileUrl = (fileId: string, download = false) => + `${import.meta.env.VITE_API_URL}/files/${fileId}${download ? "?download=1" : ""}`; + + return ( + + + {activeSetting.label ?? "Upload documents"} + + + } + loading={uploadMutation.isPending} + disabled={selectedFiles.length === 0} + onClick={() => + uploadMutation.mutate(selectedFiles, { onSuccess: () => setSelectedMap({}) }) + } + > + Upload {selectedFiles.length > 0 ? `(${selectedFiles.length})` : ""} + + + + + Uploaded documents ({docs.length}) + {isLoading ? ( + + ) : docs.length === 0 ? ( + No documents uploaded yet. + ) : ( + + + + Name + Size + Uploaded + Actions + + + + {docs.map((doc) => ( + + + + + {doc.name} + + + {fmtSize(doc.size)} + {fmtDate(doc.createdAt)} + + + window.open(fileUrl(doc.id), "_blank")}> + + + window.open(fileUrl(doc.id, true), "_blank")}> + + + removeMutation.mutate(doc.id)} + > + + + + + + ))} + + + )} + + ); +}; + const DriverDetailPage = () => { const { id = "" } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -106,6 +288,7 @@ const DriverDetailPage = () => { }>Vehicles }>History }>Trips + }>Documents @@ -149,6 +332,10 @@ const DriverDetailPage = () => { + + + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index f7bee5b41..12dd90b4c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -393,7 +393,7 @@ const FleetResourcePage = () => { {filter.label}: - {[{ value: "ALL", label: "All" }, ...filter.data].map((option) => ( + {filter.data.map((option) => ( = { + 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 + } onClick={() => setModalOpen(true)} color="edr-green"> + Report Incident + + + + {/* 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: t, label: t.replace(/_/g, " ") }))} + value={formData.type} + onChange={(val) => setFormData({ ...formData, type: (val as IncidentType) || "ACCIDENT" })} + required + /> + + ({ 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, vehicleId: val || "" })} + clearable + searchable + /> + + setFormData({ ...formData, driverId: val || "" })} + clearable + searchable + /> + + setFormData({ ...formData, description: e.currentTarget.value })} + minRows={3} + required + /> + + + setFormData({ ...formData, damageEstimate: val as number | undefined }) + } + decimalScale={2} + min={0} + thousandSeparator="," + /> + + setFormData({ ...formData, location: e.currentTarget.value })} + /> + + setFormData({ ...formData, reportedBy: e.currentTarget.value })} + /> + + + setModalOpen(false)}> + Cancel + + createMutation.mutate(formData)} + loading={createMutation.isPending} + disabled={!formData.description.trim()} + > + Report Incident + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/ProcurementPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/ProcurementPage.tsx new file mode 100644 index 000000000..0c6bbabf0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/ProcurementPage.tsx @@ -0,0 +1,665 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Badge, + Button, + Card, + Container, + Group, + Loader, + Modal, + NumberInput, + Select, + Stack, + Switch, + Table, + Tabs, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { Plus } from "lucide-react"; +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { useToast } from "@/hooks/use-toast"; +import { vehiclesService, type Vehicle } from "@/services/vehicles.service"; +import { + procurementService, + type AssetAcquisition, + type AssetDisposal, + type Vendor, + type AcquisitionType, + type AcquisitionStatus, + type VendorType, + type DisposalMethod, +} from "@/services/procurement.service"; + +const money = (x: number | null | undefined) => + `ETB ${(Number(x) || 0).toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + +const ACQUISITION_TYPES: AcquisitionType[] = ["PURCHASE", "LEASE", "RENTAL"]; +const ACQUISITION_STATUSES: AcquisitionStatus[] = ["ACTIVE", "LEASE_EXPIRING", "DISPOSED"]; +const VENDOR_TYPES: VendorType[] = ["DEALER", "LEASING", "PARTS", "SERVICE", "OTHER"]; +const DISPOSAL_METHODS: DisposalMethod[] = ["SALE", "SCRAP", "RETURN_LEASE", "TRADE_IN"]; + +const typeBadgeColor = (t: AcquisitionType) => + t === "PURCHASE" ? "green" : t === "LEASE" ? "blue" : "grape"; +const statusBadgeColor = (s: AcquisitionStatus) => + s === "ACTIVE" ? "green" : s === "LEASE_EXPIRING" ? "yellow" : "gray"; + +const vehicleLabel = ( + v?: { plateNumber?: string | null; registrationNumber?: string | null } | null, + fallback?: string | null, +) => v?.plateNumber || v?.registrationNumber || fallback || "—"; + +// Strip empty strings / null / undefined before sending to the API (ValidationPipe rejects "" for UUID fields). +const clean = >(obj: T): Partial => + Object.fromEntries( + Object.entries(obj).filter(([, v]) => v !== "" && v !== undefined && v !== null), + ) as Partial; + +const emptyAcquisition = { + vehicleId: "", + vendorId: "", + acquisitionType: "PURCHASE" as AcquisitionType, + acquisitionDate: new Date().toISOString().split("T")[0], + cost: undefined as number | undefined, + usefulLifeMonths: undefined as number | undefined, + salvageValue: undefined as number | undefined, + leaseStart: "", + leaseEnd: "", + monthlyPayment: undefined as number | undefined, + status: "ACTIVE" as AcquisitionStatus, + notes: "", +}; + +const emptyVendor = { + name: "", + type: "" as VendorType | "", + contactPerson: "", + phone: "", + email: "", + address: "", + isActive: true, +}; + +const emptyDisposal = { + vehicleId: "", + disposalDate: new Date().toISOString().split("T")[0], + method: "SALE" as DisposalMethod, + salePrice: undefined as number | undefined, + buyer: "", + notes: "", +}; + +export default function ProcurementPage() { + const { toast } = useToast(); + const qc = useQueryClient(); + + const [tab, setTab] = useState("acquisitions"); + const [acqModalOpen, setAcqModalOpen] = useState(false); + const [vendorModalOpen, setVendorModalOpen] = useState(false); + const [disposalModalOpen, setDisposalModalOpen] = useState(false); + + const [acqForm, setAcqForm] = useState({ ...emptyAcquisition }); + const [vendorForm, setVendorForm] = useState({ ...emptyVendor }); + const [disposalForm, setDisposalForm] = useState({ ...emptyDisposal }); + + // ---- Queries ---- + const { data: vehiclesData } = useQuery({ + queryKey: ["vehicles", "list"], + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, + }); + + const { data: acquisitions = [], isLoading: loadingAcquisitions } = useQuery({ + queryKey: ["procurement", "acquisitions"], + queryFn: async () => { + const res = await procurementService.listAcquisitions(); + return res.data || []; + }, + }); + + const { data: vendors = [], isLoading: loadingVendors } = useQuery({ + queryKey: ["procurement", "vendors"], + queryFn: async () => { + const res = await procurementService.listVendors(); + return res.data || []; + }, + }); + + const { data: disposals = [], isLoading: loadingDisposals } = useQuery({ + queryKey: ["procurement", "disposals"], + queryFn: async () => { + const res = await procurementService.listDisposals(); + return res.data || []; + }, + }); + + const vehicleOptions = + vehiclesData?.map((v: Vehicle) => ({ + value: v.id, + label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`, + })) || []; + + const vendorOptions = vendors.map((v: Vendor) => ({ value: v.id, label: v.name })); + + // ---- Mutations ---- + const createAcquisition = useMutation({ + mutationFn: async () => { + const res = await procurementService.createAcquisition(clean(acqForm) as never); + return res.data; + }, + onSuccess: () => { + toast({ title: "Acquisition recorded" }); + setAcqModalOpen(false); + setAcqForm({ ...emptyAcquisition }); + qc.invalidateQueries({ queryKey: ["procurement", "acquisitions"] }); + }, + onError: (error: any) => { + toast({ + title: "Error recording acquisition", + description: error?.response?.data?.message || "Failed to record acquisition", + variant: "destructive", + }); + }, + }); + + const createVendor = useMutation({ + mutationFn: async () => { + const res = await procurementService.createVendor(clean(vendorForm) as never); + return res.data; + }, + onSuccess: () => { + toast({ title: "Vendor created" }); + setVendorModalOpen(false); + setVendorForm({ ...emptyVendor }); + qc.invalidateQueries({ queryKey: ["procurement", "vendors"] }); + }, + onError: (error: any) => { + toast({ + title: "Error creating vendor", + description: error?.response?.data?.message || "Failed to create vendor", + variant: "destructive", + }); + }, + }); + + const createDisposal = useMutation({ + mutationFn: async () => { + const res = await procurementService.createDisposal(clean(disposalForm) as never); + return res.data; + }, + onSuccess: () => { + toast({ title: "Disposal recorded" }); + setDisposalModalOpen(false); + setDisposalForm({ ...emptyDisposal }); + qc.invalidateQueries({ queryKey: ["procurement", "disposals"] }); + }, + onError: (error: any) => { + toast({ + title: "Error recording disposal", + description: error?.response?.data?.message || "Failed to record disposal", + variant: "destructive", + }); + }, + }); + + return ( + + + + + Procurement & Assets + + + setTab(val || "acquisitions")}> + + Acquisitions + Vendors + Disposals + + + {/* ---- Acquisitions ---- */} + + + } + onClick={() => setAcqModalOpen(true)} + color="edr-green" + > + New Acquisition + + + + + + + Vehicle + Type + Date + Cost + Status + + + + {loadingAcquisitions ? ( + + + + + + + + ) : acquisitions.length === 0 ? ( + + + + No acquisitions recorded yet. + + + + ) : null} + {acquisitions.map((a: AssetAcquisition) => ( + + {vehicleLabel(a.vehicle, a.vehicleId)} + + + {a.acquisitionType} + + + {new Date(a.acquisitionDate).toLocaleDateString()} + {money(a.cost)} + + + {a.status} + + + + ))} + + + + + + {/* ---- Vendors ---- */} + + + } + onClick={() => setVendorModalOpen(true)} + color="edr-green" + > + New Vendor + + + + + + + Name + Type + Contact + Phone + Email + Active + + + + {loadingVendors ? ( + + + + + + + + ) : vendors.length === 0 ? ( + + + + No vendors added yet. + + + + ) : null} + {vendors.map((v: Vendor) => ( + + {v.name} + {v.type ? {v.type} : "—"} + {v.contactPerson || "—"} + {v.phone || "—"} + {v.email || "—"} + + + {v.isActive ? "Active" : "Inactive"} + + + + ))} + + + + + + {/* ---- Disposals ---- */} + + + } + onClick={() => setDisposalModalOpen(true)} + color="edr-green" + > + New Disposal + + + + + + + Vehicle + Method + Date + Sale Price + Buyer + + + + {loadingDisposals ? ( + + + + + + + + ) : disposals.length === 0 ? ( + + + + No disposals recorded yet. + + + + ) : null} + {disposals.map((d: AssetDisposal) => ( + + {d.vehicleId} + + {d.method} + + {new Date(d.disposalDate).toLocaleDateString()} + {money(d.salePrice)} + {d.buyer || "—"} + + ))} + + + + + + + {/* ---- Acquisition Modal ---- */} + setAcqModalOpen(false)} + title="New Acquisition" + size="lg" + > + + setAcqForm({ ...acqForm, vehicleId: val || "" })} + searchable + clearable + /> + setAcqForm({ ...acqForm, vendorId: val || "" })} + searchable + clearable + /> + + setAcqForm({ ...acqForm, acquisitionType: (val as AcquisitionType) || "PURCHASE" }) + } + required + /> + setAcqForm({ ...acqForm, acquisitionDate: e.currentTarget.value })} + required + /> + setAcqForm({ ...acqForm, cost: val as number | undefined })} + decimalScale={2} + min={0} + /> + + setAcqForm({ ...acqForm, usefulLifeMonths: val as number | undefined }) + } + decimalScale={0} + min={0} + /> + setAcqForm({ ...acqForm, salvageValue: val as number | undefined })} + decimalScale={2} + min={0} + /> + setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })} + /> + setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })} + /> + + setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined }) + } + decimalScale={2} + min={0} + /> + + setAcqForm({ ...acqForm, status: (val as AcquisitionStatus) || "ACTIVE" }) + } + /> + setAcqForm({ ...acqForm, notes: e.currentTarget.value })} + /> + + setAcqModalOpen(false)}> + Cancel + + createAcquisition.mutate()} + loading={createAcquisition.isPending} + disabled={!acqForm.acquisitionDate} + > + Save Acquisition + + + + + + {/* ---- Vendor Modal ---- */} + setVendorModalOpen(false)} + title="New Vendor" + size="lg" + > + + setVendorForm({ ...vendorForm, name: e.currentTarget.value })} + required + /> + setVendorForm({ ...vendorForm, type: (val as VendorType) || "" })} + clearable + /> + setVendorForm({ ...vendorForm, contactPerson: e.currentTarget.value })} + /> + setVendorForm({ ...vendorForm, phone: e.currentTarget.value })} + /> + setVendorForm({ ...vendorForm, email: e.currentTarget.value })} + /> + setVendorForm({ ...vendorForm, address: e.currentTarget.value })} + /> + setVendorForm({ ...vendorForm, isActive: e.currentTarget.checked })} + /> + + setVendorModalOpen(false)}> + Cancel + + createVendor.mutate()} + loading={createVendor.isPending} + disabled={!vendorForm.name} + > + Save Vendor + + + + + + {/* ---- Disposal Modal ---- */} + setDisposalModalOpen(false)} + title="New Disposal" + size="lg" + > + + setDisposalForm({ ...disposalForm, vehicleId: val || "" })} + searchable + required + /> + + setDisposalForm({ ...disposalForm, disposalDate: e.currentTarget.value }) + } + required + /> + + setDisposalForm({ ...disposalForm, method: (val as DisposalMethod) || "SALE" }) + } + required + /> + + setDisposalForm({ ...disposalForm, salePrice: val as number | undefined }) + } + decimalScale={2} + min={0} + /> + setDisposalForm({ ...disposalForm, buyer: e.currentTarget.value })} + /> + setDisposalForm({ ...disposalForm, notes: e.currentTarget.value })} + /> + + setDisposalModalOpen(false)}> + Cancel + + createDisposal.mutate()} + loading={createDisposal.isPending} + disabled={!disposalForm.vehicleId || !disposalForm.disposalDate} + > + Save Disposal + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx index f60c7f72b..14bb32d33 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx @@ -1,368 +1,500 @@ -import { useState, useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { Container, Grid, Card, Stack, Group, Select, Text, Badge, Button, Box, Table, SimpleGrid } from '@mantine/core'; -import { MapPin, Navigation, Radio, Activity } from 'lucide-react'; -import Breadcrumbs from '@/components/ui/Breadcrumbs'; -import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; -import { vehiclesService } from '@/services/vehicles.service'; -import { freightBrand } from '@/theme/freight-brand'; +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ActionIcon, + Badge, + Box, + Button, + Card, + Container, + Grid, + Group, + Modal, + Select, + SimpleGrid, + Stack, + Table, + Text, + TextInput, +} from "@mantine/core"; +import { + APIProvider, + InfoWindow, + Map as GoogleMap, + Marker, + useMap, +} from "@vis.gl/react-google-maps"; +import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react"; +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { useToast } from "@/hooks/use-toast"; +import { vehiclesService } from "@/services/vehicles.service"; +import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service"; +import { freightBrand } from "@/theme/freight-brand"; -interface Vehicle { - id: string; - registrationNumber: string; - plateNumber: string; - manufacturer: string; - model: string; - status?: string; +// Same default key + env override the portal's LocationPicker uses. +const GOOGLE_MAPS_API_KEY = + import.meta.env.VITE_GOOGLE_MAPS_API_KEY || + "AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI"; +const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 }; // Addis Ababa + +const toNum = (v: number | string | null | undefined): number | null => + v == null || v === "" ? null : Number(v); + +const deviceLabel = (d: GpsDevice) => + d.vehicle + ? [d.vehicle.code, d.vehicle.plateNumber].filter(Boolean).join(" · ") + : d.name || d.imei; + +const fmtTime = (iso?: string | null) => { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString(); +}; + +const StatBox = ({ label, value }: { label: string; value: string }) => ( + + {label} + {value} + +); + +type LatLng = { lat: number; lng: number }; + +/** Flip a flag once the map (and thus the Maps JS classes) is loaded. */ +function ReadyProbe({ onReady }: { onReady: () => void }) { + const map = useMap(); + useEffect(() => { + if (map) onReady(); + }, [map, onReady]); + return null; } -interface GPSLocation { +/** Fit the map to the current markers (or center on a single one). */ +function FitBounds({ points }: { points: LatLng[] }) { + const map = useMap(); + useEffect(() => { + if (!map || points.length === 0 || typeof google === "undefined") return; + if (points.length === 1) { + map.setCenter(points[0]); + map.setZoom(14); + return; + } + const b = new google.maps.LatLngBounds(); + points.forEach((p) => b.extend(p)); + map.fitBounds(b, 60); + }, [map, points]); + return null; +} + +/** Lazily reverse-geocode a coordinate to a human address. */ +function useAddress(lat: number, lng: number): string | null { + const [addr, setAddr] = useState(null); + useEffect(() => { + if (typeof google === "undefined" || !google.maps?.Geocoder) return; + setAddr(null); + let cancelled = false; + new google.maps.Geocoder().geocode({ location: { lat, lng } }, (res, status) => { + if (cancelled) return; + setAddr(status === "OK" && res?.[0] ? res[0].formatted_address : "Unknown location"); + }); + return () => { cancelled = true; }; + }, [lat, lng]); + return addr; +} + +/** Hover popup: label, coordinates, speed/course, and the reverse-geocoded place. */ +function HoverInfo({ + device, + lat, + lng, + onClose, +}: { + device: GpsDevice; lat: number; lng: number; - speed?: number; - heading?: number; - lastUpdate?: string; + onClose: () => void; +}) { + const address = useAddress(lat, lng); + return ( + + + {deviceLabel(device)} + + {lat.toFixed(5)}, {lng.toFixed(5)} + + + {toNum(device.lastSpeed) ?? 0} km/h · {device.lastCourse ?? 0}° + + {address ?? "Locating…"} + + + ); } -// Mock GPS data for demo (no real GPS backend exists — these are simulated values) -const generateMockGPS = (): GPSLocation => ({ - lat: 9.0 + Math.random() * 0.5, - lng: 38.7 + Math.random() * 0.5, - speed: Math.floor(Math.random() * 120), - heading: Math.floor(Math.random() * 360), - lastUpdate: new Date(Date.now() - Math.random() * 300000).toLocaleTimeString(), -}); +/** Draw the selected vehicle's recent path as a polyline. */ +function RouteTrail({ path }: { path: LatLng[] }) { + const map = useMap(); + useEffect(() => { + if (!map || path.length < 2 || typeof google === "undefined") return; + const line = new google.maps.Polyline({ + path, + strokeColor: freightBrand.primary, + strokeOpacity: 0.85, + strokeWeight: 4, + }); + line.setMap(map); + return () => line.setMap(null); + }, [map, path]); + return null; +} export function TrackingPage() { - const [selectedVehicleId, setSelectedVehicleId] = useState(null); - const [mapCenter] = useState({ lat: 9.0, lng: 38.8 }); + const { toast } = useToast(); + const qc = useQueryClient(); + const [selectedId, setSelectedId] = useState(null); + const [hoverId, setHoverId] = useState(null); + const [mapsReady, setMapsReady] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [editDevice, setEditDevice] = useState(null); + const [form, setForm] = useState({ imei: "", name: "", vehicleId: "" }); - const { data: vehicles = [] } = useQuery({ - queryKey: QUERY_KEYS.VEHICLES.list(), - queryFn: async () => { - const res = await vehiclesService.getAll({ limit: 1000 }); - return res.data || []; + const openRegister = () => { + setEditDevice(null); + setForm({ imei: "", name: "", vehicleId: "" }); + setModalOpen(true); + }; + const openEdit = (d: GpsDevice) => { + setEditDevice(d); + setForm({ imei: d.imei, name: d.name ?? "", vehicleId: d.vehicleId ?? "" }); + setModalOpen(true); + }; + + // Poll every 10s so the map tracks live movement. + const { data: devices = [] } = useQuery({ + queryKey: ["gps", "devices"], + queryFn: async () => (await gpsTrackingService.listDevices()).data ?? [], + refetchInterval: 10_000, + }); + + const { data: vehiclesData } = useQuery({ + queryKey: ["vehicles", "all"], + queryFn: async () => (await vehiclesService.getAll({ limit: 1000 })).data ?? [], + }); + const vehicleOptions = useMemo( + () => + (vehiclesData ?? []).map((v) => ({ + value: v.id, + label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`, + })), + [vehiclesData], + ); + + const positioned = useMemo( + () => + devices + .map((d) => ({ d, lat: toNum(d.lastLat), lng: toNum(d.lastLng) })) + .filter((x): x is { d: GpsDevice; lat: number; lng: number } => x.lat != null && x.lng != null), + [devices], + ); + + const selected = devices.find((d) => d.id === selectedId) ?? null; + const onlineCount = devices.filter((d) => d.online).length; + + // Route history for the selected device's vehicle (chronological trail). + const { data: history = [] } = useQuery({ + queryKey: ["gps", "history", selected?.vehicleId], + queryFn: async () => (await gpsTrackingService.history(selected!.vehicleId!, 300)).data ?? [], + enabled: Boolean(selected?.vehicleId), + }); + const trail = useMemo( + () => [...history].reverse().map((h) => ({ lat: Number(h.lat), lng: Number(h.lng) })), + [history], + ); + + // Teardrop pin colored by state with a white truck glyph inside. + const markerIcon = (d: GpsDevice, selectedFlag: boolean): google.maps.Icon | undefined => { + // Maps API loads async — Size/Point classes may not exist yet at first render. + if (typeof google === "undefined" || !google.maps?.Size || !mapsReady) return undefined; + const color = selectedFlag ? freightBrand.primary : d.online ? "#2f80ed" : "#95a5a6"; + const svg = ` + + + + + + + + + `; + return { + url: `data:image/svg+xml,${encodeURIComponent(svg)}`, + scaledSize: new google.maps.Size(40, 48), + anchor: new google.maps.Point(20, 48), + }; + }; + + const saveMutation = useMutation({ + mutationFn: () => + editDevice + ? gpsTrackingService.update(editDevice.id, { + name: form.name.trim() || undefined, + vehicleId: form.vehicleId || null, + }) + : gpsTrackingService.register({ + imei: form.imei.trim(), + name: form.name.trim() || undefined, + vehicleId: form.vehicleId || null, + }), + onSuccess: () => { + toast({ title: editDevice ? "Tracker updated" : "Tracker registered" }); + setModalOpen(false); + setEditDevice(null); + setForm({ imei: "", name: "", vehicleId: "" }); + void qc.invalidateQueries({ queryKey: ["gps", "devices"] }); + }, + onError: (err: unknown) => { + const description = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Failed"; + toast({ title: editDevice ? "Update failed" : "Registration failed", description, variant: "destructive" }); }, }); - // Generate mock GPS data for each vehicle - const vehiclesWithGPS = useMemo(() => { - return (vehicles as Vehicle[]).map((v) => ({ - ...v, - gps: generateMockGPS(), - })); - }, [vehicles]); + const assignMutation = useMutation({ + mutationFn: ({ id, vehicleId }: { id: string; vehicleId: string | null }) => + gpsTrackingService.update(id, { vehicleId }), + onSuccess: () => { + toast({ title: "Tracker updated" }); + void qc.invalidateQueries({ queryKey: ["gps", "devices"] }); + }, + onError: () => toast({ title: "Update failed", variant: "destructive" }), + }); - // For demo: show all vehicles as trackable (or filter by ACTIVE if status data available) - const trackableVehicles = useMemo( - () => vehiclesWithGPS.slice(0, 10), // Limit to first 10 for demo - [vehiclesWithGPS] - ); - - const selectedVehicle = trackableVehicles.find(v => v.id === selectedVehicleId); - const vehicleOptions = useMemo( - () => trackableVehicles.map(v => ({ label: v.registrationNumber, value: v.id })), - [trackableVehicles] - ); - - // Map dimensions - const mapWidth = 800; - const mapHeight = 500; - const pixelsPerLat = mapHeight / 0.6; - const pixelsPerLng = mapWidth / 0.6; - - const getMapCoords = (lat: number, lng: number) => ({ - x: ((lng - (mapCenter.lng - 0.3)) * pixelsPerLng), - y: ((mapCenter.lat + 0.3 - lat) * pixelsPerLat), + const deleteMutation = useMutation({ + mutationFn: (id: string) => gpsTrackingService.remove(id), + onSuccess: () => { + toast({ title: "Tracker removed" }); + setSelectedId(null); + void qc.invalidateQueries({ queryKey: ["gps", "devices"] }); + }, + onError: () => toast({ title: "Delete failed", variant: "destructive" }), }); return ( - + - - - - - - Real-Time Vehicle Tracking - - - Simulated GPS - - - - Monitor vehicle locations, speed, and status - - - + + + Real-Time Vehicle Tracking + Live GPS positions from GT06 trackers + + } color="edr-green" onClick={openRegister}> + Register tracker + + - - {/* Map Section */} - - - - - Map View - - }> - {trackableVehicles.length} Tracked - - - - - - - - + {/* Map */} + + + + + Live Map + }> + {onlineCount} online · {positioned.length} located + + + + + + + - {/* Grid background */} - - {/* Latitude lines */} - {[0, 1, 2, 3, 4, 5, 6].map(i => ( - setMapsReady(true)} /> + {positioned.map(({ d, lat, lng }) => ( + setSelectedId(d.id)} + onMouseOver={() => setHoverId(d.id)} /> ))} - {/* Longitude lines */} - {[0, 1, 2, 3, 4, 5, 6].map(i => ( - - ))} - - - {/* Vehicle markers */} - {trackableVehicles.map((vehicle) => { - const coords = getMapCoords(vehicle.gps.lat, vehicle.gps.lng); - const isSelected = vehicle.id === selectedVehicleId; - - return ( - setSelectedVehicleId(vehicle.id)} - title={vehicle.registrationNumber} - > - - - - - ); - })} - - {/* Map labels */} - - - 📍 Addis Ababa, Ethiopia - - - - - - Simulated map — coordinates, speed, and heading are demo values, not live GPS. + {(() => { + const h = positioned.find((p) => p.d.id === hoverId); + return h ? ( + setHoverId(null)} /> + ) : null; + })()} + ({ lat: p.lat, lng: p.lng }))} /> + {selected?.vehicleId && trail.length > 1 && } + + + + {positioned.length === 0 && ( + + No located trackers yet — waiting for GPS fixes. - - - - - {/* Sidebar */} - - - {/* Vehicle Selector */} - - - - {selectedVehicle && ( - - - - - Registration - - {selectedVehicle.registrationNumber} - - - - Vehicle - - - {selectedVehicle.manufacturer} {selectedVehicle.model} - - - - - Status - - - {selectedVehicle.status || 'Unknown'} - - - - - )} - - - - {/* GPS Details */} - {selectedVehicle && ( - - - - GPS Location - }> - Live - - - - - - - Latitude - - - {selectedVehicle.gps.lat.toFixed(4)}° - - - - - Longitude - - - {selectedVehicle.gps.lng.toFixed(4)}° - - - - - Speed - - - {selectedVehicle.gps.speed} km/h - - - - - Heading - - - {selectedVehicle.gps.heading}° - - - - - - - Last Update - - {selectedVehicle.gps.lastUpdate} - - - }> - View Full History - - - )} + + + - {/* Tracked Vehicles List */} + {/* Sidebar */} + + + {selected && ( - Tracked Vehicles ({trackableVehicles.length}) - - - - {trackableVehicles.map(v => ( - setSelectedVehicleId(v.id)} - > - - - - {v.registrationNumber} - - - {v.gps.speed} km/h - - - - - - {v.status || 'N/A'} - - - - ))} - - + + {deviceLabel(selected)} + + }> + {selected.online ? "Live" : "Offline"} + + deleteMutation.mutate(selected.id)}> + + + + + + + + + + + + + + + + IMEI + {selected.imei} + + Last fix + {fmtTime(selected.lastFixAt)} + + {selected.vehicleId && ( + + Showing last {trail.length} fixes as a route trail. + + )} + + assignMutation.mutate({ id: selected.id, vehicleId: v })} + searchable + clearable + /> - - - - + )} + + + + Trackers ({devices.length}) + + + + {devices.map((d) => ( + setSelectedId(d.id)} + > + + + {deviceLabel(d)} + {toNum(d.lastSpeed) ?? 0} km/h · {fmtTime(d.lastFixAt)} + + + + + {d.online ? "Live" : "Offline"} + { e.stopPropagation(); openEdit(d); }} + > + + + + + + ))} + {devices.length === 0 && ( + + + No trackers registered yet. + + + )} + + + + + + + + + + {/* Register / edit modal */} + setModalOpen(false)} + title={editDevice ? "Edit GPS tracker" : "Register GPS tracker"} + radius="lg" + centered + > + + setForm({ ...form, imei: e.currentTarget.value })} + /> + setForm({ ...form, name: e.currentTarget.value })} + /> + setForm({ ...form, vehicleId: v ?? "" })} + searchable + clearable + /> + + setModalOpen(false)}>Cancel + saveMutation.mutate()} + > + {editDevice ? "Save" : "Register"} + + + + ); } + +export default TrackingPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/WorkOrdersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/WorkOrdersPage.tsx new file mode 100644 index 000000000..71c591bb7 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/WorkOrdersPage.tsx @@ -0,0 +1,830 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Card, + Button, + Modal, + Stack, + Group, + Select, + TextInput, + Textarea, + NumberInput, + Table, + Badge, + Text, + Title, + Container, + Tabs, + Loader, + Switch, + ActionIcon, +} from '@mantine/core'; +import { Plus, Trash2, Pencil, Wrench, Package, ShieldCheck } from 'lucide-react'; +import Breadcrumbs from '@/components/ui/Breadcrumbs'; +import { useToast } from '@/hooks/use-toast'; +import { + maintenanceDepthService, + type WorkOrder, + type WorkOrderStatus, + type WorkOrderPriority, + type Part, + type Warranty, +} from '@/services/maintenance-depth.service'; +import { vehiclesService, type Vehicle as VehicleType } from '@/services/vehicles.service'; + +const WORK_ORDER_STATUSES: WorkOrderStatus[] = ['OPEN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']; +const WORK_ORDER_PRIORITIES: WorkOrderPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'URGENT']; +const PART_CATEGORIES = ['TIRE', 'ENGINE', 'BRAKE', 'ELECTRICAL', 'FILTER', 'FLUID', 'OTHER']; + +const etb = (x: number | string | null | undefined) => + `ETB ${(Number(x) || 0).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + +const statusColor = (status: string) => { + const colors: Record = { + OPEN: 'edr-blue', + IN_PROGRESS: 'edr-amber-soft', + COMPLETED: 'edr-green', + CANCELLED: 'edr-slate', + }; + return colors[status] || 'edr-slate'; +}; + +const priorityColor = (priority: string) => { + const colors: Record = { + LOW: 'edr-slate', + MEDIUM: 'edr-blue', + HIGH: 'edr-amber-soft', + URGENT: 'edr-red', + }; + return colors[priority] || 'edr-slate'; +}; + +const emptyWorkOrder = { + vehicleId: '', + title: '', + description: '', + status: 'OPEN' as WorkOrderStatus, + priority: 'MEDIUM' as WorkOrderPriority, + assignedTo: '', + laborCost: 0, + partsCost: 0, +}; + +const emptyPart = { + name: '', + sku: '', + category: 'TIRE', + quantityInStock: 0, + reorderLevel: 0, + unitCost: 0, + location: '', +}; + +const emptyWarranty = { + vehicleId: '', + component: '', + provider: '', + startDate: '', + expiryDate: new Date().toISOString().split('T')[0], + coverageNotes: '', +}; + +export default function WorkOrdersPage() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const [activeTab, setActiveTab] = useState('work-orders'); + + // Work orders + const [statusFilter, setStatusFilter] = useState(null); + const [openWorkOrderModal, setOpenWorkOrderModal] = useState(false); + const [workOrderForm, setWorkOrderForm] = useState(emptyWorkOrder); + + // Parts + const [lowStockOnly, setLowStockOnly] = useState(false); + const [openPartModal, setOpenPartModal] = useState(false); + const [editingPartId, setEditingPartId] = useState(null); + const [partForm, setPartForm] = useState(emptyPart); + + // Warranties + const [openWarrantyModal, setOpenWarrantyModal] = useState(false); + const [warrantyForm, setWarrantyForm] = useState(emptyWarranty); + + const { data: vehiclesData } = useQuery({ + queryKey: ['vehicles', 'all-for-maintenance-depth'], + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, + }); + + const vehicleOptions = + vehiclesData?.map((v: VehicleType) => ({ + value: v.id, + label: v.plateNumber + ? `${v.plateNumber} - ${v.manufacturer} ${v.model}` + : v.registrationNumber || v.id, + })) || []; + + const vehicleLabel = (vehicleId: string) => + vehicleOptions.find((o) => o.value === vehicleId)?.label || vehicleId; + + // ---- Work orders queries/mutations ---- + const { data: workOrders, isLoading: workOrdersLoading } = useQuery({ + queryKey: ['maintenance-work-orders', statusFilter], + queryFn: async () => { + const res = await maintenanceDepthService.getWorkOrders({ + status: (statusFilter as WorkOrderStatus) || undefined, + }); + return res.data || []; + }, + }); + const workOrderList: WorkOrder[] = Array.isArray(workOrders) ? workOrders : []; + + const createWorkOrderMutation = useMutation({ + mutationFn: async () => { + const res = await maintenanceDepthService.createWorkOrder({ + vehicleId: workOrderForm.vehicleId, + title: workOrderForm.title, + description: workOrderForm.description || undefined, + status: workOrderForm.status, + priority: workOrderForm.priority, + assignedTo: workOrderForm.assignedTo || undefined, + laborCost: Number(workOrderForm.laborCost) || undefined, + partsCost: Number(workOrderForm.partsCost) || undefined, + }); + return res.data; + }, + onSuccess: () => { + toast({ title: 'Work order created' }); + queryClient.invalidateQueries({ queryKey: ['maintenance-work-orders'] }); + setOpenWorkOrderModal(false); + setWorkOrderForm(emptyWorkOrder); + }, + onError: (err: any) => { + toast({ + title: 'Error', + description: err?.response?.data?.message ?? 'Failed', + variant: 'destructive', + }); + }, + }); + + const deleteWorkOrderMutation = useMutation({ + mutationFn: async (id: string) => { + await maintenanceDepthService.deleteWorkOrder(id); + }, + onSuccess: () => { + toast({ title: 'Work order deleted' }); + queryClient.invalidateQueries({ queryKey: ['maintenance-work-orders'] }); + }, + onError: (err: any) => { + toast({ + title: 'Error', + description: err?.response?.data?.message ?? 'Failed', + variant: 'destructive', + }); + }, + }); + + // ---- Parts queries/mutations ---- + const { data: parts, isLoading: partsLoading } = useQuery({ + queryKey: ['maintenance-parts', lowStockOnly], + queryFn: async () => { + const res = await maintenanceDepthService.getParts({ lowStock: lowStockOnly || undefined }); + return res.data || []; + }, + }); + const partList: Part[] = Array.isArray(parts) ? parts : []; + + const savePartMutation = useMutation({ + mutationFn: async () => { + const payload = { + name: partForm.name, + sku: partForm.sku || undefined, + category: partForm.category || undefined, + quantityInStock: Number(partForm.quantityInStock) || 0, + reorderLevel: Number(partForm.reorderLevel) || 0, + unitCost: Number(partForm.unitCost) || undefined, + location: partForm.location || undefined, + }; + if (editingPartId) { + const res = await maintenanceDepthService.updatePart(editingPartId, payload); + return res.data; + } + const res = await maintenanceDepthService.createPart(payload); + return res.data; + }, + onSuccess: () => { + toast({ title: editingPartId ? 'Part updated' : 'Part created' }); + queryClient.invalidateQueries({ queryKey: ['maintenance-parts'] }); + setOpenPartModal(false); + setPartForm(emptyPart); + setEditingPartId(null); + }, + onError: (err: any) => { + toast({ + title: 'Error', + description: err?.response?.data?.message ?? 'Failed', + variant: 'destructive', + }); + }, + }); + + const deletePartMutation = useMutation({ + mutationFn: async (id: string) => { + await maintenanceDepthService.deletePart(id); + }, + onSuccess: () => { + toast({ title: 'Part deleted' }); + queryClient.invalidateQueries({ queryKey: ['maintenance-parts'] }); + }, + onError: (err: any) => { + toast({ + title: 'Error', + description: err?.response?.data?.message ?? 'Failed', + variant: 'destructive', + }); + }, + }); + + // ---- Warranties queries/mutations ---- + const { data: warranties, isLoading: warrantiesLoading } = useQuery({ + queryKey: ['maintenance-warranties'], + queryFn: async () => { + const res = await maintenanceDepthService.getWarranties(); + return res.data || []; + }, + }); + const warrantyList: Warranty[] = Array.isArray(warranties) ? warranties : []; + + const createWarrantyMutation = useMutation({ + mutationFn: async () => { + const res = await maintenanceDepthService.createWarranty({ + vehicleId: warrantyForm.vehicleId, + component: warrantyForm.component, + provider: warrantyForm.provider || undefined, + startDate: warrantyForm.startDate || undefined, + expiryDate: warrantyForm.expiryDate, + coverageNotes: warrantyForm.coverageNotes || undefined, + }); + return res.data; + }, + onSuccess: () => { + toast({ title: 'Warranty created' }); + queryClient.invalidateQueries({ queryKey: ['maintenance-warranties'] }); + setOpenWarrantyModal(false); + setWarrantyForm(emptyWarranty); + }, + onError: (err: any) => { + toast({ + title: 'Error', + description: err?.response?.data?.message ?? 'Failed', + variant: 'destructive', + }); + }, + }); + + const deleteWarrantyMutation = useMutation({ + mutationFn: async (id: string) => { + await maintenanceDepthService.deleteWarranty(id); + }, + onSuccess: () => { + toast({ title: 'Warranty deleted' }); + queryClient.invalidateQueries({ queryKey: ['maintenance-warranties'] }); + }, + onError: (err: any) => { + toast({ + title: 'Error', + description: err?.response?.data?.message ?? 'Failed', + variant: 'destructive', + }); + }, + }); + + const openPartForEdit = (part: Part) => { + setEditingPartId(part.id); + setPartForm({ + name: part.name, + sku: part.sku || '', + category: part.category || 'OTHER', + quantityInStock: part.quantityInStock, + reorderLevel: part.reorderLevel, + unitCost: Number(part.unitCost) || 0, + location: part.location || '', + }); + setOpenPartModal(true); + }; + + const openPartForCreate = () => { + setEditingPartId(null); + setPartForm(emptyPart); + setOpenPartModal(true); + }; + + return ( + + + + + Work Orders & Parts + + + + + }> + Work Orders + + }> + Parts / Tires + + }> + Warranties + + + + {/* ---- Work Orders tab ---- */} + + + + setOpenWorkOrderModal(true)} + color="edr-green" + leftSection={} + > + New Work Order + + + + + {workOrdersLoading ? ( + + + + ) : workOrderList.length > 0 ? ( + + + + + Title + Vehicle + Priority + Status + Assigned + Labor + Parts + Opened + + + + + {workOrderList.map((wo) => ( + + {wo.title} + {vehicleLabel(wo.vehicleId)} + + {wo.priority} + + + {wo.status} + + {wo.assignedTo || '—'} + {etb(wo.laborCost)} + {etb(wo.partsCost)} + {new Date(wo.openedAt).toLocaleDateString()} + + deleteWorkOrderMutation.mutate(wo.id)} + aria-label="Delete work order" + > + + + + + ))} + + + + ) : ( + + No work orders yet + + )} + + + + {/* ---- Parts / Tires tab ---- */} + + + setLowStockOnly(e.currentTarget.checked)} + /> + }> + New Part + + + + + {partsLoading ? ( + + + + ) : partList.length > 0 ? ( + + + + + Name + SKU + Category + In Stock + Reorder Level + Unit Cost + Location + + + + + {partList.map((p) => ( + + {p.name} + {p.sku || '—'} + + {p.category ? {p.category} : '—'} + + + + {p.quantityInStock} + {p.quantityInStock <= p.reorderLevel && ( + + Low stock + + )} + + + {p.reorderLevel} + {etb(p.unitCost)} + {p.location || '—'} + + + openPartForEdit(p)} + aria-label="Adjust part" + > + + + deletePartMutation.mutate(p.id)} + aria-label="Delete part" + > + + + + + + ))} + + + + ) : ( + + No parts in inventory + + )} + + + + {/* ---- Warranties tab ---- */} + + + setOpenWarrantyModal(true)} + color="edr-green" + leftSection={} + > + New Warranty + + + + + {warrantiesLoading ? ( + + + + ) : warrantyList.length > 0 ? ( + + + + + Component + Vehicle + Provider + Start + Expiry + + + + + {warrantyList.map((w) => { + const expired = new Date(w.expiryDate) < new Date(); + return ( + + {w.component} + {vehicleLabel(w.vehicleId)} + {w.provider || '—'} + + {w.startDate ? new Date(w.startDate).toLocaleDateString() : '—'} + + + + {new Date(w.expiryDate).toLocaleDateString()} + + {expired ? 'Expired' : 'Active'} + + + + + deleteWarrantyMutation.mutate(w.id)} + aria-label="Delete warranty" + > + + + + + ); + })} + + + + ) : ( + + No warranties recorded + + )} + + + + + {/* ---- Work Order modal ---- */} + setOpenWorkOrderModal(false)} + title="New Work Order" + size="md" + > + + setWorkOrderForm({ ...workOrderForm, vehicleId: v || '' })} + searchable + required + /> + setWorkOrderForm({ ...workOrderForm, title: e.currentTarget.value })} + required + /> + + setWorkOrderForm({ ...workOrderForm, description: e.currentTarget.value }) + } + /> + + + setWorkOrderForm({ ...workOrderForm, priority: (v as WorkOrderPriority) || 'MEDIUM' }) + } + /> + + setWorkOrderForm({ ...workOrderForm, status: (v as WorkOrderStatus) || 'OPEN' }) + } + /> + + + setWorkOrderForm({ ...workOrderForm, assignedTo: e.currentTarget.value }) + } + /> + + setWorkOrderForm({ ...workOrderForm, laborCost: Number(v) || 0 })} + /> + setWorkOrderForm({ ...workOrderForm, partsCost: Number(v) || 0 })} + /> + + + setOpenWorkOrderModal(false)}> + Cancel + + createWorkOrderMutation.mutate()} + loading={createWorkOrderMutation.isPending} + disabled={!workOrderForm.vehicleId || !workOrderForm.title} + > + Create + + + + + + {/* ---- Part modal ---- */} + { + setOpenPartModal(false); + setEditingPartId(null); + }} + title={editingPartId ? 'Adjust Part' : 'New Part'} + size="md" + > + + setPartForm({ ...partForm, name: e.currentTarget.value })} + required + /> + + setPartForm({ ...partForm, sku: e.currentTarget.value })} + /> + setPartForm({ ...partForm, category: v || 'OTHER' })} + /> + + + setPartForm({ ...partForm, quantityInStock: Number(v) || 0 })} + /> + setPartForm({ ...partForm, reorderLevel: Number(v) || 0 })} + /> + + + setPartForm({ ...partForm, unitCost: Number(v) || 0 })} + /> + setPartForm({ ...partForm, location: e.currentTarget.value })} + /> + + + { + setOpenPartModal(false); + setEditingPartId(null); + }} + > + Cancel + + savePartMutation.mutate()} + loading={savePartMutation.isPending} + disabled={!partForm.name} + > + {editingPartId ? 'Save' : 'Create'} + + + + + + {/* ---- Warranty modal ---- */} + setOpenWarrantyModal(false)} + title="New Warranty" + size="md" + > + + setWarrantyForm({ ...warrantyForm, vehicleId: v || '' })} + searchable + required + /> + setWarrantyForm({ ...warrantyForm, component: e.currentTarget.value })} + required + /> + setWarrantyForm({ ...warrantyForm, provider: e.currentTarget.value })} + /> + + + setWarrantyForm({ ...warrantyForm, startDate: e.currentTarget.value }) + } + /> + + setWarrantyForm({ ...warrantyForm, expiryDate: e.currentTarget.value }) + } + required + /> + + + setWarrantyForm({ ...warrantyForm, coverageNotes: e.currentTarget.value }) + } + /> + + setOpenWarrantyModal(false)}> + Cancel + + createWarrantyMutation.mutate()} + loading={createWarrantyMutation.isPending} + disabled={!warrantyForm.vehicleId || !warrantyForm.component || !warrantyForm.expiryDate} + > + Create + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 5e76584ed..eb1508601 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -29,6 +29,11 @@ const VEHICLE_AVAILABILITY_OPTIONS = [ { label: "Busy", value: "BUSY" }, ]; +const CURRENCY_OPTIONS = [ + { label: "ETB", value: "ETB" }, + { label: "USD", value: "USD" }, +]; + export const vehiclesConfig: FleetResourceConfig = { slug: "vehicles", label: "Vehicles", @@ -84,12 +89,14 @@ export const vehiclesConfig: FleetResourceConfig = { { name: "locationId", label: "Location", type: "select", dynamicOptions: "yards" }, { name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" }, { name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" }, + { name: "pricePerKm", label: "Price per KM", type: "number" }, + { name: "currency", label: "Currency", type: "radio", options: CURRENCY_OPTIONS }, { name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS }, { name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS }, { name: "description", label: "Description", type: "textarea" }, ], emptyValues: { - code: "", + code: "03-ET", plateNumber: "", powerPlateNo: "", trailerPlateNo: "", @@ -102,6 +109,8 @@ export const vehiclesConfig: FleetResourceConfig = { locationId: null, estimatedDistanceKm: "", actualDistanceKm: "", + pricePerKm: "", + currency: "ETB", status: "ACTIVE", availability: "FREE", description: "", diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 2b130848c..a8c55ecf4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -20,7 +20,7 @@ import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { ActionIcon, - Autocomplete, + Alert, Badge, Box, Button, @@ -54,7 +54,6 @@ import { } from "@/services/first-mile.service"; import { bookingsService } from "@/services/bookings.service"; import { vehiclesService } from "@/services/vehicles.service"; -import { ratesService } from "@/services/rates.service"; import type { BookingDetail } from "@/types/booking"; const formatPrice = (amount: number | string | null | undefined, currency = "ETB") => @@ -191,7 +190,24 @@ const isPostPaymentPending = (r: FirstMileRecord) => // Map API record → display fields used in modals and trip slip const bookingRef = (r: FirstMileRecord) => r.booking?.reference ?? r.bookingId; -const currencyOf = (r: FirstMileRecord) => r.booking?.paymentCurrency ?? "ETB"; +const currencyOf = (r: FirstMileRecord) => + r.vehicle?.currency ?? + r.vehicleAssignments?.[0]?.vehicle?.currency ?? + r.booking?.paymentCurrency ?? + "ETB"; + +type FmAssignment = NonNullable[number]; +const truckShort = (a: FmAssignment) => + a.vehicle ? [a.vehicle.code, a.vehicle.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; +/** Billing problems on the trucks that have distance: zero price/km, mixed currency. */ +const billingIssues = (r: FirstMileRecord) => { + const trucks = (r.vehicleAssignments ?? []).filter((a) => Number(a.distanceKm) > 0); + const zeroPrice = trucks.filter((a) => !(Number(a.vehicle?.pricePerKm) > 0)).map(truckShort); + const currencies = [ + ...new Set(trucks.map((a) => a.vehicle?.currency).filter((c): c is string => Boolean(c))), + ]; + return { zeroPrice, mixedCurrency: currencies.length > 1, currencies }; +}; const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—"; const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—"; const cargoDesc = (r: FirstMileRecord) => { @@ -480,6 +496,8 @@ const FirstMilePage = () => { const [distanceOpen, setDistanceOpen] = useState(false); // Per-vehicle actual distance, keyed by vehicleId. const [distanceRows, setDistanceRows] = useState>({}); + // Record pending invoice-generation confirmation (shows a summary first). + const [invoiceConfirm, setInvoiceConfirm] = useState(null); const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false); const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState(null); @@ -500,14 +518,6 @@ const FirstMilePage = () => { }, }); - const { data: ratesData } = useQuery({ - queryKey: ["rates", "FIRST_MILE"], - queryFn: async () => { - const res = await ratesService.getByType("FIRST_MILE"); - return res.data; - }, - }); - const { data: paidBookingsData, isLoading: bookingsLoading } = useQuery({ queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }), queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }), @@ -572,10 +582,17 @@ const FirstMilePage = () => { distances: Array<{ vehicleId: string; distanceKm: number }>; remainingPayment?: number; }) => firstMileService.setDistances(id, distances, remainingPayment), - onSuccess: () => { + onSuccess: (res) => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined }); + const updated = res?.data as FirstMileRecord | undefined; closeDistance(); + // Every truck has a distance and it isn't billed yet → offer to invoice now. + const trucks = updated?.vehicleAssignments ?? []; + const allFilled = trucks.length > 0 && trucks.every((a) => Number(a.distanceKm) > 0); + if (updated && allFilled && !updated.invoice) { + setInvoiceConfirm(updated); + } }, onError: () => { toast({ title: "Update failed", variant: "destructive" }); @@ -783,18 +800,9 @@ const FirstMilePage = () => { return; } - const total = distances.reduce((s, d) => s + d.distanceKm, 0); - let remainingPayment: number | undefined; - if (ratesData?.data) { - const firstMileRate = ratesData.data.find( - (r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT") - ); - if (firstMileRate) { - remainingPayment = total * parseFloat(firstMileRate.rateValue); - } - } - - setDistancesMutation.mutate({ id: activeId, distances, remainingPayment }); + // Amount is computed server-side per truck (distance × the vehicle's + // price/km, in the vehicle's currency) — no flat FIRST_MILE rate. + setDistancesMutation.mutate({ id: activeId, distances }); }; const matchesFilter = (r: FirstMileRecord) => { @@ -849,6 +857,33 @@ const FirstMilePage = () => { return filteredRecords.slice(start, start + pagination.pageSize); }, [filteredRecords, pagination]); + // Billing problems on the leg pending invoice confirmation. + const confirmIssues = invoiceConfirm + ? billingIssues(invoiceConfirm) + : { zeroPrice: [] as string[], mixedCurrency: false, currencies: [] as string[] }; + + // Guard invoice generation: block mixed currency, warn (but proceed) on trucks + // priced at 0/km. + const handleGenerateInvoice = (r: FirstMileRecord) => { + const { zeroPrice, mixedCurrency, currencies } = billingIssues(r); + if (mixedCurrency) { + toast({ + title: "Mixed truck currencies", + description: `Trucks use ${currencies.join(", ")}. Assign trucks that share one currency.`, + variant: "destructive", + }); + return; + } + if (zeroPrice.length) { + toast({ + title: "Truck has no price/km", + description: `${zeroPrice.join(", ")} will bill 0 — set Price per KM on the vehicle.`, + variant: "destructive", + }); + } + generateInvoiceMutation.mutate(r.id); + }; + const openAssign = (id: string | null) => { const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; const rec = records.find((r) => r.id === resolved); @@ -1173,7 +1208,7 @@ const FirstMilePage = () => { !(row.original.exactKm != null && row.original.exactKm > 0) || Boolean(row.original.invoice) } - onClick={() => generateInvoiceMutation.mutate(row.original.id)} + onClick={() => handleGenerateInvoice(row.original)} > {row.original.invoice ? "Invoice generated" : "Generate Invoice"} @@ -1337,21 +1372,29 @@ const FirstMilePage = () => { clearable disabled={assignVehicleOptions.length === 0} /> - - n === row.containerNumber || - !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), - )} - value={row.containerNumber} + placeholder={containerOptions.length ? "Select container" : "No container numbers"} + data={[ + ...containerOptions.filter( + (n) => + n === row.containerNumber || + !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), + ), + // keep a manual/legacy value selectable even if not in the booking + ...(row.containerNumber && !containerOptions.includes(row.containerNumber) + ? [row.containerNumber] + : []), + ]} + value={row.containerNumber || null} onChange={(value) => setVehicleRows((prev) => - prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)), + prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)), ) } + searchable + clearable /> {vehicleRows.length > 1 && ( { + + {/* Generate Invoice — confirmation summary */} + setInvoiceConfirm(null)} + title={Generate Invoice} + size="md" + radius="lg" + centered + > + {invoiceConfirm && ( + + + {bookingRef(invoiceConfirm)} + {customerName(invoiceConfirm)} + + + + {(invoiceConfirm.vehicleAssignments ?? []).map((a) => { + const v = a.vehicle; + const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; + return ( + + + {label} + {a.containerNumber ? ` · ${a.containerNumber}` : ""} + + {a.distanceKm != null ? `${a.distanceKm} km` : "—"} + + ); + })} + + + + Total distance + {invoiceConfirm.exactKm ?? 0} km + + + Invoice amount + {formatPrice(invoiceConfirm.remainingPayment, currencyOf(invoiceConfirm))} + + {confirmIssues.mixedCurrency && ( + + Trucks use {confirmIssues.currencies.join(", ")}. Assign trucks that share one currency before invoicing. + + )} + {confirmIssues.zeroPrice.length > 0 && ( + + {confirmIssues.zeroPrice.join(", ")} will bill 0 — set Price per KM on the vehicle. + + )} + + Generate the delivery-fee invoice now, or close and generate later from the row actions. + + + setInvoiceConfirm(null)}>Later + + generateInvoiceMutation.mutate(invoiceConfirm.id, { + onSuccess: () => setInvoiceConfirm(null), + }) + } + > + Generate Invoice + + + + )} + ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index c136e67db..983745705 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -19,7 +19,6 @@ import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { ActionIcon, Alert, - Autocomplete, Badge, Box, Button, @@ -56,7 +55,6 @@ import { } from "@/services/last-mile.service"; import { vehiclesService } from "@/services/vehicles.service"; import { driversService, type Driver } from "@/services/drivers.service"; -import { ratesService } from "@/services/rates.service"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal"; import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; @@ -234,7 +232,24 @@ const computeLastMileSteps = ( }; const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId; -const currencyOf = (r: LastMileRecord) => r.booking?.paymentCurrency ?? "ETB"; +const currencyOf = (r: LastMileRecord) => + r.vehicle?.currency ?? + r.vehicleAssignments?.[0]?.vehicle?.currency ?? + r.booking?.paymentCurrency ?? + "ETB"; + +type LmAssignment = NonNullable[number]; +const truckShort = (a: LmAssignment) => + a.vehicle ? [a.vehicle.code, a.vehicle.plateNumber].filter(Boolean).join(" · ") : a.vehicleId; +/** Billing problems on the trucks that have distance: zero price/km, mixed currency. */ +const billingIssues = (r: LastMileRecord) => { + const trucks = (r.vehicleAssignments ?? []).filter((a) => Number(a.distanceKm) > 0); + const zeroPrice = trucks.filter((a) => !(Number(a.vehicle?.pricePerKm) > 0)).map(truckShort); + const currencies = [ + ...new Set(trucks.map((a) => a.vehicle?.currency).filter((c): c is string => Boolean(c))), + ]; + return { zeroPrice, mixedCurrency: currencies.length > 1, currencies }; +}; const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—"; const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—"; const cargoDesc = (r: LastMileRecord) => { @@ -583,14 +598,6 @@ const LastMilePage = () => { }, }); - const { data: ratesData } = useQuery({ - queryKey: ["rates", "LAST_MILE"], - queryFn: async () => { - const res = await ratesService.getByType("LAST_MILE"); - return res.data; - }, - }); - const records = listData?.data ?? []; const existingLastMileBookingIds = useMemo( () => new Set(records.map((record) => record.bookingId)), @@ -669,10 +676,17 @@ const LastMilePage = () => { distances: Array<{ vehicleId: string; distanceKm: number }>; remainingPayment?: number; }) => lastMileService.setDistances(id, distances, remainingPayment), - onSuccess: () => { + onSuccess: (res) => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined }); + const updated = res?.data as LastMileRecord | undefined; closeDistance(); + // Every truck has a distance and it isn't billed yet → offer to invoice now. + const trucks = updated?.vehicleAssignments ?? []; + const allFilled = trucks.length > 0 && trucks.every((a) => Number(a.distanceKm) > 0); + if (updated && allFilled && !updated.invoice) { + setInvoiceConfirm(updated); + } }, onError: () => { toast({ title: "Update failed", variant: "destructive" }); @@ -804,18 +818,9 @@ const LastMilePage = () => { return; } - const total = distances.reduce((s, d) => s + d.distanceKm, 0); - let remainingPayment: number | undefined; - if (ratesData?.data) { - const lastMileRate = ratesData.data.find( - (r) => r.rateType === "LAST_MILE" && (r.status === "LIVE" || r.status === "DRAFT") - ); - if (lastMileRate) { - remainingPayment = total * parseFloat(lastMileRate.rateValue); - } - } - - distanceMutation.mutate({ id: activeId, distances, remainingPayment }); + // Amount is computed server-side per truck (distance × the vehicle's + // price/km, in the vehicle's currency) — no flat LAST_MILE rate. + distanceMutation.mutate({ id: activeId, distances }); }; const activeRecord = useMemo( @@ -931,6 +936,11 @@ const LastMilePage = () => { [records], ); + // Billing problems on the leg pending invoice confirmation. + const confirmIssues = invoiceConfirm + ? billingIssues(invoiceConfirm) + : { zeroPrice: [] as string[], mixedCurrency: false, currencies: [] as string[] }; + const filteredRecords = useMemo(() => { const term = search.trim().toLowerCase(); return records.filter((r) => { @@ -1709,21 +1719,29 @@ const LastMilePage = () => { clearable disabled={assignVehicleOptions.length === 0} /> - - n === row.containerNumber || - !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), - )} - value={row.containerNumber} + placeholder={containerOptions.length ? "Select container" : "No container numbers"} + data={[ + ...containerOptions.filter( + (n) => + n === row.containerNumber || + !vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n), + ), + // keep a manual/legacy value selectable even if not in the booking + ...(row.containerNumber && !containerOptions.includes(row.containerNumber) + ? [row.containerNumber] + : []), + ]} + value={row.containerNumber || null} onChange={(value) => setVehicleRows((prev) => - prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)), + prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)), ) } + searchable + clearable /> {vehicleRows.length > 1 && ( { Invoice amount {formatPrice(invoiceConfirm.remainingPayment, currencyOf(invoiceConfirm))} + {confirmIssues.mixedCurrency && ( + + Trucks use {confirmIssues.currencies.join(", ")}. Assign trucks that share one currency before invoicing. + + )} + {confirmIssues.zeroPrice.length > 0 && ( + + {confirmIssues.zeroPrice.join(", ")} will bill 0 — set Price per KM on the vehicle. + + )} This creates the delivery-fee invoice. Confirm the distances and amount are correct. @@ -2012,6 +2040,7 @@ const LastMilePage = () => { setInvoiceConfirm(null)}>Cancel generateInvoiceMutation.mutate(invoiceConfirm.id, { onSuccess: () => setInvoiceConfirm(null), diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 2014f9c65..1bc12b06b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -15,7 +15,7 @@ export type ColumnFormat = | "entityLabel" | "rateLabel"; -export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea"; +export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio"; export interface ResourceColumn { id: string; diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 249130e61..7ee0f7896 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -48,6 +48,7 @@ import { } from "@/components/trainScheduling/containerPlacement.util"; import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; +import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; @@ -1130,6 +1131,12 @@ export default function TrainScheduleV2DetailPage() { void detailQuery.refetch(); }} /> + {scheduleId ? ( + + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index bc5b7dcb1..f36ce8394 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -111,7 +111,12 @@ export default function TrainScheduleV2ListPage() { const create = useMutation(api.trainScheduling.createSchedule.mutationOptions()); const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions()); - const activeRoutes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]); + // Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the + // API rejects them, so keep them out of the picker entirely. + const activeRoutes = useMemo( + () => (routesQuery.data ?? []).filter((r) => r.direction !== "DOMESTIC"), + [routesQuery.data], + ); const selectedRoute = activeRoutes.find((r) => r.id === routeId); @@ -380,7 +385,11 @@ export default function TrainScheduleV2ListPage() { } try { const created = await create.mutateAsync({ - payload: { routeId, scheduleDate, locomotiveIds }, + payload: { + routeId, + scheduleDate: new Date(scheduleDate).toISOString(), + locomotiveIds, + }, }); toast({ title: "Train schedule created" }); showScheduleWarnings(created.warnings); @@ -570,11 +579,8 @@ export default function TrainScheduleV2ListPage() { { - const raw = e.currentTarget.value; - setScheduleDate(raw ? new Date(raw).toISOString() : ""); - }} + value={scheduleDate} + onChange={(e) => setScheduleDate(e.currentTarget.value)} /> TRAIN_SCHEDULING_INVALIDATIONS, ), + intercityCandidates: endpoint< + { scheduleId: string }, + import("@/types/trainScheduling").IntercityCandidatesResult + >( + "train-scheduling", + "intercity-candidates", + ({ scheduleId }) => trainSchedulingService.getIntercityCandidates(scheduleId), + ({ scheduleId }) => ["train-scheduling", "intercity-candidates", scheduleId], + ), + + acceptIntercityBookings: endpoint< + { scheduleId: string; bookingIds: string[] }, + import("@/types/trainScheduling").IntercityAcceptResult + >( + "train-scheduling", + "intercity-accept", + ({ scheduleId, bookingIds }) => + trainSchedulingService.acceptIntercityBookings(scheduleId, bookingIds), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + loadIntercityBooking: endpoint< + { scheduleId: string; bookingId: string }, + void + >( + "train-scheduling", + "intercity-load", + ({ scheduleId, bookingId }) => + trainSchedulingService.loadIntercityBooking(scheduleId, bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + unloadIntercityBooking: endpoint< + { scheduleId: string; bookingId: string }, + void + >( + "train-scheduling", + "intercity-unload", + ({ scheduleId, bookingId }) => + trainSchedulingService.unloadIntercityBooking(scheduleId, bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + cancelSchedule: endpoint< { id: string; freightType?: FreightType }, TrainScheduleDetail diff --git a/apps/edr-freight-web/backoffice/src/services/compliance.service.ts b/apps/edr-freight-web/backoffice/src/services/compliance.service.ts new file mode 100644 index 000000000..a73494c2a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/compliance.service.ts @@ -0,0 +1,73 @@ +import { api as apiClient } from '../auth/http'; + +export type ComplianceType = + | 'INSPECTION' + | 'INSURANCE' + | 'ROADWORTHINESS' + | 'PERMIT' + | 'TAX'; + +export type ComplianceStatus = 'VALID' | 'EXPIRING' | 'EXPIRED'; + +export type AlertSeverity = 'OVERDUE' | 'DUE_SOON'; + +export interface ComplianceRecord { + id: string; + vehicleId: string; + vehicle?: { + id: string; + plateNumber?: string | null; + manufacturer?: string | null; + model?: string | null; + }; + type: ComplianceType; + documentNumber?: string | null; + issuedDate?: string | null; + expiryDate: string; + status: ComplianceStatus; + notes?: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ComplianceAlert { + vehicleId: string; + vehiclePlate?: string; + kind: string; + label: string; + expiryDate: string; + daysUntil: number; + severity: AlertSeverity; +} + +export interface ComplianceListFilters { + vehicleId?: string; + type?: ComplianceType; +} + +export interface SaveCompliancePayload { + vehicleId: string; + type: ComplianceType; + documentNumber?: string; + issuedDate?: string; + expiryDate: string; + status?: ComplianceStatus; + notes?: string; +} + +export const complianceService = { + list: (filters: ComplianceListFilters = {}) => { + const params = new URLSearchParams(); + if (filters.vehicleId) params.set('vehicleId', filters.vehicleId); + if (filters.type) params.set('type', filters.type); + const qs = params.toString(); + return apiClient.get(`/compliance${qs ? `?${qs}` : ''}`); + }, + getAlerts: () => apiClient.get('/compliance/alerts'), + getById: (id: string) => apiClient.get(`/compliance/${id}`), + create: (data: SaveCompliancePayload) => + apiClient.post('/compliance', data), + update: (id: string, data: Partial) => + apiClient.patch(`/compliance/${id}`, data), + remove: (id: string) => apiClient.delete(`/compliance/${id}`), +}; diff --git a/apps/edr-freight-web/backoffice/src/services/drivers.service.ts b/apps/edr-freight-web/backoffice/src/services/drivers.service.ts index 79b0f88ef..c16f3b083 100644 --- a/apps/edr-freight-web/backoffice/src/services/drivers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/drivers.service.ts @@ -39,6 +39,17 @@ export type SaveDriverPayload = Omit< 'id' | 'createdAt' | 'updatedAt' | 'totalTrips' | 'rating' >; +/** A stored driver document (code "driver_docs"). */ +export interface DriverDocument { + id: string; + name: string; + url: string; + size: number; + mimeType: string; + code: string; + createdAt: string; +} + export const driversService = { getAll: (filters: DriverListFilters = {}) => { const params = new URLSearchParams(); @@ -59,4 +70,18 @@ export const driversService = { update: (id: string, data: Partial) => apiClient.patch(URL_CONSTANTS.DRIVERS.BY_ID(id), data), delete: (id: string) => apiClient.delete(URL_CONSTANTS.DRIVERS.BY_ID(id)), + + // ── Driver documents (upload area code "driver_docs") ── + listDocuments: (id: string) => + apiClient.get(`${URL_CONSTANTS.DRIVERS.BY_ID(id)}/documents`), + uploadDocuments: (id: string, files: File[]) => { + const form = new FormData(); + for (const f of files) form.append('files', f); + return apiClient.post( + `${URL_CONSTANTS.DRIVERS.BY_ID(id)}/documents`, + form, + ); + }, + removeDocument: (id: string, fileId: string) => + apiClient.delete(`${URL_CONSTANTS.DRIVERS.BY_ID(id)}/documents/${fileId}`), }; diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts index 6fc80a282..275bf84ea 100644 --- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -47,6 +47,8 @@ export interface FirstMileVehicle { trailerPlateNo?: string | null; assignedDriverId?: string | null; assignedDriverName?: string | null; + pricePerKm?: number | string | null; + currency?: string | null; } export interface FirstMileRecord { diff --git a/apps/edr-freight-web/backoffice/src/services/gps-tracking.service.ts b/apps/edr-freight-web/backoffice/src/services/gps-tracking.service.ts new file mode 100644 index 000000000..d11898328 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/gps-tracking.service.ts @@ -0,0 +1,48 @@ +import { api } from "@/auth/http"; + +export interface GpsDevice { + id: string; + imei: string; + name?: string | null; + vehicleId?: string | null; + vehicle?: { + id: string; + plateNumber?: string; + code?: string | null; + manufacturer?: string; + model?: string; + } | null; + status: string; + online: boolean; + lastSeenAt?: string | null; + lastLat?: number | string | null; + lastLng?: number | string | null; + lastSpeed?: number | string | null; + lastCourse?: number | null; + lastFixAt?: string | null; + voltageLevel?: number | null; + gsmLevel?: number | null; +} + +export interface GpsPosition { + id: string; + lat: number | string; + lng: number | string; + speed: number | string; + course: number; + satellites: number; + gpsTime: string; + alarm: number; +} + +export const gpsTrackingService = { + latest: () => api.get("/gps/positions/latest"), + listDevices: () => api.get("/gps/devices"), + history: (vehicleId: string, limit = 200) => + api.get(`/gps/positions/${vehicleId}/history?limit=${limit}`), + register: (data: { imei: string; name?: string; vehicleId?: string | null }) => + api.post("/gps/devices", data), + update: (id: string, data: { name?: string; vehicleId?: string | null }) => + api.patch(`/gps/devices/${id}`, data), + remove: (id: string) => api.delete(`/gps/devices/${id}`), +}; diff --git a/apps/edr-freight-web/backoffice/src/services/incidents.service.ts b/apps/edr-freight-web/backoffice/src/services/incidents.service.ts new file mode 100644 index 000000000..6e54ec68b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/incidents.service.ts @@ -0,0 +1,87 @@ +import { api } from '@/auth/http'; + +export type IncidentType = 'ACCIDENT' | 'BREAKDOWN' | 'TRAFFIC_VIOLATION' | 'THEFT' | 'OTHER'; +export type IncidentSeverity = 'MINOR' | 'MODERATE' | 'MAJOR' | 'CRITICAL'; +export type IncidentStatus = + | 'REPORTED' + | 'UNDER_REVIEW' + | 'CLAIM_FILED' + | 'RESOLVED' + | 'CLOSED'; + +export interface Incident { + id: string; + vehicleId?: string | null; + driverId?: string | null; + bookingId?: string | null; + type: IncidentType; + severity: IncidentSeverity; + occurredAt: string; + location?: string | null; + description: string; + /** API sends numeric as string; coerce with Number. */ + damageEstimate?: number | string | null; + status: IncidentStatus; + insuranceClaimNumber?: string | null; + reportedBy?: string | null; + vehicle?: { + id: string; + plateNumber?: string | null; + registrationNumber?: string | null; + } | null; + driver?: { + id: string; + firstName?: string | null; + lastName?: string | null; + } | null; + createdAt: string; + updatedAt: string; +} + +export interface IncidentFilters { + vehicleId?: string; + driverId?: string; + status?: IncidentStatus; + type?: IncidentType; +} + +export interface DriverIncidentStats { + total: number; + byType: Record; + lastIncidentAt: string | null; +} + +export interface SaveIncidentPayload { + vehicleId?: string; + driverId?: string; + bookingId?: string; + type: IncidentType; + severity: IncidentSeverity; + occurredAt: string; + location?: string; + description: string; + damageEstimate?: number; + status?: IncidentStatus; + insuranceClaimNumber?: string; + reportedBy?: string; +} + +export const incidentsService = { + getAll: (filters: IncidentFilters = {}) => { + const params = new URLSearchParams(); + if (filters.vehicleId) params.set('vehicleId', filters.vehicleId); + if (filters.driverId) params.set('driverId', filters.driverId); + if (filters.status) params.set('status', filters.status); + if (filters.type) params.set('type', filters.type); + const qs = params.toString(); + return api.get(`/incidents${qs ? `?${qs}` : ''}`); + }, + getByDriver: (driverId: string) => api.get(`/incidents/driver/${driverId}`), + getDriverStats: (driverId: string) => + api.get(`/incidents/driver/${driverId}/stats`), + getById: (id: string) => api.get(`/incidents/${id}`), + create: (data: SaveIncidentPayload) => api.post('/incidents', data), + update: (id: string, data: Partial) => + api.patch(`/incidents/${id}`, data), + delete: (id: string) => api.delete(`/incidents/${id}`), +}; diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index 0b3fe7c2e..4b4133b6a 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -48,6 +48,8 @@ export interface LastMileVehicle { trailerPlateNo?: string | null; assignedDriverId?: string | null; assignedDriverName?: string | null; + pricePerKm?: number | string | null; + currency?: string | null; } export interface LastMileRecord { diff --git a/apps/edr-freight-web/backoffice/src/services/maintenance-depth.service.ts b/apps/edr-freight-web/backoffice/src/services/maintenance-depth.service.ts new file mode 100644 index 000000000..83ae34e67 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/maintenance-depth.service.ts @@ -0,0 +1,130 @@ +import { api } from '@/auth/http'; + +export type WorkOrderStatus = 'OPEN' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED'; +export type WorkOrderPriority = 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT'; + +export interface WorkOrder { + id: string; + vehicleId: string; + title: string; + description?: string | null; + status: WorkOrderStatus; + priority: WorkOrderPriority; + assignedTo?: string | null; + openedAt: string; + closedAt?: string | null; + /** API sends numeric as string; coerce with Number. */ + laborCost?: number | string | null; + partsCost?: number | string | null; + createdAt: string; + updatedAt: string; +} + +export interface SaveWorkOrderPayload { + vehicleId: string; + title: string; + description?: string; + status?: WorkOrderStatus; + priority?: WorkOrderPriority; + assignedTo?: string; + openedAt?: string; + closedAt?: string; + laborCost?: number; + partsCost?: number; +} + +export interface WorkOrderFilters { + vehicleId?: string; + status?: WorkOrderStatus; +} + +export interface Part { + id: string; + name: string; + sku?: string | null; + category?: string | null; + quantityInStock: number; + reorderLevel: number; + /** API sends numeric as string; coerce with Number. */ + unitCost?: number | string | null; + location?: string | null; + createdAt: string; + updatedAt: string; +} + +export interface SavePartPayload { + name: string; + sku?: string; + category?: string; + quantityInStock?: number; + reorderLevel?: number; + unitCost?: number; + location?: string; +} + +export interface PartFilters { + category?: string; + lowStock?: boolean; +} + +export interface Warranty { + id: string; + vehicleId: string; + component: string; + provider?: string | null; + startDate?: string | null; + expiryDate: string; + coverageNotes?: string | null; + createdAt: string; + updatedAt: string; +} + +export interface SaveWarrantyPayload { + vehicleId: string; + component: string; + provider?: string; + startDate?: string; + expiryDate: string; + coverageNotes?: string; +} + +export const maintenanceDepthService = { + // Work Orders + getWorkOrders: (filters: WorkOrderFilters = {}) => { + const params = new URLSearchParams(); + if (filters.vehicleId) params.set('vehicleId', filters.vehicleId); + if (filters.status) params.set('status', filters.status); + const qs = params.toString(); + return api.get(`/maintenance/work-orders${qs ? `?${qs}` : ''}`); + }, + getWorkOrder: (id: string) => api.get(`/maintenance/work-orders/${id}`), + createWorkOrder: (data: SaveWorkOrderPayload) => + api.post('/maintenance/work-orders', data), + updateWorkOrder: (id: string, data: Partial) => + api.patch