mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into alpha
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
604
apps/edr-freight-api/docs/FREIGHT_FLOW_VARIANTS.md
Normal file
604
apps/edr-freight-api/docs/FREIGHT_FLOW_VARIANTS.md
Normal file
@@ -0,0 +1,604 @@
|
||||
# EDR Freight — Major Flow Variants (each self-contained)
|
||||
|
||||
The single master graph lives in [`FREIGHT_MASTER_FLOW.md`](./FREIGHT_MASTER_FLOW.md). This file breaks the
|
||||
business logic into **one comprehensive, self-contained diagram per major scenario**, each organised with
|
||||
phase **subgraphs** so it can be read on its own.
|
||||
|
||||
**Axes covered**
|
||||
|
||||
| Axis | Values |
|
||||
| --------------- | -------------------------------------------------------------------------------------------- |
|
||||
| Origin | **One-time booking** · **General contract** (Path A transport-only / Path B GENERAL+customs) |
|
||||
| Trade direction | **Export** · **Import** · **Intercity / Domestic** |
|
||||
| Customs | **With customs** · **Without customs** |
|
||||
|
||||
**Legend** — (P) Portal (customer) · (B) Backoffice (staff) · (sys) System/event · (green) rounded = success end · (red) rounded = fail end · <> decision.
|
||||
|
||||
**Which diagram do I read?**
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef box fill:#e0e7ff,stroke:#3730a3,color:#111
|
||||
A{"Origin?"}:::dec
|
||||
A -->|"one-time"| B{"Trade direction?<br/>(gate is direction-driven, NOT a customs toggle)"}:::dec
|
||||
A -->|"framework agreement"| C{"Contract type?"}:::dec
|
||||
B -->|"DOMESTIC"| D1["§1 One-time · DOMESTIC (no gate)"]:::box
|
||||
B -->|"IMPORT / EXPORT<br/>(with or without customs)"| D2["§2 One-time · IMPORT/EXPORT (gate)"]:::box
|
||||
C -->|"transport-only (self-clearance)"| D3["§3 Contract · Path A"]:::box
|
||||
C -->|"GENERAL + customs"| D4["§4 Contract · Path B"]:::box
|
||||
D1 --> E{"Physical direction?"}:::dec
|
||||
D2 --> E
|
||||
D3 --> E
|
||||
D4 --> E
|
||||
E -->|"export"| F5["§5 EXPORT operations"]:::box
|
||||
E -->|"import"| F6["§6 IMPORT operations"]:::box
|
||||
E -->|"domestic"| F7["§7 INTERCITY operations"]:::box
|
||||
```
|
||||
|
||||
> **How the two halves connect:** §1–§4 are the **commercial** journeys (intake → approval → contract →
|
||||
> clearance → operation → payment). §5–§7 are the **physical** journeys (mile legs → warehouse → train →
|
||||
> delivery). A shipment = one commercial variant **+** one physical variant. Each diagram fully details its
|
||||
> own half and summarises the other so it stands alone.
|
||||
|
||||
---
|
||||
|
||||
## §1 — One-time booking · DOMESTIC (no clearance gate)
|
||||
|
||||
The commercial lifecycle when the counter-sign gate resolves to **no clearance** — which, in code, means
|
||||
**trade direction = DOMESTIC** (not a customs toggle). Counter-sign goes straight to `FULLY_EXECUTED` and the
|
||||
booking is enqueued **directly into the scheduling batch pipeline, skipping the operation-request/clearance
|
||||
phase**. NOTE: Import/export bookings — _even with customs off_ — do **not** land here; they always hit the
|
||||
clearance gate (§2, just with a lighter "without customs" document set).
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
|
||||
classDef back fill:#fef3c7,stroke:#b45309,color:#111
|
||||
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef good fill:#86efac,stroke:#166534,color:#062e14
|
||||
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
|
||||
|
||||
pre(["Company ACTIVE (approved profile)"]):::sys
|
||||
|
||||
subgraph DRAFT["1 · Create & price"]
|
||||
direction TB
|
||||
d1["POST /bookings → DRAFT<br/>reference, containers, cargo modifiers, files (P)"]:::port
|
||||
d2["POST /bookings/:id/generate-price<br/>rule-engine: LIVE rates + surcharges (P)"]:::port
|
||||
dW{"weight-limit-rules"}:::dec
|
||||
dWx(["HARD BLOCK 400 — VGM > capacity"]):::bad
|
||||
d3["POST /bookings/:id/submit → SUBMITTED<br/>freeze booking_rate_snapshot (P)"]:::port
|
||||
dP{"price moved?"}:::dec
|
||||
d3c["confirm-submit → SUBMITTED (P)"]:::port
|
||||
d1 --> d2 --> dW
|
||||
dW -->|"over capacity"| dWx
|
||||
dW -->|"ok / warn+surcharge"| d3 --> dP
|
||||
dP -->|"yes"| d3c
|
||||
dP -->|"no"| out1
|
||||
d3c --> out1
|
||||
d1 -.->|"delete draft"| ddx(["removed"]):::bad
|
||||
d3 -.->|"reject price"| drx(["REJECTED"]):::bad
|
||||
end
|
||||
out1[" "]:::sys
|
||||
|
||||
subgraph INTAKE["2 · Staff intake & approval"]
|
||||
direction TB
|
||||
g{"government?"}:::dec
|
||||
gexp["governmentExpedite → PAID + Eligible (B)"]:::back
|
||||
i{"staff/accept | request-changes | reject (B)"}:::dec
|
||||
ir["CHANGES_REQUESTED (B)"]:::back
|
||||
irx(["REJECTED"]):::bad
|
||||
ia["→ PENDING_APPROVAL<br/>instantiate approval steps + validity window (B)"]:::back
|
||||
ac{"chain: LINE_STAFF → DIRECTOR → CEO (B)"}:::dec
|
||||
acx(["rejectStep → REJECTED"]):::bad
|
||||
g -->|"yes"| gexp
|
||||
g -->|"no"| i
|
||||
i -->|"request-changes"| ir
|
||||
i -->|"reject"| irx
|
||||
i -->|"accept"| ia --> ac
|
||||
ac -->|"rejectStep"| acx
|
||||
end
|
||||
|
||||
subgraph SIGN["3 · Contract doc & sign (DOMESTIC → no gate)"]
|
||||
direction TB
|
||||
s1["contract/generate → CONTRACT_READY (B)"]:::back
|
||||
s2["customer sign → SIGNED_CUSTOMER (P)"]:::port
|
||||
s3["staff counter-sign (DOMESTIC) → FULLY_EXECUTED<br/>enqueueScheduleProcessing (no op-request) (B)(sys)"]:::back
|
||||
s1 --> s2 --> s3
|
||||
end
|
||||
|
||||
subgraph OPPAY["4 · Batch pipeline & payment"]
|
||||
direction TB
|
||||
fe["FULLY_EXECUTED enters day batch pool (sys)"]:::sys
|
||||
b1["batch engine offers wagons → SELECTED_FOR_BATCH<br/>invoice generated (sys)"]:::sys
|
||||
p1["customer pays → gateway → PAID (P)"]:::port
|
||||
pexp(["pay window lapses → reservation EXPIRED"]):::bad
|
||||
fe --> b1 --> p1
|
||||
p1 -.->|"unpaid"| pexp
|
||||
end
|
||||
|
||||
phys(["Physical execution:<br/>§7 intercity → COMPLETED (done)"]):::good
|
||||
|
||||
pre --> DRAFT
|
||||
out1 --> INTAKE
|
||||
ac -->|"APPROVED"| SIGN
|
||||
SIGN --> OPPAY
|
||||
p1 --> phys
|
||||
gexp -.->|"gov → PAID/Eligible"| phys
|
||||
```
|
||||
|
||||
> **Road-mode note:** a domestic booking billed by road (truck) instead of rail goes through
|
||||
> `operation/review` → `ROAD_DISPATCH_PENDING` (the road branch shown in the master graph), not the rail
|
||||
> batch pool above.
|
||||
|
||||
---
|
||||
|
||||
## §2 — One-time booking · IMPORT / EXPORT (clearance gate)
|
||||
|
||||
Every IMPORT/EXPORT one-time booking traverses the clearance gate — **whether or not customs is enabled**
|
||||
(the customs flag only selects a heavier vs lighter `clearance_*` document set; both go through
|
||||
`AWAITING_DOCUMENTS`). Commercial spine as §1 (phases 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<br/>(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<br/>→ DOCUMENTS_UNDER_REVIEW (P)"]:::port
|
||||
x2{"GL clearance/review each doc (B)"}:::dec
|
||||
x2q["doc Queried → customer re-uploads (B)"]:::back
|
||||
x3["clearance/finalize (100% approved) → CLEARANCE_READY (B)"]:::back
|
||||
x1 --> x2
|
||||
x2 -->|"query"| x2q --> x1
|
||||
x2 -->|"approve all"| x3
|
||||
end
|
||||
|
||||
subgraph PHASED["6 · Phased ET / DJ clearance (as applicable)"]
|
||||
direction TB
|
||||
ph1["upload declaration (serial) (B)"]:::back
|
||||
ph2["duty/tax advise → customer duty-slip (P)(B)"]:::back
|
||||
ph3["transit permit (ET) (B)"]:::back
|
||||
ph4["delivery order / release order (DJ) (B)"]:::back
|
||||
ph5["T1 docs → T1 close (B)"]:::back
|
||||
ph6["export release / finalize-pre-clearance (B)"]:::back
|
||||
ph1 --> ph2 --> ph3 --> ph4 --> ph5 --> ph6
|
||||
end
|
||||
|
||||
subgraph OPPAY2["7 · Operation request & payment"]
|
||||
direction TB
|
||||
o1["clearance/proceed → OPERATION_REQUEST_PENDING (P)"]:::port
|
||||
o2{"operation/review (B)"}:::dec
|
||||
o2c["OPERATION_CHANGES_REQUESTED (B)"]:::back
|
||||
om{"mode?"}:::dec
|
||||
ot["accept=train: invoice → FULLY_EXECUTED<br/>→ batch offer → SELECTED_FOR_BATCH (B)(sys)"]:::back
|
||||
orr["accept=road: invoice → ROAD_DISPATCH_PENDING (B)"]:::back
|
||||
p1["customer pays → PAID (sys)(P)"]:::sys
|
||||
pexp(["pay window lapses → EXPIRED"]):::bad
|
||||
o1 --> o2
|
||||
o2 -->|"request-changes"| o2c --> o1
|
||||
o2 -->|"accept"| om
|
||||
om -->|"train"| ot --> p1
|
||||
om -->|"road"| orr --> p1
|
||||
p1 -.->|"unpaid"| pexp
|
||||
end
|
||||
|
||||
cancel(["CANCELLED — staff-only, only from<br/>OPERATION_REQUEST_PENDING here (not from<br/>AWAITING_DOCUMENTS / DOCUMENTS_UNDER_REVIEW)"]):::bad
|
||||
phys(["Physical execution:<br/>§5 export · §6 import → COMPLETED (done)"]):::good
|
||||
|
||||
a0 --> CS --> DOCS
|
||||
x3 --> PHASED
|
||||
ph6 --> OPPAY2
|
||||
p1 --> phys
|
||||
o1 -.->|"cancel"| cancel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §3 — General contract · Path A (transport-only, self-clearance)
|
||||
|
||||
Framework agreement where the customer clears customs independently. After the contract is active and
|
||||
operations verify self-clearance (`SELF_CLEARED`), the **customer books directly** under the contract; each
|
||||
booking then runs the operation/payment/physical flow.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
|
||||
classDef back fill:#fef3c7,stroke:#b45309,color:#111
|
||||
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef good fill:#86efac,stroke:#166534,color:#062e14
|
||||
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
|
||||
|
||||
pre(["Company ACTIVE"]):::sys
|
||||
|
||||
subgraph CTR["1 · Contract lifecycle"]
|
||||
direction TB
|
||||
c1["POST /contracts → DRAFT<br/>routes + cargo scope + unit rates (NO quantities) (P)"]:::port
|
||||
c2["generate-price → submit → SUBMITTED<br/>freeze contract_rate_snapshots (P)"]:::port
|
||||
c3{"staff/accept | request-changes | reject (B)"}:::dec
|
||||
c3r["CHANGES_REQUESTED (B)"]:::back
|
||||
c3x(["Contract REJECTED"]):::bad
|
||||
c4["→ PENDING_APPROVAL (B)"]:::back
|
||||
c4a{"approval chain (B)"}:::dec
|
||||
c4x(["rejectStep → REJECTED"]):::bad
|
||||
c5["generate-contract → CONTRACT_READY (B)"]:::back
|
||||
c6["customer sign → SIGNED_CUSTOMER (P)"]:::port
|
||||
c7["staff counter-sign (IMPORT/EXPORT self-clearance) →<br/>AWAITING_CLEARANCE_DOCUMENTS (B)"]:::back
|
||||
c1 --> c2 --> c3
|
||||
c3 -->|"request-changes"| c3r --> c2
|
||||
c3 -->|"reject"| c3x
|
||||
c3 -->|"accept"| c4 --> c4a
|
||||
c4a -->|"reject"| c4x
|
||||
c4a -->|"approve"| c5 --> c6 --> c7
|
||||
c7 -.->|"lapse"| cexp(["EXPIRED"]):::bad
|
||||
c7 -.->|"renew"| cren(["RENEWAL_DRAFT → new cycle"]):::bad
|
||||
end
|
||||
|
||||
subgraph SELF["2 · Self-clearance verification"]
|
||||
direction TB
|
||||
o1["customer uploads self-clearance docs (P)"]:::port
|
||||
o2{"ops-review each doc (B)"}:::dec
|
||||
o2q["query → re-upload (B)"]:::back
|
||||
o3["ops-finalize → clearanceStatus SELF_CLEARED (B)"]:::back
|
||||
o1 --> o2
|
||||
o2 -->|"query"| o2q --> o1
|
||||
o2 -->|"approve"| o3
|
||||
end
|
||||
|
||||
subgraph BK["3 · Book directly under contract"]
|
||||
direction TB
|
||||
b1["customer POST /contracts/:id/bookings (P)"]:::port
|
||||
bv{"validate-shipment:<br/>window + capacity draw-down + pairing"}:::dec
|
||||
bvx(["rejected: over capacity /<br/>20ft pairing hard-block"]):::bad
|
||||
b2["Booking created under contract<br/>(bookings.contract_id) (sys)"]:::sys
|
||||
b1 --> bv
|
||||
bv -->|"fail"| bvx
|
||||
bv -->|"ok"| b2
|
||||
end
|
||||
|
||||
op(["Booking runs operation + payment<br/>(see §1 phase 4) then §5/§6/§7 → COMPLETED (done)"]):::good
|
||||
|
||||
pre --> CTR
|
||||
c7 --> SELF
|
||||
o3 --> BK
|
||||
b2 --> op
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §4 — General contract · Path B (GENERAL + customs)
|
||||
|
||||
Framework agreement **with** customs. The customer cannot book directly — they submit a **BookingRequest**
|
||||
(date + quantities only); GL Ethiopia accepts it and creates the booking, which then runs **per-booking
|
||||
phased customs** on the `/contracts/bookings/:bookingId/*` surface.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
|
||||
classDef back fill:#fef3c7,stroke:#b45309,color:#111
|
||||
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef good fill:#86efac,stroke:#166534,color:#062e14
|
||||
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
|
||||
|
||||
pre(["Company ACTIVE"]):::sys
|
||||
|
||||
subgraph CTR["1 · Contract lifecycle (customs)"]
|
||||
direction TB
|
||||
c1["POST /contracts → DRAFT → submit → approve → sign (P)(B)"]:::port
|
||||
c7["staff counter-sign (GENERAL + customs) → CONTRACT_ACTIVE<br/>(contract clearance cycle SKIPPED — runs per-booking) (B)"]:::back
|
||||
c1 --> c7
|
||||
c1 -.->|"reject / lapse"| cx(["REJECTED / EXPIRED"]):::bad
|
||||
end
|
||||
|
||||
subgraph REQ["2 · Booking request → GL creates booking"]
|
||||
direction TB
|
||||
r1["customer POST /contracts/:id/booking-requests<br/>(date + quantities, no per-unit data) (P)"]:::port
|
||||
r2{"GL booking-request queue (B)"}:::dec
|
||||
r2x(["reject / customer cancel →<br/>REJECTED / CANCELLED"]):::bad
|
||||
r3["GL accept → GL creates booking under contract<br/>(ct:create_booking) (B)"]:::back
|
||||
r1 --> r2
|
||||
r2 -->|"reject/cancel"| r2x
|
||||
r2 -->|"accept"| r3
|
||||
end
|
||||
|
||||
subgraph GLC["3 · Per-booking GL clearance & milestones"]
|
||||
direction TB
|
||||
g1["station-assign (route + bind staff) (B)"]:::back
|
||||
g2["declaration → duty advise (GREEN/YELLOW/RED risk) (B)"]:::back
|
||||
g3["customer duty-slip → transit / delivery / release order (P)(B)"]:::back
|
||||
g4["T1 docs → T1 close (B)"]:::back
|
||||
g5["final-invoice → customer slip → confirm paid (P)(B)"]:::back
|
||||
g6["second-duty (post-arrival import) → slip (P)(B)"]:::back
|
||||
gi["incident reports (photos) as needed (B)"]:::back
|
||||
g1 --> g2 --> g3 --> g4 --> g5 --> g6
|
||||
g4 -.-> gi
|
||||
end
|
||||
|
||||
op(["Booking runs operation + payment (see §1 phase 4)<br/>then §5/§6 physical → COMPLETED (done)"]):::good
|
||||
|
||||
pre --> CTR
|
||||
c7 --> REQ
|
||||
r3 --> GLC
|
||||
g6 --> op
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §5 — EXPORT operations (physical execution)
|
||||
|
||||
Given a PAID, scheduled **export** booking: optional first-mile road leg → origin warehouse inbound → train
|
||||
build & dispatch → corridor transit → Djibouti port unload → interchange handover. Cargo leaves the country
|
||||
at the port; the booking's terminal here is **dispatched/handed-over at Djibouti**.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
|
||||
classDef back fill:#fef3c7,stroke:#b45309,color:#111
|
||||
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef good fill:#86efac,stroke:#166534,color:#062e14
|
||||
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
|
||||
|
||||
pre(["Export booking PAID & Eligible<br/>(see §1/§2/§3/§4)"]):::sys
|
||||
|
||||
subgraph FM["1 · First-mile (if EXPORT + requested)"]
|
||||
direction TB
|
||||
fmq{"first-mile requested?"}:::dec
|
||||
fm1["leg auto-created firstMile.acceptBooking<br/>READY_TO_TRANSIT (sys)"]:::sys
|
||||
fm2["setVehicles → vehicle BUSY, SMS driver, fleet_events (B)"]:::back
|
||||
fm3["IN_TRANSIT (needs vehicle) → RECEIVED_TO_PORT (B)"]:::back
|
||||
fm4["first-mile invoice (FIRST_MILE fee); distances lock once invoiced (B)"]:::back
|
||||
fmq -->|"yes"| fm1 --> fm2 --> fm3 --> fm4
|
||||
fmq -->|"no"| fmskip[" "]:::sys
|
||||
end
|
||||
|
||||
subgraph WH["2 · Origin warehouse inbound"]
|
||||
direction TB
|
||||
w1["receive / bulkReceive → RECEIVED<br/>capacity assert, GRN, notify owner SMS (B)"]:::back
|
||||
w2{"inspection"}:::dec
|
||||
w2f["FAILED / NEEDS_REVIEW → hold + re-inspect (B)"]:::back
|
||||
w3["store (allocation rule → yard/zone) → STORED (B)"]:::back
|
||||
w4["reserve (booking PAID) → RESERVED (B)"]:::back
|
||||
w5["mark-ready-for-loading (inspection PASSED) → READY_FOR_LOADING (B)"]:::back
|
||||
w6["load onto wagon → LOADED (+ warehouse_loadings) (B)"]:::back
|
||||
w1 --> w2
|
||||
w2 -->|"fail"| w2f --> w2
|
||||
w2 -->|"PASSED"| w3 --> w4 --> w5 --> w6
|
||||
end
|
||||
|
||||
subgraph SCHED["3 · Train build & schedule"]
|
||||
direction TB
|
||||
s1["schedule DRAFT (≥2 locos, derive EXPORT direction) (B)"]:::back
|
||||
s2["assign-bookings + run-allocation (wagons) (B)"]:::back
|
||||
s3["pin wagons → finalize → SCHEDULED (bookings Scheduled) (B)"]:::back
|
||||
s3x["cancel schedule → bookings Eligible (B)"]:::back
|
||||
s1 --> s2 --> s3
|
||||
s3 -.->|"cancel"| s3x -.-> s1
|
||||
s3 -.->|"gov preempt / maintenance"| sr["reschedule: retained/displaced/readmitted (B)"]:::back
|
||||
sr -.-> s2
|
||||
end
|
||||
|
||||
subgraph RUN["4 · Dispatch → Djibouti"]
|
||||
direction TB
|
||||
r1["dispatch → DISPATCHED<br/>train_number, locos ASSIGNED, window CLOSED, unpaid EXPIRED (B)"]:::back
|
||||
r2["checkpoints (corridor) → train_checkpoint_events (B)"]:::back
|
||||
rc["customer tracking page GET /tracking/:id (JWT) (P)<br/>NOTE: tracking_events has no writer — timeline empty"]:::port
|
||||
r3["arrive → ARRIVED (bookings IN_TRANSIT, wagons/locos freed) (B)"]:::back
|
||||
ru["export/auto-unload-at-djibouti →<br/>UNLOADED_AT_DJIBOUTI_PORT (B)"]:::back
|
||||
ri["interchange document generate-from-schedule → GENERATED (B)"]:::back
|
||||
ria{"port acknowledges?"}:::dec
|
||||
r1 --> r2 --> r3 --> ru --> ri --> ria
|
||||
r2 -.-> rc
|
||||
end
|
||||
|
||||
done(["Export dispatched & handed over at Djibouti (done)"]):::good
|
||||
disp(["interchange DISPUTED → remarks / re-issue"]):::bad
|
||||
|
||||
pre --> FM
|
||||
fm4 --> WH
|
||||
fmskip --> WH
|
||||
w6 --> SCHED
|
||||
s3 --> RUN
|
||||
ria -->|"acknowledge"| done
|
||||
ria -->|"dispute"| disp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §6 — IMPORT operations (physical execution)
|
||||
|
||||
Given a PAID, scheduled **import** booking arriving by train from Djibouti: destination warehouse unload →
|
||||
inspection → import customs finalization → optional last-mile → fee gate-clearance → release → delivery →
|
||||
**COMPLETED**.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
|
||||
classDef back fill:#fef3c7,stroke:#b45309,color:#111
|
||||
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef good fill:#86efac,stroke:#166534,color:#062e14
|
||||
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
|
||||
|
||||
pre(["Import booking PAID & scheduled<br/>(see §1/§2/§4)"]):::sys
|
||||
|
||||
subgraph RAIL["1 · Import train arrival"]
|
||||
direction TB
|
||||
t1["import train dispatched from Djibouti → DISPATCHED (B)"]:::back
|
||||
t2["checkpoints → train_checkpoint_events (B)"]:::back
|
||||
tc["customer tracking page GET /tracking/:id (JWT) (P)<br/>NOTE: tracking_events has no writer — timeline empty"]:::port
|
||||
t3["arrive → ARRIVED (bookings IN_TRANSIT) → warehouse arrival automation (B)"]:::back
|
||||
t1 --> t2 --> t3
|
||||
t2 -.-> tc
|
||||
end
|
||||
|
||||
subgraph WH["2 · Destination warehouse"]
|
||||
direction TB
|
||||
w1["import/auto-unload-arrived-bookings (assign WH/yard/zone) → UNLOADED (B)"]:::back
|
||||
w2{"inspection PASSED?"}:::dec
|
||||
w2f["FAILED → hold / re-inspect / djibouti-incident report (B)"]:::back
|
||||
w3["→ READY_FOR_PICKUP (IMPORT) (B)"]:::back
|
||||
w1 --> w2
|
||||
w2 -->|"no"| w2f --> w2
|
||||
w2 -->|"yes"| w3
|
||||
end
|
||||
|
||||
subgraph CUST["3 · Import customs finalization (timestamp-driven)"]
|
||||
direction TB
|
||||
u1["upload docs (IM4/IM5/T1_CLOSURE/TRANSIT_PERMIT/…) (B)"]:::back
|
||||
u2["record declaration serial (B)"]:::back
|
||||
u3["notify duties/taxes (B)"]:::back
|
||||
u4["mark duties paid (needs CUSTOMER_PAYMENT_SLIP) (B)"]:::back
|
||||
u5["assign risk (GREEN/YELLOW/BLUE/RED) (B)"]:::back
|
||||
u6{"release gates satisfied?<br/>T1 + release permit + declaration + risk + paid"}:::dec
|
||||
u6x["blocked — missing gate → resolve (B)"]:::back
|
||||
u7["release-permitted → completedAt (B)"]:::back
|
||||
u1 --> u2 --> u3 --> u4 --> u5 --> u6
|
||||
u6 -->|"no"| u6x --> u6
|
||||
u6 -->|"yes"| u7
|
||||
end
|
||||
|
||||
subgraph LM["4 · Last-mile (if requested)"]
|
||||
direction TB
|
||||
lq{"last-mile requested?"}:::dec
|
||||
l1["leg auto-created lastMile.acceptBooking<br/>READY_TO_TRANSIT (sys)"]:::sys
|
||||
l2["setVehicles → IN_TRANSIT → DELIVERED (free vehicles) (B)"]:::back
|
||||
l3["last-mile invoice (LAST_MILE fee) (B)"]:::back
|
||||
lq -->|"yes"| l1 --> l2 --> l3
|
||||
lq -->|"no"| lskip[" "]:::sys
|
||||
end
|
||||
|
||||
subgraph DEL["5 · Release & delivery"]
|
||||
direction TB
|
||||
d0{"warehouse/storage fees fully PAID?"}:::dec
|
||||
d0x["gate-clearance BLOCKED (findBlockingInvoice) (B)"]:::back
|
||||
d0p["customer pays storage/demurrage online (P)"]:::port
|
||||
d1["release order (DO) + gate-clearance → deliver (B)"]:::back
|
||||
d2["customer approve-delivery (saved signature) → POD (P)"]:::port
|
||||
d3["inventory DELIVERED, POD to cargo, container freed (sys)"]:::sys
|
||||
d0 -->|"no"| d0x --> d0p --> d0
|
||||
d0 -->|"yes"| d1 --> d2 --> d3
|
||||
end
|
||||
|
||||
ecr(["empty-container-return chain (post-import):<br/>RETURNED → … → HANDOVER_ISSUED → COMPLETED"]):::sys
|
||||
done(["Booking COMPLETED (done) (operations/complete)"]):::good
|
||||
|
||||
pre --> RAIL
|
||||
t3 --> WH
|
||||
w3 --> CUST
|
||||
u7 --> LM
|
||||
l3 --> DEL
|
||||
lskip --> DEL
|
||||
d3 --> done
|
||||
done -.-> ecr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §7 — INTERCITY / DOMESTIC operations (no cross-border customs)
|
||||
|
||||
Rail movement **between Ethiopian yards** (e.g. inland dry ports). No import/export customs, no Djibouti
|
||||
port unload or interchange handover. Optional road mile legs if the service includes door delivery.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
|
||||
classDef back fill:#fef3c7,stroke:#b45309,color:#111
|
||||
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef good fill:#86efac,stroke:#166534,color:#062e14
|
||||
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
|
||||
|
||||
pre(["Domestic booking PAID & Eligible<br/>(see §1 — customs OFF)"]):::sys
|
||||
|
||||
subgraph FMD["1 · Optional origin pickup (road)"]
|
||||
direction TB
|
||||
fq{"door pickup / first-mile requested?"}:::dec
|
||||
f1["first-mile leg → vehicle assign → RECEIVED_TO_PORT (B)"]:::back
|
||||
fq -->|"yes"| f1 --> fnext[" "]:::sys
|
||||
fq -->|"no (drop at origin yard)"| fnext
|
||||
end
|
||||
|
||||
subgraph WHO["2 · Origin warehouse"]
|
||||
direction TB
|
||||
w1["receive → RECEIVED (B)"]:::back
|
||||
w2{"inspection PASSED?"}:::dec
|
||||
w2f["hold + re-inspect (B)"]:::back
|
||||
w3["store → STORED → reserve (PAID) → RESERVED (B)"]:::back
|
||||
w4["ready-for-loading → LOADED onto wagon (B)"]:::back
|
||||
w1 --> w2
|
||||
w2 -->|"no"| w2f --> w2
|
||||
w2 -->|"yes"| w3 --> w4
|
||||
end
|
||||
|
||||
subgraph RUN["3 · Train (ET yard → ET yard)"]
|
||||
direction TB
|
||||
s1["schedule DRAFT (direction DOMESTIC) → assign → finalize → SCHEDULED (B)"]:::back
|
||||
r1["dispatch → DISPATCHED (unpaid EXPIRED) (B)"]:::back
|
||||
r2["checkpoints → train_checkpoint_events; customer tracking (JWT) (P)(B)"]:::back
|
||||
r3["arrive → ARRIVED (bookings IN_TRANSIT) (B)"]:::back
|
||||
s1 --> r1 --> r2 --> r3
|
||||
end
|
||||
|
||||
subgraph WHD["4 · Destination warehouse & delivery"]
|
||||
direction TB
|
||||
d1["auto-unload arrived → UNLOADED / RECEIVED (B)"]:::back
|
||||
di{"inspection PASSED?"}:::dec
|
||||
dif["hold + re-inspect (B)"]:::back
|
||||
d2["READY_FOR_PICKUP (B)"]:::back
|
||||
fee{"storage fees paid?"}:::dec
|
||||
feex["gate-clearance blocked → customer pays (P)"]:::port
|
||||
d3["release order → deliver (B)"]:::back
|
||||
lq{"door delivery / last-mile?"}:::dec
|
||||
l1["last-mile leg → DELIVERED (B)"]:::back
|
||||
d4["customer approve-delivery → POD → inventory DELIVERED (P)(sys)"]:::sys
|
||||
d1 --> di
|
||||
di -->|"no"| dif --> di
|
||||
di -->|"yes"| d2 --> fee
|
||||
fee -->|"no"| feex --> fee
|
||||
fee -->|"yes"| d3 --> lq
|
||||
lq -->|"yes"| l1 --> d4
|
||||
lq -->|"no (pickup at yard)"| d4
|
||||
end
|
||||
|
||||
done(["Booking COMPLETED (done)"]):::good
|
||||
|
||||
pre --> FMD
|
||||
fnext --> WHO
|
||||
w4 --> RUN
|
||||
r3 --> WHD
|
||||
d4 --> done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cross-reference
|
||||
|
||||
| Variant | Distinctive gate(s) | Terminal ends unique to it |
|
||||
| --------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| §1 One-time · DOMESTIC | counter-sign → `FULLY_EXECUTED` (skips clearance **and** op-request; enters batch directly) | HARD BLOCK, price/intake/approval REJECTED, EXPIRED |
|
||||
| §2 One-time · IMPORT/EXPORT | `AWAITING_DOCUMENTS` → review loop → phased ET/DJ → op-request (gate applies with **or without** customs) | + doc-query loop, CANCELLED (only from OPERATION_REQUEST_PENDING) |
|
||||
| §3 Contract · Path A | `SELF_CLEARED` via ops-review; customer books direct | contract REJECTED/EXPIRED/RENEWAL, capacity/pairing block |
|
||||
| §4 Contract · Path B | BookingRequest → GL creates booking; per-booking milestones | BookingRequest REJECTED/CANCELLED |
|
||||
| §5 Export ops | first-mile → Djibouti unload → interchange handover | dispatched@Djibouti (success), interchange DISPUTED |
|
||||
| §6 Import ops | import customs finalization gates → last-mile → gate-clearance | COMPLETED (+ empty-container-return chain) |
|
||||
| §7 Intercity ops | ET→ET rail, no cross-border customs, optional mile legs | COMPLETED |
|
||||
|
||||
Full endpoint tables & per-domain state machines: [`FREIGHT_SYSTEM_FLOW.md`](./FREIGHT_SYSTEM_FLOW.md).
|
||||
Single all-in-one branching graph: [`FREIGHT_MASTER_FLOW.md`](./FREIGHT_MASTER_FLOW.md).
|
||||
260
apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md
Normal file
260
apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# EDR Freight — One Master Flow (signup → every end)
|
||||
|
||||
Single comprehensive graph of the entire freight business logic: from customer signup through every
|
||||
branch to every terminal state. Actor-coloured, endpoint-labelled.
|
||||
|
||||
**Legend**
|
||||
(P) Portal (customer) · (B) Backoffice (staff) · (sys) System/auto (event, cron, service-to-service)
|
||||
Rounded green = success end · Red = failure/terminal end · Diamond = decision · Hexagon = domain event.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
classDef start fill:#e0e7ff,stroke:#3730a3,color:#111
|
||||
classDef port fill:#dbeafe,stroke:#2563eb,color:#111
|
||||
classDef back fill:#fef3c7,stroke:#b45309,color:#111
|
||||
classDef sys fill:#dcfce7,stroke:#15803d,color:#111
|
||||
classDef dec fill:#f8fafc,stroke:#475569,color:#111
|
||||
classDef good fill:#86efac,stroke:#166534,color:#062e14
|
||||
classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a
|
||||
|
||||
%% ================= PHASE 1: IDENTITY & ONBOARDING =================
|
||||
S0(["Customer visits portal"]):::start
|
||||
S0 --> S1["Signup via IAM<br/>GET /auth/check-availability @Public<br/>POST /otp/send + /otp/verify (P)"]:::port
|
||||
S1 --> S2{"Identity proofing<br/>(VeriFayda)?"}:::dec
|
||||
S2 -->|"Yes"| S3["POST /fayda/verification/start →<br/>/callback → /complete<br/>upsert iam.users (verified_by=fayda) (P)"]:::port
|
||||
S2 -->|"No"| S4
|
||||
S3 --> S4["POST /companies/onboarding/start<br/>draft company (placeholder TIN, PENDING) (P)"]:::port
|
||||
S4 --> S4b["Wizard: PATCH /profile, /onboarding-step,<br/>upload license + docs<br/>GET /onboarding/requirements (P)"]:::port
|
||||
S4b --> S5["POST /companies/onboarding/complete<br/>re-validate → company+profiles = PENDING (P)"]:::port
|
||||
S5 --> S6{"Backoffice reviews profile<br/>PATCH /company-profiles/:id/status (B)"}:::dec
|
||||
S6 -->|"Reject / suspend"| S6x(["SUSPENDED / BLACKLISTED<br/>cannot transact"]):::bad
|
||||
S6 -->|"Approve"| S7["Mint reference (EX-#####),<br/>company → ACTIVE (B)"]:::back
|
||||
S7 --> S8{"Start a shipment?"}:::dec
|
||||
S8 -->|"idle"| S8x(["No booking (dormant account)"]):::bad
|
||||
S8 -->|"Yes"| MODE
|
||||
|
||||
%% ================= PHASE 2: COMMERCIAL ORIGIN =================
|
||||
MODE{"Booking origin?"}:::dec
|
||||
MODE -->|"One-time shipment"| B0
|
||||
MODE -->|"Framework agreement"| C1
|
||||
|
||||
%% ---- Contract track ----
|
||||
C1["Create CONTRACT DRAFT<br/>POST /contracts (routes + cargo scope) (P)"]:::port
|
||||
C1 --> C1d{"Abandon draft?"}:::dec
|
||||
C1d -->|"delete"| C1x(["Contract removed (soft-delete)"]):::bad
|
||||
C1d -->|"continue"| C2["generate-price → submit → SUBMITTED<br/>freeze contract_rate_snapshots (P)"]:::port
|
||||
C2 --> C2p{"Price changed?"}:::dec
|
||||
C2p -->|"Yes"| C2c["confirm-submit → SUBMITTED (P)"]:::port
|
||||
C2p -->|"No"| C3
|
||||
C2c --> C3
|
||||
C3{"Staff intake<br/>POST /contracts/:id/staff/* (B)"}:::dec
|
||||
C3 -->|"request-changes"| C3r["CHANGES_REQUESTED (B)"]:::back
|
||||
C3r -->|"edit + resubmit"| C2
|
||||
C3 -->|"reject"| C3x(["Contract REJECTED"]):::bad
|
||||
C3 -->|"accept"| C4["→ PENDING_APPROVAL<br/>instantiate approval steps (B)"]:::back
|
||||
C4 --> C4a{"Approval chain<br/>line → director → ceo (B)"}:::dec
|
||||
C4a -->|"rejectStep"| C4x(["Contract REJECTED"]):::bad
|
||||
C4a -->|"all approve"| C5["generate-contract → CONTRACT_READY (B)"]:::back
|
||||
C5 --> C6["Customer sign → SIGNED_CUSTOMER<br/>POST /contracts/:id/contract/sign (P)"]:::port
|
||||
C6 --> C7{"Staff counter-sign branch"}:::dec
|
||||
C7 -->|"customs on"| C7a["AWAITING_CLEARANCE_DOCUMENTS (B)"]:::back
|
||||
C7 -->|"GENERAL"| C7b["CONTRACT_ACTIVE (B)"]:::back
|
||||
C7 -->|"ONE_TIME"| C7c["FULLY_EXECUTED (B)"]:::back
|
||||
C7a --> CPATH
|
||||
C7b --> CPATH
|
||||
C7c --> CPATH
|
||||
C7b -.->|"renew"| C7renew(["RENEWAL_DRAFT → new cycle"]):::bad
|
||||
C7b -.->|"lapse"| C7exp(["Contract EXPIRED"]):::bad
|
||||
|
||||
CPATH{"How are shipments booked<br/>under the contract?"}:::dec
|
||||
CPATH -->|"Path A: transport-only"| CPA["Ops review self-clearance<br/>ops-review → ops-finalize → SELF_CLEARED (B)<br/>then customer books direct<br/>POST /contracts/:id/bookings (P)"]:::port
|
||||
CPATH -->|"Path B: GENERAL + customs"| CPB["Customer submits BookingRequest<br/>POST /contracts/:id/booking-requests (P)"]:::port
|
||||
CPB --> CPBq{"GL queue decision (B)"}:::dec
|
||||
CPBq -->|"reject / customer cancels"| CPBx(["BookingRequest REJECTED / CANCELLED"]):::bad
|
||||
CPBq -->|"accept → GL creates booking"| B0u
|
||||
CPA --> B0u["Booking created UNDER contract<br/>(window + capacity draw-down check) (sys)"]:::sys
|
||||
B0u --> BFLOW
|
||||
|
||||
%% ---- One-time booking ----
|
||||
B0["Create BOOKING DRAFT<br/>POST /bookings (reference, containers,<br/>cargo modifiers, files) (P)"]:::port
|
||||
B0 --> B0d{"Abandon draft?"}:::dec
|
||||
B0d -->|"delete"| B0x(["Booking removed (soft-delete)"]):::bad
|
||||
B0d -->|"continue"| BFLOW
|
||||
B0 -.->|"consolidation"| BCONS(["PENDING_CONSOLIDATION<br/>waits for partner shipment<br/>(shares a wagon) → rejoins"]):::sys
|
||||
BCONS -.-> BFLOW
|
||||
|
||||
%% ================= PHASE 3: PRICING & SUBMIT =================
|
||||
BFLOW["Configure shipment<br/>freight type + trade direction"]:::sys
|
||||
BFLOW --> FT{"Freight type?"}:::dec
|
||||
FT -->|"CONTAINER"| DIR
|
||||
FT -->|"BULK"| DIR
|
||||
DIR{"Trade direction?"}:::dec
|
||||
DIR -->|"EXPORT"| B1
|
||||
DIR -->|"IMPORT"| B1
|
||||
DIR -->|"DOMESTIC"| B1
|
||||
B1["POST /bookings/:id/generate-price<br/>rule-engine: LIVE rates + surcharges<br/>HAZARDOUS / REEFER / OVERWEIGHT /<br/>SHIPPING_LINE / CONSOLIDATION (P)"]:::port
|
||||
B1 --> B1w{"weight-limit-rules check"}:::dec
|
||||
B1w -->|"VGM > maxCapacity"| B1x(["HARD BLOCK (400)<br/>cannot submit"]):::bad
|
||||
B1w -->|"over maxVgm, within cap"| B1warn["warning + OVERWEIGHT surcharge"]:::sys
|
||||
B1w -->|"ok"| B2
|
||||
B1warn --> B2
|
||||
B2["POST /bookings/:id/submit → SUBMITTED<br/>create booking_rate_snapshot (P)"]:::port
|
||||
B2 --> B2p{"Price moved since draft?"}:::dec
|
||||
B2p -->|"Yes → PRICE_CHANGED_PENDING_CONFIRM"| B2c["confirm-submit → SUBMITTED (P)"]:::port
|
||||
B2p -->|"No"| GOV
|
||||
B2c --> GOV
|
||||
B2 -.->|"customer rejects price"| B2x(["Booking REJECTED"]):::bad
|
||||
|
||||
%% ================= PHASE 4: INTAKE & APPROVAL =================
|
||||
GOV{"Government booking?"}:::dec
|
||||
GOV -->|"Yes"| GEXP["governmentExpedite →<br/>PAID + schedulingStatus Eligible (B)"]:::back
|
||||
GOV -->|"No (commercial)"| BI{"Staff intake<br/>POST /bookings/:id/staff/* (B)"}:::dec
|
||||
BI -->|"request-changes"| BIr["CHANGES_REQUESTED (B)"]:::back
|
||||
BIr -->|"edit + resubmit"| B2
|
||||
BI -->|"reject"| BIx(["Booking REJECTED"]):::bad
|
||||
BI -->|"accept"| BA["→ PENDING_APPROVAL<br/>instantiate approval steps<br/>(set validity window) (B)"]:::back
|
||||
BA --> BAc{"Approval chain<br/>LINE_STAFF → DIRECTOR → CEO (B)"}:::dec
|
||||
BAc -->|"rejectStep"| BAx(["Booking REJECTED"]):::bad
|
||||
BAc -->|"all approve → APPROVED"| BC1
|
||||
|
||||
%% ================= PHASE 5: CONTRACT DOC & SIGN =================
|
||||
BC1["contract/generate → CONTRACT_READY (B)"]:::back
|
||||
BC1 --> BC2["Customer sign → SIGNED_CUSTOMER<br/>POST /bookings/:id/contract/sign (P)"]:::port
|
||||
BC2 --> BC3{"Staff counter-sign:<br/>trade direction?"}:::dec
|
||||
BC3 -->|"IMPORT / EXPORT<br/>(clearance gate, even if customs off)"| CL1
|
||||
BC3 -->|"DOMESTIC"| FEXD["counter-sign → FULLY_EXECUTED<br/>enqueue batch (skips clearance + op-request) (sys)(B)"]:::back
|
||||
FEXD --> FEB
|
||||
|
||||
%% ================= PHASE 6: CUSTOMS CLEARANCE =================
|
||||
CL1["AWAITING_DOCUMENTS → customer uploads<br/>POST /bookings/:id/clearance/documents<br/>→ DOCUMENTS_UNDER_REVIEW (P)"]:::port
|
||||
CL1 --> CL2{"GL reviews each doc<br/>clearance/review (B)"}:::dec
|
||||
CL2 -->|"Query"| CL2q["doc queried → customer re-uploads (B)"]:::back
|
||||
CL2q --> CL1
|
||||
CL2 -->|"Approve all"| CL3["finalize (100% approved) → CLEARANCE_READY (B)"]:::back
|
||||
CL3 --> CLph["Phased ET/DJ (as applicable):<br/>declaration → duty advise → duty slip →<br/>transit permit → delivery/release order →<br/>T1 docs/close → export release (sys)(B)"]:::back
|
||||
CLph --> OP1
|
||||
|
||||
%% ================= PHASE 7: OPERATION REQUEST =================
|
||||
OP1["clearance/proceed: pick binding schedule day<br/>→ OPERATION_REQUEST_PENDING (P)"]:::port
|
||||
OP1 --> OP2{"Operations review<br/>POST /bookings/:id/operation/review (B)"}:::dec
|
||||
OP2 -->|"REQUEST_CHANGES"| OP2c["OPERATION_CHANGES_REQUESTED (B)"]:::back
|
||||
OP2c --> OP1
|
||||
OP2 -->|"ACCEPT"| OPM{"Operation mode?"}:::dec
|
||||
OPM -->|"TRAIN (rail)"| OP3t["invoice generated → FULLY_EXECUTED<br/>(day batch pool) (B)"]:::back
|
||||
OPM -->|"ROAD (truck)"| OP3r["invoice generated →<br/>ROAD_DISPATCH_PENDING (billed by KM) (B)"]:::back
|
||||
OP3t --> FEB["batch engine offers wagons →<br/>SELECTED_FOR_BATCH (sys)"]:::sys
|
||||
|
||||
%% ================= PHASE 8: INVOICE & PAYMENT =================
|
||||
GEXP --> SCH
|
||||
FEB --> PAY1
|
||||
OP3r --> PAY1
|
||||
PAY1["Invoice (source=booking, INV-YYYYMMDD-#####, due +14d)<br/>booking invoice starts DRAFT → ISSUED at operation-accept (sys)"]:::sys
|
||||
PAY1 --> PAY2["Customer pays<br/>POST /billing/my-invoices/:id/pay →<br/>billing.payInvoice → payment-api initiate (P)"]:::port
|
||||
PAY2 --> PAYp{"Provider result<br/>(Telebirr/CBE/EBirr/Waafi/DMoney/Card/CAC)"}:::dec
|
||||
PAYp -->|"FAILED"| PAYf["invoice stays OPEN (retry)"]:::sys
|
||||
PAYf --> PAY2
|
||||
PAYp -->|"pay window lapses"| PAYexp(["Booking/reservation EXPIRED"]):::bad
|
||||
PAYp -->|"SUCCEEDED"| PAYok["webhook → payment outbox →<br/>POST /internal/payments/mark-paid →<br/>settleByPaymentId → invoice PAID (sys)"]:::sys
|
||||
PAYok --> EVT1{{"booking.invoice.paid event"}}:::sys
|
||||
EVT1 --> PAID["Booking → PAID"]:::sys
|
||||
PAYok -.->|"post-pay"| PAYref(["REFUNDED (terminal)"]):::bad
|
||||
PAID --> FMQ0
|
||||
EVT1 -.->|"if EXPORT + first-mile"| FM1
|
||||
PAID --> SCH
|
||||
|
||||
%% ================= PHASE 9: SCHEDULING & ALLOCATION =================
|
||||
SCH["schedulingStatus = Eligible (sys)"]:::sys
|
||||
SCH --> SC2["Train schedule DRAFT<br/>POST /train-scheduling/{container|bulk}/schedules<br/>≥2 locomotives, derive direction (B)"]:::back
|
||||
SC2 --> SC3["assign-bookings + run-allocation<br/>(wagon_booking_allocations) (B)"]:::back
|
||||
SC3 --> SC4["pin physical wagons → finalize → SCHEDULED<br/>bookings → Scheduled (B)"]:::back
|
||||
SC4 -.->|"cancel schedule"| SC4x["bookings back to Eligible (B)"]:::back
|
||||
SC4x -.-> SC2
|
||||
SC4 -.->|"gov preempt / maintenance"| RESCH["reschedule: retained / displaced /<br/>readmitted (priority: gov first) (B)"]:::back
|
||||
RESCH -.-> SC3
|
||||
SC4 --> FMQ0
|
||||
|
||||
%% ================= PHASE 10: FIRST-MILE (export origin road leg) =================
|
||||
FMQ0{"EXPORT + first-mile requested?"}:::dec
|
||||
FMQ0 -->|"Yes"| FM1["first-mile leg auto-created<br/>firstMile.acceptBooking (READY_TO_TRANSIT) (sys)"]:::sys
|
||||
FMQ0 -->|"No"| WO1
|
||||
FM1 --> FM2["setVehicles → vehicle BUSY, SMS driver,<br/>fleet_events (B)"]:::back
|
||||
FM2 --> FM3["IN_TRANSIT (needs vehicle) →<br/>RECEIVED_TO_PORT (free vehicles) (B)"]:::back
|
||||
FM3 --> FM4["first-mile invoice (FIRST_MILE fee) (B)"]:::back
|
||||
FM4 --> WO1
|
||||
|
||||
%% ================= PHASE 11: WAREHOUSE ORIGIN (export) =================
|
||||
WO1["receive / bulkReceive → RECEIVED<br/>capacity assert, GRN, notify owner (B)"]:::back
|
||||
WO1 --> WO2{"inspection outcome"}:::dec
|
||||
WO2 -->|"FAILED / NEEDS_REVIEW"| WO2f["hold + re-inspect (B)"]:::back
|
||||
WO2f --> WO2
|
||||
WO2 -->|"PASSED"| WO3["store (allocation rule picks yard/zone) → STORED (B)"]:::back
|
||||
WO3 --> WO4["reserve (booking PAID) → RESERVED (B)"]:::back
|
||||
WO4 --> WO5["mark-ready-for-loading → READY_FOR_LOADING (B)"]:::back
|
||||
WO5 --> WO6["load onto wagon → LOADED<br/>(+ warehouse_loadings) (B)"]:::back
|
||||
WO6 --> TR1
|
||||
|
||||
%% ================= PHASE 12: DISPATCH & TRANSIT =================
|
||||
TR1["dispatch → DISPATCHED<br/>assign train_number, locos ASSIGNED,<br/>window CLOSED, unpaid reservations EXPIRED (B)"]:::back
|
||||
TR1 --> TR2["record checkpoints (corridor stations) →<br/>train_checkpoint_events (B)"]:::back
|
||||
TR2 --> TRC["Customer tracking page<br/>GET /tracking/:consignmentId (JWT) (P)"]:::port
|
||||
TR2 --> TR3["arrive (final checkpoint) → ARRIVED<br/>bookings IN_TRANSIT, locos+wagons freed,<br/>warehouse arrival automation (B)"]:::back
|
||||
TR3 --> WD1
|
||||
|
||||
%% ================= PHASE 13: WAREHOUSE DEST + IMPORT CUSTOMS =================
|
||||
WD1["destination warehouse: auto-unload arrived<br/>→ UNLOADED / RECEIVED (B)"]:::back
|
||||
WD1 --> WD2{"inspection PASSED?"}:::dec
|
||||
WD2 -->|"No"| WD2f["hold + re-inspect / incident report (B)"]:::back
|
||||
WD2f --> WD2
|
||||
WD2 -->|"Yes"| DIRW{"trade direction at destination"}:::dec
|
||||
DIRW -->|"IMPORT"| WD3["READY_FOR_PICKUP (B)"]:::back
|
||||
DIRW -->|"EXPORT (Djibouti)"| WDX["auto-unload-at-djibouti → DISPATCHED /<br/>UNLOADED_AT_DJIBOUTI_PORT (B)"]:::back
|
||||
WD3 --> IMP1["Import customs finalization:<br/>upload docs → declaration → notify duties →<br/>duties paid (needs slip) → assign risk →<br/>release-permitted (all gates) (sys)(B)"]:::back
|
||||
IMP1 --> LMQ
|
||||
WDX --> ICD["interchange document (handover manifest)<br/>generate-from-schedule → GENERATED →<br/>ACKNOWLEDGED / DISPUTED (B)"]:::back
|
||||
ICD --> DE1
|
||||
|
||||
%% ================= PHASE 14: LAST-MILE (import destination road leg) =================
|
||||
LMQ{"IMPORT + last-mile requested?"}:::dec
|
||||
LMQ -->|"Yes"| LM1["last-mile leg auto-created<br/>(IMPORT inspection PASSED only) (sys)"]:::sys
|
||||
LMQ -->|"No"| DE1
|
||||
LM1 --> LM2["setVehicles → IN_TRANSIT → DELIVERED<br/>(free vehicles) (B)"]:::back
|
||||
LM2 --> LM3["last-mile invoice (LAST_MILE fee) (B)"]:::back
|
||||
LM3 --> DE1
|
||||
|
||||
%% ================= PHASE 15: DELIVERY & COMPLETION =================
|
||||
DE1{"Warehouse/storage fees fully PAID?"}:::dec
|
||||
DE1 -->|"No"| DE1x["gate-clearance BLOCKED<br/>findBlockingInvoice / assertClearanceAllowed (B)"]:::back
|
||||
DE1x --> DE1p["Customer pays storage/demurrage<br/>warehouse-fee-invoices/:id/pay-online (P)"]:::port
|
||||
DE1p --> DE1
|
||||
DE1 -->|"Yes"| DE2["release order (DO) + gate-clearance →<br/>deliver (B)"]:::back
|
||||
DE2 --> DE3["Customer approves delivery (saved signature)<br/>POST /warehouse-inventory/bookings/:id/approve-delivery (P)"]:::port
|
||||
DE3 --> DE4["inventory DELIVERED, POD to cargo,<br/>container freed, capacity released (sys)"]:::sys
|
||||
DE4 --> DONE(["Booking COMPLETED (done) <br/>operations/complete"]):::good
|
||||
|
||||
%% ================= GLOBAL EXITS =================
|
||||
GEXIT(["CANCELLED — POST /bookings/:id/cancel (staff-only)<br/>ONLY from DRAFT, SUBMITTED, PRICE_CHANGED_PENDING_CONFIRM,<br/>CHANGES_REQUESTED, PENDING_APPROVAL, CONTRACT_READY,<br/>OPERATION_REQUEST_PENDING"]):::bad
|
||||
B2 -.->|"cancel"| GEXIT
|
||||
BA -.->|"cancel"| GEXIT
|
||||
OP1 -.->|"cancel"| GEXIT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reading notes
|
||||
|
||||
- **Solid arrows** = the primary progression. **Dotted arrows** = optional / event-driven / exit hops
|
||||
(consolidation, reschedule, cancel, the first-mile event branch).
|
||||
- **Every terminal** is a rounded red or green node:
|
||||
`SUSPENDED/BLACKLISTED`, `dormant`, `booking/contract removed`, `REJECTED` (customer price, staff intake,
|
||||
approval step, GL booking-request), `HARD BLOCK` (VGM), `EXPIRED` (pay window / contract), `REFUNDED`,
|
||||
`RENEWAL_DRAFT`, `CANCELLED`, and the single success end **`COMPLETED (done) `**.
|
||||
- **Branch axes** captured: identity-proofing (Fayda / skip), origin (one-time vs contract Path A / Path B),
|
||||
freight type (container / bulk), trade direction (import / export / domestic), customer type
|
||||
(government expedite vs commercial approval chain), customs on/off, operation mode (rail / road),
|
||||
provider outcome (success / fail-retry / expire / refund), first-mile (export), last-mile (import),
|
||||
Djibouti export unload + interchange handover.
|
||||
- **Actors**: (P) portal customer, (B) backoffice staff, (sys) system (events like `booking.invoice.paid`,
|
||||
`warehouse.invoice.paid`, auto leg creation, payment webhook/outbox settlement).
|
||||
|
||||
> Endpoint-level tables, per-domain state machines, and the payment-microservice sequence live in
|
||||
> [`FREIGHT_SYSTEM_FLOW.md`](./FREIGHT_SYSTEM_FLOW.md). This file is the single end-to-end picture.
|
||||
872
apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md
Normal file
872
apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md
Normal file
@@ -0,0 +1,872 @@
|
||||
# EDR Freight System — End-to-End Flow Map (FE → Backend)
|
||||
|
||||
> Comprehensive flow documentation for the **Freight Management** slice of the EDR Platform:
|
||||
> the two frontends (**Customer Portal** + **Backoffice**) and how every action reaches the
|
||||
> **freight API**, the **payment microservice**, Postgres, MinIO and RabbitMQ.
|
||||
>
|
||||
> Generated by mapping all ~45 API modules (~300 routes / 60 controllers) against both FE apps.
|
||||
> Layered on purpose: a **master business-flow** view first, then **per-domain endpoint-level** charts.
|
||||
|
||||
---
|
||||
|
||||
## 0. How to read this document
|
||||
|
||||
- **Master view** (§3) is the freight journey at business altitude — state transitions across domains.
|
||||
- **Per-domain views** (§5–§13) drop to endpoint altitude — every `Method /path`, its guard, and the FE caller.
|
||||
- Diagrams are [Mermaid](https://mermaid.js.org). GitHub / VS Code (Markdown Preview Mermaid) render them inline.
|
||||
- **Legend** used throughout:
|
||||
- (P) **Portal** = `@edr/freight-portal` (customer users, port `5173`)
|
||||
- (B) **Backoffice** = `@edr/freight-backoffice` (EDR employees, port `5183`)
|
||||
- (green) **API** = `@edr/freight-api` (NestJS, port `3001`)
|
||||
- (red) **Payment** = `@edr/payment-api` (NestJS microservice, port `3003`)
|
||||
|
||||
---
|
||||
|
||||
## 1. System architecture & apps
|
||||
|
||||
| Layer | Package | Port | Base URL / notes |
|
||||
| ----- | ------- | ---- | ---------------- |
|
||||
| Customer Portal | `@edr/freight-portal` | 5173 | axios `utils/api.ts`, baseURL `VITE_BASE_API_URL`, React Query |
|
||||
| Backoffice | `@edr/freight-backoffice` | 5183 | axios `auth/http.ts`, baseURL `${VITE_BASE_API_URL}/api`, React Query |
|
||||
| Freight API | `@edr/freight-api` | 3001 | NestJS, global prefix `/api`, Postgres schema `freight` |
|
||||
| Payment API | `@edr/payment-api` | 3003 | NestJS, separate schema `edr_payment`, providers in `@edr/payment-providers` |
|
||||
| Datastores | — | 5433 | Postgres `edr_freight`; MinIO (files); RabbitMQ (SMS/email/payment events) |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph FE["Frontends (React + Vite + React Query)"]
|
||||
P["(P) Customer Portal :5173"]
|
||||
B["(B) Backoffice :5183"]
|
||||
end
|
||||
|
||||
subgraph EDGE["Freight API edge (NestJS :3001)"]
|
||||
direction TB
|
||||
CORS["CORS (reflect origin,<br/>credentials, IAM headers)"]
|
||||
JWT["JwtGuard (global APP_GUARD)<br/>+ HasActiveDelegationGuard"]
|
||||
PERM["FreightPermissionGuard<br/>(per-route perms)"]
|
||||
VP["ValidationPipe<br/>(implicitConversion OFF)"]
|
||||
RTI["ResponseTransformInterceptor<br/>→ { success, data }"]
|
||||
HEF["HttpExceptionFilter"]
|
||||
end
|
||||
|
||||
subgraph DOM["Domain modules (~45)"]
|
||||
direction TB
|
||||
D1["Identity / Companies / Auth"]
|
||||
D2["Bookings + Contracts"]
|
||||
D3["Rule Engine"]
|
||||
D4["Train Scheduling"]
|
||||
D5["Warehouse"]
|
||||
D6["Field Ops (mile/import)"]
|
||||
D7["Billing"]
|
||||
D8["Notifications / Inbox"]
|
||||
end
|
||||
|
||||
subgraph INFRA["Backing services"]
|
||||
PG[("Postgres<br/>schema: freight")]
|
||||
MINIO[("MinIO<br/>object store")]
|
||||
MQ{{"RabbitMQ"}}
|
||||
PAY["(red) Payment API :3003<br/>schema: edr_payment"]
|
||||
end
|
||||
|
||||
P -->|"Bearer token (cookie)<br/>axios interceptor"| CORS
|
||||
B -->|"Bearer token (cookie)<br/>axios interceptor"| CORS
|
||||
CORS --> JWT --> PERM --> VP --> DOM
|
||||
DOM --> RTI
|
||||
DOM --> PG
|
||||
DOM --> MINIO
|
||||
DOM -->|"send-sms / send-email"| MQ
|
||||
DOM -->|"POST /payments/initiate<br/>x-service-token"| PAY
|
||||
PAY -->|"payment.succeeded webhook<br/>→ /api/internal/payments/mark-paid"| DOM
|
||||
PAY -.->|"or RabbitMQ payment events"| MQ --> DOM
|
||||
D8 -->|"socket.io push"| FE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. The request pipeline (every FE call)
|
||||
|
||||
Both frontends wrap each API method in an `endpoint(service, action, fn)` helper feeding React Query.
|
||||
The axios client attaches the JWT and transparently refreshes on `401`.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant C as React component / hook
|
||||
participant Q as React Query
|
||||
participant AX as axios client (interceptors)
|
||||
participant API as Freight API (:3001)
|
||||
participant DB as Postgres
|
||||
|
||||
C->>Q: useQuery / useMutation(endpoint)
|
||||
Q->>AX: call(input)
|
||||
AX->>AX: request interceptor →<br/>Authorization: Bearer [auth-token cookie]
|
||||
AX->>API: HTTP /api/<controller>/<path>
|
||||
API->>API: CORS → JwtGuard → PermissionGuard → ValidationPipe
|
||||
alt token valid & permitted
|
||||
API->>DB: repository query (schema freight)
|
||||
DB-->>API: rows
|
||||
API->>API: ResponseTransformInterceptor → { success:true, data }
|
||||
API-->>AX: 200 { success, data }
|
||||
AX->>AX: backoffice interceptor unwraps .data<br/>(portal returns raw envelope; callers read .data)
|
||||
AX-->>Q: payload
|
||||
Q-->>C: data (+ cache by queryKey)
|
||||
else 401 Unauthorized
|
||||
API-->>AX: 401
|
||||
AX->>API: POST /api/auth/refresh-token { refreshToken cookie }
|
||||
alt refresh ok
|
||||
API-->>AX: { token, refreshToken }
|
||||
AX->>AX: set cookies, retry original request (_retry)
|
||||
AX-->>Q: payload
|
||||
else refresh fails
|
||||
AX->>AX: clear cookies →<br/>portal: reject · backoffice: redirect /auth
|
||||
end
|
||||
else 4xx/5xx
|
||||
API->>API: HttpExceptionFilter → { success:false, message }
|
||||
API-->>AX: error
|
||||
AX-->>Q: throw → onError toast
|
||||
end
|
||||
```
|
||||
|
||||
**Auth model (important):** `SharedAuthModule` (`@tria-plc/api-common`) registers `JwtGuard` +
|
||||
`HasActiveDelegationGuard` as **global `APP_GUARD`s** — *every* route is JWT-protected unless it
|
||||
carries `@Public()`. Fine-grained `FreightPermissionGuard([perm])` decorators add permission checks
|
||||
on staff routes. Explicitly **public** endpoints: `GET /api/files/:fileId`, `POST /api/otp/{send,verify}`,
|
||||
`GET /api/auth/check-availability`, the `fayda/verification/*` + `/callback` endpoints,
|
||||
`GET /api/payments/{checkout,receipt/:orderId}`, and the service-to-service `POST /api/internal/payments/mark-paid`.
|
||||
Real login / JWT issuance lives in the **external IAM package**, not this repo. (Note: `@edr/api-common`'s
|
||||
`@Public` and `@tria-plc/api-common`'s `@IsPublic` both set the same `"isPublic"` metadata key the guard reads.)
|
||||
|
||||
---
|
||||
|
||||
## 3. MASTER FLOW — the freight journey (business altitude)
|
||||
|
||||
This is the spine. A shipment travels **customer intake → pricing → approval → contract → customs
|
||||
clearance → operation request → payment → scheduling/allocation → first-mile → warehouse → train →
|
||||
arrival → warehouse → last-mile → delivery → tracking**, branching on _container vs bulk_,
|
||||
_import vs export_, _commercial vs government_, and _one-time vs contract_.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
start(["Customer signs up<br/>(IAM + Fayda + company onboarding)"]) --> mode{"Booking origin?"}
|
||||
|
||||
mode -->|"One-time shipment"| draft["Create BOOKING (DRAFT) (P)"]
|
||||
mode -->|"Framework agreement"| cdraft["Create CONTRACT (DRAFT) (P)"]
|
||||
|
||||
%% Contract branch
|
||||
cdraft --> cprice["Generate price → Submit → Approvals → Sign"]
|
||||
cprice --> cactive{"Contract type / customs?"}
|
||||
cactive -->|"Path A: transport-only,<br/>self-clearance"| bookA["Customer books directly<br/>POST /contracts/:id/bookings (P)"]
|
||||
cactive -->|"Path B: GENERAL + customs"| breq["Customer submits BookingRequest (P)<br/>→ GL accepts → GL creates booking (B)"]
|
||||
bookA --> draft2["Booking created under contract"]
|
||||
breq --> draft2
|
||||
|
||||
%% Booking spine
|
||||
draft --> price["Generate price (P)<br/>(rule-engine: rates + surcharges)"]
|
||||
draft2 --> price
|
||||
price --> submit["Submit → SUBMITTED (P)"]
|
||||
submit --> intake["Staff accept → PENDING_APPROVAL (B)<br/>(instantiate approval steps)"]
|
||||
intake --> appr["Approval chain:<br/>LINE_STAFF → DIRECTOR → CEO (B)"]
|
||||
appr --> gen["Generate contract → CONTRACT_READY (B)"]
|
||||
gen --> sign["Customer signs (P) → Staff counter-signs (B)"]
|
||||
sign --> customs{"customsClearingEnabled?"}
|
||||
|
||||
customs -->|"No"| ready["FULLY_EXECUTED"]
|
||||
customs -->|"Yes"| clr["AWAITING_DOCUMENTS →<br/>customer uploads docs (P) →<br/>GL review/finalize (B) → CLEARANCE_READY"]
|
||||
clr --> opreq["Customer requests operation (P)<br/>(binding schedule date)"]
|
||||
ready --> opreq
|
||||
opreq --> oprev{"Operations review (B)"}
|
||||
oprev -->|"Request changes"| clr
|
||||
oprev -->|"Accept: train"| inv["Invoice generated →<br/>enters day batch pool"]
|
||||
oprev -->|"Accept: road/truck"| road["ROAD_DISPATCH_PENDING<br/>(billed by KM)"]
|
||||
|
||||
inv --> pay["Customer pays invoice (P)<br/>→ (red) gateway → PAID"]
|
||||
road --> pay
|
||||
pay --> gov{"Government?"}
|
||||
gov -->|"Yes"| expedite["governmentExpedite → PAID/Eligible (B)"]
|
||||
gov -->|"No"| eligible["schedulingStatus = Eligible"]
|
||||
expedite --> sched
|
||||
eligible --> sched
|
||||
|
||||
subgraph JOURNEY["Physical movement"]
|
||||
direction TB
|
||||
sched["Train schedule: DRAFT → assign bookings →<br/>allocate wagons → finalize → SCHEDULED (B)"]
|
||||
fm{"EXPORT + first-mile?"}
|
||||
fmleg["First-mile leg: truck pickup →<br/>RECEIVED_TO_PORT (B)"]
|
||||
wh_in["Warehouse receive → inspect →<br/>STORED → READY_FOR_LOADING → LOADED (B)"]
|
||||
disp["Dispatch train → DISPATCHED<br/>(locos ASSIGNED, unpaid EXPIRED) (B)"]
|
||||
track["Checkpoints logged →<br/>tracking events (customer sees) (P)"]
|
||||
arrive["Arrive → ARRIVED<br/>(bookings IN_TRANSIT, wagons freed) (B)"]
|
||||
wh_out["Destination warehouse:<br/>unload → inspect → READY_FOR_PICKUP (B)"]
|
||||
lm{"IMPORT + last-mile?"}
|
||||
lmleg["Last-mile leg: truck delivery →<br/>DELIVERED (B)"]
|
||||
imp["Import customs finalization<br/>(declaration/duty/risk/release) (B)"]
|
||||
end
|
||||
|
||||
sched --> fm
|
||||
fm -->|"Yes"| fmleg --> wh_in
|
||||
fm -->|"No"| wh_in
|
||||
wh_in --> disp --> track --> arrive --> wh_out
|
||||
wh_out --> imp
|
||||
imp --> lm
|
||||
lm -->|"Yes"| lmleg --> deliver
|
||||
lm -->|"No"| deliver
|
||||
|
||||
deliver["Release order + gate clearance<br/>(warehouse fees must be PAID) (B)"]
|
||||
deliver --> pod["Customer approves delivery /<br/>POD captured → DELIVERED (P)"]
|
||||
pod --> complete(["Booking COMPLETED"])
|
||||
|
||||
track -.->|"public timeline"| custview["Customer tracking page (P)"]
|
||||
```
|
||||
|
||||
**Cross-cutting truth:** almost every hop between domains is fired by a **domain event** (`@OnEvent`),
|
||||
not a direct call. See §13 for the event web (e.g. `booking.invoice.paid` → advance booking → auto-create
|
||||
first-mile; `warehouse inspection PASSED` → auto-create last-mile; payment webhook → settle invoice).
|
||||
|
||||
---
|
||||
|
||||
## 4. Domain map (where each module lives in the journey)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph INTAKE["Intake & Identity"]
|
||||
auth[auth / otp / verifayda]
|
||||
comp[companies]
|
||||
sig[signatures]
|
||||
end
|
||||
subgraph COMMERCIAL["Commercial"]
|
||||
bk[bookings]
|
||||
ct[contracts]
|
||||
re[rule-engine]
|
||||
end
|
||||
subgraph OPS["Rail Operations"]
|
||||
ts[train-scheduling]
|
||||
resch[scheduling-reschedule]
|
||||
fleet[trains/wagons/locomotives/wagon-types]
|
||||
track[tracking]
|
||||
end
|
||||
subgraph GROUND["Ground Operations"]
|
||||
fmlm[first-mile / last-mile]
|
||||
imp[import-operations / interchange-documents]
|
||||
dv[drivers / vehicles]
|
||||
end
|
||||
subgraph WH["Warehouse"]
|
||||
wh[warehouses + inventory + loadings + inspection + rules]
|
||||
whinv[warehouse-fee-invoices]
|
||||
end
|
||||
subgraph MONEY["Money"]
|
||||
bill[billing]
|
||||
paymod[payment]
|
||||
payapi[edr-payment-api]
|
||||
end
|
||||
subgraph PLATFORM["Platform / Config"]
|
||||
dd[dropdown-settings]
|
||||
fus[file-upload-settings]
|
||||
files[files / minio]
|
||||
fac[facilities / routes]
|
||||
notif[notifications / inbox]
|
||||
ov[overview]
|
||||
bo[backoffice/IAM]
|
||||
end
|
||||
|
||||
INTAKE --> COMMERCIAL --> OPS --> GROUND --> WH --> MONEY
|
||||
re -.->|rates/approval/priority| COMMERCIAL
|
||||
COMMERCIAL -.->|invoices| MONEY
|
||||
WH -.->|storage invoices| MONEY
|
||||
GROUND -.->|mile invoices| MONEY
|
||||
PLATFORM -.-> COMMERCIAL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Identity, Access & Onboarding
|
||||
|
||||
### 5.1 Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
su["Signup (external IAM)"] --> chk["GET /auth/check-availability (P)<br/>(email/phone taken?) @Public"]
|
||||
chk --> otp["POST /otp/send + /otp/verify (P) @Public"]
|
||||
otp --> fayda{"Identity proofing?"}
|
||||
fayda -->|"VeriFayda 2.0"| fstart["POST /fayda/verification/start<br/>→ eSignet authorize URL"]
|
||||
fstart --> fcb["Fayda redirect → GET /callback (ack)<br/>→ GET /fayda/verification/complete<br/>(PKCE code exchange → upsert iam.users)"]
|
||||
fcb --> onb
|
||||
fayda -->|"skip"| onb
|
||||
|
||||
onb["POST /companies/onboarding/start (P)<br/>(draft company, placeholder TIN, PENDING)"]
|
||||
onb --> wiz["Wizard saves incrementally (P):<br/>PATCH /profile · /onboarding-step ·<br/>upload license & docs"]
|
||||
wiz --> reqs["GET /companies/onboarding/requirements<br/>(server-driven checklist)"]
|
||||
reqs --> comp["POST /companies/onboarding/complete<br/>→ profiles + company = PENDING"]
|
||||
comp --> review["Backoffice approves (B):<br/>PATCH /companies/company-profiles/:id/status<br/>→ mint reference, company → ACTIVE"]
|
||||
review --> book(["Can now book<br/>(assertCompanyProfileApprovedForBooking)"])
|
||||
```
|
||||
|
||||
Company status: `PENDING → ACTIVE` (+ `SUSPENDED`, `BLACKLISTED`). Nationality (`ethiopian`/`foreign`)
|
||||
drives the required document set. Booking guards elsewhere `403` if the acting profile is not `ACTIVE`.
|
||||
|
||||
### 5.2 Endpoints
|
||||
|
||||
| Method | Path | Action | Guard | FE |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| GET | `/api/auth/check-availability` | email/phone dedupe | `@Public` | (P) auth.service |
|
||||
| GET | `/api/me` | enriched profile + `permissionKeys` + catalog | JwtGuard | (B) auth/api |
|
||||
| POST | `/api/otp/send` · `/api/otp/verify` | send / verify 6-digit code | `@Public` | (P) auth.service |
|
||||
| POST | `/api/fayda/verification/start` | start eSignet session (PKCE) | `@Public` + OptionalJwt | (B) verifayda.service |
|
||||
| GET | `/api/fayda/verification/complete` | code→identity, upsert `iam.users` | `@Public` | (B) verifayda.service |
|
||||
| GET | `/api/fayda/verification/status` | current user's Fayda link | JwtGuard | — |
|
||||
| GET | `/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage |
|
||||
| GET·PUT | `/api/me/signature` | reusable signature (MinIO, base64) | JwtGuard | (P)(B) signatures.service |
|
||||
| GET | `/api/test_user1` · `/api/test_user2` | permission-guard demo | `PermissionGuard` | (B) demo pages |
|
||||
| GET | `/api/companies/getInfo` · `/profile` · `/dashboard` | company info / KPIs | JwtGuard | (P) companies.service |
|
||||
| POST | `/api/companies/fetch-etrade-info` | pull reg data by TIN | JwtGuard | (P) |
|
||||
| PATCH | `/api/companies/profile` | update profile (JSONB attrs) | JwtGuard | (P) |
|
||||
| POST | `/api/companies/onboarding/start` · `/complete` | onboarding lifecycle | JwtGuard | (P) |
|
||||
| PATCH | `/api/companies/onboarding-step` · `/active-mode` | wizard state / mode switch | JwtGuard | (P) |
|
||||
| GET | `/api/companies/onboarding/requirements` | server checklist | JwtGuard | (P) |
|
||||
| POST·GET | `/api/companies/company-profiles/:id/license` | license file up/list | JwtGuard | (P) |
|
||||
| POST | `/api/companies/company-profiles` · `/company-profile` | add operational profile(s) | JwtGuard | (P) |
|
||||
| POST·GET·PATCH·DELETE | `/api/companies` (+`/:id`) | company CRUD | `@FreightAdmin` writes | (B) customers.service |
|
||||
| GET | `/api/companies/stats` | KPI strip | JwtGuard | (B) |
|
||||
| PATCH | `/api/companies/company-profiles/:id/status` | approve profile → mint ref | `@FreightAdmin` | (B) |
|
||||
| GET·POST | `/api/companies/:companyId/documents` | company docs (signed URLs) | JwtGuard | (P)(B) |
|
||||
| GET | `/api/notifications` (+`/unread-count`) | inbox list / unread | JwtGuard | (P)(B) notificationsApi |
|
||||
| PATCH·POST | `/api/notifications/:id/read` · `/read-all` | mark read (WS re-emit) | JwtGuard | (P)(B) |
|
||||
| WS | `NOTIFICATION_WS_NAMESPACE` | live push (server→client) | WsAuth handshake | (P)(B) useNotificationSocket |
|
||||
|
||||
`notifications` module = SMS/email transport over RabbitMQ (no HTTP routes). Inbox fan-out writes one
|
||||
`notifications` row + WS push per recipient; **HIGH** priority also emails + SMSs (best-effort).
|
||||
|
||||
---
|
||||
|
||||
## 6. Bookings — the core state machine
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> DRAFT: create (P) (staff commercial auto price+submit)
|
||||
DRAFT --> SUBMITTED: submit (P)
|
||||
DRAFT --> PRICE_CHANGED_PENDING_CONFIRM: price moved
|
||||
PRICE_CHANGED_PENDING_CONFIRM --> SUBMITTED: confirm-submit (P)
|
||||
SUBMITTED --> REJECTED: customer reject (price) (P)
|
||||
SUBMITTED --> CHANGES_REQUESTED: staff request-changes (B)
|
||||
CHANGES_REQUESTED --> SUBMITTED: edit + resubmit (P)
|
||||
SUBMITTED --> PENDING_APPROVAL: staff/accept (B) (instantiate approval steps)
|
||||
SUBMITTED --> REJECTED: staff/reject (B)
|
||||
PENDING_APPROVAL --> APPROVED_PENDING_SIGNATURE: line-staff approve (B)
|
||||
APPROVED_PENDING_SIGNATURE --> APPROVED: director + ceo approve (B)
|
||||
PENDING_APPROVAL --> REJECTED: rejectStep (B)
|
||||
APPROVED --> CONTRACT_READY: contract/generate (B)
|
||||
CONTRACT_READY --> SIGNED_CUSTOMER: customer sign (P)
|
||||
SIGNED_CUSTOMER --> AWAITING_DOCUMENTS: counter-sign, IMPORT/EXPORT (B)
|
||||
SIGNED_CUSTOMER --> FULLY_EXECUTED: counter-sign, DOMESTIC (B)
|
||||
AWAITING_DOCUMENTS --> DOCUMENTS_UNDER_REVIEW: clearance/documents (P)
|
||||
DOCUMENTS_UNDER_REVIEW --> CLEARANCE_READY: review + finalize (B)
|
||||
CLEARANCE_READY --> OPERATION_REQUEST_PENDING: clearance/proceed (P)
|
||||
OPERATION_REQUEST_PENDING --> OPERATION_CHANGES_REQUESTED: review=REQUEST_CHANGES (B)
|
||||
OPERATION_CHANGES_REQUESTED --> OPERATION_REQUEST_PENDING: re-proceed (P)
|
||||
OPERATION_REQUEST_PENDING --> ROAD_DISPATCH_PENDING: accept=road (B) (invoice, KM billed)
|
||||
OPERATION_REQUEST_PENDING --> FULLY_EXECUTED: accept=train (B) (invoice)
|
||||
FULLY_EXECUTED --> SELECTED_FOR_BATCH: batch engine offers wagons (sys)
|
||||
SELECTED_FOR_BATCH --> PAID: pay invoice (P)
|
||||
ROAD_DISPATCH_PENDING --> PAID: pay invoice (P)
|
||||
PAID --> IN_TRANSIT: operations/start-transit (B)
|
||||
IN_TRANSIT --> COMPLETED: operations/complete (B)
|
||||
DRAFT --> PENDING_CONSOLIDATION: requestConsolidation (usually set on submit)
|
||||
DRAFT --> CANCELLED: cancel (B)
|
||||
PENDING_APPROVAL --> CANCELLED: cancel (B)
|
||||
OPERATION_REQUEST_PENDING --> CANCELLED: cancel (B)
|
||||
note right of PAID
|
||||
governmentExpedite (B) jumps
|
||||
gov bookings straight to PAID/Eligible
|
||||
end note
|
||||
note left of CANCELLED
|
||||
cancel is staff-only and allowed ONLY from
|
||||
DRAFT, SUBMITTED, PRICE_CHANGED_PENDING_CONFIRM,
|
||||
CHANGES_REQUESTED, PENDING_APPROVAL,
|
||||
CONTRACT_READY, OPERATION_REQUEST_PENDING
|
||||
end note
|
||||
REJECTED --> [*]
|
||||
CANCELLED --> [*]
|
||||
COMPLETED --> [*]
|
||||
```
|
||||
|
||||
> **Accuracy notes (verified against code):**
|
||||
> - Counter-sign branch is keyed on **trade direction**, not a customs flag: `IMPORT`/`EXPORT` →
|
||||
> `AWAITING_DOCUMENTS` (even when customs is off — a lighter "without customs" clearance doc-set still
|
||||
> applies); only `DOMESTIC` → `FULLY_EXECUTED`.
|
||||
> - **Domestic** bookings skip the operation-request/clearance phase entirely — counter-sign enqueues
|
||||
> them straight into the scheduling batch pipeline (`enqueueScheduleProcessing`).
|
||||
> - Train `operation/review` accept sets **`FULLY_EXECUTED`** (day batch holding pool). `SELECTED_FOR_BATCH`
|
||||
> is set **later** by the batch engine when a wagon offer/reservation is made — not at accept.
|
||||
> - `APPROVED_PENDING_SIGNATURE` is a real intermediate (line-staff approves first, then director+CEO).
|
||||
> - Full `BOOKING_STATUSES` has 35 values; this diagram is the live commercial subset (legacy statuses
|
||||
> like `WAGON_ASSIGNED`, `INVOICED`, `PNR_GENERATED` are unused).
|
||||
|
||||
Key endpoints (customer (P) / staff (B), `bk:` = `bookings:` perms):
|
||||
|
||||
| Method | Path | Action | Guard |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/api/bookings` | create | in-body (gov→`bk:staff_accept`) |
|
||||
| PATCH·DELETE | `/api/bookings/:id` | update / soft-delete DRAFT | company-scoped |
|
||||
| POST | `/api/bookings/:id/generate-price` | price preview (rule-engine) | company-scoped |
|
||||
| POST | `/api/bookings/:id/submit` · `/confirm-submit` | submit (rate snapshot) | company-scoped |
|
||||
| POST | `/api/bookings/:id/reject` | customer rejects price | company-scoped |
|
||||
| POST | `/api/bookings/:id/staff/accept` | → PENDING_APPROVAL | `bk:staff_accept` |
|
||||
| POST | `/api/bookings/:id/staff/request-changes` · `/staff/reject` | intake outcomes | `bk:request_changes` / `bk:reject` |
|
||||
| POST | `/api/bookings/:id/approval-steps/:stepId/approve` · `/reject` | approval chain | role perms |
|
||||
| POST | `/api/bookings/:id/contract/generate` | → CONTRACT_READY | `bk:generate_contract` |
|
||||
| POST | `/api/bookings/:id/contract/sign` · `/marketing/approve` | sign / counter-sign | in-body / `bk:sign_staff` |
|
||||
| GET | `/api/bookings/:id/contract/{view,document}` | HTML / PDF | company-scoped |
|
||||
| GET·POST | `/api/bookings/:id/clearance` (+`/documents`,`/review`,`/finalize`,…) | customs clearance | `bk:*` / `ct:clearance_*` |
|
||||
| POST | `/api/bookings/:id/clearance/proceed` | request operation | company-scoped |
|
||||
| POST | `/api/bookings/:id/operation/review` | accept/changes (+invoice) | `bk:operations` |
|
||||
| POST | `/api/bookings/:id/government-expedite` | gov shortcut → PAID | `bk:staff_accept` |
|
||||
| POST | `/api/bookings/:id/operations/start-transit` · `/complete` | transit lifecycle | `bk:operations` |
|
||||
| POST | `/api/bookings/:id/allocate-containers` | assign containers↔vehicles | `allocation:manage` |
|
||||
| POST·GET·DELETE | `/api/bookings/:id/consolidation` | pair/unpair wagon-share | company-scoped |
|
||||
| POST | `/api/bookings/:id/customer-truck-assignment` | external truck for pickup | company-scoped |
|
||||
| GET | `/api/bookings/:id/tracking` | consignment + tracking events | company-scoped |
|
||||
| GET | `/api/bookings` · `/list-summary` · `/queues/:queue` | lists (branch by perm) | `bk:view` / `bk:clearance_view` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Contracts — framework agreements & booking paths
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> DRAFT: create (P)
|
||||
DRAFT --> SUBMITTED: submit (P) (freeze rate snapshots)
|
||||
SUBMITTED --> PENDING_APPROVAL: staff/accept (B)
|
||||
SUBMITTED --> CHANGES_REQUESTED: request-changes (B)
|
||||
CHANGES_REQUESTED --> SUBMITTED: resubmit (P)
|
||||
SUBMITTED --> REJECTED: reject (B)
|
||||
PENDING_APPROVAL --> APPROVED: approve chain (B)
|
||||
APPROVED --> CONTRACT_READY: contract/generate (B)
|
||||
CONTRACT_READY --> SIGNED_CUSTOMER: customer sign (P)
|
||||
SIGNED_CUSTOMER --> AWAITING_CLEARANCE_DOCUMENTS: counter-sign, IMPORT/EXPORT (not GENERAL+customs) (B)
|
||||
SIGNED_CUSTOMER --> CONTRACT_ACTIVE: counter-sign, GENERAL+customs or DOMESTIC (B)
|
||||
SIGNED_CUSTOMER --> FULLY_EXECUTED: counter-sign, ONE_TIME DOMESTIC (B)
|
||||
CONTRACT_ACTIVE --> [*]: renew → RENEWAL_DRAFT
|
||||
```
|
||||
|
||||
> Diagram shows the live path; `CONTRACT_STATUSES` has **24 values** total (adds APPROVED_PENDING_SIGNATURE,
|
||||
> CLEARANCE_UNDER_REVIEW, CLEARANCE_READY_FOR_BOOKING, ACTIVE_SHIPMENT_IN_PROGRESS, CONTRACT_CLOSED, CANCELLED,
|
||||
> RENEWAL_SUBMITTED/PENDING_APPROVAL, AMENDMENTS_PROPOSED, ARCHIVED — see §15). **GENERAL+customs skips the
|
||||
> contract clearance cycle → `CONTRACT_ACTIVE` directly** (clearance runs per-booking, Path B); only IMPORT/EXPORT
|
||||
> one-time or self-clearance opens the contract-level `AWAITING_CLEARANCE_DOCUMENTS` cycle.
|
||||
|
||||
**Two ways a contract spawns shipment bookings:**
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
active["Contract ACTIVE / SELF_CLEARED"] --> path{"Contract path"}
|
||||
path -->|"Path A: transport-only"| a1["Ops reviews self-clearance docs (B)<br/>(ops-review → ops-finalize → SELF_CLEARED)"]
|
||||
a1 --> a2["Customer books directly (P)<br/>POST /contracts/:id/bookings"]
|
||||
path -->|"Path B: GENERAL + customs"| b1["Customer submits BookingRequest (P)<br/>POST /contracts/:id/booking-requests"]
|
||||
b1 --> b2["GL queue → accept (B)<br/>(ct:create_booking) → GL creates booking"]
|
||||
a2 --> cap["createUnderContract:<br/>window + capacity draw-down check"]
|
||||
b2 --> cap
|
||||
cap --> spawn(["New Booking under contract<br/>(bookings.contract_id)"])
|
||||
```
|
||||
|
||||
Contract clearance is **phased** (ET vs DJ permissioned): declaration → duty advice → duty slip →
|
||||
transit permit → delivery/release order → T1 docs/close → final invoice → incidents. `clearanceStatus`
|
||||
is a separate axis (`AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY_FOR_BOOKING /
|
||||
SELF_CLEARED / ACTIVE_SHIPMENT_IN_PROGRESS`). ~60 contract routes total. Per-booking clearance
|
||||
actions (`duty`, `risk`, `t1-documents`, `t1-close`, `final-invoice`, `second-duty`, `transport-document`,
|
||||
`station-assign`, `incidents`) hang off `/api/contracts/bookings/:bookingId/*`; note **`declaration` is a
|
||||
contract-level route** (`/api/contracts/:id/clearance/declaration`), not a booking-scoped one.
|
||||
|
||||
### Consignment / Cargo / Container (fleet-side records)
|
||||
|
||||
| Entity | Controller | Guard | Lifecycle | Callers |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Consignment | `/api/consignments` (create/list/get) | `FleetView`/`Manage` | `Pending → …` (downstream shipment record; booking `:id/tracking` reads it) | (P) read-only |
|
||||
| Cargo | `/api/cargoes` (+ `/load` `/unload` `/deliver`) | `FleetView`/`Manage` | `PENDING → LOADED → UNLOADED / DELIVERED` (POD) | (B) cargoService |
|
||||
| Container | `/api/containers` (+ `/assign-wagon` `/unassign-wagon`) | `FleetView`/`Manage` | `AVAILABLE → LOADED → IN_TRANSIT …` | (B) containerService |
|
||||
|
||||
---
|
||||
|
||||
## 8. Rule Engine — the pricing & approval brain
|
||||
|
||||
`RuleEngineService.evaluate(input)` is injected into bookings & contracts pricing. One pass pulls all
|
||||
rule tables and returns `{ priorityScore, appliedModifiers, containerWeightResults, warnings,
|
||||
hardBlocked, requiresDirectorApproval }`.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
ev["evaluate(BookingEvaluationInput)"] --> rates["rates.findLiveRates()<br/>(only LIVE)"]
|
||||
rates --> sur{"surcharge triggers"}
|
||||
sur -->|"HAZARDOUS/REEFER/OVERWEIGHT/<br/>SHIPPING_LINE/CONSOLIDATION"| mods["appliedModifiers →<br/>surcharge line-items"]
|
||||
mods --> snap["snapshotRates → booking_rate_snapshots<br/>(freeze exact rate used)"]
|
||||
ev --> wlr["weight-limit-rules"]
|
||||
wlr --> block{"VGM > maxCapacityTons?"}
|
||||
block -->|Yes| hard["HARD BLOCK (400)"]
|
||||
block -->|"over maxVgm, within cap"| warn["warning + OVERWEIGHT surcharge"]
|
||||
ev --> appr["approval-rules →<br/>instantiateApprovalSteps<br/>(requiredRole/blocksRole/stepOrder)"]
|
||||
ev --> prio["priority-configs + serviceType bonus +<br/>GOVERNMENT_PRIORITY_BONUS → priorityScore"]
|
||||
prio --> schedorder["train-scheduling orders by priorityScore"]
|
||||
```
|
||||
|
||||
**Rate lifecycle:** `DRAFT → (submit) PENDING_APPROVAL → (CEO approve) LIVE`. Only LIVE rates apply.
|
||||
|
||||
| Resource | Base path | Guard | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| approval-rules | `/api/approval-rules` (+`/chain`,`/reorder`,`/:id/move-order`) | `ruleEngine.view/manage(approval-rules)` | ordered chain |
|
||||
| cargo-types | `/api/cargo-types` | `…(cargo-types)` | portal reads via `bookings/reference-data` |
|
||||
| container-types | `/api/container-types` | `…(container-types)` | portal indirect |
|
||||
| priority-configs | `/api/priority-configs` | `…(priority-configs)` | WAGON / CURRENCY bands |
|
||||
| rates | `/api/rates` (+`/live`,`/:id/submit`,`/approve`) | `…(rates)` | DRAFT→PENDING→LIVE |
|
||||
| service-types | `/api/service-types` | `…(service-types)` | `includesFirstMile/LastMile` flags |
|
||||
| shipping-lines | `/api/shipping-lines` | `…(shipping-lines)` | surcharge trigger |
|
||||
| weight-limit-rules | `/api/weight-limit-rules` | `…(weight-limit-rules)` | VGM hard-block |
|
||||
| yards | `/api/yards` | `…(yards)` | routes/warehouses read |
|
||||
|
||||
**Settings & files** (reads open, writes `@FreightAdmin`):
|
||||
`/api/dropdown-settings/*`, `/api/file-upload-settings/*` (config that drives portal forms;
|
||||
server enforces *required-doc presence* at clearance, not size/MIME — those are client-side).
|
||||
`GET /api/files/:fileId` is `@Public` (browsers load `<img>/<a>` without a bearer); upload is **direct
|
||||
multipart** (multer memory → `FilesService` → MinIO `putObject`), presigned URLs used only for authenticated
|
||||
reads (300 s TTL). `routes` (`route_milestones`) and `facilities` are config readers with **no permission
|
||||
decorator** (behind the global JwtGuard only). NOTE: Note `FacilitiesModule` is never imported into `AppModule`,
|
||||
so `/api/facilities` is unmounted/dead.
|
||||
|
||||
---
|
||||
|
||||
## 9. Train Scheduling & Rail Operations
|
||||
|
||||
Two consist models: **`trains`** = static fleet inventory (never created by scheduling);
|
||||
**`train_sets`** = the operational consist scheduling builds per departure (**≥2 locomotives** +
|
||||
wagon-type slots, physical wagons *pinned* later).
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> DRAFT: create schedule (B) (≥2 locos, derive direction, freeze window rule)
|
||||
DRAFT --> DRAFT: assign-bookings / run-allocation / pin-wagons (B)
|
||||
DRAFT --> SCHEDULED: finalize (B) (bookings → Scheduled, ≥1 booking)
|
||||
SCHEDULED --> DISPATCHED: dispatch (B) (train_number, locos ASSIGNED, window CLOSED, unpaid EXPIRED)
|
||||
DISPATCHED --> DISPATCHED: recordCheckpoint (B) (corridor stations)
|
||||
DISPATCHED --> ARRIVED: arrive (B) (bookings IN_TRANSIT, locos+wagons freed, warehouse arrival automation)
|
||||
DRAFT --> CANCELLED: cancel (B)
|
||||
SCHEDULED --> CANCELLED: cancel (B)
|
||||
ARRIVED --> [*]
|
||||
```
|
||||
|
||||
**Reschedule** (`DRAFT`/`SCHEDULED` only): `preview` merges current + incoming bookings, sorts by
|
||||
`compareSchedulingPriority` (**government first, then priorityScore**), greedily keeps those that still
|
||||
fit → *retained*; overflow → *displaced*; commercial displaced are *readmitted* if room remains.
|
||||
`execute` re-verifies, optionally sets new departure, unassigns displaced, re-assigns final, writes a
|
||||
`scheduling_events` audit row. Triggers: `GOVERNMENT_PREEMPT`, `TRAIN_MAINTENANCE`.
|
||||
|
||||
Endpoint groups (`train-scheduling` prefix, `trainScheduling.view/manage`):
|
||||
|
||||
| Group | Representative routes |
|
||||
| --- | --- |
|
||||
| Discovery (customer (P), unguarded) | `bookable-schedules`, `available-days`, `available-days-for-cargo`, `my-booking-windows`, `contracts/:id/booking-windows` |
|
||||
| Board (staff (B)) | `batch-board`, `batch-board/:id`, `eligible-bookings`, `container|bulk/eligible-bookings`, `global-rules` |
|
||||
| Build | `container|bulk/preview`, `container|bulk/schedules` (create), `:id/assign-bookings`, `:id/assign-unassigned-booking`, `:id/pin-wagons`, `:id/run-allocation`, `:id/run-batch` |
|
||||
| Composition edits | `:id/wagons/:wagonId` (remove slot), `:id/container-items/:itemId`, `bookings/:bookingId/{mark-paid,expire,move-schedule}` |
|
||||
| Lifecycle | `schedules/:id/{finalize,dispatch,arrive,booking-window,doc-review-complete}`, `{container\|bulk}/schedules/:id/cancel` |
|
||||
| Tracking | `schedules/:id/checkpoints` (GET/POST) → writes `train_checkpoint_events` |
|
||||
| Djibouti import ops | `:id/import-djibouti/*` (gatepass, ready-for-loading, loaded, depart, load-list) |
|
||||
| Reschedule | `:id/reschedule/preview`, `:id/reschedule/execute`, `:id/maintenance` |
|
||||
|
||||
**Fleet master data** (`FleetView/Manage`): `/api/trains` (+`/:trainId/reorder-wagons`), `/api/wagons`
|
||||
(+`/assign-train`,`/unassign-train`), `/api/locomotives` (+`/decommission`), `/api/wagon-types`
|
||||
(rule-engine guarded). **Asset records** (unguarded, keyed by *road* `vehicleId`): `/api/maintenance/*`,
|
||||
`/api/fuel/*`. **`fleet_events`** is an append-only audit read via `drivers/:id/history` &
|
||||
`vehicles/:id/history`.
|
||||
|
||||
**Tracking (customer-facing):** `GET /api/tracking/:consignmentId` → `tracking_events` timeline
|
||||
(`location`, `ConsignmentStatus`, `occurredAt`), consumed by portal `TrackingPage`. It is **JWT-guarded**
|
||||
(no permission decorator — not public). Distinct from staff train **checkpoints** (`train_checkpoint_events`,
|
||||
written by `recordCheckpoint`/`arrive`). NOTE: **`TrackingService.record()` has no caller anywhere in the
|
||||
codebase — nothing writes `tracking_events`, so the customer tracking timeline is currently unpopulated;
|
||||
only the staff `train_checkpoint_events` store is written.**
|
||||
|
||||
---
|
||||
|
||||
## 10. Warehouse — inventory lifecycle
|
||||
|
||||
Hierarchy `Facility → Warehouse → Yard → Zone`; every level tracks weight/volume/container capacity kept
|
||||
in sync by `applyCapacityDelta`. `warehouse_inventory` carries nullable FKs to `booking`, `cargo`,
|
||||
`container` — the join point between warehouse and the shipment.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> RECEIVED: receive / bulkReceive (B)
|
||||
[*] --> UNLOADED: auto-unload-arrived (import train) (B)
|
||||
RECEIVED --> STORED: store (B) (allocation rule picks yard/zone)
|
||||
UNLOADED --> STORED: store (B)
|
||||
UNLOADED --> READY_FOR_PICKUP: inspection PASSED + IMPORT (B)
|
||||
RECEIVED --> READY_FOR_PICKUP: inspection PASSED + IMPORT (B)
|
||||
STORED --> RESERVED: reserve (B) (booking PAID)
|
||||
RESERVED --> READY_FOR_LOADING: mark-ready-for-loading (B) (inspection PASSED)
|
||||
READY_FOR_LOADING --> LOADED: load onto wagon (B) (+ warehouse_loadings)
|
||||
LOADED --> DISPATCHED: dispatch / bulk-dispatch-export (B)
|
||||
DISPATCHED --> UNLOADED_AT_DJIBOUTI_PORT: auto-unload-at-djibouti (B)
|
||||
READY_FOR_PICKUP --> DELIVERED: release → deliver (B) (POD, fees PAID via gate-clearance)
|
||||
READY_FOR_PICKUP --> STORED: re-store import item (B)
|
||||
READY_FOR_PICKUP --> DISPATCHED: dispatch out (B)
|
||||
UNLOADED_AT_DJIBOUTI_PORT --> [*]
|
||||
DELIVERED --> [*]
|
||||
```
|
||||
|
||||
**Export branch:** receive → STORED → RESERVED (booking PAID) → READY_FOR_LOADING → LOADED → DISPATCHED
|
||||
→ UNLOADED_AT_DJIBOUTI_PORT. Note `store()` does **not** check inspection — `RECEIVED → STORED` is legal
|
||||
without it; inspection **PASSED** is enforced only at `mark-ready-for-loading`. **Import branch:** UNLOADED
|
||||
→ inspect PASSED → READY_FOR_PICKUP (auto-creates
|
||||
last-mile if requested) → release → DELIVERED. Every transition writes `warehouse_activity_log`.
|
||||
|
||||
**Storage billing** is *not* a separate table — `WarehouseInvoiceService` is a thin layer over the
|
||||
central **billing** module (global `Invoice` rows, `source='warehouse'`, `sourceId=inventoryId`). Fees =
|
||||
`STORAGE_FEE` + `DEMURRAGE` via `warehouse_fee_rules` (free-days grace, tiers, FX-converted). **Unpaid
|
||||
warehouse fees block exit:** `gateClearance`/`release` call `findBlockingInvoice` / `assertClearanceAllowed`.
|
||||
|
||||
Controller families (84 routes, all called by backoffice `warehouse.service.ts`; **no per-route
|
||||
permission decorators → behind the global JwtGuard only**): `warehouses` · `warehouse-yards` ·
|
||||
`warehouse-zones` · `warehouse-inventory` (queries + 25 mutation actions incl. bulk + import/export
|
||||
queues, and the gate release `warehouse-inventory/:id/gate-clearance`) · `warehouse-loadings` ·
|
||||
inspection (`…/inspection-reports`) · fee-invoices (`…/generate-fee-invoice`, `warehouse-fee-invoices/*`
|
||||
incl. `pay`, `pay-online`) · rules (`warehouse-allocation-rules`, `warehouse-fee-rules`,
|
||||
`warehouse-allocation/preview`).
|
||||
|
||||
> Portal touches only: `bookings/:id/warehouse-fee-invoices`, invoice `by-id`/`document`/`receipt`/
|
||||
> `pay-online`, and `bookings/:bookingId/approve-delivery` (customer signs handover). Everything else (B).
|
||||
|
||||
---
|
||||
|
||||
## 11. Field Operations — first/last mile & import customs
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
binv["booking.invoice.paid event"] --> advance["advanceBookingOnPayment → booking PAID"]
|
||||
advance --> fmreq{"EXPORT + first-mile requested?"}
|
||||
fmreq -->|Yes| fmaccept["firstMileService.acceptBooking<br/>(auto-create leg)"]
|
||||
fmreq -->|No| skip1["—"]
|
||||
fmaccept --> fmleg
|
||||
|
||||
subgraph FM["First-mile (EXPORT origin road leg)"]
|
||||
fmleg["READY_TO_TRANSIT"] --> fmveh["setVehicles → vehicle BUSY,<br/>SMS driver, fleet_events"]
|
||||
fmveh --> fmtransit["IN_TRANSIT (needs assigned vehicle)"]
|
||||
fmtransit --> fmdone["RECEIVED_TO_PORT (free vehicles)"]
|
||||
fmleg --> fminv["generate-invoice → FIRST_MILE fee<br/>(locks distances once invoiced)"]
|
||||
end
|
||||
|
||||
whrcv["warehouse inspection PASSED (IMPORT only)"] --> lmaccept["lastMileService.acceptBooking(reference)"]
|
||||
lmaccept --> lmleg
|
||||
subgraph LM["Last-mile (IMPORT destination road leg)"]
|
||||
lmleg["READY_TO_TRANSIT"] --> lmtransit["IN_TRANSIT"] --> lmdone["DELIVERED (free vehicles)"]
|
||||
lmleg --> lminv["generate-invoice → LAST_MILE fee"]
|
||||
end
|
||||
|
||||
subgraph IMP["Import customs finalization (per booking, timestamp-driven)"]
|
||||
direction TB
|
||||
up["upload docs (IM4/IM5/T1_CLOSURE/…)"] --> decl["record declaration serial"]
|
||||
decl --> notify["notify duties/taxes"]
|
||||
notify --> paid["mark duties paid (needs CUSTOMER_PAYMENT_SLIP)"]
|
||||
paid --> risk["assign risk (GREEN/YELLOW/BLUE/RED)"]
|
||||
risk --> rel["release-permitted (asserts T1 + release permit + declaration + risk + paid)"]
|
||||
end
|
||||
```
|
||||
|
||||
| Module | Base | Guard | Terminal state |
|
||||
| --- | --- | --- | --- |
|
||||
| first-mile | `/api/first-mile` (+`/accept/:ref`,`/:id/{vehicles,distances,invoice}`) | `trainScheduling.view/manage` | `RECEIVED_TO_PORT` |
|
||||
| last-mile | `/api/last-mile` (same shape) | `trainScheduling.view/manage` | `DELIVERED` |
|
||||
| import-operations | `/api/import-operations/{customs,djibouti-incidents,empty-container-returns}/*` | none (global JwtGuard) | `completedAt` |
|
||||
| interchange-documents | `/api/interchange-documents` (+`/generate-from-schedule`,`/:id/{acknowledge,dispute,cancel}`) | none | `ACKNOWLEDGED / DISPUTED` |
|
||||
| drivers | `/api/drivers` (+`/:id/history`) | `FleetView/Manage` | soft-delete |
|
||||
| vehicles | `/api/vehicles` (+`/:id/history`) | `FleetView/Manage` | soft-delete |
|
||||
|
||||
Interchange documents are the **rail↔port handover manifest** — generated from a train schedule,
|
||||
snapshotting booking/container/cargo lines with per-item `conditionStatus` derived from warehouse
|
||||
inspection flags. Status `DRAFT → GENERATED → ACKNOWLEDGED | DISPUTED | CANCELLED`. All FE callers (B).
|
||||
|
||||
**Mile trigger precision (verified):** first-mile is created only on `booking.invoice.paid` →
|
||||
`advanceBookingOnPayment` → `firstMile.acceptBooking(bookingId)` when `EXPORT` + first-mile requested.
|
||||
Last-mile is created **only on IMPORT inspection PASSED** (two call sites: `warehouse-inspection.service`
|
||||
and the bulk-inspect branch of `warehouse-inventory.service`) — **not** on warehouse *receive*. The IMPORT
|
||||
constraint is enforced by the warehouse caller, not inside `lastMile.acceptBooking(reference)`. Auto-created
|
||||
legs enter at **`READY_TO_TRANSIT`** (the `PAYMENT_PENDING` entity default is bypassed).
|
||||
|
||||
---
|
||||
|
||||
## 12. Billing & Payment
|
||||
|
||||
### 12.1 Invoice lifecycle
|
||||
|
||||
Invoices are **source-agnostic** — `BillingService.generateInvoice()` is the single factory called by
|
||||
domain services (never a controller): `source ∈ {booking, warehouse, first_mile, last_mile}` (the enum
|
||||
also defines an unused `demurrage`), numbered `INV-YYYYMMDD-#####`, `dueAt = now + 14d`. **Initial status
|
||||
varies by source:** booking invoices start `DRAFT` (issued at operation-accept via `billing.updateStatus`);
|
||||
the `generateInvoice` default is `PENDING`; warehouse + contract-GL invoices start `ISSUED`.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> DRAFT: booking invoice (issued at operation-accept)
|
||||
[*] --> PENDING: generateInvoice default
|
||||
[*] --> ISSUED: warehouse / contract-GL invoice
|
||||
DRAFT --> ISSUED: billing.updateStatus
|
||||
PENDING --> PARTIALLY_PAID: recordPayment (partial, offline)
|
||||
ISSUED --> PARTIALLY_PAID: recordPayment (partial, offline)
|
||||
PENDING --> PAID: markInvoiceAsPaid (gateway, full)
|
||||
ISSUED --> PAID: markInvoiceAsPaid (gateway, full)
|
||||
PARTIALLY_PAID --> PAID: final payment
|
||||
PENDING --> EXPIRED: expirePayable (pay window lapses)
|
||||
ISSUED --> CANCELLED: cancelInvoice (no payments)
|
||||
PAID --> REFUNDED: markInvoiceAsRefunded (paidAmount>0)
|
||||
PAID --> [*]
|
||||
note right of PAID
|
||||
emits ${source}.invoice.paid
|
||||
(sources use first_mile / last_mile, underscores)
|
||||
→ domain listeners advance booking / mile / warehouse
|
||||
end note
|
||||
note left of ISSUED
|
||||
OVERDUE exists in the enum but NO code sets it
|
||||
(no cron / setter) — effectively unused
|
||||
end note
|
||||
```
|
||||
|
||||
`OPEN_STATUSES = {Issued, Pending, PartiallyPaid, Overdue}` are payable. Transitions lock the row
|
||||
(`pessimistic_write`); the event fires **after commit** for self-managed transitions, but **inline before
|
||||
commit** when the transition is enlisted in a caller-supplied transaction `manager`.
|
||||
|
||||
### 12.2 freight-api ↔ payment-api integration
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant U as Customer (P)
|
||||
participant FB as Freight billing (:3001)
|
||||
participant PA as Payment API (red) (:3003)
|
||||
participant PV as Provider (Telebirr/Waafi/…)
|
||||
participant OB as Payment outbox
|
||||
participant FI as Freight internal ctrl
|
||||
|
||||
U->>FB: POST /billing/my-invoices/:id/pay
|
||||
FB->>FB: payInvoice → validate OPEN, balance>0
|
||||
FB->>PA: POST /payments/initiate (x-service-token)<br/>service=FREIGHT, referenceId=sourceId, amountMinor
|
||||
PA-->>FB: { intentId, clientAction (REDIRECT/LAUNCH_APP/COLLECT_OTP) }
|
||||
FB->>FB: store paymentId on invoice (correlation)
|
||||
FB-->>U: clientAction → redirect to provider
|
||||
U->>PV: authorize payment
|
||||
PV->>PA: webhook POST /webhooks/{provider}
|
||||
PA->>PA: verify signature, dedupe, intent state machine
|
||||
PA->>OB: write PaymentEvent (payment.succeeded)
|
||||
OB->>FI: POST /api/internal/payments/mark-paid (@Public, x-service-token)
|
||||
Note over OB,FI: or RabbitMQ → PaymentEventsConsumer
|
||||
FI->>FB: handlePaymentEvent → settleByPaymentId → markInvoiceAsPaid
|
||||
FB->>FB: emit booking.invoice.paid
|
||||
FB-->>U: invoice PAID (poll / notification)
|
||||
```
|
||||
|
||||
> NOTE: **Demo shortcut in `payInvoice`:** today, if the provider doesn't settle synchronously, freight
|
||||
> self-fires `handlePaymentEvent(payment.succeeded)` inline (marked TODO/remove) — invoices settle at
|
||||
> pay-time without a real webhook. Providers: **Telebirr / CBE_BIRR / EBIRR** (ET), **Waafi / DMoney**
|
||||
> (DJ), **CARD** (intl), **CAC_BANK** (OTP). Payment DB is a **separate `edr_payment` schema** — no
|
||||
> cross-schema FKs; `referenceId` is a soft link.
|
||||
|
||||
### 12.3 Endpoints
|
||||
|
||||
| Method | Path | Facing | Guard |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/billing/invoices` (+`/:id`,`/:id/document`,`/receipt`) | (B) backoffice | `bookings.view` |
|
||||
| GET | `/api/billing/my-invoices` (+`/:id`,`/document`,`/receipt`) | (P) portal | `@CurrentUser` ownership |
|
||||
| POST | `/api/billing/my-invoices/:id/pay` | (P) portal | ownership |
|
||||
| POST | `/api/payments/initiate` | central | JwtGuard (no permission — **not** public) |
|
||||
| GET | `/api/payments/checkout` | redirect | `@Public` |
|
||||
| GET | `/api/payments/{summary,all}` | (B) backoffice | `bookings.view` |
|
||||
| GET | `/api/payments/by-company/:companyId/customer-view` | (B) | none |
|
||||
| GET | `/api/payments/intents/:bookingId` · `/receipt/:orderId` | reconcile / receipt | none / `@Public` |
|
||||
| POST | `/api/internal/payments/mark-paid` | (red) service→service | `@Public` (NOTE: currently unauthenticated) |
|
||||
| POST·GET·PUT | `/api/backoffice/organizations/:orgId/*` | (B) IAM user/role mgmt (NOT billing) | `@FreightAdmin` |
|
||||
|
||||
`GET /api/overview*` (7 tabs, `bookings.view`) is the backoffice dashboard aggregator over 11 repos.
|
||||
|
||||
---
|
||||
|
||||
## 13. Cross-cutting event web (`@OnEvent`)
|
||||
|
||||
The domains are stitched together by events, not direct calls. This is why the master flow "just happens".
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
pay["Payment webhook / demo shortcut"] --> settle["billing.settleByPaymentId → markInvoiceAsPaid"]
|
||||
settle --> ev1{{"${source}.invoice.paid"}}
|
||||
ev1 -->|source=booking| adv["booking-invoice: advanceBookingOnPayment → PAID"]
|
||||
adv --> fm["firstMile.acceptBooking (EXPORT + requested)"]
|
||||
adv --> batch["train-scheduling batch: markPaid → allocate"]
|
||||
ev1 -->|source=first_mile| e2{{"first_mile.invoice.paid"}} --> fmp["NOTE: intended: leg paid=true<br/>(listener typo 'firstmile.invoice.paid' → never fires)"]
|
||||
ev1 -->|source=last_mile| e3{{"last_mile.invoice.paid"}} --> lmp["last-mile → DELIVERED, paid=true"]
|
||||
ev1 -->|source=warehouse| e4{{"warehouse.invoice.paid"}} --> whp["settle warehouse fee → unblock gate"]
|
||||
|
||||
arrive["train arrive"] --> whauto["warehouse arrival automation:<br/>auto-unload arrived bookings"]
|
||||
insp["warehouse inspection PASSED (IMPORT)"] --> lmacc["lastMile.acceptBooking → READY_FOR_PICKUP"]
|
||||
|
||||
assign["mile setVehicles"] --> veh["vehicle BUSY + SMS driver + fleet_events"]
|
||||
release["mile terminal / delete"] --> free["vehicle FREE (releaseIfUnused)"]
|
||||
|
||||
notify["notification-inbox.notify"] --> ws["WebSocket push (always)"]
|
||||
notify -->|HIGH priority| smsemail["+ SMS + email via RabbitMQ"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Portal vs Backoffice — who does what
|
||||
|
||||
| Capability | (P) Portal (customer) | (B) Backoffice (employee) |
|
||||
| --- | --- | --- |
|
||||
| Identity / onboarding | signup, OTP, Fayda, company onboarding, profile | company approval, IAM user/role mgmt |
|
||||
| Bookings | create, price, submit, sign, upload docs, pay, approve delivery, track | accept, approve chain, generate contract, clearance review, operation review, allocate, dispatch |
|
||||
| Contracts | create, submit, sign, booking-requests, self-clearance slips | approve, generate, phased clearance, GL booking creation |
|
||||
| Rule engine | reads only via `bookings/reference-data` | full CRUD (rates approval, approval rules, priorities) |
|
||||
| Scheduling | discover bookable days/schedules | build/finalize/dispatch/arrive/reschedule trains |
|
||||
| Warehouse | pay storage fees, view invoices, approve handover | full inventory lifecycle + fees + rules |
|
||||
| Field ops | (none direct) | first/last mile, import customs, interchange docs, fleet |
|
||||
| Billing | pay own invoices, download docs/receipts | invoice + payment dashboards, per-customer views |
|
||||
| Notifications | inbox + WS | inbox + WS |
|
||||
|
||||
---
|
||||
|
||||
## 15. Status / state reference
|
||||
|
||||
| Entity | States (happy path → terminal) |
|
||||
| --- | --- |
|
||||
| Company | `PENDING → ACTIVE` (+ SUSPENDED, BLACKLISTED) |
|
||||
| Booking | `DRAFT → SUBMITTED → PENDING_APPROVAL → APPROVED_PENDING_SIGNATURE → APPROVED → CONTRACT_READY → SIGNED_CUSTOMER → [IMPORT/EXPORT: AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY → OPERATION_REQUEST_PENDING → (train) FULLY_EXECUTED → SELECTED_FOR_BATCH / (road) ROAD_DISPATCH_PENDING] · [DOMESTIC: → FULLY_EXECUTED → SELECTED_FOR_BATCH] → PAID → IN_TRANSIT → COMPLETED` (branches: REJECTED, CANCELLED, CHANGES_REQUESTED, OPERATION_CHANGES_REQUESTED, PENDING_CONSOLIDATION). 35 statuses total; ~10 legacy ones unused. |
|
||||
| Contract | `DRAFT → SUBMITTED → PENDING_APPROVAL → APPROVED_PENDING_SIGNATURE → APPROVED → CONTRACT_READY → SIGNED_CUSTOMER → AWAITING_CLEARANCE_DOCUMENTS / CONTRACT_ACTIVE / FULLY_EXECUTED` (+ CLEARANCE_UNDER_REVIEW, CLEARANCE_READY_FOR_BOOKING, ACTIVE_SHIPMENT_IN_PROGRESS, CONTRACT_CLOSED, CANCELLED, RENEWAL_DRAFT/SUBMITTED/PENDING_APPROVAL, AMENDMENTS_PROPOSED, ARCHIVED, EXPIRED — **24 total**). Separate `clearanceStatus` axis: NOT_APPLICABLE / AWAITING_DOCUMENTS / DOCUMENTS_UNDER_REVIEW / CLEARANCE_READY_FOR_BOOKING / SELF_CLEARED / ACTIVE_SHIPMENT_IN_PROGRESS. |
|
||||
| Rate | `DRAFT → PENDING_APPROVAL → LIVE` (+ SUPERSEDED) |
|
||||
| Train schedule | `DRAFT → SCHEDULED → DISPATCHED → ARRIVED` (+ CANCELLED) |
|
||||
| Warehouse inventory | export: `RECEIVED → STORED → RESERVED → READY_FOR_LOADING → LOADED → DISPATCHED → UNLOADED_AT_DJIBOUTI_PORT` · import: `UNLOADED → READY_FOR_PICKUP → DELIVERED` (READY_FOR_PICKUP may also → STORED / DISPATCHED) |
|
||||
| First-mile | `PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → RECEIVED_TO_PORT` (auto-created legs enter at READY_TO_TRANSIT) |
|
||||
| Last-mile | `PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → DELIVERED` (auto-created legs enter at READY_TO_TRANSIT) |
|
||||
| Invoice | `DRAFT / PENDING / ISSUED → PARTIALLY_PAID → PAID` (+ EXPIRED, CANCELLED, REFUNDED; **OVERDUE defined but never set**) |
|
||||
| Interchange doc | `DRAFT → GENERATED → ACKNOWLEDGED / DISPUTED / CANCELLED` |
|
||||
| Empty container return | `RETURNED → ASSIGNED_STORAGE → DOCUMENTATION_CLEARED → WAGON_ALLOCATED → TRANSPORTED_TO_DJIBOUTI → HANDOVER_ISSUED → COMPLETED` |
|
||||
|
||||
---
|
||||
|
||||
## 16. Notable gaps & caveats (verified against code)
|
||||
|
||||
- **Counter-sign is direction-based, not customs-based** (bookings *and* contracts): `IMPORT`/`EXPORT` open a
|
||||
clearance gate even with customs off; only `DOMESTIC` skips it. The customs flag only selects the clearance
|
||||
document set.
|
||||
- **`SELECTED_FOR_BATCH` is set by the batch engine, not at operation-accept** — train accept sets
|
||||
`FULLY_EXECUTED` first.
|
||||
- **`FacilitiesModule` is never imported into `AppModule`** — `/api/facilities` is **unmounted / dead** (not
|
||||
reachable at all). `tracking`, `maintenance`, `fuel` carry no permission decorator but *are* mounted — behind
|
||||
the **global JwtGuard**, just not permission-gated.
|
||||
- **`tracking_events` has no writer anywhere** — `TrackingService.record()` is never called, so the customer
|
||||
tracking timeline is unpopulated. Only staff `train_checkpoint_events` are written (by `recordCheckpoint`/`arrive`).
|
||||
- **First-mile paid-flag listener is a dead code path** — `@OnEvent("firstmile.invoice.paid")` (no underscore)
|
||||
never fires because the emitted event is `first_mile.invoice.paid`; the first-mile `paid` flag is never flipped by settlement.
|
||||
- **Invoice `OVERDUE` status is never set** — no cron/setter transitions to it, though it is in the enum and `OPEN_STATUSES`.
|
||||
- `POST /api/internal/payments/mark-paid` is `@Public` with **no service auth** (ServiceAuthGuard removed; noted in code).
|
||||
- **Demo settlement shortcut** in `billing.payInvoice` bypasses real webhooks (TODO/remove) — invoices settle inline at pay-time.
|
||||
- Server does **not** validate upload size/MIME (client-side only); it enforces *required-doc presence* at clearance.
|
||||
- Dead / unwired FE calls: `trains/:id/details` (no route), portal `consignments` service hits `/consignments` without `/api`, and list endpoints `bookings/my`, `queues/:queue`, `by-company/customer-view` have no active caller.
|
||||
- NOTE: The repo `CLAUDE.md` states auth is stubbed/unwired — **this is stale**: auth is live via `@tria-plc/api-common` (global `JwtGuard` + `HasActiveDelegationGuard` `APP_GUARD`s).
|
||||
|
||||
---
|
||||
|
||||
*Generated from `apps/edr-freight-api`, `apps/edr-freight-web/{portal,backoffice}`, `apps/edr-payment-api`
|
||||
on branch `freight/feat/fixes-v1`. Reflects code at scan time; regenerate after major module changes.*
|
||||
@@ -93,6 +93,7 @@
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/vorpal": "^1.12.8",
|
||||
"jest": "^29.7.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
|
||||
@@ -80,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";
|
||||
@@ -148,6 +152,10 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
DriversModule,
|
||||
FuelModule,
|
||||
MaintenanceModule,
|
||||
ComplianceModule,
|
||||
IncidentsModule,
|
||||
ProcurementModule,
|
||||
GpsTrackingModule,
|
||||
FirstMileModule,
|
||||
LastMileModule,
|
||||
InterchangeDocumentsModule,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -105,9 +105,14 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
password: process.env.DB_PASSWORD ?? "",
|
||||
database: process.env.DB_NAME ?? "edr_freight",
|
||||
schema: "public",
|
||||
extra: {
|
||||
options: `-c search_path=${APPLICATION_SEARCH_PATH}`,
|
||||
},
|
||||
// The `-c search_path=...` startup option is rejected by transaction-pooling
|
||||
// poolers (e.g. PgBouncer: "unsupported startup parameter in options"). When
|
||||
// behind such a pooler set DB_PGBOUNCER=true and instead make the search_path
|
||||
// a role default: ALTER ROLE <user> IN DATABASE <db> SET search_path TO
|
||||
// public,iam,freight,audit;
|
||||
...(process.env.DB_PGBOUNCER === "true"
|
||||
? {}
|
||||
: { extra: { options: `-c search_path=${APPLICATION_SEARCH_PATH}` } }),
|
||||
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
|
||||
autoLoadEntities: true,
|
||||
migrations: [
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.incidents`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddMaintenanceDepth1970000000000 implements MigrationInterface {
|
||||
name = 'AddMaintenanceDepth1970000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddProcurement1980000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Yard country becomes a two-value enum (Ethiopia | Djibouti) and every route
|
||||
* freezes its trade direction from the yard countries:
|
||||
* Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT,
|
||||
* same country = DOMESTIC (shown as "Intercity"; disabled for scheduling
|
||||
* and contracts for now).
|
||||
*
|
||||
* Existing yard rows are normalized case-insensitively; anything mentioning
|
||||
* Djibouti maps there, everything else maps to Ethiopia (the line only serves
|
||||
* these two countries). A CHECK constraint keeps future writes honest.
|
||||
*/
|
||||
export class YardCountryEnumAndRouteDirection1980000000000 implements MigrationInterface {
|
||||
name = 'YardCountryEnumAndRouteDirection1980000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.yards
|
||||
SET country = CASE
|
||||
WHEN lower(trim(country)) LIKE '%djib%' THEN 'Djibouti'
|
||||
ELSE 'Ethiopia'
|
||||
END
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.yards
|
||||
DROP CONSTRAINT IF EXISTS chk_yards_country,
|
||||
ADD CONSTRAINT chk_yards_country CHECK (country IN ('Ethiopia', 'Djibouti'))
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes
|
||||
ADD COLUMN IF NOT EXISTS direction varchar(10)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.routes r
|
||||
SET direction = CASE
|
||||
WHEN o.country = 'Djibouti' AND d.country = 'Ethiopia' THEN 'IMPORT'
|
||||
WHEN o.country = 'Ethiopia' AND d.country = 'Djibouti' THEN 'EXPORT'
|
||||
ELSE 'DOMESTIC'
|
||||
END
|
||||
FROM freight.yards o, freight.yards d
|
||||
WHERE o.id = r.origin_yard_id
|
||||
AND d.id = r.destination_yard_id
|
||||
`);
|
||||
// Orphan origin/destination (deleted yard) — no way to classify; park as
|
||||
// DOMESTIC, which is blocked everywhere, so nothing can schedule on it.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.routes SET direction = 'DOMESTIC' WHERE direction IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes
|
||||
ALTER COLUMN direction SET NOT NULL,
|
||||
DROP CONSTRAINT IF EXISTS chk_routes_direction,
|
||||
ADD CONSTRAINT chk_routes_direction CHECK (direction IN ('IMPORT', 'EXPORT', 'DOMESTIC'))
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes
|
||||
DROP CONSTRAINT IF EXISTS chk_routes_direction,
|
||||
DROP COLUMN IF EXISTS direction
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.yards DROP CONSTRAINT IF EXISTS chk_yards_country
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Double-handling fee support. warehouse_fee_rules.basis: how a
|
||||
* DOUBLE_HANDLING_FEE rule is charged — PER_CONTAINER | PER_TON | PER_ITEM
|
||||
* (null for the day-based fee types). The PER_TON / PER_ITEM quantity comes from
|
||||
* the booking's cargo total (cargo_total_weight_vgm, expressed in the cargo's
|
||||
* unit of measure), so no new booking column is needed.
|
||||
*/
|
||||
export class AddDoubleHandlingBasisAndMachinery1990000000000 implements MigrationInterface {
|
||||
name = 'AddDoubleHandlingBasisAndMachinery1990000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS basis varchar(20)`,
|
||||
);
|
||||
// machinery_units is not used (PER_ITEM reads cargo_total_weight_vgm); drop it
|
||||
// if a prior version of this migration added it.
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS machinery_units`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS basis`);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP COLUMN IF EXISTS price_per_km,
|
||||
DROP COLUMN IF EXISTS currency
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_positions`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_devices`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Truck detention support.
|
||||
* - last_mile.arrived_at / delivered_at: the detention window for an EDR
|
||||
* last-mile vehicle. The clock runs from arrival at destination; the customer
|
||||
* has a grace period (default 3h) to clear/return, after which detention
|
||||
* accrues per truck per day until delivered_at (or now, if still out).
|
||||
* - warehouse_fee_rules.free_hours: configurable grace window (hours) for a
|
||||
* TRUCK_DETENTION_FEE rule; null/0 falls back to the 3-hour default.
|
||||
*/
|
||||
export class AddTruckDetentionTiming2000000000000 implements MigrationInterface {
|
||||
name = 'AddTruckDetentionTiming2000000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.last_mile ADD COLUMN IF NOT EXISTS arrived_at timestamptz`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.last_mile ADD COLUMN IF NOT EXISTS delivered_at timestamptz`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS free_hours int`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS free_hours`);
|
||||
await queryRunner.query(`ALTER TABLE freight.last_mile DROP COLUMN IF EXISTS delivered_at`);
|
||||
await queryRunner.query(`ALTER TABLE freight.last_mile DROP COLUMN IF EXISTS arrived_at`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Truck-detention rules can be scoped by vehicle type (TRUCK / VAN / TRAILER /
|
||||
* TANKER / FLATBED / …), so different truck types carry different detention
|
||||
* rates. Null = applies to any truck type.
|
||||
*/
|
||||
export class AddFeeRuleVehicleType2010000000000 implements MigrationInterface {
|
||||
name = 'AddFeeRuleVehicleType2010000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS vehicle_type varchar(20)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS vehicle_type`);
|
||||
}
|
||||
}
|
||||
@@ -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<string[]> {
|
||||
const employees = await this.employeeRepository.find({
|
||||
where: { isCurrent: true },
|
||||
});
|
||||
return [
|
||||
...new Set(
|
||||
employees.map((e) => e.userId).filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
async createOrganizationUser(
|
||||
organizationId: string,
|
||||
dto: CreateOrganizationUserDto,
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PdfRenderService } from "./pdf-render.service";
|
||||
import {
|
||||
PdfColor,
|
||||
assembleSinglePagePdf,
|
||||
lineOp,
|
||||
rectOp,
|
||||
sealOp,
|
||||
textOp,
|
||||
textOpRight,
|
||||
wrapText,
|
||||
} from "./styled-pdf.util";
|
||||
|
||||
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
||||
|
||||
@@ -62,10 +72,136 @@ export class InvoiceDocumentService {
|
||||
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
|
||||
return {
|
||||
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
|
||||
buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }),
|
||||
buffer: await this.pdf.htmlToPdfBuffer(html, {
|
||||
label: `${model.title} ${kindLabel}`,
|
||||
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
|
||||
// summary grid, line-item table, totals) from the model — not a flat
|
||||
// plain-text dump — so it still reads as a proper invoice document.
|
||||
fallback: () => this.buildFallbackPdf(model),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vector-drawn styled invoice/receipt used when headless Chromium is
|
||||
* unavailable. Mirrors the HTML layout closely enough to pass as the same
|
||||
* document. Single A4 page; long summaries / line lists are capped to fit.
|
||||
*/
|
||||
buildFallbackPdf(model: InvoiceDocumentModel): Buffer {
|
||||
const currency = (cur?: string | null) =>
|
||||
(cur ?? model.currency) === "ETB" ? "ETB" : (cur ?? model.currency);
|
||||
const money = (amount: unknown, cur?: string | null) =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency(cur)}`;
|
||||
const date = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
|
||||
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
|
||||
const sealText =
|
||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||
const showCategory = Boolean(model.categoryHeader);
|
||||
|
||||
const ops: string[] = [];
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────
|
||||
ops.push(lineOp(36, 806, 559, 806, PdfColor.teal, 2.4));
|
||||
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", 36, 790, 8.5, "F2", PdfColor.gray));
|
||||
const titleSize = heading.length > 34 ? 18 : 22;
|
||||
ops.push(textOp(heading, 36, 762, titleSize, "F2", PdfColor.dark));
|
||||
|
||||
ops.push(textOpRight("DOCUMENT NO.", 559, 792, 7.5, "F2", PdfColor.gray));
|
||||
ops.push(textOpRight(model.documentNumber, 559, 776, 12, "F2", PdfColor.dark));
|
||||
ops.push(textOpRight(`Issued ${date(model.issuedAt)}`, 559, 762, 8.5, "F1", PdfColor.gray));
|
||||
ops.push(
|
||||
textOpRight(
|
||||
`Status ${model.status}`,
|
||||
559,
|
||||
748,
|
||||
8.5,
|
||||
"F1",
|
||||
model.status === "PAID" ? PdfColor.teal : PdfColor.gray,
|
||||
),
|
||||
);
|
||||
ops.push(lineOp(36, 736, 470, 736, PdfColor.line, 1));
|
||||
|
||||
// ── Seal ──────────────────────────────────────────────────────────────
|
||||
ops.push(sealOp(516, 706, 27, sealText.split(/\s+/), PdfColor.teal));
|
||||
|
||||
// ── Summary grid (two columns) ────────────────────────────────────────
|
||||
let y = 700;
|
||||
const colX = [36, 300];
|
||||
const colW = 250;
|
||||
model.summary.slice(0, 16).forEach((row, i) => {
|
||||
const x = colX[i % 2];
|
||||
if (i % 2 === 0 && i > 0) y -= 27;
|
||||
ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray));
|
||||
ops.push(textOp(this.clip(row.value ?? "-", 44), x, y - 11, 9, "F2", PdfColor.dark));
|
||||
ops.push(lineOp(x, y - 15, x + colW, y - 15, PdfColor.line, 0.6));
|
||||
});
|
||||
y -= 34;
|
||||
|
||||
// ── Line-item table ───────────────────────────────────────────────────
|
||||
const qtyR = 402;
|
||||
const rateR = 486;
|
||||
const amtR = 555;
|
||||
ops.push(rectOp(36, y - 18, 523, 18, PdfColor.shade, PdfColor.line, 0.7));
|
||||
ops.push(textOp("DESCRIPTION", 40, y - 13, 8, "F2", PdfColor.gray));
|
||||
if (showCategory) {
|
||||
ops.push(textOp((model.categoryHeader ?? "").toUpperCase(), 250, y - 13, 8, "F2", PdfColor.gray));
|
||||
}
|
||||
ops.push(textOpRight("QTY", qtyR, y - 13, 8, "F2", PdfColor.gray));
|
||||
ops.push(textOpRight("RATE", rateR, y - 13, 8, "F2", PdfColor.gray));
|
||||
ops.push(textOpRight("AMOUNT", amtR, y - 13, 8, "F2", PdfColor.gray));
|
||||
y -= 18;
|
||||
|
||||
const descChars = showCategory ? 44 : 66;
|
||||
for (const item of model.lines) {
|
||||
if (y < 190) break; // leave room for totals + footer
|
||||
const descLines = wrapText(item.description ?? "-", descChars).slice(0, 2);
|
||||
const rowH = Math.max(18, descLines.length * 10 + 8);
|
||||
ops.push(rectOp(36, y - rowH, 523, rowH, "1 1 1", PdfColor.line, 0.6));
|
||||
descLines.forEach((line, k) => {
|
||||
ops.push(textOp(line, 40, y - 12 - k * 10, 8, "F1", PdfColor.dark));
|
||||
});
|
||||
if (showCategory) {
|
||||
ops.push(textOp(this.clip((item.category ?? "").replace(/_/g, " "), 18), 250, y - 12, 8, "F1", PdfColor.dark));
|
||||
}
|
||||
ops.push(textOpRight(String(item.quantity ?? 0), qtyR, y - 12, 8, "F1", PdfColor.dark));
|
||||
ops.push(textOpRight(money(item.unitRate, item.currency), rateR, y - 12, 8, "F1", PdfColor.dark));
|
||||
ops.push(textOpRight(money(item.amount, item.currency), amtR, y - 12, 8, "F1", PdfColor.dark));
|
||||
y -= rowH;
|
||||
}
|
||||
|
||||
// ── Totals ────────────────────────────────────────────────────────────
|
||||
let ty = y - 16;
|
||||
for (const total of model.totals) {
|
||||
if (ty < 88) break;
|
||||
if (total.grand) {
|
||||
ops.push(lineOp(315, ty + 5, 559, ty + 5, PdfColor.dark, 0.9));
|
||||
ops.push(textOp(total.label, 320, ty - 9, 11, "F2", PdfColor.dark));
|
||||
ops.push(textOpRight(money(total.amount), 555, ty - 9, 12, "F2", PdfColor.dark));
|
||||
ty -= 24;
|
||||
} else {
|
||||
ops.push(textOp(total.label, 320, ty - 8, 9.5, "F1", PdfColor.gray));
|
||||
ops.push(textOpRight(money(total.amount), 555, ty - 8, 10, "F1", PdfColor.dark));
|
||||
ty -= 17;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Footer ────────────────────────────────────────────────────────────
|
||||
ops.push(lineOp(36, 64, 250, 64, PdfColor.dark, 0.8));
|
||||
ops.push(textOp("Prepared by EDR finance", 36, 52, 7.5, "F1", PdfColor.gray));
|
||||
ops.push(lineOp(340, 64, 559, 64, PdfColor.dark, 0.8));
|
||||
ops.push(textOp("Authorized seal / signature", 340, 52, 7.5, "F1", PdfColor.gray));
|
||||
|
||||
return assembleSinglePagePdf(ops);
|
||||
}
|
||||
|
||||
/** Truncate to `max` chars with an ellipsis. */
|
||||
private clip(value: string, max: number): string {
|
||||
const text = String(value ?? "");
|
||||
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
||||
}
|
||||
|
||||
buildHtml(model: InvoiceDocumentModel): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? "-")
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Minimal hand-built PDF primitives shared by the Chromium-less document
|
||||
* fallbacks (invoices, receipts). These draw a genuine vector layout — boxes,
|
||||
* rules, right-aligned money, a round seal — so a document still looks like a
|
||||
* real document when headless Chromium is unavailable, instead of degrading to
|
||||
* a flat plain-text dump. Coordinates are PDF user space (origin bottom-left,
|
||||
* A4 = 595 x 842 pt). Fonts: F1 = Helvetica, F2 = Helvetica-Bold.
|
||||
*/
|
||||
|
||||
export const MIN_VALID_PDF_BYTES = 2_000;
|
||||
|
||||
/** Colours as PDF "r g b" triples in the 0..1 range. */
|
||||
export const PdfColor = {
|
||||
teal: "0.06 0.46 0.43",
|
||||
dark: "0.06 0.09 0.16",
|
||||
gray: "0.39 0.45 0.55",
|
||||
line: "0.80 0.84 0.89",
|
||||
shade: "0.96 0.97 0.98",
|
||||
tint: "0.94 0.99 0.98",
|
||||
} as const;
|
||||
|
||||
export function escapePdfText(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\(/g, "\\(")
|
||||
.replace(/\)/g, "\\)")
|
||||
.replace(/[^\x20-\x7e]/g, " ");
|
||||
}
|
||||
|
||||
/** Approximate rendered width of Helvetica text (slightly over-estimated so
|
||||
* right-aligned text never crosses its column edge). */
|
||||
export function textWidth(text: string, size: number): number {
|
||||
return text.length * size * 0.52;
|
||||
}
|
||||
|
||||
export function textOp(
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
font: "F1" | "F2" = "F1",
|
||||
color: string = PdfColor.dark,
|
||||
): string {
|
||||
return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
|
||||
}
|
||||
|
||||
/** Right-align `text` so it ends at `rightX`. */
|
||||
export function textOpRight(
|
||||
text: string,
|
||||
rightX: number,
|
||||
y: number,
|
||||
size: number,
|
||||
font: "F1" | "F2" = "F1",
|
||||
color: string = PdfColor.dark,
|
||||
): string {
|
||||
return textOp(text, rightX - textWidth(text, size), y, size, font, color);
|
||||
}
|
||||
|
||||
export function lineOp(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
color: string = PdfColor.line,
|
||||
width = 0.8,
|
||||
): string {
|
||||
return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
|
||||
}
|
||||
|
||||
export function rectOp(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
fillColor = "1 1 1",
|
||||
strokeColor: string = PdfColor.line,
|
||||
lineWidth = 0.7,
|
||||
): string {
|
||||
return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`;
|
||||
}
|
||||
|
||||
function circlePath(cx: number, cy: number, r: number): string {
|
||||
const k = 0.5522847498;
|
||||
const c = r * k;
|
||||
return [
|
||||
`${cx + r} ${cy} m`,
|
||||
`${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`,
|
||||
`${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`,
|
||||
`${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`,
|
||||
`${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`,
|
||||
"h",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** A double-ring round rubber-stamp seal carrying up to three centred lines. */
|
||||
export function sealOp(
|
||||
cx: number,
|
||||
cy: number,
|
||||
r: number,
|
||||
lines: string[],
|
||||
color: string = PdfColor.teal,
|
||||
): string {
|
||||
const rows = lines.slice(0, 3);
|
||||
const ops = [
|
||||
"q",
|
||||
`${color} RG`,
|
||||
`${color} rg`,
|
||||
"2 w",
|
||||
circlePath(cx, cy, r),
|
||||
"S",
|
||||
"0.7 w",
|
||||
circlePath(cx, cy, r - 6),
|
||||
"S",
|
||||
];
|
||||
const startY = cy + (rows.length - 1) * 6;
|
||||
rows.forEach((text, i) => {
|
||||
const size = i === 0 ? 10 : 7.5;
|
||||
ops.push(textOpRight(text, cx + textWidth(text, size) / 2, startY - i * 12 - 3, size, "F2", color));
|
||||
});
|
||||
ops.push("Q");
|
||||
return ops.join("\n");
|
||||
}
|
||||
|
||||
/** Hard-truncate to `max` chars (no marker — keeps dense table cells tight). */
|
||||
export function clipText(value: string, max: number): string {
|
||||
const t = String(value ?? "");
|
||||
return t.length > max ? t.slice(0, Math.max(1, max)) : t;
|
||||
}
|
||||
|
||||
/** Strip HTML tags → plain text, decoding the basic entities the doc builders emit. */
|
||||
export function htmlToText(html: string): string {
|
||||
return String(html ?? "")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/[^\x20-\x7e]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a "summary tiles + one <table> + notice + signature lines" document (the
|
||||
* marshalling / load-list layout the train-scheduling builders emit) and draw it as a
|
||||
* styled PDF grid. Used as the Chromium-less fallback so the manifest reads as a real
|
||||
* document, not a flat text dump. Switches to landscape when the table is wide.
|
||||
*/
|
||||
export function buildTabularFallbackPdf(html: string): Buffer {
|
||||
const pick = (re: RegExp) => html.match(re)?.[1];
|
||||
const title = htmlToText(pick(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ?? "Document");
|
||||
const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
|
||||
const metaRef = htmlToText(pick(/class="meta"[\s\S]*?<strong>([\s\S]*?)<\/strong>/i) ?? "");
|
||||
const generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? "");
|
||||
|
||||
const tiles: Array<[string, string]> = [];
|
||||
for (const m of html.matchAll(
|
||||
/class="tile"[^>]*>\s*<span>([\s\S]*?)<\/span>\s*<strong>([\s\S]*?)<\/strong>/gi,
|
||||
)) {
|
||||
tiles.push([htmlToText(m[1]), htmlToText(m[2])]);
|
||||
}
|
||||
|
||||
const thead = pick(/<thead>([\s\S]*?)<\/thead>/i) ?? "";
|
||||
const headers = [...thead.matchAll(/<th[^>]*>([\s\S]*?)<\/th>/gi)].map((m) => htmlToText(m[1]));
|
||||
const tbody = pick(/<tbody>([\s\S]*?)<\/tbody>/i) ?? "";
|
||||
const rows: string[][] = [...tbody.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)].map((tr) =>
|
||||
[...tr[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map((td) => htmlToText(td[1])),
|
||||
);
|
||||
const notice = htmlToText(pick(/class="notice"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
|
||||
const parsedSigs = [...html.matchAll(/class="line"[^>]*>([\s\S]*?)<\/div>/gi)]
|
||||
.map((m) => htmlToText(m[1]))
|
||||
.filter(Boolean);
|
||||
const signatures = parsedSigs.length ? parsedSigs : ["Prepared / date", "Check / date", "Authorization / date"];
|
||||
|
||||
const landscape = headers.length > 7;
|
||||
const page = landscape ? PageSize.landscape : PageSize.portrait;
|
||||
const M = 32;
|
||||
const contentW = page.width - M * 2;
|
||||
const right = page.width - M;
|
||||
const ops: string[] = [];
|
||||
|
||||
// Header
|
||||
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
|
||||
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
|
||||
ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
|
||||
if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
|
||||
if (metaRef) {
|
||||
ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray));
|
||||
ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
|
||||
}
|
||||
if (generated) {
|
||||
ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
|
||||
}
|
||||
ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
|
||||
|
||||
// Summary tiles
|
||||
let y = page.height - 100;
|
||||
if (tiles.length) {
|
||||
const cols = landscape ? 6 : 4;
|
||||
const tileW = contentW / cols;
|
||||
const tileH = 32;
|
||||
tiles.forEach(([label, value], i) => {
|
||||
const col = i % cols;
|
||||
if (col === 0 && i > 0) y -= tileH;
|
||||
const x = M + col * tileW;
|
||||
ops.push(rectOp(x, y - tileH + 4, tileW - 4, tileH - 4, PdfColor.shade, PdfColor.line, 0.5));
|
||||
ops.push(textOp(clipText(label.toUpperCase(), Math.floor((tileW - 12) / 3.6)), x + 6, y - 8, 6.5, "F1", PdfColor.gray));
|
||||
ops.push(textOp(clipText(value, Math.floor((tileW - 12) / 4.4)), x + 6, y - 20, 9, "F2", PdfColor.dark));
|
||||
});
|
||||
y -= tileH + 12;
|
||||
}
|
||||
|
||||
// Table
|
||||
if (headers.length) {
|
||||
const colW = contentW / headers.length;
|
||||
const headerH = 16;
|
||||
const rowH = 14;
|
||||
const cellChars = Math.max(4, Math.floor(colW / 3.9));
|
||||
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
|
||||
headers.forEach((h, c) =>
|
||||
ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
|
||||
);
|
||||
y -= headerH;
|
||||
|
||||
let shown = 0;
|
||||
for (const row of rows) {
|
||||
if (y < 96) break;
|
||||
ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4));
|
||||
headers.forEach((_h, c) => {
|
||||
if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3));
|
||||
const cell = row[c] ?? "";
|
||||
if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark));
|
||||
});
|
||||
y -= rowH;
|
||||
shown += 1;
|
||||
}
|
||||
if (shown < rows.length) {
|
||||
ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
|
||||
}
|
||||
}
|
||||
|
||||
// Notice (verification clause)
|
||||
if (notice) {
|
||||
ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
|
||||
wrapText(notice, landscape ? 155 : 104)
|
||||
.slice(0, 2)
|
||||
.forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
|
||||
}
|
||||
|
||||
// Signatures
|
||||
const sigW = contentW / signatures.length;
|
||||
signatures.forEach((s, i) => {
|
||||
const x = M + i * sigW;
|
||||
ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7));
|
||||
ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
|
||||
});
|
||||
|
||||
return assembleSinglePagePdf(ops, page);
|
||||
}
|
||||
|
||||
/** Greedy word-wrap to a maximum character width. */
|
||||
export function wrapText(text: string, maxChars: number): string[] {
|
||||
const out: string[] = [];
|
||||
for (const raw of String(text ?? "").split("\n")) {
|
||||
const words = raw.split(/\s+/).filter(Boolean);
|
||||
let line = "";
|
||||
for (const word of words) {
|
||||
const next = line ? `${line} ${word}` : word;
|
||||
if (next.length > maxChars && line) {
|
||||
out.push(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = next;
|
||||
}
|
||||
}
|
||||
if (line) out.push(line);
|
||||
}
|
||||
return out.length ? out : [""];
|
||||
}
|
||||
|
||||
/** A4 page sizes in PDF points. */
|
||||
export const PageSize = {
|
||||
portrait: { width: 595, height: 842 },
|
||||
landscape: { width: 842, height: 595 },
|
||||
} as const;
|
||||
|
||||
/** Assemble a single-page PDF from content-stream ops (Helvetica fonts). Defaults to A4 portrait. */
|
||||
export function assembleSinglePagePdf(
|
||||
ops: string[],
|
||||
page: { width: number; height: number } = PageSize.portrait,
|
||||
): Buffer {
|
||||
const stream = ops.join("\n");
|
||||
const objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${page.width} ${page.height}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>`,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
|
||||
`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
|
||||
];
|
||||
|
||||
let pdf = "%PDF-1.4\n";
|
||||
const offsets: number[] = [0];
|
||||
objects.forEach((object, index) => {
|
||||
offsets.push(Buffer.byteLength(pdf, "latin1"));
|
||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
});
|
||||
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) {
|
||||
pdf += "% fallback padding\n";
|
||||
}
|
||||
const xrefOffset = Buffer.byteLength(pdf, "latin1");
|
||||
pdf += `xref\n0 ${objects.length + 1}\n`;
|
||||
pdf += "0000000000 65535 f \n";
|
||||
for (const offset of offsets.slice(1)) {
|
||||
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
|
||||
}
|
||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||
return Buffer.from(pdf, "latin1");
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
NotifyInput,
|
||||
} from '@edr/types';
|
||||
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
|
||||
/**
|
||||
* Customer + staff notifications for the booking lifecycle: review, clearance
|
||||
* and operation flow. Every customer event fans out over SMS + email (direct)
|
||||
* and a persisted in-app notification deep-linking to the booking detail page;
|
||||
* staff events land in the backoffice inbox. All sends are fire-and-forget and
|
||||
* never throw — a notification failure must not break a booking transition.
|
||||
*
|
||||
* NOTE: the batch/payment-window notifications (pay-now, allocated, expired,
|
||||
* displaced) are handled separately by {@link BookingNotifierService} in
|
||||
* train-scheduling.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingLifecycleNotifierService {
|
||||
private readonly logger = new Logger(BookingLifecycleNotifierService.name);
|
||||
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
private ref(b: Booking): string {
|
||||
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
||||
}
|
||||
|
||||
/** Send SMS + email to the booking's company contact; log-only on failure. */
|
||||
private async notifyContact(
|
||||
b: Booking,
|
||||
message: string,
|
||||
logLabel: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
||||
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
|
||||
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
|
||||
|
||||
if (phone) {
|
||||
try {
|
||||
await this.notifications.directSend('sms', phone, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`SMS failed for ${this.ref(b)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (email) {
|
||||
try {
|
||||
await this.notifications.directSend('email', email, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Email failed for ${this.ref(b)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (!phone && !email) {
|
||||
this.logger.warn(`No contact on file for ${this.ref(b)} — notification not sent`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to all portal users of the booking's company. */
|
||||
private inApp(
|
||||
b: Booking,
|
||||
title: string,
|
||||
body: string,
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
if (!b.companyId) return; // government/unlinked bookings have no portal users
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title,
|
||||
body,
|
||||
link: `/bookings/${b.id}`,
|
||||
data: { bookingId: b.id, reference: b.reference },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to every backoffice staff user. */
|
||||
private inAppStaff(
|
||||
b: Booking,
|
||||
title: string,
|
||||
body: string,
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
void this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title,
|
||||
body,
|
||||
link: `/dashboard/booking-requests/${b.id}`,
|
||||
data: { bookingId: b.id, reference: b.reference },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Customer-facing lifecycle events ───────────────────────────────────────
|
||||
|
||||
/** Line staff accepted intake → booking is under approval. */
|
||||
accepted(b: Booking): void {
|
||||
const msg =
|
||||
`Your booking ${b.reference} has been accepted and is now under approval. ` +
|
||||
`We will notify you once it is approved.`;
|
||||
void this.notifyContact(b, msg, 'ACCEPTED');
|
||||
this.inApp(b, 'Booking accepted', msg);
|
||||
}
|
||||
|
||||
/** All approval steps complete → contract generated, ready for customer to sign. */
|
||||
approved(b: Booking): void {
|
||||
const msg =
|
||||
`Your booking ${b.reference} has been approved. ` +
|
||||
`Please review and sign your contract from the portal.`;
|
||||
void this.notifyContact(b, msg, 'APPROVED');
|
||||
this.inApp(b, 'Booking approved', msg);
|
||||
}
|
||||
|
||||
/** Staff rejected the booking (intake or approval step). */
|
||||
rejected(b: Booking, reason: string): void {
|
||||
const msg =
|
||||
`Your booking ${b.reference} was rejected. Reason: ${reason}. ` +
|
||||
`Please contact us for details.`;
|
||||
void this.notifyContact(b, msg, 'REJECTED');
|
||||
this.inApp(b, 'Booking rejected', msg);
|
||||
}
|
||||
|
||||
/** Staff requested changes before approval. */
|
||||
changesRequested(b: Booking, note: string): void {
|
||||
const msg =
|
||||
`Changes were requested on your booking ${b.reference}: ${note}. ` +
|
||||
`Please update and resubmit from the portal.`;
|
||||
void this.notifyContact(b, msg, 'CHANGES REQUESTED');
|
||||
this.inApp(b, 'Booking changes requested', msg);
|
||||
}
|
||||
|
||||
/** A clearance document was queried and needs the customer to re-upload. */
|
||||
documentQueried(b: Booking, fileKey: string, note: string): void {
|
||||
const msg =
|
||||
`A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` +
|
||||
`${note}. Please re-upload from the portal.`;
|
||||
void this.notifyContact(b, msg, 'DOCUMENT QUERIED');
|
||||
this.inApp(b, 'Document queried', msg, {
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
});
|
||||
}
|
||||
|
||||
/** Clearance finalized → customer can proceed to request operation. */
|
||||
clearanceReady(b: Booking): void {
|
||||
const msg =
|
||||
`Clearance for booking ${b.reference} is complete. ` +
|
||||
`You can now proceed to request operation from the portal.`;
|
||||
void this.notifyContact(b, msg, 'CLEARANCE READY');
|
||||
this.inApp(b, 'Clearance complete', msg, {
|
||||
type: NotificationType.CLEARANCE_DECISION,
|
||||
});
|
||||
}
|
||||
|
||||
/** Operations returned the operation request for changes. */
|
||||
operationChangesRequested(b: Booking, note: string): void {
|
||||
const msg =
|
||||
`Your operation request for booking ${b.reference} needs changes: ${note}. ` +
|
||||
`Please update and resubmit from the portal.`;
|
||||
void this.notifyContact(b, msg, 'OPERATION CHANGES REQUESTED');
|
||||
this.inApp(b, 'Operation request needs changes', msg);
|
||||
}
|
||||
|
||||
/** Operation accepted → invoice ready; await payment / booking window. */
|
||||
operationAccepted(b: Booking): void {
|
||||
const msg =
|
||||
`Your operation request for booking ${b.reference} has been accepted. ` +
|
||||
`An invoice has been prepared — watch for the payment window to secure your slot.`;
|
||||
void this.notifyContact(b, msg, 'OPERATION ACCEPTED');
|
||||
this.inApp(b, 'Operation request accepted', msg);
|
||||
}
|
||||
|
||||
/** Shipment started → in transit. */
|
||||
inTransit(b: Booking): void {
|
||||
const msg = `Your shipment for booking ${b.reference} is now in transit.`;
|
||||
void this.notifyContact(b, msg, 'IN TRANSIT');
|
||||
this.inApp(b, 'Shipment in transit', msg);
|
||||
}
|
||||
|
||||
/** Shipment delivered → completed. */
|
||||
completed(b: Booking): void {
|
||||
const msg = `Your shipment for booking ${b.reference} has been delivered. Thank you.`;
|
||||
void this.notifyContact(b, msg, 'COMPLETED');
|
||||
this.inApp(b, 'Shipment delivered', msg);
|
||||
}
|
||||
|
||||
/** Booking cancelled. */
|
||||
cancelled(b: Booking, reason: string): void {
|
||||
const msg = `Your booking ${b.reference} has been cancelled. Reason: ${reason}.`;
|
||||
void this.notifyContact(b, msg, 'CANCELLED');
|
||||
this.inApp(b, 'Booking cancelled', msg);
|
||||
}
|
||||
|
||||
// ── Clearance milestones needing customer action ──────────────────────────
|
||||
|
||||
/** GL advised duty & tax — the customer must pay and upload the slip. */
|
||||
dutyAdvised(b: Booking, amount: number, currency: string): void {
|
||||
const msg =
|
||||
`Duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` +
|
||||
`Please pay and upload the payment slip from the portal.`;
|
||||
void this.notifyContact(b, msg, 'DUTY ADVISED');
|
||||
this.inApp(b, 'Duty & tax advised', msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
});
|
||||
}
|
||||
|
||||
/** GL advised the post-arrival additional duty round (import). */
|
||||
secondDutyAdvised(b: Booking, amount: number, currency: string): void {
|
||||
const msg =
|
||||
`Additional duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` +
|
||||
`Please pay and upload the payment slip from the portal.`;
|
||||
void this.notifyContact(b, msg, 'SECOND DUTY ADVISED');
|
||||
this.inApp(b, 'Additional duty & tax advised', msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
});
|
||||
}
|
||||
|
||||
/** GL raised the final (post-offload) invoice — customer pays + uploads slip. */
|
||||
finalInvoiceCreated(b: Booking, amount: number, currency: string): void {
|
||||
const msg =
|
||||
`A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` +
|
||||
`Please pay and upload the payment slip from the portal.`;
|
||||
void this.notifyContact(b, msg, 'FINAL INVOICE');
|
||||
this.inApp(b, 'Final invoice issued', msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
});
|
||||
}
|
||||
|
||||
/** GL confirmed the final-invoice payment slip. */
|
||||
finalInvoicePaid(b: Booking): void {
|
||||
const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`;
|
||||
void this.notifyContact(b, msg, 'FINAL INVOICE PAID');
|
||||
this.inApp(b, 'Final invoice paid', msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
|
||||
|
||||
/** Customer submitted a booking for review. */
|
||||
submittedToStaff(b: Booking): void {
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'New booking submitted',
|
||||
`Booking ${this.ref(b)} was submitted and is awaiting intake review.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer signed the booking contract. */
|
||||
customerSignedToStaff(b: Booking): void {
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'Customer signed booking contract',
|
||||
`The contract for booking ${this.ref(b)} was signed by the customer.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer requested operation (picked a shipment day). */
|
||||
operationRequestedToStaff(b: Booking): void {
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'Operation requested',
|
||||
`Booking ${this.ref(b)} requested operation — review capacity, documents and route.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer uploaded clearance documents — review is next. */
|
||||
clearanceDocsUploadedToStaff(b: Booking): void {
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'Clearance documents uploaded',
|
||||
`Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`,
|
||||
{
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer uploaded a duty/tax payment slip — GL verifies it. */
|
||||
dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void {
|
||||
const label =
|
||||
round === 'final'
|
||||
? 'final invoice'
|
||||
: round === 'second'
|
||||
? 'additional duty & tax'
|
||||
: 'duty & tax';
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'Payment slip uploaded',
|
||||
`Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`,
|
||||
{
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Booking> {
|
||||
@@ -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<Booking> {
|
||||
@@ -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<Booking> {
|
||||
@@ -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<Booking> {
|
||||
@@ -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<
|
||||
|
||||
@@ -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 {
|
||||
@@ -64,6 +66,7 @@ import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
|
||||
import { CustomerTruckService } from './customer-truck.service';
|
||||
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
||||
import { ContainerReceiptService } from './container-receipt.service';
|
||||
@@ -194,6 +197,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get("by-company/:companyId/customer-view")
|
||||
@BookingView()
|
||||
@ApiOperation({
|
||||
summary: "List bookings for a company (customer-view shape, backoffice)",
|
||||
})
|
||||
@@ -204,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) {
|
||||
@@ -225,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",
|
||||
@@ -358,6 +364,33 @@ export class BookingsController {
|
||||
return this.customerTruckService.removeTruck(id, assignmentId);
|
||||
}
|
||||
|
||||
@Get(':id/customer-trucks/loadable-containers')
|
||||
@ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' })
|
||||
async loadableContainers(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.customerTruckService.getLoadableContainers(id);
|
||||
}
|
||||
|
||||
@Post(':id/customer-trucks/:assignmentId/load')
|
||||
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
|
||||
async loadCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||
@Body() dto: LoadCustomerTruckDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
throw new ForbiddenException('Only warehouse staff can load a truck');
|
||||
}
|
||||
return this.customerTruckService.loadTruck(id, assignmentId, dto);
|
||||
}
|
||||
|
||||
@Post(':id/customer-trucks/:assignmentId/depart')
|
||||
@ApiOperation({
|
||||
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
|
||||
@@ -928,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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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<void> {
|
||||
const yards = await this.dataSource.getRepository(Yard).find({
|
||||
where: { id: In([originYardId, destinationYardId]) },
|
||||
});
|
||||
if (yards.some((y) => y.country !== 'Ethiopia')) {
|
||||
throw new BadRequestException(
|
||||
'Intercity bookings only run between Ethiopian yards',
|
||||
);
|
||||
}
|
||||
const rows: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT r.id
|
||||
FROM freight.routes r
|
||||
JOIN freight.route_milestones mo
|
||||
ON mo.route_id = r.id AND mo.yard_id = $1 AND mo.deleted_at IS NULL
|
||||
JOIN freight.route_milestones md
|
||||
ON md.route_id = r.id AND md.yard_id = $2 AND md.deleted_at IS NULL
|
||||
WHERE mo.sequence_no < md.sequence_no
|
||||
AND r.status = 'AVAILABLE'
|
||||
AND r.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[originYardId, destinationYardId],
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'No route passes through this origin and destination in order — intercity service is not available on this corridor',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveTradeDirectionForBooking(
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export class ContainerReceiptService {
|
||||
SET received_to_port = true,
|
||||
received_at = COALESCE(bcu.received_at, NOW()),
|
||||
updated_at = NOW()
|
||||
FROM freight.booking_containers bc,
|
||||
FROM freight.booking_container bc,
|
||||
freight.customer_truck_containers ctc
|
||||
WHERE bc.id = bcu.booking_container_id
|
||||
AND bc.booking_id = $1
|
||||
@@ -60,7 +60,7 @@ export class ContainerReceiptService {
|
||||
bcu.received_at AS "receivedAt",
|
||||
bcu.grn_number AS "grnNumber"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_containers bc
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1
|
||||
AND bcu.deleted_at IS NULL
|
||||
@@ -92,7 +92,7 @@ export class ContainerReceiptService {
|
||||
const pending: ReceivedUnitRow[] = await manager.query(
|
||||
`SELECT bcu.id, bcu.container_number AS "containerNumber"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_containers bc
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1
|
||||
AND bcu.deleted_at IS NULL
|
||||
@@ -109,7 +109,7 @@ export class ContainerReceiptService {
|
||||
const [{ batches }]: Array<{ batches: string }> = await manager.query(
|
||||
`SELECT COUNT(DISTINCT bcu.grn_number) AS batches
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_containers bc
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
@@ -129,7 +129,7 @@ export class ContainerReceiptService {
|
||||
const [{ remaining }]: Array<{ remaining: string }> = await manager.query(
|
||||
`SELECT COUNT(*) AS remaining
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_containers bc
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`,
|
||||
[bookingId],
|
||||
|
||||
@@ -198,6 +198,87 @@ export class CustomerTruckService {
|
||||
return this.listTrucks(bookingId);
|
||||
}
|
||||
|
||||
/** Booking container numbers not yet loaded onto any truck. */
|
||||
async getLoadableContainers(bookingId: string): Promise<string[]> {
|
||||
const [all, assigned] = await Promise.all([
|
||||
this.bookingContainerNumbers(bookingId),
|
||||
this.assignedContainerNumbers(bookingId),
|
||||
]);
|
||||
const taken = new Set(assigned);
|
||||
return all.filter((n) => !taken.has(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Truck_dispatch (load): assign the selected containers to a truck after it has
|
||||
* arrived, and set a provisional gross weight from their VGM. The truck is
|
||||
* weighed for real on departure. Locked once the truck has left.
|
||||
*/
|
||||
async loadTruck(
|
||||
bookingId: string,
|
||||
assignmentId: string,
|
||||
dto: { containerNumbers: string[] },
|
||||
): Promise<CustomerTruckAssignment[]> {
|
||||
await this.loadBookingGuard(bookingId);
|
||||
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
|
||||
if (!assignment || assignment.bookingId !== bookingId) {
|
||||
throw new NotFoundException('Truck assignment not found for this booking');
|
||||
}
|
||||
if (assignment.departedAt) {
|
||||
throw new ConflictException('This truck has already left — its load is locked');
|
||||
}
|
||||
|
||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
if (!requested.length) {
|
||||
throw new BadRequestException('Select at least one container to load onto the truck');
|
||||
}
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
}
|
||||
}
|
||||
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
|
||||
for (const n of requested) {
|
||||
if (elsewhere.includes(n)) {
|
||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
|
||||
const grossKg = await this.vgmKgForContainers(bookingId, requested);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||
await manager.getRepository(CustomerTruckContainer).save(
|
||||
requested.map((containerNumber) =>
|
||||
manager.getRepository(CustomerTruckContainer).create({
|
||||
assignmentId,
|
||||
bookingId,
|
||||
containerNumber,
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Provisional gross from the loaded containers' VGM — overridden by the
|
||||
// weighed gross on departure.
|
||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||
grossWeightKg: grossKg,
|
||||
});
|
||||
});
|
||||
return this.listTrucks(bookingId);
|
||||
}
|
||||
|
||||
private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise<number> {
|
||||
const [row]: Array<{ kg: string }> = await this.dataSource.query(
|
||||
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1
|
||||
AND bcu.container_number = ANY($2::varchar[])
|
||||
AND bcu.deleted_at IS NULL`,
|
||||
[bookingId, numbers],
|
||||
);
|
||||
return Number(row?.kg ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the truck carrying `containerNumber` as arrived. Called by the warehouse
|
||||
* receive flow. When every truck on the booking has arrived, the booking-level
|
||||
@@ -288,7 +369,7 @@ export class CustomerTruckService {
|
||||
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||
`SELECT bcu.container_number AS "containerNumber"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_containers bc
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
|
||||
|
||||
/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */
|
||||
export class LoadCustomerTruckDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
each: true,
|
||||
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
containerNumbers!: string[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<ComplianceRecord> {
|
||||
constructor(
|
||||
@InjectRepository(ComplianceRecord)
|
||||
private readonly complianceRepository: Repository<ComplianceRecord>,
|
||||
) {
|
||||
super(complianceRepository);
|
||||
}
|
||||
|
||||
async findWithFilters(filter: { vehicleId?: string; type?: ComplianceType } = {}) {
|
||||
const where: FindOptionsWhere<ComplianceRecord> = {};
|
||||
if (filter.vehicleId) where.vehicleId = filter.vehicleId;
|
||||
if (filter.type) where.type = filter.type;
|
||||
|
||||
return this.complianceRepository.find({
|
||||
where,
|
||||
order: { expiryDate: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<Vehicle>,
|
||||
@InjectRepository(Driver)
|
||||
private readonly driverRepo: Repository<Driver>,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateComplianceRecordDto): Promise<ComplianceRecord> {
|
||||
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<ComplianceRecord> {
|
||||
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<ComplianceRecord> {
|
||||
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<void> {
|
||||
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<ComplianceAlert[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<BookingRequest[]> {
|
||||
|
||||
@@ -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<ClearanceMilestone[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
NotifyInput,
|
||||
} from '@edr/types';
|
||||
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
|
||||
/**
|
||||
* Customer + staff notifications for the contract lifecycle. Every customer
|
||||
* event fans out over three channels: SMS + email (direct, via
|
||||
* {@link NotificationsService}) and a persisted in-app notification (via
|
||||
* {@link NotificationInboxService}) that deep-links to the contract detail page.
|
||||
* Staff events go to the backoffice inbox. All sends are fire-and-forget and
|
||||
* never throw — a notification failure must not break a contract transition.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ContractNotifierService {
|
||||
private readonly logger = new Logger(ContractNotifierService.name);
|
||||
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
private ref(c: Contract): string {
|
||||
return `${c.reference}${c.isGovernment ? ' (gov)' : ''}`;
|
||||
}
|
||||
|
||||
/** Send SMS + email to the contract's company contact; log-only on failure. */
|
||||
private async notifyContact(
|
||||
c: Contract,
|
||||
message: string,
|
||||
logLabel: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(c)}`);
|
||||
const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null;
|
||||
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
|
||||
|
||||
if (phone) {
|
||||
try {
|
||||
await this.notifications.directSend('sms', phone, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`SMS failed for ${this.ref(c)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (email) {
|
||||
try {
|
||||
await this.notifications.directSend('email', email, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Email failed for ${this.ref(c)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (!phone && !email) {
|
||||
this.logger.warn(`No contact on file for ${this.ref(c)} — notification not sent`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to all portal users of the contract's company. */
|
||||
private inApp(
|
||||
c: Contract,
|
||||
title: string,
|
||||
body: string,
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
if (!c.companyId) return; // government/unlinked contracts have no portal users
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: c.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.CONTRACT_STATUS,
|
||||
title,
|
||||
body,
|
||||
link: `/contracts/${c.id}`,
|
||||
data: { contractId: c.id, reference: c.reference },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to every backoffice staff user. */
|
||||
private inAppStaff(
|
||||
c: Contract,
|
||||
title: string,
|
||||
body: string,
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
void this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title,
|
||||
body,
|
||||
link: `/dashboard/contract-requests/${c.id}`,
|
||||
data: { contractId: c.id, reference: c.reference },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Customer-facing lifecycle events ───────────────────────────────────────
|
||||
|
||||
/** Line staff accepted intake → contract is under approval. */
|
||||
accepted(c: Contract): void {
|
||||
const msg =
|
||||
`Your contract ${c.reference} has been accepted and is now under approval. ` +
|
||||
`We will notify you once it is approved.`;
|
||||
void this.notifyContact(c, msg, 'ACCEPTED');
|
||||
this.inApp(c, 'Contract accepted', msg);
|
||||
}
|
||||
|
||||
/** All approval steps complete → contract approved. */
|
||||
approved(c: Contract): void {
|
||||
const msg =
|
||||
`Your contract ${c.reference} has been approved. ` +
|
||||
`The final document will be prepared for signing.`;
|
||||
void this.notifyContact(c, msg, 'APPROVED');
|
||||
this.inApp(c, 'Contract approved', msg);
|
||||
}
|
||||
|
||||
/** Fully executed (all parties signed) → contract active, customer can book. */
|
||||
signedActive(c: Contract): void {
|
||||
const msg =
|
||||
`Your contract ${c.reference} has been signed and is now active. ` +
|
||||
`You can start booking shipments from the portal.`;
|
||||
void this.notifyContact(c, msg, 'SIGNED / ACTIVE');
|
||||
this.inApp(c, 'Contract active', msg);
|
||||
}
|
||||
|
||||
/** Staff rejected the contract. */
|
||||
rejected(c: Contract, reason: string): void {
|
||||
const msg =
|
||||
`Your contract ${c.reference} was rejected. Reason: ${reason}. ` +
|
||||
`Please contact us for details.`;
|
||||
void this.notifyContact(c, msg, 'REJECTED');
|
||||
this.inApp(c, 'Contract rejected', msg);
|
||||
}
|
||||
|
||||
/** Staff requested changes before approval. */
|
||||
changesRequested(c: Contract, note: string): void {
|
||||
const msg =
|
||||
`Changes were requested on your contract ${c.reference}: ${note}. ` +
|
||||
`Please update and resubmit from the portal.`;
|
||||
void this.notifyContact(c, msg, 'CHANGES REQUESTED');
|
||||
this.inApp(c, 'Contract changes requested', msg);
|
||||
}
|
||||
|
||||
// ── Clearance milestones needing customer action ──────────────────────────
|
||||
|
||||
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
||||
dutyAdvised(c: Contract, amount: number, currency: string): void {
|
||||
const msg =
|
||||
`Duty & tax of ${amount} ${currency} has been advised for contract ${c.reference}. ` +
|
||||
`Please pay and upload the payment slip from the portal.`;
|
||||
void this.notifyContact(c, msg, 'DUTY ADVISED');
|
||||
this.inApp(c, 'Duty & tax advised', msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
link: `/contracts/${c.id}/clearance`,
|
||||
});
|
||||
}
|
||||
|
||||
/** A clearance document was queried — customer must re-upload it. */
|
||||
clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void {
|
||||
const msg =
|
||||
`A clearance document on contract ${c.reference} needs attention: "${fileKey}". ` +
|
||||
`${note}. Please re-upload from the portal.`;
|
||||
void this.notifyContact(c, msg, 'CLEARANCE DOC QUERIED');
|
||||
this.inApp(c, 'Clearance document queried', msg, {
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
link: `/contracts/${c.id}/clearance`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Import pre-clearance finalized — the process moves to GL Djibouti collection. */
|
||||
preClearanceFinalized(c: Contract): void {
|
||||
const msg =
|
||||
`Pre-clearance for contract ${c.reference} is complete. ` +
|
||||
`Your shipment is proceeding to document collection in Djibouti.`;
|
||||
void this.notifyContact(c, msg, 'PRE-CLEARANCE FINALIZED');
|
||||
this.inApp(c, 'Pre-clearance complete', msg, {
|
||||
type: NotificationType.CLEARANCE_DECISION,
|
||||
link: `/contracts/${c.id}/clearance`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
|
||||
|
||||
/** Customer submitted a contract for review. */
|
||||
submittedToStaff(c: Contract): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'New contract submitted',
|
||||
`Contract ${this.ref(c)} was submitted and is awaiting intake review.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer signed the contract — staff counter-sign is next. */
|
||||
customerSignedToStaff(c: Contract): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'Customer signed contract',
|
||||
`Contract ${this.ref(c)} was signed by the customer and awaits the EDR counter-signature.`,
|
||||
{ link: `/dashboard/contract-requests/${c.id}/view` },
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer uploaded clearance documents — GL review is next. */
|
||||
clearanceDocsUploadedToStaff(c: Contract): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'Clearance documents uploaded',
|
||||
`Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`,
|
||||
{
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer uploaded the duty/tax payment slip — GL verifies it. */
|
||||
dutySlipUploadedToStaff(c: Contract): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'Duty slip uploaded',
|
||||
`Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`,
|
||||
{
|
||||
type: NotificationType.PAYMENT_RECEIVED,
|
||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer filed a shipment request under a GENERAL customs contract. */
|
||||
shipmentRequestedToStaff(c: Contract, requestId: string, requestRef: string): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'New shipment request',
|
||||
`Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`,
|
||||
{
|
||||
link: `/dashboard/shipment-requests/${requestId}`,
|
||||
data: { contractId: c.id, requestId, reference: requestRef },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Contract> {
|
||||
@@ -235,7 +245,47 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject one approval step (line staff / director / CEO). The rejecting
|
||||
* approver must supply a reason. A rejection is terminal: the whole contract
|
||||
* moves to REJECTED and the customer must create a new one — there is no
|
||||
* resubmit of the same contract. The reason is recorded both on the step and
|
||||
* as a REJECTION review note so it is visible to the customer and the rest of
|
||||
* the approval chain.
|
||||
*/
|
||||
async rejectStep(
|
||||
contractId: string,
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
reason: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
||||
|
||||
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
|
||||
if (!step) throw new BadRequestException('Approval step not found');
|
||||
|
||||
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason);
|
||||
|
||||
await this.contractsRepository.createReviewNote(
|
||||
contractId,
|
||||
reason,
|
||||
'REJECTION',
|
||||
actorId,
|
||||
'STAFF',
|
||||
);
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'REJECTED',
|
||||
} as never);
|
||||
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 +347,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 +589,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 +661,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. */
|
||||
|
||||
@@ -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 {
|
||||
@@ -58,6 +60,7 @@ import { AcceptContractDto } from './dto/accept-contract.dto';
|
||||
import {
|
||||
ApproveStepDto,
|
||||
RejectContractDto,
|
||||
RejectStepDto,
|
||||
RequestChangesDto,
|
||||
} from './dto/approve-step.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
@@ -242,6 +245,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) {
|
||||
@@ -387,6 +391,27 @@ export class ContractsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/reject')
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
FREIGHT_PERMS.contracts.approveDirector,
|
||||
FREIGHT_PERMS.contracts.approveCeo,
|
||||
])
|
||||
@ApiOperation({ summary: 'Reject one approval step (terminal → REJECTED)' })
|
||||
rejectStep(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: RejectStepDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.transitionService.rejectStep(
|
||||
id,
|
||||
stepId,
|
||||
resolveAuthUserId(user),
|
||||
dto.reason,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/contract/generate')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.generateContract)
|
||||
@ApiOperation({ summary: 'Generate contract document → CONTRACT_READY' })
|
||||
@@ -449,14 +474,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<string, string> = {
|
||||
STAFF: FREIGHT_PERMS.contracts.signStaff,
|
||||
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||
};
|
||||
if (dto.role !== 'CUSTOMER') {
|
||||
assertFreightPermission(user, signRolePermission[dto.role]);
|
||||
}
|
||||
return this.transitionService.sign(id, dto, {
|
||||
signerUserId: user?.id ?? user?.sub,
|
||||
signerUserId: user?.id,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
const yardIds = [
|
||||
...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])),
|
||||
];
|
||||
const yards = await this.dataSource
|
||||
.getRepository(Yard)
|
||||
.find({ where: yardIds.map((id) => ({ id })) });
|
||||
const yardById = new Map(yards.map((y) => [y.id, y]));
|
||||
|
||||
for (const route of routes) {
|
||||
const origin = yardById.get(route.originYardId);
|
||||
const destination = yardById.get(route.destinationYardId);
|
||||
if (!origin || !destination) {
|
||||
throw new BadRequestException('Route references a yard that does not exist');
|
||||
}
|
||||
const derived = deriveTradeDirection(origin, destination);
|
||||
if (derived !== tradeDirection) {
|
||||
throw new BadRequestException(
|
||||
`Route ${origin.label} → ${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
derived === 'DOMESTIC' &&
|
||||
(origin.country !== YardCountry.ETHIOPIA ||
|
||||
destination.country !== YardCountry.ETHIOPIA)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Route ${origin.label} → ${destination.label}: intercity service only runs between Ethiopian yards`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
|
||||
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<string, unknown> = {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Booking> {
|
||||
const booking = await this.bookings.findOne({ where: { id: bookingId } });
|
||||
// company is loaded so customer notifications have a phone/email to target.
|
||||
const booking = await this.bookings.findOne({
|
||||
where: { id: bookingId },
|
||||
relations: { company: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
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<Freight.ClearanceFinalInvoiceSummary> {
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,13 @@ import {
|
||||
Body,
|
||||
Query,
|
||||
ParseUUIDPipe,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { DriversService } from './drivers.service';
|
||||
import { CreateDriverDto } from './dto/create-driver.dto';
|
||||
import { UpdateDriverDto } from './dto/update-driver.dto';
|
||||
@@ -19,7 +23,7 @@ import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
||||
@ApiTags('drivers')
|
||||
@ApiBearerAuth()
|
||||
@Controller('drivers')
|
||||
@FleetView()
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.view)
|
||||
export class DriversController {
|
||||
constructor(
|
||||
private readonly driversService: DriversService,
|
||||
@@ -27,7 +31,7 @@ export class DriversController {
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.create)
|
||||
@ApiOperation({ summary: 'Create a new driver' })
|
||||
create(@Body() createDriverDto: CreateDriverDto) {
|
||||
return this.driversService.create(createDriverDto);
|
||||
@@ -65,8 +69,33 @@ export class DriversController {
|
||||
return this.fleetHistory.getDriverHistory(id);
|
||||
}
|
||||
|
||||
@Post(':id/documents')
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.update)
|
||||
@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')
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.update)
|
||||
@ApiOperation({ summary: 'Delete a driver document' })
|
||||
removeDocument(@Param('fileId', ParseUUIDPipe) fileId: string) {
|
||||
return this.driversService.removeDocument(fileId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.update)
|
||||
@ApiOperation({ summary: 'Update a driver' })
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -76,7 +105,7 @@ export class DriversController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.delete)
|
||||
@ApiOperation({ summary: 'Delete a driver' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.driversService.remove(id);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<Driver>,
|
||||
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<void> {
|
||||
await this.filesService.remove(fileId);
|
||||
}
|
||||
|
||||
async create(dto: CreateDriverDto): Promise<Driver> {
|
||||
if (dto.faydaVerified !== true) {
|
||||
throw new BadRequestException(
|
||||
|
||||
@@ -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<void> {
|
||||
await this.filesRepository.softDelete(id);
|
||||
}
|
||||
|
||||
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
|
||||
return this.filesRepository.findByResource(resourceId, resource);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
||||
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
||||
@@ -27,7 +28,7 @@ import { FirstMileInvoiceService } from './first-mile-invoice.service';
|
||||
@ApiTags('first-mile')
|
||||
@ApiBearerAuth()
|
||||
@Controller('first-mile')
|
||||
@TrainSchedulingView()
|
||||
@BookingStaff(FREIGHT_PERMS.firstMile.view)
|
||||
export class FirstMileController {
|
||||
constructor(
|
||||
private readonly firstMileService: FirstMileService,
|
||||
@@ -63,27 +64,28 @@ export class FirstMileController {
|
||||
}
|
||||
|
||||
@Get('acceptitem/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.firstMile.accept)
|
||||
@ApiOperation({ summary: 'Get a first-mile accep by ID' })
|
||||
acceptItem(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.firstMileService.acceptBooking(id);
|
||||
}
|
||||
|
||||
@Post('accept/:reference')
|
||||
@TrainSchedulingManage()
|
||||
@BookingStaff(FREIGHT_PERMS.firstMile.accept)
|
||||
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
|
||||
acceptBooking(@Param('reference') reference: string) {
|
||||
return this.firstMileService.acceptBookingByReference(reference);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@TrainSchedulingManage()
|
||||
@BookingStaff(FREIGHT_PERMS.firstMile.create)
|
||||
@ApiOperation({ summary: 'Create a first-mile leg' })
|
||||
create(@Body() dto: CreateFirstMileDto) {
|
||||
return this.firstMileService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@TrainSchedulingManage()
|
||||
@BookingStaff(FREIGHT_PERMS.firstMile.update)
|
||||
@ApiOperation({ summary: 'Update a first-mile leg' })
|
||||
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
|
||||
// No invoice side-effects — invoices are generated only via the explicit
|
||||
@@ -92,7 +94,7 @@ export class FirstMileController {
|
||||
}
|
||||
|
||||
@Post(':id/invoice')
|
||||
@TrainSchedulingManage()
|
||||
@BookingStaff(FREIGHT_PERMS.firstMile.generateInvoice)
|
||||
@ApiOperation({ summary: 'Generate the first-mile delivery-fee invoice' })
|
||||
async generateInvoice(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const record = await this.firstMileService.findById(id);
|
||||
@@ -106,7 +108,7 @@ export class FirstMileController {
|
||||
}
|
||||
|
||||
@Post(':id/vehicles')
|
||||
@TrainSchedulingManage()
|
||||
@BookingStaff(FREIGHT_PERMS.firstMile.assignVehicles)
|
||||
@ApiOperation({ summary: 'Set the vehicles assigned to a first-mile pickup (multi-truck)' })
|
||||
async setVehicles(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -116,7 +118,7 @@ export class FirstMileController {
|
||||
}
|
||||
|
||||
@Post(':id/distances')
|
||||
@TrainSchedulingManage()
|
||||
@BookingStaff(FREIGHT_PERMS.firstMile.setDistances)
|
||||
@ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' })
|
||||
async setDistances(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -126,7 +128,7 @@ export class FirstMileController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@TrainSchedulingManage()
|
||||
@BookingStaff(FREIGHT_PERMS.firstMile.delete)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a first-mile leg' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
import { Controller, Post, Get, Body, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { FuelService } from './fuel.service';
|
||||
import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto';
|
||||
|
||||
// Stats feed the Financial Reports + Fleet Dashboard pages, so their viewers may
|
||||
// read them without full fuel access.
|
||||
const FUEL_STATS_PERMS = [
|
||||
FREIGHT_PERMS.fuel.view,
|
||||
FREIGHT_PERMS.fleetReports.view,
|
||||
FREIGHT_PERMS.fleetDashboard.view,
|
||||
];
|
||||
|
||||
@ApiTags('Fuel Management')
|
||||
@ApiBearerAuth()
|
||||
@Controller('fuel')
|
||||
export class FuelController {
|
||||
constructor(private readonly fuelService: FuelService) {}
|
||||
|
||||
@Post('purchases')
|
||||
@BookingStaff(FREIGHT_PERMS.fuel.create)
|
||||
@ApiOperation({ summary: 'Record fuel purchase' })
|
||||
async recordFuelPurchase(@Body() dto: CreateFuelPurchaseDto) {
|
||||
return this.fuelService.recordFuelPurchase(dto);
|
||||
}
|
||||
|
||||
@Get('purchases')
|
||||
@BookingStaff(FREIGHT_PERMS.fuel.view)
|
||||
@ApiOperation({ summary: 'Get all fuel purchases' })
|
||||
async getAllFuelPurchases() {
|
||||
return this.fuelService.getAllFuelPurchases();
|
||||
}
|
||||
|
||||
@Get('purchases/:vehicleId')
|
||||
@BookingStaff(FREIGHT_PERMS.fuel.view)
|
||||
@ApiOperation({ summary: 'Get fuel purchases for vehicle' })
|
||||
async getFuelPurchases(
|
||||
@Param('vehicleId') vehicleId: string,
|
||||
@@ -35,6 +49,7 @@ export class FuelController {
|
||||
}
|
||||
|
||||
@Get('consumption/:vehicleId/:month')
|
||||
@BookingStaff(FREIGHT_PERMS.fuel.view)
|
||||
@ApiOperation({ summary: 'Get monthly fuel consumption' })
|
||||
async getMonthlyConsumption(
|
||||
@Param('vehicleId') vehicleId: string,
|
||||
@@ -44,12 +59,14 @@ export class FuelController {
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
@BookingStaff(FUEL_STATS_PERMS)
|
||||
@ApiOperation({ summary: 'Get fleet-wide fuel statistics' })
|
||||
async getFleetFuelStats(@Query('months') months: number = 12) {
|
||||
return this.fuelService.getFleetFuelStats(months);
|
||||
}
|
||||
|
||||
@Get('stats/:vehicleId')
|
||||
@BookingStaff(FUEL_STATS_PERMS)
|
||||
@ApiOperation({ summary: 'Get fuel statistics for vehicle' })
|
||||
async getVehicleFuelStats(
|
||||
@Param('vehicleId') vehicleId: string,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<GpsDevice> {
|
||||
constructor(
|
||||
@InjectRepository(GpsDevice) repository: Repository<GpsDevice>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByImei(imei: string): Promise<GpsDevice | null> {
|
||||
return this.repository.findOne({ where: { imei } });
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GpsPositionRepository extends BaseRepository<GpsPosition> {
|
||||
constructor(
|
||||
@InjectRepository(GpsPosition) repository: Repository<GpsPosition>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -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<GpsDevice> {
|
||||
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<void> {
|
||||
const device = await this.ensureDevice(imei);
|
||||
await this.devices.update(device.id, { lastSeenAt: new Date(), status: 'ONLINE' });
|
||||
}
|
||||
|
||||
async handleHeartbeat(imei: string, status: Gt06Status): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.devices.softDelete(id);
|
||||
}
|
||||
}
|
||||
207
apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts
Normal file
207
apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts
Normal file
@@ -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]),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
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<net.Socket, Session>();
|
||||
|
||||
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;
|
||||
}
|
||||
const host = process.env.GT06_TCP_HOST ?? '0.0.0.0';
|
||||
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, host, () => this.logger.log(`GT06 GPS tracker listener on ${host}:${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<void> {
|
||||
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<typeof parseStream>['packets'][number],
|
||||
): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<Incident> {
|
||||
constructor(
|
||||
@InjectRepository(Incident)
|
||||
incidentRepository: Repository<Incident>,
|
||||
) {
|
||||
super(incidentRepository);
|
||||
}
|
||||
}
|
||||
@@ -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<string, number>;
|
||||
lastIncidentAt: Date | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IncidentsService {
|
||||
constructor(private readonly incidentsRepository: IncidentsRepository) {}
|
||||
|
||||
async create(dto: CreateIncidentDto): Promise<Incident> {
|
||||
return this.incidentsRepository.create({
|
||||
...dto,
|
||||
occurredAt: new Date(dto.occurredAt),
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(filter: IncidentFilter = {}): Promise<Incident[]> {
|
||||
const where: FindOptionsWhere<Incident> = {};
|
||||
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<Incident[]> {
|
||||
return this.incidentsRepository.findAll({
|
||||
where: { driverId },
|
||||
order: { occurredAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Incident> {
|
||||
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<Incident> {
|
||||
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<void> {
|
||||
await this.findById(id);
|
||||
await this.incidentsRepository.softDelete(id);
|
||||
}
|
||||
|
||||
async statsForDriver(driverId: string): Promise<DriverIncidentStats> {
|
||||
const incidents = await this.incidentsRepository.findAll({
|
||||
where: { driverId },
|
||||
order: { occurredAt: 'DESC' },
|
||||
});
|
||||
|
||||
const byType: Record<string, number> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsISO8601, IsOptional } from 'class-validator';
|
||||
|
||||
import { CreateLastMileDto } from './create-last-mile.dto';
|
||||
|
||||
export class UpdateLastMileDto extends PartialType(CreateLastMileDto) {}
|
||||
export class UpdateLastMileDto extends PartialType(CreateLastMileDto) {
|
||||
/** Truck-detention clock start (vehicle arrived at destination). Overrides the auto-stamp. */
|
||||
@ApiPropertyOptional({ description: 'Vehicle arrival time (ISO 8601) — detention clock start.' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
arrivedAt?: string;
|
||||
|
||||
/** Truck-detention clock end (cargo cleared / vehicle returned). Overrides the auto-stamp. */
|
||||
@ApiPropertyOptional({ description: 'Delivery/return time (ISO 8601) — detention clock end.' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
deliveredAt?: string;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,15 @@ export class LastMile extends BaseEntity {
|
||||
@Column({ name: 'status', type: 'varchar', length: 30, default: 'PAYMENT_PENDING' })
|
||||
status!: LastMileStatus;
|
||||
|
||||
// Truck-detention window. arrivedAt = vehicle reached destination (IN_TRANSIT);
|
||||
// deliveredAt = cargo cleared / vehicle returned (DELIVERED). Detention accrues
|
||||
// between them beyond the rule's grace hours (default 3h), per truck per day.
|
||||
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||
arrivedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
|
||||
deliveredAt?: Date | null;
|
||||
|
||||
@Column({ name: 'advanced_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
advancedPayment!: number;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||
@@ -27,7 +28,7 @@ import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||
@ApiTags('last-mile')
|
||||
@ApiBearerAuth()
|
||||
@Controller('last-mile')
|
||||
@TrainSchedulingView()
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.view)
|
||||
export class LastMileController {
|
||||
constructor(
|
||||
private readonly lastMileService: LastMileService,
|
||||
@@ -63,21 +64,21 @@ export class LastMileController {
|
||||
}
|
||||
|
||||
@Post('accept/:reference')
|
||||
@TrainSchedulingManage()
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
|
||||
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
|
||||
acceptBooking(@Param('reference') reference: string) {
|
||||
return this.lastMileService.acceptBookingByReference(reference);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@TrainSchedulingManage()
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.create)
|
||||
@ApiOperation({ summary: 'Create a last-mile leg' })
|
||||
create(@Body() dto: CreateLastMileDto) {
|
||||
return this.lastMileService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@TrainSchedulingManage()
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
||||
@ApiOperation({ summary: 'Update a last-mile leg' })
|
||||
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
|
||||
// No invoice side-effects here — invoices are generated only via the
|
||||
@@ -86,7 +87,7 @@ export class LastMileController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@TrainSchedulingManage()
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.delete)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a last-mile leg' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@@ -95,7 +96,7 @@ export class LastMileController {
|
||||
|
||||
|
||||
@Post(':id/vehicles')
|
||||
@TrainSchedulingManage()
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.assignVehicles)
|
||||
@ApiOperation({ summary: 'Set the vehicles assigned to a last-mile delivery (multi-truck)' })
|
||||
async setVehicles(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -105,7 +106,7 @@ export class LastMileController {
|
||||
}
|
||||
|
||||
@Post(':id/distances')
|
||||
@TrainSchedulingManage()
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.setDistances)
|
||||
@ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' })
|
||||
async setDistances(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -115,7 +116,7 @@ export class LastMileController {
|
||||
}
|
||||
|
||||
@Post(':id/invoice')
|
||||
@TrainSchedulingManage()
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.generateInvoice)
|
||||
@ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' })
|
||||
async generateInvoice(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const record = await this.lastMileService.findById(id);
|
||||
|
||||
@@ -282,6 +282,17 @@ export class LastMileService {
|
||||
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
|
||||
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
|
||||
...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
|
||||
// Truck-detention clock: stamp arrival when the vehicle goes IN_TRANSIT and
|
||||
// delivery when it reaches DELIVERED (first time only). Explicit dto values
|
||||
// below override the auto-stamp so staff can record the real times.
|
||||
...(dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT' && !existing.arrivedAt
|
||||
? { arrivedAt: new Date() }
|
||||
: {}),
|
||||
...(dto.status === 'DELIVERED' && existing.status !== 'DELIVERED' && !existing.deliveredAt
|
||||
? { deliveredAt: new Date() }
|
||||
: {}),
|
||||
...(dtoAny.arrivedAt !== undefined ? { arrivedAt: dtoAny.arrivedAt ? new Date(dtoAny.arrivedAt) : null } : {}),
|
||||
...(dtoAny.deliveredAt !== undefined ? { deliveredAt: dtoAny.deliveredAt ? new Date(dtoAny.deliveredAt) : null } : {}),
|
||||
} as any);
|
||||
|
||||
if (!updated) {
|
||||
@@ -510,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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<WorkOrder> {
|
||||
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<WorkOrder> {
|
||||
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<WorkOrder> {
|
||||
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<Part> {
|
||||
return this.partRepository.create({ ...dto });
|
||||
}
|
||||
|
||||
async findParts(filters: { category?: string; lowStock?: boolean }) {
|
||||
return this.partRepository.findFiltered(filters);
|
||||
}
|
||||
|
||||
async updatePart(id: string, dto: UpdatePartDto): Promise<Part> {
|
||||
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<Warranty> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,173 @@
|
||||
import { Controller, Post, Get, Patch, Body, Param } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
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')
|
||||
@ApiBearerAuth()
|
||||
@Controller('maintenance')
|
||||
export class MaintenanceController {
|
||||
constructor(private readonly maintenanceService: MaintenanceService) {}
|
||||
constructor(
|
||||
private readonly maintenanceService: MaintenanceService,
|
||||
private readonly maintenanceDepthService: MaintenanceDepthService,
|
||||
) {}
|
||||
|
||||
@Post('schedules')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.create)
|
||||
@ApiOperation({ summary: 'Schedule maintenance' })
|
||||
async scheduleMaintenanceAsync(@Body() dto: CreateMaintenanceScheduleDto) {
|
||||
return this.maintenanceService.scheduleMaintenanceAsync(dto);
|
||||
}
|
||||
|
||||
@Post('costs')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.create)
|
||||
@ApiOperation({ summary: 'Record maintenance cost' })
|
||||
async recordCost(@Body() dto: CreateMaintenanceCostDto) {
|
||||
return this.maintenanceService.recordMaintenanceCost(dto);
|
||||
}
|
||||
|
||||
@Patch('schedules/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.update)
|
||||
@ApiOperation({ summary: 'Update maintenance schedule' })
|
||||
async updateSchedule(@Param('id') id: string, @Body() dto: UpdateMaintenanceScheduleDto) {
|
||||
return this.maintenanceService.updateMaintenanceSchedule(id, dto);
|
||||
}
|
||||
|
||||
@Get('upcoming/:vehicleId')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||
@ApiOperation({ summary: 'Get upcoming maintenance' })
|
||||
async getUpcoming(@Param('vehicleId') vehicleId: string) {
|
||||
return this.maintenanceService.getUpcomingMaintenance(vehicleId);
|
||||
}
|
||||
|
||||
@Get('history/:vehicleId')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||
@ApiOperation({ summary: 'Get maintenance history' })
|
||||
async getHistory(@Param('vehicleId') vehicleId: string) {
|
||||
return this.maintenanceService.getMaintenanceHistory(vehicleId);
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
@BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetReports.view, FREIGHT_PERMS.fleetDashboard.view])
|
||||
@ApiOperation({ summary: 'Get fleet-wide maintenance statistics' })
|
||||
async getFleetStats() {
|
||||
return this.maintenanceService.getFleetMaintenanceStats();
|
||||
}
|
||||
|
||||
@Get('stats/:vehicleId')
|
||||
@BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetReports.view, FREIGHT_PERMS.fleetDashboard.view])
|
||||
@ApiOperation({ summary: 'Get maintenance statistics' })
|
||||
async getStats(@Param('vehicleId') vehicleId: string) {
|
||||
return this.maintenanceService.getVehicleMaintenanceStats(vehicleId);
|
||||
}
|
||||
|
||||
// ---- Work Orders ----
|
||||
|
||||
@Post('work-orders')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.create)
|
||||
@ApiOperation({ summary: 'Create work order' })
|
||||
async createWorkOrder(@Body() dto: CreateWorkOrderDto) {
|
||||
return this.maintenanceDepthService.createWorkOrder(dto);
|
||||
}
|
||||
|
||||
@Get('work-orders')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||
@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')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||
@ApiOperation({ summary: 'Get work order' })
|
||||
async getWorkOrder(@Param('id') id: string) {
|
||||
return this.maintenanceDepthService.findWorkOrderById(id);
|
||||
}
|
||||
|
||||
@Patch('work-orders/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.update)
|
||||
@ApiOperation({ summary: 'Update work order' })
|
||||
async updateWorkOrder(@Param('id') id: string, @Body() dto: UpdateWorkOrderDto) {
|
||||
return this.maintenanceDepthService.updateWorkOrder(id, dto);
|
||||
}
|
||||
|
||||
@Delete('work-orders/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
|
||||
@ApiOperation({ summary: 'Delete work order' })
|
||||
async deleteWorkOrder(@Param('id') id: string) {
|
||||
return this.maintenanceDepthService.deleteWorkOrder(id);
|
||||
}
|
||||
|
||||
// ---- Parts / Tires ----
|
||||
|
||||
@Post('parts')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.create)
|
||||
@ApiOperation({ summary: 'Create part' })
|
||||
async createPart(@Body() dto: CreatePartDto) {
|
||||
return this.maintenanceDepthService.createPart(dto);
|
||||
}
|
||||
|
||||
@Get('parts')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||
@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')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.update)
|
||||
@ApiOperation({ summary: 'Update part' })
|
||||
async updatePart(@Param('id') id: string, @Body() dto: UpdatePartDto) {
|
||||
return this.maintenanceDepthService.updatePart(id, dto);
|
||||
}
|
||||
|
||||
@Delete('parts/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
|
||||
@ApiOperation({ summary: 'Delete part' })
|
||||
async deletePart(@Param('id') id: string) {
|
||||
return this.maintenanceDepthService.deletePart(id);
|
||||
}
|
||||
|
||||
// ---- Warranties ----
|
||||
|
||||
@Post('warranties')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.create)
|
||||
@ApiOperation({ summary: 'Create warranty' })
|
||||
async createWarranty(@Body() dto: CreateWarrantyDto) {
|
||||
return this.maintenanceDepthService.createWarranty(dto);
|
||||
}
|
||||
|
||||
@Get('warranties')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||
@ApiOperation({ summary: 'List warranties' })
|
||||
async listWarranties(@Query('vehicleId') vehicleId?: string) {
|
||||
return this.maintenanceDepthService.findWarranties({ vehicleId });
|
||||
}
|
||||
|
||||
@Delete('warranties/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
|
||||
@ApiOperation({ summary: 'Delete warranty' })
|
||||
async deleteWarranty(@Param('id') id: string) {
|
||||
return this.maintenanceDepthService.deleteWarranty(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<Part> {
|
||||
constructor(
|
||||
@InjectRepository(Part)
|
||||
private readonly partRepository: Repository<Part>,
|
||||
) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<Warranty> {
|
||||
constructor(
|
||||
@InjectRepository(Warranty)
|
||||
private readonly warrantyRepository: Repository<Warranty>,
|
||||
) {
|
||||
super(warrantyRepository);
|
||||
}
|
||||
|
||||
async findFiltered(filters: { vehicleId?: string }) {
|
||||
const where: FindOptionsWhere<Warranty> = {};
|
||||
if (filters.vehicleId) where.vehicleId = filters.vehicleId;
|
||||
return this.warrantyRepository.find({
|
||||
where,
|
||||
order: { expiryDate: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<WorkOrder> {
|
||||
constructor(
|
||||
@InjectRepository(WorkOrder)
|
||||
private readonly workOrderRepository: Repository<WorkOrder>,
|
||||
) {
|
||||
super(workOrderRepository);
|
||||
}
|
||||
|
||||
async findFiltered(filters: { vehicleId?: string; status?: WorkOrderStatus }) {
|
||||
const where: FindOptionsWhere<WorkOrder> = {};
|
||||
if (filters.vehicleId) where.vehicleId = filters.vehicleId;
|
||||
if (filters.status) where.status = filters.status;
|
||||
return this.workOrderRepository.find({
|
||||
where,
|
||||
order: { openedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,18 @@ export class NotificationRecipientsService {
|
||||
}
|
||||
}
|
||||
|
||||
if (recipients.allBackoffice) {
|
||||
try {
|
||||
for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) {
|
||||
ids.add(uid);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to resolve allBackoffice recipients: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user