mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice
This commit is contained in:
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/supertest": "^6.0.2",
|
||||||
"@types/vorpal": "^1.12.8",
|
"@types/vorpal": "^1.12.8",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-loader": "^9.5.1",
|
"ts-loader": "^9.5.1",
|
||||||
|
|||||||
@@ -1,20 +1,33 @@
|
|||||||
import type { ScheduleTradeDirection } from '@edr/types';
|
import { YardCountry, type ScheduleTradeDirection } from '@edr/types';
|
||||||
|
|
||||||
type YardLike = { country?: string | null };
|
type YardLike = { country?: string | null };
|
||||||
|
|
||||||
/** Derive booking/schedule trade direction from origin and destination yard countries. */
|
/**
|
||||||
|
* Derive trade direction from origin and destination yard countries.
|
||||||
|
* Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT, same country =
|
||||||
|
* DOMESTIC (shown as "Intercity"; scheduling/contracts reject it for now).
|
||||||
|
* Comparison is strict against the YardCountry enum values the yards table is
|
||||||
|
* constrained to; the trim/case fold only shields legacy rows.
|
||||||
|
*/
|
||||||
export function deriveTradeDirection(
|
export function deriveTradeDirection(
|
||||||
originYard: YardLike,
|
originYard: YardLike,
|
||||||
destinationYard: YardLike,
|
destinationYard: YardLike,
|
||||||
): ScheduleTradeDirection {
|
): ScheduleTradeDirection {
|
||||||
const originCountry = originYard.country?.trim().toLowerCase();
|
const origin = normalizeCountry(originYard.country);
|
||||||
const destinationCountry = destinationYard.country?.trim().toLowerCase();
|
const destination = normalizeCountry(destinationYard.country);
|
||||||
|
|
||||||
if (originCountry === 'djibouti') {
|
if (origin === YardCountry.DJIBOUTI && destination === YardCountry.ETHIOPIA) {
|
||||||
return 'IMPORT';
|
return 'IMPORT';
|
||||||
}
|
}
|
||||||
if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') {
|
if (origin === YardCountry.ETHIOPIA && destination === YardCountry.DJIBOUTI) {
|
||||||
return 'EXPORT';
|
return 'EXPORT';
|
||||||
}
|
}
|
||||||
return 'DOMESTIC';
|
return 'DOMESTIC';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCountry(country: string | null | undefined): YardCountry | null {
|
||||||
|
const folded = country?.trim().toLowerCase();
|
||||||
|
if (folded === YardCountry.ETHIOPIA.toLowerCase()) return YardCountry.ETHIOPIA;
|
||||||
|
if (folded === YardCountry.DJIBOUTI.toLowerCase()) return YardCountry.DJIBOUTI;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,9 +105,14 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
|
|||||||
password: process.env.DB_PASSWORD ?? "",
|
password: process.env.DB_PASSWORD ?? "",
|
||||||
database: process.env.DB_NAME ?? "edr_freight",
|
database: process.env.DB_NAME ?? "edr_freight",
|
||||||
schema: "public",
|
schema: "public",
|
||||||
extra: {
|
// The `-c search_path=...` startup option is rejected by transaction-pooling
|
||||||
options: `-c search_path=${APPLICATION_SEARCH_PATH}`,
|
// poolers (e.g. PgBouncer: "unsupported startup parameter in options"). When
|
||||||
},
|
// behind such a pooler set DB_PGBOUNCER=true and instead make the search_path
|
||||||
|
// a role default: ALTER ROLE <user> IN DATABASE <db> SET search_path TO
|
||||||
|
// public,iam,freight,audit;
|
||||||
|
...(process.env.DB_PGBOUNCER === "true"
|
||||||
|
? {}
|
||||||
|
: { extra: { options: `-c search_path=${APPLICATION_SEARCH_PATH}` } }),
|
||||||
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
|
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
|
||||||
autoLoadEntities: true,
|
autoLoadEntities: true,
|
||||||
migrations: [
|
migrations: [
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured import handover records. Replaces the ad-hoc handover notes so a
|
||||||
|
* booking can carry one handover (single truck) or several (one per truck when
|
||||||
|
* multiple trucks are used). Timing differs by mile type:
|
||||||
|
* - SELF_HAUL: generated on first truck arrival, signed before the truck leaves.
|
||||||
|
* - EDR_LAST_MILE: generated at delivery (after exit); signed on delivery.
|
||||||
|
*/
|
||||||
|
export class AddBookingHandovers1980000000000 implements MigrationInterface {
|
||||||
|
name = 'AddBookingHandovers1980000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.booking_handovers (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||||
|
truck_assignment_id uuid REFERENCES freight.customer_truck_assignments(id) ON DELETE SET NULL,
|
||||||
|
truck_plate varchar(32),
|
||||||
|
mile_type varchar(20) NOT NULL,
|
||||||
|
reference varchar(100) NOT NULL,
|
||||||
|
generated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
signed_at timestamptz,
|
||||||
|
signed_by_user_id uuid,
|
||||||
|
delivered_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_booking_handovers_booking" ON freight.booking_handovers (booking_id);`,
|
||||||
|
);
|
||||||
|
// At most one live handover per (booking, customer truck). EDR trucks (which
|
||||||
|
// aren't customer_truck_assignments) and per-booking handovers are de-duped
|
||||||
|
// in the service, since a NULL truck_assignment_id can't be uniquely indexed.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_truck"
|
||||||
|
ON freight.booking_handovers (booking_id, truck_assignment_id)
|
||||||
|
WHERE deleted_at IS NULL AND truck_assignment_id IS NOT NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_handovers;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,11 @@ import { hashPassword } from "@tria-plc/api-common/utils/argon";
|
|||||||
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
|
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
|
||||||
import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm";
|
import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm";
|
||||||
|
|
||||||
import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common";
|
// Subpath imports (not the package root) so ts-jest can resolve them when this
|
||||||
|
// file lands in a spec's compile graph via the notification recipients chain.
|
||||||
|
import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity";
|
||||||
|
import { Organization } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization.entity";
|
||||||
|
import { UserCredential } from "@tria-plc/iamapi-common/entities/iam/user/user-credential.entity";
|
||||||
import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity";
|
import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity";
|
||||||
import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity";
|
import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity";
|
||||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
@@ -40,6 +44,21 @@ export class BackofficeService {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IAM user ids of every current employee across all organizations — used by
|
||||||
|
* the notification recipients resolver's `allBackoffice` selector.
|
||||||
|
*/
|
||||||
|
async getAllCurrentEmployeeUserIds(): Promise<string[]> {
|
||||||
|
const employees = await this.employeeRepository.find({
|
||||||
|
where: { isCurrent: true },
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
employees.map((e) => e.userId).filter((id): id is string => Boolean(id)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
async createOrganizationUser(
|
async createOrganizationUser(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
dto: CreateOrganizationUserDto,
|
dto: CreateOrganizationUserDto,
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { PdfRenderService } from "./pdf-render.service";
|
import { PdfRenderService } from "./pdf-render.service";
|
||||||
|
import {
|
||||||
|
PdfColor,
|
||||||
|
assembleSinglePagePdf,
|
||||||
|
lineOp,
|
||||||
|
rectOp,
|
||||||
|
sealOp,
|
||||||
|
textOp,
|
||||||
|
textOpRight,
|
||||||
|
wrapText,
|
||||||
|
} from "./styled-pdf.util";
|
||||||
|
|
||||||
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
||||||
|
|
||||||
@@ -62,10 +72,136 @@ export class InvoiceDocumentService {
|
|||||||
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
|
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
|
||||||
return {
|
return {
|
||||||
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
|
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
|
||||||
buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }),
|
buffer: await this.pdf.htmlToPdfBuffer(html, {
|
||||||
|
label: `${model.title} ${kindLabel}`,
|
||||||
|
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
|
||||||
|
// summary grid, line-item table, totals) from the model — not a flat
|
||||||
|
// plain-text dump — so it still reads as a proper invoice document.
|
||||||
|
fallback: () => this.buildFallbackPdf(model),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vector-drawn styled invoice/receipt used when headless Chromium is
|
||||||
|
* unavailable. Mirrors the HTML layout closely enough to pass as the same
|
||||||
|
* document. Single A4 page; long summaries / line lists are capped to fit.
|
||||||
|
*/
|
||||||
|
buildFallbackPdf(model: InvoiceDocumentModel): Buffer {
|
||||||
|
const currency = (cur?: string | null) =>
|
||||||
|
(cur ?? model.currency) === "ETB" ? "ETB" : (cur ?? model.currency);
|
||||||
|
const money = (amount: unknown, cur?: string | null) =>
|
||||||
|
`${Number(amount ?? 0).toLocaleString()} ${currency(cur)}`;
|
||||||
|
const date = (value: unknown) =>
|
||||||
|
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||||
|
|
||||||
|
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
|
||||||
|
const sealText =
|
||||||
|
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||||
|
const showCategory = Boolean(model.categoryHeader);
|
||||||
|
|
||||||
|
const ops: string[] = [];
|
||||||
|
|
||||||
|
// ── Header ────────────────────────────────────────────────────────────
|
||||||
|
ops.push(lineOp(36, 806, 559, 806, PdfColor.teal, 2.4));
|
||||||
|
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", 36, 790, 8.5, "F2", PdfColor.gray));
|
||||||
|
const titleSize = heading.length > 34 ? 18 : 22;
|
||||||
|
ops.push(textOp(heading, 36, 762, titleSize, "F2", PdfColor.dark));
|
||||||
|
|
||||||
|
ops.push(textOpRight("DOCUMENT NO.", 559, 792, 7.5, "F2", PdfColor.gray));
|
||||||
|
ops.push(textOpRight(model.documentNumber, 559, 776, 12, "F2", PdfColor.dark));
|
||||||
|
ops.push(textOpRight(`Issued ${date(model.issuedAt)}`, 559, 762, 8.5, "F1", PdfColor.gray));
|
||||||
|
ops.push(
|
||||||
|
textOpRight(
|
||||||
|
`Status ${model.status}`,
|
||||||
|
559,
|
||||||
|
748,
|
||||||
|
8.5,
|
||||||
|
"F1",
|
||||||
|
model.status === "PAID" ? PdfColor.teal : PdfColor.gray,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ops.push(lineOp(36, 736, 470, 736, PdfColor.line, 1));
|
||||||
|
|
||||||
|
// ── Seal ──────────────────────────────────────────────────────────────
|
||||||
|
ops.push(sealOp(516, 706, 27, sealText.split(/\s+/), PdfColor.teal));
|
||||||
|
|
||||||
|
// ── Summary grid (two columns) ────────────────────────────────────────
|
||||||
|
let y = 700;
|
||||||
|
const colX = [36, 300];
|
||||||
|
const colW = 250;
|
||||||
|
model.summary.slice(0, 16).forEach((row, i) => {
|
||||||
|
const x = colX[i % 2];
|
||||||
|
if (i % 2 === 0 && i > 0) y -= 27;
|
||||||
|
ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray));
|
||||||
|
ops.push(textOp(this.clip(row.value ?? "-", 44), x, y - 11, 9, "F2", PdfColor.dark));
|
||||||
|
ops.push(lineOp(x, y - 15, x + colW, y - 15, PdfColor.line, 0.6));
|
||||||
|
});
|
||||||
|
y -= 34;
|
||||||
|
|
||||||
|
// ── Line-item table ───────────────────────────────────────────────────
|
||||||
|
const qtyR = 402;
|
||||||
|
const rateR = 486;
|
||||||
|
const amtR = 555;
|
||||||
|
ops.push(rectOp(36, y - 18, 523, 18, PdfColor.shade, PdfColor.line, 0.7));
|
||||||
|
ops.push(textOp("DESCRIPTION", 40, y - 13, 8, "F2", PdfColor.gray));
|
||||||
|
if (showCategory) {
|
||||||
|
ops.push(textOp((model.categoryHeader ?? "").toUpperCase(), 250, y - 13, 8, "F2", PdfColor.gray));
|
||||||
|
}
|
||||||
|
ops.push(textOpRight("QTY", qtyR, y - 13, 8, "F2", PdfColor.gray));
|
||||||
|
ops.push(textOpRight("RATE", rateR, y - 13, 8, "F2", PdfColor.gray));
|
||||||
|
ops.push(textOpRight("AMOUNT", amtR, y - 13, 8, "F2", PdfColor.gray));
|
||||||
|
y -= 18;
|
||||||
|
|
||||||
|
const descChars = showCategory ? 44 : 66;
|
||||||
|
for (const item of model.lines) {
|
||||||
|
if (y < 190) break; // leave room for totals + footer
|
||||||
|
const descLines = wrapText(item.description ?? "-", descChars).slice(0, 2);
|
||||||
|
const rowH = Math.max(18, descLines.length * 10 + 8);
|
||||||
|
ops.push(rectOp(36, y - rowH, 523, rowH, "1 1 1", PdfColor.line, 0.6));
|
||||||
|
descLines.forEach((line, k) => {
|
||||||
|
ops.push(textOp(line, 40, y - 12 - k * 10, 8, "F1", PdfColor.dark));
|
||||||
|
});
|
||||||
|
if (showCategory) {
|
||||||
|
ops.push(textOp(this.clip((item.category ?? "").replace(/_/g, " "), 18), 250, y - 12, 8, "F1", PdfColor.dark));
|
||||||
|
}
|
||||||
|
ops.push(textOpRight(String(item.quantity ?? 0), qtyR, y - 12, 8, "F1", PdfColor.dark));
|
||||||
|
ops.push(textOpRight(money(item.unitRate, item.currency), rateR, y - 12, 8, "F1", PdfColor.dark));
|
||||||
|
ops.push(textOpRight(money(item.amount, item.currency), amtR, y - 12, 8, "F1", PdfColor.dark));
|
||||||
|
y -= rowH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Totals ────────────────────────────────────────────────────────────
|
||||||
|
let ty = y - 16;
|
||||||
|
for (const total of model.totals) {
|
||||||
|
if (ty < 88) break;
|
||||||
|
if (total.grand) {
|
||||||
|
ops.push(lineOp(315, ty + 5, 559, ty + 5, PdfColor.dark, 0.9));
|
||||||
|
ops.push(textOp(total.label, 320, ty - 9, 11, "F2", PdfColor.dark));
|
||||||
|
ops.push(textOpRight(money(total.amount), 555, ty - 9, 12, "F2", PdfColor.dark));
|
||||||
|
ty -= 24;
|
||||||
|
} else {
|
||||||
|
ops.push(textOp(total.label, 320, ty - 8, 9.5, "F1", PdfColor.gray));
|
||||||
|
ops.push(textOpRight(money(total.amount), 555, ty - 8, 10, "F1", PdfColor.dark));
|
||||||
|
ty -= 17;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer ────────────────────────────────────────────────────────────
|
||||||
|
ops.push(lineOp(36, 64, 250, 64, PdfColor.dark, 0.8));
|
||||||
|
ops.push(textOp("Prepared by EDR finance", 36, 52, 7.5, "F1", PdfColor.gray));
|
||||||
|
ops.push(lineOp(340, 64, 559, 64, PdfColor.dark, 0.8));
|
||||||
|
ops.push(textOp("Authorized seal / signature", 340, 52, 7.5, "F1", PdfColor.gray));
|
||||||
|
|
||||||
|
return assembleSinglePagePdf(ops);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Truncate to `max` chars with an ellipsis. */
|
||||||
|
private clip(value: string, max: number): string {
|
||||||
|
const text = String(value ?? "");
|
||||||
|
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
||||||
|
}
|
||||||
|
|
||||||
buildHtml(model: InvoiceDocumentModel): string {
|
buildHtml(model: InvoiceDocumentModel): string {
|
||||||
const esc = (value: unknown) =>
|
const esc = (value: unknown) =>
|
||||||
String(value ?? "-")
|
String(value ?? "-")
|
||||||
|
|||||||
@@ -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,
|
ruleEngineService as never,
|
||||||
{} as never, // pricingService
|
{} as never, // pricingService
|
||||||
{} as never, // contractService
|
{} as never, // contractService
|
||||||
{} as never, // invoiceService
|
|
||||||
{} as never, // filesService
|
{} as never, // filesService
|
||||||
{} as never, // fileUploadSettingsService
|
{} as never, // fileUploadSettingsService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never, // workflowService
|
||||||
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
accepted: jest.fn(),
|
||||||
|
approved: jest.fn(),
|
||||||
|
rejected: jest.fn(),
|
||||||
|
changesRequested: jest.fn(),
|
||||||
|
documentQueried: jest.fn(),
|
||||||
|
clearanceReady: jest.fn(),
|
||||||
|
operationChangesRequested: jest.fn(),
|
||||||
|
operationAccepted: jest.fn(),
|
||||||
|
inTransit: jest.fn(),
|
||||||
|
completed: jest.fn(),
|
||||||
|
cancelled: jest.fn(),
|
||||||
|
submittedToStaff: jest.fn(),
|
||||||
|
customerSignedToStaff: jest.fn(),
|
||||||
|
operationRequestedToStaff: jest.fn(),
|
||||||
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
|
} as never, // notifier
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, ruleEngineService };
|
return { service, bookingsRepository, ruleEngineService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,14 +41,32 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
{} as never, // ruleEngineService
|
{} as never, // ruleEngineService
|
||||||
{} as never, // pricingService
|
{} as never, // pricingService
|
||||||
{} as never, // contractService
|
{} as never, // contractService
|
||||||
{} as never, // invoiceService
|
|
||||||
filesService as never,
|
filesService as never,
|
||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never, // workflowService
|
||||||
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
accepted: jest.fn(),
|
||||||
|
approved: jest.fn(),
|
||||||
|
rejected: jest.fn(),
|
||||||
|
changesRequested: jest.fn(),
|
||||||
|
documentQueried: jest.fn(),
|
||||||
|
clearanceReady: jest.fn(),
|
||||||
|
operationChangesRequested: jest.fn(),
|
||||||
|
operationAccepted: jest.fn(),
|
||||||
|
inTransit: jest.fn(),
|
||||||
|
completed: jest.fn(),
|
||||||
|
cancelled: jest.fn(),
|
||||||
|
submittedToStaff: jest.fn(),
|
||||||
|
customerSignedToStaff: jest.fn(),
|
||||||
|
operationRequestedToStaff: jest.fn(),
|
||||||
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
|
} as never, // notifier
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
}
|
}
|
||||||
@@ -126,14 +144,32 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // invoiceService
|
|
||||||
filesService as never,
|
filesService as never,
|
||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never,
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never, // workflowService
|
||||||
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
accepted: jest.fn(),
|
||||||
|
approved: jest.fn(),
|
||||||
|
rejected: jest.fn(),
|
||||||
|
changesRequested: jest.fn(),
|
||||||
|
documentQueried: jest.fn(),
|
||||||
|
clearanceReady: jest.fn(),
|
||||||
|
operationChangesRequested: jest.fn(),
|
||||||
|
operationAccepted: jest.fn(),
|
||||||
|
inTransit: jest.fn(),
|
||||||
|
completed: jest.fn(),
|
||||||
|
cancelled: jest.fn(),
|
||||||
|
submittedToStaff: jest.fn(),
|
||||||
|
customerSignedToStaff: jest.fn(),
|
||||||
|
operationRequestedToStaff: jest.fn(),
|
||||||
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
|
} as never, // notifier
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
}
|
}
|
||||||
@@ -197,14 +233,32 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // invoiceService
|
|
||||||
filesService as never,
|
filesService as never,
|
||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never,
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never, // workflowService
|
||||||
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
accepted: jest.fn(),
|
||||||
|
approved: jest.fn(),
|
||||||
|
rejected: jest.fn(),
|
||||||
|
changesRequested: jest.fn(),
|
||||||
|
documentQueried: jest.fn(),
|
||||||
|
clearanceReady: jest.fn(),
|
||||||
|
operationChangesRequested: jest.fn(),
|
||||||
|
operationAccepted: jest.fn(),
|
||||||
|
inTransit: jest.fn(),
|
||||||
|
completed: jest.fn(),
|
||||||
|
cancelled: jest.fn(),
|
||||||
|
submittedToStaff: jest.fn(),
|
||||||
|
customerSignedToStaff: jest.fn(),
|
||||||
|
operationRequestedToStaff: jest.fn(),
|
||||||
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
|
} as never, // notifier
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, filesService };
|
return { service, bookingsRepository, filesService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ import { BookingTransitionService } from './booking-transition.service';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Operation-request review for general-contract drawdown orders:
|
* Operation-request review for general-contract drawdown orders:
|
||||||
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
|
* - ACCEPT a train order → FULLY_EXECUTED with the invoice ensured; import/
|
||||||
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
|
* domestic bookings wait for their booking-day window cycle (no immediate
|
||||||
|
* batch enqueue at accept time).
|
||||||
|
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, never enters the train batch.
|
||||||
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
|
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
|
||||||
*/
|
*/
|
||||||
describe('BookingTransitionService — operation review', () => {
|
describe('BookingTransitionService — operation review', () => {
|
||||||
function makeService(serviceTypeCode: string) {
|
function makeService(serviceTypeCode: string) {
|
||||||
const booking = {
|
const booking = {
|
||||||
id: 'b-1',
|
id: 'b-1',
|
||||||
|
reference: 'BKG-1',
|
||||||
status: 'OPERATION_REQUEST_PENDING',
|
status: 'OPERATION_REQUEST_PENDING',
|
||||||
originYardId: 'o-1',
|
originYardId: 'o-1',
|
||||||
destinationYardId: 'd-1',
|
destinationYardId: 'd-1',
|
||||||
@@ -26,6 +29,14 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
};
|
};
|
||||||
const bookingBatchService = {
|
const bookingBatchService = {
|
||||||
enqueueRouteDayProcessing: jest.fn(),
|
enqueueRouteDayProcessing: jest.fn(),
|
||||||
|
pickExportSchedule: jest.fn(),
|
||||||
|
acceptExportBooking: jest.fn(),
|
||||||
|
};
|
||||||
|
const invoiceService = {
|
||||||
|
ensureInvoiceForBooking: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ id: 'inv-1', invoiceNumber: 'INV-0001' }),
|
||||||
|
updateStatus: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
const service = new BookingTransitionService(
|
const service = new BookingTransitionService(
|
||||||
@@ -33,37 +44,60 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
{} as never, // ruleEngineService
|
{} as never, // ruleEngineService
|
||||||
{} as never, // pricingService
|
{} as never, // pricingService
|
||||||
{} as never, // contractService
|
{} as never, // contractService
|
||||||
{} as never, // invoiceService
|
|
||||||
{} as never, // filesService
|
{} as never, // filesService
|
||||||
{} as never, // fileUploadSettingsService
|
{} as never, // fileUploadSettingsService
|
||||||
bookingBatchService as never,
|
bookingBatchService as never,
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never, // workflowService
|
||||||
|
invoiceService as never,
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{
|
||||||
|
accepted: jest.fn(),
|
||||||
|
approved: jest.fn(),
|
||||||
|
rejected: jest.fn(),
|
||||||
|
changesRequested: jest.fn(),
|
||||||
|
documentQueried: jest.fn(),
|
||||||
|
clearanceReady: jest.fn(),
|
||||||
|
operationChangesRequested: jest.fn(),
|
||||||
|
operationAccepted: jest.fn(),
|
||||||
|
inTransit: jest.fn(),
|
||||||
|
completed: jest.fn(),
|
||||||
|
cancelled: jest.fn(),
|
||||||
|
submittedToStaff: jest.fn(),
|
||||||
|
customerSignedToStaff: jest.fn(),
|
||||||
|
operationRequestedToStaff: jest.fn(),
|
||||||
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
|
} as never, // notifier
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, bookingBatchService };
|
return { service, bookingsRepository, bookingBatchService, invoiceService };
|
||||||
}
|
}
|
||||||
|
|
||||||
it('ACCEPT of a train order → FULLY_EXECUTED and enqueues the batch pool', async () => {
|
it('ACCEPT of a train order → FULLY_EXECUTED, invoice ensured, batch waits for window cycle', async () => {
|
||||||
const { service, bookingsRepository, bookingBatchService } =
|
const { service, bookingsRepository, bookingBatchService, invoiceService } =
|
||||||
makeService('RAIL_CONTAINER');
|
makeService('RAIL_CONTAINER');
|
||||||
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
|
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
|
||||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||||
'b-1',
|
'b-1',
|
||||||
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
|
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
|
||||||
);
|
);
|
||||||
expect(bookingBatchService.enqueueRouteDayProcessing).toHaveBeenCalledTimes(1);
|
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
|
||||||
|
// Import/domestic train bookings are batched by the window cycle later —
|
||||||
|
// never enqueued directly at accept time.
|
||||||
|
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
|
||||||
|
expect(bookingBatchService.acceptExportBooking).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enqueue', async () => {
|
it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enter the batch', async () => {
|
||||||
const { service, bookingsRepository, bookingBatchService } =
|
const { service, bookingsRepository, bookingBatchService, invoiceService } =
|
||||||
makeService('ROAD_CONTAINER');
|
makeService('ROAD_CONTAINER');
|
||||||
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
|
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
|
||||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||||
'b-1',
|
'b-1',
|
||||||
expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }),
|
expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }),
|
||||||
);
|
);
|
||||||
|
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
|
||||||
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
|
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
|
Optional,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
|||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
|
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { ContainerValidationService } from './container-validation.service';
|
import { ContainerValidationService } from './container-validation.service';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
@@ -26,6 +28,7 @@ import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
|||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
||||||
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
||||||
import { ContractDocPhase } from '@edr/types';
|
import { ContractDocPhase } from '@edr/types';
|
||||||
|
|
||||||
@@ -53,7 +56,8 @@ export class BookingTransitionService {
|
|||||||
private readonly workflowService: ClearanceWorkflowService,
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly containerValidationService: ContainerValidationService,
|
private readonly containerValidationService: ContainerValidationService,
|
||||||
|
private readonly notifier: BookingLifecycleNotifierService,
|
||||||
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private isPhasedGeneralCustoms(booking: Booking): boolean {
|
private isPhasedGeneralCustoms(booking: Booking): boolean {
|
||||||
@@ -124,6 +128,9 @@ export class BookingTransitionService {
|
|||||||
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
||||||
updated!.id,
|
updated!.id,
|
||||||
);
|
);
|
||||||
|
if (finalBooking.status === "SUBMITTED") {
|
||||||
|
this.notifier.submittedToStaff(finalBooking);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
bookingId: finalBooking.id,
|
bookingId: finalBooking.id,
|
||||||
status: finalBooking.status,
|
status: finalBooking.status,
|
||||||
@@ -204,6 +211,9 @@ export class BookingTransitionService {
|
|||||||
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
|
||||||
updated!.id,
|
updated!.id,
|
||||||
);
|
);
|
||||||
|
if (finalBooking.status === "SUBMITTED") {
|
||||||
|
this.notifier.submittedToStaff(finalBooking);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
bookingId: finalBooking.id,
|
bookingId: finalBooking.id,
|
||||||
status: finalBooking.status,
|
status: finalBooking.status,
|
||||||
@@ -233,7 +243,9 @@ export class BookingTransitionService {
|
|||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: "CHANGES_REQUESTED",
|
status: "CHANGES_REQUESTED",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
|
this.notifier.changesRequested(fresh, note);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auto-create booking approval steps from system rules when none exist yet. */
|
/** Auto-create booking approval steps from system rules when none exist yet. */
|
||||||
@@ -284,7 +296,9 @@ export class BookingTransitionService {
|
|||||||
contractValidFrom: validFrom,
|
contractValidFrom: validFrom,
|
||||||
contractValidUntil: validUntil,
|
contractValidUntil: validUntil,
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
|
this.notifier.accepted(fresh);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async staffReject(
|
async staffReject(
|
||||||
@@ -305,7 +319,9 @@ export class BookingTransitionService {
|
|||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: "REJECTED",
|
status: "REJECTED",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
|
this.notifier.rejected(fresh, reason);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async approveStep(
|
async approveStep(
|
||||||
@@ -394,7 +410,9 @@ export class BookingTransitionService {
|
|||||||
|
|
||||||
if (allDone) {
|
if (allDone) {
|
||||||
const generated = await this.contractService.generateContract(bookingId);
|
const generated = await this.contractService.generateContract(bookingId);
|
||||||
return this.bookingsService.findById(generated.id);
|
const fresh = await this.bookingsService.findById(generated.id);
|
||||||
|
this.notifier.approved(fresh);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
@@ -435,7 +453,9 @@ export class BookingTransitionService {
|
|||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: "REJECTED",
|
status: "REJECTED",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
|
this.notifier.rejected(fresh, reason);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async customerSign(bookingId: string): Promise<Booking> {
|
async customerSign(bookingId: string): Promise<Booking> {
|
||||||
@@ -446,7 +466,9 @@ export class BookingTransitionService {
|
|||||||
status: "SIGNED_CUSTOMER",
|
status: "SIGNED_CUSTOMER",
|
||||||
customerSignedAt: new Date(),
|
customerSignedAt: new Date(),
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
|
this.notifier.customerSignedToStaff(fresh);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async startTransit(bookingId: string): Promise<Booking> {
|
async startTransit(bookingId: string): Promise<Booking> {
|
||||||
@@ -456,7 +478,9 @@ export class BookingTransitionService {
|
|||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: "IN_TRANSIT",
|
status: "IN_TRANSIT",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
|
this.notifier.inTransit(fresh);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async complete(bookingId: string): Promise<Booking> {
|
async complete(bookingId: string): Promise<Booking> {
|
||||||
@@ -467,7 +491,28 @@ export class BookingTransitionService {
|
|||||||
status: "COMPLETED",
|
status: "COMPLETED",
|
||||||
endDate: new Date(),
|
endDate: new Date(),
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
|
this.notifier.completed(fresh);
|
||||||
|
// Customer tracking: close out the tail milestones so a finished shipment
|
||||||
|
// never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
|
||||||
|
// implied by delivery; a storage invoice that was never raised is skipped
|
||||||
|
// (storage billing does not apply to every shipment). All doc-trigger /
|
||||||
|
// best-effort — a booking without milestone rows is untouched.
|
||||||
|
if (this.milestoneService) {
|
||||||
|
for (const code of ["IMPORT_PROCESS_COMPLETED", "EXIT_NOTE_GENERATED"]) {
|
||||||
|
try {
|
||||||
|
await this.milestoneService.completeByDocTrigger({ bookingId }, code);
|
||||||
|
} catch {
|
||||||
|
/* tracking must never block completion */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.milestoneService.skipForBooking(bookingId, "STORAGE_INVOICE_RAISED");
|
||||||
|
} catch {
|
||||||
|
/* no such milestone row (export / non-customs) — fine */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
||||||
@@ -491,7 +536,9 @@ export class BookingTransitionService {
|
|||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: "CANCELLED",
|
status: "CANCELLED",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
|
this.notifier.cancelled(fresh, reason);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -723,7 +770,9 @@ export class BookingTransitionService {
|
|||||||
} as never);
|
} as never);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.bookingsService.findById(bookingId);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
|
this.notifier.clearanceDocsUploadedToStaff(fresh);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -824,6 +873,9 @@ export class BookingTransitionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.bookingsService.findById(bookingId);
|
const updated = await this.bookingsService.findById(bookingId);
|
||||||
|
if (status === "QUERIED") {
|
||||||
|
this.notifier.documentQueried(updated, fileKey, note ?? '');
|
||||||
|
}
|
||||||
if (this.isPhasedGeneralCustoms(updated)) {
|
if (this.isPhasedGeneralCustoms(updated)) {
|
||||||
const allApproved = await this.isClearanceFullyApproved(updated);
|
const allApproved = await this.isClearanceFullyApproved(updated);
|
||||||
if (allApproved) {
|
if (allApproved) {
|
||||||
@@ -912,7 +964,9 @@ export class BookingTransitionService {
|
|||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: "CLEARANCE_READY",
|
status: "CLEARANCE_READY",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(bookingId);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
|
this.notifier.clearanceReady(fresh);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -957,7 +1011,9 @@ export class BookingTransitionService {
|
|||||||
status: "OPERATION_REQUEST_PENDING",
|
status: "OPERATION_REQUEST_PENDING",
|
||||||
scheduledDate: date,
|
scheduledDate: date,
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(bookingId);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
|
this.notifier.operationRequestedToStaff(fresh);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -992,7 +1048,9 @@ export class BookingTransitionService {
|
|||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: "OPERATION_CHANGES_REQUESTED",
|
status: "OPERATION_CHANGES_REQUESTED",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(bookingId);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
|
this.notifier.operationChangesRequested(fresh, options.note);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ACCEPT — enter the batch holding pool.
|
// ACCEPT — enter the batch holding pool.
|
||||||
@@ -1037,7 +1095,9 @@ export class BookingTransitionService {
|
|||||||
fullyExecutedAt: now,
|
fullyExecutedAt: now,
|
||||||
lockedAt: booking.lockedAt ?? now,
|
lockedAt: booking.lockedAt ?? now,
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(booking.id);
|
const roadFresh = await this.bookingsService.findById(booking.id);
|
||||||
|
this.notifier.operationAccepted(roadFresh);
|
||||||
|
return roadFresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
@@ -1072,7 +1132,9 @@ export class BookingTransitionService {
|
|||||||
// batch runs after the window closes + staff document review, never at accept
|
// batch runs after the window closes + staff document review, never at accept
|
||||||
// time. (Legacy pre-migration schedules with no window phase are still served
|
// time. (Legacy pre-migration schedules with no window phase are still served
|
||||||
// by the periodic legacy fill.)
|
// by the periodic legacy fill.)
|
||||||
return this.bookingsService.findById(booking.id);
|
const trainFresh = await this.bookingsService.findById(booking.id);
|
||||||
|
this.notifier.operationAccepted(trainFresh);
|
||||||
|
return trainFresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
async enrichBookingResponse(booking: Booking): Promise<
|
async enrichBookingResponse(booking: Booking): Promise<
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
import { BookingStaff } from '../../common/booking-guards';
|
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
|
import { BookingStaff, BookingView } from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||||
import {
|
import {
|
||||||
@@ -64,6 +66,7 @@ import { ContractViewDto } from './dto/contract-view.dto';
|
|||||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||||
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||||
|
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
|
||||||
import { CustomerTruckService } from './customer-truck.service';
|
import { CustomerTruckService } from './customer-truck.service';
|
||||||
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
||||||
import { ContainerReceiptService } from './container-receipt.service';
|
import { ContainerReceiptService } from './container-receipt.service';
|
||||||
@@ -194,6 +197,7 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("by-company/:companyId/customer-view")
|
@Get("by-company/:companyId/customer-view")
|
||||||
|
@BookingView()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List bookings for a company (customer-view shape, backoffice)",
|
summary: "List bookings for a company (customer-view shape, backoffice)",
|
||||||
})
|
})
|
||||||
@@ -204,6 +208,7 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("list-summary")
|
@Get("list-summary")
|
||||||
|
@BookingView()
|
||||||
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
|
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
|
||||||
@ApiOkResponse({ type: BookingListSummaryDto })
|
@ApiOkResponse({ type: BookingListSummaryDto })
|
||||||
findListSummary(@Query() filter: FilterBookingDto) {
|
findListSummary(@Query() filter: FilterBookingDto) {
|
||||||
@@ -225,6 +230,7 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("queues/:queue")
|
@Get("queues/:queue")
|
||||||
|
@BookingView()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List bookings for a dashboard queue",
|
summary: "List bookings for a dashboard queue",
|
||||||
description: "Queues: intake, approval, signatures, marketing, finance",
|
description: "Queues: intake, approval, signatures, marketing, finance",
|
||||||
@@ -358,6 +364,33 @@ export class BookingsController {
|
|||||||
return this.customerTruckService.removeTruck(id, assignmentId);
|
return this.customerTruckService.removeTruck(id, assignmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/customer-trucks/loadable-containers')
|
||||||
|
@ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' })
|
||||||
|
async loadableContainers(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsService.findById(id);
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||||
|
}
|
||||||
|
return this.customerTruckService.getLoadableContainers(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/customer-trucks/:assignmentId/load')
|
||||||
|
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
|
||||||
|
async loadCustomerTruck(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||||
|
@Body() dto: LoadCustomerTruckDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
throw new ForbiddenException('Only warehouse staff can load a truck');
|
||||||
|
}
|
||||||
|
return this.customerTruckService.loadTruck(id, assignmentId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/customer-trucks/:assignmentId/depart')
|
@Post(':id/customer-trucks/:assignmentId/depart')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
|
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
|
||||||
@@ -928,12 +961,18 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(":id/contract/sign")
|
@Post(":id/contract/sign")
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
|
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
|
||||||
async signContract(
|
async signContract(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: SignContractDto,
|
@Body() dto: SignContractDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
||||||
) {
|
) {
|
||||||
|
// Staff signature needs the sign permission; customer signs their own booking.
|
||||||
|
if (dto.role !== "CUSTOMER") {
|
||||||
|
assertFreightPermission(user, FREIGHT_PERMS.bookings.signStaff);
|
||||||
|
}
|
||||||
const userId = req.user?.id ?? req.user?.sub;
|
const userId = req.user?.id ?? req.user?.sub;
|
||||||
const booking = await this.contractService.signContract(id, dto, {
|
const booking = await this.contractService.signContract(id, dto, {
|
||||||
signerUserId: userId,
|
signerUserId: userId,
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import { BookingInvoiceService } from './booking-invoice.service';
|
|||||||
// import { BookingPaymentService } from './booking-payment.service';
|
// import { BookingPaymentService } from './booking-payment.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||||
|
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
|
||||||
import { BookingTransitionService } from './booking-transition.service';
|
import { BookingTransitionService } from './booking-transition.service';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
import { BookingsController } from './bookings.controller';
|
import { BookingsController } from './bookings.controller';
|
||||||
// import { PayController } from './pay.controller';
|
// import { PayController } from './pay.controller';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
@@ -64,6 +67,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
CustomerTruckContainer,
|
CustomerTruckContainer,
|
||||||
]),
|
]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
|
NotificationsModule,
|
||||||
|
NotificationInboxModule,
|
||||||
forwardRef(() => FirstMileModule),
|
forwardRef(() => FirstMileModule),
|
||||||
forwardRef(() => TrainSchedulingModule),
|
forwardRef(() => TrainSchedulingModule),
|
||||||
forwardRef(() => ContractsModule),
|
forwardRef(() => ContractsModule),
|
||||||
@@ -90,6 +95,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
ContainerValidationService,
|
ContainerValidationService,
|
||||||
BookingReferenceDataService,
|
BookingReferenceDataService,
|
||||||
BookingPricingService,
|
BookingPricingService,
|
||||||
|
BookingLifecycleNotifierService,
|
||||||
BookingTransitionService,
|
BookingTransitionService,
|
||||||
BookingContractService,
|
BookingContractService,
|
||||||
BookingInvoiceService,
|
BookingInvoiceService,
|
||||||
@@ -107,6 +113,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
BookingPricingService,
|
BookingPricingService,
|
||||||
BookingInvoiceService,
|
BookingInvoiceService,
|
||||||
|
BookingLifecycleNotifierService,
|
||||||
CustomerTruckService,
|
CustomerTruckService,
|
||||||
ContainerReceiptService,
|
ContainerReceiptService,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -176,6 +176,44 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve trade direction from yard countries; reject client mismatch. */
|
/** Resolve trade direction from yard countries; reject client mismatch. */
|
||||||
|
/**
|
||||||
|
* An intercity corridor is valid when both yards are Ethiopian and at least
|
||||||
|
* one non-retired route passes the origin strictly before the destination in
|
||||||
|
* its milestone order — that is the corridor an import/export train can
|
||||||
|
* serve the booking on.
|
||||||
|
*/
|
||||||
|
private async assertIntercityCorridorExists(
|
||||||
|
originYardId: string,
|
||||||
|
destinationYardId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const yards = await this.dataSource.getRepository(Yard).find({
|
||||||
|
where: { id: In([originYardId, destinationYardId]) },
|
||||||
|
});
|
||||||
|
if (yards.some((y) => y.country !== 'Ethiopia')) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Intercity bookings only run between Ethiopian yards',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rows: Array<{ id: string }> = await this.dataSource.query(
|
||||||
|
`SELECT r.id
|
||||||
|
FROM freight.routes r
|
||||||
|
JOIN freight.route_milestones mo
|
||||||
|
ON mo.route_id = r.id AND mo.yard_id = $1 AND mo.deleted_at IS NULL
|
||||||
|
JOIN freight.route_milestones md
|
||||||
|
ON md.route_id = r.id AND md.yard_id = $2 AND md.deleted_at IS NULL
|
||||||
|
WHERE mo.sequence_no < md.sequence_no
|
||||||
|
AND r.status = 'AVAILABLE'
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[originYardId, destinationYardId],
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'No route passes through this origin and destination in order — intercity service is not available on this corridor',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveTradeDirectionForBooking(
|
private async resolveTradeDirectionForBooking(
|
||||||
originYardId: string,
|
originYardId: string,
|
||||||
destinationYardId: string,
|
destinationYardId: string,
|
||||||
@@ -607,6 +645,23 @@ export class BookingsService {
|
|||||||
dto.tradeDirection,
|
dto.tradeDirection,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Intercity (DOMESTIC) bookings never get their own train — they ride on a
|
||||||
|
// passing import/export train, so there is no booking window and no date to
|
||||||
|
// pin. All we require at creation is that the corridor actually lies on a
|
||||||
|
// route (origin before destination in some route's milestone order); staff
|
||||||
|
// accept the booking onto a concrete train at finalize time.
|
||||||
|
if (tradeDirection === 'DOMESTIC') {
|
||||||
|
if (dto.scheduledDate || dto.trainScheduleId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Intercity bookings cannot pin a date or schedule — staff assign them to a passing train later',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.assertIntercityCorridorExists(
|
||||||
|
dto.originYardId,
|
||||||
|
dto.destinationYardId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Stamp the operational profile this booking belongs to (importer/exporter)
|
// Stamp the operational profile this booking belongs to (importer/exporter)
|
||||||
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
|
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
|
||||||
// for non-government bookings with a resolved company; never blocks creation.
|
// for non-government bookings with a resolved company; never blocks creation.
|
||||||
@@ -1333,6 +1388,17 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Surface the assigned train's operational status so the portal stepper
|
||||||
|
// can show the Arrival stage: the booking status stays IN_TRANSIT from
|
||||||
|
// dispatch until delivery, so arrival is only knowable from the schedule.
|
||||||
|
if (booking.trainScheduleId) {
|
||||||
|
const schedule = await this.dataSource
|
||||||
|
.getRepository(TrainSchedule)
|
||||||
|
.findOne({ where: { id: booking.trainScheduleId } });
|
||||||
|
(booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus =
|
||||||
|
schedule?.status ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
return booking;
|
return booking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export class ContainerReceiptService {
|
|||||||
SET received_to_port = true,
|
SET received_to_port = true,
|
||||||
received_at = COALESCE(bcu.received_at, NOW()),
|
received_at = COALESCE(bcu.received_at, NOW()),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
FROM freight.booking_containers bc,
|
FROM freight.booking_container bc,
|
||||||
freight.customer_truck_containers ctc
|
freight.customer_truck_containers ctc
|
||||||
WHERE bc.id = bcu.booking_container_id
|
WHERE bc.id = bcu.booking_container_id
|
||||||
AND bc.booking_id = $1
|
AND bc.booking_id = $1
|
||||||
@@ -60,7 +60,7 @@ export class ContainerReceiptService {
|
|||||||
bcu.received_at AS "receivedAt",
|
bcu.received_at AS "receivedAt",
|
||||||
bcu.grn_number AS "grnNumber"
|
bcu.grn_number AS "grnNumber"
|
||||||
FROM freight.booking_container_units bcu
|
FROM freight.booking_container_units bcu
|
||||||
JOIN freight.booking_containers bc
|
JOIN freight.booking_container bc
|
||||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
WHERE bc.booking_id = $1
|
WHERE bc.booking_id = $1
|
||||||
AND bcu.deleted_at IS NULL
|
AND bcu.deleted_at IS NULL
|
||||||
@@ -92,7 +92,7 @@ export class ContainerReceiptService {
|
|||||||
const pending: ReceivedUnitRow[] = await manager.query(
|
const pending: ReceivedUnitRow[] = await manager.query(
|
||||||
`SELECT bcu.id, bcu.container_number AS "containerNumber"
|
`SELECT bcu.id, bcu.container_number AS "containerNumber"
|
||||||
FROM freight.booking_container_units bcu
|
FROM freight.booking_container_units bcu
|
||||||
JOIN freight.booking_containers bc
|
JOIN freight.booking_container bc
|
||||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
WHERE bc.booking_id = $1
|
WHERE bc.booking_id = $1
|
||||||
AND bcu.deleted_at IS NULL
|
AND bcu.deleted_at IS NULL
|
||||||
@@ -109,7 +109,7 @@ export class ContainerReceiptService {
|
|||||||
const [{ batches }]: Array<{ batches: string }> = await manager.query(
|
const [{ batches }]: Array<{ batches: string }> = await manager.query(
|
||||||
`SELECT COUNT(DISTINCT bcu.grn_number) AS batches
|
`SELECT COUNT(DISTINCT bcu.grn_number) AS batches
|
||||||
FROM freight.booking_container_units bcu
|
FROM freight.booking_container_units bcu
|
||||||
JOIN freight.booking_containers bc
|
JOIN freight.booking_container bc
|
||||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`,
|
WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`,
|
||||||
[bookingId],
|
[bookingId],
|
||||||
@@ -129,7 +129,7 @@ export class ContainerReceiptService {
|
|||||||
const [{ remaining }]: Array<{ remaining: string }> = await manager.query(
|
const [{ remaining }]: Array<{ remaining: string }> = await manager.query(
|
||||||
`SELECT COUNT(*) AS remaining
|
`SELECT COUNT(*) AS remaining
|
||||||
FROM freight.booking_container_units bcu
|
FROM freight.booking_container_units bcu
|
||||||
JOIN freight.booking_containers bc
|
JOIN freight.booking_container bc
|
||||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`,
|
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`,
|
||||||
[bookingId],
|
[bookingId],
|
||||||
|
|||||||
@@ -198,6 +198,87 @@ export class CustomerTruckService {
|
|||||||
return this.listTrucks(bookingId);
|
return this.listTrucks(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Booking container numbers not yet loaded onto any truck. */
|
||||||
|
async getLoadableContainers(bookingId: string): Promise<string[]> {
|
||||||
|
const [all, assigned] = await Promise.all([
|
||||||
|
this.bookingContainerNumbers(bookingId),
|
||||||
|
this.assignedContainerNumbers(bookingId),
|
||||||
|
]);
|
||||||
|
const taken = new Set(assigned);
|
||||||
|
return all.filter((n) => !taken.has(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truck_dispatch (load): assign the selected containers to a truck after it has
|
||||||
|
* arrived, and set a provisional gross weight from their VGM. The truck is
|
||||||
|
* weighed for real on departure. Locked once the truck has left.
|
||||||
|
*/
|
||||||
|
async loadTruck(
|
||||||
|
bookingId: string,
|
||||||
|
assignmentId: string,
|
||||||
|
dto: { containerNumbers: string[] },
|
||||||
|
): Promise<CustomerTruckAssignment[]> {
|
||||||
|
await this.loadBookingGuard(bookingId);
|
||||||
|
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
|
||||||
|
if (!assignment || assignment.bookingId !== bookingId) {
|
||||||
|
throw new NotFoundException('Truck assignment not found for this booking');
|
||||||
|
}
|
||||||
|
if (assignment.departedAt) {
|
||||||
|
throw new ConflictException('This truck has already left — its load is locked');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||||
|
if (!requested.length) {
|
||||||
|
throw new BadRequestException('Select at least one container to load onto the truck');
|
||||||
|
}
|
||||||
|
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||||
|
for (const n of requested) {
|
||||||
|
if (!bookingNumbers.includes(n)) {
|
||||||
|
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
|
||||||
|
for (const n of requested) {
|
||||||
|
if (elsewhere.includes(n)) {
|
||||||
|
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grossKg = await this.vgmKgForContainers(bookingId, requested);
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||||
|
await manager.getRepository(CustomerTruckContainer).save(
|
||||||
|
requested.map((containerNumber) =>
|
||||||
|
manager.getRepository(CustomerTruckContainer).create({
|
||||||
|
assignmentId,
|
||||||
|
bookingId,
|
||||||
|
containerNumber,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Provisional gross from the loaded containers' VGM — overridden by the
|
||||||
|
// weighed gross on departure.
|
||||||
|
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||||
|
grossWeightKg: grossKg,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return this.listTrucks(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise<number> {
|
||||||
|
const [row]: Array<{ kg: string }> = await this.dataSource.query(
|
||||||
|
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_container bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1
|
||||||
|
AND bcu.container_number = ANY($2::varchar[])
|
||||||
|
AND bcu.deleted_at IS NULL`,
|
||||||
|
[bookingId, numbers],
|
||||||
|
);
|
||||||
|
return Number(row?.kg ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark the truck carrying `containerNumber` as arrived. Called by the warehouse
|
* Mark the truck carrying `containerNumber` as arrived. Called by the warehouse
|
||||||
* receive flow. When every truck on the booking has arrived, the booking-level
|
* receive flow. When every truck on the booking has arrived, the booking-level
|
||||||
@@ -288,7 +369,7 @@ export class CustomerTruckService {
|
|||||||
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||||
`SELECT bcu.container_number AS "containerNumber"
|
`SELECT bcu.container_number AS "containerNumber"
|
||||||
FROM freight.booking_container_units bcu
|
FROM freight.booking_container_units bcu
|
||||||
JOIN freight.booking_containers bc
|
JOIN freight.booking_container bc
|
||||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
|
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
|
||||||
[bookingId],
|
[bookingId],
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -85,6 +85,13 @@ function makeService(overrides?: {
|
|||||||
milestoneService as never,
|
milestoneService as never,
|
||||||
dropdownSettingsService as never,
|
dropdownSettingsService as never,
|
||||||
glOperationsService as never,
|
glOperationsService as never,
|
||||||
|
{
|
||||||
|
dutyAdvised: jest.fn(),
|
||||||
|
clearanceReady: jest.fn(),
|
||||||
|
documentQueried: jest.fn(),
|
||||||
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
|
} as never, // notifier
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -115,12 +122,18 @@ describe('BookingClearanceService', () => {
|
|||||||
|
|
||||||
it('records duty advice when duty applies', async () => {
|
it('records duty advice when duty applies', async () => {
|
||||||
const { service, milestoneService } = makeService();
|
const { service, milestoneService } = makeService();
|
||||||
await service.adviseDuty('b-general', {
|
await service.adviseDuty(
|
||||||
dutyRequired: true,
|
'b-general',
|
||||||
amount: 1500,
|
{
|
||||||
currency: 'ETB',
|
dutyRequired: true,
|
||||||
declarationSerial: 'DS-1',
|
amount: 1500,
|
||||||
});
|
currency: 'ETB',
|
||||||
|
declarationSerial: 'DS-1',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
// The duty notice attachment is now mandatory when duty applies.
|
||||||
|
{ fieldname: 'duty_tax_notice' } as Express.Multer.File,
|
||||||
|
);
|
||||||
|
|
||||||
expect(milestoneService.adviseDuty).toHaveBeenCalledWith(
|
expect(milestoneService.adviseDuty).toHaveBeenCalledWith(
|
||||||
'b-general',
|
'b-general',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { FileUploadSettingsService } from '../file-upload-settings/file-upload-s
|
|||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { BookingsService } from '../bookings/bookings.service';
|
import { BookingsService } from '../bookings/bookings.service';
|
||||||
|
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||||
@@ -100,6 +101,7 @@ export class BookingClearanceService {
|
|||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||||
private readonly glOperationsService: GlOperationsService,
|
private readonly glOperationsService: GlOperationsService,
|
||||||
|
private readonly notifier: BookingLifecycleNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
|
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
|
||||||
@@ -174,7 +176,28 @@ export class BookingClearanceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allApproved = await this.isClearanceFullyApproved(booking);
|
const allApproved = await this.isClearanceFullyApproved(booking);
|
||||||
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
let milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
||||||
|
|
||||||
|
// Self-heal: a booking that has settled its freight payment must have
|
||||||
|
// FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
|
||||||
|
// export FCFS booking (linked to its train at booking time) paid via the
|
||||||
|
// prepaid invoice can leave the milestone PENDING — the clearance "Payment &
|
||||||
|
// wagon allocation" step then never ticks. Backfill it here so already-stuck
|
||||||
|
// rows recover without a migration; idempotent (no-op once COMPLETED).
|
||||||
|
const paymentSettled = milestones.find(
|
||||||
|
(m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
paymentSettled &&
|
||||||
|
paymentSettled.status === 'PENDING' &&
|
||||||
|
(booking.paymentStatus === 'PAID' || booking.status === 'PAID')
|
||||||
|
) {
|
||||||
|
await this.workflowService.completeMilestoneForBooking(
|
||||||
|
bookingId,
|
||||||
|
'FREIGHT_PAYMENT_SETTLED',
|
||||||
|
);
|
||||||
|
milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
||||||
|
}
|
||||||
const phase = this.workflowService.resolvePhaseForBooking(booking, milestones);
|
const phase = this.workflowService.resolvePhaseForBooking(booking, milestones);
|
||||||
const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones);
|
const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones);
|
||||||
const boundary = await this.workflowService.isBoundaryCompleteForBooking(
|
const boundary = await this.workflowService.isBoundaryCompleteForBooking(
|
||||||
@@ -414,6 +437,7 @@ export class BookingClearanceService {
|
|||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
@@ -441,6 +465,7 @@ export class BookingClearanceService {
|
|||||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
this.notifier.dutySlipUploadedToStaff(booking, 'first');
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { Freight } from '@edr/types';
|
|||||||
import { BookingRequestRepository } from './booking-request.repository';
|
import { BookingRequestRepository } from './booking-request.repository';
|
||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
import { ContractBookingService } from './contract-booking.service';
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { BookingRequest } from './entities/booking-request.entity';
|
import { BookingRequest } from './entities/booking-request.entity';
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { CreateBookingRequestDto } from './dto/create-booking-request.dto';
|
import { CreateBookingRequestDto } from './dto/create-booking-request.dto';
|
||||||
@@ -26,6 +27,7 @@ export class BookingRequestService {
|
|||||||
private readonly repo: BookingRequestRepository,
|
private readonly repo: BookingRequestRepository,
|
||||||
private readonly contractsService: ContractsService,
|
private readonly contractsService: ContractsService,
|
||||||
private readonly contractBookingService: ContractBookingService,
|
private readonly contractBookingService: ContractBookingService,
|
||||||
|
private readonly notifier: ContractNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
|
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
|
||||||
@@ -107,7 +109,7 @@ export class BookingRequestService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const reference = await this.generateReference();
|
const reference = await this.generateReference();
|
||||||
return this.repo.create({
|
const request = await this.repo.create({
|
||||||
reference,
|
reference,
|
||||||
contractId,
|
contractId,
|
||||||
requestedByUserId: userId ?? null,
|
requestedByUserId: userId ?? null,
|
||||||
@@ -117,6 +119,8 @@ export class BookingRequestService {
|
|||||||
requestedLines,
|
requestedLines,
|
||||||
notes: dto.notes ?? null,
|
notes: dto.notes ?? null,
|
||||||
} as never);
|
} as never);
|
||||||
|
this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference);
|
||||||
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
listForContract(contractId: string): Promise<BookingRequest[]> {
|
listForContract(contractId: string): Promise<BookingRequest[]> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
CustomsRiskLevel,
|
CustomsRiskLevel,
|
||||||
MilestoneMetadata,
|
MilestoneMetadata,
|
||||||
} from './entities/clearance-milestone.entity';
|
} from './entities/clearance-milestone.entity';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import {
|
import {
|
||||||
HANDOFF_MILESTONES,
|
HANDOFF_MILESTONES,
|
||||||
@@ -85,10 +86,36 @@ export class ClearanceMilestoneService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
|
async listForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
|
||||||
return this.repo.find({
|
const rows = await this.repo.find({
|
||||||
where: { bookingId },
|
where: { bookingId },
|
||||||
order: { sortOrder: 'ASC' },
|
order: { sortOrder: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Self-heal: a booking that has settled its freight payment must have
|
||||||
|
// FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
|
||||||
|
// export FCFS booking (linked to its train at booking time) paid via the
|
||||||
|
// prepaid invoice can leave the milestone PENDING — the clearance "Payment &
|
||||||
|
// wagon allocation" step then never ticks. getClearanceView backfills it, but
|
||||||
|
// the stepper reads its gating milestones straight from here, so heal here too.
|
||||||
|
// Idempotent (no-op once COMPLETED); recovers already-stuck rows with no migration.
|
||||||
|
const paymentSettled = rows.find(
|
||||||
|
(m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED',
|
||||||
|
);
|
||||||
|
if (paymentSettled && paymentSettled.status === 'PENDING') {
|
||||||
|
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||||
|
where: { id: bookingId },
|
||||||
|
select: { id: true, status: true, paymentStatus: true },
|
||||||
|
});
|
||||||
|
if (booking?.paymentStatus === 'PAID' || booking?.status === 'PAID') {
|
||||||
|
await this.completeForBooking(bookingId, 'FREIGHT_PAYMENT_SETTLED');
|
||||||
|
return this.repo.find({
|
||||||
|
where: { bookingId },
|
||||||
|
order: { sortOrder: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ function makeService(milestones: ClearanceMilestone[]) {
|
|||||||
contractsRepository as never,
|
contractsRepository as never,
|
||||||
milestoneService as never,
|
milestoneService as never,
|
||||||
bookingsRepository as never,
|
bookingsRepository as never,
|
||||||
|
{ clearanceReady: jest.fn() } as never, // notifier
|
||||||
);
|
);
|
||||||
return { service, milestoneService, contractsRepository, bookingsRepository };
|
return { service, milestoneService, contractsRepository, bookingsRepository };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Contract } from './entities/contract.entity';
|
|||||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
|
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import type { ClearanceMetaState } from './clearance-workflow.types';
|
import type { ClearanceMetaState } from './clearance-workflow.types';
|
||||||
import { metaFromBooking } from './clearance-workflow.types';
|
import { metaFromBooking } from './clearance-workflow.types';
|
||||||
@@ -34,6 +35,7 @@ export class ClearanceWorkflowService {
|
|||||||
private readonly contractsRepository: ContractsRepository,
|
private readonly contractsRepository: ContractsRepository,
|
||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly notifier: BookingLifecycleNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
boundaryMilestone(tradeDirection: string): string {
|
boundaryMilestone(tradeDirection: string): string {
|
||||||
@@ -264,6 +266,14 @@ export class ClearanceWorkflowService {
|
|||||||
status: 'CLEARANCE_READY',
|
status: 'CLEARANCE_READY',
|
||||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||||
} as never);
|
} as never);
|
||||||
|
// Tell the customer clearance is done and operation can be requested. Load
|
||||||
|
// failure only skips the notice — the status change above already committed.
|
||||||
|
try {
|
||||||
|
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||||
|
if (booking) this.notifier.clearanceReady(booking);
|
||||||
|
} catch {
|
||||||
|
/* notification is best-effort */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvePhase(
|
resolvePhase(
|
||||||
|
|||||||
@@ -119,12 +119,27 @@ export class ContractBookingService {
|
|||||||
const generalCustoms =
|
const generalCustoms =
|
||||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
||||||
|
|
||||||
|
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
|
||||||
|
// there is no window and no date — staff accept them onto a train at
|
||||||
|
// finalize time, so both the window gate and scheduledDate are skipped.
|
||||||
|
const isIntercity = contract.tradeDirection === 'DOMESTIC';
|
||||||
|
if (isIntercity && dto.scheduledDate) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Intercity bookings do not pick a date — staff assign them to a passing train',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Every other direction keeps the binding shipment day (the DTO field went
|
||||||
|
// optional only for intercity).
|
||||||
|
if (!isIntercity && !dto.scheduledDate) {
|
||||||
|
throw new BadRequestException('A binding shipment day is required');
|
||||||
|
}
|
||||||
|
|
||||||
// Booking-window gate (config-driven): an operations booking may only be
|
// Booking-window gate (config-driven): an operations booking may only be
|
||||||
// created while the route's booking window is open — import: the day's window
|
// created while the route's booking window is open — import: the day's window
|
||||||
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
|
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
|
||||||
// export: within exportBookingLeadHours of departure. Customs Path B bookings
|
// export: within exportBookingLeadHours of departure. Customs Path B bookings
|
||||||
// enter clearance first and are scheduled later, so they are not gated here.
|
// enter clearance first and are scheduled later, so they are not gated here.
|
||||||
if (!generalCustoms) {
|
if (!generalCustoms && !isIntercity) {
|
||||||
await this.trainSchedulingService.assertBookingWindowOpen({
|
await this.trainSchedulingService.assertBookingWindowOpen({
|
||||||
originYardId: route?.originYardId ?? null,
|
originYardId: route?.originYardId ?? null,
|
||||||
destinationYardId: route?.destinationYardId ?? null,
|
destinationYardId: route?.destinationYardId ?? null,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { BookingsService } from '../bookings/bookings.service';
|
|||||||
import { contractClearanceCodes } from './contract-clearance.util';
|
import { contractClearanceCodes } from './contract-clearance.util';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { GlOperationsService } from './gl-operations.service';
|
import { GlOperationsService } from './gl-operations.service';
|
||||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
@@ -118,6 +119,7 @@ export class ContractClearanceService {
|
|||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||||
private readonly glOperationsService: GlOperationsService,
|
private readonly glOperationsService: GlOperationsService,
|
||||||
|
private readonly notifier: ContractNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private isPhasedCustoms(contract: Contract): boolean {
|
private isPhasedCustoms(contract: Contract): boolean {
|
||||||
@@ -543,7 +545,9 @@ export class ContractClearanceService {
|
|||||||
await this.workflowService.onDocumentReviewReopened(contractId);
|
await this.workflowService.onDocumentReviewReopened(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.clearanceDocsUploadedToStaff(updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertRequiredInputsPresent(
|
private async assertRequiredInputsPresent(
|
||||||
@@ -674,6 +678,7 @@ export class ContractClearanceService {
|
|||||||
status: 'AWAITING_CLEARANCE_DOCUMENTS',
|
status: 'AWAITING_CLEARANCE_DOCUMENTS',
|
||||||
clearanceStatus: 'AWAITING_DOCUMENTS',
|
clearanceStatus: 'AWAITING_DOCUMENTS',
|
||||||
} as never);
|
} as never);
|
||||||
|
this.notifier.clearanceDocumentQueried(contract, fileKey, note ?? '');
|
||||||
if (cycle) {
|
if (cycle) {
|
||||||
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
||||||
}
|
}
|
||||||
@@ -1009,6 +1014,7 @@ export class ContractClearanceService {
|
|||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
this.notifier.dutyAdvised(contract, dto.amount, dto.currency ?? 'ETB');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
@@ -1045,7 +1051,9 @@ export class ContractClearanceService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.dutySlipUploadedToStaff(updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadTransitPermit(
|
async uploadTransitPermit(
|
||||||
@@ -1118,6 +1126,7 @@ export class ContractClearanceService {
|
|||||||
await this.workflowService.markReadyForBooking(contractId);
|
await this.workflowService.markReadyForBooking(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.notifier.preClearanceFinalized(contract);
|
||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { SignaturesService } from '../signatures/signatures.service';
|
||||||
import { OtpService } from '../otp/otp.service';
|
import { OtpService } from '../otp/otp.service';
|
||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
@@ -66,6 +67,7 @@ export class ContractTransitionService {
|
|||||||
private readonly pdfService: ContractPdfService,
|
private readonly pdfService: ContractPdfService,
|
||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
private readonly otpService: OtpService,
|
private readonly otpService: OtpService,
|
||||||
|
private readonly notifier: ContractNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||||
@@ -79,7 +81,9 @@ export class ContractTransitionService {
|
|||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'SUBMITTED',
|
status: 'SUBMITTED',
|
||||||
} as never);
|
} as never);
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.submittedToStaff(updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Confirm a price change before submit (mirrors booking confirm-submit). */
|
/** Confirm a price change before submit (mirrors booking confirm-submit). */
|
||||||
@@ -93,7 +97,9 @@ export class ContractTransitionService {
|
|||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'SUBMITTED',
|
status: 'SUBMITTED',
|
||||||
} as never);
|
} as never);
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.submittedToStaff(updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,7 +136,9 @@ export class ContractTransitionService {
|
|||||||
contractValidFrom: validFrom,
|
contractValidFrom: validFrom,
|
||||||
contractValidUntil: validUntil,
|
contractValidUntil: validUntil,
|
||||||
} as never);
|
} as never);
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.accepted(updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -218,7 +226,9 @@ export class ContractTransitionService {
|
|||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'CHANGES_REQUESTED',
|
status: 'CHANGES_REQUESTED',
|
||||||
} as never);
|
} as never);
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.changesRequested(updated, note);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
|
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
|
||||||
@@ -235,7 +245,9 @@ export class ContractTransitionService {
|
|||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'REJECTED',
|
status: 'REJECTED',
|
||||||
} as never);
|
} as never);
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.rejected(updated, reason);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Approve one approval step in sequence; → APPROVED when all complete. */
|
/** Approve one approval step in sequence; → APPROVED when all complete. */
|
||||||
@@ -297,7 +309,11 @@ export class ContractTransitionService {
|
|||||||
if (Object.keys(updates).length > 0) {
|
if (Object.keys(updates).length > 0) {
|
||||||
await this.contractsRepository.update(contractId, updates as never);
|
await this.contractsRepository.update(contractId, updates as never);
|
||||||
}
|
}
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
if (allDone) {
|
||||||
|
this.notifier.approved(updated);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -535,7 +551,9 @@ export class ContractTransitionService {
|
|||||||
customerSignedAt: new Date(),
|
customerSignedAt: new Date(),
|
||||||
} as never);
|
} as never);
|
||||||
await this.regenerateContractPdf(contractId, contract.reference);
|
await this.regenerateContractPdf(contractId, contract.reference);
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.customerSignedToStaff(updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.counterSign(contractId, dto, options);
|
return this.counterSign(contractId, dto, options);
|
||||||
@@ -605,7 +623,9 @@ export class ContractTransitionService {
|
|||||||
|
|
||||||
await this.contractsRepository.update(contractId, updates as never);
|
await this.contractsRepository.update(contractId, updates as never);
|
||||||
await this.regenerateContractPdf(contractId, contract.reference);
|
await this.regenerateContractPdf(contractId, contract.reference);
|
||||||
return this.contractsService.findById(contractId);
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.signedActive(updated);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
|
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import {
|
import {
|
||||||
@@ -242,6 +244,7 @@ export class ContractsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('list-summary')
|
@Get('list-summary')
|
||||||
|
@BookingStaff([FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.contracts.view])
|
||||||
@ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' })
|
@ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' })
|
||||||
@ApiOkResponse({ type: ContractListSummaryDto })
|
@ApiOkResponse({ type: ContractListSummaryDto })
|
||||||
findListSummary(@Query() filter: FilterContractDto) {
|
findListSummary(@Query() filter: FilterContractDto) {
|
||||||
@@ -449,14 +452,25 @@ export class ContractsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/contract/sign')
|
@Post(':id/contract/sign')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||||
signContract(
|
signContract(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: SignContractDto,
|
@Body() dto: SignContractDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
|
// Each staff signing role maps to the permission that step already requires;
|
||||||
|
// customers sign their own contract with no permission key.
|
||||||
|
const signRolePermission: Record<string, string> = {
|
||||||
|
STAFF: FREIGHT_PERMS.contracts.signStaff,
|
||||||
|
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||||
|
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||||
|
};
|
||||||
|
if (dto.role !== 'CUSTOMER') {
|
||||||
|
assertFreightPermission(user, signRolePermission[dto.role]);
|
||||||
|
}
|
||||||
return this.transitionService.sign(id, dto, {
|
return this.transitionService.sign(id, dto, {
|
||||||
signerUserId: user?.id ?? user?.sub,
|
signerUserId: user?.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se
|
|||||||
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
||||||
import { SignaturesModule } from '../signatures/signatures.module';
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { OtpModule } from '../otp/otp.module';
|
import { OtpModule } from '../otp/otp.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ import { ContractsController } from './contracts.controller';
|
|||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { ContractTransitionService } from './contract-transition.service';
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
import { ContractClearanceService } from './contract-clearance.service';
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
import { BookingClearanceService } from './booking-clearance.service';
|
import { BookingClearanceService } from './booking-clearance.service';
|
||||||
@@ -75,6 +78,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
MinioModule,
|
MinioModule,
|
||||||
SignaturesModule,
|
SignaturesModule,
|
||||||
OtpModule,
|
OtpModule,
|
||||||
|
NotificationsModule,
|
||||||
|
NotificationInboxModule,
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||||
@@ -94,6 +99,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
ContractsService,
|
ContractsService,
|
||||||
ContractsRepository,
|
ContractsRepository,
|
||||||
ContractPricingService,
|
ContractPricingService,
|
||||||
|
ContractNotifierService,
|
||||||
ContractTransitionService,
|
ContractTransitionService,
|
||||||
ContractClearanceService,
|
ContractClearanceService,
|
||||||
ClearanceWorkflowService,
|
ClearanceWorkflowService,
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||||
|
|
||||||
|
import { YardCountry } from '@edr/types';
|
||||||
|
|
||||||
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
|
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
|
||||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
import { CompanyStatus } from '../companies/entities/company.entity';
|
||||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { MinioService } from '../minio/minio.service';
|
import { MinioService } from '../minio/minio.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
@@ -110,6 +114,49 @@ export class ContractsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every route must match the contract's declared trade direction as derived
|
||||||
|
* from the yard countries (IMPORT = DJ→ET, EXPORT = ET→DJ, DOMESTIC =
|
||||||
|
* intercity). Intercity is Ethiopian-domestic only: both yards must be in
|
||||||
|
* Ethiopia — a Djibouti-internal pair is rejected. Direction mismatches
|
||||||
|
* (e.g. an export lane on an import contract) are rejected for every kind.
|
||||||
|
*/
|
||||||
|
private async assertRoutesMatchDirection(
|
||||||
|
tradeDirection: string,
|
||||||
|
routes: CreateContractDto['routes'],
|
||||||
|
): Promise<void> {
|
||||||
|
const yardIds = [
|
||||||
|
...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])),
|
||||||
|
];
|
||||||
|
const yards = await this.dataSource
|
||||||
|
.getRepository(Yard)
|
||||||
|
.find({ where: yardIds.map((id) => ({ id })) });
|
||||||
|
const yardById = new Map(yards.map((y) => [y.id, y]));
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
const origin = yardById.get(route.originYardId);
|
||||||
|
const destination = yardById.get(route.destinationYardId);
|
||||||
|
if (!origin || !destination) {
|
||||||
|
throw new BadRequestException('Route references a yard that does not exist');
|
||||||
|
}
|
||||||
|
const derived = deriveTradeDirection(origin, destination);
|
||||||
|
if (derived !== tradeDirection) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Route ${origin.label} → ${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
derived === 'DOMESTIC' &&
|
||||||
|
(origin.country !== YardCountry.ETHIOPIA ||
|
||||||
|
destination.country !== YardCountry.ETHIOPIA)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Route ${origin.label} → ${destination.label}: intercity service only runs between Ethiopian yards`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
|
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
|
||||||
async create(
|
async create(
|
||||||
dto: CreateContractDto,
|
dto: CreateContractDto,
|
||||||
@@ -144,6 +191,7 @@ export class ContractsService {
|
|||||||
|
|
||||||
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
|
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
|
||||||
this.assertRouteShape(dto.contractKind, dto.routes);
|
this.assertRouteShape(dto.contractKind, dto.routes);
|
||||||
|
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
||||||
|
|
||||||
// Stamp the operational profile (importer/exporter) for portal scoping.
|
// Stamp the operational profile (importer/exporter) for portal scoping.
|
||||||
let companyProfileId: string | null = null;
|
let companyProfileId: string | null = null;
|
||||||
@@ -175,6 +223,13 @@ export class ContractsService {
|
|||||||
|
|
||||||
// Customs clearing is owned by the service type, not the customer.
|
// Customs clearing is owned by the service type, not the customer.
|
||||||
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
|
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
|
||||||
|
// Intercity never crosses a border, so a customs-including service type is
|
||||||
|
// a contradiction — the wizard hides them, the API enforces it.
|
||||||
|
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Intercity contracts cannot use a service type that includes customs clearing',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// An explicit reference is caller-chosen — a collision there is a real
|
// An explicit reference is caller-chosen — a collision there is a real
|
||||||
// conflict and should surface. Auto-generated references retry past a
|
// conflict and should surface. Auto-generated references retry past a
|
||||||
@@ -360,6 +415,12 @@ export class ContractsService {
|
|||||||
|
|
||||||
if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope);
|
if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope);
|
||||||
if (dto.routes) this.assertRouteShape(contractKind, dto.routes);
|
if (dto.routes) this.assertRouteShape(contractKind, dto.routes);
|
||||||
|
if (dto.routes) {
|
||||||
|
await this.assertRoutesMatchDirection(
|
||||||
|
dto.tradeDirection ?? existing.tradeDirection,
|
||||||
|
dto.routes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {
|
const updates: Record<string, unknown> = {
|
||||||
contractKind,
|
contractKind,
|
||||||
@@ -385,6 +446,11 @@ export class ContractsService {
|
|||||||
const includesCustoms = await this.resolveIncludesCustoms(
|
const includesCustoms = await this.resolveIncludesCustoms(
|
||||||
dto.serviceTypeId ?? existing.serviceTypeId,
|
dto.serviceTypeId ?? existing.serviceTypeId,
|
||||||
);
|
);
|
||||||
|
if ((dto.tradeDirection ?? existing.tradeDirection) === 'DOMESTIC' && includesCustoms) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Intercity contracts cannot use a service type that includes customs clearing',
|
||||||
|
);
|
||||||
|
}
|
||||||
updates.customsClearingEnabled = includesCustoms;
|
updates.customsClearingEnabled = includesCustoms;
|
||||||
updates.customsClearingAgent = includesCustoms
|
updates.customsClearingAgent = includesCustoms
|
||||||
? null
|
? null
|
||||||
@@ -501,6 +567,21 @@ export class ContractsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Surface the staff "request changes" note so the portal can show the
|
||||||
|
// customer what to fix. Degrade to null on lookup failure — a missing note
|
||||||
|
// must never 500 a contract fetch.
|
||||||
|
if (contract.status === 'CHANGES_REQUESTED') {
|
||||||
|
try {
|
||||||
|
const note = await this.contractsRepository.findLatestReviewNote(
|
||||||
|
contract.id,
|
||||||
|
'CHANGES_REQUESTED',
|
||||||
|
);
|
||||||
|
contract.latestChangeRequestNote = note?.body ?? null;
|
||||||
|
} catch {
|
||||||
|
contract.latestChangeRequestNote = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return contract;
|
return contract;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,9 +120,14 @@ export class CreateBookingUnderContractDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
contractRouteId?: string;
|
contractRouteId?: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Binding shipment day.', example: '2026-07-15' })
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.',
|
||||||
|
example: '2026-07-15',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduledDate!: string;
|
scheduledDate?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
|
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -260,4 +260,11 @@ export class Contract extends BaseEntity {
|
|||||||
* ContractsRepository.attachClearancePhases for list responses. Not a column.
|
* ContractsRepository.attachClearancePhases for list responses. Not a column.
|
||||||
*/
|
*/
|
||||||
clearancePhase?: string | null;
|
clearancePhase?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of the most recent CHANGES_REQUESTED review note, attached by
|
||||||
|
* ContractsService.findById so the portal can show the customer what staff
|
||||||
|
* asked them to fix. Lives in contract_review_notes, not a column here.
|
||||||
|
*/
|
||||||
|
latestChangeRequestNote?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { BillingService } from '../billing/billing.service';
|
|||||||
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
|
import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
|
||||||
import {
|
import {
|
||||||
@@ -53,6 +54,7 @@ export class GlOperationsService {
|
|||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
|
private readonly notifier: BookingLifecycleNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private get bookings() {
|
private get bookings() {
|
||||||
@@ -64,7 +66,11 @@ export class GlOperationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getBooking(bookingId: string): Promise<Booking> {
|
private async getBooking(bookingId: string): Promise<Booking> {
|
||||||
const booking = await this.bookings.findOne({ where: { id: bookingId } });
|
// company is loaded so customer notifications have a phone/email to target.
|
||||||
|
const booking = await this.bookings.findOne({
|
||||||
|
where: { id: bookingId },
|
||||||
|
relations: { company: true },
|
||||||
|
});
|
||||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
return booking;
|
return booking;
|
||||||
}
|
}
|
||||||
@@ -447,6 +453,7 @@ export class GlOperationsService {
|
|||||||
void userId;
|
void userId;
|
||||||
const summary = await this.finalInvoiceSummary(bookingId);
|
const summary = await this.finalInvoiceSummary(bookingId);
|
||||||
if (!summary) throw new NotFoundException('Final invoice could not be created.');
|
if (!summary) throw new NotFoundException('Final invoice could not be created.');
|
||||||
|
this.notifier.finalInvoiceCreated(booking, input.amount, input.currency);
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,7 +462,7 @@ export class GlOperationsService {
|
|||||||
bookingId: string,
|
bookingId: string,
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
): Promise<{ uploaded: boolean }> {
|
): Promise<{ uploaded: boolean }> {
|
||||||
await this.getBooking(bookingId);
|
const booking = await this.getBooking(bookingId);
|
||||||
if (!file) throw new BadRequestException('No payment slip uploaded');
|
if (!file) throw new BadRequestException('No payment slip uploaded');
|
||||||
|
|
||||||
const invoice = await this.billingService.findInvoice(
|
const invoice = await this.billingService.findInvoice(
|
||||||
@@ -482,6 +489,7 @@ export class GlOperationsService {
|
|||||||
code: 'final_invoice_slip',
|
code: 'final_invoice_slip',
|
||||||
file,
|
file,
|
||||||
});
|
});
|
||||||
|
this.notifier.dutySlipUploadedToStaff(booking, 'final');
|
||||||
return { uploaded: true };
|
return { uploaded: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,7 +498,7 @@ export class GlOperationsService {
|
|||||||
bookingId: string,
|
bookingId: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<Freight.ClearanceFinalInvoiceSummary> {
|
): Promise<Freight.ClearanceFinalInvoiceSummary> {
|
||||||
await this.getBooking(bookingId);
|
const booking = await this.getBooking(bookingId);
|
||||||
const invoice = await this.billingService.findInvoice(
|
const invoice = await this.billingService.findInvoice(
|
||||||
Freight.InvoiceSource.Booking,
|
Freight.InvoiceSource.Booking,
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -507,6 +515,7 @@ export class GlOperationsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.billingService.markInvoiceAsPaid(invoice.id);
|
await this.billingService.markInvoiceAsPaid(invoice.id);
|
||||||
|
this.notifier.finalInvoicePaid(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
void userId;
|
void userId;
|
||||||
@@ -575,6 +584,7 @@ export class GlOperationsService {
|
|||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
this.notifier.secondDutyAdvised(booking, input.amount, input.currency ?? 'ETB');
|
||||||
return { advised: true, skipped: false };
|
return { advised: true, skipped: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,6 +615,7 @@ export class GlOperationsService {
|
|||||||
booking.tradeDirection ?? 'IMPORT',
|
booking.tradeDirection ?? 'IMPORT',
|
||||||
);
|
);
|
||||||
await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID');
|
await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID');
|
||||||
|
this.notifier.dutySlipUploadedToStaff(booking, 'second');
|
||||||
return { milestoneCompleted: true };
|
return { milestoneCompleted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,18 @@ export class NotificationRecipientsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (recipients.allBackoffice) {
|
||||||
|
try {
|
||||||
|
for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) {
|
||||||
|
ids.add(uid);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to resolve allBackoffice recipients: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [...ids];
|
return [...ids];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import type { ScheduleTradeDirection } from '@edr/types';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
|
|
||||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
@@ -26,6 +27,14 @@ export class Route extends BaseEntity {
|
|||||||
@Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' })
|
@Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' })
|
||||||
status!: RouteStatus;
|
status!: RouteStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade direction frozen from the yard countries at create/update
|
||||||
|
* (ET→DJ = EXPORT, DJ→ET = IMPORT, same country = DOMESTIC/"Intercity").
|
||||||
|
* Consumers (scheduling, booking windows) read this instead of re-deriving.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'direction', type: 'varchar', length: 10 })
|
||||||
|
direction!: ScheduleTradeDirection;
|
||||||
|
|
||||||
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
|
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
|
||||||
milestones?: RouteMilestone[];
|
milestones?: RouteMilestone[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { CreateRouteDto } from './dto/create-route.dto';
|
import { CreateRouteDto } from './dto/create-route.dto';
|
||||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||||
@@ -83,6 +84,7 @@ export class RoutesService {
|
|||||||
originYardId: validated.originYardId,
|
originYardId: validated.originYardId,
|
||||||
destinationYardId: validated.destinationYardId,
|
destinationYardId: validated.destinationYardId,
|
||||||
status: dto.status ?? 'AVAILABLE',
|
status: dto.status ?? 'AVAILABLE',
|
||||||
|
direction: validated.direction,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -115,6 +117,7 @@ export class RoutesService {
|
|||||||
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
|
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
|
||||||
destinationYardId:
|
destinationYardId:
|
||||||
milestoneInput?.destinationYardId ?? existing.destinationYardId,
|
milestoneInput?.destinationYardId ?? existing.destinationYardId,
|
||||||
|
...(milestoneInput ? { direction: milestoneInput.direction } : {}),
|
||||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,9 +190,18 @@ export class RoutesService {
|
|||||||
throw new BadRequestException('Origin and destination yards must be different');
|
throw new BadRequestException('Origin and destination yards must be different');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const originYardId = normalized[0].yardId;
|
||||||
|
const destinationYardId = normalized[normalized.length - 1].yardId;
|
||||||
|
const yardById = new Map(yards.map((yard) => [yard.id, yard]));
|
||||||
|
const direction = deriveTradeDirection(
|
||||||
|
yardById.get(originYardId) ?? { country: null },
|
||||||
|
yardById.get(destinationYardId) ?? { country: null },
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
originYardId: normalized[0].yardId,
|
originYardId,
|
||||||
destinationYardId: normalized[normalized.length - 1].yardId,
|
destinationYardId,
|
||||||
|
direction,
|
||||||
milestones: normalized,
|
milestones: normalized,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { YardCountry } from '@edr/types';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
import { IsBoolean, IsEnum, IsInt, IsOptional, IsUUID, MaxLength, Min, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class CreateYardDto {
|
export class CreateYardDto {
|
||||||
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
|
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
|
||||||
@@ -7,10 +8,9 @@ export class CreateYardDto {
|
|||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
label!: string;
|
label!: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Country where the yard is located, e.g. Ethiopia, Djibouti', maxLength: 50 })
|
@ApiProperty({ enum: YardCountry, description: 'Country where the yard is located' })
|
||||||
@IsString()
|
@IsEnum(YardCountry)
|
||||||
@MaxLength(50)
|
country!: YardCountry;
|
||||||
country!: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: true })
|
@ApiPropertyOptional({ default: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { YardCountry } from '@edr/types';
|
||||||
import { Column, Entity, Index } from 'typeorm';
|
import { Column, Entity, Index } from 'typeorm';
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'yards' })
|
@Entity({ schema: 'freight', name: 'yards' })
|
||||||
@@ -12,8 +13,11 @@ export class Yard extends BaseEntity {
|
|||||||
@Column({ name: 'label', type: 'varchar', length: 100 })
|
@Column({ name: 'label', type: 'varchar', length: 100 })
|
||||||
label!: string;
|
label!: string;
|
||||||
|
|
||||||
|
// Constrained to YardCountry by DTO validation + a DB CHECK constraint;
|
||||||
|
// route/schedule trade direction is derived from this value. Typed as the
|
||||||
|
// enum's literal values so plain strings from seeds/queries still fit.
|
||||||
@Column({ name: 'country', type: 'varchar', length: 50 })
|
@Column({ name: 'country', type: 'varchar', length: 50 })
|
||||||
country!: string;
|
country!: `${YardCountry}`;
|
||||||
|
|
||||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ describe('SchedulingRescheduleService', () => {
|
|||||||
bookingsRepository as never,
|
bookingsRepository as never,
|
||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
schedulingRescheduleRepository as never,
|
schedulingRescheduleRepository as never,
|
||||||
|
{
|
||||||
|
rescheduled: jest.fn(),
|
||||||
|
removedFromTrain: jest.fn(),
|
||||||
|
maintenanceMoved: jest.fn(),
|
||||||
|
} as never, // notifier
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { BookingsRepository } from '../bookings/bookings.repository';
|
|||||||
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
|
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
|
||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
|
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
|
||||||
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
||||||
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
|
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ export class SchedulingRescheduleService {
|
|||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
|
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
|
||||||
|
private readonly notifier: BookingNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Preview who is retained, displaced, and readmitted on a schedule. */
|
/** Preview who is retained, displaced, and readmitted on a schedule. */
|
||||||
@@ -193,9 +195,64 @@ export class SchedulingRescheduleService {
|
|||||||
displacedBookingIds: dto.displacedBookingIds,
|
displacedBookingIds: dto.displacedBookingIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notify affected customers (SMS + email). Best-effort — a notification
|
||||||
|
// failure must never fail the reschedule, so each send is fire-and-forget
|
||||||
|
// inside the notifier. Government pre-empt already notifies via the batch
|
||||||
|
// displaced() path, so skip removed-from-train notices for that trigger.
|
||||||
|
// Use the new departure date when the reschedule moved it (the in-memory
|
||||||
|
// `schedule` still holds the pre-update date).
|
||||||
|
const effectiveDeparture = dto.newDepartureDate
|
||||||
|
? new Date(dto.newDepartureDate)
|
||||||
|
: schedule.scheduledDepartureDate;
|
||||||
|
await this.notifyRescheduleOutcome(dto, effectiveDeparture);
|
||||||
|
|
||||||
return { plan, schedule: assignResult };
|
return { plan, schedule: assignResult };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fan out reschedule notifications: bookings that stayed on the train hear the
|
||||||
|
* new departure date; bookings dropped off the train (staff reschedule, not a
|
||||||
|
* government pre-empt) hear they were removed. Loads each booking with its
|
||||||
|
* company so the notifier has a phone/email to reach.
|
||||||
|
*/
|
||||||
|
private async notifyRescheduleOutcome(
|
||||||
|
dto: ExecuteRescheduleDto,
|
||||||
|
newDeparture: Date | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const isMaintenance = dto.trigger === 'TRAIN_MAINTENANCE';
|
||||||
|
const isGovPreempt = dto.trigger === 'GOVERNMENT_PREEMPT';
|
||||||
|
|
||||||
|
if (newDeparture) {
|
||||||
|
for (const bookingId of dto.finalBookingIds) {
|
||||||
|
const booking = await this.loadBookingForNotify(bookingId);
|
||||||
|
if (!booking) continue;
|
||||||
|
if (isMaintenance) {
|
||||||
|
this.notifier.maintenanceMoved(booking, newDeparture);
|
||||||
|
} else {
|
||||||
|
this.notifier.rescheduled(booking, newDeparture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Government pre-empt displacements are already announced by the batch
|
||||||
|
// displaced() notice — don't double-notify. Staff reschedules are not.
|
||||||
|
if (!isGovPreempt) {
|
||||||
|
for (const bookingId of dto.displacedBookingIds) {
|
||||||
|
const booking = await this.loadBookingForNotify(bookingId);
|
||||||
|
if (!booking) continue;
|
||||||
|
this.notifier.removedFromTrain(booking);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadBookingForNotify(bookingId: string): Promise<Booking | null> {
|
||||||
|
try {
|
||||||
|
return await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Maintenance shortcut: new departure + rebalance. */
|
/** Maintenance shortcut: new departure + rebalance. */
|
||||||
async maintenanceReschedule(
|
async maintenanceReschedule(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
|
|||||||
@@ -302,3 +302,41 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
|
|||||||
expect(withEarly?.window?.label).toContain('08:00');
|
expect(withEarly?.window?.label).toContain('08:00');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: a schedule created INSIDE its own window day must open right away
|
||||||
|
// when the desk is open, and re-deriving after a settings change (close hour
|
||||||
|
// extended past "now", or lead pulled so the window day becomes today) must
|
||||||
|
// yield an immediate open — not tomorrow morning.
|
||||||
|
describe('computeImportWindowTimes — immediate open inside the window day', () => {
|
||||||
|
// 19:15:17 EAT on Mon 6 Jul = 16:15:17 UTC
|
||||||
|
const now = new Date('2026-07-06T16:15:17.000Z');
|
||||||
|
// Departs Thu 9 Jul ~08:53 EAT
|
||||||
|
const departure = new Date('2026-07-09T05:53:00.000Z');
|
||||||
|
const base = { importWindowLeadDays: 3, windowOpenHour: 8, windowDurationHours: 0.05 };
|
||||||
|
|
||||||
|
it('desk 8–23, created 19:15 on the window day → opens NOW', () => {
|
||||||
|
const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now);
|
||||||
|
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('desk 8–17, created 19:15 (desk shut) → opens next morning 08:00 EAT', () => {
|
||||||
|
const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 17 }, now);
|
||||||
|
expect(t.windowOpensAt.toISOString()).toBe('2026-07-07T05:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close hour extended 17 → 23 after hours: re-derive opens NOW', () => {
|
||||||
|
// Same call restampPendingWindows makes after the global-rules edit.
|
||||||
|
const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now);
|
||||||
|
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lead 3 → 4 pulls the window day to today: re-derive opens NOW', () => {
|
||||||
|
const departsJul10 = new Date('2026-07-10T05:53:00.000Z');
|
||||||
|
const t = computeImportWindowTimes(
|
||||||
|
departsJul10,
|
||||||
|
{ ...base, importWindowLeadDays: 4, windowCloseHour: 23 },
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
expect(t.windowOpensAt.getTime()).toBe(now.getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -282,15 +282,33 @@ export function computeImportWindowTimes(
|
|||||||
return { windowOpensAt: opensAt, windowClosesAt: closesAt };
|
return { windowOpensAt: opensAt, windowClosesAt: closesAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */
|
/**
|
||||||
|
* Export booking window: a single FCFS window from `exportBookingLeadHours`
|
||||||
|
* before departure until departure. The open honours the daily desk hours —
|
||||||
|
* when the raw lead instant lands while the desk is shut, the window opens at
|
||||||
|
* the next desk opening instead (capped at departure, so a config whose desk
|
||||||
|
* never opens before the train leaves yields a zero-length window rather than
|
||||||
|
* one that outlives the train).
|
||||||
|
*/
|
||||||
export function computeExportWindowTimes(
|
export function computeExportWindowTimes(
|
||||||
departure: Date,
|
departure: Date,
|
||||||
cfg: { exportBookingLeadHours: number },
|
cfg: {
|
||||||
|
exportBookingLeadHours: number;
|
||||||
|
windowOpenHour: number;
|
||||||
|
windowCloseHour: number;
|
||||||
|
},
|
||||||
): InitialWindowTimes {
|
): InitialWindowTimes {
|
||||||
return {
|
const rawOpen = new Date(
|
||||||
windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000),
|
departure.getTime() - cfg.exportBookingLeadHours * 3_600_000,
|
||||||
windowClosesAt: departure,
|
);
|
||||||
};
|
let opensAt = officeHoursOpen(rawOpen, {
|
||||||
|
windowOpenHour: cfg.windowOpenHour,
|
||||||
|
windowCloseHour: cfg.windowCloseHour,
|
||||||
|
});
|
||||||
|
if (opensAt.getTime() > departure.getTime()) {
|
||||||
|
opensAt = departure;
|
||||||
|
}
|
||||||
|
return { windowOpensAt: opensAt, windowClosesAt: departure };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -421,7 +439,9 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
|
|||||||
* after each close, on the same booking day, until departure. This mirrors
|
* after each close, on the same booking day, until departure. This mirrors
|
||||||
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
|
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
|
||||||
* exact windows the engine runs.
|
* exact windows the engine runs.
|
||||||
* EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure.
|
* EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure,
|
||||||
|
* with the open shifted to the next desk opening when it lands outside office hours
|
||||||
|
* (same math as `computeExportWindowTimes`).
|
||||||
*
|
*
|
||||||
* `anchorOpensAt` pins the FIRST window's open time to the schedule's stored
|
* `anchorOpensAt` pins the FIRST window's open time to the schedule's stored
|
||||||
* `windowOpensAt` instead of recomputing it from config. Pass it so the board
|
* `windowOpensAt` instead of recomputing it from config. Pass it so the board
|
||||||
@@ -436,8 +456,7 @@ export function listConfigBookingWindows(
|
|||||||
): BoardWindow[] {
|
): BoardWindow[] {
|
||||||
if (direction === 'EXPORT') {
|
if (direction === 'EXPORT') {
|
||||||
const start =
|
const start =
|
||||||
anchorOpensAt ??
|
anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt;
|
||||||
new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
|
|
||||||
return [boardWindowFromInterval(start, departure)];
|
return [boardWindowFromInterval(start, departure)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||||
|
{ emitPhase: jest.fn() } as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,10 +40,11 @@ import {
|
|||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
import { BookingSplitService } from './booking-split.service';
|
import { BookingSplitService } from './booking-split.service';
|
||||||
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
||||||
|
|
||||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||||
interface Capacity {
|
export interface Capacity {
|
||||||
wagons: number;
|
wagons: number;
|
||||||
weightTons: number;
|
weightTons: number;
|
||||||
lengthMeters: number;
|
lengthMeters: number;
|
||||||
@@ -204,6 +205,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
private readonly scheduler: SchedulerRegistry,
|
private readonly scheduler: SchedulerRegistry,
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
|
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||||
|
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
@Optional() private readonly splitService?: BookingSplitService,
|
@Optional() private readonly splitService?: BookingSplitService,
|
||||||
@@ -380,6 +382,18 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
|
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// Already linked at booking time (export FCFS: the customer books a
|
||||||
|
// specific train, so allocate() ran up front). allocate() is where the
|
||||||
|
// payment-settled tracking milestones are written, so on this branch we
|
||||||
|
// record them here — otherwise a paid, already-linked booking leaves
|
||||||
|
// FREIGHT_PAYMENT_SETTLED stuck PENDING and the clearance step never ticks.
|
||||||
|
void this.completeTrackingMilestones(bookingId, [
|
||||||
|
"WAGON_REQUESTED",
|
||||||
|
"FREIGHT_PAYMENT_PENDING",
|
||||||
|
"FREIGHT_PAYMENT_SETTLED",
|
||||||
|
]);
|
||||||
|
void this.markWagonAllocatedMilestone(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||||
@@ -1444,6 +1458,48 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
await this.fillSchedule(booking.trainScheduleId);
|
await this.fillSchedule(booking.trainScheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- intercity ride-along API ---------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remaining capacity budget (wagons / weight / length) for a schedule, and
|
||||||
|
* the per-booking need calculator — exposed for the intercity accept flow,
|
||||||
|
* which reserves ride-along bookings onto import/export trains outside the
|
||||||
|
* batch engine.
|
||||||
|
*/
|
||||||
|
async intercityCapacity(scheduleId: string): Promise<{
|
||||||
|
budget: Capacity;
|
||||||
|
needFor: (booking: Booking) => Capacity;
|
||||||
|
} | null> {
|
||||||
|
const schedule =
|
||||||
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
|
const locomotive = schedule?.trainSet?.locomotive;
|
||||||
|
if (!schedule || !locomotive) return null;
|
||||||
|
const rules = await this.loadGlobalRules();
|
||||||
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
|
const limits = await this.capacityLimits(locomotive, rules);
|
||||||
|
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||||
|
return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept an intercity booking onto the given train. Commercial bookings get
|
||||||
|
* the same pay-window lifecycle as a batch reservation (deadline, invoice
|
||||||
|
* due-date sync, pay-now notify, settle on the window tick), so payment →
|
||||||
|
* allocation needs no special path. Government bookings allocate directly.
|
||||||
|
*/
|
||||||
|
async acceptIntercity(booking: Booking, scheduleId: string): Promise<void> {
|
||||||
|
if (booking.isGovernment) {
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.update(booking.id, { trainScheduleId: scheduleId });
|
||||||
|
booking.trainScheduleId = scheduleId;
|
||||||
|
await this.allocate(scheduleId, booking, 'gov');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.reserve(booking, scheduleId);
|
||||||
|
this.armSettle(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- mutations ------------------------------------------------------------
|
// ---- mutations ------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1473,6 +1529,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
"PREPAID",
|
"PREPAID",
|
||||||
);
|
);
|
||||||
await this.notifier.payNow(booking, deadline);
|
await this.notifier.payNow(booking, deadline);
|
||||||
|
// Customer tracking: a wagon slot is reserved and the freight pay window is
|
||||||
|
// open. Doc-trigger path — silent no-op for bookings without milestone rows.
|
||||||
|
void this.completeTrackingMilestones(booking.id, [
|
||||||
|
"WAGON_REQUESTED",
|
||||||
|
"FREIGHT_PAYMENT_PENDING",
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
|
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
|
||||||
@@ -1504,6 +1566,15 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
this.notifier.secured(booking, reason);
|
this.notifier.secured(booking, reason);
|
||||||
void this.triggerWagonAllocation(scheduleId);
|
void this.triggerWagonAllocation(scheduleId);
|
||||||
void this.markWagonAllocatedMilestone(booking.id);
|
void this.markWagonAllocatedMilestone(booking.id);
|
||||||
|
// Customer tracking: freight payment settled (commercial pay-window path).
|
||||||
|
// Government allocations don't pay upfront — theirs stay pending.
|
||||||
|
if (reason === 'paid') {
|
||||||
|
void this.completeTrackingMilestones(booking.id, [
|
||||||
|
'WAGON_REQUESTED',
|
||||||
|
'FREIGHT_PAYMENT_PENDING',
|
||||||
|
'FREIGHT_PAYMENT_SETTLED',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async markWagonAllocatedMilestone(bookingId: string): Promise<void> {
|
private async markWagonAllocatedMilestone(bookingId: string): Promise<void> {
|
||||||
@@ -1515,6 +1586,27 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete customer-tracking milestones on lifecycle events via the
|
||||||
|
* doc-trigger path — a silent no-op for bookings without milestone rows
|
||||||
|
* (non-customs bookings). Never blocks the batch action.
|
||||||
|
*/
|
||||||
|
private async completeTrackingMilestones(
|
||||||
|
bookingId: string,
|
||||||
|
codes: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.milestoneService) return;
|
||||||
|
for (const code of codes) {
|
||||||
|
try {
|
||||||
|
await this.milestoneService.completeByDocTrigger({ bookingId }, code);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Milestone ${code} completion failed for booking ${bookingId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expire an unpaid reservation and free its capacity. With day-level pooling we
|
* Expire an unpaid reservation and free its capacity. With day-level pooling we
|
||||||
* also clear `trainScheduleId` so the booking is no longer pinned to the train
|
* also clear `trainScheduleId` so the booking is no longer pinned to the train
|
||||||
@@ -1831,6 +1923,17 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
await this.dataSource
|
await this.dataSource
|
||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
.update(scheduleId, { bookingWindowStatus: status });
|
.update(scheduleId, { bookingWindowStatus: status });
|
||||||
|
// Push the change (open / train full / closed) so portal home and GL cards
|
||||||
|
// flip in real time — FULL in particular happens outside the window tick
|
||||||
|
// (batch fill, staff mark-paid) and had no live signal before.
|
||||||
|
try {
|
||||||
|
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
|
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** No wagon slots left for allocated + reserved bookings. */
|
/** No wagon slots left for allocated + reserved bookings. */
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
NotificationAudience,
|
||||||
|
NotificationType,
|
||||||
|
NotifyInput,
|
||||||
|
} from '@edr/types';
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingNotifierService {
|
export class BookingNotifierService {
|
||||||
private readonly logger = new Logger(BookingNotifierService.name);
|
private readonly logger = new Logger(BookingNotifierService.name);
|
||||||
|
|
||||||
constructor(private readonly notifications: NotificationsService) {}
|
constructor(
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
|
) {}
|
||||||
|
|
||||||
private ref(b: Booking): string {
|
private ref(b: Booking): string {
|
||||||
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
||||||
@@ -41,11 +51,34 @@ export class BookingNotifierService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persist + push an in-app item to all portal users of the booking's company. */
|
||||||
|
private inApp(
|
||||||
|
b: Booking,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
overrides: Partial<NotifyInput> = {},
|
||||||
|
): void {
|
||||||
|
if (!b.companyId) return; // government/unlinked bookings have no portal users
|
||||||
|
void this.inbox.notify({
|
||||||
|
recipients: { companyId: b.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.SCHEDULE_UPDATE,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
link: `/bookings/${b.id}`,
|
||||||
|
data: { bookingId: b.id, reference: b.reference },
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async payNow(b: Booking, deadline: Date): Promise<void> {
|
async payNow(b: Booking, deadline: Date): Promise<void> {
|
||||||
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
||||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||||
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
|
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
|
||||||
await this.notifyContact(b, msg, 'PAY NOW');
|
await this.notifyContact(b, msg, 'PAY NOW');
|
||||||
|
this.inApp(b, 'Payment window open', msg, {
|
||||||
|
type: NotificationType.INVOICE_ISSUED,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +99,9 @@ export class BookingNotifierService {
|
|||||||
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` +
|
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` +
|
||||||
`(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
|
`(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
|
||||||
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
|
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
|
||||||
|
this.inApp(b, 'Partial allocation offer', msg, {
|
||||||
|
type: NotificationType.INVOICE_ISSUED,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
secured(b: Booking, reason: 'paid' | 'gov'): void {
|
secured(b: Booking, reason: 'paid' | 'gov'): void {
|
||||||
@@ -73,11 +109,13 @@ export class BookingNotifierService {
|
|||||||
reason === 'gov' ? ' (government)' : ''
|
reason === 'gov' ? ' (government)' : ''
|
||||||
}.`;
|
}.`;
|
||||||
void this.notifyContact(b, msg, 'ALLOCATED');
|
void this.notifyContact(b, msg, 'ALLOCATED');
|
||||||
|
this.inApp(b, 'Wagon allocated', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
expired(b: Booking): void {
|
expired(b: Booking): void {
|
||||||
const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`;
|
const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`;
|
||||||
void this.notifyContact(b, msg, 'EXPIRED');
|
void this.notifyContact(b, msg, 'EXPIRED');
|
||||||
|
this.inApp(b, 'Payment window expired', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleFull(b: Booking): void {
|
scheduleFull(b: Booking): void {
|
||||||
@@ -100,5 +138,42 @@ export class BookingNotifierService {
|
|||||||
displaced(b: Booking): void {
|
displaced(b: Booking): void {
|
||||||
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
|
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
|
||||||
void this.notifyContact(b, msg, 'DISPLACED');
|
void this.notifyContact(b, msg, 'DISPLACED');
|
||||||
|
this.inApp(b, 'Booking displaced', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff rescheduled the train carrying this booking to a new departure date.
|
||||||
|
* The booking stays on the train — only the date moved.
|
||||||
|
*/
|
||||||
|
rescheduled(b: Booking, newDeparture: Date): void {
|
||||||
|
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
|
||||||
|
const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`;
|
||||||
|
void this.notifyContact(b, msg, 'RESCHEDULED');
|
||||||
|
this.inApp(b, 'Booking rescheduled', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking was removed from its train during a staff reschedule (not a government
|
||||||
|
* pre-empt). It returns to eligible — the customer must rebook or reschedule.
|
||||||
|
*/
|
||||||
|
removedFromTrain(b: Booking): void {
|
||||||
|
const msg =
|
||||||
|
`Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` +
|
||||||
|
`Please rebook or select a new schedule from the portal.`;
|
||||||
|
void this.notifyContact(b, msg, 'REMOVED FROM TRAIN');
|
||||||
|
this.inApp(b, 'Removed from train', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The train carrying this booking was moved for maintenance to a new departure
|
||||||
|
* date. The booking stays on the train — only the date moved.
|
||||||
|
*/
|
||||||
|
maintenanceMoved(b: Booking, newDeparture: Date): void {
|
||||||
|
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
|
||||||
|
const msg =
|
||||||
|
`The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` +
|
||||||
|
`New departure date: ${when}.`;
|
||||||
|
void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE');
|
||||||
|
this.inApp(b, 'Train maintenance reschedule', msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { BOOKING_WINDOW_WS_EVENTS, BOOKING_WINDOW_WS_NAMESPACE } from '@edr/types';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { io, type Socket } from 'socket.io-client';
|
||||||
|
|
||||||
|
import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
||||||
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
|
import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end proof the booking-window socket works: boots a real Nest app with
|
||||||
|
* the gateway, connects a real socket.io client to the namespace, emits a phase
|
||||||
|
* change, and asserts the client receives the exact payload. If this passes,
|
||||||
|
* any "no live update" report is environmental (stale server process, wrong
|
||||||
|
* checkout running, client not connecting) — not the gateway.
|
||||||
|
*/
|
||||||
|
describe('BookingWindowGateway (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let gateway: BookingWindowGateway;
|
||||||
|
let client: Socket;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
BookingWindowGateway,
|
||||||
|
// Accept any token — auth plumbing is covered by the real WsAuthService.
|
||||||
|
{ provide: WsAuthService, useValue: { resolveUserId: async () => 'user-1' } },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.listen(0);
|
||||||
|
const address = app.getHttpServer().address() as { port: number };
|
||||||
|
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||||
|
gateway = app.get(BookingWindowGateway);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
client?.disconnect();
|
||||||
|
await app?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticated client receives the phase event with the schedule state', async () => {
|
||||||
|
client = io(`${baseUrl}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
|
||||||
|
auth: { token: 'any' },
|
||||||
|
transports: ['websocket'],
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
client.on('connect', () => resolve());
|
||||||
|
client.on('connect_error', (err) => reject(err));
|
||||||
|
});
|
||||||
|
|
||||||
|
const received = new Promise<Record<string, unknown>>((resolve) => {
|
||||||
|
client.on(BOOKING_WINDOW_WS_EVENTS.PHASE, (payload) => resolve(payload));
|
||||||
|
});
|
||||||
|
|
||||||
|
gateway.emitPhase({
|
||||||
|
id: 'sched-1',
|
||||||
|
originStationId: 'yard-a',
|
||||||
|
destinationStationId: 'yard-b',
|
||||||
|
direction: 'IMPORT',
|
||||||
|
windowPhase: 'OPEN',
|
||||||
|
bookingWindowStatus: 'OPEN',
|
||||||
|
bookingCycleNo: 2,
|
||||||
|
windowOpensAt: new Date('2026-07-06T16:15:00Z'),
|
||||||
|
windowClosesAt: new Date('2026-07-06T16:18:00Z'),
|
||||||
|
docReviewEndsAt: null,
|
||||||
|
paymentPhaseEndsAt: null,
|
||||||
|
scheduledDepartureDate: new Date('2026-07-09T05:53:00Z'),
|
||||||
|
} as unknown as TrainSchedule);
|
||||||
|
|
||||||
|
const payload = await received;
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
scheduleId: 'sched-1',
|
||||||
|
phase: 'OPEN',
|
||||||
|
bookingWindowStatus: 'OPEN',
|
||||||
|
bookingCycleNo: 2,
|
||||||
|
windowOpensAt: '2026-07-06T16:15:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a client whose token does not resolve to a user', async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
BookingWindowGateway,
|
||||||
|
{ provide: WsAuthService, useValue: { resolveUserId: async () => null } },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
const rejectingApp = moduleRef.createNestApplication();
|
||||||
|
await rejectingApp.listen(0);
|
||||||
|
const addr = rejectingApp.getHttpServer().address() as { port: number };
|
||||||
|
|
||||||
|
const rejected = io(`http://127.0.0.1:${addr.port}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
|
||||||
|
auth: { token: 'bad' },
|
||||||
|
transports: ['websocket'],
|
||||||
|
reconnection: false,
|
||||||
|
});
|
||||||
|
const outcome = await new Promise<string>((resolve) => {
|
||||||
|
rejected.on('disconnect', () => resolve('disconnected'));
|
||||||
|
rejected.on('connect_error', () => resolve('rejected'));
|
||||||
|
// The server accepts the transport then drops it in handleConnection.
|
||||||
|
setTimeout(() => resolve(rejected.connected ? 'still-connected' : 'disconnected'), 500);
|
||||||
|
});
|
||||||
|
rejected.disconnect();
|
||||||
|
await rejectingApp.close();
|
||||||
|
expect(outcome).not.toBe('still-connected');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
BOOKING_WINDOW_WS_EVENTS,
|
||||||
|
BOOKING_WINDOW_WS_NAMESPACE,
|
||||||
|
type BookingWindowPhaseEvent,
|
||||||
|
} from '@edr/types';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
OnGatewayConnection,
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
|
||||||
|
import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
||||||
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server → client push for booking-window state changes. Same handshake model
|
||||||
|
* as the notifications gateway: clients only listen, the token is verified on
|
||||||
|
* connect. Events are broadcast namespace-wide — window state is route-scoped
|
||||||
|
* public information for signed-in users, and clients filter/invalidate their
|
||||||
|
* own queries.
|
||||||
|
*/
|
||||||
|
@WebSocketGateway({
|
||||||
|
namespace: BOOKING_WINDOW_WS_NAMESPACE,
|
||||||
|
cors: { origin: true, credentials: true },
|
||||||
|
})
|
||||||
|
export class BookingWindowGateway implements OnGatewayConnection {
|
||||||
|
private readonly logger = new Logger(BookingWindowGateway.name);
|
||||||
|
|
||||||
|
@WebSocketServer()
|
||||||
|
private readonly server!: Server;
|
||||||
|
|
||||||
|
constructor(private readonly wsAuth: WsAuthService) {}
|
||||||
|
|
||||||
|
async handleConnection(socket: Socket): Promise<void> {
|
||||||
|
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
|
||||||
|
if (!userId) {
|
||||||
|
this.logger.debug(`Rejected booking-window handshake ${socket.id}`);
|
||||||
|
socket.disconnect(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.data.userId = userId;
|
||||||
|
// Log at info so "is anyone actually connected?" is answerable from the
|
||||||
|
// API log when diagnosing missing live updates.
|
||||||
|
this.logger.log(`Booking-window client connected (user ${userId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push a schedule's current window state to every connected client. */
|
||||||
|
emitPhase(schedule: TrainSchedule): void {
|
||||||
|
const payload: BookingWindowPhaseEvent = {
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
originYardId: schedule.originStationId,
|
||||||
|
destinationYardId: schedule.destinationStationId,
|
||||||
|
direction: schedule.direction ?? null,
|
||||||
|
phase: (schedule.windowPhase ?? 'PRE_WINDOW') as BookingWindowPhaseEvent['phase'],
|
||||||
|
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
|
||||||
|
bookingCycleNo: schedule.bookingCycleNo,
|
||||||
|
windowOpensAt: schedule.windowOpensAt?.toISOString() ?? null,
|
||||||
|
windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null,
|
||||||
|
docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null,
|
||||||
|
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
|
||||||
|
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractToken(socket: Socket): string | undefined {
|
||||||
|
const authToken = socket.handshake.auth?.token as string | undefined;
|
||||||
|
if (authToken) return authToken;
|
||||||
|
|
||||||
|
const queryToken = socket.handshake.query?.token;
|
||||||
|
if (typeof queryToken === 'string') return queryToken;
|
||||||
|
|
||||||
|
const header = socket.handshake.headers?.authorization;
|
||||||
|
if (header?.startsWith('Bearer ')) return header.slice(7);
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,19 @@ import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/com
|
|||||||
import { Cron } from '@nestjs/schedule';
|
import { Cron } from '@nestjs/schedule';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
|
import {
|
||||||
|
NotificationAudience,
|
||||||
|
NotificationType,
|
||||||
|
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||||
|
} from '@edr/types';
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import { BookingBatchService } from './booking-batch.service';
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
|
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
|
||||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||||
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
|
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
|
||||||
@@ -40,6 +46,8 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
|
private readonly gateway: BookingWindowGateway,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
@@ -48,7 +56,10 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
|
// 10-second cadence: every transition is derived from persisted timestamps
|
||||||
|
// and applied idempotently, so a finer tick only shrinks the lag between a
|
||||||
|
// deadline passing and the phase actually moving (was a full minute).
|
||||||
|
@Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
|
||||||
async tick(): Promise<void> {
|
async tick(): Promise<void> {
|
||||||
if (this.ticking) return;
|
if (this.ticking) return;
|
||||||
this.ticking = true;
|
this.ticking = true;
|
||||||
@@ -89,9 +100,10 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
|
|
||||||
await this.settleOverdueReservations();
|
await this.settleOverdueReservations();
|
||||||
|
|
||||||
// Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick.
|
// Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes
|
||||||
|
// (30 ticks at the 10-second cadence).
|
||||||
this.tickCount += 1;
|
this.tickCount += 1;
|
||||||
if (this.tickCount % 5 === 0) {
|
if (this.tickCount % 30 === 0) {
|
||||||
await this.bookingBatchService.runBatchFill();
|
await this.bookingBatchService.runBatchFill();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -151,6 +163,9 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
? await this.advanceExport(schedule, now)
|
? await this.advanceExport(schedule, now)
|
||||||
: await this.advanceImport(schedule, cfg, now);
|
: await this.advanceImport(schedule, cfg, now);
|
||||||
if (!advanced) return;
|
if (!advanced) return;
|
||||||
|
// Push the new window state to portal home / backoffice GL sections so
|
||||||
|
// they refresh instantly instead of waiting out their poll interval.
|
||||||
|
this.gateway.emitPhase(schedule);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +184,9 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
|
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
|
||||||
schedule.bookingWindowStatus = 'OPEN';
|
schedule.bookingWindowStatus = 'OPEN';
|
||||||
}
|
}
|
||||||
await this.notifyWindowOpened(schedule);
|
// Fire-and-forget: a slow SMS/email gateway must not stall the tick loop
|
||||||
|
// (the `ticking` guard would otherwise delay every schedule's transition).
|
||||||
|
void this.notifyWindowOpened(schedule);
|
||||||
this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
|
this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -216,7 +233,8 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
schedule.bookingWindowStatus = 'OPEN';
|
schedule.bookingWindowStatus = 'OPEN';
|
||||||
}
|
}
|
||||||
// Only announce the first opening of the day; reopen cycles don't re-notify.
|
// Only announce the first opening of the day; reopen cycles don't re-notify.
|
||||||
if (schedule.bookingCycleNo === 1) await this.notifyWindowOpened(schedule);
|
// Fire-and-forget so a slow SMS/email gateway never stalls the tick loop.
|
||||||
|
if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
|
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
|
||||||
);
|
);
|
||||||
@@ -368,22 +386,26 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
*/
|
*/
|
||||||
private async notifyWindowOpened(schedule: TrainSchedule): Promise<void> {
|
private async notifyWindowOpened(schedule: TrainSchedule): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const rows: Array<{ phone: string | null; email: string | null }> =
|
const rows: Array<{
|
||||||
await this.dataSource.query(
|
company_id: string;
|
||||||
`SELECT DISTINCT
|
phone: string | null;
|
||||||
COALESCE(co.contact_person_phone, co.phone) AS phone,
|
email: string | null;
|
||||||
COALESCE(co.email, co.general_manager_email) AS email
|
}> = await this.dataSource.query(
|
||||||
FROM freight.contract_routes cr
|
`SELECT DISTINCT
|
||||||
JOIN freight.contracts c
|
c.company_id,
|
||||||
ON c.id = cr.contract_id
|
COALESCE(co.contact_person_phone, co.phone) AS phone,
|
||||||
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
COALESCE(co.email, co.general_manager_email) AS email
|
||||||
AND c.deleted_at IS NULL
|
FROM freight.contract_routes cr
|
||||||
JOIN freight.companies co ON co.id = c.company_id
|
JOIN freight.contracts c
|
||||||
WHERE cr.origin_yard_id = $1
|
ON c.id = cr.contract_id
|
||||||
AND cr.destination_yard_id = $2
|
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
||||||
AND cr.deleted_at IS NULL`,
|
AND c.deleted_at IS NULL
|
||||||
[schedule.originStationId, schedule.destinationStationId],
|
JOIN freight.companies co ON co.id = c.company_id
|
||||||
);
|
WHERE cr.origin_yard_id = $1
|
||||||
|
AND cr.destination_yard_id = $2
|
||||||
|
AND cr.deleted_at IS NULL`,
|
||||||
|
[schedule.originStationId, schedule.destinationStationId],
|
||||||
|
);
|
||||||
if (!rows.length) return;
|
if (!rows.length) return;
|
||||||
|
|
||||||
const closes = schedule.windowClosesAt
|
const closes = schedule.windowClosesAt
|
||||||
@@ -398,6 +420,7 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
|
|
||||||
const seenPhone = new Set<string>();
|
const seenPhone = new Set<string>();
|
||||||
const seenEmail = new Set<string>();
|
const seenEmail = new Set<string>();
|
||||||
|
const seenCompany = new Set<string>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (r.phone && !seenPhone.has(r.phone)) {
|
if (r.phone && !seenPhone.has(r.phone)) {
|
||||||
seenPhone.add(r.phone);
|
seenPhone.add(r.phone);
|
||||||
@@ -411,9 +434,23 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
.directSend('email', r.email, msg)
|
.directSend('email', r.email, msg)
|
||||||
.catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`));
|
.catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`));
|
||||||
}
|
}
|
||||||
|
// In-app inbox item for every portal user of each eligible company,
|
||||||
|
// deep-linking to the new-booking page.
|
||||||
|
if (r.company_id && !seenCompany.has(r.company_id)) {
|
||||||
|
seenCompany.add(r.company_id);
|
||||||
|
void this.inbox.notify({
|
||||||
|
recipients: { companyId: r.company_id },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.SCHEDULE_UPDATE,
|
||||||
|
title: 'Booking window open',
|
||||||
|
body: msg,
|
||||||
|
link: '/bookings/new',
|
||||||
|
data: { trainScheduleId: schedule.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Notified ${seenPhone.size} phone / ${seenEmail.size} email contacts of open window for schedule ${schedule.id}`,
|
`Notified ${seenPhone.size} phone / ${seenEmail.size} email / ${seenCompany.size} companies (in-app) of open window for schedule ${schedule.id}`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class AcceptIntercityBookingsDto {
|
||||||
|
@ApiProperty({
|
||||||
|
type: [String],
|
||||||
|
description:
|
||||||
|
'Waiting intercity booking ids to accept onto this train, in priority order',
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
bookingIds!: string[];
|
||||||
|
}
|
||||||
@@ -59,4 +59,15 @@ export class UpdateScheduleWindowRuleDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
importWindowLeadDays?: number;
|
importWindowLeadDays?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 24,
|
||||||
|
description:
|
||||||
|
'Hours before departure the single FCFS export window opens (EXPORT schedules; re-derives the window start)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
exportBookingLeadHours?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||||
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
|
import { BookingBatchService, type Capacity } from './booking-batch.service';
|
||||||
|
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercity (DOMESTIC) ride-along: intercity bookings never get their own
|
||||||
|
* train — they ride a passing import/export schedule whose route milestones
|
||||||
|
* contain the booking's origin strictly before its destination.
|
||||||
|
*
|
||||||
|
* Flow: the customer books a corridor with no date; at finalize time staff see
|
||||||
|
* every waiting intercity booking whose corridor lies on the schedule's route,
|
||||||
|
* with its wagon/weight/length need against the train's remaining capacity;
|
||||||
|
* accepting reserves it (pay window → payment → allocation, same lifecycle as
|
||||||
|
* a batch reservation). Cargo is loaded manually when the train reaches the
|
||||||
|
* booking's origin yard and unloaded at its destination yard.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class IntercityService {
|
||||||
|
private readonly logger = new Logger(IntercityService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waiting intercity bookings this schedule could carry, with the train's
|
||||||
|
* remaining capacity along all three axes (wagons, weight, length) and each
|
||||||
|
* booking's need, so staff can pick what fits.
|
||||||
|
*/
|
||||||
|
async listCandidates(scheduleId: string) {
|
||||||
|
const schedule = await this.getSchedule(scheduleId);
|
||||||
|
const milestoneSeq = await this.routeMilestoneSequence(schedule);
|
||||||
|
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
|
||||||
|
|
||||||
|
const waiting = milestoneSeq
|
||||||
|
? await this.findWaitingIntercityBookings(milestoneSeq)
|
||||||
|
: [];
|
||||||
|
const accepted = await this.findAcceptedIntercityBookings(scheduleId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheduleId,
|
||||||
|
routeId: schedule.routeId ?? null,
|
||||||
|
remaining: capacity?.budget ?? null,
|
||||||
|
candidates: waiting.map((booking) => {
|
||||||
|
const need = capacity?.needFor(booking) ?? null;
|
||||||
|
return {
|
||||||
|
...this.mapBooking(booking),
|
||||||
|
need,
|
||||||
|
fits: need && capacity ? fits(need, capacity.budget) : false,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
accepted: accepted.map((booking) => ({
|
||||||
|
...this.mapBooking(booking),
|
||||||
|
need: capacity?.needFor(booking) ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept selected waiting intercity bookings onto this train, in the given
|
||||||
|
* order, each re-checked against the shrinking capacity budget. Commercial
|
||||||
|
* bookings open a pay window (payment → allocation runs on the existing
|
||||||
|
* settle lifecycle); government bookings allocate immediately.
|
||||||
|
*/
|
||||||
|
async acceptBookings(scheduleId: string, bookingIds: string[]) {
|
||||||
|
if (bookingIds.length === 0) {
|
||||||
|
throw new BadRequestException('Select at least one intercity booking');
|
||||||
|
}
|
||||||
|
const schedule = await this.getSchedule(scheduleId);
|
||||||
|
const milestoneSeq = await this.routeMilestoneSequence(schedule);
|
||||||
|
if (!milestoneSeq) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Schedule has no route milestones — cannot serve intercity corridors',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
|
||||||
|
if (!capacity) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Schedule has no locomotive/train set — capacity unknown',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accepted: string[] = [];
|
||||||
|
const rejected: Array<{ bookingId: string; reason: string }> = [];
|
||||||
|
let budget = capacity.budget;
|
||||||
|
|
||||||
|
for (const bookingId of bookingIds) {
|
||||||
|
const booking = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.findOne({ where: { id: bookingId }, relations: { bookingContainers: true } });
|
||||||
|
if (!booking) {
|
||||||
|
rejected.push({ bookingId, reason: 'Booking not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const notWaiting = this.whyNotWaiting(booking, milestoneSeq);
|
||||||
|
if (notWaiting) {
|
||||||
|
rejected.push({ bookingId, reason: notWaiting });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const need = capacity.needFor(booking);
|
||||||
|
if (!fits(need, budget)) {
|
||||||
|
rejected.push({
|
||||||
|
bookingId,
|
||||||
|
reason: 'Does not fit the remaining wagon/weight/length capacity',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.bookingBatchService.acceptIntercity(booking, scheduleId);
|
||||||
|
budget = subtract(budget, need);
|
||||||
|
accepted.push(bookingId);
|
||||||
|
this.logger.log(
|
||||||
|
`Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accepted, rejected, remaining: budget };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an accepted intercity booking's cargo as loaded. Only allowed while
|
||||||
|
* the train is physically at the booking's origin yard: either it has not
|
||||||
|
* departed yet and the booking boards at the train's own origin, or the
|
||||||
|
* latest recorded checkpoint is at the booking's origin yard.
|
||||||
|
*/
|
||||||
|
async loadBooking(scheduleId: string, bookingId: string) {
|
||||||
|
const { schedule, booking } = await this.getAcceptedBooking(
|
||||||
|
scheduleId,
|
||||||
|
bookingId,
|
||||||
|
);
|
||||||
|
if (booking.status !== 'PAID') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking must be paid before loading (currently ${booking.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.update(bookingId, { status: 'IN_TRANSIT' });
|
||||||
|
return { bookingId, status: 'IN_TRANSIT' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an intercity booking's cargo as unloaded at its destination yard —
|
||||||
|
* requires the latest checkpoint to be at that yard. Completes the booking.
|
||||||
|
*/
|
||||||
|
async unloadBooking(scheduleId: string, bookingId: string) {
|
||||||
|
const { schedule, booking } = await this.getAcceptedBooking(
|
||||||
|
scheduleId,
|
||||||
|
bookingId,
|
||||||
|
);
|
||||||
|
if (booking.status !== 'IN_TRANSIT') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.update(bookingId, { status: 'COMPLETED' });
|
||||||
|
return { bookingId, status: 'COMPLETED' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
private async getSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||||||
|
const schedule = await this.dataSource
|
||||||
|
.getRepository(TrainSchedule)
|
||||||
|
.findOne({ where: { id: scheduleId } });
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
|
}
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* yardId → sequenceNo for the schedule's route. Falls back to a two-stop
|
||||||
|
* origin/destination pseudo-route for legacy schedules without a routeId,
|
||||||
|
* so an intercity booking exactly matching the train's own corridor still
|
||||||
|
* qualifies.
|
||||||
|
*/
|
||||||
|
private async routeMilestoneSequence(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
): Promise<Map<string, number> | null> {
|
||||||
|
if (schedule.routeId) {
|
||||||
|
const milestones = await this.dataSource
|
||||||
|
.getRepository(RouteMilestone)
|
||||||
|
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
|
||||||
|
if (milestones.length >= 2) {
|
||||||
|
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (schedule.originStationId && schedule.destinationStationId) {
|
||||||
|
return new Map([
|
||||||
|
[schedule.originStationId, 1],
|
||||||
|
[schedule.destinationStationId, 2],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waiting = ready intercity bookings not yet on any train, corridor on this route. */
|
||||||
|
private async findWaitingIntercityBookings(
|
||||||
|
milestoneSeq: Map<string, number>,
|
||||||
|
): Promise<Booking[]> {
|
||||||
|
const pool = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||||
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
|
.where(`booking.trade_direction = 'DOMESTIC'`)
|
||||||
|
.andWhere('booking.train_schedule_id IS NULL')
|
||||||
|
.andWhere(
|
||||||
|
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||||
|
OR (booking.is_government = true AND booking.status = 'APPROVED'))`,
|
||||||
|
)
|
||||||
|
.orderBy('booking.is_government', 'DESC')
|
||||||
|
.addOrderBy('booking.priority_score', 'DESC')
|
||||||
|
.addOrderBy('booking.created_at', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
return pool.filter((b) => this.corridorOnRoute(b, milestoneSeq));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Intercity bookings already reserved/allocated on this schedule. */
|
||||||
|
private async findAcceptedIntercityBookings(
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<Booking[]> {
|
||||||
|
return this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||||
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
|
.where(`booking.trade_direction = 'DOMESTIC'`)
|
||||||
|
.andWhere('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||||
|
.orderBy('booking.created_at', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
private corridorOnRoute(
|
||||||
|
booking: Booking,
|
||||||
|
milestoneSeq: Map<string, number>,
|
||||||
|
): boolean {
|
||||||
|
const originSeq = milestoneSeq.get(booking.originYardId);
|
||||||
|
const destinationSeq = milestoneSeq.get(booking.destinationYardId);
|
||||||
|
return (
|
||||||
|
originSeq != null && destinationSeq != null && originSeq < destinationSeq
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private whyNotWaiting(
|
||||||
|
booking: Booking,
|
||||||
|
milestoneSeq: Map<string, number>,
|
||||||
|
): string | null {
|
||||||
|
if (booking.tradeDirection !== 'DOMESTIC') {
|
||||||
|
return 'Not an intercity booking';
|
||||||
|
}
|
||||||
|
if (booking.trainScheduleId) {
|
||||||
|
return 'Already assigned to a train';
|
||||||
|
}
|
||||||
|
const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED';
|
||||||
|
if (booking.status !== readyStatus) {
|
||||||
|
return `Not ready to board (status ${booking.status})`;
|
||||||
|
}
|
||||||
|
if (!this.corridorOnRoute(booking, milestoneSeq)) {
|
||||||
|
return "Corridor is not on this schedule's route";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAcceptedBooking(scheduleId: string, bookingId: string) {
|
||||||
|
const schedule = await this.getSchedule(scheduleId);
|
||||||
|
const booking = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.findOne({ where: { id: bookingId } });
|
||||||
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
if (booking.trainScheduleId !== scheduleId) {
|
||||||
|
throw new BadRequestException('Booking is not assigned to this schedule');
|
||||||
|
}
|
||||||
|
if (booking.tradeDirection !== 'DOMESTIC') {
|
||||||
|
throw new BadRequestException('Not an intercity booking');
|
||||||
|
}
|
||||||
|
return { schedule, booking };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The train is "at" a yard when the latest recorded checkpoint is that yard,
|
||||||
|
* or — for a booking boarding at the train's own origin — when the train has
|
||||||
|
* not recorded any checkpoint yet (still sitting at its origin).
|
||||||
|
*/
|
||||||
|
private async assertTrainAtYard(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
yardId: string,
|
||||||
|
side: 'origin' | 'destination',
|
||||||
|
): Promise<void> {
|
||||||
|
const latest = await this.dataSource
|
||||||
|
.getRepository(TrainCheckpointEvent)
|
||||||
|
.findOne({
|
||||||
|
where: { trainScheduleId: schedule.id },
|
||||||
|
order: { occurredAt: 'DESC', createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!latest) {
|
||||||
|
if (side === 'origin' && schedule.originStationId === yardId) return;
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Train has not reached this yard yet — record its checkpoint first',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (latest.yardId !== yardId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Train's last recorded position is not at the booking's ${side} yard`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapBooking(booking: Booking) {
|
||||||
|
return {
|
||||||
|
id: booking.id,
|
||||||
|
reference: booking.reference,
|
||||||
|
status: booking.status,
|
||||||
|
freightType: booking.freightType,
|
||||||
|
isGovernment: booking.isGovernment,
|
||||||
|
customer: booking.company?.name ?? 'Unknown customer',
|
||||||
|
originYardId: booking.originYardId,
|
||||||
|
destinationYardId: booking.destinationYardId,
|
||||||
|
origin:
|
||||||
|
booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
|
||||||
|
destination:
|
||||||
|
booking.destinationYard?.label ??
|
||||||
|
booking.destinationYard?.code ??
|
||||||
|
'Unknown destination',
|
||||||
|
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
|
||||||
|
paymentDeadline: booking.paymentDeadline?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fits(need: Capacity, budget: Capacity): boolean {
|
||||||
|
return (
|
||||||
|
need.wagons <= budget.wagons &&
|
||||||
|
need.weightTons <= budget.weightTons &&
|
||||||
|
need.lengthMeters <= budget.lengthMeters
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtract(budget: Capacity, need: Capacity): Capacity {
|
||||||
|
return {
|
||||||
|
wagons: budget.wagons - need.wagons,
|
||||||
|
weightTons: budget.weightTons - need.weightTons,
|
||||||
|
lengthMeters: budget.lengthMeters - need.lengthMeters,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
TrainSchedulingManage,
|
TrainSchedulingManage,
|
||||||
TrainSchedulingView,
|
TrainSchedulingView,
|
||||||
} from "../../common/booking-guards";
|
} from "../../common/booking-guards";
|
||||||
|
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
|
||||||
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
|
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
|
||||||
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
|
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
|
||||||
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
|
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
|
||||||
@@ -47,6 +48,7 @@ import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
|
|||||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||||
import { BookingBatchService } from "./booking-batch.service";
|
import { BookingBatchService } from "./booking-batch.service";
|
||||||
import { BookingWindowService } from "./booking-window.service";
|
import { BookingWindowService } from "./booking-window.service";
|
||||||
|
import { IntercityService } from "./intercity.service";
|
||||||
import { BillingService } from "../billing/billing.service";
|
import { BillingService } from "../billing/billing.service";
|
||||||
|
|
||||||
@ApiTags("train-scheduling")
|
@ApiTags("train-scheduling")
|
||||||
@@ -57,6 +59,7 @@ export class TrainSchedulingController {
|
|||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
private readonly bookingWindowService: BookingWindowService,
|
private readonly bookingWindowService: BookingWindowService,
|
||||||
|
private readonly intercityService: IntercityService,
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@@ -406,6 +409,54 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.dispatchSchedule(id);
|
return this.trainSchedulingService.dispatchSchedule(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("schedules/:id/intercity-candidates")
|
||||||
|
@TrainSchedulingView()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Waiting intercity bookings this train could carry (corridor on route) + remaining wagon/weight/length capacity",
|
||||||
|
})
|
||||||
|
getIntercityCandidates(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.intercityService.listCandidates(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("schedules/:id/intercity/accept")
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)",
|
||||||
|
})
|
||||||
|
acceptIntercityBookings(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: AcceptIntercityBookingsDto,
|
||||||
|
) {
|
||||||
|
return this.intercityService.acceptBookings(id, dto.bookingIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("schedules/:id/intercity/:bookingId/load")
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)",
|
||||||
|
})
|
||||||
|
loadIntercityBooking(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||||
|
) {
|
||||||
|
return this.intercityService.loadBooking(id, bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("schedules/:id/intercity/:bookingId/unload")
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)",
|
||||||
|
})
|
||||||
|
unloadIntercityBooking(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||||
|
) {
|
||||||
|
return this.intercityService.unloadBooking(id, bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("schedules/:id/import-djibouti")
|
@Get("schedules/:id/import-djibouti")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" })
|
@ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
|
||||||
|
|
||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
@@ -25,10 +26,14 @@ import { TrainSchedulingController } from './train-scheduling.controller';
|
|||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
import { BookingBatchService } from './booking-batch.service';
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
import { BookingNotifierService } from './booking-notifier.service';
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
import { BookingWindowService } from './booking-window.service';
|
import { BookingWindowService } from './booking-window.service';
|
||||||
|
import { IntercityService } from './intercity.service';
|
||||||
|
import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
||||||
import { BookingSplitService } from './booking-split.service';
|
import { BookingSplitService } from './booking-split.service';
|
||||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
import { ContractsModule } from '../contracts/contracts.module';
|
import { ContractsModule } from '../contracts/contracts.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -46,10 +51,13 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
TrainCheckpointEvent,
|
TrainCheckpointEvent,
|
||||||
ImportDjiboutiOperation,
|
ImportDjiboutiOperation,
|
||||||
BookingBatchOffer,
|
BookingBatchOffer,
|
||||||
|
// WsAuthService (booking-window gateway handshake) verifies IAM sessions.
|
||||||
|
Session,
|
||||||
]),
|
]),
|
||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
NotificationInboxModule,
|
||||||
LocomotivesModule,
|
LocomotivesModule,
|
||||||
WagonTypesModule,
|
WagonTypesModule,
|
||||||
TrainSetsModule,
|
TrainSetsModule,
|
||||||
@@ -64,9 +72,17 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
TrainCheckpointEventsRepository,
|
TrainCheckpointEventsRepository,
|
||||||
BookingBatchService,
|
BookingBatchService,
|
||||||
BookingNotifierService,
|
BookingNotifierService,
|
||||||
|
BookingWindowGateway,
|
||||||
|
WsAuthService,
|
||||||
BookingWindowService,
|
BookingWindowService,
|
||||||
BookingSplitService,
|
BookingSplitService,
|
||||||
|
IntercityService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
TrainSchedulingService,
|
||||||
|
BookingBatchService,
|
||||||
|
BookingWindowService,
|
||||||
|
BookingNotifierService,
|
||||||
],
|
],
|
||||||
exports: [TrainSchedulingService, BookingBatchService, BookingWindowService],
|
|
||||||
})
|
})
|
||||||
export class TrainSchedulingModule {}
|
export class TrainSchedulingModule {}
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
{
|
{
|
||||||
htmlToPdfBuffer: jest.fn(),
|
htmlToPdfBuffer: jest.fn(),
|
||||||
} as never,
|
} as never,
|
||||||
|
{ emitPhase: jest.fn() } as never, // bookingWindowGateway
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultFleetWagons = [
|
const defaultFleetWagons = [
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
Optional,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
@@ -21,6 +22,7 @@ import { BookingsRepository } from '../bookings/bookings.repository';
|
|||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
|
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
|
||||||
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
import { Container } from '../container-management/entities/container.entity';
|
import { Container } from '../container-management/entities/container.entity';
|
||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||||
@@ -67,6 +69,7 @@ import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduli
|
|||||||
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
|
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
|
||||||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
||||||
import { type BookingWindowConfig } from './booking-window.config';
|
import { type BookingWindowConfig } from './booking-window.config';
|
||||||
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
import {
|
import {
|
||||||
buildCappedWagonPlan,
|
buildCappedWagonPlan,
|
||||||
computeFleetAvailability,
|
computeFleetAvailability,
|
||||||
@@ -272,9 +275,63 @@ export class TrainSchedulingService {
|
|||||||
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
|
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
|
||||||
private readonly warehouseInventoryService: WarehouseInventoryService,
|
private readonly warehouseInventoryService: WarehouseInventoryService,
|
||||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||||
|
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||||
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
private readonly configService?: ConfigService,
|
private readonly configService?: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete customer-tracking clearance milestones for every booking on a
|
||||||
|
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
|
||||||
|
* unload, gatepass). Uses the doc-trigger path, which is a silent no-op for
|
||||||
|
* bookings without milestone rows (non-customs bookings), so this is safe to
|
||||||
|
* call for every direction and flow. Never blocks the operational action.
|
||||||
|
*/
|
||||||
|
private async completeMilestonesForScheduleBookings(
|
||||||
|
scheduleId: string,
|
||||||
|
codes: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.milestoneService || codes.length === 0) return;
|
||||||
|
try {
|
||||||
|
const rows: Array<{ booking_id: string }> = await this.dataSource.query(
|
||||||
|
`SELECT tsb.booking_id
|
||||||
|
FROM freight.train_schedule_bookings tsb
|
||||||
|
WHERE tsb.train_schedule_id = $1
|
||||||
|
AND tsb.deleted_at IS NULL`,
|
||||||
|
[scheduleId],
|
||||||
|
);
|
||||||
|
for (const { booking_id } of rows) {
|
||||||
|
for (const code of codes) {
|
||||||
|
await this.milestoneService.completeByDocTrigger(
|
||||||
|
{ bookingId: booking_id },
|
||||||
|
code,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Milestone completion (${codes.join(', ')}) failed for schedule ${scheduleId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a schedule's current booking-window state over the socket so the
|
||||||
|
* portal home card and backoffice GL/batch views update in real time —
|
||||||
|
* used for lifecycle changes outside the window tick (create, cancel,
|
||||||
|
* finalize, restamp). A push failure must never break the mutation.
|
||||||
|
*/
|
||||||
|
private async emitWindowState(scheduleId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
|
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
||||||
// Day-level pooling: when the wizard targets a schedule, surface the whole
|
// Day-level pooling: when the wizard targets a schedule, surface the whole
|
||||||
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
|
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
|
||||||
@@ -408,7 +465,9 @@ export class TrainSchedulingService {
|
|||||||
schedule.ruleImportWindowLeadDays ??
|
schedule.ruleImportWindowLeadDays ??
|
||||||
liveCfg.importWindowLeadDays,
|
liveCfg.importWindowLeadDays,
|
||||||
exportBookingLeadHours:
|
exportBookingLeadHours:
|
||||||
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours,
|
dto.exportBookingLeadHours ??
|
||||||
|
schedule.ruleExportBookingLeadHours ??
|
||||||
|
liveCfg.exportBookingLeadHours,
|
||||||
windowOpenHour:
|
windowOpenHour:
|
||||||
dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
|
dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
|
||||||
windowCloseHour:
|
windowCloseHour:
|
||||||
@@ -432,6 +491,12 @@ export class TrainSchedulingService {
|
|||||||
schedule.direction === 'EXPORT'
|
schedule.direction === 'EXPORT'
|
||||||
? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
|
? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
|
||||||
: computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now);
|
: computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now);
|
||||||
|
if (times.windowOpensAt.getTime() >= times.windowClosesAt.getTime()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'These settings leave no booking window before departure — with the ' +
|
||||||
|
'desk hours applied, the window would only open once the train has left.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||||||
windowOpensAt: times.windowOpensAt,
|
windowOpensAt: times.windowOpensAt,
|
||||||
@@ -441,6 +506,7 @@ export class TrainSchedulingService {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`,
|
`Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`,
|
||||||
);
|
);
|
||||||
|
void this.emitWindowState(id);
|
||||||
|
|
||||||
const fresh = await this.trainSchedulesRepository.findById(id);
|
const fresh = await this.trainSchedulesRepository.findById(id);
|
||||||
return fresh ?? schedule;
|
return fresh ?? schedule;
|
||||||
@@ -514,6 +580,7 @@ export class TrainSchedulingService {
|
|||||||
`Departure date changed for schedule ${id} → ${departure.toISOString()} ` +
|
`Departure date changed for schedule ${id} → ${departure.toISOString()} ` +
|
||||||
`(window reopens ${times.windowOpensAt.toISOString()})`,
|
`(window reopens ${times.windowOpensAt.toISOString()})`,
|
||||||
);
|
);
|
||||||
|
void this.emitWindowState(id);
|
||||||
|
|
||||||
const fresh = await this.trainSchedulesRepository.findById(id);
|
const fresh = await this.trainSchedulesRepository.findById(id);
|
||||||
return fresh ?? schedule;
|
return fresh ?? schedule;
|
||||||
@@ -553,6 +620,9 @@ export class TrainSchedulingService {
|
|||||||
...windowRuleSnapshot(cfg),
|
...windowRuleSnapshot(cfg),
|
||||||
});
|
});
|
||||||
restamped += 1;
|
restamped += 1;
|
||||||
|
// New times take effect immediately on every card (the tick then opens
|
||||||
|
// the window within seconds if the re-derived open is already due).
|
||||||
|
void this.emitWindowState(s.id);
|
||||||
}
|
}
|
||||||
if (restamped > 0) {
|
if (restamped > 0) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -686,10 +756,9 @@ export class TrainSchedulingService {
|
|||||||
lockedLocomotives.push(locked);
|
lockedLocomotives.push(locked);
|
||||||
}
|
}
|
||||||
|
|
||||||
const direction = deriveScheduleDirection(
|
// Frozen on the route at create/update from the yard-country enum;
|
||||||
route.originYard ?? { country: null },
|
// getSchedulableRoute already rejected DOMESTIC (intercity).
|
||||||
route.destinationYard ?? { country: null },
|
const direction = this.resolveRouteDirection(route);
|
||||||
);
|
|
||||||
|
|
||||||
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
|
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
|
||||||
// Effective capacity is capped by the weakest locomotive in the set.
|
// Effective capacity is capped by the weakest locomotive in the set.
|
||||||
@@ -757,6 +826,8 @@ export class TrainSchedulingService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const created = await this.getTrainScheduleById(createdScheduleId);
|
const created = await this.getTrainScheduleById(createdScheduleId);
|
||||||
|
// New window announced — portal home / GL cards pick it up immediately.
|
||||||
|
void this.emitWindowState(createdScheduleId);
|
||||||
return { ...created, warnings: scheduleWarnings };
|
return { ...created, warnings: scheduleWarnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1088,26 +1159,32 @@ export class TrainSchedulingService {
|
|||||||
{ country: schedule.destinationCountry },
|
{ country: schedule.destinationCountry },
|
||||||
);
|
);
|
||||||
if (direction === 'IMPORT') {
|
if (direction === 'IMPORT') {
|
||||||
|
const result = await this.warehouseInventoryService.autoUnloadArrivedBookings(
|
||||||
|
scheduleId,
|
||||||
|
'SYSTEM_TRAIN_ARRIVAL',
|
||||||
|
);
|
||||||
|
// Customer tracking: cargo is off the train at the destination yard.
|
||||||
|
void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
|
||||||
return {
|
return {
|
||||||
direction,
|
direction,
|
||||||
action: 'IMPORT_AUTO_UNLOAD',
|
action: 'IMPORT_AUTO_UNLOAD',
|
||||||
status: 'COMPLETED',
|
status: 'COMPLETED',
|
||||||
result: await this.warehouseInventoryService.autoUnloadArrivedBookings(
|
result,
|
||||||
scheduleId,
|
|
||||||
'SYSTEM_TRAIN_ARRIVAL',
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
|
if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
|
||||||
|
const result = await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
|
||||||
|
scheduleId,
|
||||||
|
'SYSTEM_TRAIN_ARRIVAL',
|
||||||
|
);
|
||||||
|
// Customer tracking: cargo is off the train at the Djibouti port.
|
||||||
|
void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
|
||||||
return {
|
return {
|
||||||
direction,
|
direction,
|
||||||
action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD',
|
action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD',
|
||||||
status: 'COMPLETED',
|
status: 'COMPLETED',
|
||||||
result: await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
|
result,
|
||||||
scheduleId,
|
|
||||||
'SYSTEM_TRAIN_ARRIVAL',
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1336,6 +1413,8 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Finalized — push so portal/GL cards reflect the new state instantly.
|
||||||
|
void this.emitWindowState(scheduleId);
|
||||||
return this.getTrainScheduleById(scheduleId);
|
return this.getTrainScheduleById(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1406,6 +1485,22 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch closed the window — drop it from portal/GL cards right away.
|
||||||
|
void this.emitWindowState(scheduleId);
|
||||||
|
// Customer tracking: cargo is on the departing train — loading milestones
|
||||||
|
// plus the direction's "departed" handoff milestone.
|
||||||
|
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
|
||||||
|
void this.completeMilestonesForScheduleBookings(scheduleId, [
|
||||||
|
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
|
||||||
|
// doc-trigger path no-ops it for import bookings.
|
||||||
|
'CARGO_ARRIVED',
|
||||||
|
'READY_FOR_LOADING',
|
||||||
|
'LOADED',
|
||||||
|
schedule.direction === 'IMPORT'
|
||||||
|
? 'DEPARTED_FROM_DJIBOUTI'
|
||||||
|
: 'DEPARTED_TO_DJIBOUTI',
|
||||||
|
]);
|
||||||
|
}
|
||||||
return this.getTrainScheduleById(scheduleId);
|
return this.getTrainScheduleById(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1562,6 +1657,13 @@ export class TrainSchedulingService {
|
|||||||
LoadingStatus.Loaded,
|
LoadingStatus.Loaded,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Customer tracking: staff confirmed cargo is on the wagons (CARGO_ARRIVED
|
||||||
|
// is the export-side "cargo reached origin yard" step that precedes it).
|
||||||
|
void this.completeMilestonesForScheduleBookings(scheduleId, [
|
||||||
|
'CARGO_ARRIVED',
|
||||||
|
'READY_FOR_LOADING',
|
||||||
|
'LOADED',
|
||||||
|
]);
|
||||||
return this.getTrainScheduleById(scheduleId);
|
return this.getTrainScheduleById(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1625,9 +1727,9 @@ export class TrainSchedulingService {
|
|||||||
performedBy: 'DOCUMENT_GENERATION',
|
performedBy: 'DOCUMENT_GENERATION',
|
||||||
});
|
});
|
||||||
const html = this.buildImportLoadListHtml(loadList);
|
const html = this.buildImportLoadListHtml(loadList);
|
||||||
// Generic render — NOT the release-order fallback (would mislabel this as a
|
// Styled table-aware fallback (marshalling grid) when Chromium is unavailable —
|
||||||
// gate-clearance / release order when Chromium is unavailable).
|
// NOT the release-order fallback (would mislabel this as a gate-clearance order).
|
||||||
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list');
|
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list');
|
||||||
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
|
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
|
||||||
return {
|
return {
|
||||||
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||||
@@ -1645,8 +1747,8 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const html = this.buildExportLoadListHtml(schedule);
|
const html = this.buildExportLoadListHtml(schedule);
|
||||||
// Generic render — NOT the release-order fallback (see importLoadListDocument).
|
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||||||
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list');
|
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list');
|
||||||
const reference = schedule.trainNumber ?? schedule.id;
|
const reference = schedule.trainNumber ?? schedule.id;
|
||||||
return {
|
return {
|
||||||
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||||
@@ -2115,6 +2217,7 @@ export class TrainSchedulingService {
|
|||||||
await this.dataSource
|
await this.dataSource
|
||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
.update(scheduleId, { bookingWindowStatus: status });
|
.update(scheduleId, { bookingWindowStatus: status });
|
||||||
|
void this.emitWindowState(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */
|
/** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */
|
||||||
@@ -2379,6 +2482,13 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Customer tracking: the train reached the corridor's far end.
|
||||||
|
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
|
||||||
|
void this.completeMilestonesForScheduleBookings(scheduleId, [
|
||||||
|
schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
const detail = await this.getTrainScheduleById(scheduleId);
|
const detail = await this.getTrainScheduleById(scheduleId);
|
||||||
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
||||||
return Object.assign(detail, { warehouseAutomation });
|
return Object.assign(detail, { warehouseAutomation });
|
||||||
@@ -2455,6 +2565,8 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Window retired (DONE) — remove the card from portal/GL lists right away.
|
||||||
|
void this.emitWindowState(id);
|
||||||
return this.getTrainScheduleById(id);
|
return this.getTrainScheduleById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2751,7 +2863,16 @@ export class TrainSchedulingService {
|
|||||||
take: 1,
|
take: 1,
|
||||||
});
|
});
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
// A read failure here silently downgrades every booking window to the
|
||||||
|
// hardcoded defaults (desk 8–17, duration 3h, lead 3) while the settings
|
||||||
|
// UI keeps showing the saved row — a maddening mismatch. The usual cause
|
||||||
|
// is a missing column (migrations not run on this database). Scream.
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to read train-scheduling global rules — booking windows are ` +
|
||||||
|
`running on HARDCODED DEFAULTS (8–17). Run pending migrations. ` +
|
||||||
|
`Cause: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3447,9 +3568,27 @@ export class TrainSchedulingService {
|
|||||||
`Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`,
|
`Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Intercity (same-country) service is not offered yet — only import/export
|
||||||
|
// trains can be scheduled.
|
||||||
|
if (this.resolveRouteDirection(route) === 'DOMESTIC') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Route ${formatRouteLabel(route)} is an intercity route; intercity scheduling is not available yet`,
|
||||||
|
);
|
||||||
|
}
|
||||||
return route;
|
return route;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stored route direction, deriving from yard countries for pre-migration rows. */
|
||||||
|
private resolveRouteDirection(route: Route) {
|
||||||
|
return (
|
||||||
|
route.direction ??
|
||||||
|
deriveScheduleDirection(
|
||||||
|
route.originYard ?? { country: null },
|
||||||
|
route.destinationYard ?? { country: null },
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private mapEligibleBooking(booking: Booking) {
|
private mapEligibleBooking(booking: Booking) {
|
||||||
return {
|
return {
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
@@ -3762,7 +3901,18 @@ export class TrainSchedulingService {
|
|||||||
order: { scheduledDepartureDate: 'ASC' },
|
order: { scheduledDepartureDate: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A train that has already departed can never be booked, even if the window
|
||||||
|
// engine hasn't yet flipped its bookingWindowStatus off OPEN. Mirror the
|
||||||
|
// `scheduled_departure_date >= now()` guard the booking-window SQL uses so a
|
||||||
|
// past-departure schedule never leaks into the portal day pool, the schedule
|
||||||
|
// calendar, or the ET GL create-booking gate.
|
||||||
|
const now = new Date();
|
||||||
return schedules
|
return schedules
|
||||||
|
.filter(
|
||||||
|
(s) =>
|
||||||
|
s.scheduledDepartureDate != null &&
|
||||||
|
s.scheduledDepartureDate > now,
|
||||||
|
)
|
||||||
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
|
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
|
||||||
.filter((s) => {
|
.filter((s) => {
|
||||||
// Build the full stop list: origin -> milestones (ordered) -> destination
|
// Build the full stop list: origin -> milestones (ordered) -> destination
|
||||||
@@ -4054,6 +4204,7 @@ export class TrainSchedulingService {
|
|||||||
: null,
|
: null,
|
||||||
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
|
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
|
||||||
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
||||||
|
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
|
||||||
docReviewMinutes: windowCfg.docReviewMinutes,
|
docReviewMinutes: windowCfg.docReviewMinutes,
|
||||||
paymentWindowMinutes: windowCfg.paymentWindowMinutes,
|
paymentWindowMinutes: windowCfg.paymentWindowMinutes,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator';
|
import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
|
import { FEE_RULE_BASES, FEE_RULE_TYPES, FeeRuleBasis, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
|
||||||
|
|
||||||
export class FeeRuleTierDto {
|
export class FeeRuleTierDto {
|
||||||
@ApiProperty({ example: 4 })
|
@ApiProperty({ example: 4 })
|
||||||
@@ -82,11 +82,19 @@ export class CreateFeeRuleDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
freeDays!: number;
|
freeDays!: number;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ description: 'Day-based fees: rate/day. Double handling: flat rate per basis unit.' })
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
ratePerDay!: number;
|
ratePerDay!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: FEE_RULE_BASES,
|
||||||
|
description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(FEE_RULE_BASES)
|
||||||
|
basis?: FeeRuleBasis;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: [FeeRuleTierDto] })
|
@ApiPropertyOptional({ type: [FeeRuleTierDto] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index } from 'typeorm';
|
||||||
|
|
||||||
|
export const HANDOVER_MILE_TYPES = ['SELF_HAUL', 'EDR_LAST_MILE'] as const;
|
||||||
|
export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One import handover. A booking has a single handover when one truck takes the
|
||||||
|
* whole booking (`truckAssignmentId` null = per-booking), or one per truck when
|
||||||
|
* multiple trucks are used. Self-haul handovers are generated on truck arrival
|
||||||
|
* and signed before the truck leaves; EDR last-mile handovers are generated at
|
||||||
|
* delivery (after exit).
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'booking_handovers' })
|
||||||
|
@Index(['bookingId'])
|
||||||
|
export class BookingHandover extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
/** Customer self-haul truck this handover belongs to; null = per-booking. */
|
||||||
|
@Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true })
|
||||||
|
truckAssignmentId?: string | null;
|
||||||
|
|
||||||
|
/** Denormalised plate for display / EDR trucks (which aren't customer trucks). */
|
||||||
|
@Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true })
|
||||||
|
truckPlate?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'mile_type', type: 'varchar', length: 20 })
|
||||||
|
mileType!: HandoverMileType;
|
||||||
|
|
||||||
|
@Column({ name: 'reference', type: 'varchar', length: 100 })
|
||||||
|
reference!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'generated_at', type: 'timestamptz', default: () => 'now()' })
|
||||||
|
generatedAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
|
||||||
|
signedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
signedByUserId?: string | null;
|
||||||
|
|
||||||
|
/** EDR last-mile: when the goods were delivered to the customer. */
|
||||||
|
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
|
||||||
|
deliveredAt?: Date | null;
|
||||||
|
}
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index } from 'typeorm';
|
import { Column, Entity, Index } from 'typeorm';
|
||||||
|
|
||||||
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
|
export const FEE_RULE_TYPES = [
|
||||||
|
'STORAGE_FEE',
|
||||||
|
'DEMURRAGE_FEE',
|
||||||
|
'DOUBLE_HANDLING_FEE',
|
||||||
|
'TRUCK_DETENTION_FEE',
|
||||||
|
] as const;
|
||||||
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
|
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge basis for a DOUBLE_HANDLING_FEE rule (flat rate × the chosen quantity):
|
||||||
|
* - PER_CONTAINER: booking container count
|
||||||
|
* - PER_TON: cargo total in tonnes (bulk cargo)
|
||||||
|
* - PER_ITEM: cargo total item count (break-bulk cargo, e.g. machinery)
|
||||||
|
*/
|
||||||
|
export const FEE_RULE_BASES = ['PER_CONTAINER', 'PER_TON', 'PER_ITEM'] as const;
|
||||||
|
export type FeeRuleBasis = (typeof FEE_RULE_BASES)[number];
|
||||||
|
|
||||||
export interface WarehouseFeeTier {
|
export interface WarehouseFeeTier {
|
||||||
fromDay: number;
|
fromDay: number;
|
||||||
toDay: number | null;
|
toDay: number | null;
|
||||||
@@ -60,6 +74,12 @@ export class WarehouseFeeRule extends BaseEntity {
|
|||||||
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||||
ratePerDay!: number;
|
ratePerDay!: number;
|
||||||
|
|
||||||
|
// Double-handling only: PER_CONTAINER | PER_TON | PER_MACHINERY. The flat rate
|
||||||
|
// (rate_per_day, reused as rate-per-unit) is multiplied by the basis quantity;
|
||||||
|
// free days and tiers do not apply. Null for the day-based fee types.
|
||||||
|
@Column({ name: 'basis', type: 'varchar', length: 20, nullable: true })
|
||||||
|
basis?: FeeRuleBasis | null;
|
||||||
|
|
||||||
@Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" })
|
@Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" })
|
||||||
tiers!: WarehouseFeeTier[];
|
tiers!: WarehouseFeeTier[];
|
||||||
|
|
||||||
|
|||||||
123
apps/edr-freight-api/src/modules/warehouses/handover.service.ts
Normal file
123
apps/edr-freight-api/src/modules/warehouses/handover.service.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||||
|
|
||||||
|
import { BookingHandover } from './entities/booking-handover.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import handover records. A booking has one handover per truck (single truck ⇒
|
||||||
|
* one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type:
|
||||||
|
* - SELF_HAUL: generated when the customer truck arrives, signed before it leaves.
|
||||||
|
* - EDR_LAST_MILE: generated at delivery (after exit).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HandoverService {
|
||||||
|
private readonly logger = new Logger(HandoverService.name);
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
list(bookingId: string): Promise<BookingHandover[]> {
|
||||||
|
return this.dataSource.getRepository(BookingHandover).find({
|
||||||
|
where: { bookingId },
|
||||||
|
order: { generatedAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-haul: ensure a handover exists for a customer truck that just arrived.
|
||||||
|
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
|
||||||
|
* when a manager is supplied.
|
||||||
|
*/
|
||||||
|
async ensureForArrivedTruck(
|
||||||
|
bookingId: string,
|
||||||
|
opts: { truckAssignmentId?: string | null; truckPlate?: string | null },
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<BookingHandover> {
|
||||||
|
const m = manager ?? this.dataSource.manager;
|
||||||
|
const repo = m.getRepository(BookingHandover);
|
||||||
|
const existing = await repo.findOne({
|
||||||
|
where: {
|
||||||
|
bookingId,
|
||||||
|
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const reference = await this.generateReference(bookingId, m);
|
||||||
|
const saved = await repo.save(
|
||||||
|
repo.create({
|
||||||
|
bookingId,
|
||||||
|
truckAssignmentId: opts.truckAssignmentId ?? null,
|
||||||
|
truckPlate: opts.truckPlate ?? null,
|
||||||
|
mileType: 'SELF_HAUL',
|
||||||
|
reference,
|
||||||
|
generatedAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EDR last-mile: generate a handover at delivery (after exit). One per EDR
|
||||||
|
* truck (by plate) or per booking. Idempotent by (booking, plate).
|
||||||
|
*/
|
||||||
|
async ensureAtDelivery(
|
||||||
|
bookingId: string,
|
||||||
|
opts: { truckPlate?: string | null; truckAssignmentId?: string | null },
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<BookingHandover> {
|
||||||
|
const m = manager ?? this.dataSource.manager;
|
||||||
|
const repo = m.getRepository(BookingHandover);
|
||||||
|
const existing = await repo.findOne({
|
||||||
|
where: {
|
||||||
|
bookingId,
|
||||||
|
truckPlate: opts.truckPlate ?? IsNull(),
|
||||||
|
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const reference = await this.generateReference(bookingId, m);
|
||||||
|
return repo.save(
|
||||||
|
repo.create({
|
||||||
|
bookingId,
|
||||||
|
truckAssignmentId: opts.truckAssignmentId ?? null,
|
||||||
|
truckPlate: opts.truckPlate ?? null,
|
||||||
|
mileType: 'EDR_LAST_MILE',
|
||||||
|
reference,
|
||||||
|
generatedAt: new Date(),
|
||||||
|
deliveredAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
|
||||||
|
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(BookingHandover)
|
||||||
|
.update(
|
||||||
|
{ bookingId, signedAt: IsNull() },
|
||||||
|
{ signedAt: new Date(), signedByUserId: userId ?? null },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when every handover on the booking is signed (and at least one exists). */
|
||||||
|
async isFullySigned(bookingId: string): Promise<boolean> {
|
||||||
|
const repo = this.dataSource.getRepository(BookingHandover);
|
||||||
|
const [total, unsigned] = await Promise.all([
|
||||||
|
repo.count({ where: { bookingId } }),
|
||||||
|
repo.count({ where: { bookingId, signedAt: IsNull() } }),
|
||||||
|
]);
|
||||||
|
return total > 0 && unsigned === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateReference(bookingId: string, manager: EntityManager): Promise<string> {
|
||||||
|
const [booking] = await manager.query(
|
||||||
|
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
const ref = String(booking?.reference ?? bookingId).replace(/^BK-?/i, '');
|
||||||
|
const count = await manager.getRepository(BookingHandover).count({ where: { bookingId } });
|
||||||
|
return `HND-${ref}-${String(count + 1).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { ExchangeService } from '@edr/api-common';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||||
import { FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
|
import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
|
||||||
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||||
|
|
||||||
interface ItemAttributes {
|
interface ItemAttributes {
|
||||||
@@ -16,6 +16,8 @@ interface ItemAttributes {
|
|||||||
containerTypeCode: string | null;
|
containerTypeCode: string | null;
|
||||||
inventoryQuantity: number;
|
inventoryQuantity: number;
|
||||||
bookingContainerCount: number;
|
bookingContainerCount: number;
|
||||||
|
/** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */
|
||||||
|
cargoQuantity: number;
|
||||||
facilityId: string | null;
|
facilityId: string | null;
|
||||||
warehouseId: string | null;
|
warehouseId: string | null;
|
||||||
yardId: string | null;
|
yardId: string | null;
|
||||||
@@ -24,6 +26,8 @@ interface ItemAttributes {
|
|||||||
|
|
||||||
export interface FeePreview {
|
export interface FeePreview {
|
||||||
ruleType: FeeRuleType;
|
ruleType: FeeRuleType;
|
||||||
|
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
|
||||||
|
basis: FeeRuleBasis | null;
|
||||||
ruleId: string | null;
|
ruleId: string | null;
|
||||||
ruleName: string | null;
|
ruleName: string | null;
|
||||||
freeDays: number;
|
freeDays: number;
|
||||||
@@ -132,7 +136,8 @@ export class WarehouseFeeService {
|
|||||||
b.trade_direction AS "tradeDirection",
|
b.trade_direction AS "tradeDirection",
|
||||||
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
|
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
|
||||||
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
|
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
|
||||||
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount"
|
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount",
|
||||||
|
COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity"
|
||||||
FROM freight.warehouse_inventory inv
|
FROM freight.warehouse_inventory inv
|
||||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||||
@@ -283,6 +288,10 @@ export class WarehouseFeeService {
|
|||||||
now: Date,
|
now: Date,
|
||||||
billingCurrency: string,
|
billingCurrency: string,
|
||||||
): Promise<FeePreview> {
|
): Promise<FeePreview> {
|
||||||
|
// Double handling is a flat charge (rate × basis quantity), not day-based.
|
||||||
|
if (ruleType === 'DOUBLE_HANDLING_FEE') {
|
||||||
|
return this.computeDoubleHandling(rule, item, now, billingCurrency);
|
||||||
|
}
|
||||||
const start = item.arrivedAt ? new Date(item.arrivedAt) : null;
|
const start = item.arrivedAt ? new Date(item.arrivedAt) : null;
|
||||||
const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
|
const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
|
||||||
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
|
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
|
||||||
@@ -321,6 +330,7 @@ export class WarehouseFeeService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ruleType,
|
ruleType,
|
||||||
|
basis: null,
|
||||||
ruleId: rule?.id ?? null,
|
ruleId: rule?.id ?? null,
|
||||||
ruleName: rule?.name ?? null,
|
ruleName: rule?.name ?? null,
|
||||||
freeDays,
|
freeDays,
|
||||||
@@ -340,13 +350,69 @@ export class WarehouseFeeService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Double handling — a flat one-time charge, not time-based. Amount = rate ×
|
||||||
|
* the basis quantity: PER_CONTAINER (booking container count), or PER_TON /
|
||||||
|
* PER_ITEM (the booking cargo total in the cargo's unit of measure — tonnes
|
||||||
|
* for bulk, item count for break-bulk). No free days, no elapsed days, no tiers.
|
||||||
|
*/
|
||||||
|
private async computeDoubleHandling(
|
||||||
|
rule: WarehouseFeeRule | null,
|
||||||
|
item: ItemAttributes,
|
||||||
|
now: Date,
|
||||||
|
billingCurrency: string,
|
||||||
|
): Promise<FeePreview> {
|
||||||
|
const basis: FeeRuleBasis = rule?.basis ?? 'PER_CONTAINER';
|
||||||
|
const rate = Number(rule?.ratePerDay ?? 0);
|
||||||
|
const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
|
||||||
|
const targetCurrency = this.normalizeCurrency(billingCurrency);
|
||||||
|
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||||
|
const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
|
||||||
|
const containerCount = isContainer
|
||||||
|
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
|
||||||
|
: 1;
|
||||||
|
// PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total,
|
||||||
|
// which is stored in the cargo's own unit of measure.
|
||||||
|
const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0);
|
||||||
|
const quantity = basis === 'PER_CONTAINER' ? containerCount : cargoQuantity;
|
||||||
|
const sourceAmount = Math.round(rate * quantity * 100) / 100;
|
||||||
|
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
|
||||||
|
const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ruleType: 'DOUBLE_HANDLING_FEE',
|
||||||
|
basis,
|
||||||
|
ruleId: rule?.id ?? null,
|
||||||
|
ruleName: rule?.name ?? null,
|
||||||
|
freeDays: 0,
|
||||||
|
ratePerDay: convertedRate,
|
||||||
|
currency: targetCurrency,
|
||||||
|
ruleCurrency,
|
||||||
|
billingCurrency: targetCurrency,
|
||||||
|
startDate: null,
|
||||||
|
endDate: now.toISOString(),
|
||||||
|
endIsOpen: false,
|
||||||
|
elapsedDays: 0,
|
||||||
|
chargeableDays: 0,
|
||||||
|
containerCount,
|
||||||
|
billableUnits: quantity,
|
||||||
|
amount,
|
||||||
|
tiers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
||||||
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
||||||
const item = await this.loadItem(inventoryId);
|
const item = await this.loadItem(inventoryId);
|
||||||
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE'];
|
const byType: FeeRuleType[] = [
|
||||||
|
'DEMURRAGE_FEE',
|
||||||
|
'STORAGE_FEE',
|
||||||
|
'DOUBLE_HANDLING_FEE',
|
||||||
|
'TRUCK_DETENTION_FEE',
|
||||||
|
];
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
byType.map((type) =>
|
byType.map((type) =>
|
||||||
this.compute(
|
this.compute(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
|||||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||||
import { SchedulingReadFacade } from './scheduling-read.facade';
|
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||||
|
import { HandoverService } from './handover.service';
|
||||||
|
|
||||||
@ApiTags('warehouse-inventory')
|
@ApiTags('warehouse-inventory')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -23,6 +24,7 @@ export class WarehouseInventoryController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly inventoryService: WarehouseInventoryService,
|
private readonly inventoryService: WarehouseInventoryService,
|
||||||
private readonly scheduling: SchedulingReadFacade,
|
private readonly scheduling: SchedulingReadFacade,
|
||||||
|
private readonly handoverService: HandoverService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -98,6 +100,27 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.loadedExport();
|
return this.inventoryService.loadedExport();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('loadable-trains')
|
||||||
|
@ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' })
|
||||||
|
loadableTrains() {
|
||||||
|
return this.inventoryService.loadableTrains();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('train/:scheduleId/loadable-items')
|
||||||
|
@ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' })
|
||||||
|
trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||||
|
return this.inventoryService.trainLoadableItems(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('train/:scheduleId/load')
|
||||||
|
@ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' })
|
||||||
|
loadItemsOntoTrain(
|
||||||
|
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
||||||
|
@Body() dto: { inventoryIds: string[]; performedBy?: string },
|
||||||
|
) {
|
||||||
|
return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], dto.performedBy);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('bulk-dispatch-export')
|
@Post('bulk-dispatch-export')
|
||||||
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
|
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
|
||||||
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
|
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
|
||||||
@@ -283,6 +306,19 @@ export class WarehouseInventoryController {
|
|||||||
return res.send(buffer);
|
return res.send(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('customer-truck-exit-paper/:assignmentId')
|
||||||
|
@ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' })
|
||||||
|
async truckExitPaper(
|
||||||
|
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const { filename, buffer } = await this.inventoryService.truckExitPaper(assignmentId);
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
res.setHeader('Content-Length', buffer.length);
|
||||||
|
return res.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/grn-document')
|
@Get(':id/grn-document')
|
||||||
@ApiOperation({ summary: 'View goods received note PDF' })
|
@ApiOperation({ summary: 'View goods received note PDF' })
|
||||||
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||||
@@ -312,6 +348,18 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
|
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('bookings/:bookingId/handovers')
|
||||||
|
@ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' })
|
||||||
|
bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
|
return this.handoverService.list(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('bookings/:bookingId/container-items')
|
||||||
|
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
||||||
|
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
|
return this.inventoryService.containerItems(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/deliver')
|
@Post(':id/deliver')
|
||||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
|||||||
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
||||||
|
import { HandoverService } from './handover.service';
|
||||||
|
|
||||||
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
||||||
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
||||||
@@ -272,6 +273,45 @@ export interface BulkDispatchResult {
|
|||||||
results: { inventoryId: string; status: string; reason?: string }[];
|
results: { inventoryId: string; status: string; reason?: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An EXPORT train (pre-dispatch schedule) that has inventory waiting to be loaded. */
|
||||||
|
export interface LoadableTrainRow {
|
||||||
|
scheduleId: string;
|
||||||
|
trainNumber: string | null;
|
||||||
|
origin: string | null;
|
||||||
|
destination: string | null;
|
||||||
|
status: string;
|
||||||
|
departureTime: string | Date | null;
|
||||||
|
/** Received/ready inventory not yet loaded onto this train. */
|
||||||
|
readyCount: number;
|
||||||
|
/** Inventory already loaded onto this train. */
|
||||||
|
loadedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A warehouse-inventory item (container/cargo) assigned to a train, with its allocated wagon. */
|
||||||
|
export interface TrainLoadableItemRow {
|
||||||
|
id: string;
|
||||||
|
bookingId: string | null;
|
||||||
|
bookingReference: string | null;
|
||||||
|
customerName: string | null;
|
||||||
|
containerNumber: string | null;
|
||||||
|
cargoType: string | null;
|
||||||
|
weight: number | null;
|
||||||
|
grnNumber: string | null;
|
||||||
|
inspectionStatus: string | null;
|
||||||
|
status: string;
|
||||||
|
wagonId: string | null;
|
||||||
|
wagonNumber: string | null;
|
||||||
|
sequenceNo: number | null;
|
||||||
|
/** True only when the item is READY_FOR_LOADING and has an allocated wagon. */
|
||||||
|
loadable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrainLoadResult {
|
||||||
|
loadedCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
results: { inventoryId: string; status: string; reason?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AutoUnloadArrivedResult {
|
export interface AutoUnloadArrivedResult {
|
||||||
unloadedCount: number;
|
unloadedCount: number;
|
||||||
skippedCount: number;
|
skippedCount: number;
|
||||||
@@ -342,6 +382,7 @@ export class WarehouseInventoryService {
|
|||||||
private readonly lastMileService: LastMileService,
|
private readonly lastMileService: LastMileService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly signatures: SignaturesService,
|
private readonly signatures: SignaturesService,
|
||||||
|
private readonly handover: HandoverService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -928,7 +969,7 @@ export class WarehouseInventoryService {
|
|||||||
SET received_to_port = true,
|
SET received_to_port = true,
|
||||||
received_at = COALESCE(bcu.received_at, NOW()),
|
received_at = COALESCE(bcu.received_at, NOW()),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
FROM freight.booking_containers bc
|
FROM freight.booking_container bc
|
||||||
WHERE bc.id = bcu.booking_container_id
|
WHERE bc.id = bcu.booking_container_id
|
||||||
AND bc.booking_id = $1
|
AND bc.booking_id = $1
|
||||||
AND bc.deleted_at IS NULL
|
AND bc.deleted_at IS NULL
|
||||||
@@ -1068,6 +1109,169 @@ export class WarehouseInventoryService {
|
|||||||
return this.exportInventoryByStatus('LOADED');
|
return this.exportInventoryByStatus('LOADED');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Per-train loading (Load to Train tab) ─────────────────────────────────
|
||||||
|
// Loading follows wagon allocation: staff pick an allocated EXPORT train, see
|
||||||
|
// the arrived containers/cargoes assigned to it, and load the ready ones onto
|
||||||
|
// their already-allocated wagons. Reuses the single-item load() machinery.
|
||||||
|
|
||||||
|
/** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */
|
||||||
|
async loadableTrains(): Promise<LoadableTrainRow[]> {
|
||||||
|
const rows: Array<
|
||||||
|
LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
|
||||||
|
> = await this.dataSource.query(
|
||||||
|
`SELECT ts.id AS "scheduleId",
|
||||||
|
ts.train_number AS "trainNumber",
|
||||||
|
oy.code AS "origin",
|
||||||
|
dy.code AS "destination",
|
||||||
|
oy.country AS "originCountry",
|
||||||
|
dy.country AS "destinationCountry",
|
||||||
|
ts.status AS "status",
|
||||||
|
ts.scheduled_departure_date AS "departureTime",
|
||||||
|
(SELECT count(*) FROM freight.train_schedule_bookings tsb
|
||||||
|
JOIN freight.warehouse_inventory inv
|
||||||
|
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
|
||||||
|
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
|
||||||
|
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount",
|
||||||
|
(SELECT count(*) FROM freight.train_schedule_bookings tsb
|
||||||
|
JOIN freight.warehouse_inventory inv
|
||||||
|
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
|
||||||
|
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
|
||||||
|
AND inv.status = 'LOADED') AS "loadedCount"
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||||
|
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||||
|
WHERE ts.deleted_at IS NULL
|
||||||
|
AND ts.status = ANY($1)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM freight.train_schedule_bookings tsb2
|
||||||
|
JOIN freight.warehouse_inventory inv2
|
||||||
|
ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL
|
||||||
|
WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL
|
||||||
|
AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
|
||||||
|
)
|
||||||
|
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
||||||
|
[['DRAFT', 'SCHEDULED']],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.filter(
|
||||||
|
(r) =>
|
||||||
|
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'EXPORT',
|
||||||
|
)
|
||||||
|
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
readyCount: Number(rest.readyCount) || 0,
|
||||||
|
loadedCount: Number(rest.loadedCount) || 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container/cargo inventory items assigned to a train, with the wagon each is
|
||||||
|
* allocated to. Covers the arrived-but-not-loaded set (RECEIVED..READY_FOR_LOADING)
|
||||||
|
* plus already-LOADED items, so the "Received" and "Loaded" stage tabs both fill.
|
||||||
|
*/
|
||||||
|
async trainLoadableItems(scheduleId: string): Promise<TrainLoadableItemRow[]> {
|
||||||
|
const rows: Array<Omit<TrainLoadableItemRow, 'loadable'>> = await this.dataSource.query(
|
||||||
|
`SELECT inv.id AS "id",
|
||||||
|
inv.booking_id AS "bookingId",
|
||||||
|
b.reference AS "bookingReference",
|
||||||
|
company.name AS "customerName",
|
||||||
|
ct.container_number AS "containerNumber",
|
||||||
|
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||||||
|
inv.weight AS "weight",
|
||||||
|
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber",
|
||||||
|
inv.inspection_status AS "inspectionStatus",
|
||||||
|
inv.status AS "status",
|
||||||
|
wl.wagon_id AS "wagonId",
|
||||||
|
wl.wagon_number AS "wagonNumber",
|
||||||
|
wl.sequence_no AS "sequenceNo"
|
||||||
|
FROM freight.train_schedule_bookings tsb
|
||||||
|
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
||||||
|
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||||||
|
JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
|
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||||
|
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT w.id AS wagon_id, w.wagon_number, tsw.sequence_no
|
||||||
|
FROM freight.wagon_booking_allocations wba
|
||||||
|
JOIN freight.train_set_wagons tsw
|
||||||
|
ON tsw.id = wba.train_set_wagon_id
|
||||||
|
AND tsw.train_set_id = ts.train_set_id
|
||||||
|
AND tsw.deleted_at IS NULL
|
||||||
|
JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
|
||||||
|
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL
|
||||||
|
ORDER BY tsw.sequence_no ASC NULLS LAST
|
||||||
|
LIMIT 1
|
||||||
|
) wl ON true
|
||||||
|
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||||
|
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
|
||||||
|
ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`,
|
||||||
|
[scheduleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the selected inventory items onto their allocated wagons for the given
|
||||||
|
* train. Each item must be assigned to this train, READY_FOR_LOADING, and have
|
||||||
|
* an allocated wagon; others are skipped with a reason. When every inventory
|
||||||
|
* item of a booking is loaded, its train_schedule_bookings.loading_status flips
|
||||||
|
* to LOADED so the train's confirm-loading/dispatch step reflects reality.
|
||||||
|
*/
|
||||||
|
async loadItemsOntoTrain(
|
||||||
|
scheduleId: string,
|
||||||
|
inventoryIds: string[],
|
||||||
|
performedBy?: string,
|
||||||
|
): Promise<TrainLoadResult> {
|
||||||
|
const result: TrainLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };
|
||||||
|
const items = await this.trainLoadableItems(scheduleId);
|
||||||
|
const byId = new Map(items.map((i) => [i.id, i]));
|
||||||
|
const affectedBookingIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const inventoryId of inventoryIds) {
|
||||||
|
const skip = (reason: string) => {
|
||||||
|
result.skippedCount += 1;
|
||||||
|
result.results.push({ inventoryId, status: 'SKIPPED', reason });
|
||||||
|
};
|
||||||
|
const item = byId.get(inventoryId);
|
||||||
|
if (!item) { skip('Not assigned to this train'); continue; }
|
||||||
|
if (item.status === 'LOADED') { skip('Already loaded'); continue; }
|
||||||
|
if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; }
|
||||||
|
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.load(inventoryId, { wagonId: item.wagonId, loadedBy: performedBy });
|
||||||
|
result.loadedCount += 1;
|
||||||
|
result.results.push({ inventoryId, status: 'LOADED' });
|
||||||
|
if (item.bookingId) affectedBookingIds.add(item.bookingId);
|
||||||
|
} catch (error) {
|
||||||
|
skip(error instanceof Error ? error.message : 'Load failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip a booking's train loading_status to LOADED once no un-loaded inventory remains.
|
||||||
|
for (const bookingId of affectedBookingIds) {
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE freight.train_schedule_bookings tsb
|
||||||
|
SET loading_status = 'LOADED', updated_at = NOW()
|
||||||
|
WHERE tsb.train_schedule_id = $1 AND tsb.booking_id = $2 AND tsb.deleted_at IS NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.warehouse_inventory inv
|
||||||
|
WHERE inv.booking_id = $2 AND inv.deleted_at IS NULL
|
||||||
|
AND inv.status NOT IN ('LOADED', 'DISPATCHED')
|
||||||
|
)`,
|
||||||
|
[scheduleId, bookingId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */
|
/** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */
|
||||||
private async importQueueByStatuses(statuses: string[]): Promise<ImportUnloadedRow[]> {
|
private async importQueueByStatuses(statuses: string[]): Promise<ImportUnloadedRow[]> {
|
||||||
const rows: Array<
|
const rows: Array<
|
||||||
@@ -1799,7 +2003,7 @@ export class WarehouseInventoryService {
|
|||||||
SET received_to_port = true,
|
SET received_to_port = true,
|
||||||
received_at = COALESCE(bcu.received_at, NOW()),
|
received_at = COALESCE(bcu.received_at, NOW()),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
FROM freight.booking_containers bc, freight.containers cont
|
FROM freight.booking_container bc, freight.containers cont
|
||||||
WHERE bc.id = bcu.booking_container_id
|
WHERE bc.id = bcu.booking_container_id
|
||||||
AND bc.booking_id = $1
|
AND bc.booking_id = $1
|
||||||
AND bc.deleted_at IS NULL
|
AND bc.deleted_at IS NULL
|
||||||
@@ -2080,9 +2284,14 @@ export class WarehouseInventoryService {
|
|||||||
[item.bookingId],
|
[item.bookingId],
|
||||||
);
|
);
|
||||||
const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
|
const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
|
||||||
if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) {
|
// Self-haul: the handover must be signed before the exit paper is issued.
|
||||||
|
// Prefer the structured handover record; fall back to the legacy note.
|
||||||
|
const handoverSigned =
|
||||||
|
(await this.handover.isFullySigned(item.bookingId)) ||
|
||||||
|
Boolean(this.extractCustomerDeliveryApproval(item.notes));
|
||||||
|
if (usesCustomerTruck && !handoverSigned) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Customer must approve delivery (sign the handover) before the exit paper can be generated',
|
'Customer must sign the handover before the exit paper can be generated',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2137,6 +2346,16 @@ export class WarehouseInventoryService {
|
|||||||
AND deleted_at IS NULL`,
|
AND deleted_at IS NULL`,
|
||||||
[item.bookingId],
|
[item.bookingId],
|
||||||
);
|
);
|
||||||
|
// Self-haul: generate the per-booking handover on first truck arrival
|
||||||
|
// (idempotent). It must be signed before the truck leaves.
|
||||||
|
const [selfHaul]: Array<{ ok: number }> = await manager.query(
|
||||||
|
`SELECT 1 AS ok FROM freight.bookings
|
||||||
|
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`,
|
||||||
|
[item.bookingId],
|
||||||
|
);
|
||||||
|
if (selfHaul) {
|
||||||
|
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await this.activityLog.record(
|
await this.activityLog.record(
|
||||||
{
|
{
|
||||||
@@ -2231,7 +2450,7 @@ export class WarehouseInventoryService {
|
|||||||
FROM freight.customer_truck_containers cc
|
FROM freight.customer_truck_containers cc
|
||||||
JOIN freight.booking_container_units bcu
|
JOIN freight.booking_container_units bcu
|
||||||
ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL
|
ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL
|
||||||
JOIN freight.booking_containers bc
|
JOIN freight.booking_container bc
|
||||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
AND bc.booking_id = c.booking_id
|
AND bc.booking_id = c.booking_id
|
||||||
WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL
|
WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL
|
||||||
@@ -2291,6 +2510,204 @@ export class WarehouseInventoryService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-container (or bulk) items of a booking with their lifecycle stage and
|
||||||
|
* reference sources — drives the container-level detail datatable (stage tabs,
|
||||||
|
* multiselect load-to-truck, per-item actions).
|
||||||
|
*/
|
||||||
|
async containerItems(bookingId: string): Promise<
|
||||||
|
Array<{
|
||||||
|
containerNumber: string;
|
||||||
|
goods: string | null;
|
||||||
|
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||||
|
grnNumber: string | null;
|
||||||
|
truckAssignmentId: string | null;
|
||||||
|
truckPlate: string | null;
|
||||||
|
truckArrived: boolean;
|
||||||
|
truckLeft: boolean;
|
||||||
|
bookingReference: string | null;
|
||||||
|
contractId: string | null;
|
||||||
|
hasLastMile: boolean;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const rows: Array<{
|
||||||
|
containerNumber: string;
|
||||||
|
goods: string | null;
|
||||||
|
received: boolean;
|
||||||
|
grnNumber: string | null;
|
||||||
|
truckAssignmentId: string | null;
|
||||||
|
truckPlate: string | null;
|
||||||
|
truckArrived: boolean;
|
||||||
|
truckLeft: boolean;
|
||||||
|
bookingReference: string | null;
|
||||||
|
contractId: string | null;
|
||||||
|
hasLastMile: boolean;
|
||||||
|
delivered: boolean;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`SELECT bcu.container_number AS "containerNumber",
|
||||||
|
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods,
|
||||||
|
bcu.received_to_port AS received,
|
||||||
|
bcu.grn_number AS "grnNumber",
|
||||||
|
ctc.assignment_id AS "truckAssignmentId",
|
||||||
|
a.plate_number AS "truckPlate",
|
||||||
|
(a.arrived_at IS NOT NULL) AS "truckArrived",
|
||||||
|
(a.departed_at IS NOT NULL) AS "truckLeft",
|
||||||
|
b.reference AS "bookingReference",
|
||||||
|
b.contract_id AS "contractId",
|
||||||
|
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
|
||||||
|
COALESCE(inv.status = 'DELIVERED', false) AS delivered
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_container bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
JOIN freight.bookings b ON b.id = bc.booking_id
|
||||||
|
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||||
|
LEFT JOIN freight.customer_truck_containers ctc
|
||||||
|
ON ctc.container_number = bcu.container_number
|
||||||
|
AND ctc.booking_id = b.id AND ctc.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.customer_truck_assignments a
|
||||||
|
ON a.id = ctc.assignment_id AND a.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.containers cont ON cont.container_number = bcu.container_number
|
||||||
|
LEFT JOIN freight.warehouse_inventory inv
|
||||||
|
ON inv.container_id = cont.id AND inv.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
|
||||||
|
ORDER BY bcu.container_number`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
containerNumber: r.containerNumber,
|
||||||
|
goods: r.goods,
|
||||||
|
stage: r.delivered
|
||||||
|
? 'DELIVERED'
|
||||||
|
: r.truckLeft
|
||||||
|
? 'LEFT'
|
||||||
|
: r.truckAssignmentId
|
||||||
|
? 'LOADED'
|
||||||
|
: r.grnNumber
|
||||||
|
? 'GRN'
|
||||||
|
: r.received
|
||||||
|
? 'RECEIVED'
|
||||||
|
: 'PENDING',
|
||||||
|
grnNumber: r.grnNumber,
|
||||||
|
truckAssignmentId: r.truckAssignmentId,
|
||||||
|
truckPlate: r.truckPlate,
|
||||||
|
truckArrived: r.truckArrived,
|
||||||
|
truckLeft: r.truckLeft,
|
||||||
|
bookingReference: r.bookingReference,
|
||||||
|
contractId: r.contractId,
|
||||||
|
hasLastMile: r.hasLastMile,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-truck exit paper: one paper covering the containers loaded on a specific
|
||||||
|
* customer truck (used when multiple trucks leave separately). Gated on the
|
||||||
|
* handover being signed and warehouse fees paid.
|
||||||
|
*/
|
||||||
|
async truckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
|
const [truck] = await this.dataSource.query(
|
||||||
|
`SELECT a.booking_id AS "bookingId", a.plate_number AS "plateNumber",
|
||||||
|
a.driver_name AS "driverName", a.truck_type AS "truckType",
|
||||||
|
a.gross_weight_kg AS "grossWeightKg", a.departed_at AS "departedAt",
|
||||||
|
b.reference AS "bookingReference", company.name AS "customerName"
|
||||||
|
FROM freight.customer_truck_assignments a
|
||||||
|
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
|
WHERE a.id = $1 AND a.deleted_at IS NULL`,
|
||||||
|
[assignmentId],
|
||||||
|
);
|
||||||
|
if (!truck) throw new NotFoundException(`Truck assignment ${assignmentId} not found`);
|
||||||
|
|
||||||
|
if (!(await this.handover.isFullySigned(truck.bookingId))) {
|
||||||
|
throw new BadRequestException('Handover must be signed before the exit paper can be generated');
|
||||||
|
}
|
||||||
|
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||||||
|
`SELECT id FROM freight.warehouse_inventory
|
||||||
|
WHERE booking_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1`,
|
||||||
|
[truck.bookingId],
|
||||||
|
);
|
||||||
|
if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id);
|
||||||
|
|
||||||
|
const containers: Array<{ containerNumber: string; goods: string | null }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT c.container_number AS "containerNumber",
|
||||||
|
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods
|
||||||
|
FROM freight.customer_truck_containers c
|
||||||
|
JOIN freight.bookings b ON b.id = c.booking_id
|
||||||
|
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||||
|
WHERE c.assignment_id = $1 AND c.deleted_at IS NULL
|
||||||
|
ORDER BY c.container_number`,
|
||||||
|
[assignmentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = this.buildTruckExitPaperHtml({
|
||||||
|
reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`,
|
||||||
|
bookingReference: truck.bookingReference,
|
||||||
|
customerName: truck.customerName,
|
||||||
|
plateNumber: truck.plateNumber,
|
||||||
|
driverName: truck.driverName,
|
||||||
|
truckType: truck.truckType,
|
||||||
|
grossWeightKg: Number(truck.grossWeightKg ?? 0),
|
||||||
|
gateOut: truck.departedAt,
|
||||||
|
containers,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||||
|
buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTruckExitPaperHtml(data: {
|
||||||
|
reference: string;
|
||||||
|
bookingReference: string;
|
||||||
|
customerName: string | null;
|
||||||
|
plateNumber: string;
|
||||||
|
driverName: string;
|
||||||
|
truckType: string;
|
||||||
|
grossWeightKg: number;
|
||||||
|
gateOut: string | Date | null;
|
||||||
|
containers: Array<{ containerNumber: string; goods: string | null }>;
|
||||||
|
}): string {
|
||||||
|
const esc = (v: unknown) =>
|
||||||
|
String(v ?? '-').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const gateOut = data.gateOut ? new Date(data.gateOut).toLocaleString('en-GB') : '-';
|
||||||
|
const rows: Array<[string, string]> = [
|
||||||
|
['Booking Reference', data.bookingReference],
|
||||||
|
['Customer / Consignee', data.customerName ?? '-'],
|
||||||
|
['Pickup Truck Plate', data.plateNumber],
|
||||||
|
['Driver', data.driverName],
|
||||||
|
['Truck Type', data.truckType],
|
||||||
|
['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} kg`],
|
||||||
|
['Gate-Out Time', gateOut],
|
||||||
|
['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'],
|
||||||
|
];
|
||||||
|
const containerRows = data.containers.length
|
||||||
|
? data.containers
|
||||||
|
.map((c) => `<tr><td>${esc(c.containerNumber)}</td><td>${esc(c.goods)}</td></tr>`)
|
||||||
|
.join('')
|
||||||
|
: '<tr><td colspan="2">No containers loaded on this truck.</td></tr>';
|
||||||
|
return `<!doctype html><html><head><meta charset="utf-8" /><title>Warehouse Exit Paper</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 24px; }
|
||||||
|
h1 { font-size: 24px; text-transform: uppercase; margin: 0 0 4px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
|
||||||
|
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12px; text-align: left; vertical-align: top; }
|
||||||
|
th { background: #f8fafc; width: 34%; font-weight: 800; }
|
||||||
|
.section { margin-top: 18px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .1em; }
|
||||||
|
.ref strong { font-size: 16px; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div style="color:#064c27;font-weight:800;text-transform:uppercase;">Ethio-Djibouti Railway S.C.</div>
|
||||||
|
<h1>Warehouse Release / Exit Paper</h1>
|
||||||
|
<div class="ref">Document / Release No. <strong>${esc(data.reference)}</strong></div>
|
||||||
|
<div class="section">Release Particulars</div>
|
||||||
|
<table><tbody>${rows.map(([l, v]) => `<tr><th>${esc(l)}</th><td>${esc(v)}</td></tr>`).join('')}</tbody></table>
|
||||||
|
<div class="section">Containers Leaving on This Truck</div>
|
||||||
|
<table><thead><tr><th style="width:40%">Container Number</th><th>Goods</th></tr></thead>
|
||||||
|
<tbody>${containerRows}</tbody></table>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
|
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
|
||||||
async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const [row] = await this.dataSource.query(
|
const [row] = await this.dataSource.query(
|
||||||
@@ -2388,7 +2805,17 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||||
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
|
// Styled fallback titled as a GRN (not a release order) for Chromium-less render.
|
||||||
|
buffer: await this.releaseDocuments.renderStyledDocument(
|
||||||
|
html,
|
||||||
|
{
|
||||||
|
titleLines: ['GOODS RECEIVED', 'NOTE'],
|
||||||
|
subtitle: 'OFFICIAL WAREHOUSE GOODS RECEIVED NOTE',
|
||||||
|
sectionTitle: 'RECEIVED PARTICULARS',
|
||||||
|
refLabel: 'GRN No.',
|
||||||
|
},
|
||||||
|
'Goods Received Note',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2462,6 +2889,10 @@ export class WarehouseInventoryService {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sign the structured handover record(s) for this booking (self-haul: before
|
||||||
|
// the truck leaves). Kept alongside the legacy approval note.
|
||||||
|
await this.handover.signForBooking(bookingId, userId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookingId,
|
bookingId,
|
||||||
inventoryId: item.id,
|
inventoryId: item.id,
|
||||||
@@ -2589,7 +3020,17 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||||
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
|
// Styled fallback titled as a handover (not a release order) for Chromium-less render.
|
||||||
|
buffer: await this.releaseDocuments.renderStyledDocument(
|
||||||
|
html,
|
||||||
|
{
|
||||||
|
titleLines: ['IMPORT GOODS', 'HANDOVER', 'DOCUMENT'],
|
||||||
|
subtitle: 'EDR TO CUSTOMER WAREHOUSE HANDOVER',
|
||||||
|
sectionTitle: 'HANDOVER PARTICULARS',
|
||||||
|
refLabel: 'Document / Handover No.',
|
||||||
|
},
|
||||||
|
'Import Goods Handover',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2601,6 +3042,29 @@ export class WarehouseInventoryService {
|
|||||||
throw new BadRequestException('A release order must be issued before the goods can be delivered');
|
throw new BadRequestException('A release order must be issued before the goods can be delivered');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Self-haul: the customer's own truck delivers — deliver only after the
|
||||||
|
// handover is signed AND the truck has left the warehouse holding the goods.
|
||||||
|
if (item.bookingId) {
|
||||||
|
const [sh]: Array<{ assignedAt: string | null }> = await this.dataSource.query(
|
||||||
|
`SELECT customer_truck_assigned_at AS "assignedAt"
|
||||||
|
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[item.bookingId],
|
||||||
|
);
|
||||||
|
if (sh?.assignedAt) {
|
||||||
|
if (!(await this.handover.isFullySigned(item.bookingId))) {
|
||||||
|
throw new BadRequestException('Handover must be signed before delivery');
|
||||||
|
}
|
||||||
|
const [left]: Array<{ n: string }> = await this.dataSource.query(
|
||||||
|
`SELECT COUNT(*) AS n FROM freight.customer_truck_assignments
|
||||||
|
WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`,
|
||||||
|
[item.bookingId],
|
||||||
|
);
|
||||||
|
if (Number(left?.n ?? 0) === 0) {
|
||||||
|
throw new BadRequestException('Deliver is available only after the customer truck has left');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const receiverName = dto.receiverName.trim();
|
const receiverName = dto.receiverName.trim();
|
||||||
const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date();
|
const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date();
|
||||||
const weight = Number(item.weight) || 0;
|
const weight = Number(item.weight) || 0;
|
||||||
@@ -2645,6 +3109,27 @@ export class WarehouseInventoryService {
|
|||||||
},
|
},
|
||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handover on delivery. EDR last-mile generates its handover HERE (after
|
||||||
|
// exit, on delivery). Self-haul handovers were generated on arrival —
|
||||||
|
// stamp them delivered.
|
||||||
|
if (item.bookingId) {
|
||||||
|
const [b]: Array<{ selfHaul: string | null }> = await manager.query(
|
||||||
|
`SELECT customer_truck_assigned_at AS "selfHaul"
|
||||||
|
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[item.bookingId],
|
||||||
|
);
|
||||||
|
if (b?.selfHaul) {
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.booking_handovers
|
||||||
|
SET delivered_at = COALESCE(delivered_at, NOW()), updated_at = NOW()
|
||||||
|
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[item.bookingId],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.handover.ensureAtDelivery(item.bookingId, {}, manager);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
|
|||||||
@@ -182,25 +182,38 @@ export class WarehouseInvoiceService {
|
|||||||
const items = previews
|
const items = previews
|
||||||
.filter((p) => p.amount > 0)
|
.filter((p) => p.amount > 0)
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
const feeType: WarehouseFeeType =
|
const days = `${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)`;
|
||||||
p.ruleType === "STORAGE_FEE"
|
const tierSuffix = p.tiers.length ? " using tiered tariff" : ` after ${p.freeDays} free`;
|
||||||
? "STORAGE_FEE"
|
let feeType: WarehouseFeeType;
|
||||||
: isContainer
|
let description: string;
|
||||||
? "CONTAINER_DEMURRAGE"
|
switch (p.ruleType) {
|
||||||
: "BULK_DEMURRAGE";
|
case "STORAGE_FEE":
|
||||||
|
feeType = "STORAGE_FEE";
|
||||||
|
description = `Storage fee - ${days}${tierSuffix}`;
|
||||||
|
break;
|
||||||
|
case "DOUBLE_HANDLING_FEE": {
|
||||||
|
feeType = "DOUBLE_HANDLING";
|
||||||
|
const unit =
|
||||||
|
p.basis === "PER_TON"
|
||||||
|
? "ton(s)"
|
||||||
|
: p.basis === "PER_ITEM"
|
||||||
|
? "item(s)"
|
||||||
|
: "container(s)";
|
||||||
|
description = `Double handling - ${p.billableUnits} ${unit}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "TRUCK_DETENTION_FEE":
|
||||||
|
feeType = "TRUCK_DETENTION";
|
||||||
|
description = `Truck detention - ${days}${tierSuffix}`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
feeType = isContainer ? "CONTAINER_DEMURRAGE" : "BULK_DEMURRAGE";
|
||||||
|
description = `${isContainer ? "Container" : "Bulk"} demurrage - ${days}${tierSuffix}`;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
feeRuleId: p.ruleId,
|
feeRuleId: p.ruleId,
|
||||||
feeType,
|
feeType,
|
||||||
description:
|
description,
|
||||||
p.ruleType === "STORAGE_FEE"
|
|
||||||
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
|
|
||||||
? " using tiered tariff"
|
|
||||||
: ` after ${p.freeDays} free`
|
|
||||||
}`
|
|
||||||
: `${isContainer ? "Container" : "Bulk"} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
|
|
||||||
? " using tiered tariff"
|
|
||||||
: ` after ${p.freeDays} free`
|
|
||||||
}`,
|
|
||||||
quantity: p.billableUnits,
|
quantity: p.billableUnits,
|
||||||
unitRate: p.ratePerDay,
|
unitRate: p.ratePerDay,
|
||||||
amount: p.amount,
|
amount: p.amount,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export const WAREHOUSE_FEE_TYPES = [
|
|||||||
'BULK_DEMURRAGE',
|
'BULK_DEMURRAGE',
|
||||||
'STORAGE_FEE',
|
'STORAGE_FEE',
|
||||||
'HANDLING_FEE',
|
'HANDLING_FEE',
|
||||||
|
'DOUBLE_HANDLING',
|
||||||
|
'TRUCK_DETENTION',
|
||||||
] as const;
|
] as const;
|
||||||
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
|
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||||
|
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
|
||||||
|
|
||||||
const MIN_VALID_PDF_BYTES = 2_000;
|
const MIN_VALID_PDF_BYTES = 2_000;
|
||||||
|
|
||||||
@@ -32,16 +33,51 @@ export class WarehouseReleaseDocumentService {
|
|||||||
return this.pdf.htmlToPdfBuffer(html, { label });
|
return this.pdf.htmlToPdfBuffer(html, { label });
|
||||||
}
|
}
|
||||||
|
|
||||||
private htmlToBasicPdfBuffer(html: string): Buffer {
|
/**
|
||||||
|
* Render a "summary tiles + one table + notice + signatures" document (the
|
||||||
|
* marshalling / load-list layout) with a STYLED table-aware fallback for when
|
||||||
|
* Chromium is unavailable — so the manifest draws as a real gridded document
|
||||||
|
* instead of a flat plain-text dump.
|
||||||
|
*/
|
||||||
|
renderTabularDocument(html: string, label = 'Document'): Promise<Buffer> {
|
||||||
|
return this.pdf.htmlToPdfBuffer(html, {
|
||||||
|
label,
|
||||||
|
fallback: (preparedHtml) => buildTabularFallbackPdf(preparedHtml),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render document HTML with a STYLED hand-built fallback (the release layout,
|
||||||
|
* but with a custom title + section heading) for when Chromium is unavailable.
|
||||||
|
* Handover / GRN use this so their fallback looks like a proper document —
|
||||||
|
* not a plain-text dump, and not mislabelled as a release order.
|
||||||
|
*/
|
||||||
|
renderStyledDocument(
|
||||||
|
html: string,
|
||||||
|
fallbackOpts: { titleLines?: string[]; subtitle?: string; sectionTitle?: string; refLabel?: string },
|
||||||
|
label = 'Document',
|
||||||
|
): Promise<Buffer> {
|
||||||
|
return this.pdf.htmlToPdfBuffer(html, {
|
||||||
|
label,
|
||||||
|
fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml, fallbackOpts),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private htmlToBasicPdfBuffer(
|
||||||
|
html: string,
|
||||||
|
opts?: { titleLines?: string[]; subtitle?: string; sectionTitle?: string; refLabel?: string },
|
||||||
|
): Buffer {
|
||||||
const doc = this.extractReleaseDocument(html);
|
const doc = this.extractReleaseDocument(html);
|
||||||
|
const titleLines = (opts?.titleLines ?? ['WAREHOUSE GATE', 'CLEARANCE / RELEASE', 'ORDER']).slice(0, 3);
|
||||||
|
const subtitle = opts?.subtitle ?? 'OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION';
|
||||||
|
const sectionTitle = opts?.sectionTitle ?? 'RELEASE PARTICULARS';
|
||||||
|
const refLabel = opts?.refLabel ?? 'Document / Release No.';
|
||||||
const body: string[] = [
|
const body: string[] = [
|
||||||
this.lineOp(36, 810, 559, 810, '0 0 0', 2.2),
|
this.lineOp(36, 810, 559, 810, '0 0 0', 2.2),
|
||||||
this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'),
|
this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'),
|
||||||
this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'),
|
...titleLines.map((line, i) => this.textOp(line, 36, 764 - i * 22, 24, 'F2', '0.02 0.08 0.16')),
|
||||||
this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'),
|
this.textOp(subtitle, 36, 764 - titleLines.length * 22 + 2, 8.5, 'F1', '0.25 0.34 0.45'),
|
||||||
this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'),
|
this.textOp(refLabel, 424, 781, 8.5, 'F1', '0.15 0.22 0.32'),
|
||||||
this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'),
|
|
||||||
this.textOp('Document / Release No.', 424, 781, 8.5, 'F1', '0.15 0.22 0.32'),
|
|
||||||
this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'),
|
this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'),
|
||||||
this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'),
|
this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'),
|
||||||
this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2),
|
this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2),
|
||||||
@@ -50,7 +86,7 @@ export class WarehouseReleaseDocumentService {
|
|||||||
...this.wrapLines(doc.notice, 68)
|
...this.wrapLines(doc.notice, 68)
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
.map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')),
|
.map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')),
|
||||||
this.textOp('RELEASE PARTICULARS', 36, 604, 10, 'F2', '0.08 0.32 0.18'),
|
this.textOp(sectionTitle, 36, 604, 10, 'F2', '0.08 0.32 0.18'),
|
||||||
];
|
];
|
||||||
|
|
||||||
let y = 586;
|
let y = 586;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.en
|
|||||||
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||||
|
import { BookingHandover } from './entities/booking-handover.entity';
|
||||||
|
import { HandoverService } from './handover.service';
|
||||||
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||||||
import { WarehouseLoading } from './entities/warehouse-loading.entity';
|
import { WarehouseLoading } from './entities/warehouse-loading.entity';
|
||||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||||
@@ -65,6 +67,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehouseInspectionReport,
|
WarehouseInspectionReport,
|
||||||
WarehouseAllocationRule,
|
WarehouseAllocationRule,
|
||||||
WarehouseFeeRule,
|
WarehouseFeeRule,
|
||||||
|
BookingHandover,
|
||||||
]),
|
]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
@@ -113,6 +116,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehouseSchedulingAdapterService,
|
WarehouseSchedulingAdapterService,
|
||||||
WarehouseReleaseDocumentService,
|
WarehouseReleaseDocumentService,
|
||||||
SchedulingReadFacade,
|
SchedulingReadFacade,
|
||||||
|
HandoverService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
WarehousesService,
|
WarehousesService,
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const COMPANY_TIN = 'FLMDEMO001';
|
|||||||
const COMPANY_EMAIL = 'first-last-mile-demo@edr.local';
|
const COMPANY_EMAIL = 'first-last-mile-demo@edr.local';
|
||||||
|
|
||||||
const YARDS = [
|
const YARDS = [
|
||||||
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
|
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 },
|
||||||
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
|
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CONTAINER_TYPES = [
|
const CONTAINER_TYPES = [
|
||||||
|
|||||||
@@ -28,17 +28,22 @@ const COMPANY_EMAIL = "train-scheduling-demo@edr.local";
|
|||||||
const COMPANY_TIN = "1234567890";
|
const COMPANY_TIN = "1234567890";
|
||||||
|
|
||||||
const YARDS = [
|
const YARDS = [
|
||||||
{ code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 },
|
{
|
||||||
|
code: "DJIBOUTI",
|
||||||
|
label: "Djibouti",
|
||||||
|
country: "Djibouti" as const,
|
||||||
|
displayOrder: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
code: "ADDIS_ABABA",
|
code: "ADDIS_ABABA",
|
||||||
label: "Addis Ababa",
|
label: "Addis Ababa",
|
||||||
country: "Ethiopia",
|
country: "Ethiopia" as const,
|
||||||
displayOrder: 2,
|
displayOrder: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "DIRE_DAWA",
|
code: "DIRE_DAWA",
|
||||||
label: "Dire Dawa",
|
label: "Dire Dawa",
|
||||||
country: "Ethiopia",
|
country: "Ethiopia" as const,
|
||||||
displayOrder: 3,
|
displayOrder: 3,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,30 +1,20 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
|
Application,
|
||||||
Organization,
|
Organization,
|
||||||
OrganizationConfiguration,
|
OrganizationConfiguration,
|
||||||
Permission,
|
Permission,
|
||||||
Position,
|
|
||||||
PositionPermission,
|
|
||||||
PositionType,
|
|
||||||
Role,
|
|
||||||
RolePermission,
|
|
||||||
Unit,
|
Unit,
|
||||||
} from "@tria-plc/iamapi-common";
|
} from "@tria-plc/iamapi-common";
|
||||||
import { DataSource, EntityManager, In } from "typeorm";
|
import { DataSource, EntityManager } from "typeorm";
|
||||||
|
|
||||||
import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum";
|
|
||||||
import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry";
|
|
||||||
import {
|
import {
|
||||||
EDR_FREIGHT_POSITIONS,
|
EDR_FREIGHT_APPLICATION,
|
||||||
EDR_FREIGHT_ROLES,
|
EDR_FREIGHT_PERMISSIONS,
|
||||||
type FreightSeedPosition,
|
|
||||||
type FreightSeedRole,
|
|
||||||
} from "./edr-freight.seed";
|
} from "./edr-freight.seed";
|
||||||
|
|
||||||
const EDR_UNIT_KEY = "edr_freight_hq";
|
const EDR_UNIT_KEY = "edr_freight_app";
|
||||||
const EDR_UNIT_NAME = { en: "EDR Freight HQ" };
|
const EDR_UNIT_NAME = { en: "EDR Freight App" };
|
||||||
const EDR_POSITION_TYPE_KEY = "edr_freight_role";
|
|
||||||
const EDR_POSITION_TYPE_NAME = { en: "EDR Freight Role" };
|
|
||||||
|
|
||||||
const EDR_ORG_KEY = "edr_freight";
|
const EDR_ORG_KEY = "edr_freight";
|
||||||
const EDR_ORG_NAME = { en: "EDR Freight" };
|
const EDR_ORG_NAME = { en: "EDR Freight" };
|
||||||
@@ -51,22 +41,15 @@ export class EdrOrgSeeder {
|
|||||||
const organization = await this.ensureOrganization(manager);
|
const organization = await this.ensureOrganization(manager);
|
||||||
|
|
||||||
await this.ensureOrganizationConfiguration(manager, organization.id);
|
await this.ensureOrganizationConfiguration(manager, organization.id);
|
||||||
await this.ensureRoles(manager, EDR_FREIGHT_ROLES);
|
await this.ensureDefaultUnit(manager, organization.id);
|
||||||
await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES);
|
|
||||||
await this.ensureSuperAdminPermissions(manager);
|
|
||||||
|
|
||||||
// Positions-as-roles: seed operational positions and grant their
|
const application = await this.ensureApplication(manager);
|
||||||
// permissions via PositionPermission (not Role/RolePermission).
|
await this.ensurePermissions(manager, application.id);
|
||||||
const unit = await this.ensureDefaultUnit(manager, organization.id);
|
|
||||||
const positionType = await this.ensureDefaultPositionType(manager, unit.id);
|
// Roles, positions and their permission links are intentionally NOT
|
||||||
await this.ensurePositions(
|
// seeded for now — only the application-scoped permission catalog,
|
||||||
manager,
|
// mirroring how the default IAM seed relates permissions to their
|
||||||
organization.id,
|
// application. Grants are assigned later through the IAM UI.
|
||||||
unit.id,
|
|
||||||
positionType.id,
|
|
||||||
EDR_FREIGHT_POSITIONS,
|
|
||||||
);
|
|
||||||
await this.ensurePositionPermissions(manager, unit.id, EDR_FREIGHT_POSITIONS);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`);
|
this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`);
|
||||||
@@ -128,111 +111,6 @@ export class EdrOrgSeeder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureRoles(manager: EntityManager, seedRoles: FreightSeedRole[]) {
|
|
||||||
await manager.getRepository(Role).upsert(
|
|
||||||
seedRoles.map(({ key, name }) => ({ key, name })),
|
|
||||||
{
|
|
||||||
conflictPaths: { key: true },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Ensured EDR roles '${seedRoles.map((role) => role.key).join("', '")}'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureRolePermissions(
|
|
||||||
manager: EntityManager,
|
|
||||||
seedRoles: FreightSeedRole[],
|
|
||||||
) {
|
|
||||||
const permissionKeys = [...new Set(seedRoles.flatMap((role) => role.permissionKeys))];
|
|
||||||
|
|
||||||
if (!permissionKeys.length) {
|
|
||||||
this.logger.log("No EDR role permissions configured; skipping role-permission links");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleRepository = manager.getRepository(Role);
|
|
||||||
const rolePermissionRepository = manager.getRepository(RolePermission);
|
|
||||||
|
|
||||||
const roles = await roleRepository.find({
|
|
||||||
where: { key: In(seedRoles.map((role) => role.key)) },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
const seededPermissions = await manager.getRepository(Permission).find({
|
|
||||||
where: { key: In(permissionKeys) },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const roleByKey = new Map(roles.map((role) => [role.key, role]));
|
|
||||||
const permissionByKey = new Map(
|
|
||||||
seededPermissions.map((permission) => [permission.key, permission]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const rolePermissions = seedRoles.flatMap((role) => {
|
|
||||||
const seededRole = roleByKey.get(role.key);
|
|
||||||
|
|
||||||
if (!seededRole) {
|
|
||||||
throw new Error(`missing_role:${role.key}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return role.permissionKeys.map((permissionKey) => {
|
|
||||||
const seededPermission = permissionByKey.get(permissionKey);
|
|
||||||
|
|
||||||
if (!seededPermission) {
|
|
||||||
throw new Error(`missing_permission:${permissionKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
roleId: seededRole.id,
|
|
||||||
permissionId: seededPermission.id,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await rolePermissionRepository.upsert(rolePermissions, {
|
|
||||||
conflictPaths: { roleId: true, permissionId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureSuperAdminPermissions(manager: EntityManager) {
|
|
||||||
const role = await manager.getRepository(Role).findOne({
|
|
||||||
where: { key: ERoleKey.SUPER_ADMIN },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!role) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Role ${ERoleKey.SUPER_ADMIN} not found; skipping booking/rule-engine super_admin links`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissions = await manager.getRepository(Permission).find({
|
|
||||||
where: { key: In(BOOKING_RULE_ENGINE_PERMISSION_KEYS) },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!permissions.length) {
|
|
||||||
this.logger.warn('No booking/rule-engine permissions found for super_admin');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await manager.getRepository(RolePermission).upsert(
|
|
||||||
permissions.map((permission) => ({
|
|
||||||
roleId: role.id,
|
|
||||||
permissionId: permission.id,
|
|
||||||
})),
|
|
||||||
{ conflictPaths: { roleId: true, permissionId: true } },
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Ensured ${permissions.length} booking+rule-engine permissions on super_admin`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureDefaultUnit(
|
private async ensureDefaultUnit(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
@@ -258,120 +136,51 @@ export class EdrOrgSeeder {
|
|||||||
return { id: unit.id };
|
return { id: unit.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureDefaultPositionType(
|
private async ensureApplication(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
unitId: string,
|
|
||||||
): Promise<{ id: string }> {
|
): Promise<{ id: string }> {
|
||||||
const positionTypeRepository = manager.getRepository(PositionType);
|
const applicationRepository = manager.getRepository(Application);
|
||||||
|
|
||||||
// PositionType has no unique constraint on (key, unitId); find-then-insert.
|
const application = await applicationRepository.findOne({
|
||||||
let positionType = await positionTypeRepository.findOne({
|
where: { key: EDR_FREIGHT_APPLICATION.key },
|
||||||
where: { key: EDR_POSITION_TYPE_KEY, unitId },
|
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!positionType) {
|
if (!application?.id) {
|
||||||
const insertResult = await positionTypeRepository.insert({
|
const insertResult = await applicationRepository.insert({
|
||||||
key: EDR_POSITION_TYPE_KEY,
|
id: EDR_FREIGHT_APPLICATION.id,
|
||||||
name: EDR_POSITION_TYPE_NAME,
|
key: EDR_FREIGHT_APPLICATION.key,
|
||||||
isSystem: true,
|
name: { ...EDR_FREIGHT_APPLICATION.name },
|
||||||
unitId,
|
|
||||||
});
|
});
|
||||||
this.logger.log(`Seeded EDR position type '${EDR_POSITION_TYPE_KEY}'`);
|
this.logger.log(`Seeded EDR application '${EDR_FREIGHT_APPLICATION.key}'`);
|
||||||
return { id: insertResult.identifiers[0]?.id as string };
|
return { id: insertResult.identifiers[0]?.id as string };
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Ensured EDR position type '${EDR_POSITION_TYPE_KEY}'`);
|
this.logger.log(`Ensured EDR application '${EDR_FREIGHT_APPLICATION.key}'`);
|
||||||
return { id: positionType.id };
|
return { id: application.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensurePositions(
|
private async ensurePermissions(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
organizationId: string,
|
applicationId: string,
|
||||||
unitId: string,
|
|
||||||
positionTypeId: string,
|
|
||||||
seedPositions: FreightSeedPosition[],
|
|
||||||
) {
|
) {
|
||||||
await manager.getRepository(Position).upsert(
|
const permissionRepository = manager.getRepository(Permission);
|
||||||
seedPositions.map(({ key, name, rank }) => ({
|
|
||||||
key,
|
// Upsert by key so reruns are idempotent; applicationId ties every
|
||||||
name,
|
// permission to the EDR Freight application (also backfills rows that
|
||||||
rank,
|
// were previously seeded without the relation).
|
||||||
organizationId,
|
await permissionRepository.upsert(
|
||||||
unitId,
|
EDR_FREIGHT_PERMISSIONS.map((permission) => ({
|
||||||
positionTypeId,
|
id: permission.id,
|
||||||
|
key: permission.key,
|
||||||
|
name: { ...permission.name },
|
||||||
|
applicationId,
|
||||||
})),
|
})),
|
||||||
{
|
{ conflictPaths: { key: true } },
|
||||||
conflictPaths: { key: true, unitId: true },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Ensured ${seedPositions.length} EDR positions '${seedPositions
|
`Ensured ${EDR_FREIGHT_PERMISSIONS.length} permissions on application '${EDR_FREIGHT_APPLICATION.key}'`,
|
||||||
.map((position) => position.key)
|
|
||||||
.join("', '")}'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensurePositionPermissions(
|
|
||||||
manager: EntityManager,
|
|
||||||
unitId: string,
|
|
||||||
seedPositions: FreightSeedPosition[],
|
|
||||||
) {
|
|
||||||
const permissionKeys = [
|
|
||||||
...new Set(seedPositions.flatMap((position) => position.permissionKeys)),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!permissionKeys.length) {
|
|
||||||
this.logger.log(
|
|
||||||
"No EDR position permissions configured; skipping position-permission links",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const positions = await manager.getRepository(Position).find({
|
|
||||||
where: { key: In(seedPositions.map((position) => position.key)), unitId },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
const seededPermissions = await manager.getRepository(Permission).find({
|
|
||||||
where: { key: In(permissionKeys) },
|
|
||||||
select: { id: true, key: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const positionByKey = new Map(
|
|
||||||
positions.map((position) => [position.key, position]),
|
|
||||||
);
|
|
||||||
const permissionByKey = new Map(
|
|
||||||
seededPermissions.map((permission) => [permission.key, permission]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const positionPermissions = seedPositions.flatMap((position) => {
|
|
||||||
const seededPosition = positionByKey.get(position.key);
|
|
||||||
|
|
||||||
if (!seededPosition) {
|
|
||||||
throw new Error(`missing_position:${position.key}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return position.permissionKeys.map((permissionKey) => {
|
|
||||||
const seededPermission = permissionByKey.get(permissionKey);
|
|
||||||
|
|
||||||
if (!seededPermission) {
|
|
||||||
throw new Error(`missing_permission:${permissionKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
positionId: seededPosition.id as string,
|
|
||||||
permissionId: seededPermission.id,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await manager.getRepository(PositionPermission).upsert(positionPermissions, {
|
|
||||||
conflictPaths: { positionId: true, permissionId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Ensured ${positionPermissions.length} EDR position-permission links`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
|
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
|
||||||
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
|
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
|
||||||
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
|
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
|
||||||
|
perm('a1000001-0001-4000-8000-000000000024', 'edr_freight_app:bookings:create', 'Create booking'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,11 +118,209 @@ export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('c1000001-0001-4000-8000-000000000001', 'edr_freight_app:allocation:manage', 'Allocate containers to vehicles'),
|
perm('c1000001-0001-4000-8000-000000000001', 'edr_freight_app:allocation:manage', 'Allocate containers to vehicles'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advanced backoffice resources — full CRUD + workflow-action keys.
|
||||||
|
* See docs/rbac/freight-backoffice-permissions.md. Additive only: the existing
|
||||||
|
* bookings/contracts/rule-engine/allocation keys above are unchanged.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// C. Customers
|
||||||
|
export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
perm('d1a00001-0001-4000-8000-000000000001', 'edr_freight_app:customers:view', 'View customers'),
|
||||||
|
perm('d1a00001-0001-4000-8000-000000000002', 'edr_freight_app:customers:create', 'Create customer'),
|
||||||
|
perm('d1a00001-0001-4000-8000-000000000003', 'edr_freight_app:customers:update', 'Update customer'),
|
||||||
|
perm('d1a00001-0001-4000-8000-000000000004', 'edr_freight_app:customers:deactivate', 'Deactivate customer'),
|
||||||
|
perm('d1a00001-0001-4000-8000-000000000005', 'edr_freight_app:customers:verify', 'Verify customer (KYC/Fayda)'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// D. Finance — payments + invoices
|
||||||
|
export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
perm('d2a00001-0001-4000-8000-000000000001', 'edr_freight_app:payments:view', 'View payments'),
|
||||||
|
perm('d2a00001-0001-4000-8000-000000000002', 'edr_freight_app:payments:verify', 'Verify/settle payment'),
|
||||||
|
perm('d2a00001-0001-4000-8000-000000000003', 'edr_freight_app:payments:refund', 'Refund payment'),
|
||||||
|
perm('d2b00001-0001-4000-8000-000000000001', 'edr_freight_app:invoices:view', 'View invoices'),
|
||||||
|
perm('d2b00001-0001-4000-8000-000000000002', 'edr_freight_app:invoices:create', 'Generate invoice'),
|
||||||
|
perm('d2b00001-0001-4000-8000-000000000003', 'edr_freight_app:invoices:cancel', 'Cancel invoice'),
|
||||||
|
perm('d2b00001-0001-4000-8000-000000000004', 'edr_freight_app:invoices:export', 'Download invoice document'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// E. First / last mile operations
|
||||||
|
export const MILE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
perm('d3a00001-0001-4000-8000-000000000001', 'edr_freight_app:first_mile:view', 'View first-mile'),
|
||||||
|
perm('d3a00001-0001-4000-8000-000000000002', 'edr_freight_app:first_mile:accept', 'Accept first-mile request'),
|
||||||
|
perm('d3a00001-0001-4000-8000-000000000003', 'edr_freight_app:first_mile:create', 'Create first-mile'),
|
||||||
|
perm('d3a00001-0001-4000-8000-000000000004', 'edr_freight_app:first_mile:update', 'Update first-mile'),
|
||||||
|
perm('d3a00001-0001-4000-8000-000000000005', 'edr_freight_app:first_mile:delete', 'Delete first-mile'),
|
||||||
|
perm('d3a00001-0001-4000-8000-000000000006', 'edr_freight_app:first_mile:assign_vehicles', 'Assign first-mile vehicles'),
|
||||||
|
perm('d3a00001-0001-4000-8000-000000000007', 'edr_freight_app:first_mile:set_distances', 'Set first-mile distances'),
|
||||||
|
perm('d3a00001-0001-4000-8000-000000000008', 'edr_freight_app:first_mile:generate_invoice', 'Generate first-mile invoice'),
|
||||||
|
perm('d3b00001-0001-4000-8000-000000000001', 'edr_freight_app:last_mile:view', 'View last-mile'),
|
||||||
|
perm('d3b00001-0001-4000-8000-000000000002', 'edr_freight_app:last_mile:accept', 'Accept last-mile request'),
|
||||||
|
perm('d3b00001-0001-4000-8000-000000000003', 'edr_freight_app:last_mile:create', 'Create last-mile'),
|
||||||
|
perm('d3b00001-0001-4000-8000-000000000004', 'edr_freight_app:last_mile:update', 'Update last-mile'),
|
||||||
|
perm('d3b00001-0001-4000-8000-000000000005', 'edr_freight_app:last_mile:delete', 'Delete last-mile'),
|
||||||
|
perm('d3b00001-0001-4000-8000-000000000006', 'edr_freight_app:last_mile:assign_vehicles', 'Assign last-mile vehicles'),
|
||||||
|
perm('d3b00001-0001-4000-8000-000000000007', 'edr_freight_app:last_mile:set_distances', 'Set last-mile distances'),
|
||||||
|
perm('d3b00001-0001-4000-8000-000000000008', 'edr_freight_app:last_mile:generate_invoice', 'Generate last-mile invoice'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// F. Fleet — rail assets (splits the flat fleet:view/manage)
|
||||||
|
export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
perm('e1a00001-0001-4000-8000-000000000001', 'edr_freight_app:locomotives:view', 'View locomotives'),
|
||||||
|
perm('e1a00001-0001-4000-8000-000000000002', 'edr_freight_app:locomotives:create', 'Create locomotive'),
|
||||||
|
perm('e1a00001-0001-4000-8000-000000000003', 'edr_freight_app:locomotives:update', 'Update locomotive'),
|
||||||
|
perm('e1a00001-0001-4000-8000-000000000004', 'edr_freight_app:locomotives:delete', 'Delete locomotive'),
|
||||||
|
perm('e1b00001-0001-4000-8000-000000000001', 'edr_freight_app:wagons:view', 'View wagons'),
|
||||||
|
perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'),
|
||||||
|
perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'),
|
||||||
|
perm('e1b00001-0001-4000-8000-000000000004', 'edr_freight_app:wagons:delete', 'Delete wagon'),
|
||||||
|
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
|
||||||
|
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
|
||||||
|
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
|
||||||
|
perm('e1c00001-0001-4000-8000-000000000004', 'edr_freight_app:trains:delete', 'Delete train'),
|
||||||
|
perm('e1c00001-0001-4000-8000-000000000005', 'edr_freight_app:trains:assign_wagons', 'Assign wagons to train'),
|
||||||
|
perm('e1d00001-0001-4000-8000-000000000001', 'edr_freight_app:routes:view', 'View routes'),
|
||||||
|
perm('e1d00001-0001-4000-8000-000000000002', 'edr_freight_app:routes:create', 'Create route'),
|
||||||
|
perm('e1d00001-0001-4000-8000-000000000003', 'edr_freight_app:routes:update', 'Update route'),
|
||||||
|
perm('e1d00001-0001-4000-8000-000000000004', 'edr_freight_app:routes:delete', 'Delete route'),
|
||||||
|
perm('e1e00001-0001-4000-8000-000000000001', 'edr_freight_app:containers:view', 'View containers'),
|
||||||
|
perm('e1e00001-0001-4000-8000-000000000002', 'edr_freight_app:containers:create', 'Create container'),
|
||||||
|
perm('e1e00001-0001-4000-8000-000000000003', 'edr_freight_app:containers:update', 'Update container'),
|
||||||
|
perm('e1e00001-0001-4000-8000-000000000004', 'edr_freight_app:containers:delete', 'Delete container'),
|
||||||
|
perm('e1f00001-0001-4000-8000-000000000001', 'edr_freight_app:cargoes:view', 'View cargoes'),
|
||||||
|
perm('e1f00001-0001-4000-8000-000000000002', 'edr_freight_app:cargoes:create', 'Create cargo'),
|
||||||
|
perm('e1f00001-0001-4000-8000-000000000003', 'edr_freight_app:cargoes:update', 'Update cargo'),
|
||||||
|
perm('e1f00001-0001-4000-8000-000000000004', 'edr_freight_app:cargoes:delete', 'Delete cargo'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// G. Fleet — road & telemetry
|
||||||
|
export const FLEET_ROAD_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
perm('e2a00001-0001-4000-8000-000000000001', 'edr_freight_app:vehicles:view', 'View vehicles'),
|
||||||
|
perm('e2a00001-0001-4000-8000-000000000002', 'edr_freight_app:vehicles:create', 'Create vehicle'),
|
||||||
|
perm('e2a00001-0001-4000-8000-000000000003', 'edr_freight_app:vehicles:update', 'Update vehicle'),
|
||||||
|
perm('e2a00001-0001-4000-8000-000000000004', 'edr_freight_app:vehicles:delete', 'Delete vehicle'),
|
||||||
|
perm('e2b00001-0001-4000-8000-000000000001', 'edr_freight_app:drivers:view', 'View drivers'),
|
||||||
|
perm('e2b00001-0001-4000-8000-000000000002', 'edr_freight_app:drivers:create', 'Create driver'),
|
||||||
|
perm('e2b00001-0001-4000-8000-000000000003', 'edr_freight_app:drivers:update', 'Update driver'),
|
||||||
|
perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete driver'),
|
||||||
|
perm('e2c00001-0001-4000-8000-000000000001', 'edr_freight_app:tracking:view', 'Track vehicles'),
|
||||||
|
perm('e2d00001-0001-4000-8000-000000000001', 'edr_freight_app:fuel:view', 'View fuel purchases'),
|
||||||
|
perm('e2d00001-0001-4000-8000-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'),
|
||||||
|
perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update fuel purchase'),
|
||||||
|
perm('e2d00001-0001-4000-8000-000000000004', 'edr_freight_app:fuel:delete', 'Delete fuel purchase'),
|
||||||
|
perm('e2d00001-0001-4000-8000-000000000005', 'edr_freight_app:fuel:approve', 'Approve fuel purchase'),
|
||||||
|
perm('e2e00001-0001-4000-8000-000000000001', 'edr_freight_app:maintenance:view', 'View maintenance'),
|
||||||
|
perm('e2e00001-0001-4000-8000-000000000002', 'edr_freight_app:maintenance:create', 'Create maintenance'),
|
||||||
|
perm('e2e00001-0001-4000-8000-000000000003', 'edr_freight_app:maintenance:update', 'Update maintenance'),
|
||||||
|
perm('e2e00001-0001-4000-8000-000000000004', 'edr_freight_app:maintenance:delete', 'Delete maintenance'),
|
||||||
|
perm('e2e00001-0001-4000-8000-000000000005', 'edr_freight_app:maintenance:complete', 'Complete maintenance'),
|
||||||
|
perm('e2f00001-0001-4000-8000-000000000001', 'edr_freight_app:fleet_reports:view', 'View fleet financial reports'),
|
||||||
|
perm('e2f00001-0001-4000-8000-000000000002', 'edr_freight_app:fleet_reports:export', 'Export fleet financial reports'),
|
||||||
|
perm('e2000001-0001-4000-8000-000000000001', 'edr_freight_app:fleet_dashboard:view', 'View fleet dashboard'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// H. Warehouse management
|
||||||
|
export const WAREHOUSE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
perm('f1000001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_dashboard:view', 'View warehouse dashboard'),
|
||||||
|
perm('f1a00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouses:view', 'View warehouses'),
|
||||||
|
perm('f1a00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouses:create', 'Create warehouse'),
|
||||||
|
perm('f1a00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouses:update', 'Update warehouse'),
|
||||||
|
perm('f1a00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouses:delete', 'Delete warehouse'),
|
||||||
|
perm('f1b00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_yards:view', 'View warehouse yards'),
|
||||||
|
perm('f1b00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_yards:create', 'Create warehouse yard'),
|
||||||
|
perm('f1b00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_yards:update', 'Update warehouse yard'),
|
||||||
|
perm('f1b00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_yards:delete', 'Delete warehouse yard'),
|
||||||
|
perm('f1c00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_zones:view', 'View warehouse zones'),
|
||||||
|
perm('f1c00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_zones:create', 'Create warehouse zone'),
|
||||||
|
perm('f1c00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_zones:update', 'Update warehouse zone'),
|
||||||
|
perm('f1d00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_allocation_rules:view', 'View allocation rules'),
|
||||||
|
perm('f1d00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_allocation_rules:create', 'Create allocation rule'),
|
||||||
|
perm('f1d00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_allocation_rules:update', 'Update allocation rule'),
|
||||||
|
perm('f1d00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_allocation_rules:delete', 'Delete allocation rule'),
|
||||||
|
perm('f1e00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_fee_rules:view', 'View fee rules'),
|
||||||
|
perm('f1e00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_fee_rules:create', 'Create fee rule'),
|
||||||
|
perm('f1e00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_fee_rules:update', 'Update fee rule'),
|
||||||
|
perm('f1e00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_fee_rules:delete', 'Delete fee rule'),
|
||||||
|
perm('f1f00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_inspection_reports:view', 'View inspection reports'),
|
||||||
|
perm('f1f00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_inspection_reports:create', 'Create inspection report'),
|
||||||
|
perm('f1f00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_inspection_reports:update', 'Update inspection report'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// I. Port & terminal — inventory movement + interchange + fee invoices
|
||||||
|
export const PORT_TERMINAL_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
perm('f2a00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_inventory:view', 'View terminal inventory'),
|
||||||
|
perm('f2a00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_inventory:receive', 'Receive inventory'),
|
||||||
|
perm('f2a00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_inventory:move', 'Move/store/reserve inventory'),
|
||||||
|
perm('f2a00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_inventory:load', 'Load inventory'),
|
||||||
|
perm('f2a00001-0001-4000-8000-000000000005', 'edr_freight_app:warehouse_inventory:unload', 'Unload inventory'),
|
||||||
|
perm('f2a00001-0001-4000-8000-000000000006', 'edr_freight_app:warehouse_inventory:dispatch', 'Dispatch inventory'),
|
||||||
|
perm('f2a00001-0001-4000-8000-000000000007', 'edr_freight_app:warehouse_inventory:gate_pass', 'Gate-clearance inventory'),
|
||||||
|
perm('f2a00001-0001-4000-8000-000000000008', 'edr_freight_app:warehouse_inventory:release', 'Release inventory'),
|
||||||
|
perm('f2a00001-0001-4000-8000-000000000009', 'edr_freight_app:warehouse_inventory:deliver', 'Deliver inventory'),
|
||||||
|
perm('f2a00001-0001-4000-8000-00000000000a', 'edr_freight_app:warehouse_inventory:inspect', 'Inspect inventory'),
|
||||||
|
perm('f2b00001-0001-4000-8000-000000000001', 'edr_freight_app:interchange_documents:view', 'View interchange documents'),
|
||||||
|
perm('f2b00001-0001-4000-8000-000000000002', 'edr_freight_app:interchange_documents:generate', 'Generate interchange document'),
|
||||||
|
perm('f2b00001-0001-4000-8000-000000000003', 'edr_freight_app:interchange_documents:acknowledge', 'Acknowledge interchange document'),
|
||||||
|
perm('f2b00001-0001-4000-8000-000000000004', 'edr_freight_app:interchange_documents:dispute', 'Dispute interchange document'),
|
||||||
|
perm('f2b00001-0001-4000-8000-000000000005', 'edr_freight_app:interchange_documents:cancel', 'Cancel interchange document'),
|
||||||
|
perm('f2c00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_fee_invoices:view', 'View warehouse fee invoices'),
|
||||||
|
perm('f2c00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_fee_invoices:generate', 'Generate warehouse fee invoice'),
|
||||||
|
perm('f2c00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_fee_invoices:cancel', 'Cancel warehouse fee invoice'),
|
||||||
|
perm('f2c00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_fee_invoices:pay', 'Pay warehouse fee invoice'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// E'. Train-scheduling finer actions (augment existing view/manage)
|
||||||
|
export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
perm('a2a00001-0001-4000-8000-000000000001', 'edr_freight_app:train_scheduling:create', 'Create train schedule'),
|
||||||
|
perm('a2a00001-0001-4000-8000-000000000002', 'edr_freight_app:train_scheduling:update', 'Update train schedule'),
|
||||||
|
perm('a2a00001-0001-4000-8000-000000000003', 'edr_freight_app:train_scheduling:cancel', 'Cancel train schedule'),
|
||||||
|
perm('a2a00001-0001-4000-8000-000000000004', 'edr_freight_app:train_scheduling:reschedule', 'Reschedule train'),
|
||||||
|
perm('a2a00001-0001-4000-8000-000000000005', 'edr_freight_app:train_scheduling:rules_manage', 'Manage global scheduling rules'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// L. Administration & settings (split from the coarse admin umbrella)
|
||||||
|
export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
perm('b3a00001-0001-4000-8000-000000000001', 'edr_freight_app:config:contract_validity:view', 'View contract validity periods'),
|
||||||
|
perm('b3a00001-0001-4000-8000-000000000002', 'edr_freight_app:config:contract_validity:manage', 'Manage contract validity periods'),
|
||||||
|
perm('b4a00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:file_upload:view', 'View file-upload settings'),
|
||||||
|
perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'),
|
||||||
|
perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'),
|
||||||
|
perm('b4b00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:dropdown:manage', 'Manage dropdown settings'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment
|
||||||
|
// / hierarchy_* / position_types:view keys are seeded separately in edr-freight.seed.ts.
|
||||||
|
export const STAFF_IAM_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
perm('c2a00001-0001-4000-8000-000000000001', 'edr_freight_app:staff:roles:view', 'View roles'),
|
||||||
|
perm('c2a00001-0001-4000-8000-000000000002', 'edr_freight_app:staff:roles:create', 'Create role'),
|
||||||
|
perm('c2a00001-0001-4000-8000-000000000003', 'edr_freight_app:staff:roles:update', 'Update role'),
|
||||||
|
perm('c2a00001-0001-4000-8000-000000000004', 'edr_freight_app:staff:roles:delete', 'Delete role'),
|
||||||
|
perm('c2b00001-0001-4000-8000-000000000001', 'edr_freight_app:staff:permissions:view', 'View permission assignments'),
|
||||||
|
perm('c2b00001-0001-4000-8000-000000000002', 'edr_freight_app:staff:permissions:assign', 'Assign permissions'),
|
||||||
|
perm('c2c00001-0001-4000-8000-000000000001', 'edr_freight_app:position_types:create', 'Create position type'),
|
||||||
|
perm('c2c00001-0001-4000-8000-000000000002', 'edr_freight_app:position_types:update', 'Update position type'),
|
||||||
|
perm('c2c00001-0001-4000-8000-000000000003', 'edr_freight_app:position_types:delete', 'Delete position type'),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
...CUSTOMER_PERMISSIONS,
|
||||||
|
...FINANCE_PERMISSIONS,
|
||||||
|
...MILE_PERMISSIONS,
|
||||||
|
...FLEET_RAIL_PERMISSIONS,
|
||||||
|
...FLEET_ROAD_PERMISSIONS,
|
||||||
|
...WAREHOUSE_PERMISSIONS,
|
||||||
|
...PORT_TERMINAL_PERMISSIONS,
|
||||||
|
...SCHEDULING_EXTRA_PERMISSIONS,
|
||||||
|
...CONFIG_SETTINGS_PERMISSIONS,
|
||||||
|
...STAFF_IAM_PERMISSIONS,
|
||||||
|
];
|
||||||
|
|
||||||
export const BOOKING_RULE_ENGINE_PERMISSIONS = [
|
export const BOOKING_RULE_ENGINE_PERMISSIONS = [
|
||||||
...BOOKING_PERMISSIONS,
|
...BOOKING_PERMISSIONS,
|
||||||
...CONTRACT_PERMISSIONS,
|
...CONTRACT_PERMISSIONS,
|
||||||
...RULE_ENGINE_PERMISSIONS,
|
...RULE_ENGINE_PERMISSIONS,
|
||||||
...GAP_CONTROLLER_PERMISSIONS,
|
...GAP_CONTROLLER_PERMISSIONS,
|
||||||
|
...ADVANCED_BACKOFFICE_PERMISSIONS,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map(
|
export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map(
|
||||||
@@ -131,6 +330,7 @@ export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIO
|
|||||||
export const FREIGHT_PERMS = {
|
export const FREIGHT_PERMS = {
|
||||||
bookings: {
|
bookings: {
|
||||||
view: 'edr_freight_app:bookings:view',
|
view: 'edr_freight_app:bookings:view',
|
||||||
|
create: 'edr_freight_app:bookings:create',
|
||||||
clearanceView: 'edr_freight_app:bookings:clearance_view',
|
clearanceView: 'edr_freight_app:bookings:clearance_view',
|
||||||
staffAccept: 'edr_freight_app:bookings:staff_accept',
|
staffAccept: 'edr_freight_app:bookings:staff_accept',
|
||||||
requestChanges: 'edr_freight_app:bookings:request_changes',
|
requestChanges: 'edr_freight_app:bookings:request_changes',
|
||||||
@@ -168,6 +368,11 @@ export const FREIGHT_PERMS = {
|
|||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
view: 'edr_freight_app:train_scheduling:view',
|
view: 'edr_freight_app:train_scheduling:view',
|
||||||
manage: 'edr_freight_app:train_scheduling:manage',
|
manage: 'edr_freight_app:train_scheduling:manage',
|
||||||
|
create: 'edr_freight_app:train_scheduling:create',
|
||||||
|
update: 'edr_freight_app:train_scheduling:update',
|
||||||
|
cancel: 'edr_freight_app:train_scheduling:cancel',
|
||||||
|
reschedule: 'edr_freight_app:train_scheduling:reschedule',
|
||||||
|
rulesManage: 'edr_freight_app:train_scheduling:rules_manage',
|
||||||
},
|
},
|
||||||
fleet: {
|
fleet: {
|
||||||
view: 'edr_freight_app:fleet:view',
|
view: 'edr_freight_app:fleet:view',
|
||||||
@@ -183,6 +388,244 @@ export const FREIGHT_PERMS = {
|
|||||||
allocation: {
|
allocation: {
|
||||||
manage: 'edr_freight_app:allocation:manage',
|
manage: 'edr_freight_app:allocation:manage',
|
||||||
},
|
},
|
||||||
|
customers: {
|
||||||
|
view: 'edr_freight_app:customers:view',
|
||||||
|
create: 'edr_freight_app:customers:create',
|
||||||
|
update: 'edr_freight_app:customers:update',
|
||||||
|
deactivate: 'edr_freight_app:customers:deactivate',
|
||||||
|
verify: 'edr_freight_app:customers:verify',
|
||||||
|
},
|
||||||
|
payments: {
|
||||||
|
view: 'edr_freight_app:payments:view',
|
||||||
|
verify: 'edr_freight_app:payments:verify',
|
||||||
|
refund: 'edr_freight_app:payments:refund',
|
||||||
|
},
|
||||||
|
invoices: {
|
||||||
|
view: 'edr_freight_app:invoices:view',
|
||||||
|
create: 'edr_freight_app:invoices:create',
|
||||||
|
cancel: 'edr_freight_app:invoices:cancel',
|
||||||
|
export: 'edr_freight_app:invoices:export',
|
||||||
|
},
|
||||||
|
firstMile: {
|
||||||
|
view: 'edr_freight_app:first_mile:view',
|
||||||
|
accept: 'edr_freight_app:first_mile:accept',
|
||||||
|
create: 'edr_freight_app:first_mile:create',
|
||||||
|
update: 'edr_freight_app:first_mile:update',
|
||||||
|
delete: 'edr_freight_app:first_mile:delete',
|
||||||
|
assignVehicles: 'edr_freight_app:first_mile:assign_vehicles',
|
||||||
|
setDistances: 'edr_freight_app:first_mile:set_distances',
|
||||||
|
generateInvoice: 'edr_freight_app:first_mile:generate_invoice',
|
||||||
|
},
|
||||||
|
lastMile: {
|
||||||
|
view: 'edr_freight_app:last_mile:view',
|
||||||
|
accept: 'edr_freight_app:last_mile:accept',
|
||||||
|
create: 'edr_freight_app:last_mile:create',
|
||||||
|
update: 'edr_freight_app:last_mile:update',
|
||||||
|
delete: 'edr_freight_app:last_mile:delete',
|
||||||
|
assignVehicles: 'edr_freight_app:last_mile:assign_vehicles',
|
||||||
|
setDistances: 'edr_freight_app:last_mile:set_distances',
|
||||||
|
generateInvoice: 'edr_freight_app:last_mile:generate_invoice',
|
||||||
|
},
|
||||||
|
locomotives: {
|
||||||
|
view: 'edr_freight_app:locomotives:view',
|
||||||
|
create: 'edr_freight_app:locomotives:create',
|
||||||
|
update: 'edr_freight_app:locomotives:update',
|
||||||
|
delete: 'edr_freight_app:locomotives:delete',
|
||||||
|
},
|
||||||
|
wagons: {
|
||||||
|
view: 'edr_freight_app:wagons:view',
|
||||||
|
create: 'edr_freight_app:wagons:create',
|
||||||
|
update: 'edr_freight_app:wagons:update',
|
||||||
|
delete: 'edr_freight_app:wagons:delete',
|
||||||
|
},
|
||||||
|
trains: {
|
||||||
|
view: 'edr_freight_app:trains:view',
|
||||||
|
create: 'edr_freight_app:trains:create',
|
||||||
|
update: 'edr_freight_app:trains:update',
|
||||||
|
delete: 'edr_freight_app:trains:delete',
|
||||||
|
assignWagons: 'edr_freight_app:trains:assign_wagons',
|
||||||
|
},
|
||||||
|
routes: {
|
||||||
|
view: 'edr_freight_app:routes:view',
|
||||||
|
create: 'edr_freight_app:routes:create',
|
||||||
|
update: 'edr_freight_app:routes:update',
|
||||||
|
delete: 'edr_freight_app:routes:delete',
|
||||||
|
},
|
||||||
|
containers: {
|
||||||
|
view: 'edr_freight_app:containers:view',
|
||||||
|
create: 'edr_freight_app:containers:create',
|
||||||
|
update: 'edr_freight_app:containers:update',
|
||||||
|
delete: 'edr_freight_app:containers:delete',
|
||||||
|
},
|
||||||
|
cargoes: {
|
||||||
|
view: 'edr_freight_app:cargoes:view',
|
||||||
|
create: 'edr_freight_app:cargoes:create',
|
||||||
|
update: 'edr_freight_app:cargoes:update',
|
||||||
|
delete: 'edr_freight_app:cargoes:delete',
|
||||||
|
},
|
||||||
|
vehicles: {
|
||||||
|
view: 'edr_freight_app:vehicles:view',
|
||||||
|
create: 'edr_freight_app:vehicles:create',
|
||||||
|
update: 'edr_freight_app:vehicles:update',
|
||||||
|
delete: 'edr_freight_app:vehicles:delete',
|
||||||
|
},
|
||||||
|
drivers: {
|
||||||
|
view: 'edr_freight_app:drivers:view',
|
||||||
|
create: 'edr_freight_app:drivers:create',
|
||||||
|
update: 'edr_freight_app:drivers:update',
|
||||||
|
delete: 'edr_freight_app:drivers:delete',
|
||||||
|
},
|
||||||
|
tracking: {
|
||||||
|
view: 'edr_freight_app:tracking:view',
|
||||||
|
},
|
||||||
|
fuel: {
|
||||||
|
view: 'edr_freight_app:fuel:view',
|
||||||
|
create: 'edr_freight_app:fuel:create',
|
||||||
|
update: 'edr_freight_app:fuel:update',
|
||||||
|
delete: 'edr_freight_app:fuel:delete',
|
||||||
|
approve: 'edr_freight_app:fuel:approve',
|
||||||
|
},
|
||||||
|
maintenance: {
|
||||||
|
view: 'edr_freight_app:maintenance:view',
|
||||||
|
create: 'edr_freight_app:maintenance:create',
|
||||||
|
update: 'edr_freight_app:maintenance:update',
|
||||||
|
delete: 'edr_freight_app:maintenance:delete',
|
||||||
|
complete: 'edr_freight_app:maintenance:complete',
|
||||||
|
},
|
||||||
|
fleetReports: {
|
||||||
|
view: 'edr_freight_app:fleet_reports:view',
|
||||||
|
export: 'edr_freight_app:fleet_reports:export',
|
||||||
|
},
|
||||||
|
fleetDashboard: {
|
||||||
|
view: 'edr_freight_app:fleet_dashboard:view',
|
||||||
|
},
|
||||||
|
warehouseDashboard: {
|
||||||
|
view: 'edr_freight_app:warehouse_dashboard:view',
|
||||||
|
},
|
||||||
|
warehouses: {
|
||||||
|
view: 'edr_freight_app:warehouses:view',
|
||||||
|
create: 'edr_freight_app:warehouses:create',
|
||||||
|
update: 'edr_freight_app:warehouses:update',
|
||||||
|
delete: 'edr_freight_app:warehouses:delete',
|
||||||
|
},
|
||||||
|
warehouseYards: {
|
||||||
|
view: 'edr_freight_app:warehouse_yards:view',
|
||||||
|
create: 'edr_freight_app:warehouse_yards:create',
|
||||||
|
update: 'edr_freight_app:warehouse_yards:update',
|
||||||
|
delete: 'edr_freight_app:warehouse_yards:delete',
|
||||||
|
},
|
||||||
|
warehouseZones: {
|
||||||
|
view: 'edr_freight_app:warehouse_zones:view',
|
||||||
|
create: 'edr_freight_app:warehouse_zones:create',
|
||||||
|
update: 'edr_freight_app:warehouse_zones:update',
|
||||||
|
},
|
||||||
|
warehouseAllocationRules: {
|
||||||
|
view: 'edr_freight_app:warehouse_allocation_rules:view',
|
||||||
|
create: 'edr_freight_app:warehouse_allocation_rules:create',
|
||||||
|
update: 'edr_freight_app:warehouse_allocation_rules:update',
|
||||||
|
delete: 'edr_freight_app:warehouse_allocation_rules:delete',
|
||||||
|
},
|
||||||
|
warehouseFeeRules: {
|
||||||
|
view: 'edr_freight_app:warehouse_fee_rules:view',
|
||||||
|
create: 'edr_freight_app:warehouse_fee_rules:create',
|
||||||
|
update: 'edr_freight_app:warehouse_fee_rules:update',
|
||||||
|
delete: 'edr_freight_app:warehouse_fee_rules:delete',
|
||||||
|
},
|
||||||
|
warehouseInspectionReports: {
|
||||||
|
view: 'edr_freight_app:warehouse_inspection_reports:view',
|
||||||
|
create: 'edr_freight_app:warehouse_inspection_reports:create',
|
||||||
|
update: 'edr_freight_app:warehouse_inspection_reports:update',
|
||||||
|
},
|
||||||
|
warehouseInventory: {
|
||||||
|
view: 'edr_freight_app:warehouse_inventory:view',
|
||||||
|
receive: 'edr_freight_app:warehouse_inventory:receive',
|
||||||
|
move: 'edr_freight_app:warehouse_inventory:move',
|
||||||
|
load: 'edr_freight_app:warehouse_inventory:load',
|
||||||
|
unload: 'edr_freight_app:warehouse_inventory:unload',
|
||||||
|
dispatch: 'edr_freight_app:warehouse_inventory:dispatch',
|
||||||
|
gatePass: 'edr_freight_app:warehouse_inventory:gate_pass',
|
||||||
|
release: 'edr_freight_app:warehouse_inventory:release',
|
||||||
|
deliver: 'edr_freight_app:warehouse_inventory:deliver',
|
||||||
|
inspect: 'edr_freight_app:warehouse_inventory:inspect',
|
||||||
|
},
|
||||||
|
interchangeDocuments: {
|
||||||
|
view: 'edr_freight_app:interchange_documents:view',
|
||||||
|
generate: 'edr_freight_app:interchange_documents:generate',
|
||||||
|
acknowledge: 'edr_freight_app:interchange_documents:acknowledge',
|
||||||
|
dispute: 'edr_freight_app:interchange_documents:dispute',
|
||||||
|
cancel: 'edr_freight_app:interchange_documents:cancel',
|
||||||
|
},
|
||||||
|
warehouseFeeInvoices: {
|
||||||
|
view: 'edr_freight_app:warehouse_fee_invoices:view',
|
||||||
|
generate: 'edr_freight_app:warehouse_fee_invoices:generate',
|
||||||
|
cancel: 'edr_freight_app:warehouse_fee_invoices:cancel',
|
||||||
|
pay: 'edr_freight_app:warehouse_fee_invoices:pay',
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
contractValidity: {
|
||||||
|
view: 'edr_freight_app:config:contract_validity:view',
|
||||||
|
manage: 'edr_freight_app:config:contract_validity:manage',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
fileUpload: {
|
||||||
|
view: 'edr_freight_app:settings:file_upload:view',
|
||||||
|
manage: 'edr_freight_app:settings:file_upload:manage',
|
||||||
|
},
|
||||||
|
dropdown: {
|
||||||
|
view: 'edr_freight_app:settings:dropdown:view',
|
||||||
|
manage: 'edr_freight_app:settings:dropdown:manage',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
staff: {
|
||||||
|
roles: {
|
||||||
|
view: 'edr_freight_app:staff:roles:view',
|
||||||
|
create: 'edr_freight_app:staff:roles:create',
|
||||||
|
update: 'edr_freight_app:staff:roles:update',
|
||||||
|
delete: 'edr_freight_app:staff:roles:delete',
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
view: 'edr_freight_app:staff:permissions:view',
|
||||||
|
assign: 'edr_freight_app:staff:permissions:assign',
|
||||||
|
},
|
||||||
|
// Seeded in edr-freight.seed.ts (EDR_FREIGHT_PERMISSIONS) — surfaced here for gating.
|
||||||
|
employeeRegistration: {
|
||||||
|
view: 'edr_freight_app:employee_registration:view',
|
||||||
|
create: 'edr_freight_app:employee_registration:create',
|
||||||
|
update: 'edr_freight_app:employee_registration:update',
|
||||||
|
activate: 'edr_freight_app:employee_registration:activate',
|
||||||
|
deactivate: 'edr_freight_app:employee_registration:deactivate',
|
||||||
|
},
|
||||||
|
roleAssignment: {
|
||||||
|
view: 'edr_freight_app:role_assignment:view',
|
||||||
|
assign: 'edr_freight_app:role_assignment:assign',
|
||||||
|
replace: 'edr_freight_app:role_assignment:replace',
|
||||||
|
},
|
||||||
|
hierarchyUnits: {
|
||||||
|
view: 'edr_freight_app:hierarchy_units:view',
|
||||||
|
create: 'edr_freight_app:hierarchy_units:create',
|
||||||
|
update: 'edr_freight_app:hierarchy_units:update',
|
||||||
|
delete: 'edr_freight_app:hierarchy_units:delete',
|
||||||
|
},
|
||||||
|
hierarchyPositions: {
|
||||||
|
view: 'edr_freight_app:hierarchy_positions:view',
|
||||||
|
create: 'edr_freight_app:hierarchy_positions:create',
|
||||||
|
update: 'edr_freight_app:hierarchy_positions:update',
|
||||||
|
delete: 'edr_freight_app:hierarchy_positions:delete',
|
||||||
|
changeParent: 'edr_freight_app:hierarchy_positions:change_parent',
|
||||||
|
},
|
||||||
|
hierarchyEmployeeAssignment: {
|
||||||
|
view: 'edr_freight_app:hierarchy_employee_assignment:view',
|
||||||
|
invite: 'edr_freight_app:hierarchy_employee_assignment:invite',
|
||||||
|
assign: 'edr_freight_app:hierarchy_employee_assignment:assign',
|
||||||
|
},
|
||||||
|
positionTypes: {
|
||||||
|
view: 'edr_freight_app:position_types:view',
|
||||||
|
create: 'edr_freight_app:position_types:create',
|
||||||
|
update: 'edr_freight_app:position_types:update',
|
||||||
|
delete: 'edr_freight_app:position_types:delete',
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const allRuleEngineViewKeys = () =>
|
const allRuleEngineViewKeys = () =>
|
||||||
@@ -326,12 +769,11 @@ export const POSITION_PERMISSION_PRESETS = {
|
|||||||
]),
|
]),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** Derive the module bucket from the resource segment of a permission key. */
|
||||||
|
const moduleOf = (key: string): string => key.split(':')[1] ?? 'other';
|
||||||
|
|
||||||
export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({
|
export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({
|
||||||
key: p.key,
|
key: p.key,
|
||||||
label: p.name.en,
|
label: p.name.en,
|
||||||
module: p.key.includes(':bookings:')
|
module: moduleOf(p.key),
|
||||||
? 'bookings'
|
|
||||||
: p.key.includes(':contracts:')
|
|
||||||
? 'contracts'
|
|
||||||
: 'rule_engine',
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ const COMPANY_TIN = 'PAIDMILE001';
|
|||||||
const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
|
const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
|
||||||
|
|
||||||
const YARDS = [
|
const YARDS = [
|
||||||
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
|
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 },
|
||||||
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
|
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CONTAINER_TYPES = [
|
const CONTAINER_TYPES = [
|
||||||
|
|||||||
@@ -618,6 +618,9 @@ const DashboardShell = () => {
|
|||||||
return (
|
return (
|
||||||
<FreightDashboardLayout
|
<FreightDashboardLayout
|
||||||
sidebarSections={sidebarSections}
|
sidebarSections={sidebarSections}
|
||||||
|
// GL Ethiopia / GL Djibouti are locked to a single clearance page — no
|
||||||
|
// sidebar (or mobile burger) at all; the page renders full width.
|
||||||
|
hideSidebar={Boolean(glClearanceHome)}
|
||||||
activeHref={location.pathname}
|
activeHref={location.pathname}
|
||||||
onNavigate={navigate}
|
onNavigate={navigate}
|
||||||
enableThemeToggle
|
enableThemeToggle
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CountdownTimer } from "@edr/ui-common";
|
import { CountdownTimer } from "@edr/ui-common";
|
||||||
|
|
||||||
|
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { StaffBookingWindow } from "@/types/trainScheduling";
|
import type { StaffBookingWindow } from "@/types/trainScheduling";
|
||||||
|
|
||||||
@@ -224,6 +225,9 @@ function WindowCard({ w }: { w: StaffBookingWindow }) {
|
|||||||
* Hidden when nothing is pending.
|
* Hidden when nothing is pending.
|
||||||
*/
|
*/
|
||||||
export function GlUpcomingWindowsSection() {
|
export function GlUpcomingWindowsSection() {
|
||||||
|
// Live pushes flip cards the moment the window engine transitions a phase;
|
||||||
|
// the 60s poll below stays only as a fallback.
|
||||||
|
useBookingWindowSocket();
|
||||||
const { data, isLoading } = useQuery(
|
const { data, isLoading } = useQuery(
|
||||||
api.trainScheduling.allBookingWindows.queryOptions({
|
api.trainScheduling.allBookingWindows.queryOptions({
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export interface FreightDashboardHeaderProps {
|
|||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
mobileOpened: boolean;
|
mobileOpened: boolean;
|
||||||
onToggleMobile: () => void;
|
onToggleMobile: () => void;
|
||||||
|
/** Hide the mobile burger when the shell has no sidebar to open. */
|
||||||
|
hideSidebarBurger?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every header control is a consistent 36px frosted chip — same language as the
|
// Every header control is a consistent 36px frosted chip — same language as the
|
||||||
@@ -57,6 +59,7 @@ const FreightDashboardHeader = ({
|
|||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
mobileOpened,
|
mobileOpened,
|
||||||
onToggleMobile,
|
onToggleMobile,
|
||||||
|
hideSidebarBurger = false,
|
||||||
}: FreightDashboardHeaderProps) => {
|
}: FreightDashboardHeaderProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -88,13 +91,15 @@ const FreightDashboardHeader = ({
|
|||||||
{/* Left: burger (mobile) + search — the search now occupies the slot
|
{/* Left: burger (mobile) + search — the search now occupies the slot
|
||||||
the page title used to hold; each page owns its own title. */}
|
the page title used to hold; each page owns its own title. */}
|
||||||
<Group gap={12} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
|
<Group gap={12} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
|
||||||
<Burger
|
{!hideSidebarBurger && (
|
||||||
opened={mobileOpened}
|
<Burger
|
||||||
onClick={onToggleMobile}
|
opened={mobileOpened}
|
||||||
hiddenFrom="sm"
|
onClick={onToggleMobile}
|
||||||
size="sm"
|
hiddenFrom="sm"
|
||||||
aria-label="Toggle sidebar"
|
size="sm"
|
||||||
/>
|
aria-label="Toggle sidebar"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Group
|
<Group
|
||||||
gap={8}
|
gap={8}
|
||||||
align="center"
|
align="center"
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ function getInitialTheme(): Theme {
|
|||||||
|
|
||||||
export interface FreightDashboardLayoutProps {
|
export interface FreightDashboardLayoutProps {
|
||||||
sidebarSections: SidebarSection[];
|
sidebarSections: SidebarSection[];
|
||||||
|
/** Render the shell with no navbar at all (used by GL clearance-only users). */
|
||||||
|
hideSidebar?: boolean;
|
||||||
activeHref?: string;
|
activeHref?: string;
|
||||||
onNavigate?: (href: string) => void;
|
onNavigate?: (href: string) => void;
|
||||||
headerRight?: ReactNode;
|
headerRight?: ReactNode;
|
||||||
@@ -36,6 +38,7 @@ export interface FreightDashboardLayoutProps {
|
|||||||
|
|
||||||
const FreightDashboardLayout = ({
|
const FreightDashboardLayout = ({
|
||||||
sidebarSections,
|
sidebarSections,
|
||||||
|
hideSidebar = false,
|
||||||
activeHref = "",
|
activeHref = "",
|
||||||
onNavigate,
|
onNavigate,
|
||||||
headerRight,
|
headerRight,
|
||||||
@@ -75,11 +78,17 @@ const FreightDashboardLayout = ({
|
|||||||
padding={0}
|
padding={0}
|
||||||
className="bg-edr-bg"
|
className="bg-edr-bg"
|
||||||
header={{ height: HEADER_HEIGHT }}
|
header={{ height: HEADER_HEIGHT }}
|
||||||
navbar={{
|
// When the sidebar is hidden the navbar slot is dropped entirely so Main
|
||||||
width: NAVBAR_WIDTH,
|
// spans the full viewport width (GL clearance-only users).
|
||||||
breakpoint: "sm",
|
navbar={
|
||||||
collapsed: { mobile: !mobileOpened },
|
hideSidebar
|
||||||
}}
|
? undefined
|
||||||
|
: {
|
||||||
|
width: NAVBAR_WIDTH,
|
||||||
|
breakpoint: "sm",
|
||||||
|
collapsed: { mobile: !mobileOpened },
|
||||||
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<FreightDashboardHeader
|
<FreightDashboardHeader
|
||||||
pageMeta={pageMeta}
|
pageMeta={pageMeta}
|
||||||
@@ -93,14 +102,17 @@ const FreightDashboardLayout = ({
|
|||||||
onToggleTheme={toggleTheme}
|
onToggleTheme={toggleTheme}
|
||||||
mobileOpened={mobileOpened}
|
mobileOpened={mobileOpened}
|
||||||
onToggleMobile={toggleMobile}
|
onToggleMobile={toggleMobile}
|
||||||
|
hideSidebarBurger={hideSidebar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FreightSidebar
|
{!hideSidebar && (
|
||||||
sections={sidebarSections}
|
<FreightSidebar
|
||||||
activeHref={activeHref}
|
sections={sidebarSections}
|
||||||
onNavigate={navigate}
|
activeHref={activeHref}
|
||||||
onClose={closeMobile}
|
onNavigate={navigate}
|
||||||
/>
|
onClose={closeMobile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
{/* Internal scroll keeps the fixed-viewport model the dashboard pages
|
{/* Internal scroll keeps the fixed-viewport model the dashboard pages
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const DEFAULTS = {
|
|||||||
docReviewMinutes: 30,
|
docReviewMinutes: 30,
|
||||||
paymentWindowMinutes: 60,
|
paymentWindowMinutes: 60,
|
||||||
importWindowLeadDays: 3,
|
importWindowLeadDays: 3,
|
||||||
|
exportBookingLeadHours: 24,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
|
/** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
|
||||||
@@ -53,6 +54,7 @@ interface FormState {
|
|||||||
docReviewMinutes: number | "";
|
docReviewMinutes: number | "";
|
||||||
paymentWindowMinutes: number | "";
|
paymentWindowMinutes: number | "";
|
||||||
importWindowLeadDays: number | "";
|
importWindowLeadDays: number | "";
|
||||||
|
exportBookingLeadHours: number | "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseError(error: unknown, fallback: string): string {
|
function parseError(error: unknown, fallback: string): string {
|
||||||
@@ -112,6 +114,8 @@ export default function BookingWindowSettingsModal({
|
|||||||
r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes,
|
r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes,
|
||||||
importWindowLeadDays:
|
importWindowLeadDays:
|
||||||
r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays,
|
r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays,
|
||||||
|
exportBookingLeadHours:
|
||||||
|
r?.exportBookingLeadHours ?? DEFAULTS.exportBookingLeadHours,
|
||||||
});
|
});
|
||||||
}, [opened, schedule]);
|
}, [opened, schedule]);
|
||||||
|
|
||||||
@@ -142,15 +146,20 @@ export default function BookingWindowSettingsModal({
|
|||||||
const doc = Number(form.docReviewMinutes);
|
const doc = Number(form.docReviewMinutes);
|
||||||
const pay = Number(form.paymentWindowMinutes);
|
const pay = Number(form.paymentWindowMinutes);
|
||||||
const lead = Number(form.importWindowLeadDays);
|
const lead = Number(form.importWindowLeadDays);
|
||||||
|
const exportLead = Number(form.exportBookingLeadHours);
|
||||||
|
const leadInvalid = isExport
|
||||||
|
? form.exportBookingLeadHours === "" ||
|
||||||
|
!Number.isFinite(exportLead) ||
|
||||||
|
exportLead < 1
|
||||||
|
: form.importWindowLeadDays === "" || !Number.isFinite(lead);
|
||||||
if (
|
if (
|
||||||
form.windowDurationHours === "" ||
|
form.windowDurationHours === "" ||
|
||||||
form.docReviewMinutes === "" ||
|
form.docReviewMinutes === "" ||
|
||||||
form.paymentWindowMinutes === "" ||
|
form.paymentWindowMinutes === "" ||
|
||||||
form.importWindowLeadDays === "" ||
|
|
||||||
!Number.isFinite(duration) ||
|
!Number.isFinite(duration) ||
|
||||||
!Number.isFinite(doc) ||
|
!Number.isFinite(doc) ||
|
||||||
!Number.isFinite(pay) ||
|
!Number.isFinite(pay) ||
|
||||||
!Number.isFinite(lead)
|
leadInvalid
|
||||||
) {
|
) {
|
||||||
toast({
|
toast({
|
||||||
title: "Fill every field before saving",
|
title: "Fill every field before saving",
|
||||||
@@ -164,7 +173,9 @@ export default function BookingWindowSettingsModal({
|
|||||||
windowDurationHours: duration,
|
windowDurationHours: duration,
|
||||||
docReviewMinutes: doc,
|
docReviewMinutes: doc,
|
||||||
paymentWindowMinutes: pay,
|
paymentWindowMinutes: pay,
|
||||||
importWindowLeadDays: lead,
|
...(isExport
|
||||||
|
? { exportBookingLeadHours: exportLead }
|
||||||
|
: { importWindowLeadDays: lead }),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -223,8 +234,10 @@ export default function BookingWindowSettingsModal({
|
|||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{isExport ? (
|
{isExport ? (
|
||||||
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||||||
Export schedules use a single FCFS lead window — the daily desk
|
Export schedules use a single first-come-first-served window: it
|
||||||
hours below don't apply, only the lead time does.
|
opens the export lead time before departure — shifted to the next
|
||||||
|
desk opening if that lands outside desk hours — and stays open
|
||||||
|
until departure. Cycle timing below doesn't apply.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -263,7 +276,6 @@ export default function BookingWindowSettingsModal({
|
|||||||
}
|
}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
comboboxProps={{ withinPortal: true }}
|
comboboxProps={{ withinPortal: true }}
|
||||||
disabled={isExport}
|
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Closes"
|
label="Closes"
|
||||||
@@ -275,7 +287,6 @@ export default function BookingWindowSettingsModal({
|
|||||||
}
|
}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
comboboxProps={{ withinPortal: true }}
|
comboboxProps={{ withinPortal: true }}
|
||||||
disabled={isExport}
|
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
{isOvernight && !is24h ? (
|
{isOvernight && !is24h ? (
|
||||||
@@ -290,7 +301,6 @@ export default function BookingWindowSettingsModal({
|
|||||||
color="grape"
|
color="grape"
|
||||||
label="Run 24 hours a day (never pause overnight)"
|
label="Run 24 hours a day (never pause overnight)"
|
||||||
checked={is24h}
|
checked={is24h}
|
||||||
disabled={isExport}
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const checked = e.currentTarget.checked;
|
const checked = e.currentTarget.checked;
|
||||||
setForm((f) => {
|
setForm((f) => {
|
||||||
@@ -304,12 +314,11 @@ export default function BookingWindowSettingsModal({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{!isExport ? (
|
<Text size="xs" c="dimmed" mt={6}>
|
||||||
<Text size="xs" c="dimmed" mt={6}>
|
{isExport
|
||||||
A not-yet-full train pauses at the close hour and resumes the next
|
? "If the export lead time lands while the desk is shut, booking opens at the next desk opening instead."
|
||||||
morning at the open hour, every day until it fills or departs.
|
: "A not-yet-full train pauses at the close hour and resumes the next morning at the open hour, every day until it fills or departs."}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -367,27 +376,43 @@ export default function BookingWindowSettingsModal({
|
|||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* ── Lead time ────────────────────────────────────────────────── */}
|
{/* ── Lead time ────────────────────────────────────────────────── */}
|
||||||
<NumberInput
|
{isExport ? (
|
||||||
label={isExport ? "Booking lead (days)" : "Window lead (days)"}
|
<NumberInput
|
||||||
description={
|
label="Export booking lead (hours)"
|
||||||
isExport
|
description="How many hours before departure the export booking window opens"
|
||||||
? "How many days before departure export booking opens"
|
value={form.exportBookingLeadHours}
|
||||||
: "How many days before departure the booking window starts"
|
onChange={(v) =>
|
||||||
}
|
setForm(
|
||||||
value={form.importWindowLeadDays}
|
(f) =>
|
||||||
onChange={(v) =>
|
f && {
|
||||||
setForm(
|
...f,
|
||||||
(f) =>
|
exportBookingLeadHours: v === "" ? "" : Number(v),
|
||||||
f && {
|
},
|
||||||
...f,
|
)
|
||||||
importWindowLeadDays: v === "" ? "" : Number(v),
|
}
|
||||||
},
|
min={1}
|
||||||
)
|
clampBehavior="none"
|
||||||
}
|
allowDecimal={false}
|
||||||
min={0}
|
/>
|
||||||
clampBehavior="none"
|
) : (
|
||||||
allowDecimal={false}
|
<NumberInput
|
||||||
/>
|
label="Window lead (days)"
|
||||||
|
description="How many days before departure the booking window starts"
|
||||||
|
value={form.importWindowLeadDays}
|
||||||
|
onChange={(v) =>
|
||||||
|
setForm(
|
||||||
|
(f) =>
|
||||||
|
f && {
|
||||||
|
...f,
|
||||||
|
importWindowLeadDays: v === "" ? "" : Number(v),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
clampBehavior="none"
|
||||||
|
allowDecimal={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Group justify="flex-end" mt="xs">
|
<Group justify="flex-end" mt="xs">
|
||||||
<Button variant="default" onClick={onClose} disabled={save.isPending}>
|
<Button variant="default" onClick={onClose} disabled={save.isPending}>
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type {
|
||||||
|
IntercityBookingRow,
|
||||||
|
IntercityCapacity,
|
||||||
|
} from "@/types/trainScheduling";
|
||||||
|
|
||||||
|
const parseError = (error: unknown, fallback: string) => {
|
||||||
|
const message = (error as { response?: { data?: { message?: string | string[] } } })
|
||||||
|
?.response?.data?.message;
|
||||||
|
if (Array.isArray(message)) return message.join("; ");
|
||||||
|
return message || (error as Error)?.message || fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmt(n: number): string {
|
||||||
|
return Number.isInteger(n) ? String(n) : n.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
|
||||||
|
if (!capacity) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Capacity unknown — schedule has no locomotive/train set yet.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge variant="light" color={capacity.wagons > 0 ? "teal" : "red"}>
|
||||||
|
{fmt(capacity.wagons)} wagons free
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="light" color={capacity.weightTons > 0 ? "teal" : "red"}>
|
||||||
|
{fmt(capacity.weightTons)} t free
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="light" color={capacity.lengthMeters > 0 ? "teal" : "red"}>
|
||||||
|
{fmt(capacity.lengthMeters)} m free
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NeedCells({ need }: { need: IntercityCapacity | null }) {
|
||||||
|
if (!need) return <Table.Td colSpan={3}>—</Table.Td>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table.Td>{fmt(need.wagons)}</Table.Td>
|
||||||
|
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
|
||||||
|
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CorridorCell({ row }: { row: IntercityBookingRow }) {
|
||||||
|
return (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text size="sm">{row.origin}</Text>
|
||||||
|
<ArrowRight size={13} />
|
||||||
|
<Text size="sm">{row.destination}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercity ride-along desk for one import/export schedule: waiting intercity
|
||||||
|
* bookings whose corridor lies on this train's route, checked against the
|
||||||
|
* remaining wagon/weight/length budget. Accepting opens the customer's pay
|
||||||
|
* window; after payment the booking is allocated. Loading/unloading is
|
||||||
|
* confirmed manually when the train is physically at the booking's origin /
|
||||||
|
* destination yard (the server validates against recorded checkpoints).
|
||||||
|
*/
|
||||||
|
export function IntercityRideAlongPanel({
|
||||||
|
scheduleId,
|
||||||
|
direction,
|
||||||
|
}: {
|
||||||
|
scheduleId: string;
|
||||||
|
direction: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const candidatesQuery = useQuery(
|
||||||
|
api.trainScheduling.intercityCandidates.queryOptions({
|
||||||
|
input: { scheduleId },
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidate = () =>
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const accept = useMutation(
|
||||||
|
api.trainScheduling.acceptIntercityBookings.mutationOptions({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setSelected([]);
|
||||||
|
void invalidate();
|
||||||
|
if (result.accepted.length > 0) {
|
||||||
|
toast({
|
||||||
|
title: `${result.accepted.length} intercity booking(s) accepted`,
|
||||||
|
description: "Customers have been asked to pay.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const r of result.rejected) {
|
||||||
|
toast({
|
||||||
|
title: "Booking skipped",
|
||||||
|
description: r.reason,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast({
|
||||||
|
title: "Accept failed",
|
||||||
|
description: parseError(err, "Could not accept intercity bookings"),
|
||||||
|
variant: "destructive",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = useMutation(
|
||||||
|
api.trainScheduling.loadIntercityBooking.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
void invalidate();
|
||||||
|
toast({ title: "Cargo loaded" });
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast({
|
||||||
|
title: "Load failed",
|
||||||
|
description: parseError(err, "Could not confirm loading"),
|
||||||
|
variant: "destructive",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unload = useMutation(
|
||||||
|
api.trainScheduling.unloadIntercityBooking.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
void invalidate();
|
||||||
|
toast({ title: "Cargo unloaded — booking completed" });
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast({
|
||||||
|
title: "Unload failed",
|
||||||
|
description: parseError(err, "Could not confirm unloading"),
|
||||||
|
variant: "destructive",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Intercity bookings only ride import/export trains.
|
||||||
|
if (direction !== "IMPORT" && direction !== "EXPORT") return null;
|
||||||
|
|
||||||
|
const data = candidatesQuery.data;
|
||||||
|
const candidates = data?.candidates ?? [];
|
||||||
|
const accepted = data?.accepted ?? [];
|
||||||
|
|
||||||
|
if (candidatesQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="lg" p="lg" mt="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Loader size="xs" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading intercity ride-along bookings…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0 && accepted.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="lg" p="lg" mt="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" wrap="wrap">
|
||||||
|
<Group gap="xs">
|
||||||
|
<TrainFront size={18} />
|
||||||
|
<Text fw={700}>Intercity ride-along</Text>
|
||||||
|
</Group>
|
||||||
|
<CapacityBadges capacity={data?.remaining ?? null} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{candidates.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Waiting intercity bookings whose corridor lies on this train's
|
||||||
|
route. Accepting opens the customer's payment window against the
|
||||||
|
free capacity above.
|
||||||
|
</Text>
|
||||||
|
<Table.ScrollContainer minWidth={720}>
|
||||||
|
<Table verticalSpacing="xs" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th w={36} />
|
||||||
|
<Table.Th>Booking</Table.Th>
|
||||||
|
<Table.Th>Customer</Table.Th>
|
||||||
|
<Table.Th>Corridor</Table.Th>
|
||||||
|
<Table.Th>Wagons</Table.Th>
|
||||||
|
<Table.Th>Weight</Table.Th>
|
||||||
|
<Table.Th>Length</Table.Th>
|
||||||
|
<Table.Th>Fits</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{candidates.map((row) => (
|
||||||
|
<Table.Tr key={row.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Checkbox
|
||||||
|
size="xs"
|
||||||
|
checked={selected.includes(row.id)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelected((prev) =>
|
||||||
|
e.currentTarget.checked
|
||||||
|
? [...prev, row.id]
|
||||||
|
: prev.filter((id) => id !== row.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={6}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{row.reference ?? row.id.slice(0, 8)}
|
||||||
|
</Text>
|
||||||
|
{row.isGovernment && (
|
||||||
|
<Badge size="xs" variant="light" color="grape">
|
||||||
|
GOV
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{row.customer}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<CorridorCell row={row} />
|
||||||
|
</Table.Td>
|
||||||
|
<NeedCells need={row.need} />
|
||||||
|
<Table.Td>
|
||||||
|
{row.fits ? (
|
||||||
|
<Badge size="sm" variant="light" color="teal">
|
||||||
|
Fits
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Tooltip label="Exceeds the remaining wagon/weight/length budget">
|
||||||
|
<Badge size="sm" variant="light" color="red">
|
||||||
|
No room
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="edr-green"
|
||||||
|
loading={accept.isPending}
|
||||||
|
disabled={selected.length === 0}
|
||||||
|
onClick={() => accept.mutate({ scheduleId, bookingIds: selected })}
|
||||||
|
>
|
||||||
|
Accept {selected.length > 0 ? `${selected.length} ` : ""}onto this train
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{accepted.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
On this train
|
||||||
|
</Text>
|
||||||
|
<Table.ScrollContainer minWidth={680}>
|
||||||
|
<Table verticalSpacing="xs">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Booking</Table.Th>
|
||||||
|
<Table.Th>Customer</Table.Th>
|
||||||
|
<Table.Th>Corridor</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th />
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{accepted.map((row) => (
|
||||||
|
<Table.Tr key={row.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{row.reference ?? row.id.slice(0, 8)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{row.customer}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<CorridorCell row={row} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" variant="light">
|
||||||
|
{row.status}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="xs" justify="flex-end">
|
||||||
|
{row.status === "PAID" && (
|
||||||
|
<Tooltip label="Train must be at the booking's origin yard">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<PackageCheck size={13} />}
|
||||||
|
loading={load.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
load.mutate({ scheduleId, bookingId: row.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Load
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{row.status === "IN_TRANSIT" && (
|
||||||
|
<Tooltip label="Train must be at the booking's destination yard">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<PackageOpen size={13} />}
|
||||||
|
loading={unload.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
unload.mutate({ scheduleId, bookingId: row.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Unload
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{candidatesQuery.isError && (
|
||||||
|
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
|
||||||
|
{parseError(candidatesQuery.error, "Could not load intercity candidates")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { FileText } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import {
|
||||||
|
warehouseService,
|
||||||
|
type ContainerItem,
|
||||||
|
type ContainerItemStage,
|
||||||
|
} from '@/services/warehouse.service';
|
||||||
|
import { extractErrorMessage } from './options';
|
||||||
|
import { openPdfBlob } from './pdf';
|
||||||
|
|
||||||
|
interface ContainerItemsModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
bookingId: string | null;
|
||||||
|
bookingReference?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_TABS: Array<{ value: string; label: string }> = [
|
||||||
|
{ value: 'ALL', label: 'All' },
|
||||||
|
{ value: 'RECEIVED', label: 'Received' },
|
||||||
|
{ value: 'GRN', label: "GRN'd" },
|
||||||
|
{ value: 'LOADED', label: 'Loaded' },
|
||||||
|
{ value: 'LEFT', label: 'Left' },
|
||||||
|
{ value: 'DELIVERED', label: 'Delivered' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STAGE_COLOR: Record<ContainerItemStage, string> = {
|
||||||
|
PENDING: 'gray',
|
||||||
|
RECEIVED: 'blue',
|
||||||
|
GRN: 'teal',
|
||||||
|
LOADED: 'grape',
|
||||||
|
LEFT: 'orange',
|
||||||
|
DELIVERED: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Loadable = not yet on a truck (before LOADED). */
|
||||||
|
const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN';
|
||||||
|
|
||||||
|
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [tab, setTab] = useState('ALL');
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [truckId, setTruckId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const itemsKey = ['container-items', bookingId];
|
||||||
|
const { data: items = [], isLoading } = useQuery({
|
||||||
|
queryKey: itemsKey,
|
||||||
|
queryFn: () => warehouseService.getContainerItems(bookingId as string),
|
||||||
|
enabled: opened && Boolean(bookingId),
|
||||||
|
});
|
||||||
|
const { data: trucks = [] } = useQuery({
|
||||||
|
queryKey: ['ci-trucks', bookingId],
|
||||||
|
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
||||||
|
enabled: opened && Boolean(bookingId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = useMemo(
|
||||||
|
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
|
||||||
|
[items, tab],
|
||||||
|
);
|
||||||
|
const truckOptions = trucks
|
||||||
|
.filter((t) => !(t as { departedAt?: string }).departedAt)
|
||||||
|
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
|
||||||
|
|
||||||
|
const loadMutation = useMutation({
|
||||||
|
mutationFn: () => warehouseService.loadTruck(bookingId as string, truckId as string, selected),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: itemsKey });
|
||||||
|
setSelected([]);
|
||||||
|
toast({ title: 'Containers loaded onto truck' });
|
||||||
|
},
|
||||||
|
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const openExitPaper = async (assignmentId: string, plate: string) => {
|
||||||
|
try {
|
||||||
|
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
|
||||||
|
openPdfBlob(res.data, `exit-${plate}.pdf`);
|
||||||
|
} catch (e) {
|
||||||
|
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = (n: string) => setSelected((s) => (s.includes(n) ? s.filter((x) => x !== n) : [...s, n]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
size="90%"
|
||||||
|
title={<Text fw={700}>Container / bulk items {bookingReference ? `· ${bookingReference}` : ''}</Text>}
|
||||||
|
>
|
||||||
|
<Tabs value={tab} onChange={(v) => setTab(v ?? 'ALL')} mb="sm">
|
||||||
|
<Tabs.List>
|
||||||
|
{STAGE_TABS.map((t) => {
|
||||||
|
const count = t.value === 'ALL' ? items.length : items.filter((i) => i.stage === t.value).length;
|
||||||
|
return (
|
||||||
|
<Tabs.Tab key={t.value} value={t.value} rightSection={<Badge size="xs" variant="light">{count}</Badge>}>
|
||||||
|
{t.label}
|
||||||
|
</Tabs.Tab>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs.List>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Alert color="gray" variant="light">No container or bulk items on this booking.</Alert>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Table.ScrollContainer minWidth={900}>
|
||||||
|
<Table striped highlightOnHover verticalSpacing="xs">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th />
|
||||||
|
<Table.Th>Container</Table.Th>
|
||||||
|
<Table.Th>Goods</Table.Th>
|
||||||
|
<Table.Th>Stage</Table.Th>
|
||||||
|
<Table.Th>Truck</Table.Th>
|
||||||
|
<Table.Th>Booking</Table.Th>
|
||||||
|
<Table.Th>Contract</Table.Th>
|
||||||
|
<Table.Th>Last mile</Table.Th>
|
||||||
|
<Table.Th ta="right">Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{visible.map((i) => (
|
||||||
|
<Table.Tr key={i.containerNumber}>
|
||||||
|
<Table.Td>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.includes(i.containerNumber)}
|
||||||
|
onChange={() => toggle(i.containerNumber)}
|
||||||
|
disabled={!isLoadable(i)}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td><Text fw={600}>{i.containerNumber}</Text></Table.Td>
|
||||||
|
<Table.Td>{i.goods ?? '—'}</Table.Td>
|
||||||
|
<Table.Td><Badge color={STAGE_COLOR[i.stage]} variant="light">{i.stage}</Badge></Table.Td>
|
||||||
|
<Table.Td>{i.truckPlate ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>{i.bookingReference ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
|
||||||
|
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
{i.truckAssignmentId && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<FileText size={13} />}
|
||||||
|
onClick={() => openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')}
|
||||||
|
>
|
||||||
|
Exit Paper
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
|
||||||
|
{/* Multiselect → load onto a truck */}
|
||||||
|
<Group justify="space-between" align="flex-end">
|
||||||
|
<Text size="sm" c="dimmed">{selected.length} selected</Text>
|
||||||
|
<Group gap="sm" align="flex-end">
|
||||||
|
<Select
|
||||||
|
label="Load onto truck"
|
||||||
|
placeholder={truckOptions.length ? 'Select truck' : 'No arrived truck'}
|
||||||
|
data={truckOptions}
|
||||||
|
value={truckId}
|
||||||
|
onChange={setTruckId}
|
||||||
|
disabled={truckOptions.length === 0}
|
||||||
|
w={260}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
disabled={selected.length === 0 || !truckId}
|
||||||
|
loading={loadMutation.isPending}
|
||||||
|
onClick={() => loadMutation.mutate()}
|
||||||
|
>
|
||||||
|
Load selected
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ interface FeePreviewModalProps {
|
|||||||
const LABELS: Record<string, { label: string; color: string }> = {
|
const LABELS: Record<string, { label: string; color: string }> = {
|
||||||
DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' },
|
DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' },
|
||||||
STORAGE_FEE: { label: 'Storage', color: 'teal' },
|
STORAGE_FEE: { label: 'Storage', color: 'teal' },
|
||||||
|
DOUBLE_HANDLING_FEE: { label: 'Double Handling', color: 'grape' },
|
||||||
|
TRUCK_DETENTION_FEE: { label: 'Truck Detention Cost', color: 'blue' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function fmtDate(iso: string | null) {
|
function fmtDate(iso: string | null) {
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { TrainFront } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { warehouseService, type TrainLoadableItem } from '@/services/warehouse.service';
|
||||||
|
import { extractErrorMessage } from './options';
|
||||||
|
|
||||||
|
const STAGE_COLOR: Record<string, string> = {
|
||||||
|
RECEIVED: 'blue',
|
||||||
|
STORED: 'gray',
|
||||||
|
RESERVED: 'grape',
|
||||||
|
READY_FOR_LOADING: 'teal',
|
||||||
|
LOADED: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} kg`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load to Train — pick an allocated EXPORT train, see the arrived containers/cargoes
|
||||||
|
* assigned to it (stage tabs), multiselect the ready ones and load them onto their
|
||||||
|
* already-allocated wagons. Loading follows train + wagon allocation: only items
|
||||||
|
* that are READY_FOR_LOADING and have an allocated wagon are selectable.
|
||||||
|
*/
|
||||||
|
export function LoadToTrainPanel() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [scheduleId, setScheduleId] = useState<string | null>(null);
|
||||||
|
const [tab, setTab] = useState('received');
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const trainsKey = ['loadable-trains'];
|
||||||
|
const { data: trains = [], isLoading: trainsLoading } = useQuery({
|
||||||
|
queryKey: trainsKey,
|
||||||
|
queryFn: () => warehouseService.getLoadableTrains(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemsKey = ['train-loadable-items', scheduleId];
|
||||||
|
const { data: items = [], isLoading } = useQuery({
|
||||||
|
queryKey: itemsKey,
|
||||||
|
queryFn: () => warehouseService.getTrainLoadableItems(scheduleId as string),
|
||||||
|
enabled: Boolean(scheduleId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const received = useMemo(() => items.filter((i) => i.status !== 'LOADED'), [items]);
|
||||||
|
const loaded = useMemo(() => items.filter((i) => i.status === 'LOADED'), [items]);
|
||||||
|
const visible = tab === 'loaded' ? loaded : received;
|
||||||
|
|
||||||
|
const trainOptions = trains.map((t) => ({
|
||||||
|
value: t.scheduleId,
|
||||||
|
label:
|
||||||
|
`${t.trainNumber ?? t.scheduleId.slice(0, 8)}` +
|
||||||
|
(t.origin || t.destination ? ` · ${t.origin ?? '?'}→${t.destination ?? '?'}` : '') +
|
||||||
|
` · ${t.readyCount} ready / ${t.loadedCount} loaded`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const selectableVisible = visible.filter((i) => i.loadable);
|
||||||
|
const allSelected =
|
||||||
|
selectableVisible.length > 0 && selectableVisible.every((i) => selected.includes(i.id));
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
|
||||||
|
const toggleAll = () =>
|
||||||
|
setSelected((s) =>
|
||||||
|
allSelected
|
||||||
|
? s.filter((id) => !selectableVisible.some((i) => i.id === id))
|
||||||
|
: Array.from(new Set([...s, ...selectableVisible.map((i) => i.id)])),
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadMutation = useMutation({
|
||||||
|
mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId as string, selected),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: itemsKey });
|
||||||
|
queryClient.invalidateQueries({ queryKey: trainsKey });
|
||||||
|
setSelected([]);
|
||||||
|
toast({
|
||||||
|
title: 'Loaded onto train',
|
||||||
|
description: `Loaded ${r.loadedCount} item(s); skipped ${r.skippedCount}.`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (e) =>
|
||||||
|
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderRow = (i: TrainLoadableItem) => (
|
||||||
|
<Table.Tr key={i.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.includes(i.id)}
|
||||||
|
onChange={() => toggle(i.id)}
|
||||||
|
disabled={!i.loadable}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={600}>{i.containerNumber ?? i.cargoType ?? '—'}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{i.cargoType ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>{weight(i.weight)}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={STAGE_COLOR[i.status] ?? 'gray'} variant="light">
|
||||||
|
{i.status.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{i.wagonNumber ? (
|
||||||
|
<Badge variant="outline" color="indigo">
|
||||||
|
{i.wagonNumber}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Text size="xs" c="red">
|
||||||
|
Not allocated
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{i.bookingReference ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>{i.customerName ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{i.inspectionStatus ? (
|
||||||
|
<Badge size="xs" variant="light" color={i.inspectionStatus === 'PASSED' ? 'green' : 'orange'}>
|
||||||
|
{i.inspectionStatus}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group align="flex-end" justify="space-between">
|
||||||
|
<Select
|
||||||
|
label="Train"
|
||||||
|
description="Allocated EXPORT trains awaiting loading"
|
||||||
|
placeholder={trainsLoading ? 'Loading trains…' : trainOptions.length ? 'Select a train' : 'No trains to load'}
|
||||||
|
data={trainOptions}
|
||||||
|
value={scheduleId}
|
||||||
|
onChange={(v) => {
|
||||||
|
setScheduleId(v);
|
||||||
|
setSelected([]);
|
||||||
|
setTab('received');
|
||||||
|
}}
|
||||||
|
disabled={trainOptions.length === 0}
|
||||||
|
leftSection={<TrainFront size={16} />}
|
||||||
|
w={460}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!scheduleId ? (
|
||||||
|
<Alert color="gray" variant="light">
|
||||||
|
Pick a train to see the arrived containers/cargoes allocated to it. Items appear here only
|
||||||
|
after train and wagon allocation.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Tabs value={tab} onChange={(v) => setTab(v ?? 'received')}>
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Tab
|
||||||
|
value="received"
|
||||||
|
rightSection={
|
||||||
|
<Badge size="xs" variant="light" color="blue">
|
||||||
|
{received.length}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Received
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab
|
||||||
|
value="loaded"
|
||||||
|
rightSection={
|
||||||
|
<Badge size="xs" variant="light" color="green">
|
||||||
|
{loaded.length}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Loaded
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : visible.length === 0 ? (
|
||||||
|
<Alert color="gray" variant="light">
|
||||||
|
{tab === 'loaded' ? 'Nothing loaded onto this train yet.' : 'No arrived items ready for this train.'}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Table.ScrollContainer minWidth={900}>
|
||||||
|
<Table striped highlightOnHover verticalSpacing="xs">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>
|
||||||
|
{tab === 'received' && (
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
indeterminate={!allSelected && selected.length > 0}
|
||||||
|
onChange={toggleAll}
|
||||||
|
disabled={selectableVisible.length === 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Table.Th>
|
||||||
|
<Table.Th>Container / Cargo</Table.Th>
|
||||||
|
<Table.Th>Goods</Table.Th>
|
||||||
|
<Table.Th>Weight</Table.Th>
|
||||||
|
<Table.Th>Stage</Table.Th>
|
||||||
|
<Table.Th>Wagon</Table.Th>
|
||||||
|
<Table.Th>Booking</Table.Th>
|
||||||
|
<Table.Th>Customer</Table.Th>
|
||||||
|
<Table.Th>Inspection</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>{visible.map(renderRow)}</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'received' && (
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{selected.length} selected · only READY_FOR_LOADING items with an allocated wagon can be loaded
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<TrainFront size={16} />}
|
||||||
|
disabled={selected.length === 0}
|
||||||
|
loading={loadMutation.isPending}
|
||||||
|
onClick={() => loadMutation.mutate()}
|
||||||
|
>
|
||||||
|
Load {selected.length || ''} onto train
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,6 +61,8 @@ import type {
|
|||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
import { BookingSelect } from './BookingSelect';
|
import { BookingSelect } from './BookingSelect';
|
||||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||||
|
import { TruckDispatchModal } from './TruckDispatchModal';
|
||||||
|
import { ContainerItemsModal } from './ContainerItemsModal';
|
||||||
import { FeePreviewModal } from './FeePreviewModal';
|
import { FeePreviewModal } from './FeePreviewModal';
|
||||||
import { InspectionReportModal } from './InspectionReportModal';
|
import { InspectionReportModal } from './InspectionReportModal';
|
||||||
import { InventoryDetailModal } from './InventoryDetailModal';
|
import { InventoryDetailModal } from './InventoryDetailModal';
|
||||||
@@ -644,9 +646,15 @@ function TruckEntranceFields({
|
|||||||
function LocationSelects({
|
function LocationSelects({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
allowedYardTypes,
|
||||||
|
allowedZoneTypes,
|
||||||
}: {
|
}: {
|
||||||
value: Location;
|
value: Location;
|
||||||
onChange: (next: Location) => void;
|
onChange: (next: Location) => void;
|
||||||
|
/** When non-empty, only yards of these types are offered (matched to freight). */
|
||||||
|
allowedYardTypes?: string[];
|
||||||
|
/** When non-empty, only zones of these types are offered. */
|
||||||
|
allowedZoneTypes?: string[];
|
||||||
}) {
|
}) {
|
||||||
const warehousesQuery = useQuery(
|
const warehousesQuery = useQuery(
|
||||||
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
|
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
|
||||||
@@ -672,15 +680,17 @@ function LocationSelects({
|
|||||||
() =>
|
() =>
|
||||||
(yardsQuery.data ?? [])
|
(yardsQuery.data ?? [])
|
||||||
.filter((y) => y.status === 'ACTIVE')
|
.filter((y) => y.status === 'ACTIVE')
|
||||||
|
.filter((y) => !allowedYardTypes?.length || allowedYardTypes.includes((y as { type?: string }).type ?? ''))
|
||||||
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||||
[yardsQuery.data],
|
[yardsQuery.data, allowedYardTypes],
|
||||||
);
|
);
|
||||||
const zoneOptions = useMemo(
|
const zoneOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(zonesQuery.data ?? [])
|
(zonesQuery.data ?? [])
|
||||||
.filter((z) => z.status === 'ACTIVE')
|
.filter((z) => z.status === 'ACTIVE')
|
||||||
|
.filter((z) => !allowedZoneTypes?.length || allowedZoneTypes.includes((z as { type?: string }).type ?? ''))
|
||||||
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||||
[zonesQuery.data],
|
[zonesQuery.data, allowedZoneTypes],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1758,6 +1768,29 @@ const importLocationTypesForFreight = (freightType: string | null | undefined) =
|
|||||||
const isImportContainerFreight = (freightType: string | null | undefined) =>
|
const isImportContainerFreight = (freightType: string | null | undefined) =>
|
||||||
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yard/zone types valid for the freight being received — used to filter the receive
|
||||||
|
* location pickers so the yard list matches the cargo. Container freight → container
|
||||||
|
* yards only; bulk / break-bulk → bulk, general-cargo, hazardous, or cold-storage.
|
||||||
|
* Union across the given freight types; empty input → no restriction (show all).
|
||||||
|
*/
|
||||||
|
const yardZoneTypesForFreights = (freightTypes: Array<string | null | undefined>) => {
|
||||||
|
const yardTypes = new Set<string>();
|
||||||
|
const zoneTypes = new Set<string>();
|
||||||
|
for (const freightType of freightTypes) {
|
||||||
|
const normalized = (freightType ?? '').toUpperCase();
|
||||||
|
if (!normalized) continue;
|
||||||
|
if (normalized === 'CONTAINER') {
|
||||||
|
yardTypes.add('CONTAINER_YARD');
|
||||||
|
zoneTypes.add('CONTAINER_ZONE');
|
||||||
|
} else {
|
||||||
|
['BULK_YARD', 'GENERAL_CARGO_YARD', 'HAZARDOUS_YARD', 'COLD_STORAGE_YARD'].forEach((t) => yardTypes.add(t));
|
||||||
|
['BULK_ZONE', 'GENERAL_CARGO_ZONE', 'HAZARDOUS_ZONE', 'COLD_STORAGE_ZONE'].forEach((t) => zoneTypes.add(t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { yardTypes: [...yardTypes], zoneTypes: [...zoneTypes] };
|
||||||
|
};
|
||||||
|
|
||||||
const isImportUnloadPending = (item: ImportTrainItem) =>
|
const isImportUnloadPending = (item: ImportTrainItem) =>
|
||||||
!item.currentStatus || item.currentStatus === 'RECEIVED';
|
!item.currentStatus || item.currentStatus === 'RECEIVED';
|
||||||
|
|
||||||
@@ -2151,7 +2184,6 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
);
|
);
|
||||||
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
|
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
|
||||||
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
||||||
const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions());
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
@@ -2160,6 +2192,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
|
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
const [loadTruckItem, setLoadTruckItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
|
||||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||||
const someSelected = selected.size > 0 && !allSelected;
|
const someSelected = selected.size > 0 && !allSelected;
|
||||||
@@ -2375,7 +2409,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
<Table.Td ta="right">
|
<Table.Td ta="right">
|
||||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||||
<Tooltip label="View details" withArrow>
|
<Tooltip label="View details" withArrow>
|
||||||
<ActionIcon variant="subtle" color="gray" onClick={() => setViewItem(toInventoryItem(r))}>
|
<ActionIcon variant="subtle" color="gray" onClick={() => setContainerItemsItem(toInventoryItem(r))}>
|
||||||
<Eye size={16} />
|
<Eye size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -2419,9 +2453,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
variant="light"
|
variant="light"
|
||||||
color="green"
|
color="green"
|
||||||
loading={busyId === r.id}
|
loading={busyId === r.id}
|
||||||
onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))}
|
onClick={() => setLoadTruckItem(toInventoryItem(r))}
|
||||||
>
|
>
|
||||||
Dispatch
|
Truck_dispatch
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||||
@@ -2493,6 +2527,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
/>
|
/>
|
||||||
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
|
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
|
||||||
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
|
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
|
||||||
|
<TruckDispatchModal
|
||||||
|
opened={Boolean(loadTruckItem)}
|
||||||
|
onClose={() => setLoadTruckItem(null)}
|
||||||
|
bookingId={loadTruckItem?.booking?.id ?? null}
|
||||||
|
bookingReference={loadTruckItem?.booking?.reference ?? null}
|
||||||
|
/>
|
||||||
|
<ContainerItemsModal
|
||||||
|
opened={Boolean(containerItemsItem)}
|
||||||
|
onClose={() => setContainerItemsItem(null)}
|
||||||
|
bookingId={containerItemsItem?.booking?.id ?? null}
|
||||||
|
bookingReference={containerItemsItem?.booking?.reference ?? null}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2873,6 +2919,24 @@ export function WarehouseFlowWorkbench({
|
|||||||
);
|
);
|
||||||
const activeDirection = direction === 'BOTH' ? tab : direction;
|
const activeDirection = direction === 'BOTH' ? tab : direction;
|
||||||
|
|
||||||
|
// Match the yard/zone list to the freight being received (container → container
|
||||||
|
// yards, etc). Same query key as the export tab, so React Query dedupes it.
|
||||||
|
const { data: eligibleForLocation = [] } = useQuery(
|
||||||
|
api.warehouses.eligibleBookings.queryOptions({
|
||||||
|
input: { direction: activeDirection },
|
||||||
|
enabled: enabled && activeDirection === 'EXPORT',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { yardTypes: allowedYardTypes, zoneTypes: allowedZoneTypes } = useMemo(
|
||||||
|
() =>
|
||||||
|
yardZoneTypesForFreights(
|
||||||
|
eligibleForLocation
|
||||||
|
.filter((r) => r.direction === activeDirection)
|
||||||
|
.map((r) => r.freightType),
|
||||||
|
),
|
||||||
|
[eligibleForLocation, activeDirection],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (enabled) setLocation({ warehouseId: '', yardId: '', zoneId: '' });
|
if (enabled) setLocation({ warehouseId: '', yardId: '', zoneId: '' });
|
||||||
}, [enabled, direction]);
|
}, [enabled, direction]);
|
||||||
@@ -2880,7 +2944,12 @@ export function WarehouseFlowWorkbench({
|
|||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{activeDirection === 'EXPORT' && (
|
{activeDirection === 'EXPORT' && (
|
||||||
<LocationSelects value={location} onChange={setLocation} />
|
<LocationSelects
|
||||||
|
value={location}
|
||||||
|
onChange={setLocation}
|
||||||
|
allowedYardTypes={allowedYardTypes}
|
||||||
|
allowedZoneTypes={allowedZoneTypes}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{direction === 'BOTH' ? (
|
{direction === 'BOTH' ? (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||||
import { Info, Scale } from 'lucide-react';
|
import { Info, Scale } from 'lucide-react';
|
||||||
|
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
@@ -134,6 +134,13 @@ const parseInspectionNote = (notes: string | null | undefined) => {
|
|||||||
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
|
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||||
|
const bookingId = item?.booking?.id;
|
||||||
|
// Customer self-haul trucks assigned to this booking via the portal.
|
||||||
|
const { data: customerTrucks = [] } = useQuery({
|
||||||
|
queryKey: ['release-customer-trucks', bookingId],
|
||||||
|
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
||||||
|
enabled: opened && Boolean(bookingId),
|
||||||
|
});
|
||||||
const [reference, setReference] = useState('');
|
const [reference, setReference] = useState('');
|
||||||
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
||||||
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
||||||
@@ -178,6 +185,44 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
const isEntranceLocked = isExitStep;
|
const isEntranceLocked = isExitStep;
|
||||||
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
||||||
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
|
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
|
||||||
|
|
||||||
|
// Registered trucks for THIS booking, from both sources: EDR last-mile
|
||||||
|
// (truckPrefill) and the customer portal (customer_truck_assignments).
|
||||||
|
const assignedTruckOptions = [
|
||||||
|
...(truckPrefill?.truckPlateNumber
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: truckPrefill.truckPlateNumber,
|
||||||
|
label: `Last-mile · ${truckPrefill.truckPlateNumber}`,
|
||||||
|
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
|
||||||
|
driverName: truckPrefill.driverName ?? '',
|
||||||
|
driverPhone: truckPrefill.driverPhone ?? '',
|
||||||
|
truckType: truckPrefill.truckType ?? '',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...customerTrucks.map((t) => ({
|
||||||
|
value: t.plateNumber,
|
||||||
|
label: `Customer · ${t.plateNumber} — ${t.driverName}`,
|
||||||
|
trailerPlate: '',
|
||||||
|
driverName: t.driverName,
|
||||||
|
driverPhone: '',
|
||||||
|
truckType: t.truckType,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const truckSelectOptions = [
|
||||||
|
...assignedTruckOptions,
|
||||||
|
...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({
|
||||||
|
value: t.value,
|
||||||
|
label: t.label,
|
||||||
|
trailerPlate: t.trailerPlate,
|
||||||
|
driverName: '',
|
||||||
|
driverPhone: '',
|
||||||
|
truckType: '',
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
||||||
|
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
||||||
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
||||||
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
||||||
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
|
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
|
||||||
@@ -286,18 +331,26 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
onChange={(e) => setReference(e.currentTarget.value)}
|
onChange={(e) => setReference(e.currentTarget.value)}
|
||||||
readOnly={isEntranceLocked}
|
readOnly={isEntranceLocked}
|
||||||
/>
|
/>
|
||||||
|
{noTruckAssigned && (
|
||||||
|
<Alert color="orange" variant="light" icon={<Info size={16} />}>
|
||||||
|
Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<Select
|
<Select
|
||||||
label="Registered first / last-mile truck"
|
label="Registered first / last-mile truck"
|
||||||
placeholder="Select truck or type plate manually below"
|
placeholder="Select truck or type plate manually below"
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
|
data={truckSelectOptions}
|
||||||
disabled={isTruckIdentityLocked}
|
disabled={isTruckIdentityLocked}
|
||||||
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
|
const truck = truckSelectOptions.find((row) => row.value === value);
|
||||||
setTruckPlateNumber(truck?.value ?? '');
|
setTruckPlateNumber(truck?.value ?? '');
|
||||||
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
||||||
|
if (truck?.driverName) setDriverName(truck.driverName);
|
||||||
|
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
||||||
|
if (truck?.truckType) setTruckType(truck.truckType);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { Alert, Badge, Button, Group, Loader, Modal, MultiSelect, Stack, Text } from '@mantine/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { FileText, Truck } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { warehouseService } from '@/services/warehouse.service';
|
||||||
|
import { extractErrorMessage } from './options';
|
||||||
|
import { openPdfBlob } from './pdf';
|
||||||
|
|
||||||
|
interface TruckDispatchModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
bookingId: string | null;
|
||||||
|
bookingReference?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truck_dispatch: after a self-haul truck arrives, staff select which of the
|
||||||
|
* booking's containers ride each truck. The loaded set drives the truck's gross
|
||||||
|
* weight; the truck is weighed for real on departure.
|
||||||
|
*/
|
||||||
|
export function TruckDispatchModal({ opened, onClose, bookingId, bookingReference }: TruckDispatchModalProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedByTruck, setSelectedByTruck] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
|
const trucksKey = ['td-customer-trucks', bookingId];
|
||||||
|
const loadableKey = ['td-loadable', bookingId];
|
||||||
|
|
||||||
|
const { data: trucks = [], isLoading: trucksLoading } = useQuery({
|
||||||
|
queryKey: trucksKey,
|
||||||
|
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
||||||
|
enabled: opened && Boolean(bookingId),
|
||||||
|
});
|
||||||
|
const { data: loadable = [], isLoading: loadableLoading } = useQuery({
|
||||||
|
queryKey: loadableKey,
|
||||||
|
queryFn: () => warehouseService.getLoadableContainers(bookingId as string),
|
||||||
|
enabled: opened && Boolean(bookingId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadMutation = useMutation({
|
||||||
|
mutationFn: ({ assignmentId, containerNumbers }: { assignmentId: string; containerNumbers: string[] }) =>
|
||||||
|
warehouseService.loadTruck(bookingId as string, assignmentId, containerNumbers),
|
||||||
|
onSuccess: (_res, vars) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: trucksKey });
|
||||||
|
queryClient.invalidateQueries({ queryKey: loadableKey });
|
||||||
|
setSelectedByTruck((s) => ({ ...s, [vars.assignmentId]: [] }));
|
||||||
|
toast({ title: 'Truck loaded' });
|
||||||
|
},
|
||||||
|
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const openTruckExitPaper = async (assignmentId: string, plate: string) => {
|
||||||
|
try {
|
||||||
|
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
|
||||||
|
openPdfBlob(res.data, `exit-${plate}.pdf`);
|
||||||
|
} catch (e) {
|
||||||
|
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
size="lg"
|
||||||
|
title={
|
||||||
|
<Group gap={8}>
|
||||||
|
<Truck size={18} />
|
||||||
|
<Text fw={700}>Truck_dispatch — load containers {bookingReference ? `· ${bookingReference}` : ''}</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{trucksLoading || loadableLoading ? (
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
) : trucks.length === 0 ? (
|
||||||
|
<Alert color="orange" variant="light">
|
||||||
|
No customer truck is assigned to this booking yet.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Stack gap="md">
|
||||||
|
{trucks.map((t) => {
|
||||||
|
const alreadyLoaded = (t.containers ?? []).map((c) => c.containerNumber);
|
||||||
|
// Options = still-loadable + this truck's own already-loaded (so they stay visible).
|
||||||
|
const options = Array.from(new Set([...loadable, ...alreadyLoaded]));
|
||||||
|
const selected = selectedByTruck[t.id] ?? alreadyLoaded;
|
||||||
|
const departed = Boolean(t.arrivedAt) && Boolean((t as { departedAt?: string }).departedAt);
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
key={t.id}
|
||||||
|
gap={8}
|
||||||
|
style={{ border: '1px solid #EEF2F6', borderRadius: 12, padding: 14 }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={700}>{t.plateNumber}</Text>
|
||||||
|
<Group gap={6}>
|
||||||
|
<Text size="sm" c="dimmed">{t.driverName} · {t.truckType}</Text>
|
||||||
|
{t.arrivedAt ? <Badge color="green" variant="light">Arrived</Badge> : <Badge color="orange" variant="light">Not arrived</Badge>}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<MultiSelect
|
||||||
|
label="Containers on this truck"
|
||||||
|
placeholder="Select containers"
|
||||||
|
data={options}
|
||||||
|
value={selected}
|
||||||
|
onChange={(v) => setSelectedByTruck((s) => ({ ...s, [t.id]: v }))}
|
||||||
|
searchable
|
||||||
|
disabled={departed || !t.arrivedAt}
|
||||||
|
nothingFoundMessage="No loadable containers"
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="xs">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<FileText size={14} />}
|
||||||
|
onClick={() => openTruckExitPaper(t.id, t.plateNumber)}
|
||||||
|
>
|
||||||
|
Exit Paper
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="edr-green"
|
||||||
|
disabled={departed || !t.arrivedAt || (selectedByTruck[t.id] ?? alreadyLoaded).length === 0}
|
||||||
|
loading={loadMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
loadMutation.mutate({
|
||||||
|
assignmentId: t.id,
|
||||||
|
containerNumbers: selectedByTruck[t.id] ?? alreadyLoaded,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Load truck
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -324,6 +324,14 @@ export const URL_CONSTANTS = {
|
|||||||
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
||||||
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
||||||
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
||||||
|
INTERCITY_CANDIDATES: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/intercity-candidates`,
|
||||||
|
INTERCITY_ACCEPT: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/intercity/accept`,
|
||||||
|
INTERCITY_LOAD: (id: string, bookingId: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/intercity/${bookingId}/load`,
|
||||||
|
INTERCITY_UNLOAD: (id: string, bookingId: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/intercity/${bookingId}/unload`,
|
||||||
IMPORT_LOADING_BOOKINGS: (id: string) =>
|
IMPORT_LOADING_BOOKINGS: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/import-loading-bookings`,
|
`/train-scheduling/schedules/${id}/import-loading-bookings`,
|
||||||
IMPORT_LOADING_STATUS: (id: string) =>
|
IMPORT_LOADING_STATUS: (id: string) =>
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
BOOKING_WINDOW_WS_EVENTS,
|
||||||
|
BOOKING_WINDOW_WS_NAMESPACE,
|
||||||
|
type BookingWindowPhaseEvent,
|
||||||
|
} from "@edr/types";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
|
import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
|
||||||
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
|
|
||||||
|
// The socket namespace lives at the server root, not under the `/api` REST
|
||||||
|
// prefix — strip a trailing `/api` if the base URL carries one.
|
||||||
|
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to live booking-window pushes for staff. Every phase transition
|
||||||
|
* the window engine applies invalidates the GL windows carousel and the batch
|
||||||
|
* board, so both flip the moment the backend does — polling stays only as a
|
||||||
|
* fallback.
|
||||||
|
*/
|
||||||
|
export function useBookingWindowSocket(enabled: boolean = true) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const token = getCookie(AUTH_TOKEN_COOKIE);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const socket = io(`${SOCKET_ORIGIN}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
|
||||||
|
auth: { token },
|
||||||
|
transports: ["websocket"],
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deliberate console breadcrumbs: "live updates not arriving" is only
|
||||||
|
// diagnosable from the browser when connect/reject outcomes are visible.
|
||||||
|
socket.on("connect", () =>
|
||||||
|
console.debug("[booking-windows] socket connected", socket.id),
|
||||||
|
);
|
||||||
|
socket.on("connect_error", (err) =>
|
||||||
|
console.warn("[booking-windows] socket connect failed:", err.message),
|
||||||
|
);
|
||||||
|
socket.on("disconnect", (reason) =>
|
||||||
|
console.debug("[booking-windows] socket disconnected:", reason),
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.on(
|
||||||
|
BOOKING_WINDOW_WS_EVENTS.PHASE,
|
||||||
|
(_event: BookingWindowPhaseEvent) => {
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: ["train-scheduling", "all-booking-windows"],
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off();
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
}, [enabled, qc]);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NotificationType } from "@edr/types";
|
import { NotificationType } from "@edr/types";
|
||||||
import type { NotificationItemData, NotificationVisual } from "@edr/ui-common";
|
import type { NotificationItemData, NotificationVisual } from "@edr/ui-common";
|
||||||
import { Bell, ClipboardCheck, Inbox, Wallet } from "lucide-react";
|
import { Bell, ClipboardCheck, FileSignature, Inbox, Wallet } from "lucide-react";
|
||||||
|
|
||||||
const ICON_SIZE = 17;
|
const ICON_SIZE = 17;
|
||||||
|
|
||||||
@@ -19,6 +19,8 @@ export function resolveNotificationVisual(
|
|||||||
return { icon: <Wallet size={ICON_SIZE} />, color: "teal" };
|
return { icon: <Wallet size={ICON_SIZE} />, color: "teal" };
|
||||||
case NotificationType.CLEARANCE_REVIEW:
|
case NotificationType.CLEARANCE_REVIEW:
|
||||||
return { icon: <ClipboardCheck size={ICON_SIZE} />, color: "orange" };
|
return { icon: <ClipboardCheck size={ICON_SIZE} />, color: "orange" };
|
||||||
|
case NotificationType.CONTRACT_STATUS:
|
||||||
|
return { icon: <FileSignature size={ICON_SIZE} />, color: "indigo" };
|
||||||
default:
|
default:
|
||||||
return { icon: <Bell size={ICON_SIZE} />, color: "edr-green" };
|
return { icon: <Bell size={ICON_SIZE} />, color: "edr-green" };
|
||||||
}
|
}
|
||||||
@@ -39,14 +41,32 @@ export function resolveNotificationHref(
|
|||||||
if (item.link) return item.link;
|
if (item.link) return item.link;
|
||||||
const data = item.data ?? {};
|
const data = item.data ?? {};
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case NotificationType.REQUEST_SUBMITTED:
|
case NotificationType.REQUEST_SUBMITTED: {
|
||||||
|
const bookingId = asId(data.bookingId);
|
||||||
|
if (bookingId) return `/dashboard/booking-requests/${bookingId}`;
|
||||||
|
const contractId = asId(data.contractId);
|
||||||
|
if (contractId) return `/dashboard/contract-requests/${contractId}`;
|
||||||
return "/dashboard/booking-requests";
|
return "/dashboard/booking-requests";
|
||||||
|
}
|
||||||
case NotificationType.PAYMENT_RECEIVED: {
|
case NotificationType.PAYMENT_RECEIVED: {
|
||||||
|
const bookingId = asId(data.bookingId);
|
||||||
|
if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`;
|
||||||
const id = asId(data.customerId);
|
const id = asId(data.customerId);
|
||||||
return id ? `/dashboard/customers/${id}` : "/dashboard/customers";
|
return id ? `/dashboard/customers/${id}` : "/dashboard/customers";
|
||||||
}
|
}
|
||||||
case NotificationType.CLEARANCE_REVIEW:
|
case NotificationType.CLEARANCE_REVIEW: {
|
||||||
|
const bookingId = asId(data.bookingId);
|
||||||
|
if (bookingId) return `/dashboard/bookings/${bookingId}/clearance`;
|
||||||
|
const contractId = asId(data.contractId);
|
||||||
|
if (contractId) return `/dashboard/contracts/clearance/${contractId}`;
|
||||||
return "/dashboard/arrival-queue";
|
return "/dashboard/arrival-queue";
|
||||||
|
}
|
||||||
|
case NotificationType.CONTRACT_STATUS: {
|
||||||
|
const contractId = asId(data.contractId);
|
||||||
|
return contractId
|
||||||
|
? `/dashboard/contract-requests/${contractId}`
|
||||||
|
: "/dashboard/contract-requests";
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
|||||||
export const FREIGHT_PERMS = {
|
export const FREIGHT_PERMS = {
|
||||||
bookings: {
|
bookings: {
|
||||||
view: "edr_freight_app:bookings:view",
|
view: "edr_freight_app:bookings:view",
|
||||||
|
create: "edr_freight_app:bookings:create",
|
||||||
clearanceView: "edr_freight_app:bookings:clearance_view",
|
clearanceView: "edr_freight_app:bookings:clearance_view",
|
||||||
staffAccept: "edr_freight_app:bookings:staff_accept",
|
staffAccept: "edr_freight_app:bookings:staff_accept",
|
||||||
requestChanges: "edr_freight_app:bookings:request_changes",
|
requestChanges: "edr_freight_app:bookings:request_changes",
|
||||||
@@ -41,6 +42,11 @@ export const FREIGHT_PERMS = {
|
|||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
view: "edr_freight_app:train_scheduling:view",
|
view: "edr_freight_app:train_scheduling:view",
|
||||||
manage: "edr_freight_app:train_scheduling:manage",
|
manage: "edr_freight_app:train_scheduling:manage",
|
||||||
|
create: "edr_freight_app:train_scheduling:create",
|
||||||
|
update: "edr_freight_app:train_scheduling:update",
|
||||||
|
cancel: "edr_freight_app:train_scheduling:cancel",
|
||||||
|
reschedule: "edr_freight_app:train_scheduling:reschedule",
|
||||||
|
rulesManage: "edr_freight_app:train_scheduling:rules_manage",
|
||||||
},
|
},
|
||||||
fleet: {
|
fleet: {
|
||||||
view: "edr_freight_app:fleet:view",
|
view: "edr_freight_app:fleet:view",
|
||||||
@@ -50,6 +56,243 @@ export const FREIGHT_PERMS = {
|
|||||||
allocation: {
|
allocation: {
|
||||||
manage: "edr_freight_app:allocation:manage",
|
manage: "edr_freight_app:allocation:manage",
|
||||||
},
|
},
|
||||||
|
customers: {
|
||||||
|
view: "edr_freight_app:customers:view",
|
||||||
|
create: "edr_freight_app:customers:create",
|
||||||
|
update: "edr_freight_app:customers:update",
|
||||||
|
deactivate: "edr_freight_app:customers:deactivate",
|
||||||
|
verify: "edr_freight_app:customers:verify",
|
||||||
|
},
|
||||||
|
payments: {
|
||||||
|
view: "edr_freight_app:payments:view",
|
||||||
|
verify: "edr_freight_app:payments:verify",
|
||||||
|
refund: "edr_freight_app:payments:refund",
|
||||||
|
},
|
||||||
|
invoices: {
|
||||||
|
view: "edr_freight_app:invoices:view",
|
||||||
|
create: "edr_freight_app:invoices:create",
|
||||||
|
cancel: "edr_freight_app:invoices:cancel",
|
||||||
|
export: "edr_freight_app:invoices:export",
|
||||||
|
},
|
||||||
|
firstMile: {
|
||||||
|
view: "edr_freight_app:first_mile:view",
|
||||||
|
accept: "edr_freight_app:first_mile:accept",
|
||||||
|
create: "edr_freight_app:first_mile:create",
|
||||||
|
update: "edr_freight_app:first_mile:update",
|
||||||
|
delete: "edr_freight_app:first_mile:delete",
|
||||||
|
assignVehicles: "edr_freight_app:first_mile:assign_vehicles",
|
||||||
|
setDistances: "edr_freight_app:first_mile:set_distances",
|
||||||
|
generateInvoice: "edr_freight_app:first_mile:generate_invoice",
|
||||||
|
},
|
||||||
|
lastMile: {
|
||||||
|
view: "edr_freight_app:last_mile:view",
|
||||||
|
accept: "edr_freight_app:last_mile:accept",
|
||||||
|
create: "edr_freight_app:last_mile:create",
|
||||||
|
update: "edr_freight_app:last_mile:update",
|
||||||
|
delete: "edr_freight_app:last_mile:delete",
|
||||||
|
assignVehicles: "edr_freight_app:last_mile:assign_vehicles",
|
||||||
|
setDistances: "edr_freight_app:last_mile:set_distances",
|
||||||
|
generateInvoice: "edr_freight_app:last_mile:generate_invoice",
|
||||||
|
},
|
||||||
|
locomotives: {
|
||||||
|
view: "edr_freight_app:locomotives:view",
|
||||||
|
create: "edr_freight_app:locomotives:create",
|
||||||
|
update: "edr_freight_app:locomotives:update",
|
||||||
|
delete: "edr_freight_app:locomotives:delete",
|
||||||
|
},
|
||||||
|
wagons: {
|
||||||
|
view: "edr_freight_app:wagons:view",
|
||||||
|
create: "edr_freight_app:wagons:create",
|
||||||
|
update: "edr_freight_app:wagons:update",
|
||||||
|
delete: "edr_freight_app:wagons:delete",
|
||||||
|
},
|
||||||
|
trains: {
|
||||||
|
view: "edr_freight_app:trains:view",
|
||||||
|
create: "edr_freight_app:trains:create",
|
||||||
|
update: "edr_freight_app:trains:update",
|
||||||
|
delete: "edr_freight_app:trains:delete",
|
||||||
|
assignWagons: "edr_freight_app:trains:assign_wagons",
|
||||||
|
},
|
||||||
|
routes: {
|
||||||
|
view: "edr_freight_app:routes:view",
|
||||||
|
create: "edr_freight_app:routes:create",
|
||||||
|
update: "edr_freight_app:routes:update",
|
||||||
|
delete: "edr_freight_app:routes:delete",
|
||||||
|
},
|
||||||
|
containers: {
|
||||||
|
view: "edr_freight_app:containers:view",
|
||||||
|
create: "edr_freight_app:containers:create",
|
||||||
|
update: "edr_freight_app:containers:update",
|
||||||
|
delete: "edr_freight_app:containers:delete",
|
||||||
|
},
|
||||||
|
cargoes: {
|
||||||
|
view: "edr_freight_app:cargoes:view",
|
||||||
|
create: "edr_freight_app:cargoes:create",
|
||||||
|
update: "edr_freight_app:cargoes:update",
|
||||||
|
delete: "edr_freight_app:cargoes:delete",
|
||||||
|
},
|
||||||
|
vehicles: {
|
||||||
|
view: "edr_freight_app:vehicles:view",
|
||||||
|
create: "edr_freight_app:vehicles:create",
|
||||||
|
update: "edr_freight_app:vehicles:update",
|
||||||
|
delete: "edr_freight_app:vehicles:delete",
|
||||||
|
},
|
||||||
|
drivers: {
|
||||||
|
view: "edr_freight_app:drivers:view",
|
||||||
|
create: "edr_freight_app:drivers:create",
|
||||||
|
update: "edr_freight_app:drivers:update",
|
||||||
|
delete: "edr_freight_app:drivers:delete",
|
||||||
|
},
|
||||||
|
tracking: {
|
||||||
|
view: "edr_freight_app:tracking:view",
|
||||||
|
},
|
||||||
|
fuel: {
|
||||||
|
view: "edr_freight_app:fuel:view",
|
||||||
|
create: "edr_freight_app:fuel:create",
|
||||||
|
update: "edr_freight_app:fuel:update",
|
||||||
|
delete: "edr_freight_app:fuel:delete",
|
||||||
|
approve: "edr_freight_app:fuel:approve",
|
||||||
|
},
|
||||||
|
maintenance: {
|
||||||
|
view: "edr_freight_app:maintenance:view",
|
||||||
|
create: "edr_freight_app:maintenance:create",
|
||||||
|
update: "edr_freight_app:maintenance:update",
|
||||||
|
delete: "edr_freight_app:maintenance:delete",
|
||||||
|
complete: "edr_freight_app:maintenance:complete",
|
||||||
|
},
|
||||||
|
fleetReports: {
|
||||||
|
view: "edr_freight_app:fleet_reports:view",
|
||||||
|
export: "edr_freight_app:fleet_reports:export",
|
||||||
|
},
|
||||||
|
fleetDashboard: {
|
||||||
|
view: "edr_freight_app:fleet_dashboard:view",
|
||||||
|
},
|
||||||
|
warehouseDashboard: {
|
||||||
|
view: "edr_freight_app:warehouse_dashboard:view",
|
||||||
|
},
|
||||||
|
warehouses: {
|
||||||
|
view: "edr_freight_app:warehouses:view",
|
||||||
|
create: "edr_freight_app:warehouses:create",
|
||||||
|
update: "edr_freight_app:warehouses:update",
|
||||||
|
delete: "edr_freight_app:warehouses:delete",
|
||||||
|
},
|
||||||
|
warehouseYards: {
|
||||||
|
view: "edr_freight_app:warehouse_yards:view",
|
||||||
|
create: "edr_freight_app:warehouse_yards:create",
|
||||||
|
update: "edr_freight_app:warehouse_yards:update",
|
||||||
|
delete: "edr_freight_app:warehouse_yards:delete",
|
||||||
|
},
|
||||||
|
warehouseZones: {
|
||||||
|
view: "edr_freight_app:warehouse_zones:view",
|
||||||
|
create: "edr_freight_app:warehouse_zones:create",
|
||||||
|
update: "edr_freight_app:warehouse_zones:update",
|
||||||
|
},
|
||||||
|
warehouseAllocationRules: {
|
||||||
|
view: "edr_freight_app:warehouse_allocation_rules:view",
|
||||||
|
create: "edr_freight_app:warehouse_allocation_rules:create",
|
||||||
|
update: "edr_freight_app:warehouse_allocation_rules:update",
|
||||||
|
delete: "edr_freight_app:warehouse_allocation_rules:delete",
|
||||||
|
},
|
||||||
|
warehouseFeeRules: {
|
||||||
|
view: "edr_freight_app:warehouse_fee_rules:view",
|
||||||
|
create: "edr_freight_app:warehouse_fee_rules:create",
|
||||||
|
update: "edr_freight_app:warehouse_fee_rules:update",
|
||||||
|
delete: "edr_freight_app:warehouse_fee_rules:delete",
|
||||||
|
},
|
||||||
|
warehouseInspectionReports: {
|
||||||
|
view: "edr_freight_app:warehouse_inspection_reports:view",
|
||||||
|
create: "edr_freight_app:warehouse_inspection_reports:create",
|
||||||
|
update: "edr_freight_app:warehouse_inspection_reports:update",
|
||||||
|
},
|
||||||
|
warehouseInventory: {
|
||||||
|
view: "edr_freight_app:warehouse_inventory:view",
|
||||||
|
receive: "edr_freight_app:warehouse_inventory:receive",
|
||||||
|
move: "edr_freight_app:warehouse_inventory:move",
|
||||||
|
load: "edr_freight_app:warehouse_inventory:load",
|
||||||
|
unload: "edr_freight_app:warehouse_inventory:unload",
|
||||||
|
dispatch: "edr_freight_app:warehouse_inventory:dispatch",
|
||||||
|
gatePass: "edr_freight_app:warehouse_inventory:gate_pass",
|
||||||
|
release: "edr_freight_app:warehouse_inventory:release",
|
||||||
|
deliver: "edr_freight_app:warehouse_inventory:deliver",
|
||||||
|
inspect: "edr_freight_app:warehouse_inventory:inspect",
|
||||||
|
},
|
||||||
|
interchangeDocuments: {
|
||||||
|
view: "edr_freight_app:interchange_documents:view",
|
||||||
|
generate: "edr_freight_app:interchange_documents:generate",
|
||||||
|
acknowledge: "edr_freight_app:interchange_documents:acknowledge",
|
||||||
|
dispute: "edr_freight_app:interchange_documents:dispute",
|
||||||
|
cancel: "edr_freight_app:interchange_documents:cancel",
|
||||||
|
},
|
||||||
|
warehouseFeeInvoices: {
|
||||||
|
view: "edr_freight_app:warehouse_fee_invoices:view",
|
||||||
|
generate: "edr_freight_app:warehouse_fee_invoices:generate",
|
||||||
|
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
|
||||||
|
pay: "edr_freight_app:warehouse_fee_invoices:pay",
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
contractValidity: {
|
||||||
|
view: "edr_freight_app:config:contract_validity:view",
|
||||||
|
manage: "edr_freight_app:config:contract_validity:manage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
fileUpload: {
|
||||||
|
view: "edr_freight_app:settings:file_upload:view",
|
||||||
|
manage: "edr_freight_app:settings:file_upload:manage",
|
||||||
|
},
|
||||||
|
dropdown: {
|
||||||
|
view: "edr_freight_app:settings:dropdown:view",
|
||||||
|
manage: "edr_freight_app:settings:dropdown:manage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
staff: {
|
||||||
|
roles: {
|
||||||
|
view: "edr_freight_app:staff:roles:view",
|
||||||
|
create: "edr_freight_app:staff:roles:create",
|
||||||
|
update: "edr_freight_app:staff:roles:update",
|
||||||
|
delete: "edr_freight_app:staff:roles:delete",
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
view: "edr_freight_app:staff:permissions:view",
|
||||||
|
assign: "edr_freight_app:staff:permissions:assign",
|
||||||
|
},
|
||||||
|
employeeRegistration: {
|
||||||
|
view: "edr_freight_app:employee_registration:view",
|
||||||
|
create: "edr_freight_app:employee_registration:create",
|
||||||
|
update: "edr_freight_app:employee_registration:update",
|
||||||
|
activate: "edr_freight_app:employee_registration:activate",
|
||||||
|
deactivate: "edr_freight_app:employee_registration:deactivate",
|
||||||
|
},
|
||||||
|
roleAssignment: {
|
||||||
|
view: "edr_freight_app:role_assignment:view",
|
||||||
|
assign: "edr_freight_app:role_assignment:assign",
|
||||||
|
replace: "edr_freight_app:role_assignment:replace",
|
||||||
|
},
|
||||||
|
hierarchyUnits: {
|
||||||
|
view: "edr_freight_app:hierarchy_units:view",
|
||||||
|
create: "edr_freight_app:hierarchy_units:create",
|
||||||
|
update: "edr_freight_app:hierarchy_units:update",
|
||||||
|
delete: "edr_freight_app:hierarchy_units:delete",
|
||||||
|
},
|
||||||
|
hierarchyPositions: {
|
||||||
|
view: "edr_freight_app:hierarchy_positions:view",
|
||||||
|
create: "edr_freight_app:hierarchy_positions:create",
|
||||||
|
update: "edr_freight_app:hierarchy_positions:update",
|
||||||
|
delete: "edr_freight_app:hierarchy_positions:delete",
|
||||||
|
changeParent: "edr_freight_app:hierarchy_positions:change_parent",
|
||||||
|
},
|
||||||
|
hierarchyEmployeeAssignment: {
|
||||||
|
view: "edr_freight_app:hierarchy_employee_assignment:view",
|
||||||
|
invite: "edr_freight_app:hierarchy_employee_assignment:invite",
|
||||||
|
assign: "edr_freight_app:hierarchy_employee_assignment:assign",
|
||||||
|
},
|
||||||
|
positionTypes: {
|
||||||
|
view: "edr_freight_app:position_types:view",
|
||||||
|
create: "edr_freight_app:position_types:create",
|
||||||
|
update: "edr_freight_app:position_types:update",
|
||||||
|
delete: "edr_freight_app:position_types:delete",
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
|
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
|
||||||
|
|||||||
@@ -60,3 +60,5 @@ createRoot(rootElement).render(
|
|||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// run
|
||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
} from "@/components/trainScheduling/containerPlacement.util";
|
} from "@/components/trainScheduling/containerPlacement.util";
|
||||||
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
||||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||||
|
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||||
@@ -1130,6 +1131,12 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
void detailQuery.refetch();
|
void detailQuery.refetch();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{scheduleId ? (
|
||||||
|
<IntercityRideAlongPanel
|
||||||
|
scheduleId={scheduleId}
|
||||||
|
direction={schedule.direction}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,12 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
||||||
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
|
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
|
||||||
|
|
||||||
const activeRoutes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
|
// Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
|
||||||
|
// API rejects them, so keep them out of the picker entirely.
|
||||||
|
const activeRoutes = useMemo(
|
||||||
|
() => (routesQuery.data ?? []).filter((r) => r.direction !== "DOMESTIC"),
|
||||||
|
[routesQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
|
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
|
||||||
|
|
||||||
@@ -380,7 +385,11 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const created = await create.mutateAsync({
|
const created = await create.mutateAsync({
|
||||||
payload: { routeId, scheduleDate, locomotiveIds },
|
payload: {
|
||||||
|
routeId,
|
||||||
|
scheduleDate: new Date(scheduleDate).toISOString(),
|
||||||
|
locomotiveIds,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
toast({ title: "Train schedule created" });
|
toast({ title: "Train schedule created" });
|
||||||
showScheduleWarnings(created.warnings);
|
showScheduleWarnings(created.warnings);
|
||||||
@@ -570,11 +579,8 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Departure date"
|
label="Departure date"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={scheduleDate ? scheduleDate.slice(0, 16) : ""}
|
value={scheduleDate}
|
||||||
onChange={(e) => {
|
onChange={(e) => setScheduleDate(e.currentTarget.value)}
|
||||||
const raw = e.currentTarget.value;
|
|
||||||
setScheduleDate(raw ? new Date(raw).toISOString() : "");
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label="Locomotives"
|
label="Locomotives"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Badge, Button, Card, Group, Tabs, Text } from '@mantine/core';
|
import { Badge, Button, Card, Group, Tabs, Text } from '@mantine/core';
|
||||||
import { CreditCard, Eye, Truck } from 'lucide-react';
|
import { CreditCard, Eye, Truck, TrainFront } from 'lucide-react';
|
||||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
VisualEmptyState,
|
VisualEmptyState,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
} from '@/components/warehouses';
|
} from '@/components/warehouses';
|
||||||
|
import { LoadToTrainPanel } from '@/components/warehouses/LoadToTrainPanel';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
@@ -116,6 +117,9 @@ export default function LoadingQueuePage() {
|
|||||||
>
|
>
|
||||||
Dispatch Queue
|
Dispatch Queue
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="load-train" leftSection={<TrainFront size={14} />}>
|
||||||
|
Load to Train
|
||||||
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
{/* Ready to Load — PAID bookings, can be marked Loaded */}
|
{/* Ready to Load — PAID bookings, can be marked Loaded */}
|
||||||
@@ -169,6 +173,11 @@ export default function LoadingQueuePage() {
|
|||||||
<InventoryWorkbench items={loadedItems} isLoading={loadedLoading} />
|
<InventoryWorkbench items={loadedItems} isLoading={loadedLoading} />
|
||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{/* Load to Train — per-train arrived containers/cargoes, multiselect → load onto wagons */}
|
||||||
|
<Tabs.Panel value="load-train" pt="md">
|
||||||
|
<LoadToTrainPanel />
|
||||||
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Card>
|
</Card>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
@@ -32,7 +32,21 @@ import {
|
|||||||
useFeeRules,
|
useFeeRules,
|
||||||
} from '@/hooks/useWarehouses';
|
} from '@/hooks/useWarehouses';
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
import {
|
||||||
|
FEE_RULE_BASES,
|
||||||
|
FEE_RULE_BASIS_LABELS,
|
||||||
|
FEE_RULE_TYPES,
|
||||||
|
FEE_RULE_TYPE_LABELS,
|
||||||
|
type FeeRuleBasis,
|
||||||
|
type FeeRuleType,
|
||||||
|
} from '@/types/warehouse';
|
||||||
|
|
||||||
|
const RULE_TYPE_COLOR: Record<FeeRuleType, string> = {
|
||||||
|
STORAGE_FEE: 'teal',
|
||||||
|
DEMURRAGE_FEE: 'orange',
|
||||||
|
DOUBLE_HANDLING_FEE: 'grape',
|
||||||
|
TRUCK_DETENTION_FEE: 'blue',
|
||||||
|
};
|
||||||
import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options';
|
import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options';
|
||||||
|
|
||||||
const FREIGHT = [
|
const FREIGHT = [
|
||||||
@@ -353,6 +367,7 @@ function FeeRules() {
|
|||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
|
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
|
||||||
|
basis: 'PER_CONTAINER' as FeeRuleBasis,
|
||||||
freightType: '',
|
freightType: '',
|
||||||
tradeDirection: '',
|
tradeDirection: '',
|
||||||
cargoTypeCode: '',
|
cargoTypeCode: '',
|
||||||
@@ -367,11 +382,15 @@ function FeeRules() {
|
|||||||
const containerTypeOptions = codeOptions(containerTypes);
|
const containerTypeOptions = codeOptions(containerTypes);
|
||||||
const isBulkRule = form.freightType === 'BULK';
|
const isBulkRule = form.freightType === 'BULK';
|
||||||
const isContainerRule = form.freightType === 'CONTAINER';
|
const isContainerRule = form.freightType === 'CONTAINER';
|
||||||
|
// Double handling is a flat per-unit charge (basis × rate), not day-based:
|
||||||
|
// no free days, no progressive tiers.
|
||||||
|
const isDoubleHandling = form.ruleType === 'DOUBLE_HANDLING_FEE';
|
||||||
|
|
||||||
const resetForm = () =>
|
const resetForm = () =>
|
||||||
setForm({
|
setForm({
|
||||||
name: '',
|
name: '',
|
||||||
ruleType: 'DEMURRAGE_FEE',
|
ruleType: 'DEMURRAGE_FEE',
|
||||||
|
basis: 'PER_CONTAINER',
|
||||||
freightType: '',
|
freightType: '',
|
||||||
tradeDirection: '',
|
tradeDirection: '',
|
||||||
cargoTypeCode: '',
|
cargoTypeCode: '',
|
||||||
@@ -440,10 +459,12 @@ function FeeRules() {
|
|||||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||||
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
||||||
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
|
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
|
||||||
freeDays: form.freeDays,
|
// Double handling: flat basis × rate — no free days, no tiers.
|
||||||
|
freeDays: isDoubleHandling ? 0 : form.freeDays,
|
||||||
ratePerDay: form.ratePerDay,
|
ratePerDay: form.ratePerDay,
|
||||||
currency: form.currency || 'USD',
|
currency: form.currency || 'USD',
|
||||||
...(tiers.length ? { tiers } : {}),
|
...(isDoubleHandling ? { basis: form.basis } : {}),
|
||||||
|
...(!isDoubleHandling && tiers.length ? { tiers } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -514,8 +535,8 @@ function FeeRules() {
|
|||||||
{rules.map((rule) => (
|
{rules.map((rule) => (
|
||||||
<Table.Tr key={rule.id}>
|
<Table.Tr key={rule.id}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color={rule.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">
|
<Badge color={RULE_TYPE_COLOR[rule.ruleType] ?? 'gray'} variant="light">
|
||||||
{rule.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}
|
{FEE_RULE_TYPE_LABELS[rule.ruleType] ?? rule.ruleType}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{rule.name}</Table.Td>
|
<Table.Td>{rule.name}</Table.Td>
|
||||||
@@ -577,7 +598,7 @@ function FeeRules() {
|
|||||||
label="Rule type"
|
label="Rule type"
|
||||||
data={FEE_RULE_TYPES.map((type) => ({
|
data={FEE_RULE_TYPES.map((type) => ({
|
||||||
value: type,
|
value: type,
|
||||||
label: type === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage',
|
label: FEE_RULE_TYPE_LABELS[type],
|
||||||
}))}
|
}))}
|
||||||
value={form.ruleType}
|
value={form.ruleType}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
@@ -637,14 +658,26 @@ function FeeRules() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Group grow>
|
<Group grow>
|
||||||
|
{isDoubleHandling ? (
|
||||||
|
<Select
|
||||||
|
label="Basis"
|
||||||
|
data={FEE_RULE_BASES.map((b) => ({ value: b, label: FEE_RULE_BASIS_LABELS[b] }))}
|
||||||
|
value={form.basis}
|
||||||
|
onChange={(value) =>
|
||||||
|
setForm((f) => ({ ...f, basis: selectValue(value, 'PER_CONTAINER') as FeeRuleBasis }))
|
||||||
|
}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<NumberInput
|
||||||
|
label="Free days"
|
||||||
|
min={0}
|
||||||
|
value={form.freeDays}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Free days"
|
label={isDoubleHandling ? 'Rate / unit' : 'Rate / day'}
|
||||||
min={0}
|
|
||||||
value={form.freeDays}
|
|
||||||
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
|
|
||||||
/>
|
|
||||||
<NumberInput
|
|
||||||
label="Rate / day"
|
|
||||||
min={0}
|
min={0}
|
||||||
value={form.ratePerDay}
|
value={form.ratePerDay}
|
||||||
onChange={(value) => setForm((f) => ({ ...f, ratePerDay: numberValue(value) }))}
|
onChange={(value) => setForm((f) => ({ ...f, ratePerDay: numberValue(value) }))}
|
||||||
@@ -657,6 +690,13 @@ function FeeRules() {
|
|||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
{isDoubleHandling && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Flat charge — the rate is multiplied by the selected basis (
|
||||||
|
{FEE_RULE_BASIS_LABELS[form.basis]}). No free days or progressive tiers.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!isDoubleHandling && (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
@@ -702,6 +742,7 @@ function FeeRules() {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
)}
|
||||||
<Group justify="flex-end" mt="sm">
|
<Group justify="flex-end" mt="sm">
|
||||||
<Button variant="default" onClick={() => setOpen(false)}>
|
<Button variant="default" onClick={() => setOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -593,6 +593,52 @@ export const api = {
|
|||||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
intercityCandidates: endpoint<
|
||||||
|
{ scheduleId: string },
|
||||||
|
import("@/types/trainScheduling").IntercityCandidatesResult
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"intercity-candidates",
|
||||||
|
({ scheduleId }) => trainSchedulingService.getIntercityCandidates(scheduleId),
|
||||||
|
({ scheduleId }) => ["train-scheduling", "intercity-candidates", scheduleId],
|
||||||
|
),
|
||||||
|
|
||||||
|
acceptIntercityBookings: endpoint<
|
||||||
|
{ scheduleId: string; bookingIds: string[] },
|
||||||
|
import("@/types/trainScheduling").IntercityAcceptResult
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"intercity-accept",
|
||||||
|
({ scheduleId, bookingIds }) =>
|
||||||
|
trainSchedulingService.acceptIntercityBookings(scheduleId, bookingIds),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
|
loadIntercityBooking: endpoint<
|
||||||
|
{ scheduleId: string; bookingId: string },
|
||||||
|
void
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"intercity-load",
|
||||||
|
({ scheduleId, bookingId }) =>
|
||||||
|
trainSchedulingService.loadIntercityBooking(scheduleId, bookingId),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
|
unloadIntercityBooking: endpoint<
|
||||||
|
{ scheduleId: string; bookingId: string },
|
||||||
|
void
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"intercity-unload",
|
||||||
|
({ scheduleId, bookingId }) =>
|
||||||
|
trainSchedulingService.unloadIntercityBooking(scheduleId, bookingId),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
cancelSchedule: endpoint<
|
cancelSchedule: endpoint<
|
||||||
{ id: string; freightType?: FreightType },
|
{ id: string; freightType?: FreightType },
|
||||||
TrainScheduleDetail
|
TrainScheduleDetail
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { URL_CONSTANTS } from '@/constants/URLS';
|
|||||||
|
|
||||||
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
|
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
|
||||||
|
|
||||||
|
/** Frozen from yard countries: ET→DJ EXPORT, DJ→ET IMPORT, same country DOMESTIC (intercity, disabled). */
|
||||||
|
export type RouteDirection = 'IMPORT' | 'EXPORT' | 'DOMESTIC';
|
||||||
|
|
||||||
export interface YardRef {
|
export interface YardRef {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -23,6 +26,7 @@ export interface RouteMilestone {
|
|||||||
export interface RouteRecord {
|
export interface RouteRecord {
|
||||||
id: string;
|
id: string;
|
||||||
status: RouteStatus;
|
status: RouteStatus;
|
||||||
|
direction?: RouteDirection;
|
||||||
originYardId: string;
|
originYardId: string;
|
||||||
destinationYardId: string;
|
destinationYardId: string;
|
||||||
originYard?: YardRef | null;
|
originYard?: YardRef | null;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user