# EDR Freight System — End-to-End Flow Map (FE → Backend) > Comprehensive flow documentation for the **Freight Management** slice of the EDR Platform: > the two frontends (**Customer Portal** + **Backoffice**) and how every action reaches the > **freight API**, the **payment microservice**, Postgres, MinIO and RabbitMQ. > > Generated by mapping all ~45 API modules (~300 routes / 60 controllers) against both FE apps. > Layered on purpose: a **master business-flow** view first, then **per-domain endpoint-level** charts. --- ## 0. How to read this document - **Master view** (§3) is the freight journey at business altitude — state transitions across domains. - **Per-domain views** (§5–§13) drop to endpoint altitude — every `Method /path`, its guard, and the FE caller. - Diagrams are [Mermaid](https://mermaid.js.org). GitHub / VS Code (Markdown Preview Mermaid) render them inline. - **Legend** used throughout: - (P) **Portal** = `@edr/freight-portal` (customer users, port `5173`) - (B) **Backoffice** = `@edr/freight-backoffice` (EDR employees, port `5183`) - (green) **API** = `@edr/freight-api` (NestJS, port `3001`) - (red) **Payment** = `@edr/payment-api` (NestJS microservice, port `3003`) --- ## 1. System architecture & apps | Layer | Package | Port | Base URL / notes | | ----- | ------- | ---- | ---------------- | | Customer Portal | `@edr/freight-portal` | 5173 | axios `utils/api.ts`, baseURL `VITE_BASE_API_URL`, React Query | | Backoffice | `@edr/freight-backoffice` | 5183 | axios `auth/http.ts`, baseURL `${VITE_BASE_API_URL}/api`, React Query | | Freight API | `@edr/freight-api` | 3001 | NestJS, global prefix `/api`, Postgres schema `freight` | | Payment API | `@edr/payment-api` | 3003 | NestJS, separate schema `edr_payment`, providers in `@edr/payment-providers` | | Datastores | — | 5433 | Postgres `edr_freight`; MinIO (files); RabbitMQ (SMS/email/payment events) | ```mermaid flowchart LR subgraph FE["Frontends (React + Vite + React Query)"] P["(P) Customer Portal :5173"] B["(B) Backoffice :5183"] end subgraph EDGE["Freight API edge (NestJS :3001)"] direction TB CORS["CORS (reflect origin,
credentials, IAM headers)"] JWT["JwtGuard (global APP_GUARD)
+ HasActiveDelegationGuard"] PERM["FreightPermissionGuard
(per-route perms)"] VP["ValidationPipe
(implicitConversion OFF)"] RTI["ResponseTransformInterceptor
→ { success, data }"] HEF["HttpExceptionFilter"] end subgraph DOM["Domain modules (~45)"] direction TB D1["Identity / Companies / Auth"] D2["Bookings + Contracts"] D3["Rule Engine"] D4["Train Scheduling"] D5["Warehouse"] D6["Field Ops (mile/import)"] D7["Billing"] D8["Notifications / Inbox"] end subgraph INFRA["Backing services"] PG[("Postgres
schema: freight")] MINIO[("MinIO
object store")] MQ{{"RabbitMQ"}} PAY["(red) Payment API :3003
schema: edr_payment"] end P -->|"Bearer token (cookie)
axios interceptor"| CORS B -->|"Bearer token (cookie)
axios interceptor"| CORS CORS --> JWT --> PERM --> VP --> DOM DOM --> RTI DOM --> PG DOM --> MINIO DOM -->|"send-sms / send-email"| MQ DOM -->|"POST /payments/initiate
x-service-token"| PAY PAY -->|"payment.succeeded webhook
→ /api/internal/payments/mark-paid"| DOM PAY -.->|"or RabbitMQ payment events"| MQ --> DOM D8 -->|"socket.io push"| FE ``` --- ## 2. The request pipeline (every FE call) Both frontends wrap each API method in an `endpoint(service, action, fn)` helper feeding React Query. The axios client attaches the JWT and transparently refreshes on `401`. ```mermaid sequenceDiagram autonumber participant C as React component / hook participant Q as React Query participant AX as axios client (interceptors) participant API as Freight API (:3001) participant DB as Postgres C->>Q: useQuery / useMutation(endpoint) Q->>AX: call(input) AX->>AX: request interceptor →
Authorization: Bearer [auth-token cookie] AX->>API: HTTP /api// API->>API: CORS → JwtGuard → PermissionGuard → ValidationPipe alt token valid & permitted API->>DB: repository query (schema freight) DB-->>API: rows API->>API: ResponseTransformInterceptor → { success:true, data } API-->>AX: 200 { success, data } AX->>AX: backoffice interceptor unwraps .data
(portal returns raw envelope; callers read .data) AX-->>Q: payload Q-->>C: data (+ cache by queryKey) else 401 Unauthorized API-->>AX: 401 AX->>API: POST /api/auth/refresh-token { refreshToken cookie } alt refresh ok API-->>AX: { token, refreshToken } AX->>AX: set cookies, retry original request (_retry) AX-->>Q: payload else refresh fails AX->>AX: clear cookies →
portal: reject · backoffice: redirect /auth end else 4xx/5xx API->>API: HttpExceptionFilter → { success:false, message } API-->>AX: error AX-->>Q: throw → onError toast end ``` **Auth model (important):** `SharedAuthModule` (`@tria-plc/api-common`) registers `JwtGuard` + `HasActiveDelegationGuard` as **global `APP_GUARD`s** — *every* route is JWT-protected unless it carries `@Public()`. Fine-grained `FreightPermissionGuard([perm])` decorators add permission checks on staff routes. Explicitly **public** endpoints: `GET /api/files/:fileId`, `POST /api/otp/{send,verify}`, `GET /api/auth/check-availability`, the `fayda/verification/*` + `/fayda/callback` endpoints, `GET /api/payments/{checkout,receipt/:orderId}`, and the service-to-service `POST /api/internal/payments/mark-paid`. Real login / JWT issuance lives in the **external IAM package**, not this repo. (Note: `@edr/api-common`'s `@Public` and `@tria-plc/api-common`'s `@IsPublic` both set the same `"isPublic"` metadata key the guard reads.) --- ## 3. MASTER FLOW — the freight journey (business altitude) This is the spine. A shipment travels **customer intake → pricing → approval → contract → customs clearance → operation request → payment → scheduling/allocation → first-mile → warehouse → train → arrival → warehouse → last-mile → delivery → tracking**, branching on _container vs bulk_, _import vs export_, _commercial vs government_, and _one-time vs contract_. ```mermaid flowchart TD start(["Customer signs up
(IAM + Fayda + company onboarding)"]) --> mode{"Booking origin?"} mode -->|"One-time shipment"| draft["Create BOOKING (DRAFT) (P)"] mode -->|"Framework agreement"| cdraft["Create CONTRACT (DRAFT) (P)"] %% Contract branch cdraft --> cprice["Generate price → Submit → Approvals → Sign"] cprice --> cactive{"Contract type / customs?"} cactive -->|"Path A: transport-only,
self-clearance"| bookA["Customer books directly
POST /contracts/:id/bookings (P)"] cactive -->|"Path B: GENERAL + customs"| breq["Customer submits BookingRequest (P)
→ GL accepts → GL creates booking (B)"] bookA --> draft2["Booking created under contract"] breq --> draft2 %% Booking spine draft --> price["Generate price (P)
(rule-engine: rates + surcharges)"] draft2 --> price price --> submit["Submit → SUBMITTED (P)"] submit --> intake["Staff accept → PENDING_APPROVAL (B)
(instantiate approval steps)"] intake --> appr["Approval chain:
LINE_STAFF → DIRECTOR → CEO (B)"] appr --> gen["Generate contract → CONTRACT_READY (B)"] gen --> sign["Customer signs (P) → Staff counter-signs (B)"] sign --> customs{"customsClearingEnabled?"} customs -->|"No"| ready["FULLY_EXECUTED"] customs -->|"Yes"| clr["AWAITING_DOCUMENTS →
customer uploads docs (P) →
GL review/finalize (B) → CLEARANCE_READY"] clr --> opreq["Customer requests operation (P)
(binding schedule date)"] ready --> opreq opreq --> oprev{"Operations review (B)"} oprev -->|"Request changes"| clr oprev -->|"Accept: train"| inv["Invoice generated →
enters day batch pool"] oprev -->|"Accept: road/truck"| road["ROAD_DISPATCH_PENDING
(billed by KM)"] inv --> pay["Customer pays invoice (P)
→ (red) gateway → PAID"] road --> pay pay --> gov{"Government?"} gov -->|"Yes"| expedite["governmentExpedite → PAID/Eligible (B)"] gov -->|"No"| eligible["schedulingStatus = Eligible"] expedite --> sched eligible --> sched subgraph JOURNEY["Physical movement"] direction TB sched["Train schedule: DRAFT → assign bookings →
allocate wagons → finalize → SCHEDULED (B)"] fm{"EXPORT + first-mile?"} fmleg["First-mile leg: truck pickup →
RECEIVED_TO_PORT (B)"] wh_in["Warehouse receive → inspect →
STORED → READY_FOR_LOADING → LOADED (B)"] disp["Dispatch train → DISPATCHED
(locos ASSIGNED, unpaid EXPIRED) (B)"] track["Checkpoints logged →
tracking events (customer sees) (P)"] arrive["Arrive → ARRIVED
(bookings IN_TRANSIT, wagons freed) (B)"] wh_out["Destination warehouse:
unload → inspect → READY_FOR_PICKUP (B)"] lm{"IMPORT + last-mile?"} lmleg["Last-mile leg: truck delivery →
DELIVERED (B)"] imp["Import customs finalization
(declaration/duty/risk/release) (B)"] end sched --> fm fm -->|"Yes"| fmleg --> wh_in fm -->|"No"| wh_in wh_in --> disp --> track --> arrive --> wh_out wh_out --> imp imp --> lm lm -->|"Yes"| lmleg --> deliver lm -->|"No"| deliver deliver["Release order + gate clearance
(warehouse fees must be PAID) (B)"] deliver --> pod["Customer approves delivery /
POD captured → DELIVERED (P)"] pod --> complete(["Booking COMPLETED"]) track -.->|"public timeline"| custview["Customer tracking page (P)"] ``` **Cross-cutting truth:** almost every hop between domains is fired by a **domain event** (`@OnEvent`), not a direct call. See §13 for the event web (e.g. `booking.invoice.paid` → advance booking → auto-create first-mile; `warehouse inspection PASSED` → auto-create last-mile; payment webhook → settle invoice). --- ## 4. Domain map (where each module lives in the journey) ```mermaid flowchart LR subgraph INTAKE["Intake & Identity"] auth[auth / otp / verifayda] comp[companies] sig[signatures] end subgraph COMMERCIAL["Commercial"] bk[bookings] ct[contracts] re[rule-engine] end subgraph OPS["Rail Operations"] ts[train-scheduling] resch[scheduling-reschedule] fleet[trains/wagons/locomotives/wagon-types] track[tracking] end subgraph GROUND["Ground Operations"] fmlm[first-mile / last-mile] imp[import-operations / interchange-documents] dv[drivers / vehicles] end subgraph WH["Warehouse"] wh[warehouses + inventory + loadings + inspection + rules] whinv[warehouse-fee-invoices] end subgraph MONEY["Money"] bill[billing] paymod[payment] payapi[edr-payment-api] end subgraph PLATFORM["Platform / Config"] dd[dropdown-settings] fus[file-upload-settings] files[files / minio] fac[facilities / routes] notif[notifications / inbox] ov[overview] bo[backoffice/IAM] end INTAKE --> COMMERCIAL --> OPS --> GROUND --> WH --> MONEY re -.->|rates/approval/priority| COMMERCIAL COMMERCIAL -.->|invoices| MONEY WH -.->|storage invoices| MONEY GROUND -.->|mile invoices| MONEY PLATFORM -.-> COMMERCIAL ``` --- ## 5. Identity, Access & Onboarding ### 5.1 Flow ```mermaid flowchart TD su["Signup (external IAM)"] --> chk["GET /auth/check-availability (P)
(email/phone taken?) @Public"] chk --> otp["POST /otp/send + /otp/verify (P) @Public"] otp --> fayda{"Identity proofing?"} fayda -->|"VeriFayda 2.0"| fstart["POST /fayda/verification/start
→ eSignet authorize URL"] fstart --> fcb["Fayda redirect → GET /fayda/callback (ack)
→ GET /fayda/verification/complete
(PKCE code exchange → upsert iam.users)"] fcb --> onb fayda -->|"skip"| onb onb["POST /companies/onboarding/start (P)
(draft company, placeholder TIN, PENDING)"] onb --> wiz["Wizard saves incrementally (P):
PATCH /profile · /onboarding-step ·
upload license & docs"] wiz --> reqs["GET /companies/onboarding/requirements
(server-driven checklist)"] reqs --> comp["POST /companies/onboarding/complete
→ profiles + company = PENDING"] comp --> review["Backoffice approves (B):
PATCH /companies/company-profiles/:id/status
→ mint reference, company → ACTIVE"] review --> book(["Can now book
(assertCompanyProfileApprovedForBooking)"]) ``` Company status: `PENDING → ACTIVE` (+ `SUSPENDED`, `BLACKLISTED`). Nationality (`ethiopian`/`foreign`) drives the required document set. Booking guards elsewhere `403` if the acting profile is not `ACTIVE`. ### 5.2 Endpoints | Method | Path | Action | Guard | FE | | --- | --- | --- | --- | --- | | GET | `/api/auth/check-availability` | email/phone dedupe | `@Public` | (P) auth.service | | GET | `/api/me` | enriched profile + `permissionKeys` + catalog | JwtGuard | (B) auth/api | | POST | `/api/otp/send` · `/api/otp/verify` | send / verify 6-digit code | `@Public` | (P) auth.service | | POST | `/api/fayda/verification/start` | start eSignet session (PKCE) | `@Public` + OptionalJwt | (B) verifayda.service | | GET | `/api/fayda/verification/complete` | code→identity, upsert `iam.users` | `@Public` | (B) verifayda.service | | GET | `/api/fayda/verification/status` | current user's Fayda link | JwtGuard | — | | GET | `/fayda/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage | | GET·PUT | `/api/me/signature` | reusable signature (MinIO, base64) | JwtGuard | (P)(B) signatures.service | | GET | `/api/test_user1` · `/api/test_user2` | permission-guard demo | `PermissionGuard` | (B) demo pages | | GET | `/api/companies/getInfo` · `/profile` · `/dashboard` | company info / KPIs | JwtGuard | (P) companies.service | | POST | `/api/companies/fetch-etrade-info` | pull reg data by TIN | JwtGuard | (P) | | PATCH | `/api/companies/profile` | update profile (JSONB attrs) | JwtGuard | (P) | | POST | `/api/companies/onboarding/start` · `/complete` | onboarding lifecycle | JwtGuard | (P) | | PATCH | `/api/companies/onboarding-step` · `/active-mode` | wizard state / mode switch | JwtGuard | (P) | | GET | `/api/companies/onboarding/requirements` | server checklist | JwtGuard | (P) | | POST·GET | `/api/companies/company-profiles/:id/license` | license file up/list | JwtGuard | (P) | | POST | `/api/companies/company-profiles` · `/company-profile` | add operational profile(s) | JwtGuard | (P) | | POST·GET·PATCH·DELETE | `/api/companies` (+`/:id`) | company CRUD | `@FreightAdmin` writes | (B) customers.service | | GET | `/api/companies/stats` | KPI strip | JwtGuard | (B) | | PATCH | `/api/companies/company-profiles/:id/status` | approve profile → mint ref | `@FreightAdmin` | (B) | | GET·POST | `/api/companies/:companyId/documents` | company docs (signed URLs) | JwtGuard | (P)(B) | | GET | `/api/notifications` (+`/unread-count`) | inbox list / unread | JwtGuard | (P)(B) notificationsApi | | PATCH·POST | `/api/notifications/:id/read` · `/read-all` | mark read (WS re-emit) | JwtGuard | (P)(B) | | WS | `NOTIFICATION_WS_NAMESPACE` | live push (server→client) | WsAuth handshake | (P)(B) useNotificationSocket | `notifications` module = SMS/email transport over RabbitMQ (no HTTP routes). Inbox fan-out writes one `notifications` row + WS push per recipient; **HIGH** priority also emails + SMSs (best-effort). --- ## 6. Bookings — the core state machine ```mermaid stateDiagram-v2 [*] --> DRAFT: create (P) (staff commercial auto price+submit) DRAFT --> SUBMITTED: submit (P) DRAFT --> PRICE_CHANGED_PENDING_CONFIRM: price moved PRICE_CHANGED_PENDING_CONFIRM --> SUBMITTED: confirm-submit (P) SUBMITTED --> REJECTED: customer reject (price) (P) SUBMITTED --> CHANGES_REQUESTED: staff request-changes (B) CHANGES_REQUESTED --> SUBMITTED: edit + resubmit (P) SUBMITTED --> PENDING_APPROVAL: staff/accept (B) (instantiate approval steps) SUBMITTED --> REJECTED: staff/reject (B) PENDING_APPROVAL --> APPROVED_PENDING_SIGNATURE: line-staff approve (B) APPROVED_PENDING_SIGNATURE --> APPROVED: director + ceo approve (B) PENDING_APPROVAL --> REJECTED: rejectStep (B) APPROVED --> CONTRACT_READY: contract/generate (B) CONTRACT_READY --> SIGNED_CUSTOMER: customer sign (P) SIGNED_CUSTOMER --> AWAITING_DOCUMENTS: counter-sign, IMPORT/EXPORT (B) SIGNED_CUSTOMER --> FULLY_EXECUTED: counter-sign, DOMESTIC (B) AWAITING_DOCUMENTS --> DOCUMENTS_UNDER_REVIEW: clearance/documents (P) DOCUMENTS_UNDER_REVIEW --> CLEARANCE_READY: review + finalize (B) CLEARANCE_READY --> OPERATION_REQUEST_PENDING: clearance/proceed (P) OPERATION_REQUEST_PENDING --> OPERATION_CHANGES_REQUESTED: review=REQUEST_CHANGES (B) OPERATION_CHANGES_REQUESTED --> OPERATION_REQUEST_PENDING: re-proceed (P) OPERATION_REQUEST_PENDING --> ROAD_DISPATCH_PENDING: accept=road (B) (invoice, KM billed) OPERATION_REQUEST_PENDING --> FULLY_EXECUTED: accept=train (B) (invoice) FULLY_EXECUTED --> SELECTED_FOR_BATCH: batch engine offers wagons (sys) SELECTED_FOR_BATCH --> PAID: pay invoice (P) ROAD_DISPATCH_PENDING --> PAID: pay invoice (P) PAID --> IN_TRANSIT: operations/start-transit (B) IN_TRANSIT --> COMPLETED: operations/complete (B) DRAFT --> PENDING_CONSOLIDATION: requestConsolidation (usually set on submit) DRAFT --> CANCELLED: cancel (B) PENDING_APPROVAL --> CANCELLED: cancel (B) OPERATION_REQUEST_PENDING --> CANCELLED: cancel (B) note right of PAID governmentExpedite (B) jumps gov bookings straight to PAID/Eligible end note note left of CANCELLED cancel is staff-only and allowed ONLY from DRAFT, SUBMITTED, PRICE_CHANGED_PENDING_CONFIRM, CHANGES_REQUESTED, PENDING_APPROVAL, CONTRACT_READY, OPERATION_REQUEST_PENDING end note REJECTED --> [*] CANCELLED --> [*] COMPLETED --> [*] ``` > **Accuracy notes (verified against code):** > - Counter-sign branch is keyed on **trade direction**, not a customs flag: `IMPORT`/`EXPORT` → > `AWAITING_DOCUMENTS` (even when customs is off — a lighter "without customs" clearance doc-set still > applies); only `DOMESTIC` → `FULLY_EXECUTED`. > - **Domestic** bookings skip the operation-request/clearance phase entirely — counter-sign enqueues > them straight into the scheduling batch pipeline (`enqueueScheduleProcessing`). > - Train `operation/review` accept sets **`FULLY_EXECUTED`** (day batch holding pool). `SELECTED_FOR_BATCH` > is set **later** by the batch engine when a wagon offer/reservation is made — not at accept. > - `APPROVED_PENDING_SIGNATURE` is a real intermediate (line-staff approves first, then director+CEO). > - Full `BOOKING_STATUSES` has 35 values; this diagram is the live commercial subset (legacy statuses > like `WAGON_ASSIGNED`, `INVOICED`, `PNR_GENERATED` are unused). Key endpoints (customer (P) / staff (B), `bk:` = `bookings:` perms): | Method | Path | Action | Guard | | --- | --- | --- | --- | | POST | `/api/bookings` | create | in-body (gov→`bk:staff_accept`) | | PATCH·DELETE | `/api/bookings/:id` | update / soft-delete DRAFT | company-scoped | | POST | `/api/bookings/:id/generate-price` | price preview (rule-engine) | company-scoped | | POST | `/api/bookings/:id/submit` · `/confirm-submit` | submit (rate snapshot) | company-scoped | | POST | `/api/bookings/:id/reject` | customer rejects price | company-scoped | | POST | `/api/bookings/:id/staff/accept` | → PENDING_APPROVAL | `bk:staff_accept` | | POST | `/api/bookings/:id/staff/request-changes` · `/staff/reject` | intake outcomes | `bk:request_changes` / `bk:reject` | | POST | `/api/bookings/:id/approval-steps/:stepId/approve` · `/reject` | approval chain | role perms | | POST | `/api/bookings/:id/contract/generate` | → CONTRACT_READY | `bk:generate_contract` | | POST | `/api/bookings/:id/contract/sign` · `/marketing/approve` | sign / counter-sign | in-body / `bk:sign_staff` | | GET | `/api/bookings/:id/contract/{view,document}` | HTML / PDF | company-scoped | | GET·POST | `/api/bookings/:id/clearance` (+`/documents`,`/review`,`/finalize`,…) | customs clearance | `bk:*` / `ct:clearance_*` | | POST | `/api/bookings/:id/clearance/proceed` | request operation | company-scoped | | POST | `/api/bookings/:id/operation/review` | accept/changes (+invoice) | `bk:operations` | | POST | `/api/bookings/:id/government-expedite` | gov shortcut → PAID | `bk:staff_accept` | | POST | `/api/bookings/:id/operations/start-transit` · `/complete` | transit lifecycle | `bk:operations` | | POST | `/api/bookings/:id/allocate-containers` | assign containers↔vehicles | `allocation:manage` | | POST·GET·DELETE | `/api/bookings/:id/consolidation` | pair/unpair wagon-share | company-scoped | | POST | `/api/bookings/:id/customer-truck-assignment` | external truck for pickup | company-scoped | | GET | `/api/bookings/:id/tracking` | consignment + tracking events | company-scoped | | GET | `/api/bookings` · `/list-summary` · `/queues/:queue` | lists (branch by perm) | `bk:view` / `bk:clearance_view` | --- ## 7. Contracts — framework agreements & booking paths ```mermaid stateDiagram-v2 [*] --> DRAFT: create (P) DRAFT --> SUBMITTED: submit (P) (freeze rate snapshots) SUBMITTED --> PENDING_APPROVAL: staff/accept (B) SUBMITTED --> CHANGES_REQUESTED: request-changes (B) CHANGES_REQUESTED --> SUBMITTED: resubmit (P) SUBMITTED --> REJECTED: reject (B) PENDING_APPROVAL --> APPROVED: approve chain (B) APPROVED --> CONTRACT_READY: contract/generate (B) CONTRACT_READY --> SIGNED_CUSTOMER: customer sign (P) SIGNED_CUSTOMER --> AWAITING_CLEARANCE_DOCUMENTS: counter-sign, IMPORT/EXPORT (not GENERAL+customs) (B) SIGNED_CUSTOMER --> CONTRACT_ACTIVE: counter-sign, GENERAL+customs or DOMESTIC (B) SIGNED_CUSTOMER --> FULLY_EXECUTED: counter-sign, ONE_TIME DOMESTIC (B) CONTRACT_ACTIVE --> [*]: renew → RENEWAL_DRAFT ``` > Diagram shows the live path; `CONTRACT_STATUSES` has **24 values** total (adds APPROVED_PENDING_SIGNATURE, > CLEARANCE_UNDER_REVIEW, CLEARANCE_READY_FOR_BOOKING, ACTIVE_SHIPMENT_IN_PROGRESS, CONTRACT_CLOSED, CANCELLED, > RENEWAL_SUBMITTED/PENDING_APPROVAL, AMENDMENTS_PROPOSED, ARCHIVED — see §15). **GENERAL+customs skips the > contract clearance cycle → `CONTRACT_ACTIVE` directly** (clearance runs per-booking, Path B); only IMPORT/EXPORT > one-time or self-clearance opens the contract-level `AWAITING_CLEARANCE_DOCUMENTS` cycle. **Two ways a contract spawns shipment bookings:** ```mermaid flowchart TD active["Contract ACTIVE / SELF_CLEARED"] --> path{"Contract path"} path -->|"Path A: transport-only"| a1["Ops reviews self-clearance docs (B)
(ops-review → ops-finalize → SELF_CLEARED)"] a1 --> a2["Customer books directly (P)
POST /contracts/:id/bookings"] path -->|"Path B: GENERAL + customs"| b1["Customer submits BookingRequest (P)
POST /contracts/:id/booking-requests"] b1 --> b2["GL queue → accept (B)
(ct:create_booking) → GL creates booking"] a2 --> cap["createUnderContract:
window + capacity draw-down check"] b2 --> cap cap --> spawn(["New Booking under contract
(bookings.contract_id)"]) ``` Contract clearance is **phased** (ET vs DJ permissioned): declaration → duty advice → duty slip → transit permit → delivery/release order → T1 docs/close → final invoice → incidents. `clearanceStatus` is a separate axis (`AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY_FOR_BOOKING / SELF_CLEARED / ACTIVE_SHIPMENT_IN_PROGRESS`). ~60 contract routes total. Per-booking clearance actions (`duty`, `risk`, `t1-documents`, `t1-close`, `final-invoice`, `second-duty`, `transport-document`, `station-assign`, `incidents`) hang off `/api/contracts/bookings/:bookingId/*`; note **`declaration` is a contract-level route** (`/api/contracts/:id/clearance/declaration`), not a booking-scoped one. ### Consignment / Cargo / Container (fleet-side records) | Entity | Controller | Guard | Lifecycle | Callers | | --- | --- | --- | --- | --- | | Consignment | `/api/consignments` (create/list/get) | `FleetView`/`Manage` | `Pending → …` (downstream shipment record; booking `:id/tracking` reads it) | (P) read-only | | Cargo | `/api/cargoes` (+ `/load` `/unload` `/deliver`) | `FleetView`/`Manage` | `PENDING → LOADED → UNLOADED / DELIVERED` (POD) | (B) cargoService | | Container | `/api/containers` (+ `/assign-wagon` `/unassign-wagon`) | `FleetView`/`Manage` | `AVAILABLE → LOADED → IN_TRANSIT …` | (B) containerService | --- ## 8. Rule Engine — the pricing & approval brain `RuleEngineService.evaluate(input)` is injected into bookings & contracts pricing. One pass pulls all rule tables and returns `{ priorityScore, appliedModifiers, containerWeightResults, warnings, hardBlocked, requiresDirectorApproval }`. ```mermaid flowchart TD ev["evaluate(BookingEvaluationInput)"] --> rates["rates.findLiveRates()
(only LIVE)"] rates --> sur{"surcharge triggers"} sur -->|"HAZARDOUS/REEFER/OVERWEIGHT/
SHIPPING_LINE/CONSOLIDATION"| mods["appliedModifiers →
surcharge line-items"] mods --> snap["snapshotRates → booking_rate_snapshots
(freeze exact rate used)"] ev --> wlr["weight-limit-rules"] wlr --> block{"VGM > maxCapacityTons?"} block -->|Yes| hard["HARD BLOCK (400)"] block -->|"over maxVgm, within cap"| warn["warning + OVERWEIGHT surcharge"] ev --> appr["approval-rules →
instantiateApprovalSteps
(requiredRole/blocksRole/stepOrder)"] ev --> prio["priority-configs + serviceType bonus +
GOVERNMENT_PRIORITY_BONUS → priorityScore"] prio --> schedorder["train-scheduling orders by priorityScore"] ``` **Rate lifecycle:** `DRAFT → (submit) PENDING_APPROVAL → (CEO approve) LIVE`. Only LIVE rates apply. | Resource | Base path | Guard | Notes | | --- | --- | --- | --- | | approval-rules | `/api/approval-rules` (+`/chain`,`/reorder`,`/:id/move-order`) | `ruleEngine.view/manage(approval-rules)` | ordered chain | | cargo-types | `/api/cargo-types` | `…(cargo-types)` | portal reads via `bookings/reference-data` | | container-types | `/api/container-types` | `…(container-types)` | portal indirect | | priority-configs | `/api/priority-configs` | `…(priority-configs)` | WAGON / CURRENCY bands | | rates | `/api/rates` (+`/live`,`/:id/submit`,`/approve`) | `…(rates)` | DRAFT→PENDING→LIVE | | service-types | `/api/service-types` | `…(service-types)` | `includesFirstMile/LastMile` flags | | shipping-lines | `/api/shipping-lines` | `…(shipping-lines)` | surcharge trigger | | weight-limit-rules | `/api/weight-limit-rules` | `…(weight-limit-rules)` | VGM hard-block | | yards | `/api/yards` | `…(yards)` | routes/warehouses read | **Settings & files** (reads open, writes `@FreightAdmin`): `/api/dropdown-settings/*`, `/api/file-upload-settings/*` (config that drives portal forms; server enforces *required-doc presence* at clearance, not size/MIME — those are client-side). `GET /api/files/:fileId` is `@Public` (browsers load `/` without a bearer); upload is **direct multipart** (multer memory → `FilesService` → MinIO `putObject`), presigned URLs used only for authenticated reads (300 s TTL). `routes` (`route_milestones`) and `facilities` are config readers with **no permission decorator** (behind the global JwtGuard only). NOTE: Note `FacilitiesModule` is never imported into `AppModule`, so `/api/facilities` is unmounted/dead. --- ## 9. Train Scheduling & Rail Operations Two consist models: **`trains`** = static fleet inventory (never created by scheduling); **`train_sets`** = the operational consist scheduling builds per departure (**≥2 locomotives** + wagon-type slots, physical wagons *pinned* later). ```mermaid stateDiagram-v2 [*] --> DRAFT: create schedule (B) (≥2 locos, derive direction, freeze window rule) DRAFT --> DRAFT: assign-bookings / run-allocation / pin-wagons (B) DRAFT --> SCHEDULED: finalize (B) (bookings → Scheduled, ≥1 booking) SCHEDULED --> DISPATCHED: dispatch (B) (train_number, locos ASSIGNED, window CLOSED, unpaid EXPIRED) DISPATCHED --> DISPATCHED: recordCheckpoint (B) (corridor stations) DISPATCHED --> ARRIVED: arrive (B) (bookings IN_TRANSIT, locos+wagons freed, warehouse arrival automation) DRAFT --> CANCELLED: cancel (B) SCHEDULED --> CANCELLED: cancel (B) ARRIVED --> [*] ``` **Reschedule** (`DRAFT`/`SCHEDULED` only): `preview` merges current + incoming bookings, sorts by `compareSchedulingPriority` (**government first, then priorityScore**), greedily keeps those that still fit → *retained*; overflow → *displaced*; commercial displaced are *readmitted* if room remains. `execute` re-verifies, optionally sets new departure, unassigns displaced, re-assigns final, writes a `scheduling_events` audit row. Triggers: `GOVERNMENT_PREEMPT`, `TRAIN_MAINTENANCE`. Endpoint groups (`train-scheduling` prefix, `trainScheduling.view/manage`): | Group | Representative routes | | --- | --- | | Discovery (customer (P), unguarded) | `bookable-schedules`, `available-days`, `available-days-for-cargo`, `my-booking-windows`, `contracts/:id/booking-windows` | | Board (staff (B)) | `batch-board`, `batch-board/:id`, `eligible-bookings`, `container|bulk/eligible-bookings`, `global-rules` | | Build | `container|bulk/preview`, `container|bulk/schedules` (create), `:id/assign-bookings`, `:id/assign-unassigned-booking`, `:id/pin-wagons`, `:id/run-allocation`, `:id/run-batch` | | Composition edits | `:id/wagons/:wagonId` (remove slot), `:id/container-items/:itemId`, `bookings/:bookingId/{mark-paid,expire,move-schedule}` | | Lifecycle | `schedules/:id/{finalize,dispatch,arrive,booking-window,doc-review-complete}`, `{container\|bulk}/schedules/:id/cancel` | | Tracking | `schedules/:id/checkpoints` (GET/POST) → writes `train_checkpoint_events` | | Djibouti import ops | `:id/import-djibouti/*` (gatepass, ready-for-loading, loaded, depart, load-list) | | Reschedule | `:id/reschedule/preview`, `:id/reschedule/execute`, `:id/maintenance` | **Fleet master data** (`FleetView/Manage`): `/api/trains` (+`/:trainId/reorder-wagons`), `/api/wagons` (+`/assign-train`,`/unassign-train`), `/api/locomotives` (+`/decommission`), `/api/wagon-types` (rule-engine guarded). **Asset records** (unguarded, keyed by *road* `vehicleId`): `/api/maintenance/*`, `/api/fuel/*`. **`fleet_events`** is an append-only audit read via `drivers/:id/history` & `vehicles/:id/history`. **Tracking (customer-facing):** `GET /api/tracking/:consignmentId` → `tracking_events` timeline (`location`, `ConsignmentStatus`, `occurredAt`), consumed by portal `TrackingPage`. It is **JWT-guarded** (no permission decorator — not public). Distinct from staff train **checkpoints** (`train_checkpoint_events`, written by `recordCheckpoint`/`arrive`). NOTE: **`TrackingService.record()` has no caller anywhere in the codebase — nothing writes `tracking_events`, so the customer tracking timeline is currently unpopulated; only the staff `train_checkpoint_events` store is written.** --- ## 10. Warehouse — inventory lifecycle Hierarchy `Facility → Warehouse → Yard → Zone`; every level tracks weight/volume/container capacity kept in sync by `applyCapacityDelta`. `warehouse_inventory` carries nullable FKs to `booking`, `cargo`, `container` — the join point between warehouse and the shipment. ```mermaid stateDiagram-v2 [*] --> RECEIVED: receive / bulkReceive (B) [*] --> UNLOADED: auto-unload-arrived (import train) (B) RECEIVED --> STORED: store (B) (allocation rule picks yard/zone) UNLOADED --> STORED: store (B) UNLOADED --> READY_FOR_PICKUP: inspection PASSED + IMPORT (B) RECEIVED --> READY_FOR_PICKUP: inspection PASSED + IMPORT (B) STORED --> RESERVED: reserve (B) (booking PAID) RESERVED --> READY_FOR_LOADING: mark-ready-for-loading (B) (inspection PASSED) READY_FOR_LOADING --> LOADED: load onto wagon (B) (+ warehouse_loadings) LOADED --> DISPATCHED: dispatch / bulk-dispatch-export (B) DISPATCHED --> UNLOADED_AT_DJIBOUTI_PORT: auto-unload-at-djibouti (B) READY_FOR_PICKUP --> DELIVERED: release → deliver (B) (POD, fees PAID via gate-clearance) READY_FOR_PICKUP --> STORED: re-store import item (B) READY_FOR_PICKUP --> DISPATCHED: dispatch out (B) UNLOADED_AT_DJIBOUTI_PORT --> [*] DELIVERED --> [*] ``` **Export branch:** receive → STORED → RESERVED (booking PAID) → READY_FOR_LOADING → LOADED → DISPATCHED → UNLOADED_AT_DJIBOUTI_PORT. Note `store()` does **not** check inspection — `RECEIVED → STORED` is legal without it; inspection **PASSED** is enforced only at `mark-ready-for-loading`. **Import branch:** UNLOADED → inspect PASSED → READY_FOR_PICKUP (auto-creates last-mile if requested) → release → DELIVERED. Every transition writes `warehouse_activity_log`. **Storage billing** is *not* a separate table — `WarehouseInvoiceService` is a thin layer over the central **billing** module (global `Invoice` rows, `source='warehouse'`, `sourceId=inventoryId`). Fees = `STORAGE_FEE` + `DEMURRAGE` via `warehouse_fee_rules` (free-days grace, tiers, FX-converted). **Unpaid warehouse fees block exit:** `gateClearance`/`release` call `findBlockingInvoice` / `assertClearanceAllowed`. Controller families (84 routes, all called by backoffice `warehouse.service.ts`; **no per-route permission decorators → behind the global JwtGuard only**): `warehouses` · `warehouse-yards` · `warehouse-zones` · `warehouse-inventory` (queries + 25 mutation actions incl. bulk + import/export queues, and the gate release `warehouse-inventory/:id/gate-clearance`) · `warehouse-loadings` · inspection (`…/inspection-reports`) · fee-invoices (`…/generate-fee-invoice`, `warehouse-fee-invoices/*` incl. `pay`, `pay-online`) · rules (`warehouse-allocation-rules`, `warehouse-fee-rules`, `warehouse-allocation/preview`). > Portal touches only: `bookings/:id/warehouse-fee-invoices`, invoice `by-id`/`document`/`receipt`/ > `pay-online`, and `bookings/:bookingId/approve-delivery` (customer signs handover). Everything else (B). --- ## 11. Field Operations — first/last mile & import customs ```mermaid flowchart TD binv["booking.invoice.paid event"] --> advance["advanceBookingOnPayment → booking PAID"] advance --> fmreq{"EXPORT + first-mile requested?"} fmreq -->|Yes| fmaccept["firstMileService.acceptBooking
(auto-create leg)"] fmreq -->|No| skip1["—"] fmaccept --> fmleg subgraph FM["First-mile (EXPORT origin road leg)"] fmleg["READY_TO_TRANSIT"] --> fmveh["setVehicles → vehicle BUSY,
SMS driver, fleet_events"] fmveh --> fmtransit["IN_TRANSIT (needs assigned vehicle)"] fmtransit --> fmdone["RECEIVED_TO_PORT (free vehicles)"] fmleg --> fminv["generate-invoice → FIRST_MILE fee
(locks distances once invoiced)"] end whrcv["warehouse inspection PASSED (IMPORT only)"] --> lmaccept["lastMileService.acceptBooking(reference)"] lmaccept --> lmleg subgraph LM["Last-mile (IMPORT destination road leg)"] lmleg["READY_TO_TRANSIT"] --> lmtransit["IN_TRANSIT"] --> lmdone["DELIVERED (free vehicles)"] lmleg --> lminv["generate-invoice → LAST_MILE fee"] end subgraph IMP["Import customs finalization (per booking, timestamp-driven)"] direction TB up["upload docs (IM4/IM5/T1_CLOSURE/…)"] --> decl["record declaration serial"] decl --> notify["notify duties/taxes"] notify --> paid["mark duties paid (needs CUSTOMER_PAYMENT_SLIP)"] paid --> risk["assign risk (GREEN/YELLOW/BLUE/RED)"] risk --> rel["release-permitted (asserts T1 + release permit + declaration + risk + paid)"] end ``` | Module | Base | Guard | Terminal state | | --- | --- | --- | --- | | first-mile | `/api/first-mile` (+`/accept/:ref`,`/:id/{vehicles,distances,invoice}`) | `trainScheduling.view/manage` | `RECEIVED_TO_PORT` | | last-mile | `/api/last-mile` (same shape) | `trainScheduling.view/manage` | `DELIVERED` | | import-operations | `/api/import-operations/{customs,djibouti-incidents,empty-container-returns}/*` | none (global JwtGuard) | `completedAt` | | interchange-documents | `/api/interchange-documents` (+`/generate-from-schedule`,`/:id/{acknowledge,dispute,cancel}`) | none | `ACKNOWLEDGED / DISPUTED` | | drivers | `/api/drivers` (+`/:id/history`) | `FleetView/Manage` | soft-delete | | vehicles | `/api/vehicles` (+`/:id/history`) | `FleetView/Manage` | soft-delete | Interchange documents are the **rail↔port handover manifest** — generated from a train schedule, snapshotting booking/container/cargo lines with per-item `conditionStatus` derived from warehouse inspection flags. Status `DRAFT → GENERATED → ACKNOWLEDGED | DISPUTED | CANCELLED`. All FE callers (B). **Mile trigger precision (verified):** first-mile is created only on `booking.invoice.paid` → `advanceBookingOnPayment` → `firstMile.acceptBooking(bookingId)` when `EXPORT` + first-mile requested. Last-mile is created **only on IMPORT inspection PASSED** (two call sites: `warehouse-inspection.service` and the bulk-inspect branch of `warehouse-inventory.service`) — **not** on warehouse *receive*. The IMPORT constraint is enforced by the warehouse caller, not inside `lastMile.acceptBooking(reference)`. Auto-created legs enter at **`READY_TO_TRANSIT`** (the `PAYMENT_PENDING` entity default is bypassed). --- ## 12. Billing & Payment ### 12.1 Invoice lifecycle Invoices are **source-agnostic** — `BillingService.generateInvoice()` is the single factory called by domain services (never a controller): `source ∈ {booking, warehouse, first_mile, last_mile}` (the enum also defines an unused `demurrage`), numbered `INV-YYYYMMDD-#####`, `dueAt = now + 14d`. **Initial status varies by source:** booking invoices start `DRAFT` (issued at operation-accept via `billing.updateStatus`); the `generateInvoice` default is `PENDING`; warehouse + contract-GL invoices start `ISSUED`. ```mermaid stateDiagram-v2 [*] --> DRAFT: booking invoice (issued at operation-accept) [*] --> PENDING: generateInvoice default [*] --> ISSUED: warehouse / contract-GL invoice DRAFT --> ISSUED: billing.updateStatus PENDING --> PARTIALLY_PAID: recordPayment (partial, offline) ISSUED --> PARTIALLY_PAID: recordPayment (partial, offline) PENDING --> PAID: markInvoiceAsPaid (gateway, full) ISSUED --> PAID: markInvoiceAsPaid (gateway, full) PARTIALLY_PAID --> PAID: final payment PENDING --> EXPIRED: expirePayable (pay window lapses) ISSUED --> CANCELLED: cancelInvoice (no payments) PAID --> REFUNDED: markInvoiceAsRefunded (paidAmount>0) PAID --> [*] note right of PAID emits ${source}.invoice.paid (sources use first_mile / last_mile, underscores) → domain listeners advance booking / mile / warehouse end note note left of ISSUED OVERDUE exists in the enum but NO code sets it (no cron / setter) — effectively unused end note ``` `OPEN_STATUSES = {Issued, Pending, PartiallyPaid, Overdue}` are payable. Transitions lock the row (`pessimistic_write`); the event fires **after commit** for self-managed transitions, but **inline before commit** when the transition is enlisted in a caller-supplied transaction `manager`. ### 12.2 freight-api ↔ payment-api integration ```mermaid sequenceDiagram autonumber participant U as Customer (P) participant FB as Freight billing (:3001) participant PA as Payment API (red) (:3003) participant PV as Provider (Telebirr/Waafi/…) participant OB as Payment outbox participant FI as Freight internal ctrl U->>FB: POST /billing/my-invoices/:id/pay FB->>FB: payInvoice → validate OPEN, balance>0 FB->>PA: POST /payments/initiate (x-service-token)
service=FREIGHT, referenceId=sourceId, amountMinor PA-->>FB: { intentId, clientAction (REDIRECT/LAUNCH_APP/COLLECT_OTP) } FB->>FB: store paymentId on invoice (correlation) FB-->>U: clientAction → redirect to provider U->>PV: authorize payment PV->>PA: webhook POST /webhooks/{provider} PA->>PA: verify signature, dedupe, intent state machine PA->>OB: write PaymentEvent (payment.succeeded) OB->>FI: POST /api/internal/payments/mark-paid (@Public, x-service-token) Note over OB,FI: or RabbitMQ → PaymentEventsConsumer FI->>FB: handlePaymentEvent → settleByPaymentId → markInvoiceAsPaid FB->>FB: emit booking.invoice.paid FB-->>U: invoice PAID (poll / notification) ``` > NOTE: **Demo shortcut in `payInvoice`:** today, if the provider doesn't settle synchronously, freight > self-fires `handlePaymentEvent(payment.succeeded)` inline (marked TODO/remove) — invoices settle at > pay-time without a real webhook. Providers: **Telebirr / CBE_BIRR / EBIRR** (ET), **Waafi / DMoney** > (DJ), **CARD** (intl), **CAC_BANK** (OTP). Payment DB is a **separate `edr_payment` schema** — no > cross-schema FKs; `referenceId` is a soft link. ### 12.3 Endpoints | Method | Path | Facing | Guard | | --- | --- | --- | --- | | GET | `/api/billing/invoices` (+`/:id`,`/:id/document`,`/receipt`) | (B) backoffice | `bookings.view` | | GET | `/api/billing/my-invoices` (+`/:id`,`/document`,`/receipt`) | (P) portal | `@CurrentUser` ownership | | POST | `/api/billing/my-invoices/:id/pay` | (P) portal | ownership | | POST | `/api/payments/initiate` | central | JwtGuard (no permission — **not** public) | | GET | `/api/payments/checkout` | redirect | `@Public` | | GET | `/api/payments/{summary,all}` | (B) backoffice | `bookings.view` | | GET | `/api/payments/by-company/:companyId/customer-view` | (B) | none | | GET | `/api/payments/intents/:bookingId` · `/receipt/:orderId` | reconcile / receipt | none / `@Public` | | POST | `/api/internal/payments/mark-paid` | (red) service→service | `@Public` (NOTE: currently unauthenticated) | | POST·GET·PUT | `/api/backoffice/organizations/:orgId/*` | (B) IAM user/role mgmt (NOT billing) | `@FreightAdmin` | `GET /api/overview*` (7 tabs, `bookings.view`) is the backoffice dashboard aggregator over 11 repos. --- ## 13. Cross-cutting event web (`@OnEvent`) The domains are stitched together by events, not direct calls. This is why the master flow "just happens". ```mermaid flowchart LR pay["Payment webhook / demo shortcut"] --> settle["billing.settleByPaymentId → markInvoiceAsPaid"] settle --> ev1{{"${source}.invoice.paid"}} ev1 -->|source=booking| adv["booking-invoice: advanceBookingOnPayment → PAID"] adv --> fm["firstMile.acceptBooking (EXPORT + requested)"] adv --> batch["train-scheduling batch: markPaid → allocate"] ev1 -->|source=first_mile| e2{{"first_mile.invoice.paid"}} --> fmp["NOTE: intended: leg paid=true
(listener typo 'firstmile.invoice.paid' → never fires)"] ev1 -->|source=last_mile| e3{{"last_mile.invoice.paid"}} --> lmp["last-mile → DELIVERED, paid=true"] ev1 -->|source=warehouse| e4{{"warehouse.invoice.paid"}} --> whp["settle warehouse fee → unblock gate"] arrive["train arrive"] --> whauto["warehouse arrival automation:
auto-unload arrived bookings"] insp["warehouse inspection PASSED (IMPORT)"] --> lmacc["lastMile.acceptBooking → READY_FOR_PICKUP"] assign["mile setVehicles"] --> veh["vehicle BUSY + SMS driver + fleet_events"] release["mile terminal / delete"] --> free["vehicle FREE (releaseIfUnused)"] notify["notification-inbox.notify"] --> ws["WebSocket push (always)"] notify -->|HIGH priority| smsemail["+ SMS + email via RabbitMQ"] ``` --- ## 14. Portal vs Backoffice — who does what | Capability | (P) Portal (customer) | (B) Backoffice (employee) | | --- | --- | --- | | Identity / onboarding | signup, OTP, Fayda, company onboarding, profile | company approval, IAM user/role mgmt | | Bookings | create, price, submit, sign, upload docs, pay, approve delivery, track | accept, approve chain, generate contract, clearance review, operation review, allocate, dispatch | | Contracts | create, submit, sign, booking-requests, self-clearance slips | approve, generate, phased clearance, GL booking creation | | Rule engine | reads only via `bookings/reference-data` | full CRUD (rates approval, approval rules, priorities) | | Scheduling | discover bookable days/schedules | build/finalize/dispatch/arrive/reschedule trains | | Warehouse | pay storage fees, view invoices, approve handover | full inventory lifecycle + fees + rules | | Field ops | (none direct) | first/last mile, import customs, interchange docs, fleet | | Billing | pay own invoices, download docs/receipts | invoice + payment dashboards, per-customer views | | Notifications | inbox + WS | inbox + WS | --- ## 15. Status / state reference | Entity | States (happy path → terminal) | | --- | --- | | Company | `PENDING → ACTIVE` (+ SUSPENDED, BLACKLISTED) | | Booking | `DRAFT → SUBMITTED → PENDING_APPROVAL → APPROVED_PENDING_SIGNATURE → APPROVED → CONTRACT_READY → SIGNED_CUSTOMER → [IMPORT/EXPORT: AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY → OPERATION_REQUEST_PENDING → (train) FULLY_EXECUTED → SELECTED_FOR_BATCH / (road) ROAD_DISPATCH_PENDING] · [DOMESTIC: → FULLY_EXECUTED → SELECTED_FOR_BATCH] → PAID → IN_TRANSIT → COMPLETED` (branches: REJECTED, CANCELLED, CHANGES_REQUESTED, OPERATION_CHANGES_REQUESTED, PENDING_CONSOLIDATION). 35 statuses total; ~10 legacy ones unused. | | Contract | `DRAFT → SUBMITTED → PENDING_APPROVAL → APPROVED_PENDING_SIGNATURE → APPROVED → CONTRACT_READY → SIGNED_CUSTOMER → AWAITING_CLEARANCE_DOCUMENTS / CONTRACT_ACTIVE / FULLY_EXECUTED` (+ CLEARANCE_UNDER_REVIEW, CLEARANCE_READY_FOR_BOOKING, ACTIVE_SHIPMENT_IN_PROGRESS, CONTRACT_CLOSED, CANCELLED, RENEWAL_DRAFT/SUBMITTED/PENDING_APPROVAL, AMENDMENTS_PROPOSED, ARCHIVED, EXPIRED — **24 total**). Separate `clearanceStatus` axis: NOT_APPLICABLE / AWAITING_DOCUMENTS / DOCUMENTS_UNDER_REVIEW / CLEARANCE_READY_FOR_BOOKING / SELF_CLEARED / ACTIVE_SHIPMENT_IN_PROGRESS. | | Rate | `DRAFT → PENDING_APPROVAL → LIVE` (+ SUPERSEDED) | | Train schedule | `DRAFT → SCHEDULED → DISPATCHED → ARRIVED` (+ CANCELLED) | | Warehouse inventory | export: `RECEIVED → STORED → RESERVED → READY_FOR_LOADING → LOADED → DISPATCHED → UNLOADED_AT_DJIBOUTI_PORT` · import: `UNLOADED → READY_FOR_PICKUP → DELIVERED` (READY_FOR_PICKUP may also → STORED / DISPATCHED) | | First-mile | `PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → RECEIVED_TO_PORT` (auto-created legs enter at READY_TO_TRANSIT) | | Last-mile | `PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → DELIVERED` (auto-created legs enter at READY_TO_TRANSIT) | | Invoice | `DRAFT / PENDING / ISSUED → PARTIALLY_PAID → PAID` (+ EXPIRED, CANCELLED, REFUNDED; **OVERDUE defined but never set**) | | Interchange doc | `DRAFT → GENERATED → ACKNOWLEDGED / DISPUTED / CANCELLED` | | Empty container return | `RETURNED → ASSIGNED_STORAGE → DOCUMENTATION_CLEARED → WAGON_ALLOCATED → TRANSPORTED_TO_DJIBOUTI → HANDOVER_ISSUED → COMPLETED` | --- ## 16. Notable gaps & caveats (verified against code) - **Counter-sign is direction-based, not customs-based** (bookings *and* contracts): `IMPORT`/`EXPORT` open a clearance gate even with customs off; only `DOMESTIC` skips it. The customs flag only selects the clearance document set. - **`SELECTED_FOR_BATCH` is set by the batch engine, not at operation-accept** — train accept sets `FULLY_EXECUTED` first. - **`FacilitiesModule` is never imported into `AppModule`** — `/api/facilities` is **unmounted / dead** (not reachable at all). `tracking`, `maintenance`, `fuel` carry no permission decorator but *are* mounted — behind the **global JwtGuard**, just not permission-gated. - **`tracking_events` has no writer anywhere** — `TrackingService.record()` is never called, so the customer tracking timeline is unpopulated. Only staff `train_checkpoint_events` are written (by `recordCheckpoint`/`arrive`). - **First-mile paid-flag listener is a dead code path** — `@OnEvent("firstmile.invoice.paid")` (no underscore) never fires because the emitted event is `first_mile.invoice.paid`; the first-mile `paid` flag is never flipped by settlement. - **Invoice `OVERDUE` status is never set** — no cron/setter transitions to it, though it is in the enum and `OPEN_STATUSES`. - `POST /api/internal/payments/mark-paid` is `@Public` with **no service auth** (ServiceAuthGuard removed; noted in code). - **Demo settlement shortcut** in `billing.payInvoice` bypasses real webhooks (TODO/remove) — invoices settle inline at pay-time. - Server does **not** validate upload size/MIME (client-side only); it enforces *required-doc presence* at clearance. - Dead / unwired FE calls: `trains/:id/details` (no route), portal `consignments` service hits `/consignments` without `/api`, and list endpoints `bookings/my`, `queues/:queue`, `by-company/customer-view` have no active caller. - NOTE: The repo `CLAUDE.md` states auth is stubbed/unwired — **this is stale**: auth is live via `@tria-plc/api-common` (global `JwtGuard` + `HasActiveDelegationGuard` `APP_GUARD`s). --- *Generated from `apps/edr-freight-api`, `apps/edr-freight-web/{portal,backoffice}`, `apps/edr-payment-api` on branch `freight/feat/fixes-v1`. Reflects code at scan time; regenerate after major module changes.*