diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fcd560a95..62530611c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -170,7 +170,12 @@ jobs: - name: Build ${{ matrix.service }} run: | set -euo pipefail + IMAGE_TAG="${COMPOSE_PROJECT_NAME}-${{ matrix.service }}:${GITHUB_SHA::8}" docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" + # Tag with git SHA for rollback capability + CONTAINER_NAME=$(docker compose --project-name "${COMPOSE_PROJECT_NAME}" config --services | grep "${{ matrix.service }}" | head -1) + docker tag "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}" "${IMAGE_TAG}" 2>/dev/null || true + echo "IMAGE_TAG=${IMAGE_TAG}" >> "${GITHUB_ENV}" - name: Deploy ${{ matrix.service }} run: | diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 2818dcd97..463cea535 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -121,13 +121,59 @@ This ensures `docker ps` shows `0.0.0.0:->/tcp` with matching ports. ### Runtime -The final image runs: +The final image uses Next.js `output: 'standalone'` and runs: ```bash -npx next start +node server.js ``` -Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose) to determine which port to listen on. +Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose). The standalone output bundles only the required `node_modules`, producing a significantly smaller image than a full `pnpm deploy`. + +## Rollback Procedure + +Each build is tagged with the short git SHA (`${COMPOSE_PROJECT_NAME}-:`). + +### Rollback a single service + +```bash +# 1. Find the last known-good image tag +docker images | grep passenger-api + +# 2. Re-tag it as the current image +docker tag edr-passenger-main-passenger-api: edr-passenger-main-passenger-api:latest + +# 3. Restart the container from the previous image +docker compose --project-name edr-passenger-main up -d passenger-api --force-recreate +``` + +### Rollback via re-run + +Alternatively, trigger a `workflow_dispatch` on the last known-good commit SHA from the GitHub Actions UI — this rebuilds and redeploys that exact commit. + +## Production Security Checklist + +Before deploying to production, verify: + +- [ ] `JWT_SECRET`, `JWT_ACCESS_TOKEN_SECRET`, `JWT_REFRESH_TOKEN_SECRET` are set to random 32+ char strings (`openssl rand -hex 32`) +- [ ] `DATABASE_URL` includes `?sslmode=require&connection_limit=10` +- [ ] `WAAFI_INSECURE_TLS` is `false` (app will refuse to start if `true` in production) +- [ ] `NODE_ENV=production` is set +- [ ] `GITHUB_PACKAGE_TOKEN` is a scoped read-only token, not a personal admin token +- [ ] No `.env` files are committed to the repository (`git status` should show none) + +## Data Retention Policy + +The `TasksService` runs a daily purge cron at 02:00 EAT that automatically deletes: + +| Table | Retention | +|---|---| +| `OtpCode` | 1 hour after expiry or verification | +| `FaydaVerificationSession` | 1 hour after expiry or completion | +| `AuditLog` | 365 days | +| `PaymentWebhookEvent` | 90 days | +| `GateValidationLog` | 180 days | + +No manual intervention is required. Monitor the `TasksService` log output for purge counts. ## GitHub Actions Deployment Flow @@ -147,9 +193,10 @@ For each service: - Computes branch slug and sets: - `COMPOSE_PROJECT_NAME=-` - Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`. -- Runs: - - `docker compose --project-name "$COMPOSE_PROJECT_NAME" build ` - - `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d ` +- For `passenger-api` and `payment-api`: builds and runs the migration image as a gated step before the app image. +- Builds the service image and tags it with the short git SHA. +- Runs `docker compose up -d --force-recreate`. +- For API services: polls `GET /health/ready` every 10s for up to 120s. Fails the job if the service does not become healthy. - Cleans `.npmrc`/`.npmrc_temp`. ## Branch/Environment Isolation diff --git a/README.md b/README.md index 125561b9d..50e0b55f6 100644 --- a/README.md +++ b/README.md @@ -369,7 +369,7 @@ pnpm --filter @edr/passenger-api run prisma:seed - 3 User accounts (Admin, Passenger, Agent) - Fare rules for ADULT and CHILD passenger categories - Currency exchange rates (ETB, DJF, USD) -- Baggage allowance rules +- Luggage allowance rules - Notification templates - Promotions and FAQ content - Menu items and station crowd signals diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index e02391ccd..6fdaab48b 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -1,5 +1,7 @@ # Copy to .env for local/docker compose (not committed). PORT=3001 +# GT06 GPS tracker TCP listener port (raw TCP, must be reachable by tracker SIMs). 0 disables. +GT06_TCP_PORT=5023 DB_HOST=localhost DB_PORT=5433 DB_USER=postgres @@ -49,14 +51,40 @@ MINIO_PORT=9000 MINIO_USE_SSL=false MINIO_ACCESS_KEY= MINIO_SECRET_KEY= +# Preset region so signed URLs are generated locally (no GetBucketLocation +# network call per sign). MinIO's default is us-east-1. +MINIO_REGION=us-east-1 # Redis REDIS_HOST=localhost REDIS_PORT=6379 # --- Notification broker (RabbitMQ) --------------------------------------------- -# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service). -# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker). +# SMS/email OTP + notifications are queued to RabbitMQ (consumed by the shared +# SMS/email services). Set RABBITMQ_ENABLED=false to skip the broker entirely +# (dev without a local broker). RABBITMQ_ENABLED=false RABBITMQ_URL=amqp://localhost:5672 SMS_QUEUE=sms_queue + +# ── VeriFayda 2.0 (eSignet OIDC) identity verification ────────────────────── +# Disabled by default; /fayda/verification/start returns 503 until enabled. +FAYDA_ENABLED=false +FAYDA_CLIENT_ID= +FAYDA_AUTHORIZATION_ENDPOINT= +FAYDA_TOKEN_ENDPOINT= +FAYDA_USERINFO_ENDPOINT= +# Base64-encoded RSA private JWK used for the private_key_jwt client assertion +FAYDA_PRIVATE_KEY_BASE64= +# OAuth redirect_uri for MOBILE clients (must be registered with eSignet) +FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete +# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset. +FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback +CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +FAYDA_SCOPE=openid profile email phone address +FAYDA_ACR_VALUES=mosip:idp:acr:generated-code +FAYDA_CLAIMS_LOCALES=en am +FAYDA_SESSION_TTL_MINUTES=10 +EXPIRATION_TIME=15 +ALGORITHM=RS256 +EMAIL_QUEUE=email_queue diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index f9107ed23..d88029a80 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -40,4 +40,6 @@ RUN addgroup --system --gid 1001 nodejs \ COPY --from=deployer --chown=nestjs:nodejs /deploy . USER nestjs EXPOSE 3001 +# GT06 GPS tracker TCP listener (raw TCP, not HTTP). Change via GT06_TCP_PORT. +EXPOSE 5023 CMD ["node", "dist/main.js"] diff --git a/apps/edr-freight-api/docs/FREIGHT_FLOW_VARIANTS.md b/apps/edr-freight-api/docs/FREIGHT_FLOW_VARIANTS.md new file mode 100644 index 000000000..53c8b76c3 --- /dev/null +++ b/apps/edr-freight-api/docs/FREIGHT_FLOW_VARIANTS.md @@ -0,0 +1,604 @@ +# EDR Freight — Major Flow Variants (each self-contained) + +The single master graph lives in [`FREIGHT_MASTER_FLOW.md`](./FREIGHT_MASTER_FLOW.md). This file breaks the +business logic into **one comprehensive, self-contained diagram per major scenario**, each organised with +phase **subgraphs** so it can be read on its own. + +**Axes covered** + +| Axis | Values | +| --------------- | -------------------------------------------------------------------------------------------- | +| Origin | **One-time booking** · **General contract** (Path A transport-only / Path B GENERAL+customs) | +| Trade direction | **Export** · **Import** · **Intercity / Domestic** | +| Customs | **With customs** · **Without customs** | + +**Legend** — (P) Portal (customer) · (B) Backoffice (staff) · (sys) System/event · (green) rounded = success end · (red) rounded = fail end · <> decision. + +**Which diagram do I read?** + +```mermaid +flowchart LR + classDef dec fill:#f8fafc,stroke:#475569,color:#111 + classDef box fill:#e0e7ff,stroke:#3730a3,color:#111 + A{"Origin?"}:::dec + A -->|"one-time"| B{"Trade direction?
(gate is direction-driven, NOT a customs toggle)"}:::dec + A -->|"framework agreement"| C{"Contract type?"}:::dec + B -->|"DOMESTIC"| D1["§1 One-time · DOMESTIC (no gate)"]:::box + B -->|"IMPORT / EXPORT
(with or without customs)"| D2["§2 One-time · IMPORT/EXPORT (gate)"]:::box + C -->|"transport-only (self-clearance)"| D3["§3 Contract · Path A"]:::box + C -->|"GENERAL + customs"| D4["§4 Contract · Path B"]:::box + D1 --> E{"Physical direction?"}:::dec + D2 --> E + D3 --> E + D4 --> E + E -->|"export"| F5["§5 EXPORT operations"]:::box + E -->|"import"| F6["§6 IMPORT operations"]:::box + E -->|"domestic"| F7["§7 INTERCITY operations"]:::box +``` + +> **How the two halves connect:** §1–§4 are the **commercial** journeys (intake → approval → contract → +> clearance → operation → payment). §5–§7 are the **physical** journeys (mile legs → warehouse → train → +> delivery). A shipment = one commercial variant **+** one physical variant. Each diagram fully details its +> own half and summarises the other so it stands alone. + +--- + +## §1 — One-time booking · DOMESTIC (no clearance gate) + +The commercial lifecycle when the counter-sign gate resolves to **no clearance** — which, in code, means +**trade direction = DOMESTIC** (not a customs toggle). Counter-sign goes straight to `FULLY_EXECUTED` and the +booking is enqueued **directly into the scheduling batch pipeline, skipping the operation-request/clearance +phase**. NOTE: Import/export bookings — _even with customs off_ — do **not** land here; they always hit the +clearance gate (§2, just with a lighter "without customs" document set). + +```mermaid +flowchart TD + classDef port fill:#dbeafe,stroke:#2563eb,color:#111 + classDef back fill:#fef3c7,stroke:#b45309,color:#111 + classDef sys fill:#dcfce7,stroke:#15803d,color:#111 + classDef dec fill:#f8fafc,stroke:#475569,color:#111 + classDef good fill:#86efac,stroke:#166534,color:#062e14 + classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a + + pre(["Company ACTIVE (approved profile)"]):::sys + + subgraph DRAFT["1 · Create & price"] + direction TB + d1["POST /bookings → DRAFT
reference, containers, cargo modifiers, files (P)"]:::port + d2["POST /bookings/:id/generate-price
rule-engine: LIVE rates + surcharges (P)"]:::port + dW{"weight-limit-rules"}:::dec + dWx(["HARD BLOCK 400 — VGM > capacity"]):::bad + d3["POST /bookings/:id/submit → SUBMITTED
freeze booking_rate_snapshot (P)"]:::port + dP{"price moved?"}:::dec + d3c["confirm-submit → SUBMITTED (P)"]:::port + d1 --> d2 --> dW + dW -->|"over capacity"| dWx + dW -->|"ok / warn+surcharge"| d3 --> dP + dP -->|"yes"| d3c + dP -->|"no"| out1 + d3c --> out1 + d1 -.->|"delete draft"| ddx(["removed"]):::bad + d3 -.->|"reject price"| drx(["REJECTED"]):::bad + end + out1[" "]:::sys + + subgraph INTAKE["2 · Staff intake & approval"] + direction TB + g{"government?"}:::dec + gexp["governmentExpedite → PAID + Eligible (B)"]:::back + i{"staff/accept | request-changes | reject (B)"}:::dec + ir["CHANGES_REQUESTED (B)"]:::back + irx(["REJECTED"]):::bad + ia["→ PENDING_APPROVAL
instantiate approval steps + validity window (B)"]:::back + ac{"chain: LINE_STAFF → DIRECTOR → CEO (B)"}:::dec + acx(["rejectStep → REJECTED"]):::bad + g -->|"yes"| gexp + g -->|"no"| i + i -->|"request-changes"| ir + i -->|"reject"| irx + i -->|"accept"| ia --> ac + ac -->|"rejectStep"| acx + end + + subgraph SIGN["3 · Contract doc & sign (DOMESTIC → no gate)"] + direction TB + s1["contract/generate → CONTRACT_READY (B)"]:::back + s2["customer sign → SIGNED_CUSTOMER (P)"]:::port + s3["staff counter-sign (DOMESTIC) → FULLY_EXECUTED
enqueueScheduleProcessing (no op-request) (B)(sys)"]:::back + s1 --> s2 --> s3 + end + + subgraph OPPAY["4 · Batch pipeline & payment"] + direction TB + fe["FULLY_EXECUTED enters day batch pool (sys)"]:::sys + b1["batch engine offers wagons → SELECTED_FOR_BATCH
invoice generated (sys)"]:::sys + p1["customer pays → gateway → PAID (P)"]:::port + pexp(["pay window lapses → reservation EXPIRED"]):::bad + fe --> b1 --> p1 + p1 -.->|"unpaid"| pexp + end + + phys(["Physical execution:
§7 intercity → COMPLETED (done)"]):::good + + pre --> DRAFT + out1 --> INTAKE + ac -->|"APPROVED"| SIGN + SIGN --> OPPAY + p1 --> phys + gexp -.->|"gov → PAID/Eligible"| phys +``` + +> **Road-mode note:** a domestic booking billed by road (truck) instead of rail goes through +> `operation/review` → `ROAD_DISPATCH_PENDING` (the road branch shown in the master graph), not the rail +> batch pool above. + +--- + +## §2 — One-time booking · IMPORT / EXPORT (clearance gate) + +Every IMPORT/EXPORT one-time booking traverses the clearance gate — **whether or not customs is enabled** +(the customs flag only selects a heavier vs lighter `clearance_*` document set; both go through +`AWAITING_DOCUMENTS`). Commercial spine as §1 (phases 1–3) **plus** the gate: counter-sign → `AWAITING_DOCUMENTS` +→ document review loop → `CLEARANCE_READY`, phased ET/DJ actions, then operation-request → payment. + +```mermaid +flowchart TD + classDef port fill:#dbeafe,stroke:#2563eb,color:#111 + classDef back fill:#fef3c7,stroke:#b45309,color:#111 + classDef sys fill:#dcfce7,stroke:#15803d,color:#111 + classDef dec fill:#f8fafc,stroke:#475569,color:#111 + classDef good fill:#86efac,stroke:#166534,color:#062e14 + classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a + + a0(["Booking APPROVED & signed by customer
(see §1 phases 1–3)"]):::sys + + subgraph CS["Counter-sign with customs"] + direction TB + cs1["staff counter-sign (IMPORT/EXPORT) → AWAITING_DOCUMENTS (B)"]:::back + end + + subgraph DOCS["5 · Document clearance gate"] + direction TB + x1["customer clearance/documents
→ DOCUMENTS_UNDER_REVIEW (P)"]:::port + x2{"GL clearance/review each doc (B)"}:::dec + x2q["doc Queried → customer re-uploads (B)"]:::back + x3["clearance/finalize (100% approved) → CLEARANCE_READY (B)"]:::back + x1 --> x2 + x2 -->|"query"| x2q --> x1 + x2 -->|"approve all"| x3 + end + + subgraph PHASED["6 · Phased ET / DJ clearance (as applicable)"] + direction TB + ph1["upload declaration (serial) (B)"]:::back + ph2["duty/tax advise → customer duty-slip (P)(B)"]:::back + ph3["transit permit (ET) (B)"]:::back + ph4["delivery order / release order (DJ) (B)"]:::back + ph5["T1 docs → T1 close (B)"]:::back + ph6["export release / finalize-pre-clearance (B)"]:::back + ph1 --> ph2 --> ph3 --> ph4 --> ph5 --> ph6 + end + + subgraph OPPAY2["7 · Operation request & payment"] + direction TB + o1["clearance/proceed → OPERATION_REQUEST_PENDING (P)"]:::port + o2{"operation/review (B)"}:::dec + o2c["OPERATION_CHANGES_REQUESTED (B)"]:::back + om{"mode?"}:::dec + ot["accept=train: invoice → FULLY_EXECUTED
→ batch offer → SELECTED_FOR_BATCH (B)(sys)"]:::back + orr["accept=road: invoice → ROAD_DISPATCH_PENDING (B)"]:::back + p1["customer pays → PAID (sys)(P)"]:::sys + pexp(["pay window lapses → EXPIRED"]):::bad + o1 --> o2 + o2 -->|"request-changes"| o2c --> o1 + o2 -->|"accept"| om + om -->|"train"| ot --> p1 + om -->|"road"| orr --> p1 + p1 -.->|"unpaid"| pexp + end + + cancel(["CANCELLED — staff-only, only from
OPERATION_REQUEST_PENDING here (not from
AWAITING_DOCUMENTS / DOCUMENTS_UNDER_REVIEW)"]):::bad + phys(["Physical execution:
§5 export · §6 import → COMPLETED (done)"]):::good + + a0 --> CS --> DOCS + x3 --> PHASED + ph6 --> OPPAY2 + p1 --> phys + o1 -.->|"cancel"| cancel +``` + +--- + +## §3 — General contract · Path A (transport-only, self-clearance) + +Framework agreement where the customer clears customs independently. After the contract is active and +operations verify self-clearance (`SELF_CLEARED`), the **customer books directly** under the contract; each +booking then runs the operation/payment/physical flow. + +```mermaid +flowchart TD + classDef port fill:#dbeafe,stroke:#2563eb,color:#111 + classDef back fill:#fef3c7,stroke:#b45309,color:#111 + classDef sys fill:#dcfce7,stroke:#15803d,color:#111 + classDef dec fill:#f8fafc,stroke:#475569,color:#111 + classDef good fill:#86efac,stroke:#166534,color:#062e14 + classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a + + pre(["Company ACTIVE"]):::sys + + subgraph CTR["1 · Contract lifecycle"] + direction TB + c1["POST /contracts → DRAFT
routes + cargo scope + unit rates (NO quantities) (P)"]:::port + c2["generate-price → submit → SUBMITTED
freeze contract_rate_snapshots (P)"]:::port + c3{"staff/accept | request-changes | reject (B)"}:::dec + c3r["CHANGES_REQUESTED (B)"]:::back + c3x(["Contract REJECTED"]):::bad + c4["→ PENDING_APPROVAL (B)"]:::back + c4a{"approval chain (B)"}:::dec + c4x(["rejectStep → REJECTED"]):::bad + c5["generate-contract → CONTRACT_READY (B)"]:::back + c6["customer sign → SIGNED_CUSTOMER (P)"]:::port + c7["staff counter-sign (IMPORT/EXPORT self-clearance) →
AWAITING_CLEARANCE_DOCUMENTS (B)"]:::back + c1 --> c2 --> c3 + c3 -->|"request-changes"| c3r --> c2 + c3 -->|"reject"| c3x + c3 -->|"accept"| c4 --> c4a + c4a -->|"reject"| c4x + c4a -->|"approve"| c5 --> c6 --> c7 + c7 -.->|"lapse"| cexp(["EXPIRED"]):::bad + c7 -.->|"renew"| cren(["RENEWAL_DRAFT → new cycle"]):::bad + end + + subgraph SELF["2 · Self-clearance verification"] + direction TB + o1["customer uploads self-clearance docs (P)"]:::port + o2{"ops-review each doc (B)"}:::dec + o2q["query → re-upload (B)"]:::back + o3["ops-finalize → clearanceStatus SELF_CLEARED (B)"]:::back + o1 --> o2 + o2 -->|"query"| o2q --> o1 + o2 -->|"approve"| o3 + end + + subgraph BK["3 · Book directly under contract"] + direction TB + b1["customer POST /contracts/:id/bookings (P)"]:::port + bv{"validate-shipment:
window + capacity draw-down + pairing"}:::dec + bvx(["rejected: over capacity /
20ft pairing hard-block"]):::bad + b2["Booking created under contract
(bookings.contract_id) (sys)"]:::sys + b1 --> bv + bv -->|"fail"| bvx + bv -->|"ok"| b2 + end + + op(["Booking runs operation + payment
(see §1 phase 4) then §5/§6/§7 → COMPLETED (done)"]):::good + + pre --> CTR + c7 --> SELF + o3 --> BK + b2 --> op +``` + +--- + +## §4 — General contract · Path B (GENERAL + customs) + +Framework agreement **with** customs. The customer cannot book directly — they submit a **BookingRequest** +(date + quantities only); GL Ethiopia accepts it and creates the booking, which then runs **per-booking +phased customs** on the `/contracts/bookings/:bookingId/*` surface. + +```mermaid +flowchart TD + classDef port fill:#dbeafe,stroke:#2563eb,color:#111 + classDef back fill:#fef3c7,stroke:#b45309,color:#111 + classDef sys fill:#dcfce7,stroke:#15803d,color:#111 + classDef dec fill:#f8fafc,stroke:#475569,color:#111 + classDef good fill:#86efac,stroke:#166534,color:#062e14 + classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a + + pre(["Company ACTIVE"]):::sys + + subgraph CTR["1 · Contract lifecycle (customs)"] + direction TB + c1["POST /contracts → DRAFT → submit → approve → sign (P)(B)"]:::port + c7["staff counter-sign (GENERAL + customs) → CONTRACT_ACTIVE
(contract clearance cycle SKIPPED — runs per-booking) (B)"]:::back + c1 --> c7 + c1 -.->|"reject / lapse"| cx(["REJECTED / EXPIRED"]):::bad + end + + subgraph REQ["2 · Booking request → GL creates booking"] + direction TB + r1["customer POST /contracts/:id/booking-requests
(date + quantities, no per-unit data) (P)"]:::port + r2{"GL booking-request queue (B)"}:::dec + r2x(["reject / customer cancel →
REJECTED / CANCELLED"]):::bad + r3["GL accept → GL creates booking under contract
(ct:create_booking) (B)"]:::back + r1 --> r2 + r2 -->|"reject/cancel"| r2x + r2 -->|"accept"| r3 + end + + subgraph GLC["3 · Per-booking GL clearance & milestones"] + direction TB + g1["station-assign (route + bind staff) (B)"]:::back + g2["declaration → duty advise (GREEN/YELLOW/RED risk) (B)"]:::back + g3["customer duty-slip → transit / delivery / release order (P)(B)"]:::back + g4["T1 docs → T1 close (B)"]:::back + g5["final-invoice → customer slip → confirm paid (P)(B)"]:::back + g6["second-duty (post-arrival import) → slip (P)(B)"]:::back + gi["incident reports (photos) as needed (B)"]:::back + g1 --> g2 --> g3 --> g4 --> g5 --> g6 + g4 -.-> gi + end + + op(["Booking runs operation + payment (see §1 phase 4)
then §5/§6 physical → COMPLETED (done)"]):::good + + pre --> CTR + c7 --> REQ + r3 --> GLC + g6 --> op +``` + +--- + +## §5 — EXPORT operations (physical execution) + +Given a PAID, scheduled **export** booking: optional first-mile road leg → origin warehouse inbound → train +build & dispatch → corridor transit → Djibouti port unload → interchange handover. Cargo leaves the country +at the port; the booking's terminal here is **dispatched/handed-over at Djibouti**. + +```mermaid +flowchart TD + classDef port fill:#dbeafe,stroke:#2563eb,color:#111 + classDef back fill:#fef3c7,stroke:#b45309,color:#111 + classDef sys fill:#dcfce7,stroke:#15803d,color:#111 + classDef dec fill:#f8fafc,stroke:#475569,color:#111 + classDef good fill:#86efac,stroke:#166534,color:#062e14 + classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a + + pre(["Export booking PAID & Eligible
(see §1/§2/§3/§4)"]):::sys + + subgraph FM["1 · First-mile (if EXPORT + requested)"] + direction TB + fmq{"first-mile requested?"}:::dec + fm1["leg auto-created firstMile.acceptBooking
READY_TO_TRANSIT (sys)"]:::sys + fm2["setVehicles → vehicle BUSY, SMS driver, fleet_events (B)"]:::back + fm3["IN_TRANSIT (needs vehicle) → RECEIVED_TO_PORT (B)"]:::back + fm4["first-mile invoice (FIRST_MILE fee); distances lock once invoiced (B)"]:::back + fmq -->|"yes"| fm1 --> fm2 --> fm3 --> fm4 + fmq -->|"no"| fmskip[" "]:::sys + end + + subgraph WH["2 · Origin warehouse inbound"] + direction TB + w1["receive / bulkReceive → RECEIVED
capacity assert, GRN, notify owner SMS (B)"]:::back + w2{"inspection"}:::dec + w2f["FAILED / NEEDS_REVIEW → hold + re-inspect (B)"]:::back + w3["store (allocation rule → yard/zone) → STORED (B)"]:::back + w4["reserve (booking PAID) → RESERVED (B)"]:::back + w5["mark-ready-for-loading (inspection PASSED) → READY_FOR_LOADING (B)"]:::back + w6["load onto wagon → LOADED (+ warehouse_loadings) (B)"]:::back + w1 --> w2 + w2 -->|"fail"| w2f --> w2 + w2 -->|"PASSED"| w3 --> w4 --> w5 --> w6 + end + + subgraph SCHED["3 · Train build & schedule"] + direction TB + s1["schedule DRAFT (≥2 locos, derive EXPORT direction) (B)"]:::back + s2["assign-bookings + run-allocation (wagons) (B)"]:::back + s3["pin wagons → finalize → SCHEDULED (bookings Scheduled) (B)"]:::back + s3x["cancel schedule → bookings Eligible (B)"]:::back + s1 --> s2 --> s3 + s3 -.->|"cancel"| s3x -.-> s1 + s3 -.->|"gov preempt / maintenance"| sr["reschedule: retained/displaced/readmitted (B)"]:::back + sr -.-> s2 + end + + subgraph RUN["4 · Dispatch → Djibouti"] + direction TB + r1["dispatch → DISPATCHED
train_number, locos ASSIGNED, window CLOSED, unpaid EXPIRED (B)"]:::back + r2["checkpoints (corridor) → train_checkpoint_events (B)"]:::back + rc["customer tracking page GET /tracking/:id (JWT) (P)
NOTE: tracking_events has no writer — timeline empty"]:::port + r3["arrive → ARRIVED (bookings IN_TRANSIT, wagons/locos freed) (B)"]:::back + ru["export/auto-unload-at-djibouti →
UNLOADED_AT_DJIBOUTI_PORT (B)"]:::back + ri["interchange document generate-from-schedule → GENERATED (B)"]:::back + ria{"port acknowledges?"}:::dec + r1 --> r2 --> r3 --> ru --> ri --> ria + r2 -.-> rc + end + + done(["Export dispatched & handed over at Djibouti (done)"]):::good + disp(["interchange DISPUTED → remarks / re-issue"]):::bad + + pre --> FM + fm4 --> WH + fmskip --> WH + w6 --> SCHED + s3 --> RUN + ria -->|"acknowledge"| done + ria -->|"dispute"| disp +``` + +--- + +## §6 — IMPORT operations (physical execution) + +Given a PAID, scheduled **import** booking arriving by train from Djibouti: destination warehouse unload → +inspection → import customs finalization → optional last-mile → fee gate-clearance → release → delivery → +**COMPLETED**. + +```mermaid +flowchart TD + classDef port fill:#dbeafe,stroke:#2563eb,color:#111 + classDef back fill:#fef3c7,stroke:#b45309,color:#111 + classDef sys fill:#dcfce7,stroke:#15803d,color:#111 + classDef dec fill:#f8fafc,stroke:#475569,color:#111 + classDef good fill:#86efac,stroke:#166534,color:#062e14 + classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a + + pre(["Import booking PAID & scheduled
(see §1/§2/§4)"]):::sys + + subgraph RAIL["1 · Import train arrival"] + direction TB + t1["import train dispatched from Djibouti → DISPATCHED (B)"]:::back + t2["checkpoints → train_checkpoint_events (B)"]:::back + tc["customer tracking page GET /tracking/:id (JWT) (P)
NOTE: tracking_events has no writer — timeline empty"]:::port + t3["arrive → ARRIVED (bookings IN_TRANSIT) → warehouse arrival automation (B)"]:::back + t1 --> t2 --> t3 + t2 -.-> tc + end + + subgraph WH["2 · Destination warehouse"] + direction TB + w1["import/auto-unload-arrived-bookings (assign WH/yard/zone) → UNLOADED (B)"]:::back + w2{"inspection PASSED?"}:::dec + w2f["FAILED → hold / re-inspect / djibouti-incident report (B)"]:::back + w3["→ READY_FOR_PICKUP (IMPORT) (B)"]:::back + w1 --> w2 + w2 -->|"no"| w2f --> w2 + w2 -->|"yes"| w3 + end + + subgraph CUST["3 · Import customs finalization (timestamp-driven)"] + direction TB + u1["upload docs (IM4/IM5/T1_CLOSURE/TRANSIT_PERMIT/…) (B)"]:::back + u2["record declaration serial (B)"]:::back + u3["notify duties/taxes (B)"]:::back + u4["mark duties paid (needs CUSTOMER_PAYMENT_SLIP) (B)"]:::back + u5["assign risk (GREEN/YELLOW/BLUE/RED) (B)"]:::back + u6{"release gates satisfied?
T1 + release permit + declaration + risk + paid"}:::dec + u6x["blocked — missing gate → resolve (B)"]:::back + u7["release-permitted → completedAt (B)"]:::back + u1 --> u2 --> u3 --> u4 --> u5 --> u6 + u6 -->|"no"| u6x --> u6 + u6 -->|"yes"| u7 + end + + subgraph LM["4 · Last-mile (if requested)"] + direction TB + lq{"last-mile requested?"}:::dec + l1["leg auto-created lastMile.acceptBooking
READY_TO_TRANSIT (sys)"]:::sys + l2["setVehicles → IN_TRANSIT → DELIVERED (free vehicles) (B)"]:::back + l3["last-mile invoice (LAST_MILE fee) (B)"]:::back + lq -->|"yes"| l1 --> l2 --> l3 + lq -->|"no"| lskip[" "]:::sys + end + + subgraph DEL["5 · Release & delivery"] + direction TB + d0{"warehouse/storage fees fully PAID?"}:::dec + d0x["gate-clearance BLOCKED (findBlockingInvoice) (B)"]:::back + d0p["customer pays storage/demurrage online (P)"]:::port + d1["release order (DO) + gate-clearance → deliver (B)"]:::back + d2["customer approve-delivery (saved signature) → POD (P)"]:::port + d3["inventory DELIVERED, POD to cargo, container freed (sys)"]:::sys + d0 -->|"no"| d0x --> d0p --> d0 + d0 -->|"yes"| d1 --> d2 --> d3 + end + + ecr(["empty-container-return chain (post-import):
RETURNED → … → HANDOVER_ISSUED → COMPLETED"]):::sys + done(["Booking COMPLETED (done) (operations/complete)"]):::good + + pre --> RAIL + t3 --> WH + w3 --> CUST + u7 --> LM + l3 --> DEL + lskip --> DEL + d3 --> done + done -.-> ecr +``` + +--- + +## §7 — INTERCITY / DOMESTIC operations (no cross-border customs) + +Rail movement **between Ethiopian yards** (e.g. inland dry ports). No import/export customs, no Djibouti +port unload or interchange handover. Optional road mile legs if the service includes door delivery. + +```mermaid +flowchart TD + classDef port fill:#dbeafe,stroke:#2563eb,color:#111 + classDef back fill:#fef3c7,stroke:#b45309,color:#111 + classDef sys fill:#dcfce7,stroke:#15803d,color:#111 + classDef dec fill:#f8fafc,stroke:#475569,color:#111 + classDef good fill:#86efac,stroke:#166534,color:#062e14 + classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a + + pre(["Domestic booking PAID & Eligible
(see §1 — customs OFF)"]):::sys + + subgraph FMD["1 · Optional origin pickup (road)"] + direction TB + fq{"door pickup / first-mile requested?"}:::dec + f1["first-mile leg → vehicle assign → RECEIVED_TO_PORT (B)"]:::back + fq -->|"yes"| f1 --> fnext[" "]:::sys + fq -->|"no (drop at origin yard)"| fnext + end + + subgraph WHO["2 · Origin warehouse"] + direction TB + w1["receive → RECEIVED (B)"]:::back + w2{"inspection PASSED?"}:::dec + w2f["hold + re-inspect (B)"]:::back + w3["store → STORED → reserve (PAID) → RESERVED (B)"]:::back + w4["ready-for-loading → LOADED onto wagon (B)"]:::back + w1 --> w2 + w2 -->|"no"| w2f --> w2 + w2 -->|"yes"| w3 --> w4 + end + + subgraph RUN["3 · Train (ET yard → ET yard)"] + direction TB + s1["schedule DRAFT (direction DOMESTIC) → assign → finalize → SCHEDULED (B)"]:::back + r1["dispatch → DISPATCHED (unpaid EXPIRED) (B)"]:::back + r2["checkpoints → train_checkpoint_events; customer tracking (JWT) (P)(B)"]:::back + r3["arrive → ARRIVED (bookings IN_TRANSIT) (B)"]:::back + s1 --> r1 --> r2 --> r3 + end + + subgraph WHD["4 · Destination warehouse & delivery"] + direction TB + d1["auto-unload arrived → UNLOADED / RECEIVED (B)"]:::back + di{"inspection PASSED?"}:::dec + dif["hold + re-inspect (B)"]:::back + d2["READY_FOR_PICKUP (B)"]:::back + fee{"storage fees paid?"}:::dec + feex["gate-clearance blocked → customer pays (P)"]:::port + d3["release order → deliver (B)"]:::back + lq{"door delivery / last-mile?"}:::dec + l1["last-mile leg → DELIVERED (B)"]:::back + d4["customer approve-delivery → POD → inventory DELIVERED (P)(sys)"]:::sys + d1 --> di + di -->|"no"| dif --> di + di -->|"yes"| d2 --> fee + fee -->|"no"| feex --> fee + fee -->|"yes"| d3 --> lq + lq -->|"yes"| l1 --> d4 + lq -->|"no (pickup at yard)"| d4 + end + + done(["Booking COMPLETED (done)"]):::good + + pre --> FMD + fnext --> WHO + w4 --> RUN + r3 --> WHD + d4 --> done +``` + +--- + +## Cross-reference + +| Variant | Distinctive gate(s) | Terminal ends unique to it | +| --------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| §1 One-time · DOMESTIC | counter-sign → `FULLY_EXECUTED` (skips clearance **and** op-request; enters batch directly) | HARD BLOCK, price/intake/approval REJECTED, EXPIRED | +| §2 One-time · IMPORT/EXPORT | `AWAITING_DOCUMENTS` → review loop → phased ET/DJ → op-request (gate applies with **or without** customs) | + doc-query loop, CANCELLED (only from OPERATION_REQUEST_PENDING) | +| §3 Contract · Path A | `SELF_CLEARED` via ops-review; customer books direct | contract REJECTED/EXPIRED/RENEWAL, capacity/pairing block | +| §4 Contract · Path B | BookingRequest → GL creates booking; per-booking milestones | BookingRequest REJECTED/CANCELLED | +| §5 Export ops | first-mile → Djibouti unload → interchange handover | dispatched@Djibouti (success), interchange DISPUTED | +| §6 Import ops | import customs finalization gates → last-mile → gate-clearance | COMPLETED (+ empty-container-return chain) | +| §7 Intercity ops | ET→ET rail, no cross-border customs, optional mile legs | COMPLETED | + +Full endpoint tables & per-domain state machines: [`FREIGHT_SYSTEM_FLOW.md`](./FREIGHT_SYSTEM_FLOW.md). +Single all-in-one branching graph: [`FREIGHT_MASTER_FLOW.md`](./FREIGHT_MASTER_FLOW.md). diff --git a/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md b/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md new file mode 100644 index 000000000..ea68e6938 --- /dev/null +++ b/apps/edr-freight-api/docs/FREIGHT_MASTER_FLOW.md @@ -0,0 +1,260 @@ +# EDR Freight — One Master Flow (signup → every end) + +Single comprehensive graph of the entire freight business logic: from customer signup through every +branch to every terminal state. Actor-coloured, endpoint-labelled. + +**Legend** +(P) Portal (customer) · (B) Backoffice (staff) · (sys) System/auto (event, cron, service-to-service) +Rounded green = success end · Red = failure/terminal end · Diamond = decision · Hexagon = domain event. + +```mermaid +flowchart TD + classDef start fill:#e0e7ff,stroke:#3730a3,color:#111 + classDef port fill:#dbeafe,stroke:#2563eb,color:#111 + classDef back fill:#fef3c7,stroke:#b45309,color:#111 + classDef sys fill:#dcfce7,stroke:#15803d,color:#111 + classDef dec fill:#f8fafc,stroke:#475569,color:#111 + classDef good fill:#86efac,stroke:#166534,color:#062e14 + classDef bad fill:#fecaca,stroke:#991b1b,color:#450a0a + + %% ================= PHASE 1: IDENTITY & ONBOARDING ================= + S0(["Customer visits portal"]):::start + S0 --> S1["Signup via IAM
GET /auth/check-availability @Public
POST /otp/send + /otp/verify (P)"]:::port + S1 --> S2{"Identity proofing
(VeriFayda)?"}:::dec + S2 -->|"Yes"| S3["POST /fayda/verification/start →
/callback → /complete
upsert iam.users (verified_by=fayda) (P)"]:::port + S2 -->|"No"| S4 + S3 --> S4["POST /companies/onboarding/start
draft company (placeholder TIN, PENDING) (P)"]:::port + S4 --> S4b["Wizard: PATCH /profile, /onboarding-step,
upload license + docs
GET /onboarding/requirements (P)"]:::port + S4b --> S5["POST /companies/onboarding/complete
re-validate → company+profiles = PENDING (P)"]:::port + S5 --> S6{"Backoffice reviews profile
PATCH /company-profiles/:id/status (B)"}:::dec + S6 -->|"Reject / suspend"| S6x(["SUSPENDED / BLACKLISTED
cannot transact"]):::bad + S6 -->|"Approve"| S7["Mint reference (EX-#####),
company → ACTIVE (B)"]:::back + S7 --> S8{"Start a shipment?"}:::dec + S8 -->|"idle"| S8x(["No booking (dormant account)"]):::bad + S8 -->|"Yes"| MODE + + %% ================= PHASE 2: COMMERCIAL ORIGIN ================= + MODE{"Booking origin?"}:::dec + MODE -->|"One-time shipment"| B0 + MODE -->|"Framework agreement"| C1 + + %% ---- Contract track ---- + C1["Create CONTRACT DRAFT
POST /contracts (routes + cargo scope) (P)"]:::port + C1 --> C1d{"Abandon draft?"}:::dec + C1d -->|"delete"| C1x(["Contract removed (soft-delete)"]):::bad + C1d -->|"continue"| C2["generate-price → submit → SUBMITTED
freeze contract_rate_snapshots (P)"]:::port + C2 --> C2p{"Price changed?"}:::dec + C2p -->|"Yes"| C2c["confirm-submit → SUBMITTED (P)"]:::port + C2p -->|"No"| C3 + C2c --> C3 + C3{"Staff intake
POST /contracts/:id/staff/* (B)"}:::dec + C3 -->|"request-changes"| C3r["CHANGES_REQUESTED (B)"]:::back + C3r -->|"edit + resubmit"| C2 + C3 -->|"reject"| C3x(["Contract REJECTED"]):::bad + C3 -->|"accept"| C4["→ PENDING_APPROVAL
instantiate approval steps (B)"]:::back + C4 --> C4a{"Approval chain
line → director → ceo (B)"}:::dec + C4a -->|"rejectStep"| C4x(["Contract REJECTED"]):::bad + C4a -->|"all approve"| C5["generate-contract → CONTRACT_READY (B)"]:::back + C5 --> C6["Customer sign → SIGNED_CUSTOMER
POST /contracts/:id/contract/sign (P)"]:::port + C6 --> C7{"Staff counter-sign branch"}:::dec + C7 -->|"customs on"| C7a["AWAITING_CLEARANCE_DOCUMENTS (B)"]:::back + C7 -->|"GENERAL"| C7b["CONTRACT_ACTIVE (B)"]:::back + C7 -->|"ONE_TIME"| C7c["FULLY_EXECUTED (B)"]:::back + C7a --> CPATH + C7b --> CPATH + C7c --> CPATH + C7b -.->|"renew"| C7renew(["RENEWAL_DRAFT → new cycle"]):::bad + C7b -.->|"lapse"| C7exp(["Contract EXPIRED"]):::bad + + CPATH{"How are shipments booked
under the contract?"}:::dec + CPATH -->|"Path A: transport-only"| CPA["Ops review self-clearance
ops-review → ops-finalize → SELF_CLEARED (B)
then customer books direct
POST /contracts/:id/bookings (P)"]:::port + CPATH -->|"Path B: GENERAL + customs"| CPB["Customer submits BookingRequest
POST /contracts/:id/booking-requests (P)"]:::port + CPB --> CPBq{"GL queue decision (B)"}:::dec + CPBq -->|"reject / customer cancels"| CPBx(["BookingRequest REJECTED / CANCELLED"]):::bad + CPBq -->|"accept → GL creates booking"| B0u + CPA --> B0u["Booking created UNDER contract
(window + capacity draw-down check) (sys)"]:::sys + B0u --> BFLOW + + %% ---- One-time booking ---- + B0["Create BOOKING DRAFT
POST /bookings (reference, containers,
cargo modifiers, files) (P)"]:::port + B0 --> B0d{"Abandon draft?"}:::dec + B0d -->|"delete"| B0x(["Booking removed (soft-delete)"]):::bad + B0d -->|"continue"| BFLOW + B0 -.->|"consolidation"| BCONS(["PENDING_CONSOLIDATION
waits for partner shipment
(shares a wagon) → rejoins"]):::sys + BCONS -.-> BFLOW + + %% ================= PHASE 3: PRICING & SUBMIT ================= + BFLOW["Configure shipment
freight type + trade direction"]:::sys + BFLOW --> FT{"Freight type?"}:::dec + FT -->|"CONTAINER"| DIR + FT -->|"BULK"| DIR + DIR{"Trade direction?"}:::dec + DIR -->|"EXPORT"| B1 + DIR -->|"IMPORT"| B1 + DIR -->|"DOMESTIC"| B1 + B1["POST /bookings/:id/generate-price
rule-engine: LIVE rates + surcharges
HAZARDOUS / REEFER / OVERWEIGHT /
SHIPPING_LINE / CONSOLIDATION (P)"]:::port + B1 --> B1w{"weight-limit-rules check"}:::dec + B1w -->|"VGM > maxCapacity"| B1x(["HARD BLOCK (400)
cannot submit"]):::bad + B1w -->|"over maxVgm, within cap"| B1warn["warning + OVERWEIGHT surcharge"]:::sys + B1w -->|"ok"| B2 + B1warn --> B2 + B2["POST /bookings/:id/submit → SUBMITTED
create booking_rate_snapshot (P)"]:::port + B2 --> B2p{"Price moved since draft?"}:::dec + B2p -->|"Yes → PRICE_CHANGED_PENDING_CONFIRM"| B2c["confirm-submit → SUBMITTED (P)"]:::port + B2p -->|"No"| GOV + B2c --> GOV + B2 -.->|"customer rejects price"| B2x(["Booking REJECTED"]):::bad + + %% ================= PHASE 4: INTAKE & APPROVAL ================= + GOV{"Government booking?"}:::dec + GOV -->|"Yes"| GEXP["governmentExpedite →
PAID + schedulingStatus Eligible (B)"]:::back + GOV -->|"No (commercial)"| BI{"Staff intake
POST /bookings/:id/staff/* (B)"}:::dec + BI -->|"request-changes"| BIr["CHANGES_REQUESTED (B)"]:::back + BIr -->|"edit + resubmit"| B2 + BI -->|"reject"| BIx(["Booking REJECTED"]):::bad + BI -->|"accept"| BA["→ PENDING_APPROVAL
instantiate approval steps
(set validity window) (B)"]:::back + BA --> BAc{"Approval chain
LINE_STAFF → DIRECTOR → CEO (B)"}:::dec + BAc -->|"rejectStep"| BAx(["Booking REJECTED"]):::bad + BAc -->|"all approve → APPROVED"| BC1 + + %% ================= PHASE 5: CONTRACT DOC & SIGN ================= + BC1["contract/generate → CONTRACT_READY (B)"]:::back + BC1 --> BC2["Customer sign → SIGNED_CUSTOMER
POST /bookings/:id/contract/sign (P)"]:::port + BC2 --> BC3{"Staff counter-sign:
trade direction?"}:::dec + BC3 -->|"IMPORT / EXPORT
(clearance gate, even if customs off)"| CL1 + BC3 -->|"DOMESTIC"| FEXD["counter-sign → FULLY_EXECUTED
enqueue batch (skips clearance + op-request) (sys)(B)"]:::back + FEXD --> FEB + + %% ================= PHASE 6: CUSTOMS CLEARANCE ================= + CL1["AWAITING_DOCUMENTS → customer uploads
POST /bookings/:id/clearance/documents
→ DOCUMENTS_UNDER_REVIEW (P)"]:::port + CL1 --> CL2{"GL reviews each doc
clearance/review (B)"}:::dec + CL2 -->|"Query"| CL2q["doc queried → customer re-uploads (B)"]:::back + CL2q --> CL1 + CL2 -->|"Approve all"| CL3["finalize (100% approved) → CLEARANCE_READY (B)"]:::back + CL3 --> CLph["Phased ET/DJ (as applicable):
declaration → duty advise → duty slip →
transit permit → delivery/release order →
T1 docs/close → export release (sys)(B)"]:::back + CLph --> OP1 + + %% ================= PHASE 7: OPERATION REQUEST ================= + OP1["clearance/proceed: pick binding schedule day
→ OPERATION_REQUEST_PENDING (P)"]:::port + OP1 --> OP2{"Operations review
POST /bookings/:id/operation/review (B)"}:::dec + OP2 -->|"REQUEST_CHANGES"| OP2c["OPERATION_CHANGES_REQUESTED (B)"]:::back + OP2c --> OP1 + OP2 -->|"ACCEPT"| OPM{"Operation mode?"}:::dec + OPM -->|"TRAIN (rail)"| OP3t["invoice generated → FULLY_EXECUTED
(day batch pool) (B)"]:::back + OPM -->|"ROAD (truck)"| OP3r["invoice generated →
ROAD_DISPATCH_PENDING (billed by KM) (B)"]:::back + OP3t --> FEB["batch engine offers wagons →
SELECTED_FOR_BATCH (sys)"]:::sys + + %% ================= PHASE 8: INVOICE & PAYMENT ================= + GEXP --> SCH + FEB --> PAY1 + OP3r --> PAY1 + PAY1["Invoice (source=booking, INV-YYYYMMDD-#####, due +14d)
booking invoice starts DRAFT → ISSUED at operation-accept (sys)"]:::sys + PAY1 --> PAY2["Customer pays
POST /billing/my-invoices/:id/pay →
billing.payInvoice → payment-api initiate (P)"]:::port + PAY2 --> PAYp{"Provider result
(Telebirr/CBE/EBirr/Waafi/DMoney/Card/CAC)"}:::dec + PAYp -->|"FAILED"| PAYf["invoice stays OPEN (retry)"]:::sys + PAYf --> PAY2 + PAYp -->|"pay window lapses"| PAYexp(["Booking/reservation EXPIRED"]):::bad + PAYp -->|"SUCCEEDED"| PAYok["webhook → payment outbox →
POST /internal/payments/mark-paid →
settleByPaymentId → invoice PAID (sys)"]:::sys + PAYok --> EVT1{{"booking.invoice.paid event"}}:::sys + EVT1 --> PAID["Booking → PAID"]:::sys + PAYok -.->|"post-pay"| PAYref(["REFUNDED (terminal)"]):::bad + PAID --> FMQ0 + EVT1 -.->|"if EXPORT + first-mile"| FM1 + PAID --> SCH + + %% ================= PHASE 9: SCHEDULING & ALLOCATION ================= + SCH["schedulingStatus = Eligible (sys)"]:::sys + SCH --> SC2["Train schedule DRAFT
POST /train-scheduling/{container|bulk}/schedules
≥2 locomotives, derive direction (B)"]:::back + SC2 --> SC3["assign-bookings + run-allocation
(wagon_booking_allocations) (B)"]:::back + SC3 --> SC4["pin physical wagons → finalize → SCHEDULED
bookings → Scheduled (B)"]:::back + SC4 -.->|"cancel schedule"| SC4x["bookings back to Eligible (B)"]:::back + SC4x -.-> SC2 + SC4 -.->|"gov preempt / maintenance"| RESCH["reschedule: retained / displaced /
readmitted (priority: gov first) (B)"]:::back + RESCH -.-> SC3 + SC4 --> FMQ0 + + %% ================= PHASE 10: FIRST-MILE (export origin road leg) ================= + FMQ0{"EXPORT + first-mile requested?"}:::dec + FMQ0 -->|"Yes"| FM1["first-mile leg auto-created
firstMile.acceptBooking (READY_TO_TRANSIT) (sys)"]:::sys + FMQ0 -->|"No"| WO1 + FM1 --> FM2["setVehicles → vehicle BUSY, SMS driver,
fleet_events (B)"]:::back + FM2 --> FM3["IN_TRANSIT (needs vehicle) →
RECEIVED_TO_PORT (free vehicles) (B)"]:::back + FM3 --> FM4["first-mile invoice (FIRST_MILE fee) (B)"]:::back + FM4 --> WO1 + + %% ================= PHASE 11: WAREHOUSE ORIGIN (export) ================= + WO1["receive / bulkReceive → RECEIVED
capacity assert, GRN, notify owner (B)"]:::back + WO1 --> WO2{"inspection outcome"}:::dec + WO2 -->|"FAILED / NEEDS_REVIEW"| WO2f["hold + re-inspect (B)"]:::back + WO2f --> WO2 + WO2 -->|"PASSED"| WO3["store (allocation rule picks yard/zone) → STORED (B)"]:::back + WO3 --> WO4["reserve (booking PAID) → RESERVED (B)"]:::back + WO4 --> WO5["mark-ready-for-loading → READY_FOR_LOADING (B)"]:::back + WO5 --> WO6["load onto wagon → LOADED
(+ warehouse_loadings) (B)"]:::back + WO6 --> TR1 + + %% ================= PHASE 12: DISPATCH & TRANSIT ================= + TR1["dispatch → DISPATCHED
assign train_number, locos ASSIGNED,
window CLOSED, unpaid reservations EXPIRED (B)"]:::back + TR1 --> TR2["record checkpoints (corridor stations) →
train_checkpoint_events (B)"]:::back + TR2 --> TRC["Customer tracking page
GET /tracking/:consignmentId (JWT) (P)"]:::port + TR2 --> TR3["arrive (final checkpoint) → ARRIVED
bookings IN_TRANSIT, locos+wagons freed,
warehouse arrival automation (B)"]:::back + TR3 --> WD1 + + %% ================= PHASE 13: WAREHOUSE DEST + IMPORT CUSTOMS ================= + WD1["destination warehouse: auto-unload arrived
→ UNLOADED / RECEIVED (B)"]:::back + WD1 --> WD2{"inspection PASSED?"}:::dec + WD2 -->|"No"| WD2f["hold + re-inspect / incident report (B)"]:::back + WD2f --> WD2 + WD2 -->|"Yes"| DIRW{"trade direction at destination"}:::dec + DIRW -->|"IMPORT"| WD3["READY_FOR_PICKUP (B)"]:::back + DIRW -->|"EXPORT (Djibouti)"| WDX["auto-unload-at-djibouti → DISPATCHED /
UNLOADED_AT_DJIBOUTI_PORT (B)"]:::back + WD3 --> IMP1["Import customs finalization:
upload docs → declaration → notify duties →
duties paid (needs slip) → assign risk →
release-permitted (all gates) (sys)(B)"]:::back + IMP1 --> LMQ + WDX --> ICD["interchange document (handover manifest)
generate-from-schedule → GENERATED →
ACKNOWLEDGED / DISPUTED (B)"]:::back + ICD --> DE1 + + %% ================= PHASE 14: LAST-MILE (import destination road leg) ================= + LMQ{"IMPORT + last-mile requested?"}:::dec + LMQ -->|"Yes"| LM1["last-mile leg auto-created
(IMPORT inspection PASSED only) (sys)"]:::sys + LMQ -->|"No"| DE1 + LM1 --> LM2["setVehicles → IN_TRANSIT → DELIVERED
(free vehicles) (B)"]:::back + LM2 --> LM3["last-mile invoice (LAST_MILE fee) (B)"]:::back + LM3 --> DE1 + + %% ================= PHASE 15: DELIVERY & COMPLETION ================= + DE1{"Warehouse/storage fees fully PAID?"}:::dec + DE1 -->|"No"| DE1x["gate-clearance BLOCKED
findBlockingInvoice / assertClearanceAllowed (B)"]:::back + DE1x --> DE1p["Customer pays storage/demurrage
warehouse-fee-invoices/:id/pay-online (P)"]:::port + DE1p --> DE1 + DE1 -->|"Yes"| DE2["release order (DO) + gate-clearance →
deliver (B)"]:::back + DE2 --> DE3["Customer approves delivery (saved signature)
POST /warehouse-inventory/bookings/:id/approve-delivery (P)"]:::port + DE3 --> DE4["inventory DELIVERED, POD to cargo,
container freed, capacity released (sys)"]:::sys + DE4 --> DONE(["Booking COMPLETED (done)
operations/complete"]):::good + + %% ================= GLOBAL EXITS ================= + GEXIT(["CANCELLED — POST /bookings/:id/cancel (staff-only)
ONLY from DRAFT, SUBMITTED, PRICE_CHANGED_PENDING_CONFIRM,
CHANGES_REQUESTED, PENDING_APPROVAL, CONTRACT_READY,
OPERATION_REQUEST_PENDING"]):::bad + B2 -.->|"cancel"| GEXIT + BA -.->|"cancel"| GEXIT + OP1 -.->|"cancel"| GEXIT +``` + +--- + +## Reading notes + +- **Solid arrows** = the primary progression. **Dotted arrows** = optional / event-driven / exit hops + (consolidation, reschedule, cancel, the first-mile event branch). +- **Every terminal** is a rounded red or green node: + `SUSPENDED/BLACKLISTED`, `dormant`, `booking/contract removed`, `REJECTED` (customer price, staff intake, + approval step, GL booking-request), `HARD BLOCK` (VGM), `EXPIRED` (pay window / contract), `REFUNDED`, + `RENEWAL_DRAFT`, `CANCELLED`, and the single success end **`COMPLETED (done) `**. +- **Branch axes** captured: identity-proofing (Fayda / skip), origin (one-time vs contract Path A / Path B), + freight type (container / bulk), trade direction (import / export / domestic), customer type + (government expedite vs commercial approval chain), customs on/off, operation mode (rail / road), + provider outcome (success / fail-retry / expire / refund), first-mile (export), last-mile (import), + Djibouti export unload + interchange handover. +- **Actors**: (P) portal customer, (B) backoffice staff, (sys) system (events like `booking.invoice.paid`, + `warehouse.invoice.paid`, auto leg creation, payment webhook/outbox settlement). + +> Endpoint-level tables, per-domain state machines, and the payment-microservice sequence live in +> [`FREIGHT_SYSTEM_FLOW.md`](./FREIGHT_SYSTEM_FLOW.md). This file is the single end-to-end picture. diff --git a/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md b/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md new file mode 100644 index 000000000..a6fd8bbeb --- /dev/null +++ b/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md @@ -0,0 +1,872 @@ +# EDR Freight System — End-to-End Flow Map (FE → Backend) + +> Comprehensive flow documentation for the **Freight Management** slice of the EDR Platform: +> the two frontends (**Customer Portal** + **Backoffice**) and how every action reaches the +> **freight API**, the **payment microservice**, Postgres, MinIO and RabbitMQ. +> +> Generated by mapping all ~45 API modules (~300 routes / 60 controllers) against both FE apps. +> Layered on purpose: a **master business-flow** view first, then **per-domain endpoint-level** charts. + +--- + +## 0. How to read this document + +- **Master view** (§3) is the freight journey at business altitude — state transitions across domains. +- **Per-domain views** (§5–§13) drop to endpoint altitude — every `Method /path`, its guard, and the FE caller. +- Diagrams are [Mermaid](https://mermaid.js.org). GitHub / VS Code (Markdown Preview Mermaid) render them inline. +- **Legend** used throughout: + - (P) **Portal** = `@edr/freight-portal` (customer users, port `5173`) + - (B) **Backoffice** = `@edr/freight-backoffice` (EDR employees, port `5183`) + - (green) **API** = `@edr/freight-api` (NestJS, port `3001`) + - (red) **Payment** = `@edr/payment-api` (NestJS microservice, port `3003`) + +--- + +## 1. System architecture & apps + +| Layer | Package | Port | Base URL / notes | +| ----- | ------- | ---- | ---------------- | +| Customer Portal | `@edr/freight-portal` | 5173 | axios `utils/api.ts`, baseURL `VITE_BASE_API_URL`, React Query | +| Backoffice | `@edr/freight-backoffice` | 5183 | axios `auth/http.ts`, baseURL `${VITE_BASE_API_URL}/api`, React Query | +| Freight API | `@edr/freight-api` | 3001 | NestJS, global prefix `/api`, Postgres schema `freight` | +| Payment API | `@edr/payment-api` | 3003 | NestJS, separate schema `edr_payment`, providers in `@edr/payment-providers` | +| Datastores | — | 5433 | Postgres `edr_freight`; MinIO (files); RabbitMQ (SMS/email/payment events) | + +```mermaid +flowchart LR + subgraph FE["Frontends (React + Vite + React Query)"] + P["(P) Customer Portal :5173"] + B["(B) Backoffice :5183"] + end + + subgraph EDGE["Freight API edge (NestJS :3001)"] + direction TB + CORS["CORS (reflect origin,
credentials, IAM headers)"] + JWT["JwtGuard (global APP_GUARD)
+ HasActiveDelegationGuard"] + PERM["FreightPermissionGuard
(per-route perms)"] + VP["ValidationPipe
(implicitConversion OFF)"] + RTI["ResponseTransformInterceptor
→ { success, data }"] + HEF["HttpExceptionFilter"] + end + + subgraph DOM["Domain modules (~45)"] + direction TB + D1["Identity / Companies / Auth"] + D2["Bookings + Contracts"] + D3["Rule Engine"] + D4["Train Scheduling"] + D5["Warehouse"] + D6["Field Ops (mile/import)"] + D7["Billing"] + D8["Notifications / Inbox"] + end + + subgraph INFRA["Backing services"] + PG[("Postgres
schema: freight")] + MINIO[("MinIO
object store")] + MQ{{"RabbitMQ"}} + PAY["(red) Payment API :3003
schema: edr_payment"] + end + + P -->|"Bearer token (cookie)
axios interceptor"| CORS + B -->|"Bearer token (cookie)
axios interceptor"| CORS + CORS --> JWT --> PERM --> VP --> DOM + DOM --> RTI + DOM --> PG + DOM --> MINIO + DOM -->|"send-sms / send-email"| MQ + DOM -->|"POST /payments/initiate
x-service-token"| PAY + PAY -->|"payment.succeeded webhook
→ /api/internal/payments/mark-paid"| DOM + PAY -.->|"or RabbitMQ payment events"| MQ --> DOM + D8 -->|"socket.io push"| FE +``` + +--- + +## 2. The request pipeline (every FE call) + +Both frontends wrap each API method in an `endpoint(service, action, fn)` helper feeding React Query. +The axios client attaches the JWT and transparently refreshes on `401`. + +```mermaid +sequenceDiagram + autonumber + participant C as React component / hook + participant Q as React Query + participant AX as axios client (interceptors) + participant API as Freight API (:3001) + participant DB as Postgres + + C->>Q: useQuery / useMutation(endpoint) + Q->>AX: call(input) + AX->>AX: request interceptor →
Authorization: Bearer [auth-token cookie] + AX->>API: HTTP /api// + API->>API: CORS → JwtGuard → PermissionGuard → ValidationPipe + alt token valid & permitted + API->>DB: repository query (schema freight) + DB-->>API: rows + API->>API: ResponseTransformInterceptor → { success:true, data } + API-->>AX: 200 { success, data } + AX->>AX: backoffice interceptor unwraps .data
(portal returns raw envelope; callers read .data) + AX-->>Q: payload + Q-->>C: data (+ cache by queryKey) + else 401 Unauthorized + API-->>AX: 401 + AX->>API: POST /api/auth/refresh-token { refreshToken cookie } + alt refresh ok + API-->>AX: { token, refreshToken } + AX->>AX: set cookies, retry original request (_retry) + AX-->>Q: payload + else refresh fails + AX->>AX: clear cookies →
portal: reject · backoffice: redirect /auth + end + else 4xx/5xx + API->>API: HttpExceptionFilter → { success:false, message } + API-->>AX: error + AX-->>Q: throw → onError toast + end +``` + +**Auth model (important):** `SharedAuthModule` (`@tria-plc/api-common`) registers `JwtGuard` + +`HasActiveDelegationGuard` as **global `APP_GUARD`s** — *every* route is JWT-protected unless it +carries `@Public()`. Fine-grained `FreightPermissionGuard([perm])` decorators add permission checks +on staff routes. Explicitly **public** endpoints: `GET /api/files/:fileId`, `POST /api/otp/{send,verify}`, +`GET /api/auth/check-availability`, the `fayda/verification/*` + `/callback` endpoints, +`GET /api/payments/{checkout,receipt/:orderId}`, and the service-to-service `POST /api/internal/payments/mark-paid`. +Real login / JWT issuance lives in the **external IAM package**, not this repo. (Note: `@edr/api-common`'s +`@Public` and `@tria-plc/api-common`'s `@IsPublic` both set the same `"isPublic"` metadata key the guard reads.) + +--- + +## 3. MASTER FLOW — the freight journey (business altitude) + +This is the spine. A shipment travels **customer intake → pricing → approval → contract → customs +clearance → operation request → payment → scheduling/allocation → first-mile → warehouse → train → +arrival → warehouse → last-mile → delivery → tracking**, branching on _container vs bulk_, +_import vs export_, _commercial vs government_, and _one-time vs contract_. + +```mermaid +flowchart TD + start(["Customer signs up
(IAM + Fayda + company onboarding)"]) --> mode{"Booking origin?"} + + mode -->|"One-time shipment"| draft["Create BOOKING (DRAFT) (P)"] + mode -->|"Framework agreement"| cdraft["Create CONTRACT (DRAFT) (P)"] + + %% Contract branch + cdraft --> cprice["Generate price → Submit → Approvals → Sign"] + cprice --> cactive{"Contract type / customs?"} + cactive -->|"Path A: transport-only,
self-clearance"| bookA["Customer books directly
POST /contracts/:id/bookings (P)"] + cactive -->|"Path B: GENERAL + customs"| breq["Customer submits BookingRequest (P)
→ GL accepts → GL creates booking (B)"] + bookA --> draft2["Booking created under contract"] + breq --> draft2 + + %% Booking spine + draft --> price["Generate price (P)
(rule-engine: rates + surcharges)"] + draft2 --> price + price --> submit["Submit → SUBMITTED (P)"] + submit --> intake["Staff accept → PENDING_APPROVAL (B)
(instantiate approval steps)"] + intake --> appr["Approval chain:
LINE_STAFF → DIRECTOR → CEO (B)"] + appr --> gen["Generate contract → CONTRACT_READY (B)"] + gen --> sign["Customer signs (P) → Staff counter-signs (B)"] + sign --> customs{"customsClearingEnabled?"} + + customs -->|"No"| ready["FULLY_EXECUTED"] + customs -->|"Yes"| clr["AWAITING_DOCUMENTS →
customer uploads docs (P) →
GL review/finalize (B) → CLEARANCE_READY"] + clr --> opreq["Customer requests operation (P)
(binding schedule date)"] + ready --> opreq + opreq --> oprev{"Operations review (B)"} + oprev -->|"Request changes"| clr + oprev -->|"Accept: train"| inv["Invoice generated →
enters day batch pool"] + oprev -->|"Accept: road/truck"| road["ROAD_DISPATCH_PENDING
(billed by KM)"] + + inv --> pay["Customer pays invoice (P)
→ (red) gateway → PAID"] + road --> pay + pay --> gov{"Government?"} + gov -->|"Yes"| expedite["governmentExpedite → PAID/Eligible (B)"] + gov -->|"No"| eligible["schedulingStatus = Eligible"] + expedite --> sched + eligible --> sched + + subgraph JOURNEY["Physical movement"] + direction TB + sched["Train schedule: DRAFT → assign bookings →
allocate wagons → finalize → SCHEDULED (B)"] + fm{"EXPORT + first-mile?"} + fmleg["First-mile leg: truck pickup →
RECEIVED_TO_PORT (B)"] + wh_in["Warehouse receive → inspect →
STORED → READY_FOR_LOADING → LOADED (B)"] + disp["Dispatch train → DISPATCHED
(locos ASSIGNED, unpaid EXPIRED) (B)"] + track["Checkpoints logged →
tracking events (customer sees) (P)"] + arrive["Arrive → ARRIVED
(bookings IN_TRANSIT, wagons freed) (B)"] + wh_out["Destination warehouse:
unload → inspect → READY_FOR_PICKUP (B)"] + lm{"IMPORT + last-mile?"} + lmleg["Last-mile leg: truck delivery →
DELIVERED (B)"] + imp["Import customs finalization
(declaration/duty/risk/release) (B)"] + end + + sched --> fm + fm -->|"Yes"| fmleg --> wh_in + fm -->|"No"| wh_in + wh_in --> disp --> track --> arrive --> wh_out + wh_out --> imp + imp --> lm + lm -->|"Yes"| lmleg --> deliver + lm -->|"No"| deliver + + deliver["Release order + gate clearance
(warehouse fees must be PAID) (B)"] + deliver --> pod["Customer approves delivery /
POD captured → DELIVERED (P)"] + pod --> complete(["Booking COMPLETED"]) + + track -.->|"public timeline"| custview["Customer tracking page (P)"] +``` + +**Cross-cutting truth:** almost every hop between domains is fired by a **domain event** (`@OnEvent`), +not a direct call. See §13 for the event web (e.g. `booking.invoice.paid` → advance booking → auto-create +first-mile; `warehouse inspection PASSED` → auto-create last-mile; payment webhook → settle invoice). + +--- + +## 4. Domain map (where each module lives in the journey) + +```mermaid +flowchart LR + subgraph INTAKE["Intake & Identity"] + auth[auth / otp / verifayda] + comp[companies] + sig[signatures] + end + subgraph COMMERCIAL["Commercial"] + bk[bookings] + ct[contracts] + re[rule-engine] + end + subgraph OPS["Rail Operations"] + ts[train-scheduling] + resch[scheduling-reschedule] + fleet[trains/wagons/locomotives/wagon-types] + track[tracking] + end + subgraph GROUND["Ground Operations"] + fmlm[first-mile / last-mile] + imp[import-operations / interchange-documents] + dv[drivers / vehicles] + end + subgraph WH["Warehouse"] + wh[warehouses + inventory + loadings + inspection + rules] + whinv[warehouse-fee-invoices] + end + subgraph MONEY["Money"] + bill[billing] + paymod[payment] + payapi[edr-payment-api] + end + subgraph PLATFORM["Platform / Config"] + dd[dropdown-settings] + fus[file-upload-settings] + files[files / minio] + fac[facilities / routes] + notif[notifications / inbox] + ov[overview] + bo[backoffice/IAM] + end + + INTAKE --> COMMERCIAL --> OPS --> GROUND --> WH --> MONEY + re -.->|rates/approval/priority| COMMERCIAL + COMMERCIAL -.->|invoices| MONEY + WH -.->|storage invoices| MONEY + GROUND -.->|mile invoices| MONEY + PLATFORM -.-> COMMERCIAL +``` + +--- + +## 5. Identity, Access & Onboarding + +### 5.1 Flow + +```mermaid +flowchart TD + su["Signup (external IAM)"] --> chk["GET /auth/check-availability (P)
(email/phone taken?) @Public"] + chk --> otp["POST /otp/send + /otp/verify (P) @Public"] + otp --> fayda{"Identity proofing?"} + fayda -->|"VeriFayda 2.0"| fstart["POST /fayda/verification/start
→ eSignet authorize URL"] + fstart --> fcb["Fayda redirect → GET /callback (ack)
→ GET /fayda/verification/complete
(PKCE code exchange → upsert iam.users)"] + fcb --> onb + fayda -->|"skip"| onb + + onb["POST /companies/onboarding/start (P)
(draft company, placeholder TIN, PENDING)"] + onb --> wiz["Wizard saves incrementally (P):
PATCH /profile · /onboarding-step ·
upload license & docs"] + wiz --> reqs["GET /companies/onboarding/requirements
(server-driven checklist)"] + reqs --> comp["POST /companies/onboarding/complete
→ profiles + company = PENDING"] + comp --> review["Backoffice approves (B):
PATCH /companies/company-profiles/:id/status
→ mint reference, company → ACTIVE"] + review --> book(["Can now book
(assertCompanyProfileApprovedForBooking)"]) +``` + +Company status: `PENDING → ACTIVE` (+ `SUSPENDED`, `BLACKLISTED`). Nationality (`ethiopian`/`foreign`) +drives the required document set. Booking guards elsewhere `403` if the acting profile is not `ACTIVE`. + +### 5.2 Endpoints + +| Method | Path | Action | Guard | FE | +| --- | --- | --- | --- | --- | +| GET | `/api/auth/check-availability` | email/phone dedupe | `@Public` | (P) auth.service | +| GET | `/api/me` | enriched profile + `permissionKeys` + catalog | JwtGuard | (B) auth/api | +| POST | `/api/otp/send` · `/api/otp/verify` | send / verify 6-digit code | `@Public` | (P) auth.service | +| POST | `/api/fayda/verification/start` | start eSignet session (PKCE) | `@Public` + OptionalJwt | (B) verifayda.service | +| GET | `/api/fayda/verification/complete` | code→identity, upsert `iam.users` | `@Public` | (B) verifayda.service | +| GET | `/api/fayda/verification/status` | current user's Fayda link | JwtGuard | — | +| GET | `/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage | +| GET·PUT | `/api/me/signature` | reusable signature (MinIO, base64) | JwtGuard | (P)(B) signatures.service | +| GET | `/api/test_user1` · `/api/test_user2` | permission-guard demo | `PermissionGuard` | (B) demo pages | +| GET | `/api/companies/getInfo` · `/profile` · `/dashboard` | company info / KPIs | JwtGuard | (P) companies.service | +| POST | `/api/companies/fetch-etrade-info` | pull reg data by TIN | JwtGuard | (P) | +| PATCH | `/api/companies/profile` | update profile (JSONB attrs) | JwtGuard | (P) | +| POST | `/api/companies/onboarding/start` · `/complete` | onboarding lifecycle | JwtGuard | (P) | +| PATCH | `/api/companies/onboarding-step` · `/active-mode` | wizard state / mode switch | JwtGuard | (P) | +| GET | `/api/companies/onboarding/requirements` | server checklist | JwtGuard | (P) | +| POST·GET | `/api/companies/company-profiles/:id/license` | license file up/list | JwtGuard | (P) | +| POST | `/api/companies/company-profiles` · `/company-profile` | add operational profile(s) | JwtGuard | (P) | +| POST·GET·PATCH·DELETE | `/api/companies` (+`/:id`) | company CRUD | `@FreightAdmin` writes | (B) customers.service | +| GET | `/api/companies/stats` | KPI strip | JwtGuard | (B) | +| PATCH | `/api/companies/company-profiles/:id/status` | approve profile → mint ref | `@FreightAdmin` | (B) | +| GET·POST | `/api/companies/:companyId/documents` | company docs (signed URLs) | JwtGuard | (P)(B) | +| GET | `/api/notifications` (+`/unread-count`) | inbox list / unread | JwtGuard | (P)(B) notificationsApi | +| PATCH·POST | `/api/notifications/:id/read` · `/read-all` | mark read (WS re-emit) | JwtGuard | (P)(B) | +| WS | `NOTIFICATION_WS_NAMESPACE` | live push (server→client) | WsAuth handshake | (P)(B) useNotificationSocket | + +`notifications` module = SMS/email transport over RabbitMQ (no HTTP routes). Inbox fan-out writes one +`notifications` row + WS push per recipient; **HIGH** priority also emails + SMSs (best-effort). + +--- + +## 6. Bookings — the core state machine + +```mermaid +stateDiagram-v2 + [*] --> DRAFT: create (P) (staff commercial auto price+submit) + DRAFT --> SUBMITTED: submit (P) + DRAFT --> PRICE_CHANGED_PENDING_CONFIRM: price moved + PRICE_CHANGED_PENDING_CONFIRM --> SUBMITTED: confirm-submit (P) + SUBMITTED --> REJECTED: customer reject (price) (P) + SUBMITTED --> CHANGES_REQUESTED: staff request-changes (B) + CHANGES_REQUESTED --> SUBMITTED: edit + resubmit (P) + SUBMITTED --> PENDING_APPROVAL: staff/accept (B) (instantiate approval steps) + SUBMITTED --> REJECTED: staff/reject (B) + PENDING_APPROVAL --> APPROVED_PENDING_SIGNATURE: line-staff approve (B) + APPROVED_PENDING_SIGNATURE --> APPROVED: director + ceo approve (B) + PENDING_APPROVAL --> REJECTED: rejectStep (B) + APPROVED --> CONTRACT_READY: contract/generate (B) + CONTRACT_READY --> SIGNED_CUSTOMER: customer sign (P) + SIGNED_CUSTOMER --> AWAITING_DOCUMENTS: counter-sign, IMPORT/EXPORT (B) + SIGNED_CUSTOMER --> FULLY_EXECUTED: counter-sign, DOMESTIC (B) + AWAITING_DOCUMENTS --> DOCUMENTS_UNDER_REVIEW: clearance/documents (P) + DOCUMENTS_UNDER_REVIEW --> CLEARANCE_READY: review + finalize (B) + CLEARANCE_READY --> OPERATION_REQUEST_PENDING: clearance/proceed (P) + OPERATION_REQUEST_PENDING --> OPERATION_CHANGES_REQUESTED: review=REQUEST_CHANGES (B) + OPERATION_CHANGES_REQUESTED --> OPERATION_REQUEST_PENDING: re-proceed (P) + OPERATION_REQUEST_PENDING --> ROAD_DISPATCH_PENDING: accept=road (B) (invoice, KM billed) + OPERATION_REQUEST_PENDING --> FULLY_EXECUTED: accept=train (B) (invoice) + FULLY_EXECUTED --> SELECTED_FOR_BATCH: batch engine offers wagons (sys) + SELECTED_FOR_BATCH --> PAID: pay invoice (P) + ROAD_DISPATCH_PENDING --> PAID: pay invoice (P) + PAID --> IN_TRANSIT: operations/start-transit (B) + IN_TRANSIT --> COMPLETED: operations/complete (B) + DRAFT --> PENDING_CONSOLIDATION: requestConsolidation (usually set on submit) + DRAFT --> CANCELLED: cancel (B) + PENDING_APPROVAL --> CANCELLED: cancel (B) + OPERATION_REQUEST_PENDING --> CANCELLED: cancel (B) + note right of PAID + governmentExpedite (B) jumps + gov bookings straight to PAID/Eligible + end note + note left of CANCELLED + cancel is staff-only and allowed ONLY from + DRAFT, SUBMITTED, PRICE_CHANGED_PENDING_CONFIRM, + CHANGES_REQUESTED, PENDING_APPROVAL, + CONTRACT_READY, OPERATION_REQUEST_PENDING + end note + REJECTED --> [*] + CANCELLED --> [*] + COMPLETED --> [*] +``` + +> **Accuracy notes (verified against code):** +> - Counter-sign branch is keyed on **trade direction**, not a customs flag: `IMPORT`/`EXPORT` → +> `AWAITING_DOCUMENTS` (even when customs is off — a lighter "without customs" clearance doc-set still +> applies); only `DOMESTIC` → `FULLY_EXECUTED`. +> - **Domestic** bookings skip the operation-request/clearance phase entirely — counter-sign enqueues +> them straight into the scheduling batch pipeline (`enqueueScheduleProcessing`). +> - Train `operation/review` accept sets **`FULLY_EXECUTED`** (day batch holding pool). `SELECTED_FOR_BATCH` +> is set **later** by the batch engine when a wagon offer/reservation is made — not at accept. +> - `APPROVED_PENDING_SIGNATURE` is a real intermediate (line-staff approves first, then director+CEO). +> - Full `BOOKING_STATUSES` has 35 values; this diagram is the live commercial subset (legacy statuses +> like `WAGON_ASSIGNED`, `INVOICED`, `PNR_GENERATED` are unused). + +Key endpoints (customer (P) / staff (B), `bk:` = `bookings:` perms): + +| Method | Path | Action | Guard | +| --- | --- | --- | --- | +| POST | `/api/bookings` | create | in-body (gov→`bk:staff_accept`) | +| PATCH·DELETE | `/api/bookings/:id` | update / soft-delete DRAFT | company-scoped | +| POST | `/api/bookings/:id/generate-price` | price preview (rule-engine) | company-scoped | +| POST | `/api/bookings/:id/submit` · `/confirm-submit` | submit (rate snapshot) | company-scoped | +| POST | `/api/bookings/:id/reject` | customer rejects price | company-scoped | +| POST | `/api/bookings/:id/staff/accept` | → PENDING_APPROVAL | `bk:staff_accept` | +| POST | `/api/bookings/:id/staff/request-changes` · `/staff/reject` | intake outcomes | `bk:request_changes` / `bk:reject` | +| POST | `/api/bookings/:id/approval-steps/:stepId/approve` · `/reject` | approval chain | role perms | +| POST | `/api/bookings/:id/contract/generate` | → CONTRACT_READY | `bk:generate_contract` | +| POST | `/api/bookings/:id/contract/sign` · `/marketing/approve` | sign / counter-sign | in-body / `bk:sign_staff` | +| GET | `/api/bookings/:id/contract/{view,document}` | HTML / PDF | company-scoped | +| GET·POST | `/api/bookings/:id/clearance` (+`/documents`,`/review`,`/finalize`,…) | customs clearance | `bk:*` / `ct:clearance_*` | +| POST | `/api/bookings/:id/clearance/proceed` | request operation | company-scoped | +| POST | `/api/bookings/:id/operation/review` | accept/changes (+invoice) | `bk:operations` | +| POST | `/api/bookings/:id/government-expedite` | gov shortcut → PAID | `bk:staff_accept` | +| POST | `/api/bookings/:id/operations/start-transit` · `/complete` | transit lifecycle | `bk:operations` | +| POST | `/api/bookings/:id/allocate-containers` | assign containers↔vehicles | `allocation:manage` | +| POST·GET·DELETE | `/api/bookings/:id/consolidation` | pair/unpair wagon-share | company-scoped | +| POST | `/api/bookings/:id/customer-truck-assignment` | external truck for pickup | company-scoped | +| GET | `/api/bookings/:id/tracking` | consignment + tracking events | company-scoped | +| GET | `/api/bookings` · `/list-summary` · `/queues/:queue` | lists (branch by perm) | `bk:view` / `bk:clearance_view` | + +--- + +## 7. Contracts — framework agreements & booking paths + +```mermaid +stateDiagram-v2 + [*] --> DRAFT: create (P) + DRAFT --> SUBMITTED: submit (P) (freeze rate snapshots) + SUBMITTED --> PENDING_APPROVAL: staff/accept (B) + SUBMITTED --> CHANGES_REQUESTED: request-changes (B) + CHANGES_REQUESTED --> SUBMITTED: resubmit (P) + SUBMITTED --> REJECTED: reject (B) + PENDING_APPROVAL --> APPROVED: approve chain (B) + APPROVED --> CONTRACT_READY: contract/generate (B) + CONTRACT_READY --> SIGNED_CUSTOMER: customer sign (P) + SIGNED_CUSTOMER --> AWAITING_CLEARANCE_DOCUMENTS: counter-sign, IMPORT/EXPORT (not GENERAL+customs) (B) + SIGNED_CUSTOMER --> CONTRACT_ACTIVE: counter-sign, GENERAL+customs or DOMESTIC (B) + SIGNED_CUSTOMER --> FULLY_EXECUTED: counter-sign, ONE_TIME DOMESTIC (B) + CONTRACT_ACTIVE --> [*]: renew → RENEWAL_DRAFT +``` + +> Diagram shows the live path; `CONTRACT_STATUSES` has **24 values** total (adds APPROVED_PENDING_SIGNATURE, +> CLEARANCE_UNDER_REVIEW, CLEARANCE_READY_FOR_BOOKING, ACTIVE_SHIPMENT_IN_PROGRESS, CONTRACT_CLOSED, CANCELLED, +> RENEWAL_SUBMITTED/PENDING_APPROVAL, AMENDMENTS_PROPOSED, ARCHIVED — see §15). **GENERAL+customs skips the +> contract clearance cycle → `CONTRACT_ACTIVE` directly** (clearance runs per-booking, Path B); only IMPORT/EXPORT +> one-time or self-clearance opens the contract-level `AWAITING_CLEARANCE_DOCUMENTS` cycle. + +**Two ways a contract spawns shipment bookings:** + +```mermaid +flowchart TD + active["Contract ACTIVE / SELF_CLEARED"] --> path{"Contract path"} + path -->|"Path A: transport-only"| a1["Ops reviews self-clearance docs (B)
(ops-review → ops-finalize → SELF_CLEARED)"] + a1 --> a2["Customer books directly (P)
POST /contracts/:id/bookings"] + path -->|"Path B: GENERAL + customs"| b1["Customer submits BookingRequest (P)
POST /contracts/:id/booking-requests"] + b1 --> b2["GL queue → accept (B)
(ct:create_booking) → GL creates booking"] + a2 --> cap["createUnderContract:
window + capacity draw-down check"] + b2 --> cap + cap --> spawn(["New Booking under contract
(bookings.contract_id)"]) +``` + +Contract clearance is **phased** (ET vs DJ permissioned): declaration → duty advice → duty slip → +transit permit → delivery/release order → T1 docs/close → final invoice → incidents. `clearanceStatus` +is a separate axis (`AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY_FOR_BOOKING / +SELF_CLEARED / ACTIVE_SHIPMENT_IN_PROGRESS`). ~60 contract routes total. Per-booking clearance +actions (`duty`, `risk`, `t1-documents`, `t1-close`, `final-invoice`, `second-duty`, `transport-document`, +`station-assign`, `incidents`) hang off `/api/contracts/bookings/:bookingId/*`; note **`declaration` is a +contract-level route** (`/api/contracts/:id/clearance/declaration`), not a booking-scoped one. + +### Consignment / Cargo / Container (fleet-side records) + +| Entity | Controller | Guard | Lifecycle | Callers | +| --- | --- | --- | --- | --- | +| Consignment | `/api/consignments` (create/list/get) | `FleetView`/`Manage` | `Pending → …` (downstream shipment record; booking `:id/tracking` reads it) | (P) read-only | +| Cargo | `/api/cargoes` (+ `/load` `/unload` `/deliver`) | `FleetView`/`Manage` | `PENDING → LOADED → UNLOADED / DELIVERED` (POD) | (B) cargoService | +| Container | `/api/containers` (+ `/assign-wagon` `/unassign-wagon`) | `FleetView`/`Manage` | `AVAILABLE → LOADED → IN_TRANSIT …` | (B) containerService | + +--- + +## 8. Rule Engine — the pricing & approval brain + +`RuleEngineService.evaluate(input)` is injected into bookings & contracts pricing. One pass pulls all +rule tables and returns `{ priorityScore, appliedModifiers, containerWeightResults, warnings, +hardBlocked, requiresDirectorApproval }`. + +```mermaid +flowchart TD + ev["evaluate(BookingEvaluationInput)"] --> rates["rates.findLiveRates()
(only LIVE)"] + rates --> sur{"surcharge triggers"} + sur -->|"HAZARDOUS/REEFER/OVERWEIGHT/
SHIPPING_LINE/CONSOLIDATION"| mods["appliedModifiers →
surcharge line-items"] + mods --> snap["snapshotRates → booking_rate_snapshots
(freeze exact rate used)"] + ev --> wlr["weight-limit-rules"] + wlr --> block{"VGM > maxCapacityTons?"} + block -->|Yes| hard["HARD BLOCK (400)"] + block -->|"over maxVgm, within cap"| warn["warning + OVERWEIGHT surcharge"] + ev --> appr["approval-rules →
instantiateApprovalSteps
(requiredRole/blocksRole/stepOrder)"] + ev --> prio["priority-configs + serviceType bonus +
GOVERNMENT_PRIORITY_BONUS → priorityScore"] + prio --> schedorder["train-scheduling orders by priorityScore"] +``` + +**Rate lifecycle:** `DRAFT → (submit) PENDING_APPROVAL → (CEO approve) LIVE`. Only LIVE rates apply. + +| Resource | Base path | Guard | Notes | +| --- | --- | --- | --- | +| approval-rules | `/api/approval-rules` (+`/chain`,`/reorder`,`/:id/move-order`) | `ruleEngine.view/manage(approval-rules)` | ordered chain | +| cargo-types | `/api/cargo-types` | `…(cargo-types)` | portal reads via `bookings/reference-data` | +| container-types | `/api/container-types` | `…(container-types)` | portal indirect | +| priority-configs | `/api/priority-configs` | `…(priority-configs)` | WAGON / CURRENCY bands | +| rates | `/api/rates` (+`/live`,`/:id/submit`,`/approve`) | `…(rates)` | DRAFT→PENDING→LIVE | +| service-types | `/api/service-types` | `…(service-types)` | `includesFirstMile/LastMile` flags | +| shipping-lines | `/api/shipping-lines` | `…(shipping-lines)` | surcharge trigger | +| weight-limit-rules | `/api/weight-limit-rules` | `…(weight-limit-rules)` | VGM hard-block | +| yards | `/api/yards` | `…(yards)` | routes/warehouses read | + +**Settings & files** (reads open, writes `@FreightAdmin`): +`/api/dropdown-settings/*`, `/api/file-upload-settings/*` (config that drives portal forms; +server enforces *required-doc presence* at clearance, not size/MIME — those are client-side). +`GET /api/files/:fileId` is `@Public` (browsers load `/` without a bearer); upload is **direct +multipart** (multer memory → `FilesService` → MinIO `putObject`), presigned URLs used only for authenticated +reads (300 s TTL). `routes` (`route_milestones`) and `facilities` are config readers with **no permission +decorator** (behind the global JwtGuard only). NOTE: Note `FacilitiesModule` is never imported into `AppModule`, +so `/api/facilities` is unmounted/dead. + +--- + +## 9. Train Scheduling & Rail Operations + +Two consist models: **`trains`** = static fleet inventory (never created by scheduling); +**`train_sets`** = the operational consist scheduling builds per departure (**≥2 locomotives** + +wagon-type slots, physical wagons *pinned* later). + +```mermaid +stateDiagram-v2 + [*] --> DRAFT: create schedule (B) (≥2 locos, derive direction, freeze window rule) + DRAFT --> DRAFT: assign-bookings / run-allocation / pin-wagons (B) + DRAFT --> SCHEDULED: finalize (B) (bookings → Scheduled, ≥1 booking) + SCHEDULED --> DISPATCHED: dispatch (B) (train_number, locos ASSIGNED, window CLOSED, unpaid EXPIRED) + DISPATCHED --> DISPATCHED: recordCheckpoint (B) (corridor stations) + DISPATCHED --> ARRIVED: arrive (B) (bookings IN_TRANSIT, locos+wagons freed, warehouse arrival automation) + DRAFT --> CANCELLED: cancel (B) + SCHEDULED --> CANCELLED: cancel (B) + ARRIVED --> [*] +``` + +**Reschedule** (`DRAFT`/`SCHEDULED` only): `preview` merges current + incoming bookings, sorts by +`compareSchedulingPriority` (**government first, then priorityScore**), greedily keeps those that still +fit → *retained*; overflow → *displaced*; commercial displaced are *readmitted* if room remains. +`execute` re-verifies, optionally sets new departure, unassigns displaced, re-assigns final, writes a +`scheduling_events` audit row. Triggers: `GOVERNMENT_PREEMPT`, `TRAIN_MAINTENANCE`. + +Endpoint groups (`train-scheduling` prefix, `trainScheduling.view/manage`): + +| Group | Representative routes | +| --- | --- | +| Discovery (customer (P), unguarded) | `bookable-schedules`, `available-days`, `available-days-for-cargo`, `my-booking-windows`, `contracts/:id/booking-windows` | +| Board (staff (B)) | `batch-board`, `batch-board/:id`, `eligible-bookings`, `container|bulk/eligible-bookings`, `global-rules` | +| Build | `container|bulk/preview`, `container|bulk/schedules` (create), `:id/assign-bookings`, `:id/assign-unassigned-booking`, `:id/pin-wagons`, `:id/run-allocation`, `:id/run-batch` | +| Composition edits | `:id/wagons/:wagonId` (remove slot), `:id/container-items/:itemId`, `bookings/:bookingId/{mark-paid,expire,move-schedule}` | +| Lifecycle | `schedules/:id/{finalize,dispatch,arrive,booking-window,doc-review-complete}`, `{container\|bulk}/schedules/:id/cancel` | +| Tracking | `schedules/:id/checkpoints` (GET/POST) → writes `train_checkpoint_events` | +| Djibouti import ops | `:id/import-djibouti/*` (gatepass, ready-for-loading, loaded, depart, load-list) | +| Reschedule | `:id/reschedule/preview`, `:id/reschedule/execute`, `:id/maintenance` | + +**Fleet master data** (`FleetView/Manage`): `/api/trains` (+`/:trainId/reorder-wagons`), `/api/wagons` +(+`/assign-train`,`/unassign-train`), `/api/locomotives` (+`/decommission`), `/api/wagon-types` +(rule-engine guarded). **Asset records** (unguarded, keyed by *road* `vehicleId`): `/api/maintenance/*`, +`/api/fuel/*`. **`fleet_events`** is an append-only audit read via `drivers/:id/history` & +`vehicles/:id/history`. + +**Tracking (customer-facing):** `GET /api/tracking/:consignmentId` → `tracking_events` timeline +(`location`, `ConsignmentStatus`, `occurredAt`), consumed by portal `TrackingPage`. It is **JWT-guarded** +(no permission decorator — not public). Distinct from staff train **checkpoints** (`train_checkpoint_events`, +written by `recordCheckpoint`/`arrive`). NOTE: **`TrackingService.record()` has no caller anywhere in the +codebase — nothing writes `tracking_events`, so the customer tracking timeline is currently unpopulated; +only the staff `train_checkpoint_events` store is written.** + +--- + +## 10. Warehouse — inventory lifecycle + +Hierarchy `Facility → Warehouse → Yard → Zone`; every level tracks weight/volume/container capacity kept +in sync by `applyCapacityDelta`. `warehouse_inventory` carries nullable FKs to `booking`, `cargo`, +`container` — the join point between warehouse and the shipment. + +```mermaid +stateDiagram-v2 + [*] --> RECEIVED: receive / bulkReceive (B) + [*] --> UNLOADED: auto-unload-arrived (import train) (B) + RECEIVED --> STORED: store (B) (allocation rule picks yard/zone) + UNLOADED --> STORED: store (B) + UNLOADED --> READY_FOR_PICKUP: inspection PASSED + IMPORT (B) + RECEIVED --> READY_FOR_PICKUP: inspection PASSED + IMPORT (B) + STORED --> RESERVED: reserve (B) (booking PAID) + RESERVED --> READY_FOR_LOADING: mark-ready-for-loading (B) (inspection PASSED) + READY_FOR_LOADING --> LOADED: load onto wagon (B) (+ warehouse_loadings) + LOADED --> DISPATCHED: dispatch / bulk-dispatch-export (B) + DISPATCHED --> UNLOADED_AT_DJIBOUTI_PORT: auto-unload-at-djibouti (B) + READY_FOR_PICKUP --> DELIVERED: release → deliver (B) (POD, fees PAID via gate-clearance) + READY_FOR_PICKUP --> STORED: re-store import item (B) + READY_FOR_PICKUP --> DISPATCHED: dispatch out (B) + UNLOADED_AT_DJIBOUTI_PORT --> [*] + DELIVERED --> [*] +``` + +**Export branch:** receive → STORED → RESERVED (booking PAID) → READY_FOR_LOADING → LOADED → DISPATCHED +→ UNLOADED_AT_DJIBOUTI_PORT. Note `store()` does **not** check inspection — `RECEIVED → STORED` is legal +without it; inspection **PASSED** is enforced only at `mark-ready-for-loading`. **Import branch:** UNLOADED +→ inspect PASSED → READY_FOR_PICKUP (auto-creates +last-mile if requested) → release → DELIVERED. Every transition writes `warehouse_activity_log`. + +**Storage billing** is *not* a separate table — `WarehouseInvoiceService` is a thin layer over the +central **billing** module (global `Invoice` rows, `source='warehouse'`, `sourceId=inventoryId`). Fees = +`STORAGE_FEE` + `DEMURRAGE` via `warehouse_fee_rules` (free-days grace, tiers, FX-converted). **Unpaid +warehouse fees block exit:** `gateClearance`/`release` call `findBlockingInvoice` / `assertClearanceAllowed`. + +Controller families (84 routes, all called by backoffice `warehouse.service.ts`; **no per-route +permission decorators → behind the global JwtGuard only**): `warehouses` · `warehouse-yards` · +`warehouse-zones` · `warehouse-inventory` (queries + 25 mutation actions incl. bulk + import/export +queues, and the gate release `warehouse-inventory/:id/gate-clearance`) · `warehouse-loadings` · +inspection (`…/inspection-reports`) · fee-invoices (`…/generate-fee-invoice`, `warehouse-fee-invoices/*` +incl. `pay`, `pay-online`) · rules (`warehouse-allocation-rules`, `warehouse-fee-rules`, +`warehouse-allocation/preview`). + +> Portal touches only: `bookings/:id/warehouse-fee-invoices`, invoice `by-id`/`document`/`receipt`/ +> `pay-online`, and `bookings/:bookingId/approve-delivery` (customer signs handover). Everything else (B). + +--- + +## 11. Field Operations — first/last mile & import customs + +```mermaid +flowchart TD + binv["booking.invoice.paid event"] --> advance["advanceBookingOnPayment → booking PAID"] + advance --> fmreq{"EXPORT + first-mile requested?"} + fmreq -->|Yes| fmaccept["firstMileService.acceptBooking
(auto-create leg)"] + fmreq -->|No| skip1["—"] + fmaccept --> fmleg + + subgraph FM["First-mile (EXPORT origin road leg)"] + fmleg["READY_TO_TRANSIT"] --> fmveh["setVehicles → vehicle BUSY,
SMS driver, fleet_events"] + fmveh --> fmtransit["IN_TRANSIT (needs assigned vehicle)"] + fmtransit --> fmdone["RECEIVED_TO_PORT (free vehicles)"] + fmleg --> fminv["generate-invoice → FIRST_MILE fee
(locks distances once invoiced)"] + end + + whrcv["warehouse inspection PASSED (IMPORT only)"] --> lmaccept["lastMileService.acceptBooking(reference)"] + lmaccept --> lmleg + subgraph LM["Last-mile (IMPORT destination road leg)"] + lmleg["READY_TO_TRANSIT"] --> lmtransit["IN_TRANSIT"] --> lmdone["DELIVERED (free vehicles)"] + lmleg --> lminv["generate-invoice → LAST_MILE fee"] + end + + subgraph IMP["Import customs finalization (per booking, timestamp-driven)"] + direction TB + up["upload docs (IM4/IM5/T1_CLOSURE/…)"] --> decl["record declaration serial"] + decl --> notify["notify duties/taxes"] + notify --> paid["mark duties paid (needs CUSTOMER_PAYMENT_SLIP)"] + paid --> risk["assign risk (GREEN/YELLOW/BLUE/RED)"] + risk --> rel["release-permitted (asserts T1 + release permit + declaration + risk + paid)"] + end +``` + +| Module | Base | Guard | Terminal state | +| --- | --- | --- | --- | +| first-mile | `/api/first-mile` (+`/accept/:ref`,`/:id/{vehicles,distances,invoice}`) | `trainScheduling.view/manage` | `RECEIVED_TO_PORT` | +| last-mile | `/api/last-mile` (same shape) | `trainScheduling.view/manage` | `DELIVERED` | +| import-operations | `/api/import-operations/{customs,djibouti-incidents,empty-container-returns}/*` | none (global JwtGuard) | `completedAt` | +| interchange-documents | `/api/interchange-documents` (+`/generate-from-schedule`,`/:id/{acknowledge,dispute,cancel}`) | none | `ACKNOWLEDGED / DISPUTED` | +| drivers | `/api/drivers` (+`/:id/history`) | `FleetView/Manage` | soft-delete | +| vehicles | `/api/vehicles` (+`/:id/history`) | `FleetView/Manage` | soft-delete | + +Interchange documents are the **rail↔port handover manifest** — generated from a train schedule, +snapshotting booking/container/cargo lines with per-item `conditionStatus` derived from warehouse +inspection flags. Status `DRAFT → GENERATED → ACKNOWLEDGED | DISPUTED | CANCELLED`. All FE callers (B). + +**Mile trigger precision (verified):** first-mile is created only on `booking.invoice.paid` → +`advanceBookingOnPayment` → `firstMile.acceptBooking(bookingId)` when `EXPORT` + first-mile requested. +Last-mile is created **only on IMPORT inspection PASSED** (two call sites: `warehouse-inspection.service` +and the bulk-inspect branch of `warehouse-inventory.service`) — **not** on warehouse *receive*. The IMPORT +constraint is enforced by the warehouse caller, not inside `lastMile.acceptBooking(reference)`. Auto-created +legs enter at **`READY_TO_TRANSIT`** (the `PAYMENT_PENDING` entity default is bypassed). + +--- + +## 12. Billing & Payment + +### 12.1 Invoice lifecycle + +Invoices are **source-agnostic** — `BillingService.generateInvoice()` is the single factory called by +domain services (never a controller): `source ∈ {booking, warehouse, first_mile, last_mile}` (the enum +also defines an unused `demurrage`), numbered `INV-YYYYMMDD-#####`, `dueAt = now + 14d`. **Initial status +varies by source:** booking invoices start `DRAFT` (issued at operation-accept via `billing.updateStatus`); +the `generateInvoice` default is `PENDING`; warehouse + contract-GL invoices start `ISSUED`. + +```mermaid +stateDiagram-v2 + [*] --> DRAFT: booking invoice (issued at operation-accept) + [*] --> PENDING: generateInvoice default + [*] --> ISSUED: warehouse / contract-GL invoice + DRAFT --> ISSUED: billing.updateStatus + PENDING --> PARTIALLY_PAID: recordPayment (partial, offline) + ISSUED --> PARTIALLY_PAID: recordPayment (partial, offline) + PENDING --> PAID: markInvoiceAsPaid (gateway, full) + ISSUED --> PAID: markInvoiceAsPaid (gateway, full) + PARTIALLY_PAID --> PAID: final payment + PENDING --> EXPIRED: expirePayable (pay window lapses) + ISSUED --> CANCELLED: cancelInvoice (no payments) + PAID --> REFUNDED: markInvoiceAsRefunded (paidAmount>0) + PAID --> [*] + note right of PAID + emits ${source}.invoice.paid + (sources use first_mile / last_mile, underscores) + → domain listeners advance booking / mile / warehouse + end note + note left of ISSUED + OVERDUE exists in the enum but NO code sets it + (no cron / setter) — effectively unused + end note +``` + +`OPEN_STATUSES = {Issued, Pending, PartiallyPaid, Overdue}` are payable. Transitions lock the row +(`pessimistic_write`); the event fires **after commit** for self-managed transitions, but **inline before +commit** when the transition is enlisted in a caller-supplied transaction `manager`. + +### 12.2 freight-api ↔ payment-api integration + +```mermaid +sequenceDiagram + autonumber + participant U as Customer (P) + participant FB as Freight billing (:3001) + participant PA as Payment API (red) (:3003) + participant PV as Provider (Telebirr/Waafi/…) + participant OB as Payment outbox + participant FI as Freight internal ctrl + + U->>FB: POST /billing/my-invoices/:id/pay + FB->>FB: payInvoice → validate OPEN, balance>0 + FB->>PA: POST /payments/initiate (x-service-token)
service=FREIGHT, referenceId=sourceId, amountMinor + PA-->>FB: { intentId, clientAction (REDIRECT/LAUNCH_APP/COLLECT_OTP) } + FB->>FB: store paymentId on invoice (correlation) + FB-->>U: clientAction → redirect to provider + U->>PV: authorize payment + PV->>PA: webhook POST /webhooks/{provider} + PA->>PA: verify signature, dedupe, intent state machine + PA->>OB: write PaymentEvent (payment.succeeded) + OB->>FI: POST /api/internal/payments/mark-paid (@Public, x-service-token) + Note over OB,FI: or RabbitMQ → PaymentEventsConsumer + FI->>FB: handlePaymentEvent → settleByPaymentId → markInvoiceAsPaid + FB->>FB: emit booking.invoice.paid + FB-->>U: invoice PAID (poll / notification) +``` + +> NOTE: **Demo shortcut in `payInvoice`:** today, if the provider doesn't settle synchronously, freight +> self-fires `handlePaymentEvent(payment.succeeded)` inline (marked TODO/remove) — invoices settle at +> pay-time without a real webhook. Providers: **Telebirr / CBE_BIRR / EBIRR** (ET), **Waafi / DMoney** +> (DJ), **CARD** (intl), **CAC_BANK** (OTP). Payment DB is a **separate `edr_payment` schema** — no +> cross-schema FKs; `referenceId` is a soft link. + +### 12.3 Endpoints + +| Method | Path | Facing | Guard | +| --- | --- | --- | --- | +| GET | `/api/billing/invoices` (+`/:id`,`/:id/document`,`/receipt`) | (B) backoffice | `bookings.view` | +| GET | `/api/billing/my-invoices` (+`/:id`,`/document`,`/receipt`) | (P) portal | `@CurrentUser` ownership | +| POST | `/api/billing/my-invoices/:id/pay` | (P) portal | ownership | +| POST | `/api/payments/initiate` | central | JwtGuard (no permission — **not** public) | +| GET | `/api/payments/checkout` | redirect | `@Public` | +| GET | `/api/payments/{summary,all}` | (B) backoffice | `bookings.view` | +| GET | `/api/payments/by-company/:companyId/customer-view` | (B) | none | +| GET | `/api/payments/intents/:bookingId` · `/receipt/:orderId` | reconcile / receipt | none / `@Public` | +| POST | `/api/internal/payments/mark-paid` | (red) service→service | `@Public` (NOTE: currently unauthenticated) | +| POST·GET·PUT | `/api/backoffice/organizations/:orgId/*` | (B) IAM user/role mgmt (NOT billing) | `@FreightAdmin` | + +`GET /api/overview*` (7 tabs, `bookings.view`) is the backoffice dashboard aggregator over 11 repos. + +--- + +## 13. Cross-cutting event web (`@OnEvent`) + +The domains are stitched together by events, not direct calls. This is why the master flow "just happens". + +```mermaid +flowchart LR + pay["Payment webhook / demo shortcut"] --> settle["billing.settleByPaymentId → markInvoiceAsPaid"] + settle --> ev1{{"${source}.invoice.paid"}} + ev1 -->|source=booking| adv["booking-invoice: advanceBookingOnPayment → PAID"] + adv --> fm["firstMile.acceptBooking (EXPORT + requested)"] + adv --> batch["train-scheduling batch: markPaid → allocate"] + ev1 -->|source=first_mile| e2{{"first_mile.invoice.paid"}} --> fmp["NOTE: intended: leg paid=true
(listener typo 'firstmile.invoice.paid' → never fires)"] + ev1 -->|source=last_mile| e3{{"last_mile.invoice.paid"}} --> lmp["last-mile → DELIVERED, paid=true"] + ev1 -->|source=warehouse| e4{{"warehouse.invoice.paid"}} --> whp["settle warehouse fee → unblock gate"] + + arrive["train arrive"] --> whauto["warehouse arrival automation:
auto-unload arrived bookings"] + insp["warehouse inspection PASSED (IMPORT)"] --> lmacc["lastMile.acceptBooking → READY_FOR_PICKUP"] + + assign["mile setVehicles"] --> veh["vehicle BUSY + SMS driver + fleet_events"] + release["mile terminal / delete"] --> free["vehicle FREE (releaseIfUnused)"] + + notify["notification-inbox.notify"] --> ws["WebSocket push (always)"] + notify -->|HIGH priority| smsemail["+ SMS + email via RabbitMQ"] +``` + +--- + +## 14. Portal vs Backoffice — who does what + +| Capability | (P) Portal (customer) | (B) Backoffice (employee) | +| --- | --- | --- | +| Identity / onboarding | signup, OTP, Fayda, company onboarding, profile | company approval, IAM user/role mgmt | +| Bookings | create, price, submit, sign, upload docs, pay, approve delivery, track | accept, approve chain, generate contract, clearance review, operation review, allocate, dispatch | +| Contracts | create, submit, sign, booking-requests, self-clearance slips | approve, generate, phased clearance, GL booking creation | +| Rule engine | reads only via `bookings/reference-data` | full CRUD (rates approval, approval rules, priorities) | +| Scheduling | discover bookable days/schedules | build/finalize/dispatch/arrive/reschedule trains | +| Warehouse | pay storage fees, view invoices, approve handover | full inventory lifecycle + fees + rules | +| Field ops | (none direct) | first/last mile, import customs, interchange docs, fleet | +| Billing | pay own invoices, download docs/receipts | invoice + payment dashboards, per-customer views | +| Notifications | inbox + WS | inbox + WS | + +--- + +## 15. Status / state reference + +| Entity | States (happy path → terminal) | +| --- | --- | +| Company | `PENDING → ACTIVE` (+ SUSPENDED, BLACKLISTED) | +| Booking | `DRAFT → SUBMITTED → PENDING_APPROVAL → APPROVED_PENDING_SIGNATURE → APPROVED → CONTRACT_READY → SIGNED_CUSTOMER → [IMPORT/EXPORT: AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY → OPERATION_REQUEST_PENDING → (train) FULLY_EXECUTED → SELECTED_FOR_BATCH / (road) ROAD_DISPATCH_PENDING] · [DOMESTIC: → FULLY_EXECUTED → SELECTED_FOR_BATCH] → PAID → IN_TRANSIT → COMPLETED` (branches: REJECTED, CANCELLED, CHANGES_REQUESTED, OPERATION_CHANGES_REQUESTED, PENDING_CONSOLIDATION). 35 statuses total; ~10 legacy ones unused. | +| Contract | `DRAFT → SUBMITTED → PENDING_APPROVAL → APPROVED_PENDING_SIGNATURE → APPROVED → CONTRACT_READY → SIGNED_CUSTOMER → AWAITING_CLEARANCE_DOCUMENTS / CONTRACT_ACTIVE / FULLY_EXECUTED` (+ CLEARANCE_UNDER_REVIEW, CLEARANCE_READY_FOR_BOOKING, ACTIVE_SHIPMENT_IN_PROGRESS, CONTRACT_CLOSED, CANCELLED, RENEWAL_DRAFT/SUBMITTED/PENDING_APPROVAL, AMENDMENTS_PROPOSED, ARCHIVED, EXPIRED — **24 total**). Separate `clearanceStatus` axis: NOT_APPLICABLE / AWAITING_DOCUMENTS / DOCUMENTS_UNDER_REVIEW / CLEARANCE_READY_FOR_BOOKING / SELF_CLEARED / ACTIVE_SHIPMENT_IN_PROGRESS. | +| Rate | `DRAFT → PENDING_APPROVAL → LIVE` (+ SUPERSEDED) | +| Train schedule | `DRAFT → SCHEDULED → DISPATCHED → ARRIVED` (+ CANCELLED) | +| Warehouse inventory | export: `RECEIVED → STORED → RESERVED → READY_FOR_LOADING → LOADED → DISPATCHED → UNLOADED_AT_DJIBOUTI_PORT` · import: `UNLOADED → READY_FOR_PICKUP → DELIVERED` (READY_FOR_PICKUP may also → STORED / DISPATCHED) | +| First-mile | `PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → RECEIVED_TO_PORT` (auto-created legs enter at READY_TO_TRANSIT) | +| Last-mile | `PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → DELIVERED` (auto-created legs enter at READY_TO_TRANSIT) | +| Invoice | `DRAFT / PENDING / ISSUED → PARTIALLY_PAID → PAID` (+ EXPIRED, CANCELLED, REFUNDED; **OVERDUE defined but never set**) | +| Interchange doc | `DRAFT → GENERATED → ACKNOWLEDGED / DISPUTED / CANCELLED` | +| Empty container return | `RETURNED → ASSIGNED_STORAGE → DOCUMENTATION_CLEARED → WAGON_ALLOCATED → TRANSPORTED_TO_DJIBOUTI → HANDOVER_ISSUED → COMPLETED` | + +--- + +## 16. Notable gaps & caveats (verified against code) + +- **Counter-sign is direction-based, not customs-based** (bookings *and* contracts): `IMPORT`/`EXPORT` open a + clearance gate even with customs off; only `DOMESTIC` skips it. The customs flag only selects the clearance + document set. +- **`SELECTED_FOR_BATCH` is set by the batch engine, not at operation-accept** — train accept sets + `FULLY_EXECUTED` first. +- **`FacilitiesModule` is never imported into `AppModule`** — `/api/facilities` is **unmounted / dead** (not + reachable at all). `tracking`, `maintenance`, `fuel` carry no permission decorator but *are* mounted — behind + the **global JwtGuard**, just not permission-gated. +- **`tracking_events` has no writer anywhere** — `TrackingService.record()` is never called, so the customer + tracking timeline is unpopulated. Only staff `train_checkpoint_events` are written (by `recordCheckpoint`/`arrive`). +- **First-mile paid-flag listener is a dead code path** — `@OnEvent("firstmile.invoice.paid")` (no underscore) + never fires because the emitted event is `first_mile.invoice.paid`; the first-mile `paid` flag is never flipped by settlement. +- **Invoice `OVERDUE` status is never set** — no cron/setter transitions to it, though it is in the enum and `OPEN_STATUSES`. +- `POST /api/internal/payments/mark-paid` is `@Public` with **no service auth** (ServiceAuthGuard removed; noted in code). +- **Demo settlement shortcut** in `billing.payInvoice` bypasses real webhooks (TODO/remove) — invoices settle inline at pay-time. +- Server does **not** validate upload size/MIME (client-side only); it enforces *required-doc presence* at clearance. +- Dead / unwired FE calls: `trains/:id/details` (no route), portal `consignments` service hits `/consignments` without `/api`, and list endpoints `bookings/my`, `queues/:queue`, `by-company/customer-view` have no active caller. +- NOTE: The repo `CLAUDE.md` states auth is stubbed/unwired — **this is stale**: auth is live via `@tria-plc/api-common` (global `JwtGuard` + `HasActiveDelegationGuard` `APP_GUARD`s). + +--- + +*Generated from `apps/edr-freight-api`, `apps/edr-freight-web/{portal,backoffice}`, `apps/edr-payment-api` +on branch `freight/feat/fixes-v1`. Reflects code at scan time; regenerate after major module changes.* diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 71e2fd60a..636bb7e05 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -6,7 +6,7 @@ "scripts": { "clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"", "predev": "pnpm run clean", - "dev": "nest start --watch", + "dev": "nest start --watch --clearScreen false", "prebuild": "pnpm run clean", "build": "nest build", "start": "node dist/main.js", @@ -22,7 +22,9 @@ "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts", + "seed:paid-import-export-mile-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-paid-import-export-mile-demo.ts", "seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts", + "seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts", "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts", @@ -32,7 +34,8 @@ "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js", - "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts" + "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", + "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { "@edr/api-common": "workspace:*", @@ -47,9 +50,11 @@ "@nestjs/mapped-types": "^2.1.1", "@nestjs/microservices": "^11.0.0", "@nestjs/platform-express": "^11.0.0", + "@nestjs/platform-socket.io": "^11.1.27", "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^11.4.2", "@nestjs/typeorm": "^11.0.1", + "@nestjs/websockets": "^11.1.27", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz", "amqp-connection-manager": "^5.0.0", @@ -61,12 +66,14 @@ "dotenv": "^17.4.2", "dotenv-cli": "^11.0.0", "handlebars": "^4.7.9", + "jose": "^5.10.0", "libphonenumber-js": "^1.13.6", "minio": "7.1.3", "pg": "^8.13.0", "puppeteer": "^24.2.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.3", "typeorm": "^0.3.30" }, "devDependencies": { @@ -84,13 +91,16 @@ "@types/node": "^20.14.0", "@types/pg": "^8.6.7", "@types/supertest": "^6.0.2", + "@types/vorpal": "^1.12.8", "jest": "^29.7.0", + "socket.io-client": "^4.8.3", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "vorpal": "^1.12.0" }, "jest": { "moduleFileExtensions": [ diff --git a/apps/edr-freight-api/py/README.md b/apps/edr-freight-api/py/README.md new file mode 100644 index 000000000..561b35fbc --- /dev/null +++ b/apps/edr-freight-api/py/README.md @@ -0,0 +1,67 @@ +# Contract seed driver + +Creates freight contracts across every flow variant by driving the freight API +over HTTP end-to-end — from DRAFT through **both signatures** (customer sign + +staff counter-sign). It stops right after the staff counter-sign; no clearance +or booking steps are run. + +## What it builds + +20 real flows (movement × kind × customs × freight), each created twice → **40 +contracts** on `all`. + +| Movement | Kind | Customs | Freight | Count | +| --- | --- | --- | --- | --- | +| intercity (DOMESTIC) | one-time / general | without only¹ | bulk / container | 4 | +| import (IMPORT) | one-time / general | with / without | bulk / container | 8 | +| export (EXPORT) | one-time / general | with / without | bulk / container | 8 | + +¹ intercity + customs is not a real combo — DOMESTIC has no clearance gate, so +the customs flag is ignored. Those four are skipped, leaving 20 (16 working + 4 +`with-customs + bulk`). + +The four `with-customs + bulk` flows are still built here: the known break is +downstream in clearance (customs output docs are container-only), which this +script does not reach, so all 20 reach a signed state. + +## Terminal status after both signatures (by dimension) + +- DOMESTIC one-time → `FULLY_EXECUTED` +- any GENERAL, and DOMESTIC general → `CONTRACT_ACTIVE` +- IMPORT/EXPORT one-time (customs or self-clearance) → `AWAITING_CLEARANCE_DOCUMENTS` + (fully signed; clearance not driven) + +## Setup + +```bash +cd apps/edr-freight-api/py +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # then fill it in +``` + +Fill `.env`: customer + admin IAM credentials (admin should be a **super_admin**), +`OTP_PHONE`, and the Postgres connection (used only to read the sign-OTP). + +## Run + +```bash +python create_contracts.py # all 20 flows +python create_contracts.py intercity # only DOMESTIC flows (4) +python create_contracts.py import # only IMPORT flows (8) +python create_contracts.py import export # IMPORT + EXPORT (16) +``` + +Filters are by movement: `intercity`, `import`, `export` (pass one or many); +no arg or `all` runs everything. + +## How auth + OTP work + +- **Login**: `POST /api/auth/login` with `{ email, password }` returns a JWT + (`token`), sent as `Authorization: Bearer `. MFA accounts are not + supported — the script errors out clearly if MFA is required. +- **Actors**: the customer token does create/submit/customer-sign; the admin + token does staff-accept/approve/generate/counter-sign. +- **Sign OTP**: customer sign needs a fresh 6-digit OTP. The script calls + `POST /api/otp/send { phone }`, reads the plaintext code from + `.otp_verifications` in Postgres, then signs within the 5-minute TTL. diff --git a/apps/edr-freight-api/py/__pycache__/create_contracts.cpython-312.pyc b/apps/edr-freight-api/py/__pycache__/create_contracts.cpython-312.pyc new file mode 100644 index 000000000..6c13fefb3 Binary files /dev/null and b/apps/edr-freight-api/py/__pycache__/create_contracts.cpython-312.pyc differ diff --git a/apps/edr-freight-api/py/create_contracts.py b/apps/edr-freight-api/py/create_contracts.py new file mode 100644 index 000000000..956c81cbb --- /dev/null +++ b/apps/edr-freight-api/py/create_contracts.py @@ -0,0 +1,504 @@ +#!/usr/bin/env python3 +""" +Seed freight contracts across every flow variant, driven end-to-end over HTTP. + +For each flow the script logs in, creates a DRAFT contract, and pushes it through +the lifecycle up to and INCLUDING both signatures (customer sign + staff +counter-sign). It STOPS after the staff counter-sign — no clearance, no booking. + +Flow dimensions (3 x 2 x 2 x 2 = 24 combos, but only the 20 real ones are built): + movement : intercity(DOMESTIC) | import(IMPORT) | export(EXPORT) + kind : one-time(ONE_TIME) | general(GENERAL) + customs : without | with (customsClearingEnabled) + freight : bulk(BULK) | container(CONTAINER) + +intercity + customs is dropped (DOMESTIC ignores customs → no real combo), which +removes 4 dead combos and leaves 20 flows (16 working + 4 customs+bulk whose +break is downstream in clearance). Each is created twice → 40 contracts on `all`. + +CLI (filter by movement, pass one or many): + python create_contracts.py # all 20 flows + python create_contracts.py all # all 20 flows + python create_contracts.py intercity # only DOMESTIC flows + python create_contracts.py import # only IMPORT flows + python create_contracts.py import export # IMPORT + EXPORT flows + +Config comes from .env (see .env.example). Requires: requests, psycopg, +python-dotenv (see requirements.txt). +""" +from __future__ import annotations + +import base64 +import os +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import psycopg +import requests +from dotenv import load_dotenv + +HERE = Path(__file__).resolve().parent +load_dotenv(HERE / ".env") + +# --------------------------------------------------------------------------- # +# Config +# --------------------------------------------------------------------------- # +API_URL = os.getenv("FREIGHT_API_URL", "http://localhost:3001/api").rstrip("/") + +CUSTOMER_EMAIL = os.getenv("CUSTOMER_EMAIL", "") +CUSTOMER_PASSWORD = os.getenv("CUSTOMER_PASSWORD", "") +ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "") +ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "") + +# Phone the sign-OTP is sent to and read back from Postgres. Resolved at runtime +# from the customer's own IAM profile (GET /api/me → phoneNumber). OTP_PHONE is an +# optional override / fallback used only when the customer has no phone on file. +# The sign endpoint keys the OTP purely on this number, so it just has to be the +# same value for "send" and "read". +OTP_PHONE = os.getenv("OTP_PHONE", "") +OTP_PHONE_FALLBACK = os.getenv("OTP_PHONE_FALLBACK", "251900000000") + +# DB connection used ONLY to read the plaintext sign-OTP from freight.otp_verifications. +DB_HOST = os.getenv("DB_HOST", "localhost") +DB_PORT = os.getenv("DB_PORT", "5432") +DB_NAME = os.getenv("DB_NAME", "edr_dev") +DB_USER = os.getenv("DB_USER", "postgres") +DB_PASSWORD = os.getenv("DB_PASSWORD", "") +DB_SCHEMA = os.getenv("DB_SCHEMA", "freight") + +WAAFI = HERE / "waafi.jpeg" + +VALIDITY_DAYS = int(os.getenv("VALIDITY_DAYS", "365")) +CONTRACTS_PER_FLOW = int(os.getenv("CONTRACTS_PER_FLOW", "2")) +REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "60")) + + +# --------------------------------------------------------------------------- # +# Flow matrix — the 20 real flows +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class Flow: + movement: str # intercity | import | export + trade_direction: str # DOMESTIC | IMPORT | EXPORT + kind: str # ONE_TIME | GENERAL + customs: bool # customsClearingEnabled + freight: str # BULK | CONTAINER + + @property + def label(self) -> str: + return ( + f"{self.movement}+{'general' if self.kind == 'GENERAL' else 'one-time'}" + f"+{'with' if self.customs else 'no'}-customs" + f"+{self.freight.lower()}" + ) + + +def build_flow_matrix() -> list[Flow]: + movements = [ + ("intercity", "DOMESTIC"), + ("import", "IMPORT"), + ("export", "EXPORT"), + ] + kinds = ["ONE_TIME", "GENERAL"] + freights = ["BULK", "CONTAINER"] + + flows: list[Flow] = [] + for movement, direction in movements: + # DOMESTIC ignores customs (no clearance gate) → customs=True is not a + # real combo. Only build without-customs for intercity. + customs_options = [False] if direction == "DOMESTIC" else [False, True] + for kind in kinds: + for customs in customs_options: + for freight in freights: + flows.append(Flow(movement, direction, kind, customs, freight)) + return flows + + +# --------------------------------------------------------------------------- # +# HTTP client +# --------------------------------------------------------------------------- # +class ApiError(RuntimeError): + def __init__(self, method: str, path: str, resp: requests.Response): + body = resp.text + try: + body = resp.json() + except Exception: + pass + super().__init__(f"{method} {path} -> {resp.status_code}: {body}") + self.status_code = resp.status_code + + +class Client: + """Thin wrapper that carries a bearer token.""" + + def __init__(self, name: str, token: str | None = None): + self.name = name + self.token = token + + def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]: + h: dict[str, str] = {} + if self.token: + h["Authorization"] = f"Bearer {self.token}" + if extra: + h.update(extra) + return h + + def get(self, path: str, params: dict | None = None) -> Any: + r = requests.get( + f"{API_URL}{path}", + headers=self._headers(), + params=params, + timeout=REQUEST_TIMEOUT, + ) + if not r.ok: + raise ApiError("GET", path, r) + return r.json() if r.content else None + + def post_json(self, path: str, body: dict | None = None) -> Any: + r = requests.post( + f"{API_URL}{path}", + headers=self._headers({"Content-Type": "application/json"}), + json=body or {}, + timeout=REQUEST_TIMEOUT, + ) + if not r.ok: + raise ApiError("POST", path, r) + return r.json() if r.content else None + + def post_multipart( + self, path: str, data: dict[str, str], files: list[tuple] | None = None + ) -> Any: + r = requests.post( + f"{API_URL}{path}", + headers=self._headers(), # requests sets multipart Content-Type + data=data, + files=files or [], + timeout=REQUEST_TIMEOUT, + ) + if not r.ok: + raise ApiError("POST", path, r) + return r.json() if r.content else None + + +def login(email: str, password: str, who: str) -> Client: + r = requests.post( + f"{API_URL}/auth/login", + json={"email": email, "password": password}, + timeout=REQUEST_TIMEOUT, + ) + if not r.ok: + raise ApiError("POST", "/auth/login", r) + payload = r.json() + if payload.get("mfaRequired"): + raise RuntimeError( + f"{who} login requires MFA — this script cannot complete an MFA login. " + "Disable MFA for the seed account or supply a non-MFA account." + ) + token = payload.get("token") + if not token: + raise RuntimeError(f"{who} login returned no token: {payload}") + return Client(who, token) + + +# --------------------------------------------------------------------------- # +# OTP — send + read from Postgres +# --------------------------------------------------------------------------- # +def resolve_otp_phone(customer: Client) -> str: + """Phone the sign-OTP is sent to. Prefer the customer's own IAM profile phone + (GET /api/me → phoneNumber); fall back to OTP_PHONE, then OTP_PHONE_FALLBACK. + The value only has to be consistent between send + DB read.""" + phone = "" + try: + me = customer.get("/me") or {} + phone = (me.get("phoneNumber") or "").strip() + except Exception: + pass + phone = phone or OTP_PHONE or OTP_PHONE_FALLBACK + if not phone: + raise RuntimeError( + "Could not resolve an OTP phone (customer has none, and neither " + "OTP_PHONE nor OTP_PHONE_FALLBACK is set)." + ) + return phone + + +def send_otp(customer: Client, phone: str) -> None: + # POST /api/otp/send is @Public — no token needed, but sending one is harmless. + customer.post_json("/otp/send", {"phone": phone}) + + +def read_otp_from_db(phone: str) -> str: + """Read the freshest plaintext OTP for `phone` from freight.otp_verifications.""" + dsn = ( + f"host={DB_HOST} port={DB_PORT} dbname={DB_NAME} " + f"user={DB_USER} password={DB_PASSWORD}" + ) + with psycopg.connect(dsn) as conn: + with conn.cursor() as cur: + cur.execute( + f'SELECT otp FROM "{DB_SCHEMA}".otp_verifications ' + "WHERE phone = %s ORDER BY updated_at DESC LIMIT 1", + (phone,), + ) + row = cur.fetchone() + if not row: + raise RuntimeError(f"No OTP row found for phone {phone} in {DB_SCHEMA}.otp_verifications") + return str(row[0]) + + +# --------------------------------------------------------------------------- # +# Reference-data lookups (yards / service types / cargo types) +# --------------------------------------------------------------------------- # +@dataclass +class RefData: + yards: list[dict] = field(default_factory=list) + service_types: list[dict] = field(default_factory=list) + cargo_types: list[dict] = field(default_factory=list) + + +def _as_items(resp: Any) -> list[dict]: + if isinstance(resp, list): + return resp + if isinstance(resp, dict): + return resp.get("items") or resp.get("data") or [] + return [] + + +def load_ref_data(client: Client) -> RefData: + ref = RefData( + yards=_as_items(client.get("/yards")), + service_types=_as_items(client.get("/service-types")), + cargo_types=_as_items(client.get("/cargo-types")), + ) + if len(ref.yards) < 2: + raise RuntimeError(f"Need >=2 yards, got {len(ref.yards)}. Seed yards first.") + if not ref.service_types: + raise RuntimeError("No service types found. Seed service types first.") + if not ref.cargo_types: + raise RuntimeError("No cargo types found. Seed cargo types first.") + return ref + + +def pick_service_type(ref: RefData, wants_customs: bool) -> str: + """Prefer a service type whose includesCustoms matches the flow's customs need.""" + for st in ref.service_types: + if bool(st.get("includesCustoms")) == wants_customs: + return st["id"] + # Fall back to any — customsClearingEnabled on the contract still drives the flow. + return ref.service_types[0]["id"] + + +# --------------------------------------------------------------------------- # +# Contract payload builder +# --------------------------------------------------------------------------- # +def build_create_payload(flow: Flow, ref: RefData, idx: int) -> dict[str, str]: + """Return multipart form fields. Booleans as 'true'/'false' strings; nested + arrays as JSON strings (implicit conversion is off in the API).""" + import json + + origin = ref.yards[0]["id"] + destination = ref.yards[1]["id"] + service_type_id = pick_service_type(ref, flow.customs) + + # Cargo scope: CONTAINER -> >=1 size row; BULK -> exactly one cargo-type row. + if flow.freight == "CONTAINER": + cargo_scope = [{"containerSize": "20ft"}] + if flow.kind == "GENERAL": + cargo_scope[0]["quantityCap"] = 10 + else: # BULK + cargo_scope = [{"cargoTypeId": ref.cargo_types[0]["id"]}] + if flow.kind == "GENERAL": + cargo_scope[0]["quantityCap"] = 1000 + + # Routes: ONE_TIME -> exactly 1; GENERAL -> 1..N (one is fine). + routes = [{"originYardId": origin, "destinationYardId": destination, "sortOrder": 0}] + + fields: dict[str, str] = { + "contractKind": flow.kind, + "tradeDirection": flow.trade_direction, + "freightType": flow.freight, + "serviceTypeId": service_type_id, + "paymentCurrency": "ETB", + "customsClearingEnabled": "true" if flow.customs else "false", + "contractType": "SPOT", + "cargoScope": json.dumps(cargo_scope), + "routes": json.dumps(routes), + } + if flow.customs: + fields["customsClearingAgent"] = "Seed Agent" + return fields + + +def signature_b64() -> str: + return base64.b64encode(WAAFI.read_bytes()).decode() + + +# --------------------------------------------------------------------------- # +# Lifecycle driver — create → submit → accept → approve → generate → sign x2 +# --------------------------------------------------------------------------- # +def waafi_file_tuple(field_name: str) -> tuple: + return (field_name, (WAAFI.name, WAAFI.read_bytes(), "image/jpeg")) + + +def drive_flow( + flow: Flow, idx: int, customer: Client, admin: Client, ref: RefData, otp_phone: str +) -> dict[str, Any]: + result: dict[str, Any] = {"flow": flow.label, "n": idx, "status": None} + + # S1 — create (customer, multipart, waafi attached as intake doc) + fields = build_create_payload(flow, ref, idx) + contract = customer.post_multipart( + "/contracts", data=fields, files=[waafi_file_tuple("intake_document")] + ) + cid = contract["id"] + result["contractId"] = cid + result["reference"] = contract.get("reference") + + # S2 — submit (customer). May go to PRICE_CHANGED_PENDING_CONFIRM → confirm. + contract = customer.post_json(f"/contracts/{cid}/submit") + if (contract or {}).get("status") == "PRICE_CHANGED_PENDING_CONFIRM": + contract = customer.post_json(f"/contracts/{cid}/confirm-submit") + + # S3 — staff accept (admin) → PENDING_APPROVAL + approval chain + admin.post_json(f"/contracts/{cid}/staff/accept", {"validityDays": VALIDITY_DAYS}) + + # S4 — approve every pending step IN ORDER with its exact requiredRole (admin) + approve_all_steps(admin, cid) + + # S5 — generate contract document (admin) → CONTRACT_READY + admin.post_json(f"/contracts/{cid}/contract/generate") + + # S6 — customer sign (needs OTP) → SIGNED_CUSTOMER + send_otp(customer, otp_phone) + time.sleep(1.0) # let the OTP row land + otp = read_otp_from_db(otp_phone) + customer.post_json( + f"/contracts/{cid}/contract/sign", + { + "role": "CUSTOMER", + "signatureImageBase64": signature_b64(), + "signerDisplayName": "Seed Customer", + "consentText": "I agree.", + "otp": otp, + "otpPhone": otp_phone, + }, + ) + + # S7 — staff counter-sign (admin) → FULLY_EXECUTED / CONTRACT_ACTIVE / + # AWAITING_CLEARANCE_DOCUMENTS depending on dimension. STOP HERE. + signed = admin.post_json( + f"/contracts/{cid}/contract/sign", + { + "role": "STAFF", + "signatureImageBase64": signature_b64(), + "signerDisplayName": "Seed Staff", + "consentText": "Countersigned.", + }, + ) + result["status"] = (signed or {}).get("status") + return result + + +def approve_all_steps(admin: Client, cid: str) -> None: + """Read the contract, approve each PENDING approval step in order. Superadmin + can approve any role, but the endpoint still checks step.requiredRole == body, + so we echo the step's own requiredRole back.""" + guard = 0 + while True: + guard += 1 + if guard > 12: + raise RuntimeError(f"Approval loop exceeded 12 iterations for {cid}") + contract = admin.get(f"/contracts/{cid}") + steps = contract.get("approvalSteps") or [] + pending = [s for s in steps if s.get("status") == "PENDING"] + if not pending: + return + # findNextPendingApprovalStep orders by sequence; sort the same way. + pending.sort(key=lambda s: s.get("sequence", s.get("sortOrder", 0))) + step = pending[0] + admin.post_json( + f"/contracts/{cid}/approval-steps/{step['id']}/approve", + {"requiredRole": step["requiredRole"]}, + ) + + +# --------------------------------------------------------------------------- # +# Main +# --------------------------------------------------------------------------- # +VALID_FILTERS = {"all", "intercity", "import", "export"} + + +def parse_filters(argv: list[str]) -> set[str]: + args = [a.lower() for a in argv[1:]] + if not args or "all" in args: + return {"intercity", "import", "export"} + unknown = set(args) - VALID_FILTERS + if unknown: + raise SystemExit( + f"Unknown filter(s): {', '.join(sorted(unknown))}. " + f"Valid: {', '.join(sorted(VALID_FILTERS))}" + ) + return set(args) + + +def require_config() -> None: + missing = [ + name + for name, val in [ + ("CUSTOMER_EMAIL", CUSTOMER_EMAIL), + ("CUSTOMER_PASSWORD", CUSTOMER_PASSWORD), + ("ADMIN_EMAIL", ADMIN_EMAIL), + ("ADMIN_PASSWORD", ADMIN_PASSWORD), + ] + if not val + ] + if missing: + raise SystemExit(f"Missing required .env keys: {', '.join(missing)}") + if not WAAFI.exists(): + raise SystemExit(f"Missing signature/upload image: {WAAFI}") + + +def main() -> None: + require_config() + wanted = parse_filters(sys.argv) + + flows = [f for f in build_flow_matrix() if f.movement in wanted] + total = len(flows) * CONTRACTS_PER_FLOW + print(f"API : {API_URL}") + print(f"Filters : {', '.join(sorted(wanted))}") + print(f"Flows : {len(flows)} x {CONTRACTS_PER_FLOW} = {total} contracts\n") + + print("Logging in...") + customer = login(CUSTOMER_EMAIL, CUSTOMER_PASSWORD, "customer") + admin = login(ADMIN_EMAIL, ADMIN_PASSWORD, "admin") + + otp_phone = resolve_otp_phone(customer) + print(f"OTP phone: {otp_phone}") + + print("Loading reference data...") + ref = load_ref_data(admin) + + results: list[dict] = [] + for flow in flows: + for n in range(1, CONTRACTS_PER_FLOW + 1): + tag = f"[{flow.label} #{n}]" + try: + res = drive_flow(flow, n, customer, admin, ref, otp_phone) + print(f" OK {tag} {res['reference']} -> {res['status']}") + results.append(res) + except Exception as exc: # noqa: BLE001 — report and continue + print(f" FAIL {tag} {exc}") + results.append({"flow": flow.label, "n": n, "error": str(exc)}) + + ok = [r for r in results if not r.get("error")] + bad = [r for r in results if r.get("error")] + print(f"\nDone. {len(ok)} created, {len(bad)} failed, {total} attempted.") + if bad: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/apps/edr-freight-api/py/prod-check-invoices-payments.sql b/apps/edr-freight-api/py/prod-check-invoices-payments.sql new file mode 100644 index 000000000..fa94c481d --- /dev/null +++ b/apps/edr-freight-api/py/prod-check-invoices-payments.sql @@ -0,0 +1,114 @@ +-- ============================================================================ +-- Production DB drift check + fix for the batch/window flow. +-- +-- WHY: BookingBatchService.reserve() calls billing.syncPayableDueDate, which +-- queries freight.invoices.payments (a jsonb ledger added by migration +-- 1828000000000-ExtendInvoicesForPartialPayment). If that column is MISSING on +-- production (snapshot/restore drift — the migration can read as "applied" in +-- freight.migrations while the DDL never took effect), every reserve() throws +-- `column Invoice.payments does not exist`, the batch fill loop aborts mid-pass, +-- and you see exactly: +-- * only ONE booking gets a pay window (the loop dies after the first reserve +-- whose invoice sync throws), and +-- * reservations never expire cleanly (the settle path hits the same query). +-- +-- Run STEP 1 first (read-only). If it shows the columns are MISSING, run STEP 2 +-- (idempotent, additive — safe to run even if partially applied). +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- STEP 1 — CHECK (read-only). Expect all 6 rows present; if any are missing, +-- production has the drift and STEP 2 is required. +-- --------------------------------------------------------------------------- +SELECT column_name +FROM information_schema.columns +WHERE table_schema = 'freight' + AND table_name = 'invoices' + AND column_name IN ( + 'payments', 'subtotal_amount', 'tax_amount', + 'paid_amount', 'balance_amount', 'paid_at' + ) +ORDER BY column_name; +-- Also confirm the enum has the partial-payment statuses: +SELECT unnest(enum_range(NULL::freight.invoices_status_enum))::text AS status; +-- Expect ISSUED and PARTIALLY_PAID to be present. + + +-- --------------------------------------------------------------------------- +-- STEP 2 — FIX (idempotent). Only run if STEP 1 showed missing columns. +-- Mirrors migration 1828000000000 up(); all ADD COLUMN IF NOT EXISTS, so +-- re-running is safe. Wrapped so the enum additions (which cannot run inside a +-- transaction block with immediate use) are applied first, then the columns. +-- --------------------------------------------------------------------------- + +-- Enum values (no-op if they already exist). +ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING'; +ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID'; + +-- Money-tracking + payments ledger columns. +ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_at timestamptz, + ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]'; + +-- Backfill derived money fields for existing rows (only rows not already set). +UPDATE freight.invoices + SET subtotal_amount = total_amount, + balance_amount = total_amount + WHERE subtotal_amount = 0 AND balance_amount = 0; + +UPDATE freight.invoices + SET paid_amount = total_amount, + balance_amount = 0, + paid_at = COALESCE(paid_at, updated_at) + WHERE status = 'PAID' AND paid_amount = 0; + +-- --------------------------------------------------------------------------- +-- STEP 3 — RE-CHECK. Re-run STEP 1; all 6 columns + both enum values should +-- now be present. After this, deploy the freight_feature/usermanagement branch +-- and the batch will reserve ALL fitting bookings + expire non-payers + top up. +-- --------------------------------------------------------------------------- + + +-- ============================================================================ +-- STEP 4 — BROADER DRIFT AUDIT (read-only). The same snapshot drift that hid +-- invoices.payments can hide OTHER columns the batch flow selects. reserve() +-- and settleReserved() load the FULL Booking entity, so ANY missing booking +-- column throws mid-loop (e.g. we already hit +-- `column Booking.consolidation_resume_status does not exist`). This lists every +-- booking column the entity expects that is MISSING from production — expect +-- ZERO rows. Any row = a drifted migration whose DDL must be re-applied. +-- ============================================================================ +WITH expected(col) AS ( + SELECT unnest(ARRAY[ + 'reference','customer_id','company_id','company_profile_id','is_government', + 'government_institution','train_id','status','contract_id','contract_route_id', + 'booking_type','contract_kind','created_by_role','created_by_user_id', + 'scheduled_date','estimated_shipment_date','expires_at','total_amount', + 'adjusted_total_amount','adjusted_by_staff_id','adjusted_at','adjustment_reason', + 'contract_validity_days','contract_valid_from','contract_valid_until', + 'payment_status','contract_type','service_type_id','customs_clearing_enabled', + 'customs_clearing_agent','equipment_return','origin_yard_id','destination_yard_id', + 'trade_direction','freight_type','cargo_type_id','cargo_free_text','shipping_line_id', + 'cargo_total_weight_vgm','is_hazardous','is_reefer','bulk_hazardous_quantity', + 'bulk_reefer_quantity','payment_currency','pnr_code','fully_executed_at', + 'pricing_breakdown','locked_at','priority_score','consolidation_partner_id', + 'consolidation_resume_status','wagons_required','scheduling_status', + 'hold_started_at','hold_expires_at','scheduled_at','train_schedule_id', + 'loaded_at','arrived_at','payment_deadline','selected_for_batch_at', + 'gl_station_yard_id','clearance_current_phase','duty_required', + 'vessel_departure_date','ro_amendment_requested_at','ro_hold_reason', + 'pre_clearance_finalized_at','gl_assigned_staff_id','gl_assigned_at' + ]) +) +SELECT e.col AS missing_booking_column +FROM expected e +LEFT JOIN information_schema.columns c + ON c.table_schema = 'freight' AND c.table_name = 'bookings' AND c.column_name = e.col +WHERE c.column_name IS NULL +ORDER BY e.col; +-- If any rows come back, tell me which columns — I'll give you the exact +-- migration(s) to re-apply (each is ADD COLUMN IF NOT EXISTS, idempotent). diff --git a/apps/edr-freight-api/py/requirements.txt b/apps/edr-freight-api/py/requirements.txt new file mode 100644 index 000000000..7dcde7140 --- /dev/null +++ b/apps/edr-freight-api/py/requirements.txt @@ -0,0 +1,3 @@ +requests>=2.31 +psycopg[binary]>=3.1 +python-dotenv>=1.0 diff --git a/apps/edr-freight-api/py/waafi.jpeg b/apps/edr-freight-api/py/waafi.jpeg new file mode 100644 index 000000000..392de36cc Binary files /dev/null and b/apps/edr-freight-api/py/waafi.jpeg differ diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index db05ae26f..9561a7b73 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -1,10 +1,17 @@ -import { Module, OnApplicationBootstrap } from "@nestjs/common"; +import { + MiddlewareConsumer, + Module, + OnApplicationBootstrap, +} from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; import { ScheduleModule } from "@nestjs/schedule"; import { EventEmitterModule } from "@nestjs/event-emitter"; import { DataSource, DataSourceOptions } from "typeorm"; -import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas"; +import { + ensurePostgresSchemas, + APPLICATION_SEARCH_PATH, +} from "./config/ensure-postgres-schemas"; import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; @@ -12,6 +19,7 @@ import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; import telebirrConfig from "./config/telebirr.config"; import rabbitmqConfig from "./config/rabbitmq.config"; +import faydaConfig from "./config/fayda.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; import { ContractsModule } from "./modules/contracts/contracts.module"; @@ -30,6 +38,7 @@ import { CompaniesModule } from "./modules/companies/companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; +import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { OtpModule } from "./modules/otp/otp.module"; @@ -42,6 +51,7 @@ import { EDR_FREIGHT_PERMISSIONS, } from "./seed/edr-freight.seed"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; +import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder"; import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; @@ -59,26 +69,36 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; +import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; -import { WagonsModule } from './modules/wagons/wagons.module'; -import { ContainersModule } from './modules/container-management/containers.module'; -import { CargoesModule } from './modules/cargoes/cargoes.module'; -import { RoutesModule } from './modules/routes/routes.module'; -import { WarehousesModule } from './modules/warehouses/warehouses.module'; -import { OverviewModule } from './modules/overview/overview.module'; -import { VehiclesModule } from './modules/vehicles/vehicles.module'; -import { DriversModule } from './modules/drivers/drivers.module'; -import { FirstMileModule } from './modules/first-mile/first-mile.module'; -import { LastMileModule } from './modules/last-mile/last-mile.module'; -import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; -import { ImportOperationsModule } from './modules/import-operations/import-operations.module'; +import { VerifaydaModule } from './modules/verifayda/verifayda.module'; +import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module'; +import { WagonsModule } from "./modules/wagons/wagons.module"; +import { ContainersModule } from "./modules/container-management/containers.module"; +import { CargoesModule } from "./modules/cargoes/cargoes.module"; +import { RoutesModule } from "./modules/routes/routes.module"; +import { WarehousesModule } from "./modules/warehouses/warehouses.module"; +import { OverviewModule } from "./modules/overview/overview.module"; +import { VehiclesModule } from "./modules/vehicles/vehicles.module"; +import { DriversModule } from "./modules/drivers/drivers.module"; +import { FuelModule } from "./modules/fuel/fuel.module"; +import { MaintenanceModule } from "./modules/maintenance/maintenance.module"; +import { ComplianceModule } from "./modules/compliance/compliance.module"; +import { IncidentsModule } from "./modules/incidents/incidents.module"; +import { ProcurementModule } from "./modules/procurement/procurement.module"; +import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module"; +import { FirstMileModule } from "./modules/first-mile/first-mile.module"; +import { LastMileModule } from "./modules/last-mile/last-mile.module"; +import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; +import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; +import { LoggerMiddleware } from "./logger.middleware"; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig], + load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig], }), ScheduleModule.forRoot(), EventEmitterModule.forRoot(), @@ -92,7 +112,27 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera } await ensurePostgresSchemas(options as DataSourceOptions); const dataSource = new DataSource(options as DataSourceOptions); - return dataSource.initialize(); + await dataSource.initialize(); + + // The remote edr_dev DB sits behind a connection pooler/proxy that rejects + // the Postgres `options` startup parameter (08P01). Instead of setting + // search_path at connect time, apply it per physical connection: the pg + // Pool emits `connect` for every new client (initial fill, pool growth, + // reconnect), so every backend session gets the schema search order. + const pool = (dataSource.driver as { master?: unknown }).master as + | { on?: (event: string, cb: (client: unknown) => void) => void } + | undefined; + if (pool?.on) { + pool.on("connect", (client) => { + (client as { query: (sql: string) => Promise }) + .query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`) + .catch(() => { + /* connection will be validated on first real query */ + }); + }); + } + + return dataSource; }, }), SharedAuthModule, @@ -115,6 +155,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera TrackingModule, BillingModule, NotificationsModule, + NotificationInboxModule, FileUploadSettingsModule, DropdownSettingsModule, OtpModule, @@ -133,13 +174,22 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera OverviewModule, VehiclesModule, DriversModule, + FuelModule, + MaintenanceModule, + ComplianceModule, + IncidentsModule, + ProcurementModule, + GpsTrackingModule, FirstMileModule, LastMileModule, InterchangeDocumentsModule, ImportOperationsModule, + VerifaydaModule, + FleetHistoryModule, ], providers: [ EdrOrgSeeder, + FreightPositionsSeeder, DemoUsersSeeder, FreightStaffUsersSeeder, PricingDataSeeder, @@ -156,12 +206,14 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera ExportDjiboutiInterchangeDemoSeeder, MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, + PaidImportExportMileDemoSeeder, ], }) export class AppModule implements OnApplicationBootstrap { constructor( private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, + private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly demoUsersSeeder: DemoUsersSeeder, private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, private readonly pricingDataSeeder: PricingDataSeeder, @@ -183,6 +235,7 @@ export class AppModule implements OnApplicationBootstrap { await this.freightPermissionKeyMigrationSeeder.run(); await this.seeder.run(); await this.edrOrgSeeder.run(); + await this.freightPositionsSeeder.run(); await this.demoUsersSeeder.run(); await this.freightStaffUsersSeeder.run(); await this.pricingDataSeeder.run(); @@ -207,4 +260,8 @@ export class AppModule implements OnApplicationBootstrap { // bookings bill to. Idempotent — keyed by fixed IDs. await this.govCompaniesSeeder.run(); } + + configure(consumer: MiddlewareConsumer) { + consumer.apply(LoggerMiddleware).forRoutes("*"); + } } diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 8d55f1dc4..7eae4e94d 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -28,3 +28,7 @@ export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage); /** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */ export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin); + +/** Container allocation on a booking (allocate-containers endpoint). */ +export const AllocationManage = () => + BookingStaff(FREIGHT_PERMS.allocation.manage); diff --git a/apps/edr-freight-api/src/common/derive-trade-direction.util.ts b/apps/edr-freight-api/src/common/derive-trade-direction.util.ts index b8a6f8afb..58b2d733a 100644 --- a/apps/edr-freight-api/src/common/derive-trade-direction.util.ts +++ b/apps/edr-freight-api/src/common/derive-trade-direction.util.ts @@ -1,20 +1,33 @@ -import type { ScheduleTradeDirection } from '@edr/types'; +import { YardCountry, type ScheduleTradeDirection } from '@edr/types'; type YardLike = { country?: string | null }; -/** Derive booking/schedule trade direction from origin and destination yard countries. */ +/** + * Derive trade direction from origin and destination yard countries. + * Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT, same country = + * DOMESTIC (shown as "Intercity"; scheduling/contracts reject it for now). + * Comparison is strict against the YardCountry enum values the yards table is + * constrained to; the trim/case fold only shields legacy rows. + */ export function deriveTradeDirection( originYard: YardLike, destinationYard: YardLike, ): ScheduleTradeDirection { - const originCountry = originYard.country?.trim().toLowerCase(); - const destinationCountry = destinationYard.country?.trim().toLowerCase(); + const origin = normalizeCountry(originYard.country); + const destination = normalizeCountry(destinationYard.country); - if (originCountry === 'djibouti') { + if (origin === YardCountry.DJIBOUTI && destination === YardCountry.ETHIOPIA) { return 'IMPORT'; } - if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') { + if (origin === YardCountry.ETHIOPIA && destination === YardCountry.DJIBOUTI) { return 'EXPORT'; } return 'DOMESTIC'; } + +function normalizeCountry(country: string | null | undefined): YardCountry | null { + const folded = country?.trim().toLowerCase(); + if (folded === YardCountry.ETHIOPIA.toLowerCase()) return YardCountry.ETHIOPIA; + if (folded === YardCountry.DJIBOUTI.toLowerCase()) return YardCountry.DJIBOUTI; + return null; +} diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 0e7375b19..dcf09fbd9 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -44,7 +44,6 @@ import { NotificationTemplate, } from "@tria-plc/iamapi-common"; import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity"; -import { APPLICATION_SEARCH_PATH } from "./ensure-postgres-schemas"; const iamEntities = [ DefaultPosition, @@ -105,9 +104,12 @@ export default registerAs("database", (): TypeOrmModuleOptions => { password: process.env.DB_PASSWORD ?? "", database: process.env.DB_NAME ?? "edr_freight", schema: "public", - extra: { - options: `-c search_path=${APPLICATION_SEARCH_PATH}`, - }, + // NOTE: do NOT pass `extra.options: '-c search_path=...'`. That sends the + // Postgres startup `options` parameter, which connection poolers (PgBouncer / + // proxies fronting the remote edr_dev DB) reject with + // `08P01 unsupported startup parameter in options: search_path`. + // The search_path is instead applied per-connection via a pool `connect` + // handler in app.module.ts (see setPoolSearchPath). entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities], autoLoadEntities: true, migrations: [ @@ -116,8 +118,10 @@ export default registerAs("database", (): TypeOrmModuleOptions => { freightMigrationsGlob, ], migrationsRun: true, + migrationsTransactionMode: "each", // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). synchronize: false, - logging: process.env.NODE_ENV === "development", + logging: + process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"], }; }); diff --git a/apps/edr-freight-api/src/config/fayda.config.ts b/apps/edr-freight-api/src/config/fayda.config.ts new file mode 100644 index 000000000..a25289159 --- /dev/null +++ b/apps/edr-freight-api/src/config/fayda.config.ts @@ -0,0 +1,126 @@ +import { registerAs } from '@nestjs/config'; + +export interface FaydaJwk { + kty: 'RSA'; + use?: string; + kid?: string; + alg?: string; + n: string; + e: string; + d: string; + p?: string; + q?: string; + dp?: string; + dq?: string; + qi?: string; +} + +export type FaydaPlatform = 'WEB' | 'MOBILE'; + +export interface FaydaConfig { + enabled: boolean; + clientId: string; + authorizationEndpoint: string; + tokenEndpoint: string; + userInfoEndpoint: string; + /** OAuth redirect_uri sent to eSignet for MOBILE clients. */ + redirectUri: string; + /** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */ + webRedirectUri: string; + privateJwk: FaydaJwk; + scope: string; + acrValues: string; + claimsLocales: string; + sessionTtlMinutes: number; +} + +const REQUIRED_VARS = [ + 'FAYDA_CLIENT_ID', + 'FAYDA_AUTHORIZATION_ENDPOINT', + 'FAYDA_TOKEN_ENDPOINT', + 'FAYDA_USERINFO_ENDPOINT', + 'FAYDA_PRIVATE_KEY_BASE64', +] as const; + +function decodePrivateJwk(base64: string): FaydaJwk { + let jwk: unknown; + try { + const json = Buffer.from(base64, 'base64').toString('utf8'); + jwk = JSON.parse(json); + } catch (err) { + throw new Error( + `FAYDA_PRIVATE_KEY_BASE64 is not valid Base64-encoded JSON: ${(err as Error).message}`, + ); + } + if (!jwk || typeof jwk !== 'object') { + throw new Error('FAYDA_PRIVATE_KEY_BASE64 must decode to a JSON object'); + } + const candidate = jwk as Partial; + if (candidate.kty !== 'RSA') { + throw new Error('FAYDA_PRIVATE_KEY_BASE64 JWK must have kty="RSA"'); + } + if (!candidate.n || !candidate.e || !candidate.d) { + throw new Error( + 'FAYDA_PRIVATE_KEY_BASE64 JWK is missing required RSA private-key fields (n, e, d)', + ); + } + return candidate as FaydaJwk; +} + +export default registerAs('fayda', (): FaydaConfig => { + const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true'; + // `profile` covers name/birthdate/gender/picture; `email`, `phone`, `address` + // are needed so the matching essential claims aren't rejected as out-of-scope. + const scope = process.env.FAYDA_SCOPE ?? 'openid profile email phone address'; + const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code'; + const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am'; + const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10); + const redirectUri = process.env.FAYDA_REDIRECT_URI ?? ''; + const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri; + if (!enabled) { + return { + enabled: false, + clientId: process.env.FAYDA_CLIENT_ID ?? '', + authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '', + tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '', + userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '', + redirectUri, + webRedirectUri, + privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, + scope, + acrValues, + claimsLocales, + sessionTtlMinutes: Number.isNaN(sessionTtl) || sessionTtl <= 0 ? 10 : sessionTtl, + }; + } + + const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + if (missing.length > 0) { + throw new Error( + `Fayda integration is enabled (FAYDA_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`, + ); + } + if (!redirectUri) { + throw new Error( + 'Fayda integration is enabled but the redirect URI is missing: set FAYDA_REDIRECT_URI', + ); + } + if (Number.isNaN(sessionTtl) || sessionTtl <= 0) { + throw new Error('FAYDA_SESSION_TTL_MINUTES must be a positive integer'); + } + + return { + enabled: true, + clientId: process.env.FAYDA_CLIENT_ID!, + authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!, + tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!, + userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!, + redirectUri, + webRedirectUri, + privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!), + scope, + acrValues, + claimsLocales, + sessionTtlMinutes: sessionTtl, + }; +}); diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index caf161614..05061ed9a 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -200,7 +200,9 @@ export class ContractDocumentViewModelBuilder { serviceType: this.valueOrDash( contract.serviceType?.serviceName ?? contract.serviceType?.code, ), - scheduledDate: this.formatDate(contract.estimatedShipmentDate), + // Estimated shipment date was removed from the contract wizard; the + // binding scheduled date is set per-booking, not on the contract. + scheduledDate: this.formatDate(null), contractType: this.valueOrDash(contract.contractType), cargoDescription: this.valueOrDash(cargoName), totalWeightVgm: '—', diff --git a/apps/edr-freight-api/src/logger.middleware.ts b/apps/edr-freight-api/src/logger.middleware.ts new file mode 100644 index 000000000..dd7532ec8 --- /dev/null +++ b/apps/edr-freight-api/src/logger.middleware.ts @@ -0,0 +1,21 @@ +import { Injectable, NestMiddleware, Logger } from "@nestjs/common"; +import { Request, Response, NextFunction } from "express"; + +@Injectable() +export class LoggerMiddleware implements NestMiddleware { + private readonly logger = new Logger("HTTP"); + + use(req: Request, res: Response, next: NextFunction) { + const start = Date.now(); + + res.on("finish", () => { + const duration = Date.now() - start; + + this.logger.log( + `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`, + ); + }); + + next(); + } +} diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index 0107956e4..0fa1056dd 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -38,7 +38,8 @@ async function bootstrap() { maxAge: 86400, // cache preflight for 24h to cut chatter in dev }); - app.setGlobalPrefix("api"); + // /callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack endpoint. + app.setGlobalPrefix("api", { exclude: ["callback"] }); // enableImplicitConversion is OFF: class-transformer's implicit boolean // coercion turns any non-empty multipart/form-data string (including the // literal "false") into `true`, silently corrupting flags like isHazardous diff --git a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts index 65052bad4..08ce634eb 100644 --- a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts +++ b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts @@ -93,20 +93,25 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter ); // Create indexes for service_types - await queryRunner.createIndex( - "freight.service_types", - new TableIndex({ - name: "IDX_SERVICE_TYPES_IS_ACTIVE", - columnNames: ["is_active"], - }), - ); - await queryRunner.createIndex( - "freight.service_types", - new TableIndex({ - name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", - columnNames: ["display_order"], - }), - ); + const table = await queryRunner.getTable("freight.service_types"); + if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_IS_ACTIVE")) { + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_IS_ACTIVE", + columnNames: ["is_active"], + }), + ); + } + if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_DISPLAY_ORDER")) { + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", + columnNames: ["display_order"], + }), + ); + } // Create cargo_types table if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable( diff --git a/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts b/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts index fa6087faa..1cad0fe66 100644 --- a/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts +++ b/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts @@ -56,6 +56,7 @@ export class AddWarehouseAllocationAndFeeRules1791000000000 implements Migration { name: 'zone_id', type: 'uuid', isNullable: true }, { name: 'free_days', type: 'int', default: 0 }, { name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'tiers', type: 'jsonb', default: "'[]'" }, { name: 'currency', type: 'varchar', length: '8', default: "'USD'" }, { name: 'is_active', type: 'boolean', default: true }, { name: 'created_at', type: 'timestamptz', default: 'now()' }, diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts index 65a3e764b..4c2fb5d95 100644 --- a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -62,20 +62,96 @@ export class CreateInvoices1821000000002 implements MigrationInterface { `); await queryRunner.query( - `CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`, + ` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS id uuid DEFAULT uuid_generate_v4(), + ADD COLUMN IF NOT EXISTS invoice_number varchar(64), + ADD COLUMN IF NOT EXISTS company_id uuid, + ADD COLUMN IF NOT EXISTS company_profile_id uuid, + ADD COLUMN IF NOT EXISTS total_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB', + ADD COLUMN IF NOT EXISTS status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT', + ADD COLUMN IF NOT EXISTS source varchar(255), + ADD COLUMN IF NOT EXISTS source_id varchar(255), + ADD COLUMN IF NOT EXISTS type varchar(255), + ADD COLUMN IF NOT EXISTS issued_at timestamptz, + ADD COLUMN IF NOT EXISTS payment_id uuid, + ADD COLUMN IF NOT EXISTS due_at timestamptz, + ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS deleted_at timestamptz; + `, + ); + await queryRunner.query(` + UPDATE freight.invoices + SET due_at = COALESCE(due_at, issued_at, created_at, now()) + WHERE due_at IS NULL; + `); + await queryRunner.query(`ALTER TABLE freight.invoices ALTER COLUMN due_at SET NOT NULL;`); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE contype = 'p' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT pk_invoices PRIMARY KEY (id); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'uq_invoices_invoice_number' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_invoices_company' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company + FOREIGN KEY (company_id) REFERENCES freight.companies (id) ON DELETE RESTRICT; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_invoices_company_profile' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company_profile + FOREIGN KEY (company_profile_id) REFERENCES freight.company_profiles (id) ON DELETE RESTRICT; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_invoices_payment' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_payment + FOREIGN KEY (payment_id) REFERENCES freight.payments (id) ON DELETE SET NULL; + END IF; + END $$; + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_invoices_company ON freight.invoices (company_id);`, ); await queryRunner.query( - `CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`, + `CREATE INDEX IF NOT EXISTS idx_invoices_company_profile ON freight.invoices (company_profile_id);`, ); await queryRunner.query( - `CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`, + `CREATE INDEX IF NOT EXISTS idx_invoices_source ON freight.invoices (source, source_id);`, ); await queryRunner.query( - `CREATE INDEX idx_invoices_status ON freight.invoices (status);`, + `CREATE INDEX IF NOT EXISTS idx_invoices_status ON freight.invoices (status);`, ); await queryRunner.query(` - CREATE TABLE freight.invoice_lines ( + CREATE TABLE IF NOT EXISTS freight.invoice_lines ( id uuid NOT NULL DEFAULT uuid_generate_v4(), invoice_id uuid NOT NULL, charge_type varchar NOT NULL, @@ -95,7 +171,7 @@ export class CreateInvoices1821000000002 implements MigrationInterface { `); await queryRunner.query( - `CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, + `CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, ); } diff --git a/apps/edr-freight-api/src/migrations/1828000000000-AddBulkHazmatReeferQuantity.ts b/apps/edr-freight-api/src/migrations/1828000000000-AddBulkHazmatReeferQuantity.ts new file mode 100644 index 000000000..cc9437eaf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1828000000000-AddBulkHazmatReeferQuantity.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Bulk / break-bulk freight can now declare HOW MUCH of the cargo is hazardous + * or refrigerated, in the cargo's own unit of measure (tons for PER_TON, item + * count for PER_ITEM). These two columns hold that amount on the booking; they + * stay 0 for container freight (which tracks it per line on booking_container) + * and for bulk cargo with no hazardous/reefer portion. The existing + * is_hazardous / is_reefer booleans remain the surcharge trigger. + */ +export class AddBulkHazmatReeferQuantity1828000000000 implements MigrationInterface { + name = 'AddBulkHazmatReeferQuantity1828000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_reefer_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_reefer_quantity;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_hazardous_quantity;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts b/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts new file mode 100644 index 000000000..55239c13f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts @@ -0,0 +1,71 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Extend `freight.invoices` into the billing record of record for every source + * (booking, demurrage, warehouse fees, …) so warehouse fee invoices can be + * centralized onto it instead of the parallel `warehouse_fee_invoices` table. + * + * Adds money tracking that supports partial payment (`subtotal/tax/paid/balance`), + * a `paid_at` stamp, a `payments` jsonb ledger, and the `ISSUED` / `PARTIALLY_PAID` + * statuses the warehouse flow uses. + * + * Matches billing/entities/invoice.entity.ts. All columns are additive with + * defaults, so existing booking/demurrage rows are unaffected. + */ +export class ExtendInvoicesForPartialPayment1828000000000 + implements MigrationInterface +{ + name = "ExtendInvoicesForPartialPayment1828000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // New statuses. ADD VALUE is non-transactional-value-safe on PG 12+ as long + // as the value is not referenced in the same transaction (it is not here). + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';`, + ); + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';`, + ); + + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_at timestamptz, + ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]'; + `); + + // Backfill existing rows: subtotal mirrors the total (no tax was modeled), + // the outstanding balance is the full total for unpaid invoices. + await queryRunner.query(` + UPDATE freight.invoices + SET subtotal_amount = total_amount, + balance_amount = total_amount; + `); + + // Already-settled invoices: fully paid, zero balance, stamped from updated_at. + await queryRunner.query(` + UPDATE freight.invoices + SET paid_amount = total_amount, + balance_amount = 0, + paid_at = updated_at + WHERE status = 'PAID'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS payments, + DROP COLUMN IF EXISTS paid_at, + DROP COLUMN IF EXISTS balance_amount, + DROP COLUMN IF EXISTS paid_amount, + DROP COLUMN IF EXISTS tax_amount, + DROP COLUMN IF EXISTS subtotal_amount; + `); + // Postgres cannot drop individual enum values; ISSUED / PARTIALLY_PAID are + // left on freight.invoices_status_enum (harmless, unused after down). + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts new file mode 100644 index 000000000..75082c5c4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts @@ -0,0 +1,247 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Fold warehouse fee invoices into the central billing system. + * + * Warehouse fee invoices are no longer a standalone aggregate: each becomes a + * global `freight.invoices` row (`source = 'warehouse'`, `source_id = + * inventory_id`) with its items as `freight.invoice_lines`. The warehouse + * service is now a thin layer over `BillingService`. This migration backfills the + * existing rows (preserving ids, numbers, status, amounts and payment history), + * then drops the two legacy tables. + * + * Rows that cannot be billed centrally — no company to bill (`company_id` / + * `company_profile_id` underivable from the customer or the booking) — are not + * migrated; they could never have been charged through the gateway and are + * dropped with the table. + */ +export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterface { + name = 'CentralizeWarehouseInvoices1829000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name = 'invoices' + AND column_name = 'booking_id' + ) THEN + ALTER TABLE freight.invoices ALTER COLUMN booking_id DROP NOT NULL; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name = 'invoices' + AND column_name = 'amount' + ) THEN + ALTER TABLE freight.invoices ALTER COLUMN amount DROP NOT NULL; + END IF; + END $$; + `); + + // 1. Invoice headers. Keep the same id so items still link, and so any + // external reference to the invoice id stays valid. + await queryRunner.query(` + INSERT INTO freight.invoices ( + id, invoice_number, company_id, company_profile_id, + subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount, + currency, status, source, source_id, type, + issued_at, paid_at, payments, payment_id, due_at, + created_at, updated_at, deleted_at + ) + SELECT + fee.id, + fee.invoice_number, + COALESCE(fee.customer_id, b.company_id), + COALESCE( + b.company_profile_id, + (SELECT cp.id + FROM freight.company_profiles cp + WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id) + AND cp.deleted_at IS NULL + ORDER BY cp.created_at ASC + LIMIT 1) + ), + fee.subtotal_amount, fee.tax_amount, fee.total_amount, fee.paid_amount, fee.balance_amount, + fee.currency, + fee.status::freight.invoices_status_enum, + 'warehouse', + fee.inventory_id, + fee.invoice_type, + fee.issued_at, + fee.paid_at, + COALESCE(fee.payments, '[]'::jsonb), + NULL, + COALESCE(fee.due_date, fee.issued_at, fee.created_at), + fee.created_at, fee.updated_at, fee.deleted_at + FROM freight.warehouse_fee_invoices fee + LEFT JOIN freight.bookings b ON b.id = fee.booking_id + WHERE COALESCE(fee.customer_id, b.company_id) IS NOT NULL + AND COALESCE( + b.company_profile_id, + (SELECT cp.id + FROM freight.company_profiles cp + WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id) + AND cp.deleted_at IS NULL + ORDER BY cp.created_at ASC + LIMIT 1) + ) IS NOT NULL + ON CONFLICT (id) DO NOTHING; + `); + + // 2. Invoice lines — only for items whose parent invoice migrated. Warehouse + // fee fields (fee_rule_id / chargeable_days / free_days) move into the + // line's jsonb metadata. + await queryRunner.query(` + INSERT INTO freight.invoice_lines ( + id, invoice_id, charge_type, description, quantity, unit_rate, amount, + currency, metadata, created_at, updated_at, deleted_at + ) + SELECT + item.id, + item.invoice_id, + item.fee_type, + item.description, + item.quantity, + item.unit_rate, + item.amount, + item.currency, + jsonb_build_object( + 'feeRuleId', item.fee_rule_id, + 'chargeableDays', item.chargeable_days, + 'freeDays', item.free_days + ), + item.created_at, item.updated_at, item.deleted_at + FROM freight.warehouse_fee_invoice_items item + JOIN freight.invoices i ON i.id = item.invoice_id AND i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + + // 3. Drop the legacy tables (items first — FK to invoices). + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoice_items;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoices;`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Recreate the legacy tables … + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoices ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + invoice_number varchar(40) NOT NULL, + booking_id uuid, + customer_id uuid, + inventory_id uuid NOT NULL, + facility_id uuid, + warehouse_id uuid, + yard_id uuid, + zone_id uuid, + invoice_type varchar(32) NOT NULL DEFAULT 'MIXED_WAREHOUSE_FEES', + status varchar(20) NOT NULL DEFAULT 'DRAFT', + subtotal_amount numeric(14,2) NOT NULL DEFAULT 0, + tax_amount numeric(14,2) NOT NULL DEFAULT 0, + total_amount numeric(14,2) NOT NULL DEFAULT 0, + paid_amount numeric(14,2) NOT NULL DEFAULT 0, + balance_amount numeric(14,2) NOT NULL DEFAULT 0, + currency varchar(8) NOT NULL DEFAULT 'USD', + period_start timestamptz, + period_end timestamptz, + issued_at timestamptz, + due_date timestamptz, + paid_at timestamptz, + cancelled_at timestamptz, + payments jsonb NOT NULL DEFAULT '[]', + notes text, + CONSTRAINT "PK_warehouse_fee_invoices" PRIMARY KEY (id), + CONSTRAINT "UQ_warehouse_fee_invoices_invoice_number" UNIQUE (invoice_number) + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_booking_id" ON freight.warehouse_fee_invoices (booking_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_inventory_id" ON freight.warehouse_fee_invoices (inventory_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_status" ON freight.warehouse_fee_invoices (status);`, + ); + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoice_items ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + invoice_id uuid NOT NULL, + fee_rule_id uuid, + fee_type varchar(32) NOT NULL, + description varchar(255) NOT NULL, + quantity numeric(12,2) NOT NULL DEFAULT 1, + unit_rate numeric(14,2) NOT NULL DEFAULT 0, + amount numeric(14,2) NOT NULL DEFAULT 0, + currency varchar(8) NOT NULL DEFAULT 'USD', + chargeable_days int, + free_days int, + CONSTRAINT "PK_warehouse_fee_invoice_items" PRIMARY KEY (id), + CONSTRAINT "FK_warehouse_fee_invoice_items_invoice" + FOREIGN KEY (invoice_id) REFERENCES freight.warehouse_fee_invoices (id) ON DELETE CASCADE + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoice_items_invoice_id" ON freight.warehouse_fee_invoice_items (invoice_id);`, + ); + + // … then copy the warehouse-source invoices back, deriving the typed FKs and + // period from the linked inventory item. + await queryRunner.query(` + INSERT INTO freight.warehouse_fee_invoices ( + id, created_at, updated_at, deleted_at, invoice_number, + booking_id, customer_id, inventory_id, facility_id, warehouse_id, yard_id, zone_id, + invoice_type, status, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount, + currency, period_start, period_end, issued_at, due_date, paid_at, cancelled_at, payments, notes + ) + SELECT + i.id, i.created_at, i.updated_at, i.deleted_at, i.invoice_number, + inv.booking_id, i.company_id, i.source_id, w.facility_id, inv.warehouse_id, inv.yard_id, inv.zone_id, + i.type, i.status::text, i.subtotal_amount, i.tax_amount, i.total_amount, i.paid_amount, i.balance_amount, + i.currency, inv.arrived_at, i.issued_at, i.issued_at, i.due_at, i.paid_at, + CASE WHEN i.status::text = 'CANCELLED' THEN i.updated_at ELSE NULL END, + i.payments, NULL + FROM freight.invoices i + LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + WHERE i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + await queryRunner.query(` + INSERT INTO freight.warehouse_fee_invoice_items ( + id, created_at, updated_at, deleted_at, invoice_id, fee_rule_id, fee_type, + description, quantity, unit_rate, amount, currency, chargeable_days, free_days + ) + SELECT + l.id, l.created_at, l.updated_at, l.deleted_at, l.invoice_id, + NULLIF(l.metadata->>'feeRuleId', '')::uuid, + l.charge_type, + COALESCE(l.description, ''), + l.quantity, l.unit_rate, l.amount, l.currency, + NULLIF(l.metadata->>'chargeableDays', '')::int, + NULLIF(l.metadata->>'freeDays', '')::int + FROM freight.invoice_lines l + JOIN freight.invoices i ON i.id = l.invoice_id AND i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + + // Remove the migrated rows from the central tables. + await queryRunner.query(` + DELETE FROM freight.invoice_lines + WHERE invoice_id IN (SELECT id FROM freight.invoices WHERE source = 'warehouse'); + `); + await queryRunner.query(`DELETE FROM freight.invoices WHERE source = 'warehouse';`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000000-PhasedClearanceCycleMeta.ts b/apps/edr-freight-api/src/migrations/1829000000000-PhasedClearanceCycleMeta.ts new file mode 100644 index 000000000..75288e94c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000000-PhasedClearanceCycleMeta.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhasedClearanceCycleMeta1829000000000 implements MigrationInterface { + name = 'PhasedClearanceCycleMeta1829000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS current_phase VARCHAR(40);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS duty_required;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS vessel_departure_date;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_amendment_requested_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_hold_reason;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS current_phase;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000001-SeedRoVesselMinDays.ts b/apps/edr-freight-api/src/migrations/1829000000001-SeedRoVesselMinDays.ts new file mode 100644 index 000000000..7165ea7a8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000001-SeedRoVesselMinDays.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** Admin-configurable minimum days between today and export RO vessel departure. */ +export class SeedRoVesselMinDays1829000000001 implements MigrationInterface { + name = 'SeedRoVesselMinDays1829000000001'; + private readonly code = 'ro_vessel_min_days'; + private readonly options: Array<{ value: string; label: string }> = [ + { value: '2', label: '2 days' }, + { value: '3', label: '3 days' }, + ]; + + public async up(queryRunner: QueryRunner): Promise { + const existing = await queryRunner.query( + `SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`, + [this.code], + ); + if (existing.length > 0) return; + + const inserted = await queryRunner.query( + `INSERT INTO freight.dropdown_settings (code, label, description, multiple) + VALUES ($1, $2, $3, false) + RETURNING id;`, + [ + this.code, + 'RO vessel minimum lead time (days)', + 'Minimum days between today and the vessel departure date on an export Release Order.', + ], + ); + const settingId = inserted[0].id; + + for (let i = 0; i < this.options.length; i++) { + const opt = this.options[i]; + await queryRunner.query( + `INSERT INTO freight.dropdown_options (setting_id, value, label, display_order) + VALUES ($1, $2, $3, $4);`, + [settingId, opt.value, opt.label, i], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [ + this.code, + ]); + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000002-BookingClearanceMeta.ts b/apps/edr-freight-api/src/migrations/1829000000002-BookingClearanceMeta.ts new file mode 100644 index 000000000..c108b7baa --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000002-BookingClearanceMeta.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class BookingClearanceMeta1829000000002 implements MigrationInterface { + name = 'BookingClearanceMeta1829000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_current_phase VARCHAR(40);`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_current_phase;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS duty_required;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS vessel_departure_date;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_amendment_requested_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_hold_reason;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts b/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts new file mode 100644 index 000000000..e4b353b94 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add the `EXPIRED` invoice status. An invoice expires when its source's pay + * window closes before settlement (e.g. a booking whose `paymentDeadline` + * lapses) — driven event-style from the domain via `BillingService.expirePayable`, + * which emits `${source}.invoice.expired`. Terminal and not settle-able (kept out + * of `OPEN_STATUSES`), so it is distinct from `CANCELLED` (manual void) and + * `OVERDUE` (still payable). + * + * Matches Freight.InvoiceStatus in packages/types. ADD VALUE only — additive and + * not referenced in this same transaction, so it is PG 12+ safe. + */ +export class AddExpiredInvoiceStatus1830000000000 implements MigrationInterface { + name = "AddExpiredInvoiceStatus1830000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'EXPIRED' AFTER 'REFUNDED';`, + ); + } + + public async down(): Promise { + // Postgres cannot drop individual enum values; EXPIRED is left on + // freight.invoices_status_enum (harmless, unused after down). + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000000-DropCargoTypeShowFreeTextBox.ts b/apps/edr-freight-api/src/migrations/1830000000000-DropCargoTypeShowFreeTextBox.ts new file mode 100644 index 000000000..5bdfe1f30 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000000-DropCargoTypeShowFreeTextBox.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DropCargoTypeShowFreeTextBox1830000000000 implements MigrationInterface { + name = 'DropCargoTypeShowFreeTextBox1830000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + DROP COLUMN IF EXISTS show_free_text_box + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS show_free_text_box boolean NOT NULL DEFAULT false + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000001-RouteStatusAndSegmentKm.ts b/apps/edr-freight-api/src/migrations/1830000000001-RouteStatusAndSegmentKm.ts new file mode 100644 index 000000000..701b52c18 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000001-RouteStatusAndSegmentKm.ts @@ -0,0 +1,72 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RouteStatusAndSegmentKm1830000000001 implements MigrationInterface { + name = 'RouteStatusAndSegmentKm1830000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE' + `); + + await queryRunner.query(` + UPDATE freight.routes + SET status = CASE WHEN is_active = true THEN 'AVAILABLE' ELSE 'STOP_WORKING' END + `); + + await queryRunner.query(` + DROP INDEX IF EXISTS freight."IDX_routes_name" + `); + await queryRunner.query(` + ALTER TABLE freight.routes DROP COLUMN IF EXISTS name + `); + await queryRunner.query(` + ALTER TABLE freight.routes DROP COLUMN IF EXISTS is_active + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status) + `); + + await queryRunner.query(` + ALTER TABLE freight.route_milestones + ADD COLUMN IF NOT EXISTS distance_km numeric(10,2) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.route_milestones DROP COLUMN IF EXISTS distance_km + `); + + await queryRunner.query(` + ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS name varchar(120) + `); + await queryRunner.query(` + UPDATE freight.routes SET name = id::text WHERE name IS NULL + `); + await queryRunner.query(` + ALTER TABLE freight.routes ALTER COLUMN name SET NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true + `); + await queryRunner.query(` + UPDATE freight.routes + SET is_active = CASE WHEN status = 'AVAILABLE' THEN true ELSE false END + `); + + await queryRunner.query(` + ALTER TABLE freight.routes DROP COLUMN IF EXISTS status + `); + await queryRunner.query(` + DROP INDEX IF EXISTS freight."IDX_routes_status" + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_routes_name" ON freight.routes (name) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000002-PreClearanceFinalizedAt.ts b/apps/edr-freight-api/src/migrations/1830000000002-PreClearanceFinalizedAt.ts new file mode 100644 index 000000000..df57cd828 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000002-PreClearanceFinalizedAt.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PreClearanceFinalizedAt1830000000002 implements MigrationInterface { + name = 'PreClearanceFinalizedAt1830000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS pre_clearance_finalized_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS pre_clearance_finalized_at;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1831000000000-AddWarehouseFeeRuleTiers.ts b/apps/edr-freight-api/src/migrations/1831000000000-AddWarehouseFeeRuleTiers.ts new file mode 100644 index 000000000..c6da11cc8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1831000000000-AddWarehouseFeeRuleTiers.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWarehouseFeeRuleTiers1831000000000 implements MigrationInterface { + name = 'AddWarehouseFeeRuleTiers1831000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_fee_rules + ADD COLUMN IF NOT EXISTS tiers jsonb NOT NULL DEFAULT '[]'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_fee_rules + DROP COLUMN IF EXISTS tiers; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1832000000000-AddCustomerTruckAssignmentToBookings.ts b/apps/edr-freight-api/src/migrations/1832000000000-AddCustomerTruckAssignmentToBookings.ts new file mode 100644 index 000000000..c137ca264 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1832000000000-AddCustomerTruckAssignmentToBookings.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCustomerTruckAssignmentToBookings1832000000000 implements MigrationInterface { + name = 'AddCustomerTruckAssignmentToBookings1832000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32), + ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120), + ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60), + ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16), + ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz, + ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS customer_truck_arrived_at, + DROP COLUMN IF EXISTS customer_truck_assigned_at, + DROP COLUMN IF EXISTS customer_truck_container_number, + DROP COLUMN IF EXISTS customer_truck_type, + DROP COLUMN IF EXISTS customer_truck_driver_name, + DROP COLUMN IF EXISTS customer_truck_plate_number + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts b/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts new file mode 100644 index 000000000..a74a58f8e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts @@ -0,0 +1,79 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateFuelTables1840000000000 implements MigrationInterface { + name = "CreateFuelTables1840000000000"; + + public async up(queryRunner: QueryRunner): Promise { + const fuelPurchasesExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_purchases';`, + ); + + if (!fuelPurchasesExists.length) { + await queryRunner.query(` + CREATE TABLE freight.fuel_purchases ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + purchase_date timestamptz NOT NULL, + liters numeric(10, 2) NOT NULL, + cost_per_liter numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + fuel_station varchar(255) NULL, + payment_method varchar(50) DEFAULT 'CASH', + odometer_reading numeric(10, 2) NULL, + driver_id uuid NULL, + receipt_number varchar(255) NULL, + notes text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_purchases PRIMARY KEY (id), + CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`, + ); + } + + const fuelConsumptionExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_consumption';`, + ); + + if (!fuelConsumptionExists.length) { + await queryRunner.query(` + CREATE TABLE freight.fuel_consumption ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + month date NOT NULL, + total_liters numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + total_distance_km numeric(10, 2) NOT NULL, + fuel_efficiency_km_per_l numeric(10, 2) NULL, + number_of_purchases integer DEFAULT 0, + average_cost_per_liter numeric(10, 2) NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_consumption PRIMARY KEY (id), + CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE, + CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month) + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_consumption;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_purchases;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts b/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts new file mode 100644 index 000000000..26d4afe21 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts @@ -0,0 +1,82 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateMaintenanceTables1850000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Create maintenance_schedules table + const scheduleTableExists = await queryRunner.query(` + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'freight' AND table_name = 'maintenance_schedules' + ) + `); + + if (!scheduleTableExists[0].exists) { + await queryRunner.query(` + CREATE TABLE "freight"."maintenance_schedules" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "vehicle_id" uuid NOT NULL, + "maintenance_type" varchar NOT NULL, + "description" varchar NOT NULL, + "scheduled_date" timestamptz NOT NULL, + "completed_date" timestamptz, + "estimated_cost" numeric(14,2), + "actual_cost" numeric(14,2), + "status" varchar NOT NULL DEFAULT 'SCHEDULED', + "odometer_reading" numeric, + "service_provider" varchar, + "notes" text, + "next_due_km" numeric, + "next_due_date" timestamptz, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + PRIMARY KEY ("id") + ) + `); + + await queryRunner.query( + `CREATE INDEX "idx_maintenance_schedules_vehicle_date" ON "freight"."maintenance_schedules" ("vehicle_id", "scheduled_date")` + ); + } + + // Create maintenance_costs table + const costsTableExists = await queryRunner.query(` + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'freight' AND table_name = 'maintenance_costs' + ) + `); + + if (!costsTableExists[0].exists) { + await queryRunner.query(` + CREATE TABLE "freight"."maintenance_costs" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "vehicle_id" uuid NOT NULL, + "maintenance_schedule_id" uuid, + "incurred_date" timestamptz NOT NULL, + "cost_amount" numeric(14,2) NOT NULL, + "cost_type" varchar NOT NULL, + "description" varchar NOT NULL, + "service_provider" varchar, + "invoice_number" varchar, + "notes" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + PRIMARY KEY ("id"), + CONSTRAINT "fk_maintenance_schedule" FOREIGN KEY ("maintenance_schedule_id") + REFERENCES "freight"."maintenance_schedules" ("id") ON DELETE SET NULL + ) + `); + + await queryRunner.query( + `CREATE INDEX "idx_maintenance_costs_vehicle_date" ON "freight"."maintenance_costs" ("vehicle_id", "incurred_date")` + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_costs"`); + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_schedules"`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts b/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts new file mode 100644 index 000000000..261e16099 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add paid column to first_mile and last_mile tables to track invoice payment status. + */ +export class AddPaidToFirstAndLastMile1860000000000 + implements MigrationInterface +{ + name = "AddPaidToFirstAndLastMile1860000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.first_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false; + `); + + await queryRunner.query(` + ALTER TABLE freight.last_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.first_mile + DROP COLUMN IF EXISTS paid; + `); + + await queryRunner.query(` + ALTER TABLE freight.last_mile + DROP COLUMN IF EXISTS paid; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts b/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts new file mode 100644 index 000000000..a98fcd80d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddBookingWindowGlobalRules1861000000000 implements MigrationInterface { + name = "AddBookingWindowGlobalRules1861000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN import_window_lead_days integer NOT NULL DEFAULT 3, + ADD COLUMN export_booking_lead_hours integer NOT NULL DEFAULT 24, + ADD COLUMN window_open_hour integer NOT NULL DEFAULT 8, + ADD COLUMN window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3, + ADD COLUMN doc_review_minutes integer NOT NULL DEFAULT 30, + ADD COLUMN payment_window_minutes integer NOT NULL DEFAULT 60, + ADD COLUMN reopen_delay_minutes integer NOT NULL DEFAULT 90; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS import_window_lead_days, + DROP COLUMN IF EXISTS export_booking_lead_hours, + DROP COLUMN IF EXISTS window_open_hour, + DROP COLUMN IF EXISTS window_duration_hours, + DROP COLUMN IF EXISTS doc_review_minutes, + DROP COLUMN IF EXISTS payment_window_minutes, + DROP COLUMN IF EXISTS reopen_delay_minutes; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts b/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts new file mode 100644 index 000000000..0d5155391 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * arriveSchedule used to release only the primary locomotive of a train set, leaving + * secondary locomotives ASSIGNED forever. Locomotives are now only ASSIGNED while out + * on a dispatched train — release every ASSIGNED locomotive that is not attached to a + * currently-DISPATCHED schedule. + */ +export class ReleaseStuckAssignedLocomotives1861000000001 implements MigrationInterface { + name = "ReleaseStuckAssignedLocomotives1861000000001"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.locomotives l + SET status = 'AVAILABLE' + WHERE l.status = 'ASSIGNED' + AND NOT EXISTS ( + SELECT 1 + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN ( + SELECT tsl.train_set_id, tsl.locomotive_id + FROM freight.train_set_locomotives tsl + WHERE tsl.deleted_at IS NULL + UNION + SELECT t.id AS train_set_id, t.locomotive_id + FROM freight.train_sets t + WHERE t.locomotive_id IS NOT NULL + ) loco ON loco.train_set_id = tset.id + WHERE ts.status = 'DISPATCHED' + AND ts.deleted_at IS NULL + AND loco.locomotive_id = l.id + ); + `); + } + + public async down(): Promise { + // Data fix — not reversible. + } +} diff --git a/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts b/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts new file mode 100644 index 000000000..cae33a534 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddScheduleWindowPhases1862000000000 implements MigrationInterface { + name = "AddScheduleWindowPhases1862000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN window_phase varchar(20) NULL, + ADD COLUMN window_opens_at timestamptz NULL, + ADD COLUMN window_closes_at timestamptz NULL, + ADD COLUMN doc_review_ends_at timestamptz NULL, + ADD COLUMN doc_review_completed_at timestamptz NULL, + ADD COLUMN payment_phase_ends_at timestamptz NULL, + ADD COLUMN booking_cycle_no integer NOT NULL DEFAULT 0; + `); + await queryRunner.query(` + CREATE INDEX idx_train_schedules_window_phase + ON freight.train_schedules (window_phase) + WHERE window_phase IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_window_phase;`); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS window_phase, + DROP COLUMN IF EXISTS window_opens_at, + DROP COLUMN IF EXISTS window_closes_at, + DROP COLUMN IF EXISTS doc_review_ends_at, + DROP COLUMN IF EXISTS doc_review_completed_at, + DROP COLUMN IF EXISTS payment_phase_ends_at, + DROP COLUMN IF EXISTS booking_cycle_no; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts b/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts new file mode 100644 index 000000000..779807a57 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateBookingBatchOffers1863000000000 implements MigrationInterface { + name = "CreateBookingBatchOffers1863000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE freight.booking_batch_offers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE, + offered_wagons integer NOT NULL, + total_wagons integer NOT NULL, + offered_lines jsonb NULL, + offered_weight_tons numeric(12, 3) NOT NULL, + offered_amount numeric(14, 2) NOT NULL, + offered_pricing_breakdown jsonb NULL, + invoice_id uuid NULL, + payment_deadline timestamptz NOT NULL, + status varchar(10) NOT NULL DEFAULT 'OFFERED', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ); + `); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_batch_offers;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts b/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts new file mode 100644 index 000000000..3434631a2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add location_id column to vehicles table to track vehicle base location. + */ +export class AddLocationToVehicles1870000000000 implements MigrationInterface { + name = "AddLocationToVehicles1870000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS location_id uuid; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS location_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1870000000000-RepairSynchronizeDrift.ts b/apps/edr-freight-api/src/migrations/1870000000000-RepairSynchronizeDrift.ts new file mode 100644 index 000000000..32f1e6f0c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1870000000000-RepairSynchronizeDrift.ts @@ -0,0 +1,269 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Repairs schema drift on databases that were originally built by TypeORM + * `synchronize` (at an older entity snapshot) and never had their migration + * history recorded. Such databases have `freight.migrations` empty while most + * of the schema already exists, so a from-scratch migration run aborts on the + * first non-idempotent statement and never reaches the columns/tables added + * after synchronize was last used. + * + * The deployment procedure for those databases is: + * 1. Baseline every pre-existing migration into `freight.migrations`. + * 2. Run migrations — this file is the only pending one and back-fills the + * objects the drift scan found missing. + * + * Every statement is idempotent (IF NOT EXISTS / guarded CREATE TYPE), so it is + * also safe on a clean database where the earlier migrations already created + * these objects — it simply no-ops. + */ +export class RepairSynchronizeDrift1870000000000 + implements MigrationInterface +{ + name = 'RepairSynchronizeDrift1870000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // --- enum types (derived from entities that never had a source migration) --- + await queryRunner.query(`DO $$ BEGIN + CREATE TYPE freight.consignments_cargo_type_enum AS ENUM ( + 'CONTAINER', 'BULK_LIQUID', 'BULK_DRY', 'GENERAL', 'REFRIGERATED', 'HAZARDOUS' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$;`); + await queryRunner.query(`DO $$ BEGIN + CREATE TYPE freight.consignments_status_enum AS ENUM ( + 'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$;`); + await queryRunner.query(`DO $$ BEGIN + CREATE TYPE freight.tracking_events_status_enum AS ENUM ( + 'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$;`); + + // --- missing tables --- + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.consignments ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL, + tracking_number varchar(64) NOT NULL, + cargo_type freight.consignments_cargo_type_enum NOT NULL, + weight_kg numeric(12, 2) NOT NULL, + status freight.consignments_status_enum NOT NULL DEFAULT 'PENDING', + origin_station varchar(128) NOT NULL, + destination_station varchar(128) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_consignments PRIMARY KEY (id), + CONSTRAINT uq_consignments_tracking_number UNIQUE (tracking_number) + );`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.tracking_events ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + consignment_id uuid NOT NULL, + location varchar(256) NOT NULL, + status freight.tracking_events_status_enum NOT NULL, + occurred_at timestamptz NOT NULL, + description text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_tracking_events PRIMARY KEY (id) + );`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_purchases ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + purchase_date timestamptz NOT NULL, + liters numeric(10, 2) NOT NULL, + cost_per_liter numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + fuel_station varchar(255) NULL, + payment_method varchar(50) DEFAULT 'CASH', + odometer_reading numeric(10, 2) NULL, + driver_id uuid NULL, + receipt_number varchar(255) NULL, + notes text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_purchases PRIMARY KEY (id), + CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_consumption ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + month date NOT NULL, + total_liters numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + total_distance_km numeric(10, 2) NOT NULL, + fuel_efficiency_km_per_l numeric(10, 2) NULL, + number_of_purchases integer DEFAULT 0, + average_cost_per_liter numeric(10, 2) NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_consumption PRIMARY KEY (id), + CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE, + CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month) + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_schedules ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + maintenance_type varchar NOT NULL, + description varchar NOT NULL, + scheduled_date timestamptz NOT NULL, + completed_date timestamptz, + estimated_cost numeric(14,2), + actual_cost numeric(14,2), + status varchar NOT NULL DEFAULT 'SCHEDULED', + odometer_reading numeric, + service_provider varchar, + notes text, + next_due_km numeric, + next_due_date timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + PRIMARY KEY (id) + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_schedules_vehicle_date ON freight.maintenance_schedules (vehicle_id, scheduled_date);`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_costs ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + maintenance_schedule_id uuid, + incurred_date timestamptz NOT NULL, + cost_amount numeric(14,2) NOT NULL, + cost_type varchar NOT NULL, + description varchar NOT NULL, + service_provider varchar, + invoice_number varchar, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + PRIMARY KEY (id), + CONSTRAINT fk_maintenance_schedule FOREIGN KEY (maintenance_schedule_id) + REFERENCES freight.maintenance_schedules (id) ON DELETE SET NULL + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_costs_vehicle_date ON freight.maintenance_costs (vehicle_id, incurred_date);`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.otp_verifications ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + phone varchar NOT NULL, + otp varchar NOT NULL, + verified boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_otp_verifications PRIMARY KEY (id), + CONSTRAINT uq_otp_verifications_phone UNIQUE (phone) + );`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.booking_batch_offers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE, + offered_wagons integer NOT NULL, + total_wagons integer NOT NULL, + offered_lines jsonb NULL, + offered_weight_tons numeric(12, 3) NOT NULL, + offered_amount numeric(14, 2) NOT NULL, + offered_pricing_breakdown jsonb NULL, + invoice_id uuid NULL, + payment_deadline timestamptz NOT NULL, + status varchar(10) NOT NULL DEFAULT 'OFFERED', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + );`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`); + + // --- missing columns on existing tables --- + await queryRunner.query(`ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_at timestamptz;`); + + await queryRunner.query(`ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS booking_type varchar(20) NOT NULL DEFAULT 'ONE_TIME', + ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32), + ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120), + ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60), + ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16), + ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz, + ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz, + ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity numeric(12,3) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS bulk_reefer_quantity numeric(12,3) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS clearance_current_phase varchar(40), + ADD COLUMN IF NOT EXISTS duty_required boolean, + ADD COLUMN IF NOT EXISTS vessel_departure_date date, + ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz, + ADD COLUMN IF NOT EXISTS ro_hold_reason text, + ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_bookings_booking_type ON freight.bookings (booking_type);`); + + await queryRunner.query(`ALTER TABLE freight.cargoes + ADD COLUMN IF NOT EXISTS receiver_name varchar, + ADD COLUMN IF NOT EXISTS delivered_at timestamp, + ADD COLUMN IF NOT EXISTS delivery_remarks text;`); + + await queryRunner.query(`ALTER TABLE freight.contract_clearance_cycles + ADD COLUMN IF NOT EXISTS duty_required boolean, + ADD COLUMN IF NOT EXISTS vessel_departure_date date, + ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz, + ADD COLUMN IF NOT EXISTS ro_hold_reason text, + ADD COLUMN IF NOT EXISTS current_phase varchar(40), + ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`); + + await queryRunner.query(`ALTER TABLE freight.first_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`); + await queryRunner.query(`ALTER TABLE freight.last_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`); + + await queryRunner.query(`ALTER TABLE freight.route_milestones + ADD COLUMN IF NOT EXISTS distance_km numeric(10,2);`); + + await queryRunner.query(`ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE';`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status);`); + + await queryRunner.query(`ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS window_phase varchar(20) NULL, + ADD COLUMN IF NOT EXISTS window_opens_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS window_closes_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS doc_review_ends_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS doc_review_completed_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS payment_phase_ends_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS booking_cycle_no integer NOT NULL DEFAULT 0;`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_train_schedules_window_phase + ON freight.train_schedules (window_phase) WHERE window_phase IS NOT NULL;`); + + await queryRunner.query(`ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN IF NOT EXISTS import_window_lead_days integer NOT NULL DEFAULT 3, + ADD COLUMN IF NOT EXISTS export_booking_lead_hours integer NOT NULL DEFAULT 24, + ADD COLUMN IF NOT EXISTS window_open_hour integer NOT NULL DEFAULT 8, + ADD COLUMN IF NOT EXISTS window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3, + ADD COLUMN IF NOT EXISTS doc_review_minutes integer NOT NULL DEFAULT 30, + ADD COLUMN IF NOT EXISTS payment_window_minutes integer NOT NULL DEFAULT 60, + ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;`); + } + + public async down(): Promise { + // No-op: this migration only repairs drift by additively creating objects + // that other migrations own. Rolling it back would drop objects those + // migrations legitimately created. Revert individual feature migrations + // instead if needed. + } +} diff --git a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts new file mode 100644 index 000000000..484a2686b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add FREE and BUSY statuses to vehicle status enum. + */ +export class AddVehicleStatuses1880000000000 implements MigrationInterface { + name = "AddVehicleStatuses1880000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // Create enum type if it doesn't exist + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'vehicles_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight')) THEN + CREATE TYPE freight.vehicles_status_enum AS ENUM ('ACTIVE', 'FREE', 'BUSY', 'MAINTENANCE', 'RETIRED', 'OUT_OF_SERVICE'); + ELSE + -- Add values if enum already exists but doesn't have them + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE'; + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE'; + END IF; + END $$; + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // Note: Postgres cannot drop individual enum values, so the down migration is a no-op + // The enum values FREE and BUSY will remain but will be unused after downgrade + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts b/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts new file mode 100644 index 000000000..c0015c3c6 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Split the mixed vehicle status into two fields: + * - status: operational state (ACTIVE, MAINTENANCE, RETIRED, OUT_OF_SERVICE) + * - availability: assignment state (FREE, BUSY) + * + * Existing FREE/BUSY statuses are moved to availability and the status is + * normalized back to ACTIVE. + */ +export class SeparateVehicleAvailability1890000000000 implements MigrationInterface { + name = "SeparateVehicleAvailability1890000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS availability varchar DEFAULT 'FREE' + `); + await queryRunner.query(` + UPDATE freight.vehicles SET availability = 'BUSY' WHERE status = 'BUSY' + `); + await queryRunner.query(` + UPDATE freight.vehicles SET availability = 'FREE' WHERE availability IS NULL + `); + await queryRunner.query(` + UPDATE freight.vehicles SET status = 'ACTIVE' WHERE status IN ('FREE', 'BUSY') + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Fold availability back into status before dropping the column + await queryRunner.query(` + UPDATE freight.vehicles SET status = availability + WHERE status = 'ACTIVE' AND availability IN ('FREE', 'BUSY') + `); + await queryRunner.query(` + ALTER TABLE freight.vehicles DROP COLUMN IF EXISTS availability + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts b/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts new file mode 100644 index 000000000..6f9faa1f8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add code, power_plate_no and trailer_plate_no columns to vehicles. + * These fields existed in the DTO and UI form but had no entity columns, + * so submitted values were silently dropped. + */ +export class AddVehicleCodeAndPlates1890000000001 implements MigrationInterface { + name = "AddVehicleCodeAndPlates1890000000001"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS code varchar, + ADD COLUMN IF NOT EXISTS power_plate_no varchar, + ADD COLUMN IF NOT EXISTS trailer_plate_no varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS code, + DROP COLUMN IF EXISTS power_plate_no, + DROP COLUMN IF EXISTS trailer_plate_no + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000002-AddFaydaVerificationSessions.ts b/apps/edr-freight-api/src/migrations/1890000000002-AddFaydaVerificationSessions.ts new file mode 100644 index 000000000..10b358eea --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000002-AddFaydaVerificationSessions.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Session store for the VeriFayda 2.0 OIDC verification flow (ported from + * passenger-api). One row per started verification; `state` is the + * single-use CSRF token linking the eSignet redirect back to the session. + */ +export class AddFaydaVerificationSessions1890000000002 implements MigrationInterface { + name = "AddFaydaVerificationSessions1890000000002"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.fayda_verification_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + state varchar NOT NULL UNIQUE, + code_verifier varchar NOT NULL, + purpose varchar NOT NULL DEFAULT 'VERIFY', + platform varchar NOT NULL DEFAULT 'WEB', + save_to_account boolean NOT NULL DEFAULT false, + status varchar NOT NULL DEFAULT 'PENDING', + error_code varchar, + error_description text, + iam_user_id uuid, + expires_at timestamptz NOT NULL, + completed_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_FAYDA_SESSIONS_EXPIRES_AT" + ON freight.fayda_verification_sessions (expires_at) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_IAM_USER_ID" + ON freight.fayda_verification_sessions (iam_user_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.fayda_verification_sessions`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000003-AddDriverFaydaVerification.ts b/apps/edr-freight-api/src/migrations/1890000000003-AddDriverFaydaVerification.ts new file mode 100644 index 000000000..ba8003143 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000003-AddDriverFaydaVerification.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Track Fayda identity verification on drivers: whether the driver's + * identity was verified through VeriFayda and the OIDC subject it was + * verified against. + */ +export class AddDriverFaydaVerification1890000000003 implements MigrationInterface { + name = "AddDriverFaydaVerification1890000000003"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.drivers + ADD COLUMN IF NOT EXISTS fayda_verified boolean DEFAULT false, + ADD COLUMN IF NOT EXISTS fayda_sub varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.drivers + DROP COLUMN IF EXISTS fayda_verified, + DROP COLUMN IF EXISTS fayda_sub + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts b/apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts new file mode 100644 index 000000000..7c1413617 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000004-AddLastMileVehicleAssignments.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Allow more than one vehicle per last-mile delivery. Junction table joins + * last_mile ⇄ vehicles; existing single vehicle_id values are backfilled as + * the first assignment so nothing is lost. + */ +export class AddLastMileVehicleAssignments1890000000004 implements MigrationInterface { + name = "AddLastMileVehicleAssignments1890000000004"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + last_mile_id uuid NOT NULL REFERENCES freight.last_mile(id) ON DELETE CASCADE, + vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "UQ_LAST_MILE_VEHICLE" UNIQUE (last_mile_id, vehicle_id) + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_LM_VEHICLE_ASSIGNMENTS_VEHICLE" + ON freight.last_mile_vehicle_assignments (vehicle_id) + `); + // Backfill: existing single-vehicle assignments become the first row + await queryRunner.query(` + INSERT INTO freight.last_mile_vehicle_assignments (last_mile_id, vehicle_id) + SELECT id, vehicle_id FROM freight.last_mile + WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL + ON CONFLICT (last_mile_id, vehicle_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_assignments`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts b/apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts new file mode 100644 index 000000000..2ec26e034 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000005-AddDriverGender.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Store the driver's gender. Prefilled from the Fayda VERIFY response + * (Male/Female) but editable; nullable so existing rows and manual, + * non-Fayda driver records stay valid. + */ +export class AddDriverGender1890000000005 implements MigrationInterface { + name = "AddDriverGender1890000000005"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.drivers + ADD COLUMN IF NOT EXISTS gender varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.drivers + DROP COLUMN IF EXISTS gender + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts b/apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts new file mode 100644 index 000000000..04c9a7000 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000006-AddDriverFaydaSubUnique.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Enforce one driver record per verified Fayda identity. A unique index on + * fayda_sub blocks a second driver from being created against the same Fayda + * OIDC subject; NULLs stay distinct so legacy/unverified rows are unaffected. + */ +export class AddDriverFaydaSubUnique1890000000006 implements MigrationInterface { + name = "AddDriverFaydaSubUnique1890000000006"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB" + ON freight.drivers (fayda_sub) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB" + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts b/apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts new file mode 100644 index 000000000..77d0a3043 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000007-DriverUniquePartialSoftDelete.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Make driver uniqueness soft-delete aware. The original table used plain + * column UNIQUE constraints (drivers_email_key, etc.) which count soft-deleted + * rows, so deleting a driver then re-adding the same email/phone/license/Fayda + * identity failed at the DB with a raw 500 — even though the service's own + * (deleted_at-excluding) duplicate check saw nothing. Replace them with partial + * unique indexes scoped to live rows (deleted_at IS NULL) so uniqueness matches + * what the service enforces and freed values become reusable after deletion. + */ +export class DriverUniquePartialSoftDelete1890000000007 implements MigrationInterface { + name = "DriverUniquePartialSoftDelete1890000000007"; + + public async up(queryRunner: QueryRunner): Promise { + // Drop the full-table unique constraints from CreateDriversTable... + await queryRunner.query(` + ALTER TABLE freight.drivers + DROP CONSTRAINT IF EXISTS drivers_email_key, + DROP CONSTRAINT IF EXISTS drivers_phone_number_key, + DROP CONSTRAINT IF EXISTS drivers_license_number_key + `); + // ...and the plain fayda_sub unique index from 1890000000006. + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB"`); + + // Re-add each as a partial unique index scoped to non-deleted rows. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_EMAIL_ACTIVE" + ON freight.drivers (email) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_PHONE_ACTIVE" + ON freight.drivers (phone_number) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_LICENSE_ACTIVE" + ON freight.drivers (license_number) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB_ACTIVE" + ON freight.drivers (fayda_sub) WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_EMAIL_ACTIVE"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_PHONE_ACTIVE"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_LICENSE_ACTIVE"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB_ACTIVE"`); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB" + ON freight.drivers (fayda_sub) + `); + await queryRunner.query(` + ALTER TABLE freight.drivers + ADD CONSTRAINT drivers_email_key UNIQUE (email), + ADD CONSTRAINT drivers_phone_number_key UNIQUE (phone_number), + ADD CONSTRAINT drivers_license_number_key UNIQUE (license_number) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts b/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts new file mode 100644 index 000000000..8fbb688d3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000008-AddFleetEvents.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Append-only audit log for fleet activity (driver↔vehicle assignments, vehicle + * status/availability transitions, first/last-mile vehicle assignments + mile + * status changes). Queried by vehicle_id or driver_id to build a per-record + * timeline. Populated going forward — existing records have no back-history. + */ +export class AddFleetEvents1890000000008 implements MigrationInterface { + name = "AddFleetEvents1890000000008"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.fleet_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + event_type varchar NOT NULL, + vehicle_id uuid, + driver_id uuid, + first_mile_id uuid, + last_mile_id uuid, + from_value varchar, + to_value varchar, + label varchar, + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_VEHICLE" + ON freight.fleet_events (vehicle_id, created_at) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_DRIVER" + ON freight.fleet_events (driver_id, created_at) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.fleet_events`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000009-AddLastMileAssignmentContainerNumber.ts b/apps/edr-freight-api/src/migrations/1890000000009-AddLastMileAssignmentContainerNumber.ts new file mode 100644 index 000000000..d8bc1c5c0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000009-AddLastMileAssignmentContainerNumber.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Container number carried by each vehicle on a last-mile delivery. Auto-filled + * from the booking's container number when present, else entered by the operator + * at assignment time. + */ +export class AddLastMileAssignmentContainerNumber1890000000009 + implements MigrationInterface +{ + name = "AddLastMileAssignmentContainerNumber1890000000009"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + ADD COLUMN IF NOT EXISTS container_number varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + DROP COLUMN IF EXISTS container_number + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000010-AddLastMileAssignmentDistance.ts b/apps/edr-freight-api/src/migrations/1890000000010-AddLastMileAssignmentDistance.ts new file mode 100644 index 000000000..fc2d30552 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000010-AddLastMileAssignmentDistance.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Per-vehicle actual distance on a last-mile delivery. A booking served by + * several trucks records each truck's km; the record's total (last_mile.exact_km) + * is their sum and drives the invoice. + */ +export class AddLastMileAssignmentDistance1890000000010 + implements MigrationInterface +{ + name = "AddLastMileAssignmentDistance1890000000010"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + ADD COLUMN IF NOT EXISTS distance_km numeric(10,2) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + DROP COLUMN IF EXISTS distance_km + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts new file mode 100644 index 000000000..55a6568c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Support email as a second OTP channel alongside phone (e.g. signup lets the + * user choose which one to verify). `phone` becomes nullable since an + * email-channel row has none, and `email` is added as a nullable unique column + * mirroring `phone`'s shape. + */ +export class AddEmailToOtpVerifications1900000000000 + implements MigrationInterface +{ + name = "AddEmailToOtpVerifications1900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // The table lives in the `freight` schema (the OtpVerification entity pins + // schema: "freight"). An earlier version of this migration targeted + // `public.otp_verifications`, which does not exist there — leaving the real + // freight table without an `email` column and OTP send failing with + // `column OtpVerification.email does not exist`. Target `freight` explicitly. + await queryRunner.query(` + ALTER TABLE freight.otp_verifications + ALTER COLUMN phone DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.otp_verifications + ADD COLUMN IF NOT EXISTS email varchar UNIQUE + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.otp_verifications + DROP COLUMN IF EXISTS email + `); + await queryRunner.query(` + ALTER TABLE freight.otp_verifications + ALTER COLUMN phone SET NOT NULL + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts b/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts new file mode 100644 index 000000000..6c7235e3b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Track per-booking loading confirmation (LOADED/UNLOADED) on train_schedule_bookings. + * Tracking only — does not gate dispatch. + */ +export class AddLoadingStatusToTrainScheduleBookings1900000000000 + implements MigrationInterface +{ + name = "AddLoadingStatusToTrainScheduleBookings1900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedule_bookings + ADD COLUMN IF NOT EXISTS loading_status varchar(20) NOT NULL DEFAULT 'UNLOADED' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedule_bookings + DROP COLUMN IF EXISTS loading_status + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts b/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts new file mode 100644 index 000000000..7a661bc7c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts @@ -0,0 +1,126 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Simplify the rate + weight-limit configuration model: + * + * 1. Drop the effective_from / effective_to validity window from both + * `rates` and `weight_limit_rules`. Rates are now activated purely by + * the approval workflow (status = LIVE) and weight limits are always + * active for their container + direction. No time-travel scheduling. + * + * 2. Enforce "one rate per pattern" with partial unique indexes so the same + * configuration (e.g. FIRST_MILE for a given container type) cannot be + * duplicated. NULL scope columns are COALESCE-normalised because Postgres + * treats NULLs as distinct in a plain unique index. + * + * This migration is destructive on the date columns — existing effective_* + * values are dropped. + */ +export class SimplifyRatesAndWeightLimitRules1900000000000 implements MigrationInterface { + name = 'SimplifyRatesAndWeightLimitRules1900000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ── 1. De-duplicate existing data so the unique indexes can be created ── + // Keep the most recently-created row per pattern, soft-delete the rest. + await queryRunner.query(` + WITH ranked AS ( + SELECT id, + row_number() OVER ( + PARTITION BY rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''), + rate_unit + ORDER BY created_at DESC, id DESC + ) AS rn + FROM freight.rates + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED' + ) + UPDATE freight.rates r + SET deleted_at = now() + FROM ranked + WHERE r.id = ranked.id AND ranked.rn > 1; + `); + + await queryRunner.query(` + WITH ranked AS ( + SELECT id, + row_number() OVER ( + PARTITION BY container_type_id, trade_direction + ORDER BY created_at DESC, id DESC + ) AS rn + FROM freight.weight_limit_rules + WHERE deleted_at IS NULL + ) + UPDATE freight.weight_limit_rules w + SET deleted_at = now() + FROM ranked + WHERE w.id = ranked.id AND ranked.rn > 1; + `); + + // ── 2. Drop the effective-date indexes + columns ─────────────────────── + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_effective_from";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_weight_limit_rules_effective_from";`); + // Indexes created by TypeORM's @Index carry generated hashed names — drop + // any index that references the effective_from column defensively. + await queryRunner.query(` + DO $$ + DECLARE idx record; + BEGIN + FOR idx IN + SELECT indexname FROM pg_indexes + WHERE schemaname = 'freight' + AND tablename IN ('rates', 'weight_limit_rules') + AND indexdef ILIKE '%effective_from%' + LOOP + EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx.indexname); + END LOOP; + END $$; + `); + + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_from;`); + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_to;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_from;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_to;`); + + // ── 3. One-rate-per-pattern partial unique indexes ───────────────────── + // The unit is part of the identity so a surcharge can legitimately carry two + // rows that bill different ways (e.g. reefer PER_CONTAINER + reefer PER_TON), + // while still blocking a true duplicate (same rateType + scope + unit). + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" + ON freight.rates ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''), + rate_unit + ) + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_weight_limit_rules_pattern" + ON freight.weight_limit_rules (container_type_id, trade_direction) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_weight_limit_rules_pattern";`); + + await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_from date;`); + await queryRunner.query(`UPDATE freight.rates SET effective_from = COALESCE(effective_from, created_at::date);`); + await queryRunner.query(`ALTER TABLE freight.rates ALTER COLUMN effective_from SET NOT NULL;`); + await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_to date;`); + + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_from date;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_to date;`); + + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_rates_effective_from" ON freight.rates (effective_from);`); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_weight_limit_rules_effective_from" ON freight.weight_limit_rules (effective_from);`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts b/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts new file mode 100644 index 000000000..194c0d056 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Widen train_scheduling_global_rules.window_duration_hours from numeric(4,2) + * to numeric(6,4). The UI now lets staff enter the booking-window duration in + * minutes / hours / days and converts to the column's native hours unit; a + * 4-minute window is 0.0667h, which numeric(4,2) rounds to 0.07 (≈3.96 min). + * Four decimals store sub-minute durations exactly (0.0667h → 4.00 min). + */ +export class WidenWindowDurationHoursPrecision1910000000000 + implements MigrationInterface +{ + name = "WidenWindowDurationHoursPrecision1910000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ALTER COLUMN window_duration_hours TYPE numeric(6, 4); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ALTER COLUMN window_duration_hours TYPE numeric(4, 2); + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts b/apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts new file mode 100644 index 000000000..d48e127c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1920000000000-AddScheduleWindowRuleSnapshot.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Snapshot the booking-window rule onto each train schedule. + * + * A schedule's window (open time + reopen cycles) must be frozen to the rule it + * was created with: a later global-rules edit applies only to FUTURE schedules, + * while an already-open schedule keeps its base rule. Previously the batch board + * recomputed windows from the LIVE global config, so editing the rule redrew the + * board for open schedules (a synthetic grid that no longer matched the window + * the customer was shown). These columns give the board a per-schedule rule to + * derive its display windows from. + * + * Existing rows are backfilled from the current global-rules singleton — the best + * available base, since they never stored one. Their stamped windowOpensAt/ + * windowClosesAt are still real, so only projected reopen cycles rely on the + * backfill. + */ +export class AddScheduleWindowRuleSnapshot1920000000000 + implements MigrationInterface +{ + name = "AddScheduleWindowRuleSnapshot1920000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS rule_window_open_hour integer, + ADD COLUMN IF NOT EXISTS rule_window_duration_hours numeric(6, 4), + ADD COLUMN IF NOT EXISTS rule_reopen_delay_minutes integer, + ADD COLUMN IF NOT EXISTS rule_import_window_lead_days integer, + ADD COLUMN IF NOT EXISTS rule_export_booking_lead_hours integer; + `); + + // Backfill from the global-rules singleton so pre-existing schedules render. + await queryRunner.query(` + UPDATE freight.train_schedules ts + SET + rule_window_open_hour = COALESCE(ts.rule_window_open_hour, r.window_open_hour), + rule_window_duration_hours = COALESCE(ts.rule_window_duration_hours, r.window_duration_hours), + rule_reopen_delay_minutes = COALESCE(ts.rule_reopen_delay_minutes, r.reopen_delay_minutes), + rule_import_window_lead_days = COALESCE(ts.rule_import_window_lead_days, r.import_window_lead_days), + rule_export_booking_lead_hours = COALESCE(ts.rule_export_booking_lead_hours, r.export_booking_lead_hours) + FROM freight.train_scheduling_global_rules r + WHERE ts.rule_window_open_hour IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS rule_window_open_hour, + DROP COLUMN IF EXISTS rule_window_duration_hours, + DROP COLUMN IF EXISTS rule_reopen_delay_minutes, + DROP COLUMN IF EXISTS rule_import_window_lead_days, + DROP COLUMN IF EXISTS rule_export_booking_lead_hours; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts b/apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts new file mode 100644 index 000000000..b1218a9b3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1930000000000-AddMaxCapacityToWeightLimitRules.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add a hard per-unit weight ceiling to weight limit rules. + * + * maxVgmTons stays the soft "overweight" threshold (surcharge + warning); + * max_capacity_tons is the absolute ceiling above which a booking cannot be + * created at all. Null means no ceiling (existing behavior). + */ +export class AddMaxCapacityToWeightLimitRules1930000000000 + implements MigrationInterface +{ + name = "AddMaxCapacityToWeightLimitRules1930000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.weight_limit_rules + ADD COLUMN IF NOT EXISTS max_capacity_tons numeric(8, 3); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.weight_limit_rules + DROP COLUMN IF EXISTS max_capacity_tons; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts b/apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts new file mode 100644 index 000000000..42f2c4eba --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1940000000000-AddFirstMileVehicleAssignments.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Allow more than one vehicle per first-mile pickup. Junction table joins + * first_mile ⇄ vehicles, with each truck's container number + actual distance; + * existing single vehicle_id values are backfilled as the first assignment so + * nothing is lost. Mirrors the last-mile vehicle-assignment schema. + */ +export class AddFirstMileVehicleAssignments1940000000000 implements MigrationInterface { + name = "AddFirstMileVehicleAssignments1940000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.first_mile_vehicle_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + first_mile_id uuid NOT NULL REFERENCES freight.first_mile(id) ON DELETE CASCADE, + vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id), + container_number varchar, + distance_km numeric(10,2), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "UQ_FIRST_MILE_VEHICLE" UNIQUE (first_mile_id, vehicle_id) + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_FM_VEHICLE_ASSIGNMENTS_VEHICLE" + ON freight.first_mile_vehicle_assignments (vehicle_id) + `); + // Backfill: existing single-vehicle assignments become the first row + await queryRunner.query(` + INSERT INTO freight.first_mile_vehicle_assignments (first_mile_id, vehicle_id) + SELECT id, vehicle_id FROM freight.first_mile + WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL + ON CONFLICT (first_mile_id, vehicle_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.first_mile_vehicle_assignments`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1940000000000-AddWagonTypeFkToCargoAndContainerTypes.ts b/apps/edr-freight-api/src/migrations/1940000000000-AddWagonTypeFkToCargoAndContainerTypes.ts new file mode 100644 index 000000000..c7ab60577 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1940000000000-AddWagonTypeFkToCargoAndContainerTypes.ts @@ -0,0 +1,133 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Replace load-type string matching with a real wagon-type foreign key. + * + * Before this migration, train scheduling picked a wagon type by matching + * strings — a hardcoded cargo-code → wagon-code map for bulk (COFFEE→KW2, …) + * and a fixed NW5 default for every container. This adds `wagon_type_id` FKs on + * `cargo_types` and `container_types` so scheduling resolves the wagon type + * through the relation instead. + * + * The columns are NULLABLE: cargo grouping rows and container/legacy cargo that + * never ship in bulk have no wagon type, and forcing one onto them is + * meaningless. Scheduling enforces the requirement at run time (it throws when a + * scheduled bulk cargo type or a container type in the batch has no wagon type). + * + * Backfill reproduces the old hardcoded resolution one final time so existing + * bulk cargo + container rows are not left unset. After this, the runtime map is + * removed — the FK is the single source of truth. + */ +export class AddWagonTypeFkToCargoAndContainerTypes1940000000000 + implements MigrationInterface +{ + name = "AddWagonTypeFkToCargoAndContainerTypes1940000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // ── Columns + FKs ──────────────────────────────────────────────────────── + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS wagon_type_id uuid; + `); + await queryRunner.query(` + ALTER TABLE freight.container_types + ADD COLUMN IF NOT EXISTS wagon_type_id uuid; + `); + + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD CONSTRAINT fk_cargo_types_wagon_type + FOREIGN KEY (wagon_type_id) + REFERENCES freight.wagon_types(id) + ON DELETE RESTRICT; + `); + await queryRunner.query(` + ALTER TABLE freight.container_types + ADD CONSTRAINT fk_container_types_wagon_type + FOREIGN KEY (wagon_type_id) + REFERENCES freight.wagon_types(id) + ON DELETE RESTRICT; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_cargo_types_wagon_type_id + ON freight.cargo_types (wagon_type_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_container_types_wagon_type_id + ON freight.container_types (wagon_type_id); + `); + + // ── Backfill: old cargo-code → wagon-code map (one last time) ───────────── + // COFFEE/GRAIN/WHEAT/SORGHUM/CORN → KW2, FERTILIZER/SUGAR → PW2, + // COAL → KW3, STEEL/ORE → CW3. Unmapped bulk cargo → CW3 (old default). + const cargoCodeToWagon: Record = { + COFFEE: "KW2", + GRAIN: "KW2", + WHEAT: "KW2", + SORGHUM: "KW2", + CORN: "KW2", + FERTILIZER: "PW2", + SUGAR: "PW2", + COAL: "KW3", + STEEL: "CW3", + ORE: "CW3", + }; + + for (const [cargoCode, wagonCode] of Object.entries(cargoCodeToWagon)) { + await queryRunner.query( + ` + UPDATE freight.cargo_types ct + SET wagon_type_id = wt.id + FROM freight.wagon_types wt + WHERE wt.code = $1 + AND UPPER(TRIM(ct.code)) = $2 + AND ct.wagon_type_id IS NULL; + `, + [wagonCode, cargoCode], + ); + } + + // Remaining bulk cargo (PER_TON) without a mapped code → default bulk wagon CW3. + await queryRunner.query(` + UPDATE freight.cargo_types ct + SET wagon_type_id = wt.id + FROM freight.wagon_types wt + WHERE wt.code = 'CW3' + AND ct.wagon_type_id IS NULL + AND ct.unit_of_measure = 'PER_TON'; + `); + + // All container types → the old container default wagon NW5. + await queryRunner.query(` + UPDATE freight.container_types ct + SET wagon_type_id = wt.id + FROM freight.wagon_types wt + WHERE wt.code = 'NW5' + AND ct.wagon_type_id IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_container_types_wagon_type_id; + `); + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_cargo_types_wagon_type_id; + `); + await queryRunner.query(` + ALTER TABLE freight.container_types + DROP CONSTRAINT IF EXISTS fk_container_types_wagon_type; + `); + await queryRunner.query(` + ALTER TABLE freight.cargo_types + DROP CONSTRAINT IF EXISTS fk_cargo_types_wagon_type; + `); + await queryRunner.query(` + ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id; + `); + await queryRunner.query(` + ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1950000000000-AddCustomerTruckAssignments.ts b/apps/edr-freight-api/src/migrations/1950000000000-AddCustomerTruckAssignments.ts new file mode 100644 index 000000000..7c95199b1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1950000000000-AddCustomerTruckAssignments.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Multi-truck customer (self-haul) assignment. Replaces the single + * booking.customer_truck_* fields with a per-booking list of trucks, each + * carrying 1–2 containers and tracking its own arrival. The legacy + * booking.customer_truck_* columns are kept as a synced booking-level flag + * (any truck assigned / all trucks arrived) so the warehouse exit-gate and + * delivery-approval logic keep working. + */ +export class AddCustomerTruckAssignments1950000000000 implements MigrationInterface { + name = 'AddCustomerTruckAssignments1950000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.customer_truck_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + plate_number varchar(32) NOT NULL, + driver_name varchar(120) NOT NULL, + truck_type varchar(60) NOT NULL, + assigned_at timestamptz NOT NULL DEFAULT now(), + arrived_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_customer_truck_assignments_booking" ON freight.customer_truck_assignments (booking_id);`, + ); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.customer_truck_containers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + assignment_id uuid NOT NULL REFERENCES freight.customer_truck_assignments(id) ON DELETE CASCADE, + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + container_number varchar(64) NOT NULL, + 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_customer_truck_containers_assignment" ON freight.customer_truck_containers (assignment_id);`, + ); + // One container number can be loaded onto exactly one truck per booking. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_customer_truck_containers_booking_number" + ON freight.customer_truck_containers (booking_id, container_number) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_containers;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_assignments;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1950000000000-AddVehicleCompliance.ts b/apps/edr-freight-api/src/migrations/1950000000000-AddVehicleCompliance.ts new file mode 100644 index 000000000..f83f0a236 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1950000000000-AddVehicleCompliance.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Vehicle Compliance & Expiry Alerts. + * - Adds expiry-tracking columns to freight.vehicles. + * - Creates freight.compliance_records for per-document compliance tracking. + */ +export class AddVehicleCompliance1950000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Vehicle expiry / compliance columns. + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS vin VARCHAR, + ADD COLUMN IF NOT EXISTS ownership VARCHAR, + ADD COLUMN IF NOT EXISTS insurance_expiry DATE, + ADD COLUMN IF NOT EXISTS registration_expiry DATE, + ADD COLUMN IF NOT EXISTS next_inspection_date DATE; + `); + + // Compliance records table. + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.compliance_records ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vehicle_id UUID NOT NULL REFERENCES freight.vehicles(id), + type VARCHAR NOT NULL, + document_number VARCHAR, + issued_date DATE, + expiry_date DATE NOT NULL, + status VARCHAR NOT NULL DEFAULT 'VALID', + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_compliance_records_vehicle_id ON freight.compliance_records(vehicle_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_compliance_records_expiry_date ON freight.compliance_records(expiry_date);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_compliance_records_type ON freight.compliance_records(type);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.compliance_records CASCADE;`); + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS vin, + DROP COLUMN IF EXISTS ownership, + DROP COLUMN IF EXISTS insurance_expiry, + DROP COLUMN IF EXISTS registration_expiry, + DROP COLUMN IF EXISTS next_inspection_date; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1950000000000-AddWindowCloseHour.ts b/apps/edr-freight-api/src/migrations/1950000000000-AddWindowCloseHour.ts new file mode 100644 index 000000000..6ff9bdc12 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1950000000000-AddWindowCloseHour.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add the daily booking-desk close hour. + * + * The import booking window used to reopen only within the same EAT calendar day + * as its close; a cycle whose reopen crossed midnight died at CLOSED_FOR_DAY with + * capacity still free. The window now runs a daily office range [openHour, + * closeHour): a not-yet-full train pauses at closeHour and resumes the next + * morning at openHour, every day until it fills or departs. openHour === closeHour + * means a 24-hour desk. + * + * `window_close_hour` on the global-rules singleton is the live config; the + * matching `rule_window_close_hour` snapshot on each schedule freezes it at + * creation so the batch board keeps drawing the window the customer was shown. + * Both default/backfill to 17:00 (5 PM), the previous implicit office close. + */ +export class AddWindowCloseHour1950000000000 implements MigrationInterface { + name = "AddWindowCloseHour1950000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN IF NOT EXISTS window_close_hour integer NOT NULL DEFAULT 17; + `); + + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS rule_window_close_hour integer; + `); + + // Backfill the snapshot from the global-rules singleton so pre-existing + // schedules keep projecting reopen cycles. + await queryRunner.query(` + UPDATE freight.train_schedules ts + SET rule_window_close_hour = COALESCE(ts.rule_window_close_hour, r.window_close_hour) + FROM freight.train_scheduling_global_rules r + WHERE ts.rule_window_close_hour IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS rule_window_close_hour; + `); + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS window_close_hour; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1950000000000-CreateNotifications.ts b/apps/edr-freight-api/src/migrations/1950000000000-CreateNotifications.ts new file mode 100644 index 000000000..c5c608a95 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1950000000000-CreateNotifications.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * In-app notification inbox. One row per recipient per logical notification; + * producers fan out by inserting many rows. Indexed for the two hot queries: + * unread-count (recipient + is_read) and the newest-first list (recipient + + * created_at). Enum-like columns are stored as varchar to avoid PG enum churn. + */ +export class CreateNotifications1950000000000 implements MigrationInterface { + name = "CreateNotifications1950000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.notifications ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + recipient_user_id uuid NOT NULL, + audience varchar(20) NOT NULL, + type varchar(48) NOT NULL DEFAULT 'GENERIC', + title varchar(200) NOT NULL, + body text NOT NULL, + link varchar, + data jsonb, + priority varchar(12) NOT NULL DEFAULT 'NORMAL', + is_read boolean NOT NULL DEFAULT false, + read_at timestamptz, + channels_sent jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_NOTIFICATIONS_RECIPIENT_UNREAD" + ON freight.notifications (recipient_user_id, is_read) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_NOTIFICATIONS_RECIPIENT_CREATED" + ON freight.notifications (recipient_user_id, created_at) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_NOTIFICATIONS_RECIPIENT_CREATED"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_NOTIFICATIONS_RECIPIENT_UNREAD"`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.notifications`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts b/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts new file mode 100644 index 000000000..59a0441c5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-container receive tracking. A booking's containers arrive individually + * (on separate self-haul trucks), so each container unit tracks whether it has + * been received into the port and, once staff confirm it, the GRN it belongs to. + * A single GRN covers the containers received together — so if the whole booking + * arrives at once, all its units share one GRN (per-booking GRN). + */ +export class AddContainerReceiptToBookingContainerUnits1960000000000 + implements MigrationInterface +{ + name = 'AddContainerReceiptToBookingContainerUnits1960000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_container_units + ADD COLUMN IF NOT EXISTS received_to_port boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS received_at timestamptz, + ADD COLUMN IF NOT EXISTS grn_number varchar(100) + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_booking_container_units_grn" ON freight.booking_container_units (grn_number);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_booking_container_units_grn";`); + await queryRunner.query(` + ALTER TABLE freight.booking_container_units + DROP COLUMN IF EXISTS received_to_port, + DROP COLUMN IF EXISTS received_at, + DROP COLUMN IF EXISTS grn_number + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1960000000000-AddIncidents.ts b/apps/edr-freight-api/src/migrations/1960000000000-AddIncidents.ts new file mode 100644 index 000000000..dbb2994c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1960000000000-AddIncidents.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Accident & Incident register for the fleet. Tracks accidents, breakdowns, + * traffic violations, thefts and other incidents against a vehicle, driver + * and/or booking, with severity, damage estimate, insurance claim tracking and + * a lifecycle status. Queried by driver_id for per-driver incident history. + */ +export class AddIncidents1960000000000 implements MigrationInterface { + name = 'AddIncidents1960000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.incidents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + vehicle_id uuid, + driver_id uuid, + booking_id uuid, + type varchar NOT NULL, + severity varchar NOT NULL, + occurred_at timestamptz NOT NULL, + location varchar, + description text NOT NULL, + damage_estimate numeric(14,2), + status varchar NOT NULL DEFAULT 'REPORTED', + insurance_claim_number varchar, + reported_by varchar + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_INCIDENTS_DRIVER" + ON freight.incidents (driver_id, occurred_at) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_INCIDENTS_VEHICLE" + ON freight.incidents (vehicle_id, occurred_at) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.incidents`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts b/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts new file mode 100644 index 000000000..e36420751 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Import self-haul trucks are weighed on leaving. The customer does not + * pre-specify what an import truck takes — staff register the containers loaded + * and the weighed gross when the truck departs. These columns capture that. + */ +export class AddCustomerTruckDeparture1970000000000 implements MigrationInterface { + name = 'AddCustomerTruckDeparture1970000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + ADD COLUMN IF NOT EXISTS gross_weight_kg numeric(14, 2), + ADD COLUMN IF NOT EXISTS departed_at timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + DROP COLUMN IF EXISTS gross_weight_kg, + DROP COLUMN IF EXISTS departed_at + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1970000000000-AddMaintenanceDepth.ts b/apps/edr-freight-api/src/migrations/1970000000000-AddMaintenanceDepth.ts new file mode 100644 index 000000000..87b52ceff --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1970000000000-AddMaintenanceDepth.ts @@ -0,0 +1,93 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMaintenanceDepth1970000000000 implements MigrationInterface { + name = 'AddMaintenanceDepth1970000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.work_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vehicle_id UUID NOT NULL, + title VARCHAR NOT NULL, + description TEXT, + status VARCHAR NOT NULL DEFAULT 'OPEN', + priority VARCHAR NOT NULL DEFAULT 'MEDIUM', + assigned_to VARCHAR, + opened_at TIMESTAMPTZ NOT NULL DEFAULT now(), + closed_at TIMESTAMPTZ, + labor_cost NUMERIC(14, 2), + parts_cost NUMERIC(14, 2), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.parts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR NOT NULL, + sku VARCHAR, + category VARCHAR, + quantity_in_stock INT NOT NULL DEFAULT 0, + reorder_level INT NOT NULL DEFAULT 0, + unit_cost NUMERIC(14, 2), + location VARCHAR, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warranties ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vehicle_id UUID NOT NULL, + component VARCHAR NOT NULL, + provider VARCHAR, + start_date DATE, + expiry_date DATE NOT NULL, + coverage_notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_work_orders_vehicle_id_status" ON freight.work_orders (vehicle_id, status)`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_parts_category" ON freight.parts (category)`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warranties_vehicle_id_expiry_date" ON freight.warranties (vehicle_id, expiry_date)`, + ); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.work_orders + ADD CONSTRAINT "FK_work_orders_vehicle_id" + FOREIGN KEY (vehicle_id) REFERENCES freight.vehicles(id) ON DELETE CASCADE; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.warranties + ADD CONSTRAINT "FK_warranties_vehicle_id" + FOREIGN KEY (vehicle_id) REFERENCES freight.vehicles(id) ON DELETE CASCADE; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.warranties CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.parts CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.work_orders CASCADE`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1980000000000-AddBookingHandovers.ts b/apps/edr-freight-api/src/migrations/1980000000000-AddBookingHandovers.ts new file mode 100644 index 000000000..a47ff6297 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1980000000000-AddBookingHandovers.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_handovers;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1980000000000-AddProcurement.ts b/apps/edr-freight-api/src/migrations/1980000000000-AddProcurement.ts new file mode 100644 index 000000000..6d4304aaf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1980000000000-AddProcurement.ts @@ -0,0 +1,73 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddProcurement1980000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.vendors ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + name varchar NOT NULL, + type varchar, + contact_person varchar, + phone varchar, + email varchar, + address varchar, + is_active boolean NOT NULL DEFAULT true + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.asset_acquisitions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + vehicle_id uuid, + vendor_id uuid, + acquisition_type varchar NOT NULL, + acquisition_date date NOT NULL, + cost numeric(14,2), + useful_life_months integer, + salvage_value numeric(14,2), + lease_start date, + lease_end date, + monthly_payment numeric(14,2), + status varchar NOT NULL DEFAULT 'ACTIVE', + notes text + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_asset_acquisitions_vehicle_date + ON freight.asset_acquisitions(vehicle_id, acquisition_date); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.asset_disposals ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + vehicle_id uuid NOT NULL, + disposal_date date NOT NULL, + method varchar NOT NULL, + sale_price numeric(14,2), + buyer varchar, + notes text + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_asset_disposals_vehicle_date + ON freight.asset_disposals(vehicle_id, disposal_date); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.asset_disposals CASCADE;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.asset_acquisitions CASCADE;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.vendors CASCADE;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1980000000000-YardCountryEnumAndRouteDirection.ts b/apps/edr-freight-api/src/migrations/1980000000000-YardCountryEnumAndRouteDirection.ts new file mode 100644 index 000000000..3274b4d1c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1980000000000-YardCountryEnumAndRouteDirection.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Yard country becomes a two-value enum (Ethiopia | Djibouti) and every route + * freezes its trade direction from the yard countries: + * Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT, + * same country = DOMESTIC (shown as "Intercity"; disabled for scheduling + * and contracts for now). + * + * Existing yard rows are normalized case-insensitively; anything mentioning + * Djibouti maps there, everything else maps to Ethiopia (the line only serves + * these two countries). A CHECK constraint keeps future writes honest. + */ +export class YardCountryEnumAndRouteDirection1980000000000 implements MigrationInterface { + name = 'YardCountryEnumAndRouteDirection1980000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.yards + SET country = CASE + WHEN lower(trim(country)) LIKE '%djib%' THEN 'Djibouti' + ELSE 'Ethiopia' + END + `); + await queryRunner.query(` + ALTER TABLE freight.yards + DROP CONSTRAINT IF EXISTS chk_yards_country, + ADD CONSTRAINT chk_yards_country CHECK (country IN ('Ethiopia', 'Djibouti')) + `); + + await queryRunner.query(` + ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS direction varchar(10) + `); + await queryRunner.query(` + UPDATE freight.routes r + SET direction = CASE + WHEN o.country = 'Djibouti' AND d.country = 'Ethiopia' THEN 'IMPORT' + WHEN o.country = 'Ethiopia' AND d.country = 'Djibouti' THEN 'EXPORT' + ELSE 'DOMESTIC' + END + FROM freight.yards o, freight.yards d + WHERE o.id = r.origin_yard_id + AND d.id = r.destination_yard_id + `); + // Orphan origin/destination (deleted yard) — no way to classify; park as + // DOMESTIC, which is blocked everywhere, so nothing can schedule on it. + await queryRunner.query(` + UPDATE freight.routes SET direction = 'DOMESTIC' WHERE direction IS NULL + `); + await queryRunner.query(` + ALTER TABLE freight.routes + ALTER COLUMN direction SET NOT NULL, + DROP CONSTRAINT IF EXISTS chk_routes_direction, + ADD CONSTRAINT chk_routes_direction CHECK (direction IN ('IMPORT', 'EXPORT', 'DOMESTIC')) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.routes + DROP CONSTRAINT IF EXISTS chk_routes_direction, + DROP COLUMN IF EXISTS direction + `); + await queryRunner.query(` + ALTER TABLE freight.yards DROP CONSTRAINT IF EXISTS chk_yards_country + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1990000000000-AddDoubleHandlingBasisAndMachinery.ts b/apps/edr-freight-api/src/migrations/1990000000000-AddDoubleHandlingBasisAndMachinery.ts new file mode 100644 index 000000000..be4ed060a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1990000000000-AddDoubleHandlingBasisAndMachinery.ts @@ -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 { + 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 { + await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS basis`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1990000000000-AddVehiclePricePerKm.ts b/apps/edr-freight-api/src/migrations/1990000000000-AddVehiclePricePerKm.ts new file mode 100644 index 000000000..7767cd796 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1990000000000-AddVehiclePricePerKm.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Per-km haulage rate on a vehicle (mainly trucks) plus the currency it's + * quoted in (ETB | USD, default ETB). + */ +export class AddVehiclePricePerKm1990000000000 implements MigrationInterface { + name = "AddVehiclePricePerKm1990000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS price_per_km numeric(14,2), + ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS price_per_km, + DROP COLUMN IF EXISTS currency + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1990000000000-SegmentCorridorBookings.ts b/apps/edr-freight-api/src/migrations/1990000000000-SegmentCorridorBookings.ts new file mode 100644 index 000000000..ca88ef7cd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1990000000000-SegmentCorridorBookings.ts @@ -0,0 +1,75 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Segment corridor bookings: a booking may ride only part of a train's route + * (its own origin→destination leg), so dispatch/arrival become per-booking + * facts and wagon capacity is consumed per leg instead of per whole route. + * + * - bookings.loaded_at / arrived_at (+ by-user): operator-confirmed load at + * the booking's origin yard and unload at its destination yard. Clearance + * gates read arrived_at, not the train's actual_arrival_at. + * - train_set_wagons.board_yard_id / alight_yard_id: the leg a consist slot + * occupies; NULL/NULL = whole route (legacy). Non-overlapping legs coexist + * without consuming each other's capacity. + * - wagon_movements: auditable ledger of every physical wagon relocation + * (loaded leg / empty reposition / manual correction) with the acting user. + */ +export class SegmentCorridorBookings1990000000000 implements MigrationInterface { + name = 'SegmentCorridorBookings1990000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS loaded_at timestamptz, + ADD COLUMN IF NOT EXISTS loaded_by_user_id uuid, + ADD COLUMN IF NOT EXISTS arrived_at timestamptz, + ADD COLUMN IF NOT EXISTS arrived_by_user_id uuid; + `); + + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + ADD COLUMN IF NOT EXISTS board_yard_id uuid REFERENCES freight.yards(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS alight_yard_id uuid REFERENCES freight.yards(id) ON DELETE SET NULL; + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_movements ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + wagon_id uuid NOT NULL REFERENCES freight.wagons(id) ON DELETE CASCADE, + from_yard_id uuid REFERENCES freight.yards(id), + to_yard_id uuid NOT NULL REFERENCES freight.yards(id), + train_schedule_id uuid REFERENCES freight.train_schedules(id) ON DELETE SET NULL, + booking_id uuid REFERENCES freight.bookings(id) ON DELETE SET NULL, + kind varchar(30) NOT NULL, + moved_by_user_id uuid, + occurred_at timestamptz NOT NULL DEFAULT now(), + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_wagon_movements_wagon_occurred" ON freight.wagon_movements (wagon_id, occurred_at);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_wagon_movements_schedule" ON freight.wagon_movements (train_schedule_id);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_movements;`); + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + DROP COLUMN IF EXISTS board_yard_id, + DROP COLUMN IF EXISTS alight_yard_id; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS loaded_at, + DROP COLUMN IF EXISTS loaded_by_user_id, + DROP COLUMN IF EXISTS arrived_at, + DROP COLUMN IF EXISTS arrived_by_user_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2000000000000-AddCustomsPriorityConfig.ts b/apps/edr-freight-api/src/migrations/2000000000000-AddCustomsPriorityConfig.ts new file mode 100644 index 000000000..c7009c303 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000000-AddCustomsPriorityConfig.ts @@ -0,0 +1,83 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Moves service-level priority off the service_types table and onto the + * admin-managed priority_configs table as a new CUSTOMS rule type. + * + * - Drops service_types.priority_bonus_points (replaced by CUSTOMS configs). + * - Widens priority_configs.type CHECK to allow 'CUSTOMS' (currency must be + * null, same as WAGON). + * - Seeds the two customs wagon-count tiers: 1–10 → 7 pts, 11–53 → 15 pts. + * CUSTOMS rules apply only when the booking's service type includesCustoms. + */ +export class AddCustomsPriorityConfig2000000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.service_types DROP COLUMN IF EXISTS priority_bonus_points; + `); + + await queryRunner.query(` + ALTER TABLE freight.priority_configs + DROP CONSTRAINT IF EXISTS priority_configs_type_check; + `); + await queryRunner.query(` + ALTER TABLE freight.priority_configs + ADD CONSTRAINT priority_configs_type_check + CHECK (type IN ('WAGON', 'CURRENCY', 'CUSTOMS')); + `); + + await queryRunner.query(` + ALTER TABLE freight.priority_configs + DROP CONSTRAINT IF EXISTS chk_currency_for_type; + `); + await queryRunner.query(` + ALTER TABLE freight.priority_configs + ADD CONSTRAINT chk_currency_for_type CHECK ( + (type = 'WAGON' AND currency IS NULL) OR + (type = 'CURRENCY' AND currency IS NOT NULL) OR + (type = 'CUSTOMS' AND currency IS NULL) + ); + `); + + await queryRunner.query(` + INSERT INTO freight.priority_configs + (type, label, currency, min_wagon_count, max_wagon_count, score_points, is_active, display_order) + VALUES + ('CUSTOMS', 'With customs 1–10 wagons', NULL, 1, 10, 7, true, 1), + ('CUSTOMS', 'With customs 11–53 wagons', NULL, 11, 53, 15, true, 2); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM freight.priority_configs WHERE type = 'CUSTOMS'; + `); + + await queryRunner.query(` + ALTER TABLE freight.priority_configs + DROP CONSTRAINT IF EXISTS chk_currency_for_type; + `); + await queryRunner.query(` + ALTER TABLE freight.priority_configs + ADD CONSTRAINT chk_currency_for_type CHECK ( + (type = 'WAGON' AND currency IS NULL) OR + (type = 'CURRENCY' AND currency IS NOT NULL) + ); + `); + + await queryRunner.query(` + ALTER TABLE freight.priority_configs + DROP CONSTRAINT IF EXISTS priority_configs_type_check; + `); + await queryRunner.query(` + ALTER TABLE freight.priority_configs + ADD CONSTRAINT priority_configs_type_check + CHECK (type IN ('WAGON', 'CURRENCY')); + `); + + await queryRunner.query(` + ALTER TABLE freight.service_types + ADD COLUMN IF NOT EXISTS priority_bonus_points INT NOT NULL DEFAULT 0; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2000000000000-AddGpsTracking.ts b/apps/edr-freight-api/src/migrations/2000000000000-AddGpsTracking.ts new file mode 100644 index 000000000..3d440c678 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000000-AddGpsTracking.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * GPS tracking: physical trackers (gps_devices, one denormalized latest fix per + * device for the live map) + append-only fix history (gps_positions). + */ +export class AddGpsTracking2000000000000 implements MigrationInterface { + name = "AddGpsTracking2000000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.gps_devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + imei varchar(20) NOT NULL UNIQUE, + name varchar, + vehicle_id uuid REFERENCES freight.vehicles(id), + status varchar(16) NOT NULL DEFAULT 'REGISTERED', + last_seen_at timestamptz, + last_lat numeric(10,6), + last_lng numeric(10,6), + last_speed numeric(6,2), + last_course int, + last_fix_at timestamptz, + voltage_level int, + gsm_level int, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_DEVICES_VEHICLE" + ON freight.gps_devices (vehicle_id) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.gps_positions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + device_id uuid NOT NULL, + imei varchar(20) NOT NULL, + vehicle_id uuid, + lat numeric(10,6) NOT NULL, + lng numeric(10,6) NOT NULL, + speed numeric(6,2) NOT NULL DEFAULT 0, + course int NOT NULL DEFAULT 0, + satellites int NOT NULL DEFAULT 0, + positioned boolean NOT NULL DEFAULT false, + gps_time timestamptz NOT NULL, + alarm int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_DEVICE_TIME" + ON freight.gps_positions (device_id, gps_time) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME" + ON freight.gps_positions (vehicle_id, gps_time) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_positions`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_devices`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2000000000000-AddTruckDetentionTiming.ts b/apps/edr-freight-api/src/migrations/2000000000000-AddTruckDetentionTiming.ts new file mode 100644 index 000000000..ce7bfd326 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000000-AddTruckDetentionTiming.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Truck detention support. + * - last_mile.arrived_at / delivered_at: the detention window for an EDR + * last-mile vehicle. The clock runs from arrival at destination; the customer + * has a grace period (default 3h) to clear/return, after which detention + * accrues per truck per day until delivered_at (or now, if still out). + * - warehouse_fee_rules.free_hours: configurable grace window (hours) for a + * TRUCK_DETENTION_FEE rule; null/0 falls back to the 3-hour default. + */ +export class AddTruckDetentionTiming2000000000000 implements MigrationInterface { + name = 'AddTruckDetentionTiming2000000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.last_mile ADD COLUMN IF NOT EXISTS arrived_at timestamptz`, + ); + await queryRunner.query( + `ALTER TABLE freight.last_mile ADD COLUMN IF NOT EXISTS delivered_at timestamptz`, + ); + await queryRunner.query( + `ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS free_hours int`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS free_hours`); + await queryRunner.query(`ALTER TABLE freight.last_mile DROP COLUMN IF EXISTS delivered_at`); + await queryRunner.query(`ALTER TABLE freight.last_mile DROP COLUMN IF EXISTS arrived_at`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2010000000000-AddConsolidationResumeStatus.ts b/apps/edr-freight-api/src/migrations/2010000000000-AddConsolidationResumeStatus.ts new file mode 100644 index 000000000..cb6f7dc91 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2010000000000-AddConsolidationResumeStatus.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds bookings.consolidation_resume_status: the status a booking parked in + * PENDING_CONSOLIDATION returns to once it pairs with a wagon partner. + * + * Direct customer bookings leave it NULL (they resume to SUBMITTED, unchanged). + * Contract-drawdown bookings (GL shipments) set it to the status + * createUnderContract would otherwise have used (OPERATION_REQUEST_PENDING or + * AWAITING_DOCUMENTS), so pairing resumes them into the contract-booking flow + * instead of wrongly moving them to SUBMITTED. + */ +export class AddConsolidationResumeStatus2010000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS consolidation_resume_status VARCHAR(40); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS consolidation_resume_status; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2010000000000-AddFeeRuleVehicleType.ts b/apps/edr-freight-api/src/migrations/2010000000000-AddFeeRuleVehicleType.ts new file mode 100644 index 000000000..a71f3cb5b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2010000000000-AddFeeRuleVehicleType.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Truck-detention rules can be scoped by vehicle type (TRUCK / VAN / TRAILER / + * TANKER / FLATBED / …), so different truck types carry different detention + * rates. Null = applies to any truck type. + */ +export class AddFeeRuleVehicleType2010000000000 implements MigrationInterface { + name = 'AddFeeRuleVehicleType2010000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS vehicle_type varchar(20)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS vehicle_type`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2020000000000-RepairOtpEmailSchema.ts b/apps/edr-freight-api/src/migrations/2020000000000-RepairOtpEmailSchema.ts new file mode 100644 index 000000000..e11923a4d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2020000000000-RepairOtpEmailSchema.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Repair: AddEmailToOtpVerifications1900000000000 originally altered + * `public.otp_verifications`, but the OtpVerification entity pins + * schema: "freight". On any DB where that migration already ran (and is recorded + * as executed, so it won't run again), the real `freight.otp_verifications` table + * never got the `email` column and `phone` was never made nullable — so OTP send + * dies with `column OtpVerification.email does not exist`. + * + * This migration re-applies the change against the correct schema. Idempotent + * (IF NOT EXISTS / no-op DROP NOT NULL), and guarded so it's a no-op when the + * freight table is absent. + */ +export class RepairOtpEmailSchema2020000000000 implements MigrationInterface { + name = "RepairOtpEmailSchema2020000000000"; + + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable("freight.otp_verifications"); + if (!exists) return; + + await queryRunner.query(` + ALTER TABLE freight.otp_verifications + ALTER COLUMN phone DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.otp_verifications + ADD COLUMN IF NOT EXISTS email varchar UNIQUE + `); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable("freight.otp_verifications"); + if (!exists) return; + + await queryRunner.query(` + ALTER TABLE freight.otp_verifications + DROP COLUMN IF EXISTS email + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2020000000000-WarehouseCapacityKgToTons.ts b/apps/edr-freight-api/src/migrations/2020000000000-WarehouseCapacityKgToTons.ts new file mode 100644 index 000000000..333a0f541 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2020000000000-WarehouseCapacityKgToTons.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Weights are tonnes everywhere. Warehouse / yard / zone capacity was stored in + * kg (e.g. 25000, 5000, 2500) — convert existing rows to tonnes (÷1000). Cargo + * weight (warehouse_inventory.weight ← cargo_total_weight_vgm) is already tonnes + * and is NOT touched; truck gross weight has no data yet. Runs exactly once + * (tracked by TypeORM) — re-running would divide again. + */ +export class WarehouseCapacityKgToTons2020000000000 implements MigrationInterface { + name = 'WarehouseCapacityKgToTons2020000000000'; + + private readonly tables = ['warehouses', 'warehouse_yards', 'warehouse_zones']; + private readonly columns = ['capacity_weight', 'current_weight', 'max_weight']; + + public async up(queryRunner: QueryRunner): Promise { + for (const table of this.tables) { + for (const column of this.columns) { + await queryRunner.query( + `UPDATE freight.${table} SET ${column} = ${column} / 1000.0 WHERE ${column} IS NOT NULL`, + ); + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + for (const table of this.tables) { + for (const column of this.columns) { + await queryRunner.query( + `UPDATE freight.${table} SET ${column} = ${column} * 1000.0 WHERE ${column} IS NOT NULL`, + ); + } + } + } +} diff --git a/apps/edr-freight-api/src/migrations/2030000000000-AddTrainScheduleReference.ts b/apps/edr-freight-api/src/migrations/2030000000000-AddTrainScheduleReference.ts new file mode 100644 index 000000000..a05465c53 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2030000000000-AddTrainScheduleReference.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds train_schedules.reference: a human-facing unique schedule number + * S-YYYY-NNNNN (per-year sequence, like bookings' BK-YYYY-NNNNNN). + * + * - Adds the nullable column. + * - Backfills existing rows: within each created-at year, numbers rows by + * created_at ascending (oldest → S--00001). Deterministic order. + * - Adds a partial unique index (NULLs allowed so a future insert can stage + * the row before the app stamps its reference). + */ +export class AddTrainScheduleReference2030000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS reference VARCHAR(20); + `); + + // Backfill per-year, ordered by created_at (oldest = 00001). Uses the row's + // own created-at year as the reference year so historical rows keep a + // sensible number. + await queryRunner.query(` + WITH numbered AS ( + SELECT + id, + EXTRACT(YEAR FROM created_at)::int AS yr, + ROW_NUMBER() OVER ( + PARTITION BY EXTRACT(YEAR FROM created_at) + ORDER BY created_at ASC, id ASC + ) AS seq + FROM freight.train_schedules + WHERE reference IS NULL + ) + UPDATE freight.train_schedules ts + SET reference = 'S-' || numbered.yr || '-' || LPAD(numbered.seq::text, 5, '0') + FROM numbered + WHERE ts.id = numbered.id; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_train_schedules_reference + ON freight.train_schedules (reference) + WHERE reference IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.ux_train_schedules_reference; + `); + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS reference; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts b/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts new file mode 100644 index 000000000..13084d1c8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Query } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Public } from "@edr/api-common"; + +import { CheckAvailabilityService } from "./check-availability.service"; + +@ApiTags("auth") +@Controller("auth") +@Public() +export class CheckAvailabilityController { + constructor( + private readonly checkAvailabilityService: CheckAvailabilityService, + ) {} + + @Get("check-availability") + @ApiOperation({ + summary: "Check whether an email and/or phone number is already registered", + }) + check(@Query("email") email?: string, @Query("phone") phone?: string) { + return this.checkAvailabilityService.check({ email, phone }); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/check-availability.service.ts b/apps/edr-freight-api/src/modules/auth/check-availability.service.ts new file mode 100644 index 000000000..c9ce84b72 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/check-availability.service.ts @@ -0,0 +1,47 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +export interface CheckAvailabilityQuery { + email?: string; + phone?: string; +} + +export interface CheckAvailabilityResult { + emailTaken: boolean; + phoneTaken: boolean; +} + +@Injectable() +export class CheckAvailabilityService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + async check({ + email, + phone, + }: CheckAvailabilityQuery): Promise { + if (!email && !phone) { + throw new BadRequestException("email or phone is required"); + } + + const matches = await this.userRepository.find({ + where: [ + ...(email ? [{ email }] : []), + ...(phone ? [{ phoneNumber: phone }] : []), + ], + select: { id: true, email: true, phoneNumber: true }, + }); + + return { + emailTaken: email ? matches.some((user) => user.email === email) : false, + phoneTaken: phone + ? matches.some((user) => user.phoneNumber === phone) + : false, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index a689ba24e..16fbeffda 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -1,10 +1,16 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; + +import { CheckAvailabilityController } from './check-availability.controller'; +import { CheckAvailabilityService } from './check-availability.service'; import { FreightMeController } from './freight-me.controller'; import { FreightMeService } from './freight-me.service'; @Module({ - controllers: [FreightMeController], - providers: [FreightMeService], + imports: [TypeOrmModule.forFeature([User])], + controllers: [FreightMeController, CheckAvailabilityController], + providers: [FreightMeService, CheckAvailabilityService], }) export class FreightAuthModule {} diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index 7c7805b28..d49cc304e 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -8,7 +8,11 @@ import { hashPassword } from "@tria-plc/api-common/utils/argon"; import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm"; -import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common"; +// Subpath imports (not the package root) so ts-jest can resolve them when this +// file lands in a spec's compile graph via the notification recipients chain. +import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity"; +import { Organization } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization.entity"; +import { UserCredential } from "@tria-plc/iamapi-common/entities/iam/user/user-credential.entity"; import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; @@ -40,6 +44,21 @@ export class BackofficeService { private readonly dataSource: DataSource, ) {} + /** + * IAM user ids of every current employee across all organizations — used by + * the notification recipients resolver's `allBackoffice` selector. + */ + async getAllCurrentEmployeeUserIds(): Promise { + const employees = await this.employeeRepository.find({ + where: { isCurrent: true }, + }); + return [ + ...new Set( + employees.map((e) => e.userId).filter((id): id is string => Boolean(id)), + ), + ]; + } + async createOrganizationUser( organizationId: string, dto: CreateOrganizationUserDto, diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index e954b7e1b..b9f0a74c0 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,19 +1,31 @@ -import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Query, + Res, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Response } from "express"; -import { FreightAdmin } from "../../common/booking-guards"; +import { BookingView } from "../../common/booking-guards"; import { BillingService } from "./billing.service"; +import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @ApiTags("billing") @Controller("billing") -@FreightAdmin() +@BookingView() +@ApiBearerAuth() export class BillingController { - constructor(private readonly billingService: BillingService) { } + constructor(private readonly billingService: BillingService) {} @Get("invoices") - @ApiOperation({ summary: "List all invoices" }) - findAll() { - return this.billingService.findAll(); + @ApiOperation({ + summary: "List invoices (paginated, filterable by company/status/search)", + }) + findAll(@Query() query: FilterInvoiceDto) { + return this.billingService.findAllPaginated(query); } @Get("invoices/:id") @@ -21,4 +33,26 @@ export class BillingController { findById(@Param("id", ParseUUIDPipe) id: string) { return this.billingService.findById(id); } + + @Get("invoices/:id/document") + @ApiOperation({ summary: "Download the sealed invoice PDF" }) + async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.billingService.document(id); + sendPdf(res, filename, buffer); + } + + @Get("invoices/:id/receipt") + @ApiOperation({ summary: "Download the sealed payment receipt PDF" }) + async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.billingService.receipt(id); + sendPdf(res, filename, buffer); + } +} + +/** Stream a generated PDF as a file download. */ +export function sendPdf(res: Response, filename: string, buffer: Buffer): void { + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + res.send(buffer); } diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 551fae6bf..771156fd3 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -3,7 +3,9 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { BillingController } from "./billing.controller"; import { PortalBillingController } from "./portal-billing.controller"; +import { PaymentController } from "./payment.controller"; import { BillingService } from "./billing.service"; +import { DocumentsModule } from "./documents/documents.module"; import { Invoice } from "./entities/invoice.entity"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { InvoiceRepository } from "./invoice.repository"; @@ -16,8 +18,9 @@ import { CompaniesModule } from "../companies/companies.module"; TypeOrmModule.forFeature([Invoice, InvoiceLine]), forwardRef(() => PaymentModule), CompaniesModule, + DocumentsModule, ], - controllers: [BillingController, PortalBillingController], + controllers: [BillingController, PortalBillingController, PaymentController], providers: [BillingService, InvoiceRepository, InvoiceLineRepository], exports: [BillingService], }) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 0e6d97de0..037957367 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -21,7 +21,9 @@ function makeManager(savedLines: unknown[]) { } function makeEvents() { - return { emit: jest.fn() }; + // BillingService emits via both emit() and emitAsync() (the post-commit async + // listener path) — the mock must provide both. + return { emit: jest.fn(), emitAsync: jest.fn().mockResolvedValue([]) }; } function generateInput(overrides: Record = {}) { @@ -76,6 +78,7 @@ describe("BillingService.generateInvoice", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); }); @@ -88,7 +91,7 @@ describe("BillingService.generateInvoice", () => { expect(invoice.sourceId).toBe("booking-1"); expect(invoice.totalAmount).toBe(1500); expect(invoice.issuedAt).toBeInstanceOf(Date); - expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/); + expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/); expect(savedLines).toHaveLength(2); }); @@ -115,12 +118,14 @@ describe("BillingService.generateInvoice", () => { }); describe("BillingService.markInvoiceAsPaid", () => { - it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => { + it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => { const open = { id: "inv-1", status: Freight.InvoiceStatus.Pending, source: "booking", sourceId: "booking-1", + totalAmount: 1500, + paidAt: null, }; const mg = { findOne: jest.fn().mockResolvedValue(open), @@ -134,6 +139,7 @@ describe("BillingService.markInvoiceAsPaid", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -141,9 +147,24 @@ describe("BillingService.markInvoiceAsPaid", () => { expect(mg.update).toHaveBeenCalledWith( expect.anything(), { id: "inv-1" }, - { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, + { + status: Freight.InvoiceStatus.Paid, + paymentId: "pay-1", + paidAt: expect.any(Date), + paidAmount: 1500, + balanceAmount: 0, + payments: [ + { + amount: 1500, + method: "GATEWAY", + reference: "pay-1", + paidAt: expect.any(String), + metadata: null, + }, + ], + }, ); - expect(events.emit).toHaveBeenCalledWith( + expect(events.emitAsync).toHaveBeenCalledWith( "booking.invoice.paid", expect.objectContaining({ invoiceId: "inv-1", @@ -171,80 +192,188 @@ describe("BillingService.markInvoiceAsPaid", () => { events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); expect(mg.update).not.toHaveBeenCalled(); expect(events.emit).not.toHaveBeenCalled(); + expect(events.emitAsync).not.toHaveBeenCalled(); }); }); -describe("BillingService.settlePayable", () => { - it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => { - const open = { - id: "inv-1", - status: Freight.InvoiceStatus.Pending, - source: Freight.InvoiceSource.Booking, - sourceId: "booking-1", - }; +describe("BillingService.recordPayment", () => { + function serviceFor(invoice: Record | null) { const mg = { - findOne: jest.fn().mockResolvedValue(open), + findOne: jest.fn().mockResolvedValue(invoice), update: jest.fn().mockResolvedValue(undefined), }; const events = makeEvents(); + const dataSource = { + manager: mg, + transaction: jest + .fn() + .mockImplementation((cb: (mg: unknown) => unknown) => cb(mg)), + }; const service = new BillingService( - { manager: mg } as never, + dataSource as never, {} as never, {} as never, events as never, {} as never, // payment {} as never, // companies + {} as never, // invoiceDocuments ); + return { service, mg, events }; + } - const settled = await service.settlePayable( - Freight.InvoiceSource.Booking, - "booking-1", - "pay-1", - mg as never, - ); + const openInvoice = (overrides: Record = {}) => ({ + id: "inv-1", + status: Freight.InvoiceStatus.Issued, + source: "warehouse", + sourceId: "inv-item-1", + totalAmount: 1000, + paidAmount: 0, + balanceAmount: 1000, + payments: [], + paidAt: null, + ...overrides, + }); - expect(settled?.status).toBe(Freight.InvoiceStatus.Paid); + it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => { + const { service, mg, events } = serviceFor(openInvoice()); + + const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" }); + + expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid); + expect(updated.paidAmount).toBe(400); + expect(updated.balanceAmount).toBe(600); + expect(updated.payments).toHaveLength(1); expect(mg.update).toHaveBeenCalledWith( expect.anything(), { id: "inv-1" }, - { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, + expect.objectContaining({ + status: Freight.InvoiceStatus.PartiallyPaid, + paidAmount: 400, + balanceAmount: 600, + }), ); - expect(events.emit).toHaveBeenCalledWith( - "booking.invoice.paid", - expect.anything(), + expect(events.emit).not.toHaveBeenCalled(); + expect(events.emitAsync).not.toHaveBeenCalled(); + }); + + it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => { + const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 })); + + const updated = await service.recordPayment("inv-1", { amount: 600 }); + + expect(updated.status).toBe(Freight.InvoiceStatus.Paid); + expect(updated.balanceAmount).toBe(0); + expect(updated.paidAt).toBeInstanceOf(Date); + expect(mg.update).toHaveBeenCalled(); + expect(events.emitAsync).toHaveBeenCalledWith( + "warehouse.invoice.paid", + expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }), ); }); - it("is a no-op (returns null) when the source has no open invoice", async () => { - const mg = { - findOne: jest.fn().mockResolvedValue(null), + it("rejects a non-positive amount", async () => { + const { service, mg } = serviceFor(openInvoice()); + await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow(); + expect(mg.update).not.toHaveBeenCalled(); + }); + + it("rejects a payment that exceeds the outstanding balance", async () => { + const { service, mg } = serviceFor(openInvoice()); + await expect( + service.recordPayment("inv-1", { amount: 1500 }), + ).rejects.toThrow(); + expect(mg.update).not.toHaveBeenCalled(); + }); + + it("rejects payment against a cancelled invoice", async () => { + const { service, mg } = serviceFor( + openInvoice({ status: Freight.InvoiceStatus.Cancelled }), + ); + await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow(); + expect(mg.update).not.toHaveBeenCalled(); + }); +}); + +/** + * Regression: `expirePayable` (batch settle path, called when a payment window + * lapses) transitions the invoice to EXPIRED, which locks the row FOR UPDATE. + * The bug passed `dataSource.manager` (the non-transactional default) into the + * transition, so runTransition skipped opening a transaction and the lock threw + * `An open transaction is required for pessimistic lock` — aborting the whole + * settle pass (the "settle/reserve one booking at a time" symptom). The locked + * write MUST run inside dataSource.transaction. + */ +describe("BillingService.expirePayable — locked write runs in a transaction", () => { + const openInvoice = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: "booking", + sourceId: "booking-1", + }; + + const build = (lookupResult: Record | null) => { + const defaultManager = { + findOne: jest.fn().mockResolvedValue(lookupResult), update: jest.fn().mockResolvedValue(undefined), }; + const txManager = { + findOne: jest.fn().mockResolvedValue(openInvoice), + update: jest.fn().mockResolvedValue(undefined), + }; + const transaction = jest + .fn() + .mockImplementation((cb: (mg: unknown) => unknown) => cb(txManager)); const events = makeEvents(); const service = new BillingService( - { manager: mg } as never, + { manager: defaultManager, transaction } as never, {} as never, {} as never, events as never, - {} as never, // payment - {} as never, // companies + {} as never, + {} as never, + {} as never, ); + return { service, defaultManager, txManager, transaction }; + }; - const settled = await service.settlePayable( + it("opens a transaction and runs the pessimistic-lock read on the tx manager", async () => { + const { service, transaction, txManager, defaultManager } = build(openInvoice); + + await service.expirePayable( Freight.InvoiceSource.Booking, "booking-1", - "pay-1", - mg as never, + "prepaid", ); - expect(settled).toBeNull(); - expect(mg.update).not.toHaveBeenCalled(); - expect(events.emit).not.toHaveBeenCalled(); + expect(transaction).toHaveBeenCalledTimes(1); + expect(txManager.findOne).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ lock: { mode: "pessimistic_write" } }), + ); + expect(txManager.update).toHaveBeenCalled(); + // The default manager only does the initial lock-free lookup, never a locked read. + for (const call of defaultManager.findOne.mock.calls) { + expect(call[1]).not.toHaveProperty("lock"); + } + }); + + it("is a no-op (no transaction) when there is no open invoice", async () => { + const { service, transaction } = build(null); + + const result = await service.expirePayable( + Freight.InvoiceSource.Booking, + "booking-1", + "prepaid", + ); + + expect(result).toBeNull(); + expect(transaction).not.toHaveBeenCalled(); }); }); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 01b057a76..3e104c7ed 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,15 +1,28 @@ -import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; -import { EventEmitter2 } from "@nestjs/event-emitter"; import { Freight, PaymentReferenceType } from "@edr/types"; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { EventEmitter2 } from "@nestjs/event-emitter"; import { DataSource, EntityManager, In } from "typeorm"; -import { Invoice } from "./entities/invoice.entity"; -import { InvoiceLine } from "./entities/invoice-line.entity"; -import { InvoiceRepository } from "./invoice.repository"; -import { InvoiceLineRepository } from "./invoice-line.repository"; +import { CompaniesService } from "../companies/companies.service"; import { PaymentService } from "../payment/payment.service"; import { InitiateResponseDto } from "../payment/payments.dto"; -import { CompaniesService } from "../companies/companies.service"; +import { + InvoiceDocumentModel, + InvoiceDocumentService, +} from "./documents/invoice-document.service"; +import { InvoiceLine } from "./entities/invoice-line.entity"; +import { Invoice, InvoicePayment } from "./entities/invoice.entity"; +import { InvoiceLineRepository } from "./invoice-line.repository"; +import { nextDailyInvoiceNumber } from "./invoice-numbering.util"; +import { applySettlement, round2 } from "./invoice-settlement.util"; +import { InvoiceRepository } from "./invoice.repository"; /** Options forwarded to the payment gateway when settling an invoice. */ export interface PayInvoiceOptions { @@ -20,13 +33,25 @@ export interface PayInvoiceOptions { failureUrl?: string; } +/** A single manual/offline settlement to record against an invoice. */ +export interface RecordPaymentInput { + /** Amount settled by this payment; must be > 0. */ + amount: number; + method?: string | null; + reference?: string | null; + /** When the settlement occurred; defaults to now. */ + paidAt?: Date; + metadata?: Record | null; +} + /** Default invoice payment-term window, in days, used to compute `dueAt`. */ const DEFAULT_DUE_DAYS = 14; /** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ const OPEN_STATUSES: Freight.InvoiceStatus[] = [ - Freight.InvoiceStatus.Draft, + Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending, + Freight.InvoiceStatus.PartiallyPaid, Freight.InvoiceStatus.Overdue, ]; @@ -56,7 +81,11 @@ export interface GenerateInvoiceInput { companyProfileId: string; lines: InvoiceLineInput[]; currency?: string; - /** Explicit total; defaults to the sum of line amounts. */ + /** Explicit pre-tax subtotal; defaults to the sum of line amounts. */ + subtotalAmount?: number; + /** Tax applied on top of the subtotal; defaults to 0. */ + taxAmount?: number; + /** Explicit total; defaults to `subtotalAmount + taxAmount`. */ totalAmount?: number; /** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */ dueAt?: Date; @@ -95,7 +124,8 @@ export class BillingService { @Inject(forwardRef(() => PaymentService)) private readonly payment: PaymentService, private readonly companies: CompaniesService, - ) { } + private readonly invoiceDocuments: InvoiceDocumentService, + ) {} // ── Reads ────────────────────────────────────────────────────────────────── @@ -104,9 +134,56 @@ export class BillingService { return this.invoices.findAll({ order: { issuedAt: "DESC" } }); } + /** + * Paginated invoice list for the backoffice — optionally narrowed to a + * company (customer detail "Invoices" tab) and/or status/search (global + * invoices page). + */ + async findAllPaginated( + filter: { + companyId?: string; + status?: Freight.InvoiceStatus; + search?: string; + page?: number; + pageSize?: number; + } = {}, + ): Promise<{ items: Invoice[]; total: number }> { + const page = filter.page && filter.page > 0 ? filter.page : 1; + const pageSize = + filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; + + const qb = this.dataSource + .getRepository(Invoice) + .createQueryBuilder("invoice") + .leftJoinAndSelect("invoice.company", "company") + .orderBy("invoice.issuedAt", "DESC") + .skip((page - 1) * pageSize) + .take(pageSize); + + if (filter.companyId) { + qb.andWhere("invoice.companyId = :companyId", { + companyId: filter.companyId, + }); + } + if (filter.status) { + qb.andWhere("invoice.status = :status", { status: filter.status }); + } + if (filter.search) { + qb.andWhere( + "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", + { search: `%${filter.search}%` }, + ); + } + + const [items, total] = await qb.getManyAndCount(); + return { items, total }; + } + /** Invoice header plus its line items. */ async findById(id: string): Promise { - const invoice = await this.invoices.findById(id); + const invoice = await this.invoices.findById(id, { + relations: { company: true, companyProfile: true }, + }); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); const lines = await this.invoiceLines.findAll({ where: { invoiceId: id }, @@ -115,6 +192,89 @@ export class BillingService { return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] }; } + // ── Documents (central PDF) ────────────────────────────────────────────────── + + /** Sealed PDF invoice for any source, rendered by the shared document service. */ + async document(id: string): Promise<{ filename: string; buffer: Buffer }> { + const invoice = await this.findById(id); + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "INVOICE"), + ); + } + + /** Sealed PDF receipt; available once any payment has been recorded. */ + async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { + const invoice = await this.findById(id); + if (Number(invoice.paidAmount) <= 0) { + throw new BadRequestException( + "A receipt is available only after payment is recorded.", + ); + } + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "RECEIPT"), + ); + } + + /** Map a global invoice (+ lines) onto the source-agnostic document model. */ + private toDocumentModel( + invoice: Invoice & { lines: InvoiceLine[] }, + kind: "INVOICE" | "RECEIPT", + ): InvoiceDocumentModel { + const title = invoice.source + ? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1) + : "EDR"; + const totals: InvoiceDocumentModel["totals"] = [ + { label: "Subtotal", amount: Number(invoice.subtotalAmount) }, + ]; + if (Number(invoice.taxAmount) > 0) { + totals.push({ label: "Tax", amount: Number(invoice.taxAmount) }); + } + totals.push({ + label: "Total", + amount: Number(invoice.totalAmount), + grand: true, + }); + totals.push({ label: "Paid", amount: Number(invoice.paidAmount) }); + totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) }); + + return { + kind, + title, + documentNumber: invoice.invoiceNumber, + issuedAt: invoice.issuedAt ?? invoice.createdAt, + status: invoice.status, + currency: invoice.currency, + summary: [ + { label: "Status", value: invoice.status }, + { label: "Type", value: invoice.type }, + { label: "Reference", value: invoice.sourceId }, + { label: "Currency", value: invoice.currency }, + { + label: "Issued", + value: invoice.issuedAt + ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") + : null, + }, + { + label: "Due", + value: invoice.dueAt + ? new Date(invoice.dueAt).toLocaleDateString("en-GB") + : null, + }, + ], + categoryHeader: "Charge type", + lines: invoice.lines.map((l) => ({ + description: l.description ?? l.chargeType, + category: l.chargeType, + quantity: l.quantity, + unitRate: l.unitRate, + amount: l.amount, + currency: l.currency, + })), + totals, + }; + } + // ── Customer-scoped reads (portal) ─────────────────────────────────────────── /** Resolve the customer's company id from their IAM user id (null if none). */ @@ -127,19 +287,43 @@ export class BillingService { } } - /** Every invoice billed to a company, newest first, with billing relations. */ - findByCompany(companyId: string): Promise { + /** + * Every invoice billed to a company, newest first, with billing relations. + * Optionally narrow to a single source record (e.g. a booking's invoices) via + * `{ source, sourceId }`. + */ + findByCompany( + companyId: string, + filter: { source?: string; sourceId?: string } = {}, + ): Promise { return this.invoices.findAll({ - where: { companyId }, + where: { + companyId, + ...(filter.source ? { source: filter.source } : {}), + ...(filter.sourceId ? { sourceId: filter.sourceId } : {}), + }, relations: { company: true, companyProfile: true }, order: { createdAt: "DESC" }, }); } + /** Invoices for a batch of source records (e.g. many last-mile legs), so a + * list can show which records already have an invoice without N+1 queries. */ + findBySourceIds(source: string, sourceIds: string[]): Promise { + if (!sourceIds.length) return Promise.resolve([]); + return this.invoices.findAll({ + where: { source, sourceId: In(sourceIds) }, + order: { createdAt: "DESC" }, + }); + } + /** Invoices for the signed-in customer; empty when they have no company. */ - async findForUser(userId: string): Promise { + async findForUser( + userId: string, + filter: { source?: string; sourceId?: string } = {}, + ): Promise { const companyId = await this.resolveCompanyId(userId); - return companyId ? this.findByCompany(companyId) : []; + return companyId ? this.findByCompany(companyId, filter) : []; } /** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */ @@ -157,36 +341,43 @@ export class BillingService { /** * Initiate gateway payment for one of the customer's own invoices. Verifies - * ownership, then charges whichever open invoice the source currently has - * (see {@link payInvoice}). + * ownership, then charges the invoice directly by ID (see {@link payInvoice}). */ async payInvoiceForUser( id: string, userId: string, opts: PayInvoiceOptions = {}, ): Promise { - const invoice = await this.findByIdForUser(id, userId); - return this.payInvoice( - invoice.source as Freight.InvoiceSource, - invoice.sourceId, - opts, - ); + await this.findByIdForUser(id, userId); + return this.payInvoice(id, opts); + } + + /** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */ + async documentForUser( + id: string, + userId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + await this.findByIdForUser(id, userId); + return this.document(id); + } + + /** Sealed receipt PDF for one of the customer's own invoices (ownership-checked). */ + async receiptForUser( + id: string, + userId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + await this.findByIdForUser(id, userId); + return this.receipt(id); } // ── Generation ─────────────────────────────────────────────────────────────── - /** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */ - private async nextInvoiceNumber(mg: EntityManager): Promise { - const now = new Date(); - const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`; - const prefix = `FRT-${ymd}-`; - const [row] = await mg.query( - `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq - FROM freight.invoices WHERE invoice_number LIKE $1`, - [`${prefix}%`], - ); - const next = Number(row?.seq ?? 0) + 1; - return `${prefix}${String(next).padStart(5, "0")}`; + /** `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */ + private nextInvoiceNumber(mg: EntityManager): Promise { + return nextDailyInvoiceNumber(mg, { + table: "freight.invoices", + code: "INV", + }); } /** @@ -204,6 +395,7 @@ export class BillingService { input: GenerateInvoiceInput, manager?: EntityManager, ): Promise { + console.log("oooooooooo", input); const run = (mg: EntityManager) => this.createInvoice(input, mg); return manager ? run(manager) : this.dataSource.transaction(run); } @@ -230,14 +422,17 @@ export class BillingService { }; }); - const totalAmount = - input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0); + const subtotalAmount = + input.subtotalAmount ?? + lines.reduce((sum, l) => sum + Number(l.amount), 0); + const taxAmount = input.taxAmount ?? 0; + const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount); const dueAt = input.dueAt ?? new Date( Date.now() + - (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, + (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, ); const invoiceNumber = await this.nextInvoiceNumber(mg); @@ -250,7 +445,12 @@ export class BillingService { type: input.type, companyId: input.companyId, companyProfileId: input.companyProfileId, - totalAmount, + subtotalAmount: round2(subtotalAmount), + taxAmount: round2(taxAmount), + totalAmount: round2(totalAmount), + paidAmount: 0, + balanceAmount: round2(totalAmount), + payments: [], currency, status, issuedAt: issued ? new Date() : null, @@ -274,28 +474,182 @@ export class BillingService { // ── State transitions ──────────────────────────────────────────────────────── /** - * Mark an invoice paid and link the gateway payment, then emit - * `${source}.invoice.paid`. Full-payment only — no partial settlement. - * No-op when the invoice is already paid. Pass `manager` to enlist in a - * caller's transaction. + * Run `fn` inside a transaction and only emit its returned domain event + * after commit. When the caller passes their own `manager`, they own commit + * timing — `fn`'s event fires inline as soon as it resolves (the outer + * transaction may still roll back afterwards; this is the caller's + * documented tradeoff). When no `manager` is given, this opens its own + * transaction and defers the emit until after that transaction commits, so + * listeners (e.g. booking advancement) can never observe an invoice change + * that then rolls back. + */ + private async runTransition( + manager: EntityManager | undefined, + fn: (mg: EntityManager) => Promise<{ result: T; emit?: () => void }>, + ): Promise { + if (manager) { + const { result, emit } = await fn(manager); + emit?.(); + return result; + } + let pending: (() => void) | undefined; + const result = await this.dataSource.transaction(async (mg) => { + const out = await fn(mg); + pending = out.emit; + return out.result; + }); + pending?.(); + return result; + } + + /** + * Mark an invoice paid, stamp the paid timestamp, sync paid/balance amounts, + * append the settlement to the `payments` ledger, link the gateway payment, + * then emit `${source}.invoice.paid`. Full-payment only — no partial + * settlement. No-op when the invoice is already paid. Pass `manager` to + * enlist in a caller's transaction; otherwise locks the row for update and + * emits only after commit (see {@link runTransition}). */ async markInvoiceAsPaid( invoiceId: string, paymentId: string | null = null, manager?: EntityManager, + settlement: { providerTxnId?: string; paidAt?: Date } = {}, ): Promise { - return this.transition( - invoiceId, - Freight.InvoiceStatus.Paid, - "paid", - { paymentId: paymentId ?? undefined }, - manager, - ); + return this.runTransition(manager, async (mg) => { + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId }, + lock: { mode: "pessimistic_write" }, + }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + return { result: invoice }; + } + + const paidAt = invoice.paidAt ?? settlement.paidAt ?? new Date(); + const settledAmount = round2( + Number(invoice.totalAmount) - Number(invoice.paidAmount ?? 0), + ); + const entry: InvoicePayment = { + amount: settledAmount, + method: "GATEWAY", + reference: settlement.providerTxnId ?? paymentId ?? null, + paidAt: paidAt.toISOString(), + metadata: null, + }; + const payments = [...(invoice.payments ?? []), entry]; + + const patch = { + status: Freight.InvoiceStatus.Paid, + paymentId, + paidAt, + paidAmount: invoice.totalAmount, + balanceAmount: 0, + payments, + }; + await mg.update(Invoice, { id: invoiceId }, patch as never); + + const updated = { ...invoice, ...patch } as Invoice; + return { + result: updated, + emit: () => this.emitInvoiceEvent("paid", updated), + }; + }); + } + + /** + * Record a (possibly partial) settlement against an invoice and sync its + * status. Appends to the `payments` ledger, recomputes `paidAmount` / + * `balanceAmount`, and moves the invoice to PARTIALLY_PAID or — once the + * balance reaches zero — PAID, stamping `paidAt` and emitting + * `${source}.invoice.paid`. Use this for manual/offline settlement (e.g. cash + * at the warehouse counter); gateway settlement goes through + * {@link markInvoiceAsPaid}. + * + * Throws when the invoice is missing, cancelled, refunded, already fully + * paid, `amount` is not positive, or `amount` exceeds the outstanding + * balance. Pass `manager` to enlist in a caller's transaction; otherwise + * locks the row for update and emits only after commit (see + * {@link runTransition}). + */ + async recordPayment( + invoiceId: string, + input: RecordPaymentInput, + manager?: EntityManager, + ): Promise { + if (!(input.amount > 0)) { + throw new BadRequestException( + "Payment amount must be greater than zero.", + ); + } + + return this.runTransition(manager, async (mg) => { + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId }, + lock: { mode: "pessimistic_write" }, + }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.status === Freight.InvoiceStatus.Cancelled) { + throw new BadRequestException("Cannot pay a cancelled invoice."); + } + if (invoice.status === Freight.InvoiceStatus.Refunded) { + throw new BadRequestException("Cannot pay a refunded invoice."); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException("Invoice is already fully paid."); + } + if (round2(input.amount) > Number(invoice.balanceAmount)) { + throw new BadRequestException( + `Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`, + ); + } + + const at = input.paidAt ?? new Date(); + const { paidAmount, balanceAmount, fullyPaid } = applySettlement( + invoice.totalAmount, + invoice.paidAmount, + input.amount, + ); + const status = fullyPaid + ? Freight.InvoiceStatus.Paid + : Freight.InvoiceStatus.PartiallyPaid; + + const entry: InvoicePayment = { + amount: round2(input.amount), + method: input.method ?? null, + reference: input.reference ?? null, + paidAt: at.toISOString(), + metadata: input.metadata ?? null, + }; + const payments = [...(invoice.payments ?? []), entry]; + + const patch = { + paidAmount, + balanceAmount, + status, + payments, + paidAt: fullyPaid ? at : (invoice.paidAt ?? null), + }; + await mg.update(Invoice, { id: invoice.id }, patch as never); + + const updated = { ...invoice, ...patch } as Invoice; + return { + result: updated, + emit: fullyPaid + ? () => this.emitInvoiceEvent("paid", updated) + : undefined, + }; + }); } /** * Mark an invoice refunded and emit `${source}.invoice.refunded`. - * No-op when already refunded. + * No-op when already refunded. Throws when the invoice has no recorded + * payment (nothing to refund). */ async markInvoiceAsRefunded( invoiceId: string, @@ -307,12 +661,20 @@ export class BillingService { "refunded", {}, manager, + (invoice) => { + if (!(Number(invoice.paidAmount) > 0)) { + throw new BadRequestException( + "Cannot refund an invoice with no recorded payment.", + ); + } + }, ); } /** * Mark an invoice cancelled and emit `${source}.invoice.cancelled`. - * No-op when already cancelled. + * No-op when already cancelled. Throws when the invoice has payments + * recorded against it (refund it instead). */ async cancelInvoice( invoiceId: string, @@ -324,16 +686,23 @@ export class BillingService { "cancelled", {}, manager, + (invoice) => { + if (Number(invoice.paidAmount) > 0) { + throw new BadRequestException( + "Cannot cancel an invoice that has payments recorded against it.", + ); + } + }, ); } /** * Load the invoice, apply the new status (+ extra columns), then emit - * `${source}.invoice.`. No-op (returns the invoice) when it is already - * in the target status. Throws when the invoice does not exist. - * - * Note: the event fires in-process synchronously. When a `manager` from an - * outer transaction is passed, listeners run before that transaction commits. + * `${source}.invoice.`. No-op (returns the invoice, skipping `guard`) + * when it is already in the target status. Throws when the invoice does not + * exist or `guard` rejects the current state. Pass `manager` to enlist in a + * caller's transaction; otherwise locks the row for update and emits only + * after commit (see {@link runTransition}). */ private async transition( invoiceId: string, @@ -341,17 +710,27 @@ export class BillingService { event: string, extra: { paymentId?: string }, manager?: EntityManager, + guard?: (invoice: Invoice) => void, ): Promise { - const mg = manager ?? this.dataSource.manager; - const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } }); - if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); - if (invoice.status === status) return invoice; + return this.runTransition(manager, async (mg) => { + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId }, + lock: { mode: "pessimistic_write" }, + }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.status === status) return { result: invoice }; + guard?.(invoice); - await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); + await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); - const updated = { ...invoice, ...extra, status } as Invoice; - this.emitInvoiceEvent(event, updated); - return updated; + const updated = { ...invoice, ...extra, status } as Invoice; + return { + result: updated, + emit: () => this.emitInvoiceEvent(event, updated), + }; + }); } /** Broadcast `${invoice.source}.invoice.` to in-process listeners. */ @@ -369,22 +748,29 @@ export class BillingService { status: invoice.status, paymentId: invoice.paymentId ?? null, }; - this.events.emit(`${invoice.source}.invoice.${event}`, payload); + this.events + .emitAsync(`${invoice.source}.invoice.${event}`, payload) + .catch((err) => + this.logger.error( + `Listener for ${invoice.source}.invoice.${event} (invoice ${invoice.id}) failed: ${err instanceof Error ? err.message : String(err)}`, + ), + ); } // ── Payment reconciliation (by source) ─────────────────────────────────────── /** - * The invoice a gateway payment should settle for a source record, or null if - * none. This is the billing document of record for "what is owed" — callers - * (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than - * recomputing from the source's own total, so discounts/penalties/adjustments - * carried on the invoice are honored. + * The invoice a source record already has open, or null if it needs a new + * one. This is the idempotency check every `ensureInvoiceFor*` (booking, + * first-mile, last-mile) runs before generating — it must see DRAFT + * invoices too, not just issued ones, otherwise a source that already has + * an unissued draft gets a second, duplicate invoice minted alongside it + * instead of that draft being reused and then issued. * * Pass `type` to select a specific invoice when a source carries several (e.g. * a booking's up-front vs final charge); omit it to settle whichever single - * invoice is currently open. Returns the most recent matching open (unpaid, - * non-cancelled) invoice. + * invoice is currently open. Returns the most recent matching draft-or-open + * (unpaid, non-cancelled) invoice. */ findPayable( source: Freight.InvoiceSource, @@ -395,7 +781,7 @@ export class BillingService { where: { source, sourceId, - status: In(OPEN_STATUSES), + status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), ...(type ? { type } : {}), }, order: { issuedAt: "DESC" }, @@ -403,74 +789,140 @@ export class BillingService { } /** - * Settle a source's currently-open invoice as paid and link the gateway - * payment, then emit `${source}.invoice.paid`. Resolves the open invoice then - * delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial - * settlement. No-op (returns null) when the source has no open invoice. - * - * Type-blind by design: settles whichever invoice is due; any per-type reaction - * belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`. - * Pass the caller's transaction `manager` to enlist in its DB transaction. - * - * NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded` - * event ({@link settleByPaymentId}); this source-keyed settle is a generic helper - * for callers that settle by source rather than by gateway intent id. + * Pass `type` to select a specific invoice when a source carries several (e.g. + * a booking's up-front vs final charge); omit it to settle whichever single + * invoice is currently open. Returns the most recent matching open (unpaid, + * non-cancelled) invoice. */ - async settlePayable( + findInvoice( source: Freight.InvoiceSource, sourceId: string, - paymentId: string | null, - manager?: EntityManager, + type?: string, ): Promise { - const mg = manager ?? this.dataSource.manager; - const invoice = await mg.findOne(Invoice, { - where: { source, sourceId, status: In(OPEN_STATUSES) }, + return this.dataSource.getRepository(Invoice).findOne({ + where: { + source, + sourceId, + ...(type ? { type } : {}), + }, order: { issuedAt: "DESC" }, }); - if (!invoice) return null; - - return this.markInvoiceAsPaid(invoice.id, paymentId, mg); } /** - * Refund a source's paid invoice, then emit `${source}.invoice.refunded`. - * Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}. - * No-op (returns null) when the source has no paid invoice. + * Expire a source's currently-open invoice (its pay window closed before + * settlement), then emit `${source}.invoice.expired`. Resolves the open invoice + * and transitions it to EXPIRED — a terminal, non-payable status (kept out of + * `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice + * (already paid/cancelled/expired). * - * Pass the caller's transaction `manager` (e.g. from `payment.service.refund`) - * to enlist in its DB transaction. + * Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in + * the batch engine) to enlist in its DB transaction. */ - async refundPayable( + async expirePayable( source: Freight.InvoiceSource, sourceId: string, + type?: string, manager?: EntityManager, ): Promise { + // Lookup can use the default manager (no lock). But the pessimistic-lock write + // inside `transition` NEEDS an open transaction: pass the caller's `manager` + // through untouched (undefined when there is no caller txn) so `runTransition` + // opens its own. Passing `this.dataSource.manager` here made `runTransition` + // treat it as an already-open transaction and skip wrapping — the lock then + // threw `An open transaction is required for pessimistic lock`, aborting the + // whole settle pass (the "reservations settle/reserve one at a time" symptom). const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { - where: { source, sourceId, status: Freight.InvoiceStatus.Paid }, + where: { + source, + sourceId, + status: In(OPEN_STATUSES), + ...(type ? { type } : {}), + }, order: { issuedAt: "DESC" }, }); if (!invoice) return null; - return this.markInvoiceAsRefunded(invoice.id, mg); + return this.transition( + invoice.id, + Freight.InvoiceStatus.Expired, + "expired", + {}, + manager, + ); + } + + /** + * Sync a source's open invoice `dueAt` to its real pay-window deadline. The + * booking invoice is generated before the pay window opens (at booking + * creation/approval), so its printed due date is refreshed when the batch engine + * sets `paymentDeadline`. No-op when the source has no open invoice. + */ + async syncPayableDueDate( + source: Freight.InvoiceSource, + sourceId: string, + dueAt: Date, + type?: string, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { + source, + sourceId, + status: In(OPEN_STATUSES), + ...(type ? { type } : {}), + }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return; + await mg.update(Invoice, { id: invoice.id }, { dueAt }); + } + + /** + * Force an invoice to `status`, including issuing a still-DRAFT invoice + * (stamping `issuedAt`) — unlike the other transitions here, this is a + * blunt admin/workflow override, not a settlement. No-op when the invoice + * is missing or already terminal (paid/cancelled/refunded/expired). + */ + async updateStatus( + invoiceId: string, + status: Freight.InvoiceStatus, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { + id: invoiceId, + status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), + }, + }); + if (!invoice) return; + await mg.update( + Invoice, + { id: invoice.id }, + { status, issuedAt: invoice.issuedAt ?? new Date() }, + ); } // ── Payment initiation & settlement (the gateway boundary) ─────────────────── /** - * Charge a source's open invoice through the payment gateway. Billing is the - * single place that turns "what is owed" (the invoice) into a payment intent — - * the domain never talks to the payment service directly. Resolves the open - * invoice, opens an intent for `invoice.totalAmount`, records the intent id on - * the invoice (the settlement correlation key), and returns the client action. + * Charge an invoice through the payment gateway. Billing is the single place + * that turns "what is owed" (the invoice) into a payment intent — the domain + * never talks to the payment service directly. Resolves the invoice by ID, + * opens an intent for `invoice.balanceAmount` (so partial payments are honored), + * records the intent id on the invoice (the settlement correlation key), and + * returns the client action. * * When the provider settles synchronously, the invoice is settled inline here — * after the intent id is stored — so the `payment.succeeded` correlation can - * never fire before the link exists. Throws when the source has no open invoice. + * never fire before the link exists. Throws when the invoice is not found or + * not in an open/payable status. */ async payInvoice( - source: Freight.InvoiceSource, - sourceId: string, + invoiceId: string, opts: { method?: string; platform?: "web" | "mobile"; @@ -479,21 +931,31 @@ export class BillingService { failureUrl?: string; } = {}, ): Promise { - const invoice = await this.findPayable(source, sourceId); + const invoice = await this.dataSource.getRepository(Invoice).findOne({ + where: { id: invoiceId, status: In(OPEN_STATUSES) }, + }); if (!invoice) { - throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`); + throw new NotFoundException( + `Invoice ${invoiceId} not found or not in a payable status`, + ); + } + + const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount); + if (!(amountDue > 0)) { + throw new BadRequestException("Invoice has no outstanding balance."); } const result = await this.payment.initiate({ - referenceId: sourceId, + referenceId: invoice.sourceId, source: invoice.source, - // Gateway reference type derives from the invoice source by convention - // (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and - // the domain never supplies it. New sources add their uppercased value to - // the PaymentReferenceType enum. - referenceType: invoice.source.toUpperCase() as PaymentReferenceType, - orderRef: invoice.invoiceNumber, - amountMinor: Math.round(Number(invoice.totalAmount)), + // Freight payments settle under the generic SHIPMENT reference — how the + // payment service attributes them to the freight API. The payment ↔ invoice + // link is the intent id (`paymentId`); per-source post-payment reactions live + // in the domain via `${source}.invoice.paid`. Neither billing nor the payment + // service branches on a domain-specific reference type. + referenceType: PaymentReferenceType.SHIPMENT, + orderRef: invoice.invoiceNumber.replace("-", "_"), + amountMinor: Math.round(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, method: opts.method ?? "TELEBIRR", @@ -502,12 +964,27 @@ export class BillingService { returnUrl: opts.returnUrl, failureUrl: opts.failureUrl, }); - +// // Link the intent to the invoice BEFORE any settlement can correlate against it. await this.dataSource .getRepository(Invoice) .update({ id: invoice.id }, { paymentId: result.intentId }); + // DEMO: manually fire the gateway `payment.succeeded` callback here, without + // waiting for real gateway settlement. Runs AFTER the paymentId link above so + // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO: + // remove — real settlement flips this via the `${source}.invoice.paid` handler. + if (!result.immediateSuccess) { + await this.payment.handlePaymentEvent({ + eventType: "payment.succeeded", + eventId: `demo-${result.intentId}`, + referenceId: invoice.sourceId, + intentId: result.intentId, + providerTxnId: result.providerTxnId, + paidAt: (result.paidAt ?? new Date()).toISOString(), + }); + } + if (result.immediateSuccess) { await this.settleByPaymentId( result.intentId, @@ -528,8 +1005,8 @@ export class BillingService { */ async settleByPaymentId( paymentId: string, - _providerTxnId?: string, - _paidAt?: Date, + providerTxnId?: string, + paidAt?: Date, ): Promise { const invoice = await this.dataSource.getRepository(Invoice).findOne({ where: { paymentId, status: In(OPEN_STATUSES) }, @@ -537,6 +1014,9 @@ export class BillingService { }); if (!invoice) return null; - return this.markInvoiceAsPaid(invoice.id, paymentId); + return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, { + providerTxnId, + paidAt, + }); } } diff --git a/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts new file mode 100644 index 000000000..c320a5d44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts @@ -0,0 +1,16 @@ +import { Module } from "@nestjs/common"; + +import { InvoiceDocumentService } from "./invoice-document.service"; +import { PdfRenderService } from "./pdf-render.service"; + +/** + * Standalone document infrastructure — generic HTML→PDF plus the shared + * invoice/receipt renderer. Has no domain dependencies, so any module (billing, + * warehouses, …) can import it to print invoices without coupling to the + * billing payment graph. + */ +@Module({ + providers: [PdfRenderService, InvoiceDocumentService], + exports: [PdfRenderService, InvoiceDocumentService], +}) +export class DocumentsModule {} diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts new file mode 100644 index 000000000..06d164bbd --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -0,0 +1,315 @@ +import { Injectable } from "@nestjs/common"; + +import { PdfRenderService } from "./pdf-render.service"; +import { + PdfColor, + assembleSinglePagePdf, + lineOp, + rectOp, + sealOp, + textOp, + textOpRight, + wrapText, +} from "./styled-pdf.util"; + +export type InvoiceDocumentKind = "INVOICE" | "RECEIPT"; + +/** One billed line on the document (charge type / fee type agnostic). */ +export interface InvoiceDocumentLine { + description: string | null; + /** Optional categorisation column (e.g. "Fee type" / "Charge type"). */ + category?: string | null; + quantity?: number | null; + unitRate?: number | null; + amount?: number | null; + currency?: string | null; +} + +/** A labelled total row in the totals box; mark `grand` for the headline total. */ +export interface InvoiceDocumentTotal { + label: string; + amount: number; + grand?: boolean; +} + +/** + * Source-agnostic description of a printable invoice/receipt. Each billing + * source maps its own entity onto this shape; the renderer owns the layout so + * every EDR invoice document looks identical regardless of source. + */ +export interface InvoiceDocumentModel { + kind: InvoiceDocumentKind; + /** Document heading, e.g. "Warehouse Fee Invoice" / "Freight Invoice". */ + title: string; + documentNumber: string; + issuedAt?: Date | string | null; + status: string; + currency: string; + /** Free-form summary grid (label/value pairs). */ + summary: Array<{ label: string; value: string | null }>; + /** Header for the line-item category column; column hidden when omitted. */ + categoryHeader?: string; + lines: InvoiceDocumentLine[]; + totals: InvoiceDocumentTotal[]; + /** Override the round seal text; defaults from kind/status. */ + sealText?: string; +} + +/** + * Central invoice/receipt PDF renderer shared by every billing source. Turns a + * {@link InvoiceDocumentModel} into the sealed EDR document HTML and renders it + * via {@link PdfRenderService}. Previously this layout lived (warehouse-only) in + * `WarehouseInvoiceService`; it now serves all invoices. + */ +@Injectable() +export class InvoiceDocumentService { + constructor(private readonly pdf: PdfRenderService) {} + + async render( + model: InvoiceDocumentModel, + ): Promise<{ filename: string; buffer: Buffer }> { + const html = this.buildHtml(model); + const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice"; + return { + filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`, + buffer: await this.pdf.htmlToPdfBuffer(html, { + label: `${model.title} ${kindLabel}`, + // Chromium-less fallback: draw a genuine styled invoice (header, seal, + // summary grid, line-item table, totals) from the model — not a flat + // plain-text dump — so it still reads as a proper invoice document. + fallback: () => this.buildFallbackPdf(model), + }), + }; + } + + /** + * Vector-drawn styled invoice/receipt used when headless Chromium is + * unavailable. Mirrors the HTML layout closely enough to pass as the same + * document. Single A4 page; long summaries / line lists are capped to fit. + */ + buildFallbackPdf(model: InvoiceDocumentModel): Buffer { + const currency = (cur?: string | null) => + (cur ?? model.currency) === "ETB" ? "ETB" : (cur ?? model.currency); + const money = (amount: unknown, cur?: string | null) => + `${Number(amount ?? 0).toLocaleString()} ${currency(cur)}`; + const date = (value: unknown) => + value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; + + const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`; + const sealText = + model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); + const showCategory = Boolean(model.categoryHeader); + + const ops: string[] = []; + + // ── Header ──────────────────────────────────────────────────────────── + ops.push(lineOp(36, 806, 559, 806, PdfColor.teal, 2.4)); + ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", 36, 790, 8.5, "F2", PdfColor.gray)); + const titleSize = heading.length > 34 ? 18 : 22; + ops.push(textOp(heading, 36, 762, titleSize, "F2", PdfColor.dark)); + + ops.push(textOpRight("DOCUMENT NO.", 559, 792, 7.5, "F2", PdfColor.gray)); + ops.push(textOpRight(model.documentNumber, 559, 776, 12, "F2", PdfColor.dark)); + ops.push(textOpRight(`Issued ${date(model.issuedAt)}`, 559, 762, 8.5, "F1", PdfColor.gray)); + ops.push( + textOpRight( + `Status ${model.status}`, + 559, + 748, + 8.5, + "F1", + model.status === "PAID" ? PdfColor.teal : PdfColor.gray, + ), + ); + ops.push(lineOp(36, 736, 470, 736, PdfColor.line, 1)); + + // ── Seal ────────────────────────────────────────────────────────────── + ops.push(sealOp(516, 706, 27, sealText.split(/\s+/), PdfColor.teal)); + + // ── Summary grid (two columns) ──────────────────────────────────────── + let y = 700; + const colX = [36, 300]; + const colW = 250; + model.summary.slice(0, 16).forEach((row, i) => { + const x = colX[i % 2]; + if (i % 2 === 0 && i > 0) y -= 27; + ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray)); + ops.push(textOp(this.clip(row.value ?? "-", 44), x, y - 11, 9, "F2", PdfColor.dark)); + ops.push(lineOp(x, y - 15, x + colW, y - 15, PdfColor.line, 0.6)); + }); + y -= 34; + + // ── Line-item table ─────────────────────────────────────────────────── + const qtyR = 402; + const rateR = 486; + const amtR = 555; + ops.push(rectOp(36, y - 18, 523, 18, PdfColor.shade, PdfColor.line, 0.7)); + ops.push(textOp("DESCRIPTION", 40, y - 13, 8, "F2", PdfColor.gray)); + if (showCategory) { + ops.push(textOp((model.categoryHeader ?? "").toUpperCase(), 250, y - 13, 8, "F2", PdfColor.gray)); + } + ops.push(textOpRight("QTY", qtyR, y - 13, 8, "F2", PdfColor.gray)); + ops.push(textOpRight("RATE", rateR, y - 13, 8, "F2", PdfColor.gray)); + ops.push(textOpRight("AMOUNT", amtR, y - 13, 8, "F2", PdfColor.gray)); + y -= 18; + + const descChars = showCategory ? 44 : 66; + for (const item of model.lines) { + if (y < 190) break; // leave room for totals + footer + const descLines = wrapText(item.description ?? "-", descChars).slice(0, 2); + const rowH = Math.max(18, descLines.length * 10 + 8); + ops.push(rectOp(36, y - rowH, 523, rowH, "1 1 1", PdfColor.line, 0.6)); + descLines.forEach((line, k) => { + ops.push(textOp(line, 40, y - 12 - k * 10, 8, "F1", PdfColor.dark)); + }); + if (showCategory) { + ops.push(textOp(this.clip((item.category ?? "").replace(/_/g, " "), 18), 250, y - 12, 8, "F1", PdfColor.dark)); + } + ops.push(textOpRight(String(item.quantity ?? 0), qtyR, y - 12, 8, "F1", PdfColor.dark)); + ops.push(textOpRight(money(item.unitRate, item.currency), rateR, y - 12, 8, "F1", PdfColor.dark)); + ops.push(textOpRight(money(item.amount, item.currency), amtR, y - 12, 8, "F1", PdfColor.dark)); + y -= rowH; + } + + // ── Totals ──────────────────────────────────────────────────────────── + let ty = y - 16; + for (const total of model.totals) { + if (ty < 88) break; + if (total.grand) { + ops.push(lineOp(315, ty + 5, 559, ty + 5, PdfColor.dark, 0.9)); + ops.push(textOp(total.label, 320, ty - 9, 11, "F2", PdfColor.dark)); + ops.push(textOpRight(money(total.amount), 555, ty - 9, 12, "F2", PdfColor.dark)); + ty -= 24; + } else { + ops.push(textOp(total.label, 320, ty - 8, 9.5, "F1", PdfColor.gray)); + ops.push(textOpRight(money(total.amount), 555, ty - 8, 10, "F1", PdfColor.dark)); + ty -= 17; + } + } + + // ── Footer ──────────────────────────────────────────────────────────── + ops.push(lineOp(36, 64, 250, 64, PdfColor.dark, 0.8)); + ops.push(textOp("Prepared by EDR finance", 36, 52, 7.5, "F1", PdfColor.gray)); + ops.push(lineOp(340, 64, 559, 64, PdfColor.dark, 0.8)); + ops.push(textOp("Authorized seal / signature", 340, 52, 7.5, "F1", PdfColor.gray)); + + return assembleSinglePagePdf(ops); + } + + /** Truncate to `max` chars with an ellipsis. */ + private clip(value: string, max: number): string { + const text = String(value ?? ""); + return text.length > max ? `${text.slice(0, max - 3)}...` : text; + } + + buildHtml(model: InvoiceDocumentModel): string { + const esc = (value: unknown) => + String(value ?? "-") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + const money = (amount: unknown, currency = model.currency) => + `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; + const date = (value: unknown) => + value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; + + const showCategory = Boolean(model.categoryHeader); + const sealText = + model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); + + const summaryRows = model.summary + .map((row) => `
${esc(row.label)}${esc(row.value)}
`) + .join(""); + + const itemRows = model.lines + .map( + (item) => ` + ${esc(item.description)} + ${showCategory ? `${esc((item.category ?? "").replace(/_/g, " "))}` : ""} + ${esc(item.quantity ?? 0)} + ${esc(money(item.unitRate, item.currency ?? model.currency))} + ${esc(money(item.amount, item.currency ?? model.currency))} + `, + ) + .join(""); + + const totalRows = model.totals + .map( + (total) => + `
${esc(total.label)}${esc(money(total.amount))}
`, + ) + .join(""); + + return ` + + + + ${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"} + + + +
+
+
+
Ethio-Djibouti Railway S.C.
+

${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}

+
+
+ Document no. + ${esc(model.documentNumber)} + Issued: ${esc(date(model.issuedAt))} +
+
+
${esc(sealText)}
+
${summaryRows}
+ + + + + ${showCategory ? `` : ""} + + + + + + + ${itemRows} + +
Description${esc(model.categoryHeader)}QtyRateAmount
+
${totalRows}
+ +
+ +`; + } + + safeFilename(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, "-"); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts new file mode 100644 index 000000000..447bc2516 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -0,0 +1,160 @@ +import { existsSync } from "fs"; + +import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; + +const MIN_VALID_PDF_BYTES = 2_000; + +const PDF_PRINT_STYLES = ` +`; + +export interface PdfRenderOptions { + /** Label used in logs to identify the document kind. */ + label?: string; + /** + * Degraded renderer used when Chromium is unavailable. Receives the + * print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-` + * header). When omitted, a generic single-page fallback is produced. + */ + fallback?: (preparedHtml: string) => Buffer; +} + +/** + * Generic HTML → PDF renderer shared by every document producer (invoices, + * receipts, warehouse release orders). Renders via headless Chromium when + * available and degrades to a caller-supplied (or generic) hand-built PDF + * otherwise. This is pure infrastructure — it knows nothing about invoices. + */ +@Injectable() +export class PdfRenderService { + private readonly logger = new Logger(PdfRenderService.name); + + async htmlToPdfBuffer(html: string, opts: PdfRenderOptions = {}): Promise { + const label = opts.label ?? "document"; + const preparedHtml = this.injectPdfPrintStyles(html); + const executablePath = this.resolveExecutablePath(); + + try { + const puppeteer = await import("puppeteer"); + const launchOptions: import("puppeteer").LaunchOptions = { + headless: true, + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"], + ...(executablePath ? { executablePath } : {}), + }; + + const browser = await puppeteer.default.launch(launchOptions); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); + await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 }); + await page.emulateMediaType("print"); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const pdf = await page.pdf({ + format: "A4", + printBackground: true, + margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" }, + }); + + const buffer = Buffer.from(pdf); + if (!this.isValidPdf(buffer)) { + throw new Error(`Puppeteer produced invalid ${label} PDF (${buffer.length} bytes)`); + } + this.logger.log( + `${label} PDF rendered (${buffer.length} bytes) via ${executablePath ?? "bundled Chromium"}`, + ); + return buffer; + } finally { + await browser.close(); + } + } catch (error) { + this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`); + const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml); + if (this.isValidPdf(fallback)) { + this.logger.warn( + `Using ${label} PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, + ); + return fallback; + } + throw new InternalServerErrorException( + `${label} PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.`, + ); + } + } + + private injectPdfPrintStyles(html: string): string { + if (html.includes("edr-pdf-print-fix")) return html; + if (html.includes("")) { + return html.replace("", `${PDF_PRINT_STYLES}`); + } + return `${PDF_PRINT_STYLES}${html}`; + } + + private resolveExecutablePath(): string | undefined { + const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim(); + if (fromEnv && existsSync(fromEnv)) return fromEnv; + + const candidates = [ + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + ]; + return candidates.find((path) => existsSync(path)); + } + + isValidPdf(buffer: Buffer): boolean { + return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString("ascii") === "%PDF-"; + } + + /** Minimal valid one-page PDF carrying a plain-text rendering of the document. */ + private genericFallbackPdf(html: string): Buffer { + const text = html + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/[^\x20-\x7e]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 900); + + const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)"); + const lines = (text.match(/.{1,90}/g) ?? ["Document"]).slice(0, 40); + const stream = + "BT\n/F1 10 Tf\n36 800 Td\n12 TL\n" + + lines.map((line, i) => `${i === 0 ? "" : "T*\n"}(${escape(line)}) Tj\n`).join("") + + "ET"; + + 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 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + `<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`, + ]; + + let pdf = "%PDF-1.4\n"; + const offsets: number[] = []; + 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 += "% pad\n"; + const xrefOffset = Buffer.byteLength(pdf, "latin1"); + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + for (const offset of offsets) 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"); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts new file mode 100644 index 000000000..b4f168e4e --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts @@ -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(//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 + 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(/]*>([\s\S]*?)<\/h1>/i) ?? "Document"); + const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? ""); + const metaRef = htmlToText(pick(/class="meta"[\s\S]*?([\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*([\s\S]*?)<\/span>\s*([\s\S]*?)<\/strong>/gi, + )) { + tiles.push([htmlToText(m[1]), htmlToText(m[2])]); + } + + const thead = pick(/([\s\S]*?)<\/thead>/i) ?? ""; + const headers = [...thead.matchAll(/]*>([\s\S]*?)<\/th>/gi)].map((m) => htmlToText(m[1])); + const tbody = pick(/([\s\S]*?)<\/tbody>/i) ?? ""; + const rows: string[][] = [...tbody.matchAll(/]*>([\s\S]*?)<\/tr>/gi)].map((tr) => + [...tr[1].matchAll(/]*>([\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"); +} diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts new file mode 100644 index 000000000..e8942d586 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -0,0 +1,42 @@ +import { Freight } from "@edr/types"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { + IsIn, + IsInt, + IsOptional, + IsString, + IsUUID, + Min, +} from "class-validator"; + +export class FilterInvoiceDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + pageSize?: number = 20; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + companyId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: Freight.InvoiceStatus }) + @IsOptional() + @IsIn(Object.values(Freight.InvoiceStatus)) + status?: Freight.InvoiceStatus; +} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 61bc9c16b..23c332f80 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -5,6 +5,16 @@ import { PaymentEntity } from "../../payment/entities/payment.entity"; import { Company } from "../../companies/entities/company.entity"; import { CompanyProfile } from "../../companies/entities/company-profile.entity"; +/** A single recorded settlement against an invoice (payment ledger entry). */ +export interface InvoicePayment { + amount: number; + method?: string | null; + reference?: string | null; + /** ISO timestamp of when the settlement was recorded. */ + paidAt: string; + metadata?: Record | null; +} + @Entity({ schema: "freight", name: "invoices" }) @Index(["companyId"]) @Index(["companyProfileId"]) @@ -28,9 +38,24 @@ export class Invoice extends BaseEntity { @JoinColumn({ name: "company_profile_id" }) companyProfile?: CompanyProfile; + /** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */ + @Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + subtotalAmount!: number; + + @Column({ name: "tax_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + taxAmount!: number; + @Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 }) totalAmount!: number; + /** Cumulative amount settled so far (supports partial payment). */ + @Column({ name: "paid_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + paidAmount!: number; + + /** Outstanding balance = `totalAmount - paidAmount` (0 once fully paid). */ + @Column({ name: "balance_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + balanceAmount!: number; + @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" }) currency!: string; @@ -62,6 +87,14 @@ export class Invoice extends BaseEntity { @Column({ name: "issued_at", type: "timestamptz", nullable: true }) issuedAt?: Date | null; + /** Set when the invoice is fully settled. */ + @Column({ name: "paid_at", type: "timestamptz", nullable: true }) + paidAt?: Date | null; + + /** Ledger of individual settlements (manual or gateway), newest last. */ + @Column({ name: "payments", type: "jsonb", default: () => "'[]'" }) + payments!: InvoicePayment[]; + /** The ID of the payment that generated this invoice. */ @Column({ name: "payment_id", type: "uuid", nullable: true }) paymentId?: string | null; diff --git a/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts new file mode 100644 index 000000000..d3e6a208f --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts @@ -0,0 +1,51 @@ +/** + * Shared per-day sequential invoice numbering, used by every billing source + * (freight `FRT-…`, warehouse fees `WHF-…`, …) so the format and the + * `MAX(seq)+1` allocation live in one place instead of being copy-pasted per + * service. + * + * Produces `-YYYYMMDD-00001`: the sequence is the max existing suffix for + * the day + 1. Run inside the caller's transaction (pass that transaction's + * manager) so concurrent generation within a transaction stays consistent. + */ + +/** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */ +export interface SqlRunner { + query(sql: string, params?: unknown[]): Promise; +} + +export interface InvoiceNumberOptions { + /** Schema-qualified table to scan, e.g. `freight.invoices`. */ + table: string; + /** Document code prefix, e.g. `FRT` or `WHF`. */ + code: string; + /** Column holding the number; defaults to `invoice_number`. */ + column?: string; + /** Clock injection point (tests); defaults to now. */ + now?: Date; +} + +export async function nextDailyInvoiceNumber( + runner: SqlRunner, + opts: InvoiceNumberOptions, +): Promise { + const now = opts.now ?? new Date(); + const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`; + const prefix = `${opts.code}-${ymd}-`; + const column = opts.column ?? "invoice_number"; + + // Serialize concurrent allocation for this exact day+code prefix so two + // simultaneous transactions can't both read the same MAX(seq) and mint a + // duplicate number. Session-scoped to the caller's transaction — released + // automatically on commit/rollback. Different prefixes hash to different + // keys and never contend with each other. + await runner.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [prefix]); + + const rows = (await runner.query( + `SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq + FROM ${opts.table} WHERE ${column} LIKE $1`, + [`${prefix}%`], + )) as Array<{ seq: number | string }>; + const next = Number(rows[0]?.seq ?? 0) + 1; + return `${prefix}${String(next).padStart(5, "0")}`; +} diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts new file mode 100644 index 000000000..ab1e27b1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts @@ -0,0 +1,36 @@ +/** + * Shared payment/settlement math for invoices. Both the global + * `BillingService.recordPayment` and the warehouse fee invoice flow apply a + * payment the same way — accumulate `paidAmount`, derive the outstanding + * `balanceAmount`, and decide whether the invoice is now fully settled. Keeping + * it here means the two flows can never drift on rounding or the + * partial-vs-full threshold. + */ + +/** Round to 2 decimals, avoiding binary float drift. */ +export const round2 = (n: number): number => Math.round(n * 100) / 100; + +export interface SettlementResult { + /** New cumulative amount paid. */ + paidAmount: number; + /** Remaining balance (0 once fully paid). */ + balanceAmount: number; + /** True once the balance reaches zero. */ + fullyPaid: boolean; +} + +/** + * Apply a single payment of `amount` to an invoice with `totalAmount` already + * carrying `currentPaid`. Caller is responsible for validating `amount > 0` and + * the invoice being in a payable state. + */ +export function applySettlement( + totalAmount: number, + currentPaid: number, + amount: number, +): SettlementResult { + const total = Number(totalAmount); + const paidAmount = round2(Number(currentPaid) + Number(amount)); + const balanceAmount = Math.max(0, round2(total - paidAmount)); + return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total }; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts b/apps/edr-freight-api/src/modules/billing/payment.controller.ts similarity index 83% rename from apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts rename to apps/edr-freight-api/src/modules/billing/payment.controller.ts index ae01ebc36..543c84201 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/payment.controller.ts @@ -16,9 +16,8 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; -import { Freight } from "@edr/types"; -import { BillingService } from "../billing/billing.service"; +import { BillingService } from "./billing.service"; import { InitiatePaymentDto, InitiateResponseDto, @@ -27,25 +26,24 @@ import { } from "../payment/payments.dto"; /** - * Booking-payment entrypoints. This is the ONE place that knows a payment is for a - * booking — it maps the request to {@link Freight.InvoiceSource.Booking} and hands - * off to billing, which resolves the invoice/amount and drives the gateway. Billing - * and payment stay source-agnostic; the booking knowledge lives here, in the domain. + * Central payment entrypoints. Domain-agnostic — the caller supplies an + * invoice ID and the billing service resolves the amount and drives the + * gateway. The domain never talks to the payment service directly. * Routes are unchanged (`/payments/*`) so the portal is unaffected. */ @ApiTags("Payment") @Controller("payments") -export class BookingPaymentController { +export class PaymentController { constructor(private readonly billing: BillingService) { } @Post("initiate") @ApiOperation({ - summary: "Initiate payment for a freight booking", - description: "Charges the booking's open invoice through the payment gateway.", + summary: "Initiate payment for an invoice", + description: "Charges the invoice through the payment gateway.", }) @ApiOkResponse({ type: InitiateResponseDto }) initiate(@Body() dto: InitiatePaymentDto): Promise { - return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, { + return this.billing.payInvoice(dto.invoiceId, { method: dto.method, platform: dto.platform, payerAccount: dto.payerAccount, @@ -59,23 +57,23 @@ export class BookingPaymentController { @ApiOperation({ summary: "Browser checkout redirect", description: - "Charges the booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.", + "Charges the invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.", }) - @ApiQuery({ name: "bookingId", required: true }) + @ApiQuery({ name: "invoiceId", required: true }) @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) @ApiProduces("text/html") async checkout( - @Query("bookingId") bookingId: string, + @Query("invoiceId") invoiceId: string, @Query("method") method: PaymentMethodTypeEnum, @Query("platform") platform: PaymentPlatformDto = "web", @Res() res: Response, ) { - if (!bookingId) { + if (!invoiceId) { return res .status(HttpStatus.BAD_REQUEST) .type("html") - .send(this.buildErrorHtml("Missing required query parameter: bookingId")); + .send(this.buildErrorHtml("Missing required query parameter: invoiceId")); } if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) { return res @@ -86,8 +84,7 @@ export class BookingPaymentController { try { const result = await this.billing.payInvoice( - Freight.InvoiceSource.Booking, - bookingId, + invoiceId, { method, platform }, ); const url = diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts index 5a007c320..94e917754 100644 --- a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -5,14 +5,18 @@ import { Param, ParseUUIDPipe, Post, + Query, + Res, } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Response } from "express"; import { CurrentUser } from "@edr/api-common"; import { type AuthUserPayload, resolveAuthUserId, } from "../../common/resolve-auth-user-id"; +import { sendPdf } from "./billing.controller"; import { BillingService } from "./billing.service"; import { PayInvoiceDto } from "./dto/pay-invoice.dto"; @@ -29,8 +33,15 @@ export class PortalBillingController { @Get("my-invoices") @ApiOperation({ summary: "List the signed-in customer's invoices" }) - findMine(@CurrentUser() user: AuthUserPayload) { - return this.billingService.findForUser(resolveAuthUserId(user)); + findMine( + @CurrentUser() user: AuthUserPayload, + @Query("source") source?: string, + @Query("sourceId") sourceId?: string, + ) { + return this.billingService.findForUser(resolveAuthUserId(user), { + source, + sourceId, + }); } @Get("my-invoices/:id") @@ -42,6 +53,34 @@ export class PortalBillingController { return this.billingService.findByIdForUser(id, resolveAuthUserId(user)); } + @Get("my-invoices/:id/document") + @ApiOperation({ summary: "Download one of the customer's invoice PDFs" }) + async document( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Res() res: Response, + ) { + const { filename, buffer } = await this.billingService.documentForUser( + id, + resolveAuthUserId(user), + ); + sendPdf(res, filename, buffer); + } + + @Get("my-invoices/:id/receipt") + @ApiOperation({ summary: "Download one of the customer's payment receipt PDFs" }) + async receipt( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Res() res: Response, + ) { + const { filename, buffer } = await this.billingService.receiptForUser( + id, + resolveAuthUserId(user), + ); + sendPdf(res, filename, buffer); + } + @Post("my-invoices/:id/pay") @ApiOperation({ summary: "Initiate payment for one of the customer's invoices" }) pay( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts index cfb9887c3..bb159c538 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { BookingsService } from './bookings.service'; import { AllocateContainersDto } from './dto/allocate-containers.dto'; +import { AllocationManage } from '../../common/booking-guards'; @ApiTags('bookings') @Controller('bookings') @@ -10,6 +11,7 @@ export class BookingAllocationController { constructor(private readonly bookingsService: BookingsService) {} @Post(':bookingId/allocate-containers') + @AllocationManage() @ApiOperation({ summary: 'Allocate containers to vehicles' }) async allocateContainers( @Param('bookingId', ParseUUIDPipe) bookingId: string, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 47338f196..ddf09dea6 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -1,20 +1,26 @@ -import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { Freight } from '@edr/types'; -import { DataSource } from 'typeorm'; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, +} from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { Freight } from "@edr/types"; +import { DataSource, EntityManager } from "typeorm"; import { BillingService, GenerateInvoiceInput, InvoiceEventPayload, InvoiceLineInput, -} from '../billing/billing.service'; -import { Invoice } from '../billing/entities/invoice.entity'; -import { FirstMileService } from '../first-mile/first-mile.service'; -import { BookingBatchService } from '../train-scheduling/booking-batch.service'; -import { PriceLineItemDto } from './dto/generate-price-response.dto'; -import { BookingsRepository } from './bookings.repository'; -import { Booking } from './entities/booking.entity'; +} from "../billing/billing.service"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { FirstMileService } from "../first-mile/first-mile.service"; +import { BookingBatchService } from "../train-scheduling/booking-batch.service"; +import { PriceLineItemDto } from "./dto/generate-price-response.dto"; +import { BookingsRepository } from "./bookings.repository"; +import { Booking } from "./entities/booking.entity"; /** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */ interface StoredPricingBreakdown { @@ -23,6 +29,12 @@ interface StoredPricingBreakdown { currency?: string; } +export interface InvoiceOptions { + dueDate?: Date; + invoiceType?: string; + invoiceStatus?: Freight.InvoiceStatus; +} + /** Round to 2 decimals, avoiding binary float drift. */ const round2 = (n: number): number => Math.round(n * 100) / 100; @@ -52,32 +64,28 @@ export class BookingInvoiceService { * Ensure the booking has its invoice, generating one from the snapshotted * pricing breakdown if absent. Called when a booking reaches a billable state. * Idempotent — returns the existing open invoice instead of a duplicate. - * Returns `null` (and logs) when the booking is not billable: no company to - * bill (e.g. government bookings whose `companyId` is null, which the invoices - * FK requires), or no priced amount. + * Throws `BadRequestException` when the booking is not billable: no company + * to bill (e.g. government bookings whose `companyId` is null, which the + * invoices FK requires), or no priced amount. */ - async ensureInvoiceForBooking(booking: Booking): Promise { + async ensureInvoiceForBooking( + booking: Booking, + invoiceOptions: InvoiceOptions = {}, + ): Promise { const existing = await this.billing.findPayable( Freight.InvoiceSource.Booking, booking.id, - Freight.InvoiceType.Prepaid, + "PREPAID", ); if (existing) return existing; if (!booking.companyId) { - this.logger.warn( - `Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`, + throw new BadRequestException( + `Cannot generate invoice for booking ${booking.reference} (${booking.id}): no company to bill.`, ); - return null; } - const input = this.buildInput(booking); - if (!input) { - this.logger.warn( - `Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`, - ); - return null; - } + const input = this.buildInput(booking, invoiceOptions); return this.billing.generateInvoice(input); } @@ -87,10 +95,13 @@ export class BookingInvoiceService { * reactions live here (not in the payment process): each invoice type advances * the booking its own way. Only PREPAID exists today. */ - @OnEvent('booking.invoice.paid') + @OnEvent("booking.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { + this.logger.log( + `onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`, + ); switch (payload.type) { - case Freight.InvoiceType.Prepaid: + case "PREPAID": await this.advanceBookingOnPayment(payload.sourceId); break; default: @@ -100,6 +111,14 @@ export class BookingInvoiceService { } } + updateStatus( + invoiceId: string, + status: Freight.InvoiceStatus, + manager?: EntityManager, + ): Promise { + return this.billing.updateStatus(invoiceId, status, manager); + } + /** * Advance a booking once its prepaid invoice settles — the domain side-effect * of payment, relocated out of the payment service: the booking becomes PAID @@ -114,20 +133,29 @@ export class BookingInvoiceService { private async advanceBookingOnPayment(bookingId: string): Promise { const booking = await this.bookingsRepository.findById(bookingId); if (!booking) { - this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`); + this.logger.warn( + `Cannot advance unknown booking ${bookingId} on payment.`, + ); return; } - if (booking.paymentStatus === 'PAID') return; + // if (booking.paymentStatus === "PAID") return; await this.dataSource.transaction(async (mg) => { await mg.update( Booking, { id: bookingId }, - { paymentStatus: 'PAID', status: 'PAID' }, + { paymentStatus: "PAID", status: "PAID" }, ); - await this.firstMile.acceptBooking(bookingId); }); + try { + await this.firstMile.acceptBooking(bookingId); + } catch (err) { + this.logger.error( + `Error accepting first-mile after payment: ${err instanceof Error ? err.message : String(err)}`, + ); + } + try { await this.bookingBatch.ensurePaidBookingAllocated(bookingId); } catch (err) { @@ -138,9 +166,13 @@ export class BookingInvoiceService { } /** Map a booking's pricing snapshot into a generic invoice request. */ - private buildInput(booking: Booking): GenerateInvoiceInput | null { - const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown; - const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB'; + private buildInput( + booking: Booking, + invoiceOptions: InvoiceOptions = {}, + ): GenerateInvoiceInput { + const breakdown = (booking.pricingBreakdown ?? + {}) as StoredPricingBreakdown; + const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB"; const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({ chargeType: l.code, @@ -155,10 +187,14 @@ export class BookingInvoiceService { // Fall back to a single freight line when no breakdown was snapshotted. if (lines.length === 0) { const amount = Number(booking.totalAmount); - if (!Number.isFinite(amount) || amount <= 0) return null; + if (!Number.isFinite(amount) || amount <= 0) { + throw new BadRequestException( + `Cannot generate invoice for booking ${booking.reference} (${booking.id}): no priced amount.`, + ); + } lines.push({ - chargeType: 'FREIGHT', - description: 'Rail freight', + chargeType: "FREIGHT", + description: "Rail freight", quantity: 1, unitRate: amount, amount, @@ -166,7 +202,9 @@ export class BookingInvoiceService { }); } - const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0)); + const subtotal = round2( + lines.reduce((sum, l) => sum + Number(l.amount), 0), + ); let totalAmount = subtotal; // Honor a staff price override: bill the adjusted total, recording the delta @@ -176,8 +214,8 @@ export class BookingInvoiceService { const delta = round2(Number(adjusted) - subtotal); if (delta !== 0) { lines.push({ - chargeType: 'ADJUSTMENT', - description: 'Staff price adjustment', + chargeType: "ADJUSTMENT", + description: "Staff price adjustment", quantity: 1, unitRate: delta, amount: delta, @@ -190,12 +228,14 @@ export class BookingInvoiceService { return { source: Freight.InvoiceSource.Booking, sourceId: booking.id, - type: Freight.InvoiceType.Prepaid, companyId: booking.companyId, companyProfileId: booking.companyProfileId, currency, lines, totalAmount, + dueAt: invoiceOptions.dueDate, + type: invoiceOptions.invoiceType ?? "PREPAID", + status: invoiceOptions.invoiceStatus ?? Freight.InvoiceStatus.Draft, }; } } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts new file mode 100644 index 000000000..a4546e301 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -0,0 +1,307 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + NotificationAudience, + NotificationType, + NotifyInput, +} from '@edr/types'; + +import { Booking } from './entities/booking.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; + +/** + * Customer + staff notifications for the booking lifecycle: review, clearance + * and operation flow. Every customer event fans out over SMS + email (direct) + * and a persisted in-app notification deep-linking to the booking detail page; + * staff events land in the backoffice inbox. All sends are fire-and-forget and + * never throw — a notification failure must not break a booking transition. + * + * NOTE: the batch/payment-window notifications (pay-now, allocated, expired, + * displaced) are handled separately by {@link BookingNotifierService} in + * train-scheduling. + */ +@Injectable() +export class BookingLifecycleNotifierService { + private readonly logger = new Logger(BookingLifecycleNotifierService.name); + + constructor( + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} + + private ref(b: Booking): string { + return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; + } + + /** Send SMS + email to the booking's company contact; log-only on failure. */ + private async notifyContact( + b: Booking, + message: string, + logLabel: string, + ): Promise { + this.logger.log(`${logLabel} — ${this.ref(b)}`); + const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null; + const email = b.company?.email ?? b.company?.generalManagerEmail ?? null; + + if (phone) { + try { + await this.notifications.directSend('sms', phone, message); + } catch (err) { + this.logger.warn(`SMS failed for ${this.ref(b)}: ${(err as Error).message}`); + } + } + if (email) { + try { + await this.notifications.directSend('email', email, message); + } catch (err) { + this.logger.warn(`Email failed for ${this.ref(b)}: ${(err as Error).message}`); + } + } + if (!phone && !email) { + this.logger.warn(`No contact on file for ${this.ref(b)} — notification not sent`); + } + } + + /** Persist + push an in-app item to all portal users of the booking's company. */ + private inApp( + b: Booking, + title: string, + body: string, + overrides: Partial = {}, + ): void { + if (!b.companyId) return; // government/unlinked bookings have no portal users + void this.inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title, + body, + link: `/bookings/${b.id}`, + data: { bookingId: b.id, reference: b.reference }, + ...overrides, + }); + } + + /** Persist + push an in-app item to every backoffice staff user. */ + private inAppStaff( + b: Booking, + title: string, + body: string, + overrides: Partial = {}, + ): void { + void this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: `/dashboard/booking-requests/${b.id}`, + data: { bookingId: b.id, reference: b.reference }, + ...overrides, + }); + } + + // ── Customer-facing lifecycle events ─────────────────────────────────────── + + /** Line staff accepted intake → booking is under approval. */ + accepted(b: Booking): void { + const msg = + `Your booking ${b.reference} has been accepted and is now under approval. ` + + `We will notify you once it is approved.`; + void this.notifyContact(b, msg, 'ACCEPTED'); + this.inApp(b, 'Booking accepted', msg); + } + + /** All approval steps complete → contract generated, ready for customer to sign. */ + approved(b: Booking): void { + const msg = + `Your booking ${b.reference} has been approved. ` + + `Please review and sign your contract from the portal.`; + void this.notifyContact(b, msg, 'APPROVED'); + this.inApp(b, 'Booking approved', msg); + } + + /** Staff rejected the booking (intake or approval step). */ + rejected(b: Booking, reason: string): void { + const msg = + `Your booking ${b.reference} was rejected. Reason: ${reason}. ` + + `Please contact us for details.`; + void this.notifyContact(b, msg, 'REJECTED'); + this.inApp(b, 'Booking rejected', msg); + } + + /** Staff requested changes before approval. */ + changesRequested(b: Booking, note: string): void { + const msg = + `Changes were requested on your booking ${b.reference}: ${note}. ` + + `Please update and resubmit from the portal.`; + void this.notifyContact(b, msg, 'CHANGES REQUESTED'); + this.inApp(b, 'Booking changes requested', msg); + } + + /** A clearance document was queried and needs the customer to re-upload. */ + documentQueried(b: Booking, fileKey: string, note: string): void { + const msg = + `A clearance document on booking ${b.reference} needs attention: "${fileKey}". ` + + `${note}. Please re-upload from the portal.`; + void this.notifyContact(b, msg, 'DOCUMENT QUERIED'); + this.inApp(b, 'Document queried', msg, { + type: NotificationType.DOCUMENT_ACTION, + }); + } + + /** Clearance finalized → customer can proceed to request operation. */ + clearanceReady(b: Booking): void { + const msg = + `Clearance for booking ${b.reference} is complete. ` + + `You can now proceed to request operation from the portal.`; + void this.notifyContact(b, msg, 'CLEARANCE READY'); + this.inApp(b, 'Clearance complete', msg, { + type: NotificationType.CLEARANCE_DECISION, + }); + } + + /** Operations returned the operation request for changes. */ + operationChangesRequested(b: Booking, note: string): void { + const msg = + `Your operation request for booking ${b.reference} needs changes: ${note}. ` + + `Please update and resubmit from the portal.`; + void this.notifyContact(b, msg, 'OPERATION CHANGES REQUESTED'); + this.inApp(b, 'Operation request needs changes', msg); + } + + /** Operation accepted → invoice ready; await payment / booking window. */ + operationAccepted(b: Booking): void { + const msg = + `Your operation request for booking ${b.reference} has been accepted. ` + + `An invoice has been prepared — watch for the payment window to secure your slot.`; + void this.notifyContact(b, msg, 'OPERATION ACCEPTED'); + this.inApp(b, 'Operation request accepted', msg); + } + + /** Shipment started → in transit. */ + inTransit(b: Booking): void { + const msg = `Your shipment for booking ${b.reference} is now in transit.`; + void this.notifyContact(b, msg, 'IN TRANSIT'); + this.inApp(b, 'Shipment in transit', msg); + } + + /** Shipment delivered → completed. */ + completed(b: Booking): void { + const msg = `Your shipment for booking ${b.reference} has been delivered. Thank you.`; + void this.notifyContact(b, msg, 'COMPLETED'); + this.inApp(b, 'Shipment delivered', msg); + } + + /** Booking cancelled. */ + cancelled(b: Booking, reason: string): void { + const msg = `Your booking ${b.reference} has been cancelled. Reason: ${reason}.`; + void this.notifyContact(b, msg, 'CANCELLED'); + this.inApp(b, 'Booking cancelled', msg); + } + + // ── Clearance milestones needing customer action ────────────────────────── + + /** GL advised duty & tax — the customer must pay and upload the slip. */ + dutyAdvised(b: Booking, amount: number, currency: string): void { + const msg = + `Duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` + + `Please pay and upload the payment slip from the portal.`; + void this.notifyContact(b, msg, 'DUTY ADVISED'); + this.inApp(b, 'Duty & tax advised', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** GL advised the post-arrival additional duty round (import). */ + secondDutyAdvised(b: Booking, amount: number, currency: string): void { + const msg = + `Additional duty & tax of ${amount} ${currency} has been advised for booking ${b.reference}. ` + + `Please pay and upload the payment slip from the portal.`; + void this.notifyContact(b, msg, 'SECOND DUTY ADVISED'); + this.inApp(b, 'Additional duty & tax advised', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** GL raised the final (post-offload) invoice — customer pays + uploads slip. */ + finalInvoiceCreated(b: Booking, amount: number, currency: string): void { + const msg = + `A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` + + `Please pay and upload the payment slip from the portal.`; + void this.notifyContact(b, msg, 'FINAL INVOICE'); + this.inApp(b, 'Final invoice issued', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** GL confirmed the final-invoice payment slip. */ + finalInvoicePaid(b: Booking): void { + const msg = `Your final invoice payment for booking ${b.reference} has been confirmed. Thank you.`; + void this.notifyContact(b, msg, 'FINAL INVOICE PAID'); + this.inApp(b, 'Final invoice paid', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + // ── Staff-facing (backoffice inbox) ──────────────────────────────────────── + + /** Customer submitted a booking for review. */ + submittedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'New booking submitted', + `Booking ${this.ref(b)} was submitted and is awaiting intake review.`, + ); + } + + /** Customer signed the booking contract. */ + customerSignedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'Customer signed booking contract', + `The contract for booking ${this.ref(b)} was signed by the customer.`, + ); + } + + /** Customer requested operation (picked a shipment day). */ + operationRequestedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'Operation requested', + `Booking ${this.ref(b)} requested operation — review capacity, documents and route.`, + ); + } + + /** Customer uploaded clearance documents — review is next. */ + clearanceDocsUploadedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'Clearance documents uploaded', + `Customer uploaded clearance documents for booking ${this.ref(b)} — review them in the clearance queue.`, + { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/bookings/${b.id}/clearance`, + }, + ); + } + + /** Customer uploaded a duty/tax payment slip — GL verifies it. */ + dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void { + const label = + round === 'final' + ? 'final invoice' + : round === 'second' + ? 'additional duty & tax' + : 'duty & tax'; + this.inAppStaff( + b, + 'Payment slip uploaded', + `Customer uploaded the ${label} payment slip for booking ${this.ref(b)}.`, + { + type: NotificationType.PAYMENT_RECEIVED, + link: `/dashboard/bookings/${b.id}/clearance`, + }, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts index 2140a7688..9ef951853 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts @@ -28,7 +28,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{ { key: 'payment', statuses: ['FULLY_EXECUTED'] }, { key: 'operations', - statuses: ['IN_TRANSIT', 'PAID'], + statuses: ['IN_TRANSIT', 'ARRIVED', 'PAID'], }, { key: 'completed', statuses: ['COMPLETED'] }, { key: 'closed', statuses: ['REJECTED', 'CANCELLED'] }, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts index ac6e35b56..d93b5b7bd 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -102,6 +102,7 @@ export function computeNextStep( description: 'Mark shipment as in transit', }; case 'IN_TRANSIT': + case 'ARRIVED': return { action: 'COMPLETE', description: 'Mark shipment complete', diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts deleted file mode 100644 index 1fbe34e1f..000000000 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { Freight } from '@edr/types'; -import { BookingsRepository } from './bookings.repository'; -import { Booking } from './entities/booking.entity'; -import { assertBookingStatus } from './booking-status.util'; -import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; -import { BillingService } from '../billing/billing.service'; -import { PaymentMethodTypeEnum } from '../payment/payments.dto'; -export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { } - -@Injectable() -export class BookingPaymentService { - constructor( - private readonly bookingsRepository: BookingsRepository, - private readonly billing: BillingService, - ) { } - - /** - * Start payment for a booking. The booking never touches the payment gateway - * directly — it charges its invoice through billing, which resolves the amount - * and drives the provider. Returns the provider redirect URL (empty when none). - */ - async pay(bookingId: string): Promise<{ redirectUrl: string }> { - const booking = await this.requireBooking(bookingId); - assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']); - - const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, { - method: PaymentMethodTypeEnum.TELEBIRR, - platform: 'web', - }); - - const action = resp.clientAction as { type?: string; url?: string } | undefined; - return { - redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '', - }; - } - - private async requireBooking(id: string): Promise { - const booking = await this.bookingsRepository.findById(id); - if (!booking) throw new NotFoundException(`Booking ${id} not found`); - return booking; - } -} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 1c1b490dd..db6b70eae 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -45,6 +45,7 @@ describe('BookingPricingService — domestic corridor', () => { {} as never, ratesService as never, exchangeService as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index c5e5b710e..d63106c2b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -17,6 +17,14 @@ import { import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; +import { ContainerValidationService } from './container-validation.service'; + +export interface OverweightLine { + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; +} export interface ComputedPriceResult { lineItems: PriceLineItemDto[]; @@ -27,6 +35,7 @@ export interface ComputedPriceResult { priorityScore: number; warnings: string[]; hardBlocked: string[]; + overweightLines: OverweightLine[]; } type StoredPricingBreakdown = { @@ -67,6 +76,7 @@ export class BookingPricingService { private readonly containerTypesService: ContainerTypesService, private readonly ratesService: RatesService, private readonly exchangeService: ExchangeService, + private readonly containerValidationService: ContainerValidationService, ) {} async generatePrice(bookingId: string): Promise { @@ -94,12 +104,19 @@ export class BookingPricingService { }, } as never); + // 20ft weight-pairing preview: surfaced now so the customer sees the problem + // (and the overweight warning + surcharge) at the confirm step, before submit. + // Submit re-runs this and HARD-BLOCKS on a non-empty result. + const pairing = await this.containerValidationService.validate20ftPairing(booking); + return { bookingId, totalAmount: computed.totalAmount, currency: computed.currency, lineItems: computed.lineItems, warnings: computed.warnings, + overweightLines: computed.overweightLines, + pairingErrors: pairing.map((p) => p.message), }; } @@ -169,6 +186,35 @@ export class BookingPricingService { if (rate) usedRatesMap.set(rate.id, rate); } + // Overweight detail for the customer: map the engine's per-line results back + // to the booking's container lines (same order) for code + weights. maxAllowed + // is derived from the line total minus the excess the engine computed. + const overweightLines: OverweightLine[] = []; + const containerLines = (booking.bookingContainers ?? []).filter( + (bc) => bc.containerTypeId != null, + ); + for (let i = 0; i < ruleResult.containerWeightResults.length; i++) { + const wr = ruleResult.containerWeightResults[i]; + if (!wr?.isOverweight) continue; + const line = containerLines[i]; + const totalVgmTons = Number(line?.totalVgmTons ?? 0); + const excessTons = Number(wr.overweightExcessTons ?? 0); + let code = line?.containerSize ?? ''; + if (line?.containerTypeId) { + try { + code = (await this.containerTypesService.findById(line.containerTypeId)).code; + } catch { + // fall back to the container size label + } + } + overweightLines.push({ + containerTypeCode: code, + totalVgmTons, + maxAllowedTons: Math.max(0, totalVgmTons - excessTons), + excessTons, + }); + } + return { lineItems, totalAmount: total, @@ -178,6 +224,7 @@ export class BookingPricingService { priorityScore: ruleResult.priorityScore, warnings: ruleResult.warnings, hardBlocked: ruleResult.hardBlocked, + overweightLines, }; } @@ -384,7 +431,7 @@ export class BookingPricingService { const lines: PriceLineItemDto[] = []; const usedRatesMap = new Map(); - const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); + const wagonCount = await this.resolveWagonCount(booking); for (const container of evalInput.containers) { const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD'); @@ -524,6 +571,23 @@ export class BookingPricingService { return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; } + /** + * Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate; + * an unsaved preview booking (no id) sums the wagonsRequired already computed + * on its in-memory container lines — same math, no DB row needed. + */ + private async resolveWagonCount(booking: Booking): Promise { + if (!booking.id) { + return Math.ceil( + (booking.bookingContainers ?? []).reduce( + (sum, bc) => sum + Number(bc.wagonsRequired ?? 0), + 0, + ), + ); + } + return this.bookingsRepository.calculateWagonCount(booking.id); + } + /** Friendly container-type label for the per-unit card; degrades to "Container". */ private async containerTypeLabel(containerTypeId: string): Promise { try { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index e15fcba0d..6e96dc6f8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -58,7 +58,6 @@ export function buildCargoTypeTree( id: child.id, name: child.cargoTypeName, code: child.code, - show_free_text_box: child.showFreeTextBox, unit_of_measure: child.unitOfMeasure ?? null, }), ); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index a9806f1f7..607a0d7a4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -30,11 +30,32 @@ describe('BookingTransitionService — acceptIntake validity window', () => { ruleEngineService as never, {} as never, // pricingService {} as never, // contractService - {} as never, // invoiceService {} as never, // filesService {} as never, // fileUploadSettingsService {} as never, // bookingBatchService bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, // workflowService + {} as never, // invoiceService + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); return { service, bookingsRepository, ruleEngineService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index ff08784a6..72c83e136 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -41,11 +41,32 @@ describe('BookingTransitionService — finalizeClearance gate', () => { {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService - {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, {} as never, // bookingBatchService bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, // workflowService + {} as never, // invoiceService + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); return { service, bookingsRepository }; } @@ -123,11 +144,32 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( {} as never, {} as never, {} as never, - {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, - {} as never, + {} as never, // bookingBatchService bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, // workflowService + {} as never, // invoiceService + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); return { service, bookingsRepository }; } @@ -191,11 +233,32 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields {} as never, {} as never, {} as never, - {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, - {} as never, + {} as never, // bookingBatchService bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, // workflowService + {} as never, // invoiceService + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); return { service, bookingsRepository, filesService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index cc2288963..9201e4fa9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -3,14 +3,17 @@ import { BookingTransitionService } from './booking-transition.service'; /** * Operation-request review for general-contract drawdown orders: - * - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool. - * - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued. + * - ACCEPT a train order → FULLY_EXECUTED with the invoice ensured; import/ + * domestic bookings wait for their booking-day window cycle (no immediate + * batch enqueue at accept time). + * - ACCEPT a road order → ROAD_DISPATCH_PENDING, never enters the train batch. * - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED. */ describe('BookingTransitionService — operation review', () => { function makeService(serviceTypeCode: string) { const booking = { id: 'b-1', + reference: 'BKG-1', status: 'OPERATION_REQUEST_PENDING', originYardId: 'o-1', destinationYardId: 'd-1', @@ -26,6 +29,14 @@ describe('BookingTransitionService — operation review', () => { }; const bookingBatchService = { enqueueRouteDayProcessing: jest.fn(), + pickExportSchedule: jest.fn(), + acceptExportBooking: jest.fn(), + }; + const invoiceService = { + ensureInvoiceForBooking: jest + .fn() + .mockResolvedValue({ id: 'inv-1', invoiceNumber: 'INV-0001' }), + updateStatus: jest.fn().mockResolvedValue(undefined), }; const service = new BookingTransitionService( @@ -33,34 +44,60 @@ describe('BookingTransitionService — operation review', () => { {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService - {} as never, // invoiceService {} as never, // filesService {} as never, // fileUploadSettingsService bookingBatchService as never, bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, // workflowService + invoiceService as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + accepted: jest.fn(), + approved: jest.fn(), + rejected: jest.fn(), + changesRequested: jest.fn(), + documentQueried: jest.fn(), + clearanceReady: jest.fn(), + operationChangesRequested: jest.fn(), + operationAccepted: jest.fn(), + inTransit: jest.fn(), + completed: jest.fn(), + cancelled: jest.fn(), + submittedToStaff: jest.fn(), + customerSignedToStaff: jest.fn(), + operationRequestedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + } as never, // notifier ); - return { service, bookingsRepository, bookingBatchService }; + return { service, bookingsRepository, bookingBatchService, invoiceService }; } - it('ACCEPT of a train order → FULLY_EXECUTED and enqueues the batch pool', async () => { - const { service, bookingsRepository, bookingBatchService } = + it('ACCEPT of a train order → FULLY_EXECUTED, invoice ensured, batch waits for window cycle', async () => { + const { service, bookingsRepository, bookingBatchService, invoiceService } = makeService('RAIL_CONTAINER'); await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1'); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-1', expect.objectContaining({ status: 'FULLY_EXECUTED' }), ); - expect(bookingBatchService.enqueueRouteDayProcessing).toHaveBeenCalledTimes(1); + expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); + // Import/domestic train bookings are batched by the window cycle later — + // never enqueued directly at accept time. + expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled(); + expect(bookingBatchService.acceptExportBooking).not.toHaveBeenCalled(); }); - it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enqueue', async () => { - const { service, bookingsRepository, bookingBatchService } = + it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enter the batch', async () => { + const { service, bookingsRepository, bookingBatchService, invoiceService } = makeService('ROAD_CONTAINER'); await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1'); expect(bookingsRepository.update).toHaveBeenCalledWith( 'b-1', expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }), ); + expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled(); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 2ebceeabc..8b6e8a2f8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -4,8 +4,9 @@ import { Inject, Injectable, Logger, -} from '@nestjs/common'; -import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + Optional, +} from "@nestjs/common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; @@ -15,8 +16,9 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { FilesService } from '../files/files.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { BookingContractService } from './booking-contract.service'; -import { BookingInvoiceService } from './booking-invoice.service'; +import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import { BookingPricingService } from './booking-pricing.service'; +import { ContainerValidationService } from './container-validation.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; import { clearanceCodesForBooking } from './clearance.util'; @@ -25,32 +27,63 @@ import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { BookingsService } from './bookings.service'; +import { BookingClearanceService } from '../contracts/booking-clearance.service'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; +import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service'; +import { ContractDocPhase } from '@edr/types'; + + +import { Freight } from "@edr/types"; +import { BookingInvoiceService } from "./booking-invoice.service"; @Injectable() export class BookingTransitionService { private readonly logger = new Logger(BookingTransitionService.name); - constructor( private readonly bookingsRepository: BookingsRepository, private readonly ruleEngineService: RuleEngineService, private readonly pricingService: BookingPricingService, private readonly contractService: BookingContractService, - private readonly invoiceService: BookingInvoiceService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, @Inject(forwardRef(() => BookingsService)) private readonly bookingsService: BookingsService, + @Inject(forwardRef(() => BookingClearanceService)) + private readonly bookingClearanceService: BookingClearanceService, + @Inject(forwardRef(() => ClearanceWorkflowService)) + private readonly workflowService: ClearanceWorkflowService, + private readonly invoiceService: BookingInvoiceService, + private readonly containerValidationService: ContainerValidationService, + private readonly notifier: BookingLifecycleNotifierService, + @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} + private isPhasedGeneralCustoms(booking: Booking): boolean { + return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking); + } + + /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */ + private async assert20ftPairable(booking: Booking): Promise { + const violations = + await this.containerValidationService.validate20ftPairing(booking); + if (violations.length) { + throw new BadRequestException( + `Cannot submit — 20ft containers cannot be paired on wagons: ${violations + .map((v) => v.message) + .join(' ')}`, + ); + } + } + async submit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); + assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]); if (Number(booking.totalAmount) <= 0) { throw new BadRequestException( - 'Generate a price before submitting (POST /bookings/:id/generate-price)', + "Generate a price before submitting (POST /bookings/:id/generate-price)", ); } @@ -64,12 +97,18 @@ export class BookingTransitionService { requiresDirectorApproval: false, }); + // 20ft weight-pairing hard block: two 20ft on a wagon must differ ≤ the cap. + // If no balanced pairing exists the booking cannot proceed (overweight only + // warns; this rejects). An odd leftover 20ft is fine — it goes to consolidation. + await this.assert20ftPairable(booking); + const stored = booking.pricingBreakdown as { lineItems?: PriceLineItemDto[]; totalAmount?: number; } | null; const unchanged = this.pricingService.pricesMatch(stored, computed); - const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + const priorityScore = + await this.pricingService.computeSubmitPriorityScore(booking); if (unchanged) { await this.pricingService.createPricingSnapshots( @@ -79,7 +118,7 @@ export class BookingTransitionService { ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SUBMITTED', + status: "SUBMITTED", priorityScore, } as never); @@ -89,6 +128,9 @@ export class BookingTransitionService { const finalBooking = await this.bookingsService.runConsolidationOnSubmit( updated!.id, ); + if (finalBooking.status === "SUBMITTED") { + this.notifier.submittedToStaff(finalBooking); + } return { bookingId: finalBooking.id, status: finalBooking.status, @@ -109,7 +151,7 @@ export class BookingTransitionService { currency: computed.currency, generatedAt: new Date().toISOString(), }, - status: 'PRICE_CHANGED_PENDING_CONFIRM', + status: "PRICE_CHANGED_PENDING_CONFIRM", } as never); const updatedBooking = await this.bookingsService.findById(bookingId); @@ -121,16 +163,17 @@ export class BookingTransitionService { totalAmount: computed.totalAmount, currency: computed.currency, lineItems: computed.lineItems, - message: 'Price has changed since preview. Confirm to submit with the updated price.', + message: + "Price has changed since preview. Confirm to submit with the updated price.", }; } async confirmSubmit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']); + assertBookingStatus(booking, ["PRICE_CHANGED_PENDING_CONFIRM"]); if (Number(booking.totalAmount) <= 0) { - throw new BadRequestException('No price to confirm'); + throw new BadRequestException("No price to confirm"); } const computed = await this.pricingService.computePriceForBooking(booking); @@ -142,6 +185,7 @@ export class BookingTransitionService { hardBlocked: computed.hardBlocked, requiresDirectorApproval: false, }); + await this.assert20ftPairable(booking); await this.pricingService.createPricingSnapshots( bookingId, @@ -149,9 +193,10 @@ export class BookingTransitionService { computed.appliedModifiers, ); - const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + const priorityScore = + await this.pricingService.computeSubmitPriorityScore(booking); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SUBMITTED', + status: "SUBMITTED", priorityScore, totalAmount: computed.totalAmount, pricingBreakdown: { @@ -166,6 +211,9 @@ export class BookingTransitionService { const finalBooking = await this.bookingsService.runConsolidationOnSubmit( updated!.id, ); + if (finalBooking.status === "SUBMITTED") { + this.notifier.submittedToStaff(finalBooking); + } return { bookingId: finalBooking.id, status: finalBooking.status, @@ -173,7 +221,7 @@ export class BookingTransitionService { totalAmount: Number(finalBooking.totalAmount), currency: finalBooking.paymentCurrency, lineItems: computed.lineItems, - message: 'Booking submitted with confirmed price.', + message: "Booking submitted with confirmed price.", }; } @@ -183,19 +231,21 @@ export class BookingTransitionService { actorId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SUBMITTED']); + assertBookingStatus(booking, ["SUBMITTED"]); await this.bookingsRepository.createReviewNote( bookingId, note, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'CHANGES_REQUESTED', + status: "CHANGES_REQUESTED", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.changesRequested(fresh, note); + return fresh; } /** Auto-create booking approval steps from system rules when none exist yet. */ @@ -203,7 +253,7 @@ export class BookingTransitionService { if ((booking.approvalSteps?.length ?? 0) > 0) return; await this.ruleEngineService.instantiateApprovalSteps(booking.id, { - freightType: booking.freightType as 'CONTAINER' | 'BULK', + freightType: booking.freightType as "CONTAINER" | "BULK", cargoTypeId: booking.cargoTypeId, }); } @@ -217,14 +267,14 @@ export class BookingTransitionService { // Only SUBMITTED bookings are acceptable. A booking that still needs // consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and // is therefore never offered for accept until a partner moves it to SUBMITTED. - assertBookingStatus(booking, ['SUBMITTED']); + assertBookingStatus(booking, ["SUBMITTED"]); // The backoffice must define how long the accepted contract stays valid. // Without a window the contract has no end date and cannot be relied on, so // accept is blocked until a positive number of days is supplied. if (!Number.isInteger(validityDays) || validityDays < 1) { throw new BadRequestException( - 'A contract validity (in days) is required to accept this booking.', + "A contract validity (in days) is required to accept this booking.", ); } @@ -234,19 +284,21 @@ export class BookingTransitionService { validUntil.setDate(validUntil.getDate() + validityDays); await this.ruleEngineService.instantiateApprovalSteps(bookingId, { - freightType: booking.freightType as 'CONTAINER' | 'BULK', + freightType: booking.freightType as "CONTAINER" | "BULK", cargoTypeId: booking.cargoTypeId, }); const updated = await this.bookingsRepository.update(bookingId, { - status: 'PENDING_APPROVAL', + status: "PENDING_APPROVAL", approvedByStaffId: actorId, approvedByStaffAt: validFrom, contractValidityDays: validityDays, contractValidFrom: validFrom, contractValidUntil: validUntil, } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.accepted(fresh); + return fresh; } async staffReject( @@ -255,19 +307,21 @@ export class BookingTransitionService { actorId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']); + assertBookingStatus(booking, ["SUBMITTED", "PENDING_APPROVAL"]); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.rejected(fresh, reason); + return fresh; } async approveStep( @@ -283,8 +337,8 @@ export class BookingTransitionService { let booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'PENDING_APPROVAL', - 'APPROVED_PENDING_SIGNATURE', + "PENDING_APPROVAL", + "APPROVED_PENDING_SIGNATURE", ]); if ((booking.approvalSteps?.length ?? 0) === 0) { @@ -296,14 +350,17 @@ export class BookingTransitionService { bookingId, stepId, ); - if (!step || step.status !== 'PENDING') { - throw new BadRequestException('Approval step not found or already actioned'); + if (!step || step.status !== "PENDING") { + throw new BadRequestException( + "Approval step not found or already actioned", + ); } - const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId); + const next = + await this.bookingsRepository.findNextPendingApprovalStep(bookingId); if (!next || next.id !== step.id) { throw new BadRequestException( - 'Approval steps must be completed in order', + "Approval steps must be completed in order", ); } @@ -315,29 +372,36 @@ export class BookingTransitionService { const blocksRole = step.blocksRole; if (blocksRole && blocksRole === requiredRole) { - throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); + throw new BadRequestException( + `Role ${requiredRole} is blocked for this step`, + ); } - await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); + await this.bookingsRepository.completeApprovalStep( + step.id, + actorId, + "APPROVED", + ); const updates: Record = {}; const now = new Date(); - if (requiredRole === 'LINE_STAFF') { - updates.status = 'APPROVED_PENDING_SIGNATURE'; + if (requiredRole === "LINE_STAFF") { + updates.status = "APPROVED_PENDING_SIGNATURE"; updates.approvedByStaffId = actorId; updates.approvedByStaffAt = now; - } else if (requiredRole === 'DIRECTOR') { + } else if (requiredRole === "DIRECTOR") { updates.signedByDirectorId = actorId; updates.signedByDirectorAt = now; - } else if (requiredRole === 'CEO') { + } else if (requiredRole === "CEO") { updates.signedByCeoId = actorId; updates.signedByCeoAt = now; } - const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId); + const allDone = + await this.bookingsRepository.allApprovalStepsComplete(bookingId); if (allDone) { - updates.status = 'APPROVED'; + updates.status = "APPROVED"; } if (Object.keys(updates).length > 0) { @@ -346,7 +410,9 @@ export class BookingTransitionService { if (allDone) { const generated = await this.contractService.generateContract(bookingId); - return this.bookingsService.findById(generated.id); + const fresh = await this.bookingsService.findById(generated.id); + this.notifier.approved(fresh); + return fresh; } return this.bookingsService.findById(bookingId); @@ -359,116 +425,120 @@ export class BookingTransitionService { reason: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); + assertBookingStatus(booking, [ + "PENDING_APPROVAL", + "APPROVED_PENDING_SIGNATURE", + ]); const step = await this.bookingsRepository.findApprovalStepById( bookingId, stepId, ); - if (!step) throw new BadRequestException('Approval step not found'); + if (!step) throw new BadRequestException("Approval step not found"); await this.bookingsRepository.completeApprovalStep( step.id, actorId, - 'REJECTED', + "REJECTED", reason, ); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.rejected(fresh, reason); + return fresh; } async customerSign(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['CONTRACT_READY']); + assertBookingStatus(booking, ["CONTRACT_READY"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SIGNED_CUSTOMER', + status: "SIGNED_CUSTOMER", customerSignedAt: new Date(), } as never); - return this.bookingsService.findById(updated!.id); - } - - async marketingApprove(bookingId: string, actorId: string): Promise { - const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SIGNED_CUSTOMER']); - - const updated = await this.bookingsRepository.update(bookingId, { - status: 'FULLY_EXECUTED', - fullyExecutedAt: new Date(), - marketingApprovedById: actorId, - marketingApprovedAt: new Date(), - lockedAt: new Date(), - } as never); - - const executed = await this.bookingsService.findById(updated!.id); - - // Billable state reached — generate the invoice payment will settle. - // Non-blocking: a billing hiccup must not undo the execution. - await this.invoiceService - .ensureInvoiceForBooking(executed) - .catch((err) => - this.logger.error( - `Failed to generate invoice for booking ${executed.reference}: ${ - err instanceof Error ? err.message : String(err) - }`, - ), - ); - - return executed; + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.customerSignedToStaff(fresh); + return fresh; } async startTransit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PAID']); + assertBookingStatus(booking, ["PAID"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'IN_TRANSIT', + status: "IN_TRANSIT", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.inTransit(fresh); + return fresh; } async complete(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['IN_TRANSIT']); + assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'COMPLETED', + status: "COMPLETED", endDate: new Date(), } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.completed(fresh); + // Customer tracking: close out the tail milestones so a finished shipment + // never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are + // implied by delivery; a storage invoice that was never raised is skipped + // (storage billing does not apply to every shipment). All doc-trigger / + // best-effort — a booking without milestone rows is untouched. + if (this.milestoneService) { + for (const code of ["IMPORT_PROCESS_COMPLETED", "EXIT_NOTE_GENERATED"]) { + try { + await this.milestoneService.completeByDocTrigger({ bookingId }, code); + } catch { + /* tracking must never block completion */ + } + } + try { + await this.milestoneService.skipForBooking(bookingId, "STORAGE_INVOICE_RAISED"); + } catch { + /* no such milestone row (export / non-customs) — fine */ + } + } + return fresh; } async cancel(bookingId: string, reason: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'DRAFT', - 'SUBMITTED', - 'PRICE_CHANGED_PENDING_CONFIRM', - 'CHANGES_REQUESTED', - 'PENDING_APPROVAL', - 'CONTRACT_READY', + "DRAFT", + "SUBMITTED", + "PRICE_CHANGED_PENDING_CONFIRM", + "CHANGES_REQUESTED", + "PENDING_APPROVAL", + "CONTRACT_READY", + "OPERATION_REQUEST_PENDING", ]); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'CANCELLED', + status: "CANCELLED", } as never); - return this.bookingsService.findById(updated!.id); + const fresh = await this.bookingsService.findById(updated!.id); + this.notifier.cancelled(fresh, reason); + return fresh; } /** @@ -479,20 +549,20 @@ export class BookingTransitionService { async reject(bookingId: string, reason?: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'DRAFT', - 'SUBMITTED', - 'PRICE_CHANGED_PENDING_CONFIRM', - 'PENDING_CONSOLIDATION', + "DRAFT", + "SUBMITTED", + "PRICE_CHANGED_PENDING_CONFIRM", + "PENDING_CONSOLIDATION", ]); await this.bookingsRepository.createReviewNote( bookingId, - reason?.trim() || 'Customer rejected the price estimate.', - 'REJECTION', + reason?.trim() || "Customer rejected the price estimate.", + "REJECTION", ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); return this.bookingsService.findById(updated!.id); } @@ -513,32 +583,44 @@ export class BookingTransitionService { fileKey: string; label: string; required: boolean; - uploadedBy: 'customer' | 'gl'; + uploadedBy: "customer" | "gl"; settingCode: string; file: { id: string; name: string; url: string } | null; - reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null; + reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null; note: string | null; }>; allApproved: boolean; + phase?: string | null; + milestones?: unknown[]; + nextAction?: unknown; + dutyRequired?: boolean | null; + roHold?: boolean; + roHoldReason?: string | null; + vesselDepartureDate?: string | null; + operationReady?: boolean; }> { const booking = await this.bookingsService.findById(bookingId); + if (this.isPhasedGeneralCustoms(booking)) { + return this.bookingClearanceService.getClearanceView(bookingId); + } const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking); - const files = await this.filesService.findByResource(bookingId, 'bookings'); + const files = await this.filesService.findByResource(bookingId, "bookings"); const fileByCode = new Map(files.map((f) => [f.code, f])); - const reviews = await this.bookingsRepository.findDocumentReviews(bookingId); + const reviews = + await this.bookingsRepository.findDocumentReviews(bookingId); const reviewByKey = new Map( reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]), ); const documents: Awaited< - ReturnType - >['documents'] = []; + ReturnType + >["documents"] = []; const pushSetting = async ( code: string | null, - uploadedBy: 'customer' | 'gl', + uploadedBy: "customer" | "gl", ) => { if (!code) return; let setting; @@ -556,28 +638,26 @@ export class BookingTransitionService { required: field.isRequired, uploadedBy, settingCode: code, - file: file - ? { id: file.id, name: file.name, url: file.url } - : null, + file: file ? { id: file.id, name: file.name, url: file.url } : null, reviewStatus: review?.status ?? null, note: review?.note ?? null, }); } }; - await pushSetting(inputCode, 'customer'); - await pushSetting(outputCode, 'gl'); + await pushSetting(inputCode, "customer"); + await pushSetting(outputCode, "gl"); // Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set. for (const f of files) { - if (!f.code?.startsWith('custom_')) continue; + if (!f.code?.startsWith("custom_")) continue; const review = reviewByKey.get(`custom:${f.code}`) ?? null; documents.push({ fileKey: f.code, label: f.name, required: false, - uploadedBy: 'customer', - settingCode: 'custom', + uploadedBy: "customer", + settingCode: "custom", file: { id: f.id, name: f.name, url: f.url }, reviewStatus: review?.status ?? null, note: review?.note ?? null, @@ -611,13 +691,15 @@ export class BookingTransitionService { } const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return true; - const reviews = await this.bookingsRepository.findDocumentReviews(booking.id); + const reviews = await this.bookingsRepository.findDocumentReviews( + booking.id, + ); return required.every((field) => reviews.some( (r) => r.settingCode === inputCode && r.fileKey === field.fileKey && - r.status === 'APPROVED', + r.status === "APPROVED", ), ); } @@ -632,33 +714,38 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, [ + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + ]); const { inputCode } = clearanceCodesForBooking(booking); if (!inputCode) { - throw new BadRequestException('This booking has no document-clearance step'); + throw new BadRequestException( + "This booking has no document-clearance step", + ); } if (files.length === 0) { - throw new BadRequestException('No documents uploaded'); + throw new BadRequestException("No documents uploaded"); } // First submission (nothing in review yet): every required input field must // be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer // is only fixing queried/pending docs, so the already-uploaded required docs // stay in place and we don't re-gate on the full required set. - if (booking.status === 'AWAITING_DOCUMENTS') { + if (booking.status === "AWAITING_DOCUMENTS") { await this.assertRequiredInputsPresent(bookingId, inputCode, files); } for (const file of files) { const record = await this.filesService.upsertByCode({ resourceId: bookingId, - resource: 'bookings', + resource: "bookings", code: file.fieldname, file, }); // Ad-hoc docs (custom_*) are not part of the required gate; still tracked. - const settingCode = file.fieldname.startsWith('custom_') - ? 'custom' + const settingCode = file.fieldname.startsWith("custom_") + ? "custom" : inputCode; await this.bookingsRepository.upsertDocumentReviewPending({ bookingId, @@ -669,9 +756,23 @@ export class BookingTransitionService { } await this.bookingsRepository.update(bookingId, { - status: 'DOCUMENTS_UNDER_REVIEW', + status: "DOCUMENTS_UNDER_REVIEW", } as never); - return this.bookingsService.findById(bookingId); + + if (this.isPhasedGeneralCustoms(booking)) { + await this.workflowService.onCustomerDocsUploadedForBooking( + bookingId, + booking.tradeDirection ?? 'IMPORT', + ); + await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtReview, + } as never); + } + + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.clearanceDocsUploadedToStaff(fresh); + return fresh; } /** @@ -694,7 +795,10 @@ export class BookingTransitionService { const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return; - const existing = await this.filesService.findByResource(bookingId, 'bookings'); + const existing = await this.filesService.findByResource( + bookingId, + "bookings", + ); const presentKeys = new Set([ ...existing.map((f) => f.code), ...files.map((f) => f.fieldname), @@ -702,7 +806,7 @@ export class BookingTransitionService { const missing = required.filter((f) => !presentKeys.has(f.fileKey)); if (missing.length > 0) { - const labels = missing.map((f) => f.fileLabel).join(', '); + const labels = missing.map((f) => f.fileLabel).join(", "); throw new BadRequestException( `Please upload all required documents before submitting: ${labels}`, ); @@ -713,22 +817,36 @@ export class BookingTransitionService { async reviewDocument( bookingId: string, fileKey: string, - status: 'APPROVED' | 'QUERIED', + status: "APPROVED" | "QUERIED", staffId: string, note?: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); const { inputCode, outputCode } = clearanceCodesForBooking(booking); - const existing = await this.bookingsRepository.findDocumentReviews(bookingId); + const existing = + await this.bookingsRepository.findDocumentReviews(bookingId); const match = existing.find((r) => r.fileKey === fileKey); const settingCode = match?.settingCode ?? - (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom')); + (fileKey.startsWith("custom_") + ? "custom" + : (inputCode ?? outputCode ?? "custom")); - if (status === 'QUERIED' && !note?.trim()) { - throw new BadRequestException('A note is required when querying a document'); + if (status === "QUERIED" && !note?.trim()) { + throw new BadRequestException( + "A note is required when querying a document", + ); + } + if ( + status === 'QUERIED' && + this.isPhasedGeneralCustoms(booking) && + booking.preClearanceFinalizedAt + ) { + throw new BadRequestException( + 'Customer documents cannot be queried after pre-clearance is finalized.', + ); } await this.bookingsRepository.setDocumentReviewStatus( @@ -739,15 +857,40 @@ export class BookingTransitionService { staffId, note, ); - if (status === 'QUERIED') { + if (status === "QUERIED") { await this.bookingsRepository.createReviewNote( bookingId, `Document "${fileKey}" queried: ${note}`, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", staffId, ); + if (this.isPhasedGeneralCustoms(booking)) { + await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtReview, + } as never); + } } - return this.bookingsService.findById(bookingId); + + const updated = await this.bookingsService.findById(bookingId); + if (status === "QUERIED") { + this.notifier.documentQueried(updated, fileKey, note ?? ''); + } + if (this.isPhasedGeneralCustoms(updated)) { + const allApproved = await this.isClearanceFullyApproved(updated); + if (allApproved) { + await this.workflowService.onAllDocsApprovedForBooking(bookingId); + const phase = + updated.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlDjCollection + : ContractDocPhase.GlEtOutput; + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: phase, + } as never); + } + } + + return updated; } /** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */ @@ -756,18 +899,20 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); const { outputCode } = clearanceCodesForBooking(booking); if (!outputCode) { - throw new BadRequestException('This booking has no customs output documents'); + throw new BadRequestException( + "This booking has no customs output documents", + ); } if (files.length === 0) { - throw new BadRequestException('No documents uploaded'); + throw new BadRequestException("No documents uploaded"); } for (const file of files) { await this.filesService.upsertByCode({ resourceId: bookingId, - resource: 'bookings', + resource: "bookings", code: file.fieldname, file, }); @@ -781,19 +926,28 @@ export class BookingTransitionService { */ async finalizeClearance(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); + if (this.isPhasedGeneralCustoms(booking)) { + throw new BadRequestException( + 'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.', + ); + } assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); const approved = await this.isClearanceFullyApproved(booking); if (!approved) { throw new BadRequestException( - 'All required documents must be approved before clearance can be finalized', + "All required documents must be approved before clearance can be finalized", ); } const { outputCode } = clearanceCodesForBooking(booking); if (outputCode) { - const setting = await this.fileUploadSettingsService.getByCode(outputCode); - const files = await this.filesService.findByResource(bookingId, 'bookings'); + const setting = + await this.fileUploadSettingsService.getByCode(outputCode); + const files = await this.filesService.findByResource( + bookingId, + "bookings", + ); const uploaded = new Set(files.map((f) => f.code)); const missing = (setting.fields ?? []).filter( (f) => f.isRequired && !uploaded.has(f.fileKey), @@ -802,15 +956,17 @@ export class BookingTransitionService { throw new BadRequestException( `Upload all required customs output documents first: ${missing .map((m) => m.fileLabel) - .join(', ')}`, + .join(", ")}`, ); } } await this.bookingsRepository.update(bookingId, { - status: 'CLEARANCE_READY', + status: "CLEARANCE_READY", } as never); - return this.bookingsService.findById(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.clearanceReady(fresh); + return fresh; } /** @@ -827,11 +983,14 @@ export class BookingTransitionService { scheduledDate: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']); + assertBookingStatus(booking, [ + "CLEARANCE_READY", + "OPERATION_CHANGES_REQUESTED", + ]); const date = new Date(scheduledDate); if (Number.isNaN(date.getTime())) { - throw new BadRequestException('A valid schedule date is required'); + throw new BadRequestException("A valid schedule date is required"); } // The binding shipment day must have at least one OPEN departure on the @@ -844,15 +1003,17 @@ export class BookingTransitionService { ); if (!hasDeparture) { throw new BadRequestException( - 'No departures available on the selected day for this route', + "No departures available on the selected day for this route", ); } await this.bookingsRepository.update(bookingId, { - status: 'OPERATION_REQUEST_PENDING', + status: "OPERATION_REQUEST_PENDING", scheduledDate: date, } as never); - return this.bookingsService.findById(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.operationRequestedToStaff(fresh); + return fresh; } /** @@ -865,29 +1026,31 @@ export class BookingTransitionService { */ async reviewOperationRequest( bookingId: string, - decision: 'ACCEPT' | 'REQUEST_CHANGES', + decision: "ACCEPT" | "REQUEST_CHANGES", actorId: string, options: { note?: string } = {}, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']); + assertBookingStatus(booking, ["OPERATION_REQUEST_PENDING"]); - if (decision === 'REQUEST_CHANGES') { + if (decision === "REQUEST_CHANGES") { if (!options.note?.trim()) { throw new BadRequestException( - 'A note is required when requesting changes', + "A note is required when requesting changes", ); } await this.bookingsRepository.createReviewNote( bookingId, options.note, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", actorId, ); await this.bookingsRepository.update(bookingId, { - status: 'OPERATION_CHANGES_REQUESTED', + status: "OPERATION_CHANGES_REQUESTED", } as never); - return this.bookingsService.findById(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.operationChangesRequested(fresh, options.note); + return fresh; } // ACCEPT — enter the batch holding pool. @@ -907,54 +1070,156 @@ export class BookingTransitionService { private async acceptOperationRequest(booking: Booking): Promise { const now = new Date(); + // Export is FCFS: fail the accept up-front (409) when no export train on the + // booking's day still has capacity — nothing below runs and the request stays + // pending for staff to move/decline. (For a consolidated pair this is a rough + // solo pre-check; the real combined-capacity reservation happens after the + // booking is FULLY_EXECUTED, once both partners are ready.) + const isExportTrain = + booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType); + if (isExportTrain) { + await this.bookingBatchService.pickExportSchedule(booking); + } + + const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); + this.logger.log( + `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`, + ); + await this.invoiceService.updateStatus( + invoice.id, + Freight.InvoiceStatus.Pending, + ); if (isRoadService(booking.serviceType)) { await this.bookingsRepository.update(booking.id, { - status: 'ROAD_DISPATCH_PENDING', + status: "ROAD_DISPATCH_PENDING", fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); - return this.bookingsService.findById(booking.id); + const roadFresh = await this.bookingsService.findById(booking.id); + this.notifier.operationAccepted(roadFresh); + return roadFresh; } await this.bookingsRepository.update(booking.id, { - status: 'FULLY_EXECUTED', + status: "FULLY_EXECUTED", fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); - if (booking.scheduledDate) { - this.bookingBatchService.enqueueRouteDayProcessing( - booking.originYardId, - booking.destinationYardId, - eatDay(new Date(booking.scheduledDate)), - ); + if (isExportTrain) { + // FCFS: reserve the slot and send the payment notification immediately; + // paid → auto-allocated by the settle/paid pipeline. Consolidated bookings + // only reserve once both partners are FULLY_EXECUTED (handled inside). + const fresh = await this.bookingsService.findById(booking.id); + try { + await this.bookingBatchService.acceptExportBooking(fresh); + } catch (err) { + // The status update above already committed. Without compensation the + // client gets an error for a booking that reads as accepted after a + // refresh — half-applied state. Put the request back so staff can retry. + await this.bookingsRepository.update(booking.id, { + status: "OPERATION_REQUEST_PENDING", + fullyExecutedAt: null, + lockedAt: booking.lockedAt ?? null, + } as never); + this.logger.warn( + `Export accept failed post-commit for ${booking.reference}:${booking.id}; reverted to OPERATION_REQUEST_PENDING: ${(err as Error).message}`, + ); + throw err; + } } - return this.bookingsService.findById(booking.id); + // IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the + // batch runs after the window closes + staff document review, never at accept + // time. (Legacy pre-migration schedules with no window phase are still served + // by the periodic legacy fill.) + const trainFresh = await this.bookingsService.findById(booking.id); + this.notifier.operationAccepted(trainFresh); + return trainFresh; } - async enrichBookingResponse(booking: Booking): Promise { - const note = await this.bookingsRepository.findLatestReviewNote( - booking.id, - 'CHANGES_REQUESTED', - ); - const summary = - booking.contractSummary ?? - this.contractService.buildContractSummary(booking); - const nextPending = - booking.status === 'PENDING_APPROVAL' || - booking.status === 'APPROVED_PENDING_SIGNATURE' - ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) - : null; - const nextStep = computeNextStep(booking, nextPending); + async enrichBookingResponse(booking: Booking): Promise< + Booking & { + latestChangeRequestNote?: string | null; + contractSummary?: string | null; + nextStep: BookingNextStep | null; + activeBatchOffer?: { + offeredWagons: number; + totalWagons: number; + offeredAmount: number; + paymentDeadline: Date; + } | null; + /** Flat list of physical container numbers on this booking (for the + * customer truck-assignment container picker). */ + containerNumbers: string[]; + } + > { + // This enrichment runs AFTER the transition has committed. A failure here + // must never 500 the response — the client would report "failed" for a + // transition that actually succeeded (visible only after a refresh). + // Degrade each fragile field to null instead. + let note: Awaited< + ReturnType + > = null; + try { + note = await this.bookingsRepository.findLatestReviewNote( + booking.id, + "CHANGES_REQUESTED", + ); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: review-note lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let summary: string | null = booking.contractSummary ?? null; + try { + summary = + booking.contractSummary ?? + this.contractService.buildContractSummary(booking); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: contract summary failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let nextStep: BookingNextStep | null = null; + try { + const nextPending = + booking.status === "PENDING_APPROVAL" || + booking.status === "APPROVED_PENDING_SIGNATURE" + ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) + : null; + nextStep = computeNextStep(booking, nextPending); + } catch (err) { + this.logger.warn( + `enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } + let activeBatchOffer: Awaited< + ReturnType + > = null; + try { + activeBatchOffer = + booking.status === "SELECTED_FOR_BATCH" + ? await this.bookingBatchService.getOpenOfferSummary(booking.id) + : null; + } catch (err) { + this.logger.warn( + `enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`, + ); + } + // Physical container numbers entered at booking time (booking_container + // units), flattened for the customer truck-assignment container picker. + const containerNumbers = (booking.bookingContainers ?? []) + .flatMap((bc) => bc.units ?? []) + .map((unit) => unit.containerNumber) + .filter((n): n is string => Boolean(n)); + return { ...booking, latestChangeRequestNote: note?.note ?? null, contractSummary: summary, nextStep, + activeBatchOffer, + containerNumbers, }; } -} \ No newline at end of file +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 0f413199b..79d94f3de 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, + ForbiddenException, Get, HttpCode, Param, @@ -12,14 +13,17 @@ import { Request, Res, UnauthorizedException, + UploadedFile, UploadedFiles, + UseGuards, UseInterceptors, } from '@nestjs/common'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { BookingStaff } from '../../common/booking-guards'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { BookingStaff, BookingView } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; -import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, @@ -27,12 +31,17 @@ import { ApiOkResponse, ApiOperation, ApiTags, -} from '@nestjs/swagger'; -import type { Response } from 'express'; +} from "@nestjs/swagger"; +import type { Response } from "express"; import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingTransitionService } from './booking-transition.service'; +import { BookingClearanceService } from '../contracts/booking-clearance.service'; +import { + AdviseContractDutyDto, + RoAmendmentDto, +} from '../contracts/dto/phased-clearance.dto'; import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingsService } from './bookings.service'; import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; @@ -54,16 +63,26 @@ import { StaffRejectDto, } from './dto/request-changes.dto'; import { ContractViewDto } from './dto/contract-view.dto'; +import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; +import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; +import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto'; +import { CustomerTruckService } from './customer-truck.service'; +import { GenerateGrnDto } from './dto/generate-grn.dto'; +import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { type AuthUserPayload, resolveAuthUserId, -} from '../../common/resolve-auth-user-id'; -import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; +} from "../../common/resolve-auth-user-id"; +import { + assertFreightPermission, + hasFreightPermission, +} from "../../common/freight-permission.util"; -@ApiTags('bookings') -@Controller('bookings') +@ApiTags("bookings") +@Controller("bookings") @ApiBearerAuth() export class BookingsController { constructor( @@ -72,12 +91,15 @@ export class BookingsController { private readonly pricingService: BookingPricingService, private readonly transitionService: BookingTransitionService, private readonly contractService: BookingContractService, + private readonly bookingClearanceService: BookingClearanceService, + private readonly customerTruckService: CustomerTruckService, + private readonly containerReceiptService: ContainerReceiptService, ) {} @Post() @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Create a new freight booking (DRAFT)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Create a new freight booking (DRAFT)" }) @ApiBody({ type: CreateBookingDto }) async create( @Body() dto: CreateBookingDto, @@ -87,15 +109,24 @@ export class BookingsController { if (dto.isGovernment) { assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); } - const result = await this.bookingsService.create(dto, files ?? [], user?.id); + const result = await this.bookingsService.create( + dto, + files ?? [], + user?.id, + ); // Staff-created commercial bookings skip the draft stage: auto generate-price + submit. - const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); + const isStaff = hasFreightPermission( + user, + FREIGHT_PERMS.bookings.staffAccept, + ); if (isStaff && !dto.isGovernment) { try { await this.pricingService.generatePrice(result.booking.id); await this.transitionService.submit(result.booking.id); - const submitted = await this.bookingsService.findById(result.booking.id); + const submitted = await this.bookingsService.findById( + result.booking.id, + ); return { booking: submitted, warnings: result.warnings }; } catch { // If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually. @@ -105,16 +136,16 @@ export class BookingsController { return result; } - @Patch(':id') + @Patch(":id") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: 'Update booking', - description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.', + summary: "Update booking", + description: "Allowed when status is DRAFT or CHANGES_REQUESTED.", }) @ApiBody({ type: UpdateBookingDto }) update( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateBookingDto, @UploadedFiles() files: Express.Multer.File[], ) { @@ -122,7 +153,7 @@ export class BookingsController { } @Get() - @ApiOperation({ summary: 'List freight bookings (paginated)' }) + @ApiOperation({ summary: "List freight bookings (paginated)" }) async findAll( @Query() filter: FilterBookingDto, @CurrentUser() user: TCurrentUser, @@ -139,7 +170,7 @@ export class BookingsController { return this.bookingsService.findClearanceQueue(filter); } const userId = user?.id; - if (!userId) throw new UnauthorizedException('Authentication required'); + if (!userId) throw new UnauthorizedException("Authentication required"); const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); // No linked company yet → no bookings to show (avoids leaking all bookings). @@ -165,27 +196,31 @@ export class BookingsController { return this.bookingsService.findAll(filter, companyId); } - @Get('by-company/:companyId/customer-view') - @ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' }) + @Get("by-company/:companyId/customer-view") + @BookingView() + @ApiOperation({ + summary: "List bookings for a company (customer-view shape, backoffice)", + }) findByCompanyCustomerView( - @Param('companyId', ParseUUIDPipe) companyId: string, + @Param("companyId", ParseUUIDPipe) companyId: string, ) { return this.bookingsService.findCustomerBookings(companyId); } - @Get('list-summary') - @ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' }) + @Get("list-summary") + @BookingView() + @ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" }) @ApiOkResponse({ type: BookingListSummaryDto }) findListSummary(@Query() filter: FilterBookingDto) { return this.bookingsService.getListSummary(filter); } - @Get('my') + @Get("my") @ApiOperation({ summary: "List the current customer's bookings ready for payment", description: - 'Bookings owned by the authenticated user\'s company that are payable ' + - '(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.', + "Bookings owned by the authenticated user's company that are payable " + + "(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.", }) findMyPayable( @CurrentUser() user: AuthUserPayload, @@ -194,32 +229,33 @@ export class BookingsController { return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter); } - @Get('queues/:queue') + @Get("queues/:queue") + @BookingView() @ApiOperation({ - summary: 'List bookings for a dashboard queue', - description: 'Queues: intake, approval, signatures, marketing, finance', + summary: "List bookings for a dashboard queue", + description: "Queues: intake, approval, signatures, marketing, finance", }) findQueue( - @Param('queue') queue: string, + @Param("queue") queue: string, @Query() filter: FilterBookingDto, - @Query('excludeBulk') excludeBulk?: string, + @Query("excludeBulk") excludeBulk?: string, ) { return this.bookingsService.findQueue(queue, filter, { - excludeBulk: excludeBulk === 'true', + excludeBulk: excludeBulk === "true", }); } - @Get('reference-data') - @ApiOperation({ summary: 'Booking form catalog' }) + @Get("reference-data") + @ApiOperation({ summary: "Booking form catalog" }) @ApiOkResponse({ type: BookingReferenceDataDto }) getReferenceData(): Promise { return this.bookingReferenceDataService.getReferenceData(); } - @Get('by-reference/:reference') - @ApiOperation({ summary: 'Get booking by reference' }) + @Get("by-reference/:reference") + @ApiOperation({ summary: "Get booking by reference" }) async findByReference( - @Param('reference') reference: string, + @Param("reference") reference: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findByReference(reference); @@ -233,10 +269,10 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id') - @ApiOperation({ summary: 'Get booking by ID' }) + @Get(":id") + @ApiOperation({ summary: "Get booking by ID" }) async findOne( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -254,15 +290,178 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/customer-truck-assignment') + @ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' }) + async assignCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CustomerTruckAssignmentDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + const assigned = await this.bookingsService.assignCustomerTruck(id, dto); + return this.transitionService.enrichBookingResponse(assigned); + } + + @Get(':id/customer-truck-assignment/freight-order') + @ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' }) + async customerTruckFreightOrder( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + const { filename, buffer } = + await this.bookingsService.customerTruckFreightOrderCopies(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(buffer); + } + + @Get(':id/customer-trucks') + @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) + async listCustomerTrucks( + @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.listTrucks(id); + } + + @Post(':id/customer-trucks') + @ApiOperation({ summary: 'Add a customer self-haul truck carrying 1–2 of the booking containers' }) + async addCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AddCustomerTruckDto, + @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.addTruck(id, dto); + } + + @Patch(':id/customer-trucks/:assignmentId') + @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) + async updateCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Body() dto: AddCustomerTruckDto, + @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.updateTruck(id, assignmentId, dto); + } + + @Delete(':id/customer-trucks/:assignmentId') + @ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' }) + async removeCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: 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.removeTruck(id, assignmentId); + } + + @Get(':id/customer-trucks/loadable-containers') + @ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' }) + async loadableContainers( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.getLoadableContainers(id); + } + + @Post(':id/customer-trucks/:assignmentId/load') + @ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' }) + async loadCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Body() dto: LoadCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can load a truck'); + } + return this.customerTruckService.loadTruck(id, assignmentId, dto); + } + + @Post(':id/customer-trucks/:assignmentId/depart') + @ApiOperation({ + summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', + }) + async departCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Body() dto: DepartCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + // Weighing + registering the load on exit is a warehouse/gate staff action. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can register a truck departure'); + } + return this.customerTruckService.departTruck(id, assignmentId, dto); + } + + @Get(':id/received-pending-grn') + @ApiOperation({ summary: 'Containers received into port but not yet on a GRN' }) + async receivedPendingGrn( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + // GRN is a warehouse-staff action — no customer access. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + } + return this.containerReceiptService.listReceivedPendingGrn(id); + } + + @Post(':id/generate-grn') + @ApiOperation({ + summary: + 'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch', + }) + async generateGrn( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: GenerateGrnDto, + @CurrentUser() user: TCurrentUser, + ) { + // GRN is a warehouse-staff action — no customer access. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + } + return this.containerReceiptService.generateGrn(id, dto.containerNumbers); + } + @Get(':id/tracking') @ApiOperation({ - summary: 'Shipment tracking timeline for a booking', + summary: "Shipment tracking timeline for a booking", description: "Returns the booking's consignment (once dispatched) and its ordered " + - 'tracking events. Scoped to the customer\'s own company.', + "tracking events. Scoped to the customer's own company.", }) async findTracking( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -276,66 +475,66 @@ export class BookingsController { return this.bookingsService.getBookingTracking(id); } - @Delete(':id') + @Delete(":id") @HttpCode(204) - @ApiOperation({ summary: 'Soft-delete DRAFT booking' }) - remove(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Soft-delete DRAFT booking" }) + remove(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.remove(id); } - @Post(':id/documents') + @Post(":id/documents") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" }) async uploadDocuments( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.bookingsService.uploadDocuments(id, files ?? []); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/generate-price') + @Post(":id/generate-price") @ApiOperation({ - summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)', + summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)", description: - 'Computes and stores a price preview on the booking. Does not create rate snapshots.', + "Computes and stores a price preview on the booking. Does not create rate snapshots.", }) @ApiOkResponse({ type: GeneratePriceResponseDto }) - generatePrice(@Param('id', ParseUUIDPipe) id: string) { + generatePrice(@Param("id", ParseUUIDPipe) id: string) { return this.pricingService.generatePrice(id); } - @Post(':id/submit') + @Post(":id/submit") @ApiOperation({ - summary: 'Customer submit booking', + summary: "Customer submit booking", description: - 'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.', + "Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.", }) @ApiOkResponse({ type: SubmitBookingResponseDto }) - submit(@Param('id', ParseUUIDPipe) id: string) { + submit(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.submit(id); } - @Post(':id/confirm-submit') + @Post(":id/confirm-submit") @ApiOperation({ - summary: 'Confirm submit after price change', + summary: "Confirm submit after price change", description: - 'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.', + "Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.", }) @ApiOkResponse({ type: SubmitBookingResponseDto }) - confirmSubmit(@Param('id', ParseUUIDPipe) id: string) { + confirmSubmit(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.confirmSubmit(id); } - @Post(':id/reject') + @Post(":id/reject") @ApiOperation({ - summary: 'Customer reject price estimate', + summary: "Customer reject price estimate", description: - 'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.', + "Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.", }) async reject( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RejectBookingDto, ) { const booking = await this.transitionService.reject(id, dto.reason); @@ -344,22 +543,37 @@ export class BookingsController { // ── Document clearance (post counter-sign) ──────────────────────────────── + @Get('clearance/et-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' }) + getBookingEtClearanceQueue() { + return this.bookingClearanceService.etQueue(); + } + + @Get('clearance/dj-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL DJ queue — general customs bookings awaiting DJ action' }) + getBookingDjClearanceQueue() { + return this.bookingClearanceService.djQueue(); + } + @Get(':id/clearance') @ApiOperation({ - summary: 'Document-clearance grid (required docs + upload + GL review status)', + summary: + "Document-clearance grid (required docs + upload + GL review status)", }) - getClearance(@Param('id', ParseUUIDPipe) id: string) { + getClearance(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.getClearanceView(id); } - @Post(':id/clearance/documents') + @Post(":id/clearance/documents") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: 'Customer uploads clearance documents (fieldname = document key)', + summary: "Customer uploads clearance documents (fieldname = document key)", }) async submitClearanceDocuments( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.transitionService.submitClearanceDocuments( @@ -369,14 +583,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/proceed') + @Post(":id/clearance/proceed") @ApiOperation({ summary: - 'Customer requests operation with a schedule day ' + - '(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)', + "Customer requests operation with a schedule day " + + "(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)", }) async proceedToOperation( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestOperationDto, ) { const booking = await this.transitionService.requestOperation( @@ -386,15 +600,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operation/review') + @Post(":id/operation/review") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: - 'Operations reviews an operation request: ACCEPT (→ batch pool), ' + - 'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)', + "Operations reviews an operation request: ACCEPT (→ batch pool), " + + "REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)", }) async reviewOperationRequest( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: OperationReviewDto, @CurrentUser() user: AuthUserPayload, ) { @@ -407,11 +621,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/review') + @Post(":id/clearance/review") @BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments) - @ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' }) + @ApiOperation({ + summary: "GL reviews a clearance document (Approve | Query)", + }) async reviewClearanceDocument( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: ReviewDocumentDto, @CurrentUser() user: AuthUserPayload, ) { @@ -425,13 +641,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/output-documents') + @Post(":id/clearance/output-documents") @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" }) async uploadClearanceOutput( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.transitionService.uploadClearanceOutputDocuments( @@ -441,21 +657,178 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/finalize') + @Post(":id/clearance/finalize") @BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance) @ApiOperation({ - summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY', + summary: + "GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", }) - async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { + async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.finalizeClearance(id); return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/clearance/declaration') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads customs declaration on booking (GENERAL customs)' }) + async uploadBookingDeclaration( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.uploadDeclaration( + id, + files ?? [], + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/duty') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise) + @UseInterceptors(FileInterceptor('attachment')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET sets duty/tax on booking with notice attachment' }) + async adviseBookingDuty( + @Param('id', ParseUUIDPipe) id: string, + @Body('dutyRequired') dutyRequiredRaw: string, + @Body('amount') amountRaw: string | undefined, + @Body('currency') currency: string | undefined, + @Body('declarationSerial') declarationSerial: string | undefined, + @UploadedFile() attachment: Express.Multer.File | undefined, + @CurrentUser() user: TCurrentUser, + ) { + const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1'; + const dto: AdviseContractDutyDto = { + dutyRequired, + amount: + amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, + currency: currency ?? 'ETB', + declarationSerial, + }; + const booking = await this.bookingClearanceService.adviseDuty( + id, + dto, + resolveAuthUserId(user), + attachment, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/finalize-pre-clearance') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' }) + async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.bookingClearanceService.finalizePreClearance(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/duty-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' }) + async uploadBookingDutySlip( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + ) { + const booking = await this.bookingClearanceService.uploadDutySlip(id, file); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/transit-permit') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + async uploadBookingTransitPermit( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.uploadTransitPermit( + id, + files ?? [], + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/delivery-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + async uploadBookingDeliveryOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.uploadDeliveryOrder( + id, + file, + resolveAuthUserId(user), + vesselDepartureDate, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/release-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + async uploadBookingReleaseOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string, + @CurrentUser() user: TCurrentUser, + ) { + const result = await this.bookingClearanceService.uploadReleaseOrder( + id, + file, + vesselDepartureDate, + resolveAuthUserId(user), + ); + return { + ...this.transitionService.enrichBookingResponse(result.booking), + hold: result.hold, + holdReason: result.holdReason, + }; + } + + @Post(':id/clearance/ro-amendment') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + async requestBookingRoAmendment( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RoAmendmentDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.requestRoAmendment( + id, + dto.note, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/export-release') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + async confirmBookingExportRelease( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.confirmExportRelease( + id, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/staff/request-changes') @BookingStaff(FREIGHT_PERMS.bookings.requestChanges) - @ApiOperation({ summary: 'Staff return booking for customer updates' }) + @ApiOperation({ summary: "Staff return booking for customer updates" }) async requestChanges( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestChangesDto, @CurrentUser() user: AuthUserPayload, ) { @@ -467,14 +840,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/accept') + @Post(":id/staff/accept") @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @ApiOperation({ summary: - 'Staff accept intake → set contract validity window + start approval chain', + "Staff accept intake → set contract validity window + start approval chain", }) async acceptIntake( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: AcceptIntakeDto, @CurrentUser() user: AuthUserPayload, ) { @@ -486,11 +859,11 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/reject') + @Post(":id/staff/reject") @BookingStaff(FREIGHT_PERMS.bookings.reject) - @ApiOperation({ summary: 'Staff final reject' }) + @ApiOperation({ summary: "Staff final reject" }) async staffReject( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: StaffRejectDto, @CurrentUser() user: AuthUserPayload, ) { @@ -502,11 +875,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/government-expedite') + @Post(":id/government-expedite") @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) - @ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' }) + @ApiOperation({ + summary: "Expedite government booking to PAID / ELIGIBLE for scheduling", + }) async governmentExpedite( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingsService.governmentExpedite( @@ -516,16 +891,16 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/approval-steps/:stepId/approve') + @Post(":id/approval-steps/:stepId/approve") @BookingStaff([ FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.approveDirector, FREIGHT_PERMS.bookings.approveCeo, ]) - @ApiOperation({ summary: 'Approve one approval step in sequence' }) + @ApiOperation({ summary: "Approve one approval step in sequence" }) async approveStep( - @Param('id', ParseUUIDPipe) id: string, - @Param('stepId', ParseUUIDPipe) stepId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("stepId", ParseUUIDPipe) stepId: string, @Body() dto: ApproveStepDto, @CurrentUser() user: TCurrentUser, ) { @@ -539,12 +914,12 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/approval-steps/:stepId/reject') + @Post(":id/approval-steps/:stepId/reject") @BookingStaff(FREIGHT_PERMS.bookings.rejectApproval) - @ApiOperation({ summary: 'Reject at approval step' }) + @ApiOperation({ summary: "Reject at approval step" }) async rejectStep( - @Param('id', ParseUUIDPipe) id: string, - @Param('stepId', ParseUUIDPipe) stepId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("stepId", ParseUUIDPipe) stepId: string, @Body() dto: RejectStepDto, @CurrentUser() user: AuthUserPayload, ) { @@ -557,56 +932,62 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/contract/generate') + @Post(":id/contract/generate") @BookingStaff(FREIGHT_PERMS.bookings.generateContract) - @ApiOperation({ summary: 'Generate contract PDF from template' }) - async generateContract(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Generate contract PDF from template" }) + async generateContract(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.contractService.generateContract(id); return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/contract/view') + @Get(":id/contract/view") @ApiOkResponse({ type: ContractViewDto }) - @ApiOperation({ summary: 'Contract HTML view for portal and backoffice' }) + @ApiOperation({ summary: "Contract HTML view for portal and backoffice" }) getContractView( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Request() req: { user?: { id?: string; sub?: string } }, ) { const userId = req.user?.id ?? req.user?.sub; return this.contractService.getContractView(id, userId); } - @Get(':id/contract/document') - @ApiOperation({ summary: 'Download contract PDF' }) + @Get(":id/contract/document") + @ApiOperation({ summary: "Download contract PDF" }) async downloadContractDocument( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Res() res: Response, ): Promise { const { stream, record } = await this.contractService.streamContract(id); - res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader("Content-Type", record.mimeType ?? "application/pdf"); res.setHeader( - 'Content-Disposition', + "Content-Disposition", `attachment; filename="${record.name}"`, ); stream.pipe(res); } - @Get(':id/contract') - @ApiOperation({ summary: 'Download contract file (alias)' }) + @Get(":id/contract") + @ApiOperation({ summary: "Download contract file (alias)" }) async downloadContract( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Res() res: Response, ): Promise { return this.downloadContractDocument(id, res); } - @Post(':id/contract/sign') - @ApiOperation({ summary: 'Apply digital signature (customer or staff)' }) + @Post(":id/contract/sign") + @UseGuards(JwtGuard) + @ApiOperation({ summary: "Apply digital signature (customer or staff)" }) async signContract( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, + @CurrentUser() user: TCurrentUser, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { + // Staff signature needs the sign permission; customer signs their own booking. + if (dto.role !== "CUSTOMER") { + assertFreightPermission(user, FREIGHT_PERMS.bookings.signStaff); + } const userId = req.user?.id ?? req.user?.sub; const booking = await this.contractService.signContract(id, dto, { signerUserId: userId, @@ -615,28 +996,28 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/contract/signatures') - @ApiOperation({ summary: 'List contract signatures' }) - getContractSignatures(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id/contract/signatures") + @ApiOperation({ summary: "List contract signatures" }) + getContractSignatures(@Param("id", ParseUUIDPipe) id: string) { return this.contractService.getSignatures(id); } - @Get(':id/summary') - @ApiOperation({ summary: 'Contract summary string for dashboard' }) - getSummary(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id/summary") + @ApiOperation({ summary: "Contract summary string for dashboard" }) + getSummary(@Param("id", ParseUUIDPipe) id: string) { return this.contractService.getSummary(id); } - @Post(':id/customer/sign') + @Post(":id/customer/sign") @ApiOperation({ - summary: 'Customer digital signature (deprecated — use POST contract/sign)', + summary: "Customer digital signature (deprecated — use POST contract/sign)", }) async customerSign( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { - const payload: SignContractDto = { ...dto, role: 'CUSTOMER' }; + const payload: SignContractDto = { ...dto, role: "CUSTOMER" }; const booking = await this.contractService.signContract(id, payload, { signerUserId: req.user?.id ?? req.user?.sub, ipAddress: req.ip, @@ -644,20 +1025,21 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/marketing/approve') + @Post(":id/marketing/approve") @BookingStaff(FREIGHT_PERMS.bookings.signStaff) @ApiOperation({ - summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)', + summary: + "Staff contract signature and fully execute (use contract/sign STAFF preferred)", }) async marketingApprove( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @CurrentUser() user: AuthUserPayload, @Request() req: { ip?: string }, ) { const payload: SignContractDto = { ...dto, - role: 'STAFF', + role: "STAFF", }; const booking = await this.contractService.signContract(id, payload, { signerUserId: resolveAuthUserId(user), @@ -666,48 +1048,48 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operations/start-transit') + @Post(":id/operations/start-transit") @BookingStaff(FREIGHT_PERMS.bookings.operations) - @ApiOperation({ summary: 'Mark in transit' }) - async startTransit(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Mark in transit" }) + async startTransit(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.startTransit(id); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operations/complete') + @Post(":id/operations/complete") @BookingStaff(FREIGHT_PERMS.bookings.operations) - @ApiOperation({ summary: 'Mark completed' }) - async complete(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Mark completed" }) + async complete(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.complete(id); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/cancel') + @Post(":id/cancel") @BookingStaff(FREIGHT_PERMS.bookings.cancel) - @ApiOperation({ summary: 'Cancel booking' }) + @ApiOperation({ summary: "Cancel booking" }) async cancel( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelBookingDto, ) { const booking = await this.transitionService.cancel(id, dto.reason); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/consolidation') - @ApiOperation({ summary: 'Request freight consolidation' }) - requestConsolidation(@Param('id', ParseUUIDPipe) id: string) { + @Post(":id/consolidation") + @ApiOperation({ summary: "Request freight consolidation" }) + requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.requestConsolidation(id); } - @Delete(':id/consolidation') - @ApiOperation({ summary: 'Remove consolidation pairing' }) - removeConsolidation(@Param('id', ParseUUIDPipe) id: string) { + @Delete(":id/consolidation") + @ApiOperation({ summary: "Remove consolidation pairing" }) + removeConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.removeConsolidation(id); } - @Get(':id/consolidation') - @ApiOperation({ summary: 'Get consolidation details' }) - getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id/consolidation") + @ApiOperation({ summary: "Get consolidation details" }) + getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.getConsolidationDetails(id); } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 5d7e3b2c9..48c5b628b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,7 +1,7 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; +import { Module, forwardRef } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ExchangeModule, ExchangeOptions } from "@edr/api-common"; // import { CustomersModule } from '../customers/customers.module'; import { CompaniesModule } from '../companies/companies.module'; @@ -14,15 +14,19 @@ import { BillingModule } from '../billing/billing.module'; import { FirstMileModule } from '../first-mile/first-mile.module'; import { BookingContractService } from './booking-contract.service'; import { BookingInvoiceService } from './booking-invoice.service'; -import { BookingPaymentController } from './booking-payment.controller'; -import { BookingPaymentService } from './booking-payment.service'; +// import { BookingPaymentController } from './booking-payment.controller'; +// import { BookingPaymentService } from './booking-payment.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingReferenceDataService } from './booking-reference-data.service'; +import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import { BookingTransitionService } from './booking-transition.service'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { BookingsController } from './bookings.controller'; -import { PayController } from './pay.controller'; +// import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; +import { ContainerValidationService } from './container-validation.service'; import { BookingsService } from './bookings.service'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; @@ -32,13 +36,20 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingContractSignature } from './entities/booking-contract-signature.entity'; import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; -import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; +import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; +import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; +import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; +import { CustomerTruckService } from './customer-truck.service'; +import { ContainerReceiptService } from './container-receipt.service'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; -import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder'; -import { ContractRendererService } from '../../contracts/contract-renderer.service'; -import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; -import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; -import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; +import { ContractsModule } from '../contracts/contracts.module'; +import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; +import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder"; +import { ContractRendererService } from "../../contracts/contract-renderer.service"; +import { ContractTemplateResolver } from "../../contracts/contract-template.resolver"; +import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder"; +import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; +import { VehiclesModule } from "../vehicles/vehicles.module"; @Module({ imports: [ @@ -52,12 +63,19 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingReviewNote, BookingContractSignature, BookingContainerAllocation, + CustomerTruckAssignment, + CustomerTruckContainer, ]), BillingModule, + NotificationsModule, + NotificationInboxModule, forwardRef(() => FirstMileModule), forwardRef(() => TrainSchedulingModule), + forwardRef(() => ContractsModule), + forwardRef(() => ContractsModule), FilesModule, MinioModule, + VehiclesModule, CompaniesModule, // CustomersModule, RuleEngineModule, @@ -66,26 +84,39 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => - config.get('app.cbeExchange') ?? {}, + config.get("app.cbeExchange") ?? {}, }), ], - controllers: [BookingsController, PayController, BookingPaymentController], + controllers: [BookingsController], providers: [ BookingsService, BookingsRepository, ConsolidationService, + ContainerValidationService, BookingReferenceDataService, BookingPricingService, + BookingLifecycleNotifierService, BookingTransitionService, BookingContractService, BookingInvoiceService, - BookingPaymentService, ContractTemplateResolver, ContractViewModelBuilder, ContractPricingScheduleBuilder, ContractRendererService, ContractPdfService, + CustomerTruckAssignmentsRepository, + CustomerTruckService, + ContainerReceiptService, + ], + exports: [ + BookingsService, + BookingsRepository, + BookingPricingService, + BookingInvoiceService, + BookingLifecycleNotifierService, + ConsolidationService, + CustomerTruckService, + ContainerReceiptService, ], - exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService], }) -export class BookingsModule {} +export class BookingsModule { } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 57e811e34..fa11fc66c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { Contract } from '../contracts/entities/contract.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; @@ -35,6 +36,7 @@ export interface BookingListFilterOptions { serviceTypeId?: string; cargoTypeId?: string; freightType?: string; + bookingType?: string; tradeDirection?: string; paymentCurrency?: string; paymentStatus?: string; @@ -60,16 +62,23 @@ export class BookingsRepository extends BaseRepository { return this.repository.findOne({ where: { reference } }); } - /** Count bookings created in a specific year. */ - async countByYear(year: number): Promise { - const startDate = new Date(year, 0, 1); - const endDate = new Date(year + 1, 0, 1); - - return this.repository + /** + * Highest NNNNNN sequence already issued for `BK--…` references. + * Includes soft-deleted bookings so the next number clears references that + * still occupy the unique index. (A created-at count drifts below the issued + * sequence after any delete and then collides forever.) + */ + async maxReferenceSequence(year: number): Promise { + const row = await this.repository .createQueryBuilder('booking') - .where('booking.created_at >= :startDate', { startDate }) - .andWhere('booking.created_at < :endDate', { endDate }) - .getCount(); + .withDeleted() + .select( + "COALESCE(MAX(CAST(SUBSTRING(booking.reference FROM '[0-9]+$') AS int)), 0)", + 'max', + ) + .where('booking.reference LIKE :prefix', { prefix: `BK-${year}-%` }) + .getRawOne<{ max: string | number | null }>(); + return Number(row?.max ?? 0); } /** Find a booking by reference with files and relations. */ @@ -89,6 +98,7 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.bookingContainers', 'bc') .leftJoinAndSelect('bc.containerType', 'ct') + .leftJoinAndSelect('bc.units', 'bcu') .leftJoinAndSelect('booking.company', 'company') // .leftJoinAndSelect('booking.customer', 'customer') .leftJoinAndSelect('booking.train', 'train') @@ -103,6 +113,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') .where('booking.id = :id', { id }) + .addOrderBy('bcu.sort_order', 'ASC') .leftJoinAndMapMany( 'booking.files', FileRecord, @@ -121,6 +132,8 @@ export class BookingsRepository extends BaseRepository { containerTypeId: string; quantity: number; vgmPerUnitTons: number; + hazardousQuantity?: number; + reeferQuantity?: number; weightResult: ContainerWeightResult; }>, ): Promise { @@ -133,11 +146,16 @@ export class BookingsRepository extends BaseRepository { const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; const totalVgm = item.quantity * item.vgmPerUnitTons; const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit); + // A per-line breakdown can never exceed the line's own quantity. + const clamp = (v?: number) => + Math.max(0, Math.min(item.quantity, Math.floor(Number(v ?? 0)) || 0)); const row = containerRepo.create({ bookingId, containerTypeId: item.containerTypeId, quantity: item.quantity, + hazardousQuantity: clamp(item.hazardousQuantity), + reeferQuantity: clamp(item.reeferQuantity), vgmPerUnitTons: item.vgmPerUnitTons, totalVgmTons: totalVgm, wagonsRequired, @@ -179,7 +197,13 @@ export class BookingsRepository extends BaseRepository { /** * Find another booking whose container quantity complements this one to fill whole wagon(s) - * (same route, same container type, partial wagon on both sides). + * (same route, same container type, partial wagon on both sides). Only 20ft lines ever + * reach here — 40ft has perWagon=1 so `quantity % 1 == 0` is never partial. + * + * Partners must also ride the SAME booking day: consolidation shares one physical wagon, + * and the window/batch pool is keyed on the EAT departure day, so a pair that can't board + * the same train is useless. The day filter is applied only when THIS booking already has + * a scheduled_date (draft bookings without a date match on route/type alone until they pick one). */ async findComplementaryConsolidationPartner( booking: Booking, @@ -191,7 +215,7 @@ export class BookingsRepository extends BaseRepository { ): Promise { const { containerTypeId, quantity, containersPerWagon: perWagon } = slot; - return this.repository + const qb = this.repository .createQueryBuilder('b') .innerJoinAndSelect('b.bookingContainers', 'bc') .innerJoin('bc.containerType', 'ct') @@ -217,9 +241,18 @@ export class BookingsRepository extends BaseRepository { .andWhere('((:quantity + bc.quantity) % :perWagon) = 0', { quantity, perWagon, - }) - .orderBy('b.createdAt', 'ASC') - .getOne(); + }); + + // Same EAT booking day, so the pair can share a wagon on one train. Skip only + // when this booking has no date yet (matched again once it picks its day). + if (booking.scheduledDate) { + qb.andWhere( + `DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`, + { bookingDate: booking.scheduledDate }, + ); + } + + return qb.orderBy('b.createdAt', 'ASC').getOne(); } /** Try each partial-wagon line until a complementary partner booking is found. */ @@ -239,26 +272,51 @@ export class BookingsRepository extends BaseRepository { } /** - * Pair two bookings for consolidation. Both return to SUBMITTED so staff can - * accept them into the approval chain; the link itself (consolidationPartnerId) - * marks them as consolidated in the UI. + * Pair two bookings for consolidation. Each returns to its own resume status — + * SUBMITTED for a direct customer booking (so staff can accept it into the + * approval chain) or the stored consolidationResumeStatus for a contract + * drawdown (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS). The link itself + * (consolidationPartnerId) marks them as consolidated in the UI. The resume + * status is cleared once used, so a later un-pair re-parks cleanly. */ async pairConsolidation(bookingId: string, partnerId: string): Promise { + const [booking, partner] = await Promise.all([ + this.repository.findOne({ + where: { id: bookingId }, + select: { id: true, consolidationResumeStatus: true }, + }), + this.repository.findOne({ + where: { id: partnerId }, + select: { id: true, consolidationResumeStatus: true }, + }), + ]); + await this.repository.update(bookingId, { consolidationPartnerId: partnerId, - status: 'SUBMITTED', + status: booking?.consolidationResumeStatus ?? 'SUBMITTED', + consolidationResumeStatus: null, } as never); await this.repository.update(partnerId, { consolidationPartnerId: bookingId, - status: 'SUBMITTED', + status: partner?.consolidationResumeStatus ?? 'SUBMITTED', + consolidationResumeStatus: null, } as never); } - /** Park a booking that needs consolidation but has no partner yet. */ - async parkForConsolidation(bookingId: string): Promise { + /** + * Park a booking that needs consolidation but has no partner yet. The optional + * resumeStatus is where the booking returns once it pairs — pass it for a + * contract drawdown so pairing resumes the contract-booking flow rather than + * the direct-booking SUBMITTED default. + */ + async parkForConsolidation( + bookingId: string, + resumeStatus?: string | null, + ): Promise { await this.repository.update(bookingId, { consolidationPartnerId: null, status: 'PENDING_CONSOLIDATION', + consolidationResumeStatus: resumeStatus ?? null, } as never); } @@ -483,6 +541,15 @@ export class BookingsRepository extends BaseRepository { } as never); } + /** Bookings in any of the given statuses (clearance queue helpers). */ + async findByStatuses(statuses: string[]): Promise { + if (!statuses.length) return []; + return this.repository.find({ + where: { status: In(statuses) }, + order: { createdAt: 'DESC' }, + }); + } + /** Queue listing with optional bulk exclusion for LINE_STAFF. */ async findQueue(options: { status: string | string[]; @@ -553,6 +620,12 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.serviceType', 'serviceType') .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') + // Contract reference for the list column + search (no entity relation on + // Booking → contract, so join the entity by id and select just the + // reference — a schema-qualified table string is parsed as alias.relation + // by TypeORM and crashes). + .leftJoin(Contract, 'contract', 'contract.id = booking.contract_id') + .addSelect('contract.reference', 'contract_reference') .where('booking.deleted_at IS NULL'); this.applyListFilters(qb, options); @@ -571,10 +644,24 @@ export class BookingsRepository extends BaseRepository { qb.orderBy(sortField, options.sortOrder ?? 'DESC'); } - const [items, total] = await qb + const total = await qb.getCount(); + const { entities: items, raw } = await qb .skip((page - 1) * pageSize) .take(pageSize) - .getManyAndCount(); + .getRawAndEntities(); + + // The joined contract.reference comes back on the raw rows only (entity has no + // contract relation) — map it onto each booking by position. + const contractRefByBooking = new Map(); + for (const row of raw as Array<{ booking_id: string; contract_reference: string | null }>) { + if (row.booking_id && !contractRefByBooking.has(row.booking_id)) { + contractRefByBooking.set(row.booking_id, row.contract_reference ?? null); + } + } + for (const item of items) { + (item as Booking & { contractReference?: string | null }).contractReference = + contractRefByBooking.get(item.id) ?? null; + } if (items.length) { const links = await this.dataSource.getRepository(TrainScheduleBooking).find({ @@ -706,6 +793,11 @@ export class BookingsRepository extends BaseRepository { freightType: options.freightType, }); } + if (options.bookingType) { + qb.andWhere('booking.bookingType = :bookingType', { + bookingType: options.bookingType, + }); + } if (options.createdFrom) { qb.andWhere('booking.created_at >= :createdFrom', { createdFrom: options.createdFrom, @@ -952,6 +1044,81 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** + * Corridor day pool: ready, not-yet-allocated bookings for one EAT day whose + * origin AND destination both lie on the day's corridor stop set — covers + * full-route bookings and sub-corridor bookings (Dire→Djibouti on an + * Addis→…→Djibouti train). The caller still verifies stop ORDER per train + * via the corridor budget; this query only narrows the pool. Same status + * rules and ordering as {@link findBatchPool}. + */ + findBatchPoolByCorridorDay( + corridorYardIds: string[], + day: string, + ): Promise { + if (corridorYardIds.length === 0) return Promise.resolve([]); + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') + .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds }) + .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', { + corridorYardIds, + }) + .andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + { day }, + ) + .andWhere('sb.id IS NULL') + .andWhere( + `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') + OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`, + ) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.fully_executed_at', 'ASC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + + /** + * Commercial bookings on the day's corridor whose operation request was NOT + * accepted by staff (still pending / changes / price-confirm) and are not yet + * linked to a train. These never reached FULLY_EXECUTED, so they never enter the + * batch pool; the window's doc-review end sweeps them to EXPIRED. Government + * bookings are excluded (they don't go through the customer window). + */ + findUnacceptedForRouteDay( + corridorYardIds: string[], + day: string, + ): Promise { + if (corridorYardIds.length === 0) return Promise.resolve([]); + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') + .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds }) + .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', { + corridorYardIds, + }) + .andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + { day }, + ) + .andWhere('sb.id IS NULL') + .andWhere('booking.is_government = false') + .andWhere( + `booking.status IN ( + 'OPERATION_REQUESTED', + 'OPERATION_REQUEST_PENDING', + 'OPERATION_CHANGES_REQUESTED', + 'OPERATION_PRICE_PENDING_CONFIRM' + )`, + ) + .getMany(); + } + /** Every booking that targeted a schedule (any status) — for the batch monitoring board. */ findAllBySchedule(scheduleId: string): Promise { return this.repository @@ -1020,8 +1187,12 @@ export class BookingsRepository extends BaseRepository { company: true, originYard: true, destinationYard: true, - bookingContainers: { containerType: true }, - cargoType: true, + // units carry the real per-container numbers entered at booking time — + // the wagon plan shows those instead of generated placeholders. + // containerType.wagonType + cargoType.wagonType drive wagon-type + // resolution during scheduling (FK, not the old load-type string map). + bookingContainers: { containerType: { wagonType: true }, units: true }, + cargoType: { wagonType: true }, }, order: { priorityScore: 'DESC', createdAt: 'ASC' }, }); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index a4636cbf7..a5f25ad21 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -9,6 +9,7 @@ import { NotFoundException, } from '@nestjs/common'; import { Freight, SchedulingStatus } from '@edr/types'; +import { insertWithGeneratedReference } from '@edr/api-common'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; import { ProfileType } from '../companies/entities/company-profile.entity'; @@ -23,6 +24,7 @@ import { RuleEngineService, } from '../rule-engine/rule-engine.service'; import { InjectDataSource } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; @@ -31,6 +33,8 @@ import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; +import { VehiclesService } from '../vehicles/vehicles.service'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { assertFreightShape } from './booking-freight.util'; import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; import { mapStatusCountsToTabs } from './booking-list-tabs.config'; @@ -45,6 +49,8 @@ import { import { Booking } from './entities/booking.entity'; import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { FileRecord } from '../files/entities/file.entity'; +import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedBookings { @@ -67,6 +73,17 @@ const NEEDS_ACTION_STATUSES = [ 'APPROVED_PENDING_SIGNATURE', ] as const; +/** + * Clamp a bulk hazardous/reefer amount into 0..cargoAmount: it can never exceed + * the total cargo it's a portion of, and is never negative. + */ +function clampToCargo(value: number | undefined, cargoAmount: number): number { + const v = Number(value ?? 0); + if (!Number.isFinite(v) || v <= 0) return 0; + const cap = Number.isFinite(cargoAmount) && cargoAmount > 0 ? cargoAmount : 0; + return Math.min(v, cap); +} + @Injectable() export class BookingsService { constructor( @@ -81,9 +98,124 @@ export class BookingsService { private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly consolidationService: ConsolidationService, + private readonly vehiclesService: VehiclesService, + private readonly contractPdfService: ContractPdfService, + private readonly events: EventEmitter2, ) {} + async assignCustomerTruck( + bookingId: string, + dto: CustomerTruckAssignmentDto, + ): Promise { + const booking = await this.findById(bookingId); + const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim()); + const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim()); + const usesMileService = + booking.tradeDirection === 'IMPORT' + ? hasLastMile + : booking.tradeDirection === 'EXPORT' + ? hasFirstMile + : hasFirstMile || hasLastMile; + if (usesMileService) { + throw new BadRequestException( + 'Customer truck assignment is only allowed when first/last mile delivery is not selected', + ); + } + if (booking.customerTruckAssignedAt) { + throw new ConflictException('Customer truck assignment is already submitted and locked'); + } + if (booking.paymentStatus !== 'PAID') { + throw new BadRequestException('Booking must be paid before assigning an external customer truck'); + } + + await this.bookingsRepository.update(bookingId, { + status: 'TRUCK_ASSIGNED', + customerTruckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(), + customerTruckDriverName: dto.driverName.trim(), + customerTruckType: dto.truckType.trim(), + customerTruckContainerNumber: dto.containerNumberToLoad.trim().toUpperCase(), + customerTruckAssignedAt: new Date(), + }); + + return this.findById(bookingId); + } + + async customerTruckFreightOrderCopies( + bookingId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + const booking = await this.findById(bookingId); + if (!booking.customerTruckAssignedAt) { + throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated'); + } + + const trucks: Array<{ + plateNumber: string; + driverName: string; + truckType: string; + arrivedAt: string | null; + containers: string | null; + }> = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + a.arrived_at AS "arrivedAt", + string_agg(c.container_number, ', ' ORDER BY c.container_number) AS "containers" + FROM freight.customer_truck_assignments a + LEFT JOIN freight.customer_truck_containers c + ON c.assignment_id = a.id AND c.deleted_at IS NULL + WHERE a.booking_id = $1 AND a.deleted_at IS NULL + GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.arrived_at, a.assigned_at + ORDER BY a.assigned_at`, + [bookingId], + ); + + const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks); + const buffer = await this.contractPdfService.htmlToPdfBuffer(html); + return { + filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer, + }; + } + /** Resolve trade direction from yard countries; reject client mismatch. */ + /** + * An intercity corridor is valid when both yards are Ethiopian and at least + * one non-retired route passes the origin strictly before the destination in + * its milestone order — that is the corridor an import/export train can + * serve the booking on. + */ + private async assertIntercityCorridorExists( + originYardId: string, + destinationYardId: string, + ): Promise { + const yards = await this.dataSource.getRepository(Yard).find({ + where: { id: In([originYardId, destinationYardId]) }, + }); + if (yards.some((y) => y.country !== 'Ethiopia')) { + throw new BadRequestException( + 'Intercity bookings only run between Ethiopian yards', + ); + } + const rows: Array<{ id: string }> = await this.dataSource.query( + `SELECT r.id + FROM freight.routes r + JOIN freight.route_milestones mo + ON mo.route_id = r.id AND mo.yard_id = $1 AND mo.deleted_at IS NULL + JOIN freight.route_milestones md + ON md.route_id = r.id AND md.yard_id = $2 AND md.deleted_at IS NULL + WHERE mo.sequence_no < md.sequence_no + AND r.status = 'AVAILABLE' + AND r.deleted_at IS NULL + LIMIT 1`, + [originYardId, destinationYardId], + ); + if (rows.length === 0) { + throw new BadRequestException( + 'No route passes through this origin and destination in order — intercity service is not available on this corridor', + ); + } + } + private async resolveTradeDirectionForBooking( originYardId: string, destinationYardId: string, @@ -116,8 +248,131 @@ export class BookingsService { /** Generate a unique booking reference number. */ private async generateReference(): Promise { const year = new Date().getFullYear(); - const count = await this.bookingsRepository.countByYear(year); - return `BK-${year}-${String(count + 1).padStart(6, '0')}`; + const seq = await this.bookingsRepository.maxReferenceSequence(year); + return `BK-${year}-${String(seq + 1).padStart(6, '0')}`; + } + + private buildCustomerTruckFreightOrderHtml( + booking: Booking, + trucks: Array<{ + plateNumber: string; + driverName: string; + truckType: string; + arrivedAt: string | null; + containers: string | null; + }>, + ): string { + const assignedAt = booking.customerTruckAssignedAt + ? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB') + : '-'; + const bookingRows: Array<[string, string | null | undefined]> = [ + ['Booking Reference', booking.reference], + ['Client Name', booking.company?.name], + ['Client ID', booking.companyId], + ['Trade Direction', booking.tradeDirection], + ['Freight Type', booking.freightType], + ['Assigned At', assignedAt], + ['Booking Status', booking.status], + ]; + const bookingRowHtml = bookingRows + .map(([label, value]) => ``) + .join(''); + + // Fall back to the legacy single-truck booking columns when there are no + // multi-truck rows (bookings assigned before the multi-truck feature). + const truckList = + trucks.length > 0 + ? trucks + : booking.customerTruckPlateNumber + ? [ + { + plateNumber: booking.customerTruckPlateNumber, + driverName: booking.customerTruckDriverName ?? '', + truckType: booking.customerTruckType ?? '', + arrivedAt: booking.customerTruckArrivedAt + ? String(booking.customerTruckArrivedAt) + : null, + containers: booking.customerTruckContainerNumber ?? null, + }, + ] + : []; + + const truckBlocks = truckList + .map((t, i) => { + const rows: Array<[string, string | null | undefined]> = [ + ['Truck Plate Number', t.plateNumber], + ['Driver Name', t.driverName], + ['Truck Type', t.truckType], + ['Containers Loaded', t.containers], + [ + 'Arrival', + t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival', + ], + ]; + const html = rows + .map( + ([label, value]) => + ``, + ) + .join(''); + return `

Truck ${i + 1}

${this.escapeHtml(label)}${this.escapeHtml(value || '-')}
${this.escapeHtml(label)}${this.escapeHtml(value || '-')}
${html}
`; + }) + .join(''); + + const copy = (watermark: string) => ` +
+
${this.escapeHtml(watermark)}
+
+
+

Freight Order

+

Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}

+
+ ${this.escapeHtml(booking.reference)} +
+ ${bookingRowHtml}
+ ${truckBlocks} +
+
Customer / Carrier Signature
+
Port Operations Verification
+
Gate Security Verification
+
+
`; + + return ` + + + + + + + ${copy('Copy 1: Port Operations Copy')} + ${copy('Copy 2: Gate Security & Carrier Copy')} + + `; + } + + private escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } /** Build evaluation input from booking freight shape. */ @@ -240,6 +495,14 @@ export class BookingsService { messages.push( this.consolidationService.describePaired(partner.reference, slots), ); + // Let deferred owners (e.g. contract drawdowns whose invoice/milestones + // were held while the booking waited) finalize now that a whole wagon + // exists. Fire-and-forget: a listener failure must not undo the pairing. + this.events + .emitAsync('booking.consolidation.paired', { + bookingIds: [booking.id, partner.id], + }) + .catch(() => undefined); return { booking: paired, messages }; } @@ -352,10 +615,15 @@ export class BookingsService { if (schedule.bookingWindowStatus !== 'OPEN') { throw new BadRequestException('Selected schedule is no longer accepting bookings'); } - if ( - schedule.originStationId !== dto.originYardId || - schedule.destinationStationId !== dto.destinationYardId - ) { + // Corridor-aware: the booking's leg must lie on the schedule's route in + // stop order — sub-corridor pins (Dire→Djibouti on an Addis→Djibouti + // train) are valid. + const stops = await this.trainSchedulingService.stopYardsForSchedule( + schedule, + ); + const fromIdx = stops.indexOf(dto.originYardId); + const toIdx = stops.indexOf(dto.destinationYardId); + if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) { throw new BadRequestException('Selected schedule is not on the booking route'); } } else if (dto.scheduledDate) { @@ -379,7 +647,6 @@ export class BookingsService { } } - const reference = dto.reference || (await this.generateReference()); const containers = dto.containers ?? []; assertFreightShape({ freightType: dto.freightType, @@ -393,6 +660,23 @@ export class BookingsService { dto.tradeDirection, ); + // Intercity (DOMESTIC) bookings never get their own train — they ride on a + // passing import/export train, so there is no booking window and no date to + // pin. All we require at creation is that the corridor actually lies on a + // route (origin before destination in some route's milestone order); staff + // accept the booking onto a concrete train at finalize time. + if (tradeDirection === 'DOMESTIC') { + if (dto.scheduledDate || dto.trainScheduleId) { + throw new BadRequestException( + 'Intercity bookings cannot pin a date or schedule — staff assign them to a passing train later', + ); + } + await this.assertIntercityCorridorExists( + dto.originYardId, + dto.destinationYardId, + ); + } + // Stamp the operational profile this booking belongs to (importer/exporter) // so the customer portal can scope lists/KPIs to the active mode. Best-effort // for non-government bookings with a resolved company; never blocks creation. @@ -474,7 +758,10 @@ export class BookingsService { // the customer clears it themselves and may name their broker. const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId); - const booking = await this.bookingsRepository.create({ + // Explicit reference is caller-chosen (a collision is a real conflict); + // auto-generated references retry past a concurrent same-sequence insert. + const insertBooking = (reference: string) => + this.bookingsRepository.create({ reference, companyId, companyProfileId, @@ -506,6 +793,16 @@ export class BookingsService { // the container type at pricing time, so the booking-level flag stays off // for container freight to avoid double-counting. isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false, + // Bulk-only hazardous/reefer amount, clamped to the cargo amount. Container + // freight tracks this per line, so these are 0 for CONTAINER. + bulkHazardousQuantity: + dto.freightType === 'BULK' + ? clampToCargo(dto.bulkHazardousQuantity, dto.cargoTotalWeightVgm) + : 0, + bulkReeferQuantity: + dto.freightType === 'BULK' + ? clampToCargo(dto.bulkReeferQuantity, dto.cargoTotalWeightVgm) + : 0, paymentCurrency: dto.paymentCurrency, pnrCode: dto.pnrCode, financialTerms: dto.financialTerms, @@ -519,7 +816,14 @@ export class BookingsService { priorityScore: ruleResult.priorityScore, totalAmount: 0, paymentStatus: 'PENDING', - }); + }); + + const booking = dto.reference + ? await insertBooking(dto.reference) + : await insertWithGeneratedReference( + () => this.generateReference(), + insertBooking, + ); if (dto.freightType === 'CONTAINER') { await this.bookingsRepository.createContainers( @@ -528,6 +832,8 @@ export class BookingsService { containerTypeId: c.containerTypeId, quantity: c.quantity, vgmPerUnitTons: c.vgmPerUnitTons, + hazardousQuantity: c.hazardousQuantity, + reeferQuantity: c.reeferQuantity, weightResult: ruleResult.containerWeightResults[i], })), ); @@ -665,6 +971,8 @@ export class BookingsService { containers, ); + const cargoAmount = + dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0); const updates: Record = { ...dto, freightType, @@ -675,6 +983,22 @@ export class BookingsService { freightType === 'BULK' ? (dto.isReefer ?? existing.isReefer ?? false) : false, + // Bulk-only hazardous/reefer amount, clamped to the cargo amount; 0 for + // container freight (per-line on the containers instead). + bulkHazardousQuantity: + freightType === 'BULK' + ? clampToCargo( + dto.bulkHazardousQuantity ?? Number(existing.bulkHazardousQuantity ?? 0), + cargoAmount, + ) + : 0, + bulkReeferQuantity: + freightType === 'BULK' + ? clampToCargo( + dto.bulkReeferQuantity ?? Number(existing.bulkReeferQuantity ?? 0), + cargoAmount, + ) + : 0, priorityScore: ruleResult.priorityScore, tradeDirection, }; @@ -721,6 +1045,8 @@ export class BookingsService { containerTypeId: c.containerTypeId, quantity: c.quantity, vgmPerUnitTons: c.vgmPerUnitTons, + hazardousQuantity: c.hazardousQuantity, + reeferQuantity: c.reeferQuantity, weightResult: ruleResult.containerWeightResults[i], })), ); @@ -826,6 +1152,7 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, + bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, @@ -966,6 +1293,14 @@ export class BookingsService { ): Promise { const booking = await this.findById(bookingId); + const journey = { + bookingStatus: booking.status ?? null, + bookingOriginYardId: booking.originYardId ?? null, + bookingDestinationYardId: booking.destinationYardId ?? null, + loadedAt: booking.loadedAt ? new Date(booking.loadedAt).toISOString() : null, + arrivedAt: booking.arrivedAt ? new Date(booking.arrivedAt).toISOString() : null, + }; + const empty: Freight.IBookingTracking = { bookingId: booking.id, bookingReference: booking.reference, @@ -983,6 +1318,7 @@ export class BookingsService { actualArrivalAt: null, scheduledDepartureAt: null, scheduledArrivalAt: null, + ...journey, }; if (!booking.trainScheduleId) { @@ -1019,6 +1355,7 @@ export class BookingsService { actualArrivalAt: track.actualArrivalAt, scheduledDepartureAt: track.scheduledDepartureAt, scheduledArrivalAt: track.scheduledArrivalAt, + ...journey, }; } @@ -1076,6 +1413,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; } @@ -1284,7 +1632,7 @@ export class BookingsService { if (!booking.isGovernment) { throw new BadRequestException('Only government bookings can be expedited'); } - const blocked = ['PAID', 'IN_TRANSIT', 'COMPLETED', 'CANCELLED', 'REJECTED']; + const blocked = ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED']; if (blocked.includes(booking.status)) { throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`); } @@ -1348,6 +1696,16 @@ export class BookingsService { throw new NotFoundException(`Booking ${bookingId} not found`); } + const previousAllocations = await this.dataSource.manager.find(BookingContainerAllocation, { + where: { + bookingId, + containerId: In(allocations.map((a) => a.containerId)), + }, + }); + const previousVehicleIds = previousAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + await this.dataSource.transaction(async (manager) => { for (const allocation of allocations) { await manager.delete(BookingContainerAllocation, { @@ -1364,6 +1722,16 @@ export class BookingsService { } }); + const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); + await Promise.all( + [...vehicleIds].map((vehicleId) => + this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY), + ), + ); + await this.vehiclesService.releaseIfUnused( + previousVehicleIds.filter((id) => !vehicleIds.has(id)), + ); + return { success: true, allocated: allocations.length, diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts index a7bd13c28..ac21b2dce 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -42,8 +42,13 @@ describe('clearance.util — clearanceOutputSettingCode', () => { expect(clearanceOutputSettingCode('IMPORT', 'CONTAINER', false)).toBeNull(); }); - it('returns null for bulk (no container output set) and domestic', () => { - expect(clearanceOutputSettingCode('IMPORT', 'BULK', true)).toBeNull(); + it('resolves bulk output sets (mirrors container) and returns null for domestic', () => { + expect(clearanceOutputSettingCode('IMPORT', 'BULK', true)).toBe( + 'clearance_output_import_bulk', + ); + expect(clearanceOutputSettingCode('EXPORT', 'BULK', true)).toBe( + 'clearance_output_export_bulk', + ); expect(clearanceOutputSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull(); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index 2e2865d8b..6d7b86c8f 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -33,7 +33,7 @@ export function clearanceSettingCode( return `clearance_${op}_${freight}_${customs}`; } -/** The GL-output (customs output) setting code; only container customs sets exist. */ +/** The GL-output (customs output) setting code, keyed on op + freight. */ export function clearanceOutputSettingCode( tradeDirection: string, freightType: string, @@ -42,9 +42,8 @@ export function clearanceOutputSettingCode( if (!includesCustoms) return null; const op = operationFor(tradeDirection); if (!op) return null; - // Only container customs output sets are seeded for this phase. - if (freightFor(freightType) !== 'container') return null; - return `clearance_output_${op}_container`; + const freight = freightFor(freightType); + return `clearance_output_${op}_${freight}`; } /** Convenience: resolve both codes for a loaded booking (with its serviceType). */ diff --git a/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts new file mode 100644 index 000000000..6aed9bf1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts @@ -0,0 +1,55 @@ +import { validate20ftWeightPairing } from './container-pairing.util'; + +describe('validate20ftWeightPairing', () => { + const MAX_DIFF = 10; + + it('passes when a balanced pairing exists (adjacent diffs within cap)', () => { + // sorted: 8, 15, 18, 24 → pairs (8,15) diff 7, (18,24) diff 6 — both ≤ 10. + const units = [ + { label: 'A', grossWeightTons: 24 }, + { label: 'B', grossWeightTons: 8 }, + { label: 'C', grossWeightTons: 18 }, + { label: 'D', grossWeightTons: 15 }, + ]; + expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]); + }); + + it('flags a pair whose weight difference exceeds the cap', () => { + // sorted: 5, 25 → single pair diff 20 > 10. + const units = [ + { label: 'HEAVY', grossWeightTons: 25 }, + { label: 'LIGHT', grossWeightTons: 5 }, + ]; + const result = validate20ftWeightPairing(units, MAX_DIFF); + expect(result).toHaveLength(1); + expect(result[0].labels).toEqual(['LIGHT', 'HEAVY']); + expect(result[0].diffTons).toBe(20); + }); + + it('allows an odd leftover unit (goes to consolidation, not a violation)', () => { + // sorted: 10, 12, 30 → pair (10,12) diff 2 ok; 30 is the odd leftover. + const units = [ + { label: 'A', grossWeightTons: 10 }, + { label: 'B', grossWeightTons: 12 }, + { label: 'C', grossWeightTons: 30 }, + ]; + expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]); + }); + + it('adjacent-by-weight pairing succeeds where a naive input order would fail', () => { + // Input order (20, 12, 22, 10) naively pairs (20,12)=8 and (22,10)=12 (fail), + // but sorted (10,12,20,22) pairs (10,12)=2 and (20,22)=2 — valid, so no violation. + const units = [ + { label: 'A', grossWeightTons: 20 }, + { label: 'B', grossWeightTons: 12 }, + { label: 'C', grossWeightTons: 22 }, + { label: 'D', grossWeightTons: 10 }, + ]; + expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]); + }); + + it('returns nothing for fewer than two units', () => { + expect(validate20ftWeightPairing([{ label: 'A', grossWeightTons: 30 }], MAX_DIFF)).toEqual([]); + expect(validate20ftWeightPairing([], MAX_DIFF)).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts new file mode 100644 index 000000000..cf1cfe944 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts @@ -0,0 +1,64 @@ +/** + * Booking-time 20ft weight-pairing rule. + * + * A container wagon holds two 20ft containers (2 TEU). When two 20ft ride the + * same wagon their gross-weight difference must not exceed `maxPairDiffTons` + * (global rule `max20ftPairWeightDiffTons`, default 10t) so the wagon load stays + * balanced. 40ft containers occupy a whole wagon alone and never pair. + * + * At booking time the customer enters every 20ft container's weight but not its + * wagon slot, so we auto-pair: sort the 20ft weights ascending and pair adjacent + * (0-1, 2-3, …). Adjacent pairing minimises the diff of every pair, so if ANY + * valid pairing exists this one finds it — a violation here means no balanced + * pairing is possible and the booking must be blocked. An odd leftover 20ft is + * fine: it has no partner in this booking and flows to consolidation. + */ + +export interface Container20ftUnit { + /** Human label for messages, e.g. the container number. */ + label: string; + grossWeightTons: number; +} + +export interface PairingViolation { + message: string; + /** The two container labels whose pairing exceeds the diff cap. */ + labels: [string, string]; + diffTons: number; +} + +const round2 = (n: number): number => Math.round(n * 100) / 100; + +/** + * Validate that the given 20ft units can all be paired onto wagons within the + * weight-difference cap. Returns one violation per over-cap adjacent pair (empty + * when every wagon pair is balanced or there is nothing to pair). A single + * leftover unit (odd count) is not a violation. + */ +export function validate20ftWeightPairing( + units: Container20ftUnit[], + maxPairDiffTons: number, +): PairingViolation[] { + if (units.length < 2 || maxPairDiffTons == null) return []; + + // Ascending by weight: adjacent pairs have the smallest possible diffs. + const sorted = [...units].sort((a, b) => a.grossWeightTons - b.grossWeightTons); + const violations: PairingViolation[] = []; + + for (let i = 0; i + 1 < sorted.length; i += 2) { + const a = sorted[i]; + const b = sorted[i + 1]; + const diff = Math.abs(a.grossWeightTons - b.grossWeightTons); + if (diff > maxPairDiffTons) { + violations.push({ + message: + `20ft containers ${a.label} (${round2(a.grossWeightTons)}T) and ` + + `${b.label} (${round2(b.grossWeightTons)}T) cannot share a wagon: ` + + `weight difference ${round2(diff)}T exceeds the ${maxPairDiffTons}T limit.`, + labels: [a.label, b.label], + diffTons: round2(diff), + }); + } + } + return violations; +} diff --git a/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts new file mode 100644 index 000000000..fea73603a --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts @@ -0,0 +1,145 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; + +export interface ReceivedUnitRow { + id: string; + containerNumber: string; + receivedToPort: boolean; + receivedAt: string | null; + grnNumber: string | null; +} + +/** + * Per-container receive + GRN tracking on booking_container_units. + * + * Containers arrive individually (on separate self-haul trucks), so each unit is + * flipped `received_to_port` when its truck arrives (auto). Staff then confirm a + * Goods Received Note over the received-but-un-GRN'd containers: one GRN covers a + * batch, so if the whole booking arrives together every unit shares a single GRN + * (per-booking GRN); if trucks arrive separately each batch gets its own GRN. + */ +@Injectable() +export class ContainerReceiptService { + constructor(private readonly dataSource: DataSource) {} + + /** + * Auto-mark the containers loaded on an arrived truck as received into the + * port. Idempotent — only flips units not already received. Runs inside the + * caller's transaction when a manager is supplied. + */ + async markReceivedForAssignment( + bookingId: string, + assignmentId: string, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + await m.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_container bc, + freight.customer_truck_containers ctc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND ctc.assignment_id = $2 + AND ctc.deleted_at IS NULL + AND ctc.container_number = bcu.container_number + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [bookingId, assignmentId], + ); + } + + /** Received-into-port containers that have not yet been assigned a GRN. */ + async listReceivedPendingGrn(bookingId: string): Promise { + return this.dataSource.query( + `SELECT bcu.id, + bcu.container_number AS "containerNumber", + bcu.received_to_port AS "receivedToPort", + bcu.received_at AS "receivedAt", + bcu.grn_number AS "grnNumber" + 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.deleted_at IS NULL + AND bcu.received_to_port = true + AND bcu.grn_number IS NULL + ORDER BY bcu.received_at`, + [bookingId], + ); + } + + /** + * Confirm a GRN over the currently received-but-un-GRN'd containers (optionally + * a subset by container number). Assigns one GRN number to the whole batch and + * returns it with the covered containers. If the batch covers every container + * on the booking it is effectively a per-booking GRN. + */ + async generateGrn( + bookingId: string, + containerNumbers?: string[], + ): Promise<{ grnNumber: string; containerNumbers: string[]; perBooking: boolean }> { + const [booking] = await this.dataSource.query( + `SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + return this.dataSource.transaction(async (manager) => { + const wanted = containerNumbers?.map((n) => n.trim().toUpperCase()); + const pending: ReceivedUnitRow[] = await manager.query( + `SELECT bcu.id, bcu.container_number AS "containerNumber" + 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.deleted_at IS NULL + AND bcu.received_to_port = true + AND bcu.grn_number IS NULL + ${wanted ? 'AND bcu.container_number = ANY($2::varchar[])' : ''}`, + wanted ? [bookingId, wanted] : [bookingId], + ); + if (!pending.length) { + throw new BadRequestException('No received containers are awaiting a GRN'); + } + + // Batch sequence = number of GRNs already issued for this booking + 1. + const [{ batches }]: Array<{ batches: string }> = await manager.query( + `SELECT COUNT(DISTINCT bcu.grn_number) AS batches + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`, + [bookingId], + ); + const seq = Number(batches) + 1; + const grnNumber = `GRN-${String(booking.reference).replace(/^BK-?/i, '')}-${String(seq).padStart(2, '0')}`; + + const ids = pending.map((p) => p.id); + await manager.query( + `UPDATE freight.booking_container_units + SET grn_number = $1, updated_at = NOW() + WHERE id = ANY($2::uuid[])`, + [grnNumber, ids], + ); + + // Per-booking when no container on the booking is left un-GRN'd. + const [{ remaining }]: Array<{ remaining: string }> = await manager.query( + `SELECT COUNT(*) AS remaining + 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.deleted_at IS NULL AND bcu.grn_number IS NULL`, + [bookingId], + ); + + return { + grnNumber, + containerNumbers: pending.map((p) => p.containerNumber), + perBooking: Number(remaining) === 0 && seq === 1, + }; + }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts b/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts new file mode 100644 index 000000000..de4dca661 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, In } from 'typeorm'; + +import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; +import { Booking } from './entities/booking.entity'; +import { BookingContainerUnit } from './entities/booking-container-unit.entity'; +import { + Container20ftUnit, + PairingViolation, + validate20ftWeightPairing, +} from './container-pairing.util'; + +/** Default 20ft pair weight-difference cap when no global rules row exists (matches the entity default). */ +const DEFAULT_MAX_20FT_PAIR_DIFF_TONS = 10; + +/** + * Booking-time container validations that need the customer-entered per-unit + * weights (`BookingContainerUnit`): the 20ft weight-pairing rule. Kept out of the + * rule engine (which works on line totals) because pairing is per physical unit. + */ +@Injectable() +export class ContainerValidationService { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + private async maxPairDiffTons(): Promise { + const row = await this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .find({ order: { createdAt: 'ASC' }, take: 1 }) + .then((rows) => rows[0] ?? null) + .catch(() => null); + const v = row?.max20ftPairWeightDiffTons; + const n = v == null ? NaN : Number(v); + return Number.isFinite(n) ? n : DEFAULT_MAX_20FT_PAIR_DIFF_TONS; + } + + /** Load every 20ft container UNIT weight for a booking (customer-entered VGM). */ + private async load20ftUnits(booking: Booking): Promise { + const lines = (booking.bookingContainers ?? []).filter( + (bc) => (bc.containerSize ?? '').includes('20'), + ); + if (!lines.length) return []; + + const units = await this.dataSource + .getRepository(BookingContainerUnit) + .find({ + where: { bookingContainerId: In(lines.map((l) => l.id)) }, + order: { sortOrder: 'ASC' }, + }); + + return units.map((u) => ({ + label: u.containerNumber || u.id.slice(0, 8), + grossWeightTons: Number(u.vgmTons ?? 0), + })); + } + + /** + * Validate the 20ft weight-pairing rule for a booking. Returns one message per + * pair whose weight difference exceeds the cap; empty when all 20ft can be + * balanced onto wagons (or there is nothing to pair). A lone odd 20ft is fine — + * it flows to consolidation. Callers hard-block a non-empty result. + */ + async validate20ftPairing(booking: Booking): Promise { + // Only bookings whose 20ft lines actually carry per-unit weights can be + // checked; contract-drawdown bookings do (units are required there). + const containerLines = booking.bookingContainers ?? []; + const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20')); + if (!has20ft) return []; + + const units = await this.load20ftUnits(booking); + if (units.length < 2) return []; + + const maxDiff = await this.maxPairDiffTons(); + return validate20ftWeightPairing(units, maxDiff); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck-assignments.repository.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck-assignments.repository.ts new file mode 100644 index 000000000..45a09a6a3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck-assignments.repository.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; + +import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; + +@Injectable() +export class CustomerTruckAssignmentsRepository extends BaseRepository { + constructor( + @InjectRepository(CustomerTruckAssignment) + private readonly repo: Repository, + ) { + super(repo); + } + + /** All trucks assigned to a booking, oldest first, with their containers. */ + findByBookingId(bookingId: string): Promise { + return this.repo.find({ + where: { bookingId }, + relations: { containers: true }, + order: { assignedAt: 'ASC' }, + }); + } + + findByIdWithContainers(id: string): Promise { + return this.repo.findOne({ where: { id }, relations: { containers: true } }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts new file mode 100644 index 000000000..14402f830 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -0,0 +1,505 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager, IsNull } from 'typeorm'; + +import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; +import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; +import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; +import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; + +interface BookingGuardRow { + tradeDirection: string | null; + firstMile: string | null; + lastMile: string | null; + paymentStatus: string | null; + status: string | null; +} + +/** + * Multi-truck self-haul assignment. A booking with no EDR first/last-mile leg + * can have several customer trucks, each carrying 1–2 of its containers and + * tracking its own arrival. The legacy booking.customer_truck_* columns are kept + * as a booking-level flag (any truck assigned / all arrived) so the warehouse + * exit-gate + delivery-approval logic keep working unchanged. + */ +@Injectable() +export class CustomerTruckService { + constructor( + private readonly dataSource: DataSource, + private readonly assignments: CustomerTruckAssignmentsRepository, + ) {} + + listTrucks(bookingId: string): Promise { + return this.assignments.findByBookingId(bookingId); + } + + async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise { + const booking = await this.loadBookingGuard(bookingId); + this.assertSelfHaulPaid(booking); + + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + + // Both import and export specify the containers each truck carries. Capacity + // is size-based: a 40ft container fills the truck (max 1); two 20ft containers + // fit (max 2), no size mixing. #trucks <= #containers follows naturally since + // each container is assigned to exactly one truck. + if (requested.length < 1) { + throw new BadRequestException('Select at least one container for this truck'); + } + if (requested.length > 2) { + throw new BadRequestException('A truck carries at most 2 containers'); + } + + if (requested.length) { + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + // Never assign more trucks than the booking has containers. + const existingTrucks = await this.dataSource + .getRepository(CustomerTruckAssignment) + .count({ where: { bookingId } }); + if (existingTrucks + 1 > bookingNumbers.length) { + throw new BadRequestException( + `Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`, + ); + } + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + const alreadyAssigned = await this.assignedContainerNumbers(bookingId); + for (const n of requested) { + if (alreadyAssigned.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } + } + // Size cap: a 40ft container fills the truck. + const sizes = await this.containerSizes(bookingId, requested); + if (sizes.some((s) => s.includes('40')) && requested.length > 1) { + throw new BadRequestException( + 'A 40ft container fills the truck — assign only 1 container to this truck', + ); + } + } + + await this.dataSource.transaction(async (manager) => { + const assignment = await manager.getRepository(CustomerTruckAssignment).save( + manager.getRepository(CustomerTruckAssignment).create({ + bookingId, + plateNumber: dto.truckPlateNumber.trim().toUpperCase(), + driverName: dto.driverName.trim(), + truckType: dto.truckType.trim(), + }), + ); + await manager.getRepository(CustomerTruckContainer).save( + requested.map((containerNumber) => + manager.getRepository(CustomerTruckContainer).create({ + assignmentId: assignment.id, + bookingId, + containerNumber, + }), + ), + ); + // Booking-level flag: first truck marks the booking as truck-assigned. + await manager.query( + `UPDATE freight.bookings + SET customer_truck_assigned_at = COALESCE(customer_truck_assigned_at, NOW()), + status = CASE WHEN status = 'PAID' THEN 'TRUCK_ASSIGNED' ELSE status END, + updated_at = NOW() + WHERE id = $1`, + [bookingId], + ); + }); + + return this.listTrucks(bookingId); + } + + async removeTruck(bookingId: string, assignmentId: string): Promise { + const assignment = await this.assignments.findByIdWithContainers(assignmentId); + if (!assignment || assignment.bookingId !== bookingId) { + throw new NotFoundException('Truck assignment not found for this booking'); + } + if (assignment.arrivedAt) { + throw new ConflictException('Cannot remove a truck that has already arrived'); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + await manager.getRepository(CustomerTruckAssignment).softDelete(assignmentId); + const remaining = await manager + .getRepository(CustomerTruckAssignment) + .count({ where: { bookingId } }); + if (remaining === 0) { + // No trucks left — clear the booking-level flag and revert the status. + await manager.query( + `UPDATE freight.bookings + SET customer_truck_assigned_at = NULL, + status = CASE WHEN status = 'TRUCK_ASSIGNED' THEN 'PAID' ELSE status END, + updated_at = NOW() + WHERE id = $1`, + [bookingId], + ); + } + }); + + return this.listTrucks(bookingId); + } + + /** + * Edit a truck assignment — plate/driver/type and the containers it carries. + * Allowed only until the truck has arrived (same guard as removal). Container + * rules mirror {@link addTruck}: 1–2 of the booking's containers, none already + * on another truck, and a 40ft container fills the truck (max 1). + */ + async updateTruck( + bookingId: string, + assignmentId: string, + dto: AddCustomerTruckDto, + ): Promise { + const booking = await this.loadBookingGuard(bookingId); + this.assertSelfHaulPaid(booking); + + const assignment = await this.assignments.findByIdWithContainers(assignmentId); + if (!assignment || assignment.bookingId !== bookingId) { + throw new NotFoundException('Truck assignment not found for this booking'); + } + if (assignment.arrivedAt) { + throw new ConflictException('Cannot edit a truck that has already arrived'); + } + + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (requested.length < 1) { + throw new BadRequestException('Select at least one container for this truck'); + } + if (requested.length > 2) { + throw new BadRequestException('A truck carries at most 2 containers'); + } + 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`); + } + } + // Exclude THIS truck's own containers so re-saving the same set is allowed. + const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); + for (const n of requested) { + if (assignedElsewhere.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } + } + const sizes = await this.containerSizes(bookingId, requested); + if (sizes.some((s) => s.includes('40')) && requested.length > 1) { + throw new BadRequestException( + 'A 40ft container fills the truck — assign only 1 container to this truck', + ); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { + plateNumber: dto.truckPlateNumber.trim().toUpperCase(), + driverName: dto.driverName.trim(), + truckType: dto.truckType.trim(), + }); + await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + await manager.getRepository(CustomerTruckContainer).save( + requested.map((containerNumber) => + manager.getRepository(CustomerTruckContainer).create({ + assignmentId, + bookingId, + containerNumber, + }), + ), + ); + }); + + return this.listTrucks(bookingId); + } + + /** + * Register an IMPORT self-haul truck leaving the port: the containers it + * actually loaded (replacing any provisional list) and its weighed gross. + * Export bookings have no truck departure — trucks only deliver (receive). + */ + async departTruck( + bookingId: string, + assignmentId: string, + dto: DepartCustomerTruckDto, + ): Promise { + const booking = await this.loadBookingGuard(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException( + 'Truck departure/weighing applies to import self-haul only (export trucks only deliver)', + ); + } + const assignment = await this.assignments.findByIdWithContainers(assignmentId); + if (!assignment || assignment.bookingId !== bookingId) { + throw new NotFoundException('Truck assignment not found for this booking'); + } + // Once filled, the departure record is uneditable. + if (assignment.departedAt) { + throw new ConflictException('This truck has already departed — its exit record is locked'); + } + + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (requested.length) { + 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`); + } + } + } + + await this.dataSource.transaction(async (manager) => { + if (requested.length) { + // Replace the truck's containers with what was actually loaded. + await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + await manager.getRepository(CustomerTruckContainer).save( + requested.map((containerNumber) => + manager.getRepository(CustomerTruckContainer).create({ + assignmentId, + bookingId, + containerNumber, + }), + ), + ); + } + await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { + grossWeightKg: dto.grossWeightKg, + departedAt: dto.gateOutTime ? new Date(dto.gateOutTime) : new Date(), + arrivedAt: assignment.arrivedAt ?? new Date(), + }); + }); + + return this.listTrucks(bookingId); + } + + /** Booking container numbers not yet loaded onto any truck. */ + async getLoadableContainers(bookingId: string): Promise { + 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 { + 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 grossTons = await this.vgmTonsForContainers(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 (tonnes) from the loaded containers' VGM — overridden + // by the weighed gross on departure. (Column is *_kg but holds tonnes.) + await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { + grossWeightKg: grossTons, + }); + }); + return this.listTrucks(bookingId); + } + + /** Summed VGM (tonnes) of the given containers — provisional truck gross. */ + private async vgmTonsForContainers(bookingId: string, numbers: string[]): Promise { + const [row]: Array<{ tons: string }> = await this.dataSource.query( + `SELECT COALESCE(SUM(bcu.vgm_tons), 0) AS tons + 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?.tons ?? 0); + } + + /** + * Mark the truck carrying `containerNumber` as arrived. Called by the warehouse + * receive flow. When every truck on the booking has arrived, the booking-level + * customer_truck_arrived_at flag is stamped (used by the delivery-approval + * gate). No-op when the container is not on any customer truck. + */ + async markArrivedByContainer( + bookingId: string, + containerNumber: string, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + const cn = containerNumber.trim().toUpperCase(); + const container = await m.getRepository(CustomerTruckContainer).findOne({ + where: { bookingId, containerNumber: cn }, + }); + if (!container) return; + + await m + .getRepository(CustomerTruckAssignment) + .update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() }); + + await this.syncBookingArrival(bookingId, m); + } + + /** Mark every truck on the booking arrived (fallback when no container is known). */ + async markAllArrived(bookingId: string, manager?: EntityManager): Promise { + const m = manager ?? this.dataSource.manager; + await m + .getRepository(CustomerTruckAssignment) + .update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() }); + await this.syncBookingArrival(bookingId, m); + } + + /** + * Stamp the booking-level arrival flag on the FIRST truck arrival. The import + * handover is signed once, before the first truck leaves, even though trucks + * pick up per-container — so the flag fires on the first arrival (COALESCE + * keeps it), not once all trucks have arrived. + */ + private async syncBookingArrival(bookingId: string, m: EntityManager): Promise { + await m.query( + `UPDATE freight.bookings + SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()), + updated_at = NOW() + WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL`, + [bookingId], + ); + } + + private async loadBookingGuard(bookingId: string): Promise { + const [row]: BookingGuardRow[] = await this.dataSource.query( + `SELECT trade_direction AS "tradeDirection", + first_mile_pickup_address AS "firstMile", + last_mile_delivery_address AS "lastMile", + payment_status AS "paymentStatus", + status + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!row) throw new NotFoundException(`Booking ${bookingId} not found`); + return row; + } + + private assertSelfHaulPaid(booking: BookingGuardRow): void { + const hasFirstMile = Boolean(booking.firstMile?.trim()); + const hasLastMile = Boolean(booking.lastMile?.trim()); + const usesMileService = + booking.tradeDirection === 'IMPORT' + ? hasLastMile + : booking.tradeDirection === 'EXPORT' + ? hasFirstMile + : hasFirstMile || hasLastMile; + if (usesMileService) { + throw new BadRequestException( + 'Customer truck assignment is only allowed when first/last mile delivery is not selected', + ); + } + if (booking.paymentStatus !== 'PAID') { + throw new BadRequestException( + 'Booking must be paid before assigning an external customer truck', + ); + } + } + + private async bookingContainerNumbers(bookingId: string): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT bcu.container_number AS "containerNumber" + 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.deleted_at IS NULL`, + [bookingId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } + + private async assignedContainerNumbers(bookingId: string): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT container_number AS "containerNumber" + FROM freight.customer_truck_containers + WHERE booking_id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } + + private async assignedContainerNumbersExcept( + bookingId: string, + exceptAssignmentId: string, + ): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT container_number AS "containerNumber" + FROM freight.customer_truck_containers + WHERE booking_id = $1 AND assignment_id <> $2 AND deleted_at IS NULL`, + [bookingId, exceptAssignmentId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } + + /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ + private async containerSizes(bookingId: string, numbers: string[]): Promise { + if (!numbers.length) return []; + const rows: Array<{ size: string | null }> = await this.dataSource.query( + `SELECT bc.container_size AS "size" + 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 UPPER(bcu.container_number) = ANY($2) + AND bcu.deleted_at IS NULL`, + [bookingId, numbers], + ); + return rows.map((r) => (r.size ?? '').trim()); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts new file mode 100644 index 000000000..4356d66ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts @@ -0,0 +1,47 @@ +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsIn, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, +} from 'class-validator'; + +import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; + +/** + * Add one external customer truck to a booking. + * - EXPORT: the truck delivers 1–2 known containers (required, validated in the + * service against the booking's containers). + * - IMPORT: the customer does not pre-specify — containers are registered and + * weighed when the truck leaves, so `containerNumbers` may be omitted/empty. + */ +export class AddCustomerTruckDto { + @IsString() + @IsNotEmpty() + @MaxLength(32) + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(120) + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index 35df9dcd3..c930d7aa1 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -72,9 +72,6 @@ export class BookingReferenceCargoTypeChildDto { @ApiProperty({ example: 'BULK_COFFEE' }) code!: string; - @ApiProperty() - show_free_text_box!: boolean; - @ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false }) unit_of_measure?: CargoUnitOfMeasure | null; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 9380faba5..c4d971f51 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -52,6 +52,28 @@ export class CreateBookingContainerDto { @Min(0) @Transform(({ value }) => Number(value)) vgmPerUnitTons!: number; + + @ApiPropertyOptional({ + description: 'How many of this line are hazardous (0..quantity)', + minimum: 0, + default: 0, + }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + hazardousQuantity?: number; + + @ApiPropertyOptional({ + description: 'How many of this line are refrigerated (0..quantity)', + minimum: 0, + default: 0, + }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + reeferQuantity?: number; } /** @@ -320,6 +342,25 @@ export class CreateBookingDto { @Transform(({ value }) => value === 'true' || value === true) isReefer?: boolean; + /** + * Bulk-only: how much of the cargo is hazardous / refrigerated, in the cargo's + * unit of measure (tons for PER_TON, item count for PER_ITEM). Must not exceed + * cargoTotalWeightVgm. Ignored for container freight (per-line on containers). + */ + @ApiPropertyOptional({ minimum: 0, default: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + bulkHazardousQuantity?: number; + + @ApiPropertyOptional({ minimum: 0, default: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + bulkReeferQuantity?: number; + @ApiProperty({ enum: PAYMENT_CURRENCIES }) @IsIn([...PAYMENT_CURRENCIES]) paymentCurrency!: string; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts new file mode 100644 index 000000000..9daf7523e --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts @@ -0,0 +1,34 @@ +import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator'; + +export const CUSTOMER_TRUCK_TYPES = [ + 'Flatbed', + 'Container Chassis', + 'Lowboy', + 'Box Truck', + 'Tipper', +] as const; + +export class CustomerTruckAssignmentDto { + @IsString() + @IsNotEmpty() + @MaxLength(32) + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(120) + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(16) + @Matches(/^[A-Z]{4}\d{7}$/, { + message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567', + }) + containerNumberToLoad!: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts new file mode 100644 index 000000000..31ab1b5bd --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts @@ -0,0 +1,37 @@ +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsDateString, + IsNumber, + IsOptional, + Matches, + Min, +} from 'class-validator'; + +/** + * Register an import self-haul truck leaving the port: the containers it actually + * loaded (staff read them off the truck) and the weighed gross. Container numbers + * are optional here only because they may already have been recorded; the weighed + * gross is required. + */ +export class DepartCustomerTruckDto { + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; + + @IsNumber() + @Min(0) + grossWeightKg!: number; + + /** Gate-out time. Defaults to now when omitted. */ + @IsOptional() + @IsDateString() + gateOutTime?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts new file mode 100644 index 000000000..2f5ea86af --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts @@ -0,0 +1,17 @@ +import { ArrayUnique, IsArray, IsOptional, Matches } from 'class-validator'; + +/** + * Confirm a Goods Received Note. Omit `containerNumbers` to GRN every + * received-but-un-GRN'd container on the booking (per-booking when that's all of + * them); pass a subset to GRN just those. + */ +export class GenerateGrnDto { + @IsOptional() + @IsArray() + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts index 532d6b1a7..0ee77aeed 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts @@ -27,6 +27,20 @@ export class PriceLineItemDto { currency!: string; } +export class OverweightLineDto { + @ApiProperty() + containerTypeCode!: string; + + @ApiProperty() + totalVgmTons!: number; + + @ApiProperty() + maxAllowedTons!: number; + + @ApiProperty() + excessTons!: number; +} + export class GeneratePriceResponseDto { @ApiProperty() bookingId!: string; @@ -42,4 +56,16 @@ export class GeneratePriceResponseDto { @ApiProperty({ type: [String] }) warnings!: string[]; + + /** Overweight container lines (VGM over the weight-limit rule) — surcharge already in lineItems. */ + @ApiProperty({ type: [OverweightLineDto] }) + overweightLines!: OverweightLineDto[]; + + /** + * 20ft weight-pairing violations. Non-empty means the booking cannot be + * balanced onto wagons and submit is HARD-BLOCKED — the customer must fix + * container weights/quantities. (Overweight, by contrast, only warns.) + */ + @ApiProperty({ type: [String] }) + pairingErrors!: string[]; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts new file mode 100644 index 000000000..11c80f687 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts index e8ef1b138..619013280 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts @@ -34,4 +34,17 @@ export class BookingContainerUnit extends BaseEntity { @Column({ name: 'sort_order', type: 'smallint', default: 0 }) sortOrder!: number; + + /** Whether this container has been received into the port (auto-set when its + * self-haul truck arrives). */ + @Column({ name: 'received_to_port', type: 'boolean', default: false }) + receivedToPort!: boolean; + + @Column({ name: 'received_at', type: 'timestamptz', nullable: true }) + receivedAt?: Date | null; + + /** The GRN this container was received under (assigned when staff confirm the + * Goods Received Note for a batch of received containers). */ + @Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true }) + grnNumber?: string | null; } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts index db9746c09..182ff153d 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { ContainerType } from '../../rule-engine/entities/container-type.entity'; import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity'; import { Booking } from './booking.entity'; +import { BookingContainerUnit } from './booking-container-unit.entity'; @Entity({ schema: 'freight', name: 'booking_container' }) @Index(['bookingId']) @@ -61,4 +62,8 @@ export class BookingContainer extends BaseEntity { @Column({ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) overweightExcessTons?: number | null; + + /** The physical containers under this line — each with its own number + VGM. */ + @OneToMany(() => BookingContainerUnit, (u) => u.bookingContainer) + units?: BookingContainerUnit[]; } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 19aa3a199..398c64809 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -37,6 +37,7 @@ export const BOOKING_STATUSES = [ 'PAYMENT_VERIFICATION_IN_PROGRESS', 'PAID', 'IN_TRANSIT', + 'ARRIVED', 'COMPLETED', 'REJECTED', 'CANCELLED', @@ -51,6 +52,7 @@ export const BOOKING_STATUSES = [ // Road (truck) drawdown orders skip the train batch pool and wait here for // truck dispatch after Marketing accepts; billed by KM, not wagons. 'ROAD_DISPATCH_PENDING', + 'TRUCK_ASSIGNED', 'OPERATION_REQUESTED', // Operations review gate: customer picks a schedule day and submits the // operation request; the operations team reviews capacity/docs/route before @@ -156,6 +158,10 @@ export class Booking extends BaseEntity { @Column({ name: 'contract_route_id', type: 'uuid', nullable: true }) contractRouteId?: string | null; + /** Booking origin: ONE_TIME (single-shipment) or GENERAL_CONTRACT (drawdown). */ + @Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' }) + bookingType!: string; + /** Denormalized contract kind (ONE_TIME | GENERAL) for the single-active-booking index. */ @Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true }) contractKind?: string | null; @@ -260,6 +266,24 @@ export class Booking extends BaseEntity { @Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true }) lastMileDeliveryLng?: number | null; + @Column({ name: 'customer_truck_plate_number', type: 'varchar', length: 32, nullable: true }) + customerTruckPlateNumber?: string | null; + + @Column({ name: 'customer_truck_driver_name', type: 'varchar', length: 120, nullable: true }) + customerTruckDriverName?: string | null; + + @Column({ name: 'customer_truck_type', type: 'varchar', length: 60, nullable: true }) + customerTruckType?: string | null; + + @Column({ name: 'customer_truck_container_number', type: 'varchar', length: 16, nullable: true }) + customerTruckContainerNumber?: string | null; + + @Column({ name: 'customer_truck_assigned_at', type: 'timestamptz', nullable: true }) + customerTruckAssignedAt?: Date | null; + + @Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true }) + customerTruckArrivedAt?: Date | null; + @Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false }) customsClearingEnabled!: boolean; @@ -272,7 +296,7 @@ export class Booking extends BaseEntity { @Column({ name: 'origin_yard_id', type: 'uuid' }) originYardId!: string; - @ManyToOne(() => Yard) + @ManyToOne(() => Yard) @JoinColumn({ name: 'origin_yard_id' }) originYard?: Yard; @@ -321,6 +345,19 @@ export class Booking extends BaseEntity { @Column({ name: 'is_reefer', type: 'boolean', default: false }) isReefer!: boolean; + /** + * Bulk-only hazardous / reefer amount, in the cargo's own unit of measure + * (tons for PER_TON commodities, item count for PER_ITEM) — i.e. how much of + * `cargoTotalWeightVgm` is hazardous / refrigerated. 0 when none. Container + * freight carries this per line on `booking_container` instead, so these stay + * 0 for CONTAINER bookings. The booleans above remain the surcharge trigger. + */ + @Column({ name: 'bulk_hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + bulkHazardousQuantity!: number; + + @Column({ name: 'bulk_reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + bulkReeferQuantity!: number; + @Column({ name: 'payment_currency', type: 'varchar', length: 5 }) paymentCurrency!: string; @@ -394,6 +431,14 @@ export class Booking extends BaseEntity { @JoinColumn({ name: 'consolidation_partner_id' }) consolidationPartner?: Booking | null; + // Status a booking parked in PENDING_CONSOLIDATION returns to once it pairs. + // Null for direct customer bookings (they resume to SUBMITTED, the historical + // default); contract-drawdown bookings set it to the status createUnderContract + // would otherwise have used (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS), so + // pairing resumes them into the right flow instead of the direct-booking one. + @Column({ name: 'consolidation_resume_status', type: 'varchar', length: 40, nullable: true }) + consolidationResumeStatus?: string | null; + @Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true }) wagonsRequired?: number | null; @@ -422,6 +467,24 @@ export class Booking extends BaseEntity { @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) trainScheduleId?: string | null; + // ── Per-booking journey (segment corridor bookings) ──────────────────────── + // A booking rides only its own origin→destination leg of the train's route, + // so dispatch/arrival are per-booking facts, not train facts. Clearance gates + // read arrivedAt (booking arrival), never the schedule's actualArrivalAt. + /** Operator confirmed cargo loaded at the booking's origin yard (per-booking dispatch). */ + @Column({ name: 'loaded_at', type: 'timestamptz', nullable: true }) + loadedAt?: Date | null; + + @Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true }) + loadedByUserId?: string | null; + + /** Operator confirmed cargo unloaded at the booking's destination yard (per-booking arrival). */ + @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) + arrivedAt?: Date | null; + + @Column({ name: 'arrived_by_user_id', type: 'uuid', nullable: true }) + arrivedByUserId?: string | null; + /** End of the pay window once the booking is SELECTED_FOR_BATCH. */ @Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true }) paymentDeadline?: Date | null; @@ -435,6 +498,25 @@ export class Booking extends BaseEntity { @Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true }) glStationYardId?: string | null; + /** Per-booking phased clearance (GENERAL + customs). */ + @Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true }) + clearanceCurrentPhase?: string | null; + + @Column({ name: 'duty_required', type: 'boolean', nullable: true }) + dutyRequired?: boolean | null; + + @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) + vesselDepartureDate?: string | null; + + @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) + roAmendmentRequestedAt?: Date | null; + + @Column({ name: 'ro_hold_reason', type: 'text', nullable: true }) + roHoldReason?: string | null; + + @Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true }) + preClearanceFinalizedAt?: Date | null; + /** GL staff user bound to this shipment by the station manager. */ @Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true }) glAssignedStaffId?: string | null; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts new file mode 100644 index 000000000..6eeaba963 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts @@ -0,0 +1,47 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { Booking } from './booking.entity'; +import { CustomerTruckContainer } from './customer-truck-container.entity'; + +/** + * One external (self-haul) truck a customer assigns to a booking that has no + * EDR first/last-mile leg. Each truck carries 1–2 containers and tracks its own + * arrival at the terminal/warehouse. + */ +@Entity({ schema: 'freight', name: 'customer_truck_assignments' }) +@Index(['bookingId']) +export class CustomerTruckAssignment extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'plate_number', type: 'varchar', length: 32 }) + plateNumber!: string; + + @Column({ name: 'driver_name', type: 'varchar', length: 120 }) + driverName!: string; + + @Column({ name: 'truck_type', type: 'varchar', length: 60 }) + truckType!: string; + + @Column({ name: 'assigned_at', type: 'timestamptz', default: () => 'now()' }) + assignedAt!: Date; + + @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) + arrivedAt?: Date | null; + + /** Weighed gross of what the truck actually loaded (import), captured on + * leaving. Null until the truck departs. */ + @Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true }) + grossWeightKg?: number | null; + + @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) + departedAt?: Date | null; + + @OneToMany(() => CustomerTruckContainer, (c) => c.assignment, { cascade: true }) + containers?: CustomerTruckContainer[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts new file mode 100644 index 000000000..110e31671 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { CustomerTruckAssignment } from './customer-truck-assignment.entity'; + +/** + * A container number loaded onto a customer truck. A container may be loaded + * onto exactly one truck per booking (enforced by a partial unique index on + * booking_id + container_number). + */ +@Entity({ schema: 'freight', name: 'customer_truck_containers' }) +@Index(['assignmentId']) +export class CustomerTruckContainer extends BaseEntity { + @Column({ name: 'assignment_id', type: 'uuid' }) + assignmentId!: string; + + @ManyToOne(() => CustomerTruckAssignment, (a) => a.containers, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'assignment_id' }) + assignment?: CustomerTruckAssignment; + + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @Column({ name: 'container_number', type: 'varchar', length: 64 }) + containerNumber!: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/pay.controller.ts b/apps/edr-freight-api/src/modules/bookings/pay.controller.ts deleted file mode 100644 index 25f1927d3..000000000 --- a/apps/edr-freight-api/src/modules/bookings/pay.controller.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; -import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; - -import { BookingPaymentService } from './booking-payment.service'; -// import { BookingTransitionService } from './booking-transition.service'; -// import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; -// import { Booking } from './entities/booking.entity'; -// import { BookingNextStep } from './booking-next-step.util'; - -@ApiTags('payments') -@ApiBearerAuth() -@Controller('bookings') -export class PayController { - constructor( - private readonly paymentService: BookingPaymentService, - // private readonly transitionService: BookingTransitionService, - ) { } - - @Post(':id/payment/pay') - @ApiOperation({ summary: 'Complete in-app payment (mock)' }) - @ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' }) - async pay(@Param('id', ParseUUIDPipe) id: string) { - return await this.paymentService.pay(id); - // const abstract = await this.transitionService.enrichBookingResponse(booking); - // return { ...abstract, paymentReceipt: receipt }; - } -} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 4bcc3252a..b1761b35f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -334,15 +334,19 @@ export class CompaniesController { @Param("companyId", ParseUUIDPipe) companyId: string, ) { const files = await this.filesService.findByResource(companyId, "companies"); - return files.map((f) => ({ - id: f.id, - name: f.name, - code: f.code, - mimeType: f.mimeType, - size: f.size, - uploadedAt: f.createdAt, - url: f.url, - })); + return Promise.all( + files.map(async (f) => ({ + id: f.id, + name: f.name, + code: f.code, + mimeType: f.mimeType, + size: f.size, + uploadedAt: f.createdAt, + // Raw `f.url` is an un-signed MinIO path the browser can't open — sign + // it so the file previews/downloads in the client. + url: f.url ? await this.filesService.signUrl(f.url) : f.url, + })), + ); } @Post(":companyId/documents") diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 88871f8ad..42186dd8e 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -33,6 +33,11 @@ import { ETradeService } from "./services/etrade.service"; CompanyDashboardRepository, ETradeService, ], - exports: [CompaniesService], + exports: [ + CompaniesService, + // Consumed by NotificationInboxModule for portal recipient targeting. + ExternalProfileRepository, + CompanyProfileRepository, + ], }) export class CompaniesModule { } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 02f77b2e0..fe679627b 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -334,9 +334,28 @@ export class CompaniesService { const company = await this.companiesRepo.findById(id); if (!company) throw new NotFoundException(`Company ${id} not found`); company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); + for (const profile of company.companyProfiles) { + profile.businessLicenseFiles = await this.signLicenseFiles( + profile.businessLicenseFiles, + ); + } return company; } + /** + * Business-license files are stored as raw, unsigned MinIO URLs (see + * `BusinessLicenseFile` on `CompanyProfile`) — a browser can't fetch them + * directly. Sign each one with a short-lived URL before it reaches a response. + */ + private async signLicenseFiles( + files?: BusinessLicenseFile[] | null, + ): Promise { + if (!files?.length) return []; + return Promise.all( + files.map(async (f) => ({ ...f, url: await this.filesService.signUrl(f.url) })), + ); + } + /** * Validate an explicitly-chosen company profile for a booking: it must belong * to the booking's company and be Active. Used for government bookings (staff @@ -1183,9 +1202,11 @@ export class CompaniesService { const { businessInfo } = await this.etradeService.resolveCompanyData(tin); if (!businessInfo) { throw new BadRequestException( - "No business license found for this TIN. Please check the number and try again.", + "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", ); } - return this.etradeService.extractRegistrationData(businessInfo); + const registrationData = this.etradeService.extractRegistrationData(businessInfo); + const tinTaken = await this.companiesRepo.existsByTin(tin); + return { ...registrationData, tinTaken }; } } diff --git a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts index 842a36616..aa9a31f49 100644 --- a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts @@ -21,6 +21,7 @@ const COMMITTED_STATUSES = [ 'PAYMENT_VERIFICATION_IN_PROGRESS', 'PAID', 'IN_TRANSIT', + 'ARRIVED', 'COMPLETED', 'DELIVERED', 'CONSOLIDATED', diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index e5b686d11..a56ea5ad8 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator'; import { CompanyType, CompanyStatus } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; @@ -17,10 +17,7 @@ export class CreateCompanyDto { @IsString() @IsNotEmpty() - @Length(10, 10) - @Matches(/^00\d{8}$/, { - message: 'TIN must be 10 digits starting with 00', - }) + @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) tin!: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts index 200b69fee..ef7eb2a21 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts @@ -17,6 +17,7 @@ export class ETradeResponseDto implements CompanyRegistrationData { managerName!: string; managerEmail?: string; managerPhone!: string; + tinTaken?: boolean; constructor(data: CompanyRegistrationData) { this.licenceNumber = data.licenceNumber; @@ -35,5 +36,6 @@ export class ETradeResponseDto implements CompanyRegistrationData { this.managerName = data.managerName; this.managerEmail = data.managerEmail; this.managerPhone = data.managerPhone; + this.tinTaken = data.tinTaken; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 316038dc9..9fd8f28ae 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator'; import { CompanyNationality } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; @@ -34,10 +34,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() - @Length(10, 10) - @Matches(/^00\d{8}$/, { - message: 'TIN must be 10 digits starting with 00', - }) + @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) tin?: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts new file mode 100644 index 000000000..2a5715647 --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts @@ -0,0 +1,53 @@ +import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { ComplianceService } from './compliance.service'; +import { + CreateComplianceRecordDto, + UpdateComplianceRecordDto, +} from './dto/create-compliance-record.dto'; +import { ComplianceType } from './entities/compliance-record.entity'; + +@ApiTags('Vehicle Compliance') +@Controller('compliance') +export class ComplianceController { + constructor(private readonly complianceService: ComplianceService) {} + + @Post() + @ApiOperation({ summary: 'Create a compliance record' }) + create(@Body() dto: CreateComplianceRecordDto) { + return this.complianceService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List compliance records' }) + findAll( + @Query('vehicleId') vehicleId?: string, + @Query('type') type?: ComplianceType, + ) { + return this.complianceService.findAll({ vehicleId, type }); + } + + @Get('alerts') + @ApiOperation({ summary: 'List overdue / due-soon compliance & expiry alerts' }) + getAlerts() { + return this.complianceService.getAlerts(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a compliance record by ID' }) + findOne(@Param('id') id: string) { + return this.complianceService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a compliance record' }) + update(@Param('id') id: string, @Body() dto: UpdateComplianceRecordDto) { + return this.complianceService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Soft-delete a compliance record' }) + remove(@Param('id') id: string) { + return this.complianceService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.module.ts b/apps/edr-freight-api/src/modules/compliance/compliance.module.ts new file mode 100644 index 000000000..1477fbc8b --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/compliance.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ComplianceRecord } from './entities/compliance-record.entity'; +import { Vehicle } from '../vehicles/entities/vehicle.entity'; +import { Driver } from '../drivers/entities/driver.entity'; +import { ComplianceService } from './compliance.service'; +import { ComplianceRepository } from './compliance.repository'; +import { ComplianceController } from './compliance.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([ComplianceRecord, Vehicle, Driver])], + providers: [ComplianceService, ComplianceRepository], + controllers: [ComplianceController], + exports: [ComplianceService], +}) +export class ComplianceModule {} diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.repository.ts b/apps/edr-freight-api/src/modules/compliance/compliance.repository.ts new file mode 100644 index 000000000..e9764f8a4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/compliance.repository.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository, FindOptionsWhere } from 'typeorm'; +import { ComplianceRecord, ComplianceType } from './entities/compliance-record.entity'; + +@Injectable() +export class ComplianceRepository extends BaseRepository { + constructor( + @InjectRepository(ComplianceRecord) + private readonly complianceRepository: Repository, + ) { + super(complianceRepository); + } + + async findWithFilters(filter: { vehicleId?: string; type?: ComplianceType } = {}) { + const where: FindOptionsWhere = {}; + if (filter.vehicleId) where.vehicleId = filter.vehicleId; + if (filter.type) where.type = filter.type; + + return this.complianceRepository.find({ + where, + order: { expiryDate: 'ASC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.service.ts b/apps/edr-freight-api/src/modules/compliance/compliance.service.ts new file mode 100644 index 000000000..ec6e2a803 --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/compliance.service.ts @@ -0,0 +1,184 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, IsNull, Repository } from 'typeorm'; +import { ComplianceRepository } from './compliance.repository'; +import { + ComplianceRecord, + ComplianceStatus, + ComplianceType, +} from './entities/compliance-record.entity'; +import { + CreateComplianceRecordDto, + UpdateComplianceRecordDto, +} from './dto/create-compliance-record.dto'; +import { Vehicle } from '../vehicles/entities/vehicle.entity'; +import { Driver } from '../drivers/entities/driver.entity'; + +const DUE_SOON_DAYS = 30; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export type AlertSeverity = 'OVERDUE' | 'DUE_SOON'; + +export interface ComplianceAlert { + vehicleId: string; + vehiclePlate?: string; + kind: string; + label: string; + expiryDate: string; + daysUntil: number; + severity: AlertSeverity; +} + +@Injectable() +export class ComplianceService { + constructor( + private readonly complianceRepository: ComplianceRepository, + @InjectRepository(Vehicle) + private readonly vehicleRepo: Repository, + @InjectRepository(Driver) + private readonly driverRepo: Repository, + ) {} + + async create(dto: CreateComplianceRecordDto): Promise { + return this.complianceRepository.create({ + ...dto, + status: dto.status ?? this.deriveStatus(dto.expiryDate), + }); + } + + async findAll(filter: { vehicleId?: string; type?: ComplianceType } = {}) { + return this.complianceRepository.findWithFilters(filter); + } + + async findById(id: string): Promise { + const record = await this.complianceRepository.findById(id); + if (!record) { + throw new NotFoundException(`Compliance record ${id} not found`); + } + return record; + } + + async update(id: string, dto: UpdateComplianceRecordDto): Promise { + await this.findById(id); + const nextExpiry = dto.expiryDate; + const updated = await this.complianceRepository.update(id, { + ...dto, + // Re-derive status when expiry changes and the caller didn't set it explicitly. + status: dto.status ?? (nextExpiry ? this.deriveStatus(nextExpiry) : undefined), + }); + return updated!; + } + + async remove(id: string): Promise { + await this.findById(id); + await this.complianceRepository.softDelete(id); + } + + /** + * Flat list of compliance items that are overdue or due within 30 days. + * Combines the compliance_records table with the vehicle expiry columns + * (insurance / registration / next inspection) and assigned-driver license + * expiry. `new Date()` is fine here — this is the NestJS API runtime. + */ + async getAlerts(): Promise { + const now = new Date(); + const alerts: ComplianceAlert[] = []; + + const vehicles = await this.vehicleRepo.find({ where: { deletedAt: IsNull() } }); + const vehicleById = new Map(vehicles.map((v) => [v.id, v])); + const plateOf = (v?: Vehicle) => v?.plateNumber ?? v?.code ?? undefined; + + // 1. Compliance records + const records = await this.complianceRepository.findWithFilters(); + for (const record of records) { + const computed = this.computeSeverity(record.expiryDate, now); + if (!computed) continue; + const vehicle = vehicleById.get(record.vehicleId); + alerts.push({ + vehicleId: record.vehicleId, + vehiclePlate: plateOf(vehicle), + kind: record.type, + label: record.documentNumber + ? `${record.type} · ${record.documentNumber}` + : record.type, + expiryDate: record.expiryDate, + daysUntil: computed.daysUntil, + severity: computed.severity, + }); + } + + // 2. Vehicle-level expiry columns + const vehicleFields: { field: keyof Vehicle; kind: string; label: string }[] = [ + { field: 'insuranceExpiry', kind: 'INSURANCE', label: 'Insurance' }, + { field: 'registrationExpiry', kind: 'REGISTRATION', label: 'Registration' }, + { field: 'nextInspectionDate', kind: 'INSPECTION', label: 'Inspection' }, + ]; + for (const vehicle of vehicles) { + for (const { field, kind, label } of vehicleFields) { + const value = vehicle[field] as string | undefined; + if (!value) continue; + const computed = this.computeSeverity(value, now); + if (!computed) continue; + alerts.push({ + vehicleId: vehicle.id, + vehiclePlate: plateOf(vehicle), + kind, + label, + expiryDate: value, + daysUntil: computed.daysUntil, + severity: computed.severity, + }); + } + } + + // 3. Assigned-driver license expiry + const driverIds = [ + ...new Set(vehicles.map((v) => v.assignedDriverId).filter((id): id is string => !!id)), + ]; + if (driverIds.length > 0) { + const drivers = await this.driverRepo.find({ where: { id: In(driverIds) } }); + const driverById = new Map(drivers.map((d) => [d.id, d])); + for (const vehicle of vehicles) { + if (!vehicle.assignedDriverId) continue; + const driver = driverById.get(vehicle.assignedDriverId); + if (!driver?.licenseExpiryDate) continue; + const expiry = + driver.licenseExpiryDate instanceof Date + ? driver.licenseExpiryDate.toISOString().slice(0, 10) + : String(driver.licenseExpiryDate); + const computed = this.computeSeverity(expiry, now); + if (!computed) continue; + alerts.push({ + vehicleId: vehicle.id, + vehiclePlate: plateOf(vehicle), + kind: 'DRIVER_LICENSE', + label: `Driver License · ${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(), + expiryDate: expiry, + daysUntil: computed.daysUntil, + severity: computed.severity, + }); + } + } + + return alerts.sort((a, b) => a.daysUntil - b.daysUntil); + } + + private computeSeverity( + expiryDate: string, + now: Date, + ): { daysUntil: number; severity: AlertSeverity } | null { + const daysUntil = Math.ceil((new Date(expiryDate).getTime() - now.getTime()) / MS_PER_DAY); + if (daysUntil < 0) return { daysUntil, severity: 'OVERDUE' }; + if (daysUntil <= DUE_SOON_DAYS) return { daysUntil, severity: 'DUE_SOON' }; + return null; + } + + private deriveStatus(expiryDate: string): ComplianceStatus { + const daysUntil = Math.ceil( + (new Date(expiryDate).getTime() - Date.now()) / MS_PER_DAY, + ); + if (daysUntil < 0) return ComplianceStatus.EXPIRED; + if (daysUntil <= DUE_SOON_DAYS) return ComplianceStatus.EXPIRING; + return ComplianceStatus.VALID; + } +} diff --git a/apps/edr-freight-api/src/modules/compliance/dto/create-compliance-record.dto.ts b/apps/edr-freight-api/src/modules/compliance/dto/create-compliance-record.dto.ts new file mode 100644 index 000000000..8b716ef15 --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/dto/create-compliance-record.dto.ts @@ -0,0 +1,55 @@ +import { IsUUID, IsString, IsDateString, IsOptional, IsEnum } from 'class-validator'; +import { ComplianceType, ComplianceStatus } from '../entities/compliance-record.entity'; + +export class CreateComplianceRecordDto { + @IsUUID() + vehicleId!: string; + + @IsEnum(ComplianceType) + type!: ComplianceType; + + @IsOptional() + @IsString() + documentNumber?: string; + + @IsOptional() + @IsDateString() + issuedDate?: string; + + @IsDateString() + expiryDate!: string; + + @IsOptional() + @IsEnum(ComplianceStatus) + status?: ComplianceStatus; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateComplianceRecordDto { + @IsOptional() + @IsEnum(ComplianceType) + type?: ComplianceType; + + @IsOptional() + @IsString() + documentNumber?: string; + + @IsOptional() + @IsDateString() + issuedDate?: string; + + @IsOptional() + @IsDateString() + expiryDate?: string; + + @IsOptional() + @IsEnum(ComplianceStatus) + status?: ComplianceStatus; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/compliance/entities/compliance-record.entity.ts b/apps/edr-freight-api/src/modules/compliance/entities/compliance-record.entity.ts new file mode 100644 index 000000000..04355c1f9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/compliance/entities/compliance-record.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +export enum ComplianceType { + INSPECTION = 'INSPECTION', + INSURANCE = 'INSURANCE', + ROADWORTHINESS = 'ROADWORTHINESS', + PERMIT = 'PERMIT', + TAX = 'TAX', +} + +export enum ComplianceStatus { + VALID = 'VALID', + EXPIRING = 'EXPIRING', + EXPIRED = 'EXPIRED', +} + +@Entity({ name: 'compliance_records', schema: 'freight' }) +@Index(['vehicleId', 'expiryDate']) +export class ComplianceRecord extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false, nullable: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'type', type: 'varchar' }) + type!: ComplianceType; + + @Column({ name: 'document_number', type: 'varchar', nullable: true }) + documentNumber?: string; + + @Column({ name: 'issued_date', type: 'date', nullable: true }) + issuedDate?: string; + + @Column({ name: 'expiry_date', type: 'date' }) + expiryDate!: string; + + @Column({ name: 'status', type: 'varchar', default: ComplianceStatus.VALID }) + status!: ComplianceStatus; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts new file mode 100644 index 000000000..7b5cb77d6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -0,0 +1,212 @@ +import { BadRequestException } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; + +import { BookingClearanceService } from './booking-clearance.service'; +import type { Booking } from '../bookings/entities/booking.entity'; + +const generalImportBooking = { + id: 'b-general', + status: 'DOCUMENTS_UNDER_REVIEW', + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + customsClearingEnabled: true, + contractKind: 'GENERAL', + contractId: 'c-1', + dutyRequired: true, + roHoldReason: null, + vesselDepartureDate: null, +} as Booking; + +const generalExportBooking = { + ...generalImportBooking, + id: 'b-export', + tradeDirection: 'EXPORT', + dutyRequired: null, +} as Booking; + +function makeService(overrides?: { + booking?: Booking; + workflowThrows?: boolean; +}) { + const booking = overrides?.booking ?? generalImportBooking; + const bookingsRepository = { + findDocumentReviews: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue(booking), + }; + const bookingsService = { + findById: jest.fn().mockResolvedValue(booking), + }; + const filesService = { + upsertByCode: jest.fn().mockResolvedValue({}), + findByResource: jest.fn().mockResolvedValue([]), + }; + const fileUploadSettingsService = { + getByCode: jest.fn().mockRejectedValue(new Error('no setting')), + }; + const workflowService = { + assertPriorCompleteForBooking: overrides?.workflowThrows + ? jest.fn().mockRejectedValue(new BadRequestException('Prior milestone incomplete')) + : jest.fn().mockResolvedValue(undefined), + completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined), + onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined), + onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined), + listMilestonesForBooking: jest.fn().mockResolvedValue([]), + resolvePhaseForBooking: jest.fn().mockReturnValue(null), + computeNextActionForBooking: jest.fn().mockReturnValue(null), + isBoundaryCompleteForBooking: jest.fn().mockResolvedValue(false), + markReadyForOperation: jest.fn().mockResolvedValue(undefined), + onExportReleasedForBooking: jest.fn().mockResolvedValue(undefined), + }; + const milestoneService = { + adviseDuty: jest.fn().mockResolvedValue(undefined), + }; + const dropdownSettingsService = { + getByCode: jest.fn().mockResolvedValue({ + children: [{ value: '2' }], + }), + }; + const glOperationsService = { + t1State: jest.fn().mockResolvedValue({ + bookingId: 'b-general', + wagonAllocated: false, + trainDepartedAt: null, + trainArrivedAt: null, + closed: false, + closedAt: null, + }), + }; + + const service = new BookingClearanceService( + bookingsRepository as never, + bookingsService as never, + filesService as never, + fileUploadSettingsService as never, + workflowService as never, + milestoneService as never, + dropdownSettingsService as never, + glOperationsService as never, + { + dutyAdvised: jest.fn(), + clearanceReady: jest.fn(), + documentQueried: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + clearanceDocsUploadedToStaff: jest.fn(), + } as never, // notifier + ); + + return { + service, + bookingsRepository, + bookingsService, + filesService, + workflowService, + milestoneService, + }; +} + +describe('BookingClearanceService', () => { + describe('adviseDuty', () => { + it('skips duty milestones when duty is not required', async () => { + const { service, workflowService, bookingsRepository } = makeService(); + await service.adviseDuty('b-general', { dutyRequired: false }); + + expect(workflowService.onDutySkippedForBooking).toHaveBeenCalledWith('b-general'); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ + dutyRequired: false, + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + }), + ); + }); + + it('records duty advice when duty applies', async () => { + const { service, milestoneService } = makeService(); + await service.adviseDuty( + 'b-general', + { + dutyRequired: true, + amount: 1500, + currency: 'ETB', + declarationSerial: 'DS-1', + }, + undefined, + // The duty notice attachment is now mandatory when duty applies. + { fieldname: 'duty_tax_notice' } as Express.Multer.File, + ); + + expect(milestoneService.adviseDuty).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ amount: 1500, currency: 'ETB', declarationSerial: 'DS-1' }), + undefined, + ); + }); + }); + + describe('uploadDutySlip', () => { + it('rejects when duty is not required', async () => { + const { service } = makeService({ + booking: { ...generalImportBooking, dutyRequired: false } as Booking, + }); + await expect( + service.uploadDutySlip('b-general', { fieldname: 'file' } as Express.Multer.File), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('uploads slip and completes DUTY_TAX_PAID on happy path', async () => { + const { service, filesService, workflowService, bookingsRepository } = makeService(); + const file = { fieldname: 'file' } as Express.Multer.File; + + await service.uploadDutySlip('b-general', file); + + expect(filesService.upsertByCode).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'b-general', + code: 'duty_tax_receipt', + file, + }), + ); + expect(workflowService.completeMilestoneForBooking).toHaveBeenCalledWith( + 'b-general', + 'DUTY_TAX_PAID', + ); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + }), + ); + }); + }); + + describe('uploadDeclaration', () => { + it('rejects when a prior milestone is incomplete', async () => { + const { service } = makeService({ workflowThrows: true }); + await expect( + service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('uploadReleaseOrder', () => { + it('places RO on hold when vessel departs too soon', async () => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const dateStr = tomorrow.toISOString().slice(0, 10); + + const { service, bookingsRepository } = makeService({ booking: generalExportBooking }); + const result = await service.uploadReleaseOrder( + 'b-export', + { fieldname: 'ro' } as Express.Multer.File, + dateStr, + ); + + expect(result.hold).toBe(true); + expect(result.holdReason).toMatch(/minimum lead time/i); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-export', + expect.objectContaining({ roHoldReason: expect.any(String) }), + ); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts new file mode 100644 index 000000000..59dad2248 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -0,0 +1,731 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { + ContractDocPhase, + type ClearanceFinalInvoiceSummary, + type ClearanceSecondDuty, + type ClearanceT1State, + type ClearanceTrainState, +} from '@edr/types'; + +import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; +import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; +import { FilesService } from '../files/files.service'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingsService } from '../bookings/bookings.service'; +import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { Booking } from '../bookings/entities/booking.entity'; +import { clearanceCodesForBooking } from '../bookings/clearance.util'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { GlOperationsService } from './gl-operations.service'; +import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; + +const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; + +export interface BookingClearanceView { + bookingId: string; + status: string; + includesCustoms: boolean; + inputCode: string | null; + outputCode: string | null; + documents: Array<{ + fileKey: string; + label: string; + required: boolean; + uploadedBy: 'customer' | 'gl'; + settingCode: string; + file: { id: string; name: string; url: string } | null; + reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null; + note: string | null; + }>; + allApproved: boolean; + phase?: string | null; + milestones?: Array<{ + id: string; + milestoneCode: string; + milestoneLabel: string; + status: string; + ownerRegion?: string | null; + metadata?: Record | null; + sortOrder: number; + }>; + nextAction?: { + actor: string; + action: string; + milestoneCode?: string | null; + blockedReason?: string | null; + } | null; + dutyRequired?: boolean | null; + roHold?: boolean; + roHoldReason?: string | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: string | null; + operationReady?: boolean; + preClearanceFinalized?: boolean; + dutyAdvice?: { + amount: number; + currency: string; + declarationSerial?: string | null; + noticeFile?: { id: string; name: string; url: string } | null; + } | null; + workflowFiles?: ReturnType; + /** Import post-allocation T1 transit document state (null until wagon allocation). */ + t1?: ClearanceT1State | null; + /** Train link state for the booking (both directions). */ + train?: ClearanceTrainState | null; + gatepassGranted?: boolean; + gatepassAt?: string | null; + t1Closed?: boolean; + t1ClosedAt?: string | null; + offloaded?: boolean; + /** GL Djibouti post-offload final invoice (export). */ + finalInvoice?: ClearanceFinalInvoiceSummary | null; + /** Customs risk level assigned by GL ET (import; visible to the customer). */ + riskLevel?: string | null; + riskAssignedAt?: string | null; + /** Post-arrival additional duty/tax round (import). */ + secondDuty?: ClearanceSecondDuty | null; + importReleaseGranted?: boolean; +} + +@Injectable() +export class BookingClearanceService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly bookingsService: BookingsService, + private readonly filesService: FilesService, + private readonly fileUploadSettingsService: FileUploadSettingsService, + private readonly workflowService: ClearanceWorkflowService, + private readonly milestoneService: ClearanceMilestoneService, + private readonly dropdownSettingsService: DropdownSettingsService, + private readonly glOperationsService: GlOperationsService, + private readonly notifier: BookingLifecycleNotifierService, + ) {} + + private async assertPhasedGeneralCustoms(booking: Booking): Promise { + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Phased clearance applies only to customs bookings.'); + } + if (booking.contractKind !== 'GENERAL') { + throw new BadRequestException('Per-booking phased clearance applies to general contracts.'); + } + if (!booking.contractId) { + throw new BadRequestException('Booking is not linked to a contract.'); + } + } + + private async loadBooking(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + await this.assertPhasedGeneralCustoms(booking); + return booking; + } + + async getClearanceView(bookingId: string): Promise { + const booking = await this.loadBooking(bookingId); + const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking); + + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const fileByCode = new Map(files.map((f) => [f.code, f])); + const reviews = await this.bookingsRepository.findDocumentReviews(bookingId); + const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r])); + + const documents: BookingClearanceView['documents'] = []; + + const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => { + if (!code) return; + let setting; + try { + setting = await this.fileUploadSettingsService.getByCode(code); + } catch { + return; + } + for (const field of setting.fields ?? []) { + const file = fileByCode.get(field.fileKey) ?? null; + const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null; + documents.push({ + fileKey: field.fileKey, + label: field.fileLabel, + required: field.isRequired, + uploadedBy, + settingCode: code, + file: file ? { id: file.id, name: file.name, url: file.url } : null, + reviewStatus: review?.status ?? null, + note: review?.note ?? null, + }); + } + }; + + await pushSetting(inputCode, 'customer'); + await pushSetting(outputCode, 'gl'); + + for (const f of files) { + if (!f.code?.startsWith('custom_')) continue; + const review = reviewByKey.get(`custom:${f.code}`) ?? null; + documents.push({ + fileKey: f.code, + label: f.name, + required: false, + uploadedBy: 'customer', + settingCode: 'custom', + file: { id: f.id, name: f.name, url: f.url }, + reviewStatus: review?.status ?? null, + note: review?.note ?? null, + }); + } + + const allApproved = await this.isClearanceFullyApproved(booking); + let milestones = await this.workflowService.listMilestonesForBooking(bookingId); + + // Self-heal: a booking that has settled its freight payment must have + // FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an + // export FCFS booking (linked to its train at booking time) paid via the + // prepaid invoice can leave the milestone PENDING — the clearance "Payment & + // wagon allocation" step then never ticks. Backfill it here so already-stuck + // rows recover without a migration; idempotent (no-op once COMPLETED). + const paymentSettled = milestones.find( + (m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED', + ); + if ( + paymentSettled && + paymentSettled.status === 'PENDING' && + (booking.paymentStatus === 'PAID' || booking.status === 'PAID') + ) { + await this.workflowService.completeMilestoneForBooking( + bookingId, + 'FREIGHT_PAYMENT_SETTLED', + ); + milestones = await this.workflowService.listMilestonesForBooking(bookingId); + } + const phase = this.workflowService.resolvePhaseForBooking(booking, milestones); + const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones); + const boundary = await this.workflowService.isBoundaryCompleteForBooking( + bookingId, + booking.tradeDirection ?? 'IMPORT', + ); + const dutyAdvice = this.buildDutyAdvice(files, milestones); + const workflowFiles = buildWorkflowFiles( + files, + booking.tradeDirection ?? 'IMPORT', + ); + + let t1: ClearanceT1State | null = null; + if ((booking.tradeDirection ?? 'IMPORT') === 'IMPORT') { + try { + t1 = await this.glOperationsService.t1State(bookingId); + } catch { + t1 = null; + } + } + + let train: ClearanceTrainState | null = null; + try { + train = await this.glOperationsService.trainState(bookingId); + } catch { + train = null; + } + const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId); + const bookingMilestone = (code: string) => + milestones.find((m) => m.milestoneCode === code); + const gatepass = await this.glOperationsService.gatepassForBooking(bookingId); + const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); + const riskMilestone = bookingMilestone('RISK_ASSIGNED'); + const secondDuty = this.glOperationsService.secondDutyState(milestones, files); + + return { + bookingId, + status: booking.status, + includesCustoms, + inputCode, + outputCode, + documents, + allApproved, + phase, + milestones: milestones.map((m) => ({ + id: m.id, + milestoneCode: m.milestoneCode, + milestoneLabel: m.milestoneLabel, + status: m.status, + ownerRegion: m.ownerRegion, + metadata: (m.metadata ?? null) as Record | null, + sortOrder: m.sortOrder, + })), + nextAction, + dutyRequired: booking.dutyRequired ?? null, + roHold: Boolean(booking.roHoldReason), + roHoldReason: booking.roHoldReason ?? null, + vesselDepartureDate: booking.vesselDepartureDate ?? null, + roAmendmentRequestedAt: booking.roAmendmentRequestedAt + ? booking.roAmendmentRequestedAt.toISOString() + : null, + operationReady: boundary, + preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt), + dutyAdvice, + workflowFiles, + t1, + train, + gatepassGranted: gatepass.granted, + gatepassAt: gatepass.grantedAt, + t1Closed: t1ClosedMilestone?.status === 'COMPLETED', + t1ClosedAt: + t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt + ? t1ClosedMilestone.triggeredAt.toISOString() + : null, + offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + finalInvoice, + riskLevel: + riskMilestone?.status === 'COMPLETED' + ? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null) + : null, + riskAssignedAt: + riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt + ? riskMilestone.triggeredAt.toISOString() + : null, + secondDuty, + importReleaseGranted: + bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', + }; + } + + private buildDutyAdvice( + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + milestones: ClearanceMilestone[], + ): BookingClearanceView['dutyAdvice'] { + const advised = milestones.find( + (m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED', + ); + if (!advised?.metadata) return null; + const amount = advised.metadata.dutyAmount; + const currency = advised.metadata.dutyCurrency; + if (typeof amount !== 'number' || typeof currency !== 'string') return null; + const notice = files.find((f) => f.code === 'duty_tax_notice'); + return { + amount, + currency, + declarationSerial: + typeof advised.metadata.declarationSerial === 'string' + ? advised.metadata.declarationSerial + : null, + noticeFile: notice + ? { id: notice.id, name: notice.name, url: notice.url } + : null, + }; + } + + private async isClearanceFullyApproved(booking: Booking): Promise { + const { inputCode } = clearanceCodesForBooking(booking); + if (!inputCode) return true; + let setting; + try { + setting = await this.fileUploadSettingsService.getByCode(inputCode); + } catch { + return false; + } + const required = (setting.fields ?? []).filter((f) => f.isRequired); + if (required.length === 0) return true; + const reviews = await this.bookingsRepository.findDocumentReviews(booking.id); + return required.every((field) => + reviews.some( + (r) => + r.settingCode === inputCode && + r.fileKey === field.fileKey && + r.status === 'APPROVED', + ), + ); + } + + isPhasedGeneralCustomsBooking(booking: Booking): boolean { + return ( + Boolean(booking.customsClearingEnabled) && + booking.contractKind === 'GENERAL' && + Boolean(booking.contractId) + ); + } + + async uploadDeclaration( + bookingId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + const allApproved = await this.isClearanceFullyApproved(booking); + if (!allApproved) { + throw new BadRequestException( + 'All required customer documents must be approved before uploading a declaration.', + ); + } + const milestones = await this.workflowService.listMilestonesForBooking(bookingId); + const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); + if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { + await this.workflowService.onAllDocsApprovedForBooking(bookingId); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + tradeDirection, + 'UNDER_CUSTOMS_CLEARANCE', + ); + + if (files.length === 0) { + throw new BadRequestException('No declaration documents uploaded'); + } + + await persistDeclarationUploads(this.filesService, bookingId, 'bookings', files); + + await this.workflowService.onDeclarationUploadedForBooking(bookingId, userId); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: + tradeDirection === 'EXPORT' + ? ContractDocPhase.GlEtPostClearance + : ContractDocPhase.CustomerDuty, + } as never); + + // Export: the declaration is the last GL ET pre-operation action — release + // immediately so the customer can proceed without a separate confirm click. + if (tradeDirection === 'EXPORT') { + await this.workflowService.onExportReleasedForBooking(bookingId, userId); + } + + return this.bookingsService.findById(bookingId); + } + + async adviseDuty( + bookingId: string, + dto: AdviseContractDutyDto, + userId?: string, + attachment?: Express.Multer.File, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty advice applies only to import bookings.'); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'IMPORT', + 'DUTY_TAXES_ADVISED', + ); + + await this.bookingsRepository.update(bookingId, { + dutyRequired: dto.dutyRequired, + clearanceCurrentPhase: dto.dutyRequired + ? ContractDocPhase.CustomerDuty + : ContractDocPhase.GlEtPostClearance, + } as never); + + if (!dto.dutyRequired) { + await this.workflowService.onDutySkippedForBooking(bookingId); + } else { + if (dto.amount == null || dto.amount < 0) { + throw new BadRequestException('Duty amount is required when duty applies.'); + } + if (!attachment) { + throw new BadRequestException('Duty notice attachment is required when duty applies.'); + } + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_notice', + file: attachment, + }); + await this.milestoneService.adviseDuty( + bookingId, + { + amount: dto.amount, + currency: dto.currency ?? 'ETB', + declarationSerial: dto.declarationSerial, + }, + userId, + ); + this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB'); + } + + return this.bookingsService.findById(bookingId); + } + + async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty slip upload applies only to import bookings.'); + } + if (!booking.dutyRequired) { + throw new BadRequestException('Duty/tax is not required for this clearance.'); + } + if (!file) throw new BadRequestException('No payment slip uploaded'); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_receipt', + file, + }); + + await this.workflowService.completeMilestoneForBooking(bookingId, 'DUTY_TAX_PAID'); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + } as never); + + this.notifier.dutySlipUploadedToStaff(booking, 'first'); + return this.bookingsService.findById(bookingId); + } + + async uploadTransitPermit( + bookingId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Transit permit applies only to import bookings.'); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } + + await persistTransitPermitUploads(this.filesService, bookingId, 'bookings', files); + + await this.workflowService.completeMilestoneForBooking( + bookingId, + 'TRANSIT_PERMIT_UPLOADED', + userId, + ); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + } as never); + + return this.bookingsService.findById(bookingId); + } + + async finalizePreClearance(bookingId: string): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Pre-clearance finalize applies only to import bookings.'); + } + + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + + if (booking.preClearanceFinalizedAt) { + return this.bookingsService.findById(bookingId); + } + + await this.bookingsRepository.update(bookingId, { + preClearanceFinalizedAt: new Date(), + clearanceCurrentPhase: ContractDocPhase.GlDjCollection, + } as never); + + // GL Djibouti may have uploaded the DO early (un-gated) — count it now. + const files = await this.filesService.findByResource(bookingId, 'bookings'); + if (files.some((f) => f.code === 'delivery_order')) { + await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED'); + await this.workflowService.markReadyForOperation(bookingId); + } + + return this.bookingsService.findById(bookingId); + } + + async uploadDeliveryOrder( + bookingId: string, + file: Express.Multer.File, + userId?: string, + vesselDepartureDate?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Delivery Order applies only to import bookings.'); + } + + if (!file) throw new BadRequestException('No Delivery Order uploaded'); + + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, + // any file type. The DO_COLLECTED milestone (and operation readiness) still + // waits for GL Ethiopia to finalize pre-clearance so the workflow order holds. + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'delivery_order', + file, + }); + + if (vesselDepartureDate?.trim()) { + await this.bookingsRepository.update(bookingId, { + vesselDepartureDate: vesselDepartureDate.trim(), + } as never); + } + + if (booking.preClearanceFinalizedAt) { + await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForOperation(bookingId); + } + + return this.bookingsService.findById(bookingId); + } + + private async resolveRoMinDays(): Promise { + try { + const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE); + const first = setting.children?.[0]; + const n = Number(first?.value); + return Number.isFinite(n) && n > 0 ? n : 2; + } catch { + return 2; + } + } + + private daysUntil(dateStr: string): number { + const target = new Date(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + target.setHours(0, 0, 0, 0); + return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)); + } + + async uploadReleaseOrder( + bookingId: string, + file: Express.Multer.File, + vesselDepartureDate: string, + userId?: string, + ): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Release Order applies only to export bookings.'); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'EXPORT', + 'RELEASE_ORDER_SECURED', + ); + + if (!file) throw new BadRequestException('No Release Order uploaded'); + if (!vesselDepartureDate?.trim()) { + throw new BadRequestException('Vessel departure date is required'); + } + + const minDays = await this.resolveRoMinDays(); + const leadDays = this.daysUntil(vesselDepartureDate); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'release_order', + file, + }); + + await this.bookingsRepository.update(bookingId, { + vesselDepartureDate, + roAmendmentRequestedAt: null, + } as never); + + if (leadDays < minDays) { + const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`; + await this.bookingsRepository.update(bookingId, { + roHoldReason: reason, + clearanceCurrentPhase: ContractDocPhase.GlDjCollection, + } as never); + return { + booking: await this.bookingsService.findById(bookingId), + hold: true, + holdReason: reason, + }; + } + + await this.bookingsRepository.update(bookingId, { + roHoldReason: null, + clearanceCurrentPhase: ContractDocPhase.GlEtOutput, + } as never); + await this.workflowService.completeMilestoneForBooking( + bookingId, + 'RELEASE_ORDER_SECURED', + userId, + ); + + return { booking: await this.bookingsService.findById(bookingId), hold: false }; + } + + async requestRoAmendment( + bookingId: string, + note?: string, + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('RO amendment applies only to export bookings.'); + } + + const reason = + note?.trim() || + 'Port amendment requested — vessel departure window is too short. A new Release Order will be required.'; + + await this.bookingsRepository.update(bookingId, { + roAmendmentRequestedAt: new Date(), + roHoldReason: reason, + clearanceCurrentPhase: ContractDocPhase.GlDjCollection, + } as never); + + if (userId) { + await this.bookingsRepository.createReviewNote( + bookingId, + reason, + 'CHANGES_REQUESTED', + userId, + ); + } + + return this.bookingsService.findById(bookingId); + } + + async confirmExportRelease(bookingId: string, userId?: string): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Export release applies only to export bookings.'); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'EXPORT', + 'EXPORT_RELEASED', + ); + await this.workflowService.onExportReleasedForBooking(bookingId, userId); + return this.bookingsService.findById(bookingId); + } + + async etQueue(): Promise { + const candidates = await this.bookingsRepository.findByStatuses([ + ...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES, + ]); + const filtered: Booking[] = []; + for (const b of candidates) { + if (!this.isPhasedGeneralCustomsBooking(b)) continue; + const milestones = await this.workflowService.listMilestonesForBooking(b.id); + if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); + } + return filtered; + } + + async djQueue(): Promise { + const candidates = await this.bookingsRepository.findByStatuses([ + ...DJ_BOOKING_QUEUE_STATUSES, + ]); + const filtered: Booking[] = []; + for (const b of candidates) { + if (!this.isPhasedGeneralCustomsBooking(b)) continue; + const milestones = await this.workflowService.listMilestonesForBooking(b.id); + if ( + belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, { + roHoldReason: b.roHoldReason, + preClearanceFinalizedAt: b.preClearanceFinalizedAt, + }) + ) { + filtered.push(b); + } + } + return filtered; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts index 9529cabbe..0ae829529 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts @@ -34,12 +34,36 @@ export class BookingRequestRepository extends BaseRepository { async findById(id: string): Promise { return this.repository.findOne({ where: { id }, - relations: { contract: true }, + // Load the contract with the bits the detail page surfaces: customer + // (company), service type (mile/customs flags), routes (with yard labels) + // and cargo scope. + relations: { + contract: { + company: true, + serviceType: true, + routes: { originYard: true, destinationYard: true }, + cargoScope: true, + }, + }, }); } - /** Total rows — used to mint the next sequential reference. */ - async count(): Promise { - return this.repository.count(); + /** + * Highest NNNNNN sequence already issued for `SR-…` references (all-time — + * these are not year-scoped). Includes soft-deleted rows so a cancel/delete + * can't make the next number reuse an earlier one. A plain row count drifts + * below the issued sequence after any delete and hands out duplicates. + */ + async maxReferenceSequence(): Promise { + const row = await this.repository + .createQueryBuilder('request') + .withDeleted() + .select( + "COALESCE(MAX(CAST(SUBSTRING(request.reference FROM '[0-9]+$') AS int)), 0)", + 'max', + ) + .where('request.reference LIKE :prefix', { prefix: 'SR-%' }) + .getRawOne<{ max: string | number | null }>(); + return Number(row?.max ?? 0); } } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 199276862..4752b004f 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -10,6 +10,7 @@ import type { Freight } from '@edr/types'; import { BookingRequestRepository } from './booking-request.repository'; import { ContractsService } from './contracts.service'; import { ContractBookingService } from './contract-booking.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { BookingRequest } from './entities/booking-request.entity'; import { Contract } from './entities/contract.entity'; import { CreateBookingRequestDto } from './dto/create-booking-request.dto'; @@ -26,6 +27,7 @@ export class BookingRequestService { private readonly repo: BookingRequestRepository, private readonly contractsService: ContractsService, private readonly contractBookingService: ContractBookingService, + private readonly notifier: ContractNotifierService, ) {} /** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */ @@ -107,7 +109,7 @@ export class BookingRequestService { }; const reference = await this.generateReference(); - return this.repo.create({ + const request = await this.repo.create({ reference, contractId, requestedByUserId: userId ?? null, @@ -117,6 +119,8 @@ export class BookingRequestService { requestedLines, notes: dto.notes ?? null, } as never); + this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference); + return request; } listForContract(contractId: string): Promise { @@ -192,8 +196,7 @@ export class BookingRequestService { } private async generateReference(): Promise { - const count = await this.repo.count(); - const seq = String(count + 1).padStart(6, '0'); - return `SR-${seq}`; + const seq = await this.repo.maxReferenceSequence(); + return `SR-${String(seq + 1).padStart(6, '0')}`; } } diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts index 3389933a7..648d9666f 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts @@ -22,6 +22,11 @@ const IMPORT_DEFS: Record> = { DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true }, DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false }, DUTY_TAX_PAID: { label: 'Duty and Tax Paid', ownerRegion: 'CUST', triggeredByDoc: true }, + TRANSIT_PERMIT_UPLOADED: { + label: 'Transit Permit Uploaded', + ownerRegion: 'ET', + triggeredByDoc: true, + }, DO_COLLECTED: { label: 'DO Collected', ownerRegion: 'DJ', triggeredByDoc: true }, WAGON_REQUESTED: { label: 'Wagon Allocation Requested', ownerRegion: 'ET', triggeredByDoc: false }, FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled (freight)', ownerRegion: 'CUST', triggeredByDoc: true }, @@ -34,6 +39,16 @@ const IMPORT_DEFS: Record> = { OFFLOADED: { label: 'Offloaded', ownerRegion: 'OPS', triggeredByDoc: false }, T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'ET', triggeredByDoc: false }, RISK_ASSIGNED: { label: 'Risk Assigned', ownerRegion: 'ET', triggeredByDoc: false }, + SECOND_DUTY_ADVISED: { + label: 'Additional Duty and Taxes Advised', + ownerRegion: 'ET', + triggeredByDoc: false, + }, + SECOND_DUTY_PAID: { + label: 'Additional Duty and Tax Paid', + ownerRegion: 'CUST', + triggeredByDoc: true, + }, IMPORT_RELEASE_GRANTED: { label: 'Import Release Granted', ownerRegion: 'ET', triggeredByDoc: true }, IMPORT_PROCESS_COMPLETED: { label: 'Import Process Completed', ownerRegion: 'ET', triggeredByDoc: true }, STORAGE_INVOICE_RAISED: { label: 'Storage Invoice Raised', ownerRegion: 'OPS', triggeredByDoc: false }, @@ -52,12 +67,18 @@ const EXPORT_DEFS: Record> = { FREIGHT_PAYMENT_PENDING: { label: 'Pending Payment', ownerRegion: 'CUST', triggeredByDoc: false }, FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled', ownerRegion: 'CUST', triggeredByDoc: true }, WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false }, + EXPORT_TRANSPORT_ISSUED: { + label: 'Export Transport Document Issued', + ownerRegion: 'ET', + triggeredByDoc: true, + }, CARGO_ARRIVED: { label: 'Cargo Arrived', ownerRegion: 'OPS', triggeredByDoc: false }, READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'OPS', triggeredByDoc: false }, LOADED: { label: 'Loaded', ownerRegion: 'OPS', triggeredByDoc: false }, DEPARTED_TO_DJIBOUTI: { label: 'Departed to Djibouti', ownerRegion: 'OPS', triggeredByDoc: false }, ARRIVED_AT_DJIBOUTI: { label: 'Arrived at Djibouti', ownerRegion: 'DJ', triggeredByDoc: false }, GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false }, + T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'DJ', triggeredByDoc: false }, OFFLOADED: { label: 'Offloaded', ownerRegion: 'DJ', triggeredByDoc: true }, }; diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index 947fc6979..4a58e50be 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { @@ -6,6 +6,7 @@ import { CustomsRiskLevel, MilestoneMetadata, } from './entities/clearance-milestone.entity'; +import { Booking } from '../bookings/entities/booking.entity'; import { Contract } from './entities/contract.entity'; import { HANDOFF_MILESTONES, @@ -39,6 +40,15 @@ export class ClearanceMilestoneService { }); } + /** Seed pre-booking milestones on a booking (GENERAL + customs per-shipment clearance). */ + async seedPreBookingMilestonesOnBooking( + bookingId: string, + tradeDirection: string, + ): Promise { + const { preBooking } = splitMilestones(tradeDirection); + await this.seed(preBooking, { bookingId }); + } + /** Seed the post-booking milestones onto a freshly created booking. */ async seedPostBookingMilestones( bookingId: string, @@ -76,10 +86,69 @@ export class ClearanceMilestoneService { } async listForBooking(bookingId: string): Promise { - return this.repo.find({ + const rows = await this.repo.find({ where: { bookingId }, order: { sortOrder: 'ASC' }, }); + + // Self-heal: a booking that has settled its freight payment must have + // FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an + // export FCFS booking (linked to its train at booking time) paid via the + // prepaid invoice can leave the milestone PENDING — the clearance "Payment & + // wagon allocation" step then never ticks. getClearanceView backfills it, but + // the stepper reads its gating milestones straight from here, so heal here too. + // Idempotent (no-op once COMPLETED); recovers already-stuck rows with no migration. + const paymentSettled = rows.find( + (m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED', + ); + if (paymentSettled && paymentSettled.status === 'PENDING') { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: bookingId }, + select: { id: true, status: true, paymentStatus: true }, + }); + if (booking?.paymentStatus === 'PAID' || booking?.status === 'PAID') { + await this.completeForBooking(bookingId, 'FREIGHT_PAYMENT_SETTLED'); + return this.repo.find({ + where: { bookingId }, + order: { sortOrder: 'ASC' }, + }); + } + } + + return rows; + } + + /** + * Find-or-create a post-booking milestone row from the catalog. Needed for codes + * added to the catalog after a booking's rows were seeded (e.g. export T1_CLOSED). + */ + async ensureForBooking( + bookingId: string, + code: string, + tradeDirection: string, + ): Promise { + const existing = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (existing) return existing; + + const { postBooking } = splitMilestones(tradeDirection); + const idx = postBooking.findIndex((d) => d.code === code); + if (idx < 0) { + throw new NotFoundException( + `Milestone ${code} is not a ${tradeDirection} post-booking milestone`, + ); + } + const def = postBooking[idx]!; + return this.repo.save( + this.repo.create({ + bookingId, + milestoneCode: def.code, + milestoneLabel: def.label, + ownerRegion: def.ownerRegion, + triggeredByDoc: def.triggeredByDoc, + status: 'PENDING', + sortOrder: idx, + }), + ); } /** Mark a milestone complete (by code) on a booking. */ @@ -94,7 +163,7 @@ export class ClearanceMilestoneService { throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`); } if (milestone.status === 'COMPLETED') { - throw new BadRequestException(`Milestone ${code} is already completed.`); + return milestone; } milestone.status = 'COMPLETED'; milestone.triggeredAt = new Date(); @@ -178,7 +247,7 @@ export class ClearanceMilestoneService { throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); } if (milestone.status === 'COMPLETED') { - throw new BadRequestException(`Milestone ${code} is already completed.`); + return milestone; } milestone.status = 'COMPLETED'; milestone.triggeredAt = new Date(); @@ -187,6 +256,99 @@ export class ClearanceMilestoneService { return this.repo.save(milestone); } + /** Skip optional milestones (e.g. duty when not required). */ + /** Reopen a completed contract milestone so review can continue after a query. */ + async reopenForContract(contractId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); + if (!milestone || milestone.status !== 'COMPLETED') return; + milestone.status = 'PENDING'; + milestone.triggeredAt = null; + milestone.triggeredByUserId = null; + await this.repo.save(milestone); + } + + /** Reopen a completed booking milestone so review can continue after a query. */ + async reopenForBooking(bookingId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (!milestone || milestone.status !== 'COMPLETED') return; + milestone.status = 'PENDING'; + milestone.triggeredAt = null; + milestone.triggeredByUserId = null; + await this.repo.save(milestone); + } + + async skipForContract(contractId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); + } + if (milestone.status === 'COMPLETED') return milestone; + milestone.status = 'SKIPPED'; + milestone.triggeredAt = new Date(); + return this.repo.save(milestone); + } + + async skipForBooking(bookingId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`); + } + if (milestone.status === 'COMPLETED') return milestone; + milestone.status = 'SKIPPED'; + milestone.triggeredAt = new Date(); + return this.repo.save(milestone); + } + + async completeWithMetadataForBooking( + bookingId: string, + code: string, + metadata: MilestoneMetadata, + userId?: string, + note?: string, + ): Promise { + return this.completeWithMetadata(bookingId, code, metadata, userId, note); + } + + /** Complete a contract milestone with structured metadata (duty advice, etc.). */ + async completeWithMetadataForContract( + contractId: string, + code: string, + metadata: MilestoneMetadata, + userId?: string, + note?: string, + ): Promise { + const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); + } + if (milestone.status === 'COMPLETED') { + return milestone; + } + milestone.status = 'COMPLETED'; + milestone.triggeredAt = new Date(); + milestone.triggeredByUserId = userId ?? null; + milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata }; + if (note) milestone.note = note; + return this.repo.save(milestone); + } + + async adviseDutyForContract( + contractId: string, + input: { amount: number; currency: string; declarationSerial?: string }, + userId?: string, + ): Promise { + return this.completeWithMetadataForContract( + contractId, + 'DUTY_TAXES_ADVISED', + { + dutyAmount: input.amount, + dutyCurrency: input.currency, + declarationSerial: input.declarationSerial, + }, + userId, + ); + } + /** Complete a doc-triggered milestone when its document is uploaded/approved. */ async completeByDocTrigger( scope: { bookingId?: string; contractId?: string }, diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts new file mode 100644 index 000000000..75178d640 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts @@ -0,0 +1,332 @@ +import { BadRequestException } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; + +import { ClearanceWorkflowService } from './clearance-workflow.service'; +import type { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import type { Contract } from './entities/contract.entity'; +import type { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; +import type { Booking } from '../bookings/entities/booking.entity'; + +function ms( + code: string, + status: 'PENDING' | 'COMPLETED' | 'SKIPPED', + ownerRegion: 'ET' | 'DJ' | 'CUST' | 'OPS' = 'ET', +): ClearanceMilestone { + return { milestoneCode: code, status, ownerRegion } as ClearanceMilestone; +} + +function importThroughDeclaration(): ClearanceMilestone[] { + return [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('UNDER_CUSTOMS_CLEARANCE', 'PENDING', 'ET'), + ms('DECLARED', 'PENDING', 'ET'), + ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'), + ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; +} + +function makeService(milestones: ClearanceMilestone[]) { + const contractsRepository = { + currentCycle: jest.fn(), + update: jest.fn(), + setCycleStatus: jest.fn(), + updateCycle: jest.fn(), + }; + const milestoneService = { + listForContract: jest.fn().mockResolvedValue(milestones), + listForBooking: jest.fn().mockResolvedValue(milestones), + skipForContract: jest.fn(), + completeForContract: jest.fn(), + completeWithMetadataForContract: jest.fn(), + }; + const bookingsRepository = { update: jest.fn() }; + const service = new ClearanceWorkflowService( + contractsRepository as never, + milestoneService as never, + bookingsRepository as never, + { clearanceReady: jest.fn() } as never, // notifier + ); + return { service, milestoneService, contractsRepository, bookingsRepository }; +} + +const importContract = { + id: 'c-import', + tradeDirection: 'IMPORT', + customsClearingEnabled: true, + contractKind: 'ONE_TIME', +} as Contract; + +const exportContract = { + id: 'c-export', + tradeDirection: 'EXPORT', + customsClearingEnabled: true, + contractKind: 'ONE_TIME', +} as Contract; + +describe('ClearanceWorkflowService', () => { + describe('boundaryMilestone', () => { + it('uses DO_COLLECTED for import and EXPORT_RELEASED for export', () => { + const { service } = makeService([]); + expect(service.boundaryMilestone('IMPORT')).toBe('DO_COLLECTED'); + expect(service.boundaryMilestone('EXPORT')).toBe('EXPORT_RELEASED'); + }); + }); + + describe('assertPriorComplete', () => { + it('rejects when a prior milestone is still pending', async () => { + const milestones = importThroughDeclaration().map((m) => + m.milestoneCode === 'DOCUMENTS_APPROVED' + ? ms('DOCUMENTS_APPROVED', 'PENDING', 'ET') + : m, + ); + const { service } = makeService(milestones); + await expect( + service.assertPriorComplete('c-import', 'IMPORT', 'DECLARED'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows proceeding when prior milestones are completed or skipped', async () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('UNDER_CUSTOMS_CLEARANCE', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; + const { service } = makeService(milestones); + await expect( + service.assertPriorComplete('c-import', 'IMPORT', 'TRANSIT_PERMIT_UPLOADED'), + ).resolves.toBeUndefined(); + }); + }); + + describe('isBoundaryComplete', () => { + it('returns true only when boundary milestone is completed', async () => { + const done = [ + ...importThroughDeclaration().slice(0, -1), + ms('DO_COLLECTED', 'COMPLETED', 'DJ'), + ]; + const { service: doneSvc } = makeService(done); + await expect(doneSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(true); + + const pending = importThroughDeclaration(); + const { service: pendingSvc } = makeService(pending); + await expect(pendingSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(false); + }); + }); + + describe('onDutySkipped', () => { + it('skips duty milestones on the contract', async () => { + const { service, milestoneService } = makeService([]); + await service.onDutySkipped('c-import'); + expect(milestoneService.skipForContract).toHaveBeenCalledWith( + 'c-import', + 'DUTY_TAXES_ADVISED', + ); + expect(milestoneService.skipForContract).toHaveBeenCalledWith( + 'c-import', + 'DUTY_TAX_PAID', + ); + }); + }); + + describe('computeNextAction — import happy path', () => { + it('prompts customer to upload docs first', () => { + const { service } = makeService([ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST')]); + const next = service.computeNextAction(importContract, null, [ + ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST'), + ]); + expect(next?.actor).toBe('CUSTOMER'); + expect(next?.milestoneCode).toBe('IMPORT_DOCS_UPLOADED'); + }); + + it('prompts ET review after customer docs', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), + ]; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, null, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.action).toMatch(/Review/i); + }); + + it('prompts duty toggle when declaration done and duty unset', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'), + ]; + const cycle = { dutyRequired: null } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.action).toMatch(/duty/i); + }); + + it('prompts customer duty slip when duty required and advised', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'COMPLETED', 'ET'), + ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), + ]; + const cycle = { dutyRequired: true } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('CUSTOMER'); + expect(next?.milestoneCode).toBe('DUTY_TAX_PAID'); + }); + + it('skips duty path when duty not required', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), + ]; + const cycle = { dutyRequired: false } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.milestoneCode).toBe('TRANSIT_PERMIT_UPLOADED'); + }); + + it('prompts ET to finalize pre-clearance after transit permit', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; + const cycle = { dutyRequired: false } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.action).toMatch(/finalize pre-clearance/i); + }); + + it('prompts DJ for DO then ET booking when pre-booking complete', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; + const cycle = { + dutyRequired: false, + preClearanceFinalizedAt: new Date(), + } as ContractClearanceCycle; + const { service } = makeService(milestones); + const djNext = service.computeNextAction(importContract, cycle, milestones); + expect(djNext?.actor).toBe('GL_DJ'); + + const booked = milestones.map((m) => + m.milestoneCode === 'DO_COLLECTED' ? ms('DO_COLLECTED', 'COMPLETED', 'DJ') : m, + ); + const etNext = service.computeNextAction(importContract, cycle, booked); + expect(etNext?.actor).toBe('GL_ET'); + expect(etNext?.action).toMatch(/booking/i); + }); + }); + + describe('computeNextAction — export RO hold', () => { + it('surfaces DJ action when RO is on hold', () => { + const milestones = [ + ms('EXPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('RELEASE_ORDER_SECURED', 'PENDING', 'DJ'), + ]; + const cycle = { + roHoldReason: 'Vessel departs in 1 day(s) — minimum lead time is 2 day(s).', + } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(exportContract, cycle, milestones); + expect(next?.actor).toBe('GL_DJ'); + expect(next?.blockedReason).toMatch(/minimum lead time/i); + }); + }); + + describe('inferPhase', () => { + it('places import contract in customer duty phase when duty outstanding', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), + ]; + const cycle = { dutyRequired: true } as ContractClearanceCycle; + const { service } = makeService(milestones); + const phase = service.inferPhase(importContract, cycle, milestones); + expect(phase).toBe(ContractDocPhase.CustomerDuty); + }); + }); + + describe('queue helpers', () => { + it('returns first pending ET-owned milestone code', () => { + const { service } = makeService([ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]); + expect(service.etPendingMilestoneCodes([])).toBeNull(); + expect( + service.etPendingMilestoneCodes([ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), + ]), + ).toBe('DOCUMENTS_APPROVED'); + }); + + it('returns first pending DJ-owned milestone code', () => { + const { service } = makeService([]); + expect( + service.djPendingMilestoneCodes([ + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]), + ).toBe('DO_COLLECTED'); + }); + }); + + describe('computeNextActionForBooking', () => { + it('prompts customer to proceed after import boundary on booking', () => { + const booking = { + tradeDirection: 'IMPORT', + dutyRequired: false, + preClearanceFinalizedAt: new Date(), + } as Booking; + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'COMPLETED', 'DJ'), + ]; + const { service } = makeService(milestones); + const next = service.computeNextActionForBooking(booking, milestones); + expect(next?.actor).toBe('CUSTOMER'); + expect(next?.action).toMatch(/operation/i); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts new file mode 100644 index 000000000..9b17a3e76 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts @@ -0,0 +1,576 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; + +import { ContractsRepository } from './contracts.repository'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { MilestoneMetadata } from './entities/clearance-milestone.entity'; +import { splitMilestones } from './clearance-milestone.catalog'; +import { Contract } from './entities/contract.entity'; +import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import type { ClearanceMetaState } from './clearance-workflow.types'; +import { metaFromBooking } from './clearance-workflow.types'; + +export type ClearanceActorRole = 'CUSTOMER' | 'GL_ET' | 'GL_DJ' | 'OPERATIONS'; + +export interface ClearanceNextAction { + actor: ClearanceActorRole; + action: string; + milestoneCode?: string | null; + blockedReason?: string | null; +} + +const IMPORT_BOUNDARY = 'DO_COLLECTED'; +const EXPORT_BOUNDARY = 'EXPORT_RELEASED'; + +const IMPORT_DOC_UPLOADED = 'IMPORT_DOCS_UPLOADED'; +const EXPORT_DOC_UPLOADED = 'EXPORT_DOCS_UPLOADED'; + +@Injectable() +export class ClearanceWorkflowService { + constructor( + private readonly contractsRepository: ContractsRepository, + private readonly milestoneService: ClearanceMilestoneService, + private readonly bookingsRepository: BookingsRepository, + private readonly notifier: BookingLifecycleNotifierService, + ) {} + + boundaryMilestone(tradeDirection: string): string { + return tradeDirection === 'IMPORT' ? IMPORT_BOUNDARY : EXPORT_BOUNDARY; + } + + // ── Contract scope (ONE_TIME) ───────────────────────────────────────────── + + async listMilestones(contractId: string): Promise { + return this.milestoneService.listForContract(contractId); + } + + async listMilestonesForBooking(bookingId: string): Promise { + return this.milestoneService.listForBooking(bookingId); + } + + async isBoundaryComplete(contractId: string, tradeDirection: string): Promise { + return this.isBoundaryCompleteForMilestones( + await this.listMilestones(contractId), + tradeDirection, + ); + } + + async isBoundaryCompleteForBooking( + bookingId: string, + tradeDirection: string, + ): Promise { + return this.isBoundaryCompleteForMilestones( + await this.listMilestonesForBooking(bookingId), + tradeDirection, + ); + } + + private isBoundaryCompleteForMilestones( + milestones: ClearanceMilestone[], + tradeDirection: string, + ): boolean { + const code = this.boundaryMilestone(tradeDirection); + const m = milestones.find((x) => x.milestoneCode === code); + return m?.status === 'COMPLETED'; + } + + async assertBoundaryComplete(contract: Contract): Promise { + const ok = await this.isBoundaryComplete(contract.id, contract.tradeDirection); + if (!ok) { + throw new BadRequestException( + `Pre-booking clearance is not complete — ${this.boundaryMilestone(contract.tradeDirection)} must be finished before booking.`, + ); + } + } + + async assertPriorComplete( + contractId: string, + tradeDirection: string, + targetCode: string, + ): Promise { + await this.assertPriorCompleteOnMilestones( + await this.listMilestones(contractId), + tradeDirection, + targetCode, + ); + } + + async assertPriorCompleteForBooking( + bookingId: string, + tradeDirection: string, + targetCode: string, + ): Promise { + await this.assertPriorCompleteOnMilestones( + await this.listMilestonesForBooking(bookingId), + tradeDirection, + targetCode, + ); + } + + private async assertPriorCompleteOnMilestones( + milestones: ClearanceMilestone[], + tradeDirection: string, + targetCode: string, + ): Promise { + const { preBooking } = splitMilestones(tradeDirection); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + const targetIdx = preBooking.findIndex((d) => d.code === targetCode); + if (targetIdx < 0) return; + + for (let i = 0; i < targetIdx; i++) { + + const code = preBooking[i]!.code; + const m = byCode.get(code); + if (!m) continue; + if (m.status === 'SKIPPED') continue; + if (m.status !== 'COMPLETED') { + throw new BadRequestException( + `Complete "${preBooking[i]!.label}" before proceeding.`, + ); + } + } + } + + async skipMilestones(contractId: string, codes: string[]): Promise { + for (const code of codes) { + await this.milestoneService.skipForContract(contractId, code); + } + } + + async skipMilestonesForBooking(bookingId: string, codes: string[]): Promise { + for (const code of codes) { + await this.milestoneService.skipForBooking(bookingId, code); + } + } + + async completeMilestone( + contractId: string, + code: string, + userId?: string, + metadata?: MilestoneMetadata, + ): Promise { + if (metadata && Object.keys(metadata).length > 0) { + return this.milestoneService.completeWithMetadataForContract( + contractId, + code, + metadata, + userId, + ); + } + return this.milestoneService.completeForContract(contractId, code, userId); + } + + async completeMilestoneForBooking( + bookingId: string, + code: string, + userId?: string, + metadata?: MilestoneMetadata, + ): Promise { + if (metadata && Object.keys(metadata).length > 0) { + return this.milestoneService.completeWithMetadataForBooking( + bookingId, + code, + metadata, + userId, + ); + } + return this.milestoneService.completeForBooking(bookingId, code, userId); + } + + async onCustomerDocsUploaded(contractId: string, tradeDirection: string): Promise { + const uploaded = + tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; + await this.completeMilestone(contractId, uploaded); + await this.completeMilestone(contractId, 'PENDING_DOCUMENT_REVIEW'); + } + + async onCustomerDocsUploadedForBooking( + bookingId: string, + tradeDirection: string, + ): Promise { + const uploaded = + tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; + await this.completeMilestoneForBooking(bookingId, uploaded); + await this.completeMilestoneForBooking(bookingId, 'PENDING_DOCUMENT_REVIEW'); + } + + async onAllDocsApproved(contractId: string): Promise { + await this.completeMilestone(contractId, 'DOCUMENTS_APPROVED'); + } + + async onAllDocsApprovedForBooking(bookingId: string): Promise { + await this.completeMilestoneForBooking(bookingId, 'DOCUMENTS_APPROVED'); + } + + /** Customer doc queried or re-uploaded — document approval milestone must reopen. */ + async onDocumentReviewReopened(contractId: string): Promise { + await this.milestoneService.reopenForContract(contractId, 'DOCUMENTS_APPROVED'); + } + + async onDocumentReviewReopenedForBooking(bookingId: string): Promise { + await this.milestoneService.reopenForBooking(bookingId, 'DOCUMENTS_APPROVED'); + } + + async onDeclarationUploaded(contractId: string, userId?: string): Promise { + await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE'); + await this.completeMilestone(contractId, 'DECLARED', userId); + } + + async onDeclarationUploadedForBooking(bookingId: string, userId?: string): Promise { + await this.completeMilestoneForBooking(bookingId, 'UNDER_CUSTOMS_CLEARANCE'); + await this.completeMilestoneForBooking(bookingId, 'DECLARED', userId); + } + + async onDutySkipped(contractId: string): Promise { + await this.skipMilestones(contractId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']); + } + + async onDutySkippedForBooking(bookingId: string): Promise { + await this.skipMilestonesForBooking(bookingId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']); + } + + async onExportReleased(contractId: string, userId?: string): Promise { + await this.completeMilestone(contractId, 'EXPORT_RELEASED', userId); + await this.markReadyForBooking(contractId); + } + + async onExportReleasedForBooking(bookingId: string, userId?: string): Promise { + await this.completeMilestoneForBooking(bookingId, 'EXPORT_RELEASED', userId); + await this.markReadyForOperation(bookingId); + } + + async markReadyForBooking(contractId: string): Promise { + const cycle = await this.contractsRepository.currentCycle(contractId); + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_READY_FOR_BOOKING', + clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING', + } as never); + if (cycle) { + await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', { + clearanceReadyAt: new Date(), + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + } + } + + /** GENERAL per-booking: boundary complete → customer may proceed to operations. */ + async markReadyForOperation(bookingId: string): Promise { + await this.bookingsRepository.update(bookingId, { + status: 'CLEARANCE_READY', + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + } as never); + // Tell the customer clearance is done and operation can be requested. Load + // failure only skips the notice — the status change above already committed. + try { + const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); + if (booking) this.notifier.clearanceReady(booking); + } catch { + /* notification is best-effort */ + } + } + + resolvePhase( + contract: Contract, + cycle: ContractClearanceCycle | null, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + const meta: ClearanceMetaState = { + dutyRequired: cycle?.dutyRequired ?? null, + vesselDepartureDate: cycle?.vesselDepartureDate ?? null, + roHoldReason: cycle?.roHoldReason ?? null, + currentPhase: cycle?.currentPhase ?? null, + }; + return this.resolvePhaseFromMeta(contract.tradeDirection, meta, milestones); + } + + resolvePhaseForBooking( + booking: Booking, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + return this.resolvePhaseFromMeta( + booking.tradeDirection ?? 'IMPORT', + metaFromBooking(booking), + milestones, + ); + } + + private resolvePhaseFromMeta( + tradeDirection: string, + meta: ClearanceMetaState, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + if (meta.currentPhase) { + return meta.currentPhase as ContractDocPhase; + } + return this.inferPhaseFromMeta(tradeDirection, meta, milestones); + } + + inferPhase( + contract: Contract, + cycle: ContractClearanceCycle | null, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + return this.inferPhaseFromMeta( + contract.tradeDirection, + { + dutyRequired: cycle?.dutyRequired ?? null, + roHoldReason: cycle?.roHoldReason ?? null, + }, + milestones, + ); + } + + inferPhaseForBooking(booking: Booking, milestones: ClearanceMilestone[]): ContractDocPhase { + return this.inferPhaseFromMeta( + booking.tradeDirection ?? 'IMPORT', + metaFromBooking(booking), + milestones, + ); + } + + private inferPhaseFromMeta( + tradeDirection: string, + meta: ClearanceMetaState, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + const isDone = (code: string) => + byCode.get(code)?.status === 'COMPLETED' || byCode.get(code)?.status === 'SKIPPED'; + + const docUploaded = + tradeDirection === 'IMPORT' + ? isDone(IMPORT_DOC_UPLOADED) + : isDone(EXPORT_DOC_UPLOADED); + + if (!docUploaded) return ContractDocPhase.CustomerIntake; + if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview; + + if (tradeDirection === 'EXPORT') { + if (!isDone('RELEASE_ORDER_SECURED')) { + return ContractDocPhase.GlDjCollection; + } + if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; + if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance; + return ContractDocPhase.GlEtPostClearance; + } + + if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; + if (meta.dutyRequired === true && !isDone('DUTY_TAX_PAID')) { + return ContractDocPhase.CustomerDuty; + } + if (!isDone('TRANSIT_PERMIT_UPLOADED')) return ContractDocPhase.GlEtPostClearance; + if (!meta.preClearanceFinalizedAt) return ContractDocPhase.GlEtPostClearance; + if (!isDone(IMPORT_BOUNDARY)) return ContractDocPhase.GlDjCollection; + return ContractDocPhase.GlEtPostClearance; + } + + computeNextAction( + contract: Contract, + cycle: ContractClearanceCycle | null, + milestones: ClearanceMilestone[], + ): ClearanceNextAction | null { + return this.computeNextActionFromMeta( + contract.tradeDirection, + { + dutyRequired: cycle?.dutyRequired ?? null, + roHoldReason: cycle?.roHoldReason ?? null, + preClearanceFinalizedAt: cycle?.preClearanceFinalizedAt ?? null, + }, + milestones, + 'contract', + ); + } + + computeNextActionForBooking( + booking: Booking, + milestones: ClearanceMilestone[], + ): ClearanceNextAction | null { + return this.computeNextActionFromMeta( + booking.tradeDirection ?? 'IMPORT', + metaFromBooking(booking), + milestones, + 'booking', + ); + } + + private computeNextActionFromMeta( + tradeDirection: string, + meta: ClearanceMetaState, + milestones: ClearanceMilestone[], + terminalScope: 'contract' | 'booking', + ): ClearanceNextAction | null { + if (meta.roHoldReason) { + return { + actor: 'GL_DJ', + action: 'Re-upload Release Order or request port amendment', + milestoneCode: 'RELEASE_ORDER_SECURED', + blockedReason: meta.roHoldReason, + }; + } + + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + const pending = (code: string) => { + const m = byCode.get(code); + return m && m.status === 'PENDING'; + }; + const isDone = (code: string) => { + const m = byCode.get(code); + return m?.status === 'COMPLETED' || m?.status === 'SKIPPED'; + }; + + const docCode = + tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; + + if (pending(docCode) || !isDone(docCode)) { + return { + actor: 'CUSTOMER', + action: 'Upload clearance documents', + milestoneCode: docCode, + }; + } + + if (!isDone('DOCUMENTS_APPROVED')) { + return { + actor: 'GL_ET', + action: 'Review and approve customer documents', + milestoneCode: 'DOCUMENTS_APPROVED', + }; + } + + const terminalAction = + terminalScope === 'contract' + ? 'Create shipment booking' + : 'Proceed to request operation'; + + if (tradeDirection === 'EXPORT') { + if (!isDone('RELEASE_ORDER_SECURED')) { + return { + actor: 'GL_DJ', + action: 'Upload Release Order and vessel departure date', + milestoneCode: 'RELEASE_ORDER_SECURED', + }; + } + if (!isDone('DECLARED')) { + return { + actor: 'GL_ET', + action: 'Upload customs declaration documents', + milestoneCode: 'DECLARED', + }; + } + if (!isDone(EXPORT_BOUNDARY)) { + return { + actor: 'GL_ET', + action: 'Confirm export release', + milestoneCode: EXPORT_BOUNDARY, + }; + } + if (terminalScope === 'booking') { + if (!isDone('FREIGHT_PAYMENT_SETTLED')) { + return { + actor: 'CUSTOMER', + action: 'Pay freight charges', + milestoneCode: 'FREIGHT_PAYMENT_SETTLED', + }; + } + if (!isDone('WAGON_ALLOCATED')) { + return { + actor: 'OPERATIONS', + action: 'Allocate wagon', + milestoneCode: 'WAGON_ALLOCATED', + }; + } + if (!isDone('EXPORT_TRANSPORT_ISSUED')) { + return { + actor: 'GL_ET', + action: 'Upload transit permit', + milestoneCode: 'EXPORT_TRANSPORT_ISSUED', + }; + } + return null; + } + return { + actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER', + action: terminalAction, + milestoneCode: EXPORT_BOUNDARY, + }; + } + + if (!isDone('DECLARED')) { + return { + actor: 'GL_ET', + action: 'Upload customs declaration documents', + milestoneCode: 'DECLARED', + }; + } + + if (meta.dutyRequired === null || meta.dutyRequired === undefined) { + return { + actor: 'GL_ET', + action: 'Set whether duty/tax applies', + milestoneCode: 'DUTY_TAXES_ADVISED', + }; + } + + if (meta.dutyRequired && !isDone('DUTY_TAX_PAID')) { + if (!isDone('DUTY_TAXES_ADVISED')) { + return { + actor: 'GL_ET', + action: 'Advise duty and tax amount', + milestoneCode: 'DUTY_TAXES_ADVISED', + }; + } + return { + actor: 'CUSTOMER', + action: 'Upload duty/tax payment slip', + milestoneCode: 'DUTY_TAX_PAID', + }; + } + + if (!isDone('TRANSIT_PERMIT_UPLOADED')) { + return { + actor: 'GL_ET', + action: 'Upload transit permit screenshot', + milestoneCode: 'TRANSIT_PERMIT_UPLOADED', + }; + } + + if (!meta.preClearanceFinalizedAt) { + return { + actor: 'GL_ET', + action: 'Finalize pre-clearance', + milestoneCode: 'TRANSIT_PERMIT_UPLOADED', + }; + } + + if (!isDone(IMPORT_BOUNDARY)) { + return { + actor: 'GL_DJ', + action: 'Upload Delivery Order', + milestoneCode: IMPORT_BOUNDARY, + }; + } + + return { + actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER', + action: terminalAction, + milestoneCode: IMPORT_BOUNDARY, + }; + } + + etPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null { + const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'ET'); + return pending?.milestoneCode ?? null; + } + + djPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null { + const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'DJ'); + return pending?.milestoneCode ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.types.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.types.ts new file mode 100644 index 000000000..25d01b17e --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.types.ts @@ -0,0 +1,33 @@ +import type { ContractDocPhase } from '@edr/types'; + +/** Shared clearance metadata for contract cycles and per-booking GENERAL clearance. */ +export interface ClearanceMetaState { + dutyRequired?: boolean | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: Date | null; + roHoldReason?: string | null; + currentPhase?: ContractDocPhase | string | null; + preClearanceFinalizedAt?: Date | null; +} + +export type ClearanceScope = + | { kind: 'contract'; contractId: string } + | { kind: 'booking'; bookingId: string }; + +export function metaFromBooking(booking: { + dutyRequired?: boolean | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: Date | null; + roHoldReason?: string | null; + clearanceCurrentPhase?: string | null; + preClearanceFinalizedAt?: Date | null; +}): ClearanceMetaState { + return { + dutyRequired: booking.dutyRequired ?? null, + vesselDepartureDate: booking.vesselDepartureDate ?? null, + roAmendmentRequestedAt: booking.roAmendmentRequestedAt ?? null, + roHoldReason: booking.roHoldReason ?? null, + currentPhase: booking.clearanceCurrentPhase ?? null, + preClearanceFinalizedAt: booking.preClearanceFinalizedAt ?? null, + }; +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts new file mode 100644 index 000000000..a83837350 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -0,0 +1,181 @@ +import { ContractBookingService } from './contract-booking.service'; +import { Booking } from '../bookings/entities/booking.entity'; + +/** + * The GL contract-drawdown path must run wagon consolidation before invoicing. + * A partial-wagon drawdown (e.g. 21× 20FT → one leftover container) parks in + * PENDING_CONSOLIDATION and is NOT finalized (no invoice / milestones) until it + * pairs with a wagon partner. These tests exercise the two new hooks directly. + */ +describe('ContractBookingService — drawdown consolidation gate', () => { + function makeService(overrides: { + consolidationService?: Partial>; + bookingsRepository?: Partial>; + invoiceService?: Partial>; + milestoneService?: Partial>; + contractsRepository?: Partial>; + }) { + const consolidationService = { + slotsFromBooking: jest.fn().mockResolvedValue([]), + describePaired: jest.fn().mockReturnValue('paired'), + describePending: jest.fn().mockReturnValue('pending'), + needsConsolidationFromBooking: jest.fn().mockResolvedValue(false), + ...overrides.consolidationService, + }; + const bookingsRepository = { + findConsolidationPartner: jest.fn().mockResolvedValue(null), + pairConsolidation: jest.fn().mockResolvedValue(undefined), + parkForConsolidation: jest.fn().mockResolvedValue(undefined), + findByIdWithFiles: jest.fn(), + ...overrides.bookingsRepository, + }; + const invoiceService = { + ensureInvoiceForBooking: jest.fn().mockResolvedValue({ id: 'inv-1' }), + ...overrides.invoiceService, + }; + const milestoneService = { + seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined), + seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined), + ...overrides.milestoneService, + }; + const contractsRepository = { + findByIdWithRelations: jest.fn(), + currentCycle: jest.fn().mockResolvedValue(null), + linkBooking: jest.fn().mockResolvedValue(undefined), + update: jest.fn().mockResolvedValue(undefined), + ...overrides.contractsRepository, + }; + + const service = new ContractBookingService( + contractsRepository as never, + bookingsRepository as never, + {} as never, // bookingPricingService + consolidationService as never, + {} as never, // containerTypesService + {} as never, // ruleEngineService + milestoneService as never, + {} as never, // workflowService + invoiceService as never, + {} as never, // dataSource + {} as never, // trainSchedulingService + ); + return { + service, + consolidationService, + bookingsRepository, + invoiceService, + milestoneService, + contractsRepository, + }; + } + + const booking = { id: 'b-1', reference: 'BK-1' } as Booking; + + it('parks (not pairs) when no complementary partner exists', async () => { + const { service, bookingsRepository } = makeService({ + consolidationService: { + slotsFromBooking: jest + .fn() + .mockResolvedValue([{ containerTypeId: 'ct', slotsNeeded: 1 }]), + }, + bookingsRepository: { + findConsolidationPartner: jest.fn().mockResolvedValue(null), + }, + }); + + const result = await (service as never as { + consolidateDrawdown: (b: Booking, s: string) => Promise<{ paired: boolean }>; + }).consolidateDrawdown(booking, 'OPERATION_REQUEST_PENDING'); + + expect(result.paired).toBe(false); + expect(bookingsRepository.parkForConsolidation).toHaveBeenCalledWith( + 'b-1', + 'OPERATION_REQUEST_PENDING', + ); + expect(bookingsRepository.pairConsolidation).not.toHaveBeenCalled(); + }); + + it('pairs when a complementary partner exists', async () => { + const { service, bookingsRepository } = makeService({ + consolidationService: { + slotsFromBooking: jest + .fn() + .mockResolvedValue([{ containerTypeId: 'ct', slotsNeeded: 1 }]), + }, + bookingsRepository: { + findConsolidationPartner: jest + .fn() + .mockResolvedValue({ id: 'p-1', reference: 'BK-2' }), + }, + }); + + const result = await (service as never as { + consolidateDrawdown: (b: Booking, s: string) => Promise<{ paired: boolean }>; + }).consolidateDrawdown(booking, 'AWAITING_DOCUMENTS'); + + expect(result.paired).toBe(true); + expect(bookingsRepository.pairConsolidation).toHaveBeenCalledWith('b-1', 'p-1'); + expect(bookingsRepository.parkForConsolidation).not.toHaveBeenCalled(); + }); + + it('onConsolidationPaired finalizes a resumed contract booking (invoice + milestones)', async () => { + const paired = { + id: 'b-1', + reference: 'BK-1', + contractId: 'c-1', + status: 'OPERATION_REQUEST_PENDING', + } as Booking; + const contract = { + id: 'c-1', + contractKind: 'GENERAL', + customsClearingEnabled: true, + tradeDirection: 'EXPORT', + }; + const { service, invoiceService, milestoneService } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest.fn().mockResolvedValue(paired), + }, + contractsRepository: { + findByIdWithRelations: jest.fn().mockResolvedValue(contract), + }, + }); + + await service.onConsolidationPaired({ bookingIds: ['b-1'] }); + + expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); + // GENERAL customs → per-booking pre + post milestones. + expect(milestoneService.seedPreBookingMilestonesOnBooking).toHaveBeenCalled(); + expect(milestoneService.seedPostBookingMilestones).toHaveBeenCalled(); + }); + + it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => { + const stillPending = { + id: 'b-1', + contractId: 'c-1', + status: 'PENDING_CONSOLIDATION', + } as Booking; + const { service, invoiceService } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest.fn().mockResolvedValue(stillPending), + }, + }); + + await service.onConsolidationPaired({ bookingIds: ['b-1'] }); + + expect(invoiceService.ensureInvoiceForBooking).not.toHaveBeenCalled(); + }); + + it('onConsolidationPaired ignores a non-contract (direct) booking', async () => { + const direct = { id: 'd-1', status: 'SUBMITTED', contractId: null } as Booking; + const { service, invoiceService, contractsRepository } = makeService({ + bookingsRepository: { + findByIdWithFiles: jest.fn().mockResolvedValue(direct), + }, + }); + + await service.onConsolidationPaired({ bookingIds: ['d-1'] }); + + expect(contractsRepository.findByIdWithRelations).not.toHaveBeenCalled(); + expect(invoiceService.ensureInvoiceForBooking).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index b077930b7..55dd5c29f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -1,18 +1,27 @@ import { BadRequestException, ForbiddenException, + Inject, Injectable, Logger, NotFoundException, + forwardRef, } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { OnEvent } from '@nestjs/event-emitter'; +import { insertWithGeneratedReference } from '@edr/api-common'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { ConsolidationService } from '../bookings/consolidation.service'; +import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; +import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; +import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; +import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; @@ -23,6 +32,7 @@ import { Contract } from './entities/contract.entity'; import { ContractRoute } from './entities/contract-route.entity'; import { ContractsRepository } from './contracts.repository'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; /** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */ @@ -53,11 +63,15 @@ export class ContractBookingService { private readonly contractsRepository: ContractsRepository, private readonly bookingsRepository: BookingsRepository, private readonly bookingPricingService: BookingPricingService, + private readonly consolidationService: ConsolidationService, private readonly containerTypesService: ContainerTypesService, private readonly ruleEngineService: RuleEngineService, private readonly milestoneService: ClearanceMilestoneService, + private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly dataSource: DataSource, + @Inject(forwardRef(() => TrainSchedulingService)) + private readonly trainSchedulingService: TrainSchedulingService, ) {} async createUnderContract( @@ -100,7 +114,6 @@ export class ContractBookingService { const route = await this.resolveRoute(contract, dto.contractRouteId); const warnings: string[] = []; - const reference = await this.generateReference(); const freightType = contract.freightType; // GENERAL + customs (Path B) runs per-booking clearance: the booking starts @@ -109,8 +122,53 @@ export class ContractBookingService { const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); + // Intercity (DOMESTIC) bookings ride on a passing import/export train: + // there is no window and no date — staff accept them onto a train at + // finalize time, so both the window gate and scheduledDate are skipped. + const isIntercity = contract.tradeDirection === 'DOMESTIC'; + if (isIntercity && dto.scheduledDate) { + throw new BadRequestException( + 'Intercity bookings do not pick a date — staff assign them to a passing train', + ); + } + // Every other direction keeps the binding shipment day (the DTO field went + // optional only for intercity). + if (!isIntercity && !dto.scheduledDate) { + throw new BadRequestException('A binding shipment day is required'); + } + + // Booking-window gate (config-driven): an operations booking may only be + // created while the route's booking window is open — import: the day's window + // (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours); + // export: within exportBookingLeadHours of departure. Customs Path B bookings + // enter clearance first and are scheduled later, so they are not gated here. + if (!generalCustoms && !isIntercity) { + await this.trainSchedulingService.assertBookingWindowOpen({ + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, + scheduledDate: dto.scheduledDate ?? null, + direction: contract.tradeDirection ?? null, + }); + } + + // Hard capacity gate: a container line whose total weight exceeds the + // container type's max capacity can never be booked — no surcharge path, + // no override. Checked before any row is written. + if (freightType === 'CONTAINER') { + await this.assertWithinMaxCapacity(contract, dto); + // 20ft weight-pairing gate at CREATION: two 20ft on a wagon must differ + // ≤ the cap, and drawdown bookings never pass through submit — so this is + // their only chance to hard-block an unbalanceable set. Entry order is + // irrelevant (the check sorts by weight before pairing). + await this.assert20ftPairableAtCreate(dto); + } + // Denormalize route/direction/freight onto the booking for the scheduling engine. - const booking = await this.bookingsRepository.create({ + // Retry past a concurrent insert that grabbed the same BK sequence number. + const booking = await insertWithGeneratedReference( + () => this.generateReference(), + (reference) => + this.bookingsRepository.create({ reference, companyId: contract.companyId ?? null, companyProfileId: contract.companyProfileId ?? null, @@ -144,7 +202,8 @@ export class ContractBookingService { lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null, lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null, - } as never); + } as never), + ); // Persist container lines + per-unit container numbers (container freight only). if (freightType === 'CONTAINER') { @@ -158,6 +217,20 @@ export class ContractBookingService { await this.applyWeightResults(loaded); } const computed = await this.bookingPricingService.computePriceForBooking(loaded); + // Reject a zero-price booking outright. A total of 0 means no contract rate + // matched the route/container (or the rate is unset), so the booking is not + // valid to ship or invoice. Roll back the just-inserted row + its lines so it + // does NOT occupy the one-time contract's single active-booking slot — else + // the customer's retry hits "already has an active booking" against a broken + // draft. The customer must fix the contract's rates, then rebook. + if (!(computed.totalAmount > 0)) { + await this.bookingsRepository.deleteContainers(booking.id); + await this.bookingsRepository.hardDelete(booking.id); + throw new BadRequestException( + 'Booking price came out as 0 — no contract rate matches this ' + + 'route/cargo. Set the contract rate and try again.', + ); + } await this.bookingsRepository.update(booking.id, { totalAmount: computed.totalAmount, priorityScore: computed.priorityScore, @@ -176,6 +249,105 @@ export class ContractBookingService { warnings.push(...computed.warnings); } + // Wagon consolidation gate. A container drawdown whose lines leave a partial + // wagon (e.g. 21× 20FT → one leftover) must share that wagon with a partner + // before it can ship. Direct bookings do this at submit; drawdowns have no + // submit step, so we run it here — BEFORE invoicing/milestones. When it parks + // for a partner the booking is NOT invoiced or scheduled: those steps run + // later in finalizeContractBooking, triggered by the pairing event. When it + // pairs (or needs no consolidation) we finalize inline. + const withContainers = await this.bookingsRepository.findByIdWithFiles( + booking.id, + ); + const intendedStatus = generalCustoms + ? 'AWAITING_DOCUMENTS' + : 'OPERATION_REQUEST_PENDING'; + if ( + withContainers && + freightType === 'CONTAINER' && + (await this.consolidationService.needsConsolidationFromBooking( + withContainers, + )) + ) { + const parked = await this.consolidateDrawdown( + withContainers, + intendedStatus, + ); + warnings.push(parked.message); + if (!parked.paired) { + // Waiting for a partner — stop here. The booking sits in + // PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs. + const pendingResult = await this.bookingsRepository.findByIdWithFiles( + booking.id, + ); + return { booking: pendingResult ?? booking, warnings }; + } + } + + await this.finalizeContractBooking( + booking.id, + contract, + generalCustoms, + ); + + const result = await this.bookingsRepository.findByIdWithFiles(booking.id); + return { booking: result ?? booking, warnings }; + } + + /** + * Search for a complementary partner for a parked-eligible drawdown, pair it or + * park it in PENDING_CONSOLIDATION with the resume status it should return to. + * Pairing (via BookingsRepository.pairConsolidation) resumes both partners and + * emits booking.consolidation.paired, which finalizes any deferred contract + * booking. Returns whether a partner was found plus a customer-facing message. + */ + private async consolidateDrawdown( + booking: Booking, + resumeStatus: string, + ): Promise<{ paired: boolean; message: string }> { + const slots = await this.consolidationService.slotsFromBooking(booking); + if (!slots.length) { + return { paired: false, message: '' }; + } + + const partner = await this.bookingsRepository.findConsolidationPartner( + booking, + slots, + ); + + if (partner) { + await this.bookingsRepository.pairConsolidation(booking.id, partner.id); + return { + paired: true, + message: this.consolidationService.describePaired( + partner.reference, + slots, + ), + }; + } + + await this.bookingsRepository.parkForConsolidation(booking.id, resumeStatus); + return { + paired: false, + message: this.consolidationService.describePending(booking, slots), + }; + } + + /** + * Finalize a contract booking once it is cleared to proceed (needed no + * consolidation, or has just paired): seed clearance milestones / link the + * contract cycle, then generate the invoice. Idempotent — safe to call again + * for a booking that pairs after having waited. Skips a booking that is still + * PENDING_CONSOLIDATION (guards the pairing event against a stray partner). + */ + private async finalizeContractBooking( + bookingId: string, + contract: Contract, + generalCustoms: boolean, + ): Promise { + const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); + if (!booking || booking.status === 'PENDING_CONSOLIDATION') return; + // ONE_TIME customs (legacy contract-cycle path): link the contract clearance // cycle to this booking, seed post-booking milestones, and lock the contract // to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle @@ -183,10 +355,10 @@ export class ContractBookingService { if (contract.customsClearingEnabled && !generalCustoms) { const cycle = await this.contractsRepository.currentCycle(contract.id); if (cycle) { - await this.contractsRepository.linkBooking(cycle.id, booking.id); + await this.contractsRepository.linkBooking(cycle.id, bookingId); } await this.milestoneService.seedPostBookingMilestones( - booking.id, + bookingId, contract.tradeDirection, ); await this.contractsRepository.update(contract.id, { @@ -194,24 +366,24 @@ export class ContractBookingService { clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS', } as never); } else if (generalCustoms) { - // Per-booking clearance: seed post-booking milestones on the booking (no - // cycle needed) and leave the contract active. The booking now drives its - // own clearance via the booking-level pipeline. + // Per-booking clearance: seed full milestone timeline on the booking. + await this.milestoneService.seedPreBookingMilestonesOnBooking( + bookingId, + contract.tradeDirection, + ); await this.milestoneService.seedPostBookingMilestones( - booking.id, + bookingId, contract.tradeDirection, ); } - const result = await this.bookingsRepository.findByIdWithFiles(booking.id); - // Contract bookings are born past the billable gate (the contract is already // executed), so the invoice is generated here — they never pass through the // legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings. // Idempotent and non-blocking: a billing hiccup must not undo the booking. // Skips silently when unbillable (no company / no priced amount). await this.invoiceService - .ensureInvoiceForBooking(result ?? booking) + .ensureInvoiceForBooking(booking) .catch((err) => this.logger.error( `Failed to generate invoice for contract booking ${booking.reference}: ${ @@ -219,8 +391,40 @@ export class ContractBookingService { }`, ), ); + } - return { booking: result ?? booking, warnings }; + /** + * A parked drawdown just paired — finalize whichever partner is a contract + * booking that was waiting (invoice + milestones deferred at creation). The + * pairing already resumed the booking's status from consolidationResumeStatus; + * this runs the create-time tail that was skipped. Non-contract partners have + * their own finalize path (staff accept) and are ignored here. + */ + @OnEvent('booking.consolidation.paired') + async onConsolidationPaired(payload: { + bookingIds: string[]; + }): Promise { + for (const id of payload.bookingIds ?? []) { + const booking = await this.bookingsRepository.findByIdWithFiles(id); + if (!booking?.contractId || booking.status === 'PENDING_CONSOLIDATION') { + continue; + } + const contract = await this.contractsRepository.findByIdWithRelations( + booking.contractId, + ); + if (!contract) continue; + const generalCustoms = + contract.contractKind === 'GENERAL' && + Boolean(contract.customsClearingEnabled); + await this.finalizeContractBooking(id, contract, generalCustoms).catch( + (err) => + this.logger.error( + `Failed to finalize paired contract booking ${booking.reference}: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + } } /** @@ -246,10 +450,14 @@ export class ContractBookingService { } return 'GL_ET'; } - // ONE_TIME customs — UNCHANGED: requires the finalized contract cycle. - if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') { + // ONE_TIME customs — pre-booking boundary milestone must be complete. + const boundaryOk = await this.workflowService.isBoundaryComplete( + contract.id, + contract.tradeDirection, + ); + if (!boundaryOk) { throw new BadRequestException( - 'Contract clearance is not ready for booking yet.', + 'Pre-booking clearance is not complete — booking cannot be created yet.', ); } return 'GL_ET'; @@ -555,6 +763,214 @@ export class ContractBookingService { } } + /** + * Pre-create validation + authoritative price preview for the shipment form: + * build an UNSAVED booking shaped exactly like {@link createUnderContract} + * would persist it and run the same BookingPricingService compute over it — + * base rail freight, first/last-mile trucking, and every rule-engine surcharge + * (overweight, hazard, reefer, consolidation, …). The portal and the GL + * backoffice form call this from the price-confirm modal, so the breakdown the + * user confirms is line-for-line what the booking will be charged. Also runs + * the 20ft weight-pairing rule, which hard-blocks creation. + */ + async validateShipment( + contractId: string, + dto: CreateBookingUnderContractDto, + ): Promise<{ + overweightLines: Array<{ + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; + }>; + overweightSurchargeAmount: number; + currency: string | null; + pairingErrors: string[]; + capacityErrors: string[]; + lineItems: PriceLineItemDto[]; + totalAmount: number; + }> { + const contract = await this.contractsRepository.findByIdWithRelations(contractId); + if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); + + const lines = dto.containers ?? []; + if (contract.freightType === 'CONTAINER' && !lines.length) { + return { + overweightLines: [], + overweightSurchargeAmount: 0, + currency: null, + pairingErrors: [], + capacityErrors: [], + lineItems: [], + totalAmount: 0, + }; + } + + // Resolve each container line's type + total VGM (sum of unit weights) — + // mirrors persistContainers so the preview lines match the persisted ones. + const resolved = await Promise.all( + lines.map(async (line) => { + const ct = await this.resolveContainerTypeForSize( + line.containerSize, + contract.isReefer || (line.reeferQuantity ?? 0) > 0, + ); + const totalVgmTons = (line.units ?? []).reduce( + (s, u) => s + Number(u.vgmTons ?? 0), + 0, + ); + return { line, ct, totalVgmTons }; + }), + ); + + // The unsaved twin of the booking createUnderContract would write: same + // denormalized contract fields, same container-line math. No id → the + // pricing service derives wagon counts from the in-memory lines. + const route = await this.resolveRoute(contract, dto.contractRouteId); + const previewBooking = Object.assign(new Booking(), { + freightType: contract.freightType, + tradeDirection: contract.tradeDirection, + paymentCurrency: contract.paymentCurrency, + serviceTypeId: contract.serviceTypeId, + cargoTypeId: this.resolveCargoTypeId(contract, dto), + isHazardous: contract.isHazardous, + isReefer: contract.isReefer, + isGovernment: contract.isGovernment, + shippingLineId: null, + contractRouteId: route?.id ?? null, + cargoTotalWeightVgm: this.resolveBulkTons(dto), + firstMilePickupAddress: contract.firstMilePickupAddress ?? null, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, + bookingContainers: resolved.map(({ line, ct, totalVgmTons }) => + Object.assign(new BookingContainer(), { + containerTypeId: ct.id, + containerSize: line.containerSize, + quantity: line.quantity, + hazardousQuantity: line.hazardousQuantity ?? 0, + reeferQuantity: line.reeferQuantity ?? 0, + vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, + totalVgmTons, + wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)), + }), + ), + }) as Booking; + + const computed = await this.bookingPricingService.computePriceForBooking(previewBooking); + + // The overweight surcharge line is already currency-converted; surface its + // amount separately so the warning alert can reference the exact charge. + const overweightSurchargeAmount = + computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0; + + // 20ft weight-pairing: gather every 20ft unit weight and check the pair rule. + const twentyFtUnits = resolved + .filter((r) => (r.line.containerSize ?? '').includes('20')) + .flatMap((r) => + (r.line.units ?? []).map((u, idx) => ({ + label: u.containerNumber || `${r.line.containerSize}-${idx + 1}`, + grossWeightTons: Number(u.vgmTons ?? 0), + })), + ); + const maxDiff = await this.max20ftPairDiffTons(); + const pairingErrors = validate20ftWeightPairing(twentyFtUnits, maxDiff).map( + (v) => v.message, + ); + + // Hard capacity ceiling — a non-empty result means the create call will be + // rejected, so the form can block submit up front. + const capacityErrors = await this.ruleEngineService.capacityViolations( + resolved.map(({ line, ct, totalVgmTons }) => ({ + containerTypeId: ct.id, + quantity: line.quantity, + totalVgmTons, + })), + contract.tradeDirection, + ); + + return { + overweightLines: computed.overweightLines, + overweightSurchargeAmount, + currency: computed.currency, + pairingErrors, + capacityErrors, + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + }; + } + + /** + * Throws when any container line's total weight exceeds the hard capacity + * ceiling of its weight limit rule. Mirrors validateShipment's line + * resolution so the gate matches what the form preview reported. + */ + private async assertWithinMaxCapacity( + contract: Contract, + dto: CreateBookingUnderContractDto, + ): Promise { + const lines = dto.containers ?? []; + if (!lines.length) return; + + const containers = await Promise.all( + lines.map(async (line) => { + const ct = await this.resolveContainerTypeForSize( + line.containerSize, + contract.isReefer || (line.reeferQuantity ?? 0) > 0, + ); + const totalVgmTons = (line.units ?? []).reduce( + (s, u) => s + Number(u.vgmTons ?? 0), + 0, + ); + return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons }; + }), + ); + + const violations = await this.ruleEngineService.capacityViolations( + containers, + contract.tradeDirection, + ); + if (violations.length) { + throw new BadRequestException(violations.join('; ')); + } + } + + /** + * Hard-block booking creation when the 20ft container weights cannot be + * balanced onto wagons (pair diff over the global cap). Same rule the + * shipment-form preview reports as `pairingErrors`, enforced server-side. + */ + private async assert20ftPairableAtCreate( + dto: CreateBookingUnderContractDto, + ): Promise { + const twentyFtUnits = (dto.containers ?? []) + .filter((line) => (line.containerSize ?? '').includes('20')) + .flatMap((line, lineIdx) => + (line.units ?? []).map((u, idx) => ({ + label: u.containerNumber || `20ft-${lineIdx + 1}.${idx + 1}`, + grossWeightTons: Number(u.vgmTons ?? 0), + })), + ); + if (twentyFtUnits.length < 2) return; + + const maxDiff = await this.max20ftPairDiffTons(); + const violations = validate20ftWeightPairing(twentyFtUnits, maxDiff); + if (violations.length) { + throw new BadRequestException( + `Cannot create booking — 20ft containers cannot be paired on wagons: ${violations + .map((v) => v.message) + .join(' ')}`, + ); + } + } + + private async max20ftPairDiffTons(): Promise { + const row = await this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .find({ order: { createdAt: 'ASC' }, take: 1 }) + .then((rows) => rows[0] ?? null) + .catch(() => null); + const n = row?.max20ftPairWeightDiffTons == null ? NaN : Number(row.max20ftPairWeightDiffTons); + return Number.isFinite(n) ? n : 10; + } + /** Pick the default container type for a size; prefer reefer when requested. */ private async resolveContainerTypeForSize( size: string, @@ -575,8 +991,7 @@ export class ContractBookingService { private async generateReference(): Promise { const year = new Date().getFullYear(); - const count = await this.bookingsRepository.countByYear(year); - const seq = String(count + 1).padStart(6, '0'); - return `BK-${year}-${seq}`; + const seq = await this.bookingsRepository.maxReferenceSequence(year); + return `BK-${year}-${String(seq + 1).padStart(6, '0')}`; } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 71cfa0580..3d41e68a5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -1,13 +1,31 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; +import { + ContractDocPhase, + type ClearanceFinalInvoiceSummary, + type ClearanceSecondDuty, + type ClearanceT1State, + type ClearanceTrainState, +} from '@edr/types'; +import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { FilesService } from '../files/files.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService, PaginatedContracts } from './contracts.service'; +import { BookingsService } from '../bookings/bookings.service'; import { contractClearanceCodes } from './contract-clearance.util'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { ContractNotifierService } from './contract-notifier.service'; +import { GlOperationsService } from './gl-operations.service'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; import { FilterContractDto } from './dto/filter-contract.dto'; +import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util'; + +const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; export interface ContractClearanceDocument { fileKey: string; @@ -34,6 +52,59 @@ export interface ContractClearanceView { outputCode: string | null; documents: ContractClearanceDocument[]; allApproved: boolean; + phase?: string | null; + milestones?: Array<{ + id: string; + milestoneCode: string; + milestoneLabel: string; + status: string; + ownerRegion?: string | null; + metadata?: Record | null; + sortOrder: number; + }>; + nextAction?: { + actor: string; + action: string; + milestoneCode?: string | null; + blockedReason?: string | null; + } | null; + dutyRequired?: boolean | null; + roHold?: boolean; + roHoldReason?: string | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: string | null; + bookingReady?: boolean; + preClearanceFinalized?: boolean; + /** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */ + exportClearanceFinalized?: boolean; + linkedBookingId?: string | null; + /** Reference + status of the GL-created shipment booking, once it exists. */ + linkedBookingReference?: string | null; + linkedBookingStatus?: string | null; + dutyAdvice?: { + amount: number; + currency: string; + declarationSerial?: string | null; + noticeFile?: { id: string; name: string; url: string } | null; + } | null; + workflowFiles?: ReturnType; + /** Import post-allocation T1 transit document state (null until a booking is linked). */ + t1?: ClearanceT1State | null; + /** Train link state for the booking (both directions; null until a booking is linked). */ + train?: ClearanceTrainState | null; + gatepassGranted?: boolean; + gatepassAt?: string | null; + t1Closed?: boolean; + t1ClosedAt?: string | null; + offloaded?: boolean; + /** GL Djibouti post-offload final invoice (export). */ + finalInvoice?: ClearanceFinalInvoiceSummary | null; + /** Customs risk level assigned by GL ET (import; visible to the customer). */ + riskLevel?: string | null; + riskAssignedAt?: string | null; + /** Post-arrival additional duty/tax round (import). */ + secondDuty?: ClearanceSecondDuty | null; + importReleaseGranted?: boolean; } @Injectable() @@ -41,13 +112,59 @@ export class ContractClearanceService { constructor( private readonly contractsRepository: ContractsRepository, private readonly contractsService: ContractsService, + private readonly bookingsService: BookingsService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, + private readonly workflowService: ClearanceWorkflowService, + private readonly milestoneService: ClearanceMilestoneService, + private readonly dropdownSettingsService: DropdownSettingsService, + private readonly glOperationsService: GlOperationsService, + private readonly notifier: ContractNotifierService, ) {} + private isPhasedCustoms(contract: Contract): boolean { + return contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME'; + } + + private assertPhasedCustoms(contract: Contract): void { + if (!this.isPhasedCustoms(contract)) { + throw new BadRequestException( + 'Phased clearance (Phase 1) applies to one-time customs contracts.', + ); + } + } + + /** + * Legacy finalize() used to set CLEARANCE_READY_FOR_BOOKING without completing + * phased milestones. Revert that state so declaration / DO steps can proceed. + */ + private async reconcilePrematureBookingReady( + contractId: string, + contract: Contract, + bookingReady: boolean, + ): Promise { + if ( + !this.isPhasedCustoms(contract) || + contract.status !== 'CLEARANCE_READY_FOR_BOOKING' || + bookingReady + ) { + return contract; + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_UNDER_REVIEW', + clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', + } as never); + if (cycle?.status === 'CLEARANCE_READY_FOR_BOOKING') { + await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); + } + return this.contractsService.findById(contractId); + } + /** The pre-booking clearance document grid for a contract (Path B). */ async getClearanceView(contractId: string): Promise { - const contract = await this.contractsService.findById(contractId); + let contract = await this.contractsService.findById(contractId); const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract); const cycle = await this.contractsRepository.currentCycle(contractId); @@ -109,6 +226,89 @@ export class ContractClearanceService { } const allApproved = await this.isClearanceFullyApproved(contract); + const milestones = await this.workflowService.listMilestones(contractId); + let boundary = await this.workflowService.isBoundaryComplete( + contractId, + contract.tradeDirection, + ); + contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary); + const phase = this.workflowService.resolvePhase(contract, cycle, milestones); + const dutyAdvice = this.buildDutyAdvice(files, milestones); + let workflowFiles = buildWorkflowFiles( + files, + contract.tradeDirection ?? 'IMPORT', + ); + let bookingFiles: Awaited> = []; + if (cycle?.bookingId) { + bookingFiles = await this.filesService.findByResource( + cycle.bookingId, + 'bookings', + ); + const bookingWorkflow = buildWorkflowFiles( + bookingFiles, + contract.tradeDirection ?? 'IMPORT', + ); + const byCode = new Map(workflowFiles.map((f) => [f.code, f])); + for (const row of bookingWorkflow) { + if (row.file) byCode.set(row.code, row); + } + workflowFiles = [...byCode.values()]; + } + + let t1: ClearanceT1State | null = null; + if (cycle?.bookingId && contract.tradeDirection === 'IMPORT') { + try { + t1 = await this.glOperationsService.t1State(cycle.bookingId); + } catch { + t1 = null; // linked booking missing — view stays usable + } + } + + let train: ClearanceTrainState | null = null; + let bookingMilestones: ClearanceMilestone[] = []; + let finalInvoice: ClearanceFinalInvoiceSummary | null = null; + if (cycle?.bookingId) { + try { + train = await this.glOperationsService.trainState(cycle.bookingId); + } catch { + train = null; + } + bookingMilestones = await this.workflowService.listMilestonesForBooking( + cycle.bookingId, + ); + finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId); + } + const bookingMilestone = (code: string) => + bookingMilestones.find((m) => m.milestoneCode === code); + const gatepass = cycle?.bookingId + ? await this.glOperationsService.gatepassForBooking(cycle.bookingId) + : { granted: false, grantedAt: null }; + const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); + const riskMilestone = bookingMilestone('RISK_ASSIGNED'); + const secondDuty = this.glOperationsService.secondDutyState( + bookingMilestones, + bookingFiles, + ); + + let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); + // Once GL creates the shipment booking, surface its reference + status so the + // customer sees the concrete booking instead of a stale "will be created + // shortly" message. Reuse the export booking load; fetch for import too. + let linkedBookingReference: string | null = null; + let linkedBookingStatus: string | null = null; + if (cycle?.bookingId) { + const booking = await this.bookingsService.findById(cycle.bookingId); + if (booking) { + linkedBookingReference = booking.reference ?? null; + linkedBookingStatus = booking.status ?? null; + if (contract.tradeDirection === 'EXPORT') { + nextAction = this.workflowService.computeNextActionForBooking( + booking, + bookingMilestones, + ); + } + } + } return { contractId, @@ -120,6 +320,79 @@ export class ContractClearanceService { outputCode, documents, allApproved, + phase, + milestones: milestones.map((m) => ({ + id: m.id, + milestoneCode: m.milestoneCode, + milestoneLabel: m.milestoneLabel, + status: m.status, + ownerRegion: m.ownerRegion, + metadata: (m.metadata ?? null) as Record | null, + sortOrder: m.sortOrder, + })), + nextAction, + dutyRequired: cycle?.dutyRequired ?? null, + roHold: Boolean(cycle?.roHoldReason), + roHoldReason: cycle?.roHoldReason ?? null, + vesselDepartureDate: cycle?.vesselDepartureDate ?? null, + roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt + ? cycle.roAmendmentRequestedAt.toISOString() + : null, + bookingReady: boundary, + preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt), + exportClearanceFinalized: Boolean(cycle?.completedAt), + linkedBookingId: cycle?.bookingId ?? null, + linkedBookingReference, + linkedBookingStatus, + dutyAdvice, + workflowFiles, + t1, + train, + gatepassGranted: gatepass.granted, + gatepassAt: gatepass.grantedAt, + t1Closed: t1ClosedMilestone?.status === 'COMPLETED', + t1ClosedAt: + t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt + ? t1ClosedMilestone.triggeredAt.toISOString() + : null, + offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + finalInvoice, + riskLevel: + riskMilestone?.status === 'COMPLETED' + ? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null) + : null, + riskAssignedAt: + riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt + ? riskMilestone.triggeredAt.toISOString() + : null, + secondDuty, + importReleaseGranted: + bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', + }; + } + + private buildDutyAdvice( + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + milestones: ClearanceMilestone[], + ): ContractClearanceView['dutyAdvice'] { + const advised = milestones.find( + (m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED', + ); + if (!advised?.metadata) return null; + const amount = advised.metadata.dutyAmount; + const currency = advised.metadata.dutyCurrency; + if (typeof amount !== 'number' || typeof currency !== 'string') return null; + const notice = files.find((f) => f.code === 'duty_tax_notice'); + return { + amount, + currency, + declarationSerial: + typeof advised.metadata.declarationSerial === 'string' + ? advised.metadata.declarationSerial + : null, + noticeFile: notice + ? { id: notice.id, name: notice.name, url: notice.url } + : null, }; } @@ -154,6 +427,59 @@ export class ContractClearanceService { ); } + /** Staff may approve/query documents during review, after a query cycle, or post-finalize re-query. */ + private assertClearanceReviewableStatus(contract: Contract): void { + const allowed = [ + 'CLEARANCE_UNDER_REVIEW', + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_READY_FOR_BOOKING', + ]; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot review clearance documents on status "${contract.status}".`, + ); + } + } + + /** Finalize when docs are under review or all approved after a partial query cycle. */ + private assertClearanceFinalizableStatus(contract: Contract): void { + const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot finalize clearance on status "${contract.status}".`, + ); + } + } + + private assertClearanceOutputUploadableStatus(contract: Contract): void { + const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot upload output documents on status "${contract.status}".`, + ); + } + } + + private async bumpToUnderReviewWhenFullyApproved(contractId: string): Promise { + const refreshed = await this.contractsService.findById(contractId); + const allApproved = await this.isClearanceFullyApproved(refreshed); + if ( + !allApproved || + (refreshed.status !== 'AWAITING_CLEARANCE_DOCUMENTS' && + refreshed.status !== 'CLEARANCE_READY_FOR_BOOKING') + ) { + return; + } + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_UNDER_REVIEW', + clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', + } as never); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); + } + } + /** * Customer uploads clearance documents on the contract. When every required * input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET. @@ -209,9 +535,19 @@ export class ContractClearanceService { clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', } as never); if (cycle) { - await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); + await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW', { + currentPhase: ContractDocPhase.GlEtReview, + }); } - return this.contractsService.findById(contractId); + + if (contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME') { + await this.workflowService.onCustomerDocsUploaded(contractId, contract.tradeDirection); + await this.workflowService.onDocumentReviewReopened(contractId); + } + + const updated = await this.contractsService.findById(contractId); + this.notifier.clearanceDocsUploadedToStaff(updated); + return updated; } private async assertRequiredInputsPresent( @@ -294,25 +630,22 @@ export class ContractClearanceService { note?: string, ): Promise { const contract = await this.contractsService.findById(contractId); - // Reviewing is allowed both while the batch is UNDER_REVIEW and after it has - // dropped back to AWAITING_CLEARANCE_DOCUMENTS — querying one document flips - // the contract to "awaiting" (the customer must re-upload), but the reviewer - // may still be working through the rest of the batch. Restricting to - // UNDER_REVIEW only would 409 every review after the first query. - if ( - contract.status !== 'CLEARANCE_UNDER_REVIEW' && - contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' - ) { - throw new ConflictException( - `Cannot review clearance documents on status "${contract.status}".`, - ); - } + this.assertClearanceReviewableStatus(contract); if (status === 'QUERIED' && !note?.trim()) { throw new BadRequestException('A note is required when querying a document'); } const { inputCode, outputCode } = contractClearanceCodes(contract); const cycle = await this.contractsRepository.currentCycle(contractId); + if ( + status === 'QUERIED' && + this.isPhasedCustoms(contract) && + cycle?.preClearanceFinalizedAt + ) { + throw new BadRequestException( + 'Customer documents cannot be queried after pre-clearance is finalized.', + ); + } const reviews = await this.contractsRepository.findDocumentReviews( contractId, cycle?.id ?? null, @@ -345,9 +678,37 @@ export class ContractClearanceService { status: 'AWAITING_CLEARANCE_DOCUMENTS', clearanceStatus: 'AWAITING_DOCUMENTS', } as never); + this.notifier.clearanceDocumentQueried(contract, fileKey, note ?? ''); if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); } + if (this.isPhasedCustoms(contract)) { + await this.workflowService.onDocumentReviewReopened(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtReview, + }); + } + } + } else if (status === 'APPROVED') { + await this.bumpToUnderReviewWhenFullyApproved(contractId); + const refreshed = await this.contractsService.findById(contractId); + if ( + refreshed.customsClearingEnabled && + refreshed.contractKind === 'ONE_TIME' && + (await this.isClearanceFullyApproved(refreshed)) + ) { + await this.workflowService.onAllDocsApproved(contractId); + const c = await this.contractsRepository.currentCycle(contractId); + if (c) { + await this.contractsRepository.updateCycle(c.id, { + currentPhase: + refreshed.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlDjCollection + : ContractDocPhase.GlEtOutput, + }); + } + } } return this.contractsService.findById(contractId); @@ -359,11 +720,7 @@ export class ContractClearanceService { files: Express.Multer.File[], ): Promise { const contract = await this.contractsService.findById(contractId); - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot upload output documents on status "${contract.status}".`, - ); - } + this.assertClearanceOutputUploadableStatus(contract); const { outputCode } = contractClearanceCodes(contract); if (!outputCode) { throw new BadRequestException('This contract has no customs output documents'); @@ -384,8 +741,10 @@ export class ContractClearanceService { /** * GL ET finalizes Path B pre-booking clearance: requires every customer - * document APPROVED and required output docs present → CLEARANCE_READY_FOR_BOOKING - * (GL then creates the booking). Rejects self-clearance (Path A) contracts. + * document APPROVED. For phased customs (ONE_TIME), document review completes + * here — booking readiness is set only after delivery order (import) or export + * release via the milestone workflow. Non-phased customs still jump straight to + * CLEARANCE_READY_FOR_BOOKING. Rejects self-clearance (Path A) contracts. */ async finalize(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); @@ -394,11 +753,7 @@ export class ContractClearanceService { 'Self-clearance (Path A) contracts are finalized by Operations, not GL.', ); } - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot finalize clearance on status "${contract.status}".`, - ); - } + this.assertClearanceFinalizableStatus(contract); const approved = await this.isClearanceFullyApproved(contract); if (!approved) { @@ -407,6 +762,24 @@ export class ContractClearanceService { ); } + if (this.isPhasedCustoms(contract)) { + await this.workflowService.onAllDocsApproved(contractId); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: + contract.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlDjCollection + : ContractDocPhase.GlEtOutput, + }); + } + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_UNDER_REVIEW', + clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', + } as never); + return this.contractsService.findById(contractId); + } + const { outputCode } = contractClearanceCodes(contract); if (outputCode) { const setting = await this.fileUploadSettingsService.getByCode(outputCode); @@ -452,11 +825,7 @@ export class ContractClearanceService { 'Operations finalize applies only to self-clearance (non-customs) contracts.', ); } - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot finalize clearance on status "${contract.status}".`, - ); - } + this.assertClearanceFinalizableStatus(contract); const approved = await this.isClearanceFullyApproved(contract); if (!approved) { @@ -479,19 +848,14 @@ export class ContractClearanceService { } /** - * GL ET clearance hub: every customs (Path B) contract that still needs - * customs clearance — awaiting the customer's documents, under GL review, or - * finalized and waiting for the customer to create the booking in the portal. + * GL ET clearance hub: every customs (Path B) contract in phased clearance, + * including after booking is created. */ async queue(filter: FilterContractDto): Promise { return this.contractsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 100, - statuses: [ - 'AWAITING_CLEARANCE_DOCUMENTS', - 'CLEARANCE_UNDER_REVIEW', - 'CLEARANCE_READY_FOR_BOOKING', - ], + statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES], customsClearingEnabled: true, sortBy: filter.sortBy, sortOrder: filter.sortOrder, @@ -536,4 +900,512 @@ export class ContractClearanceService { sortOrder: filter.sortOrder ?? 'DESC', }); } + + // ── Phased clearance actions (ONE_TIME customs, Phase 1) ─────────────────── + + /** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */ + private async ensureDeclarationPrerequisites( + contractId: string, + contract: Contract, + ): Promise { + const allApproved = await this.isClearanceFullyApproved(contract); + if (!allApproved) { + throw new BadRequestException( + 'All required customer documents must be approved before uploading a declaration.', + ); + } + const milestones = await this.workflowService.listMilestones(contractId); + const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); + if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { + await this.workflowService.onAllDocsApproved(contractId); + } + } + + async uploadDeclaration( + contractId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + await this.ensureDeclarationPrerequisites(contractId, contract); + await this.workflowService.assertPriorComplete( + contractId, + contract.tradeDirection, + 'UNDER_CUSTOMS_CLEARANCE', + ); + + if (files.length === 0) { + throw new BadRequestException('No declaration documents uploaded'); + } + + await persistDeclarationUploads( + this.filesService, + contractId, + 'contracts', + files, + ); + + await this.workflowService.onDeclarationUploaded(contractId, userId); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: + contract.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlEtPostClearance + : ContractDocPhase.CustomerDuty, + }); + } + + // Export: the declaration is the last GL ET pre-booking action — release + // immediately so booking creation unlocks without a separate confirm click. + if (contract.tradeDirection === 'EXPORT') { + await this.workflowService.onExportReleased(contractId, userId); + } + + return this.contractsService.findById(contractId); + } + + async adviseDuty( + contractId: string, + dto: AdviseContractDutyDto, + userId?: string, + attachment?: Express.Multer.File, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty advice applies only to import contracts.'); + } + await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DUTY_TAXES_ADVISED'); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + await this.contractsRepository.updateCycle(cycle.id, { + dutyRequired: dto.dutyRequired, + currentPhase: dto.dutyRequired + ? ContractDocPhase.CustomerDuty + : ContractDocPhase.GlEtPostClearance, + }); + + if (!dto.dutyRequired) { + await this.workflowService.onDutySkipped(contractId); + } else { + if (dto.amount == null || dto.amount < 0) { + throw new BadRequestException('Duty amount is required when duty applies.'); + } + if (!attachment) { + throw new BadRequestException('Duty notice attachment is required when duty applies.'); + } + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'duty_tax_notice', + file: attachment, + }); + await this.milestoneService.adviseDutyForContract( + contractId, + { + amount: dto.amount, + currency: dto.currency ?? 'ETB', + declarationSerial: dto.declarationSerial, + }, + userId, + ); + this.notifier.dutyAdvised(contract, dto.amount, dto.currency ?? 'ETB'); + } + + return this.contractsService.findById(contractId); + } + + async uploadDutySlip( + contractId: string, + file: Express.Multer.File, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty slip upload applies only to import contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.dutyRequired) { + throw new BadRequestException('Duty/tax is not required for this clearance.'); + } + + if (!file) throw new BadRequestException('No payment slip uploaded'); + + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'duty_tax_receipt', + file, + }); + + await this.workflowService.completeMilestone(contractId, 'DUTY_TAX_PAID'); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + } + + const updated = await this.contractsService.findById(contractId); + this.notifier.dutySlipUploadedToStaff(updated); + return updated; + } + + async uploadTransitPermit( + contractId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Transit permit applies only to import contracts.'); + } + await this.workflowService.assertPriorComplete( + contractId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } + + await persistTransitPermitUploads( + this.filesService, + contractId, + 'contracts', + files, + ); + + await this.workflowService.completeMilestone(contractId, 'TRANSIT_PERMIT_UPLOADED', userId); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + } + + return this.contractsService.findById(contractId); + } + + async finalizePreClearance(contractId: string): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Pre-clearance finalize applies only to import contracts.'); + } + + await this.workflowService.assertPriorComplete( + contractId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + if (cycle.preClearanceFinalizedAt) { + return this.contractsService.findById(contractId); + } + + await this.contractsRepository.updateCycle(cycle.id, { + preClearanceFinalizedAt: new Date(), + currentPhase: ContractDocPhase.GlDjCollection, + }); + + // GL Djibouti may have uploaded the DO early (un-gated) — count it now. + const files = await this.filesService.findByResource(contractId, 'contracts'); + if (files.some((f) => f.code === 'delivery_order')) { + await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED'); + await this.workflowService.markReadyForBooking(contractId); + } + + this.notifier.preClearanceFinalized(contract); + return this.contractsService.findById(contractId); + } + + async uploadDeliveryOrder( + contractId: string, + file: Express.Multer.File, + userId?: string, + vesselDepartureDate?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Delivery Order applies only to import contracts.'); + } + + if (!file) throw new BadRequestException('No Delivery Order uploaded'); + + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, + // any file type. The DO_COLLECTED milestone (and booking readiness) still waits + // for GL Ethiopia to finalize pre-clearance so the workflow order holds. + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'delivery_order', + file, + }); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle && vesselDepartureDate?.trim()) { + await this.contractsRepository.updateCycle(cycle.id, { + vesselDepartureDate: vesselDepartureDate.trim(), + }); + } + if (cycle?.preClearanceFinalizedAt) { + await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForBooking(contractId); + } + + return this.contractsService.findById(contractId); + } + + private async resolveRoMinDays(): Promise { + try { + const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE); + const first = setting.children?.[0]; + const n = Number(first?.value); + return Number.isFinite(n) && n > 0 ? n : 2; + } catch { + return 2; + } + } + + private daysUntil(dateStr: string): number { + const target = new Date(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + target.setHours(0, 0, 0, 0); + return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)); + } + + async uploadReleaseOrder( + contractId: string, + file: Express.Multer.File, + vesselDepartureDate: string, + userId?: string, + ): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Release Order applies only to export contracts.'); + } + await this.workflowService.assertPriorComplete( + contractId, + 'EXPORT', + 'RELEASE_ORDER_SECURED', + ); + + if (!file) throw new BadRequestException('No Release Order uploaded'); + if (!vesselDepartureDate?.trim()) { + throw new BadRequestException('Vessel departure date is required'); + } + + const minDays = await this.resolveRoMinDays(); + const leadDays = this.daysUntil(vesselDepartureDate); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'release_order', + file, + }); + + await this.contractsRepository.updateCycle(cycle.id, { + vesselDepartureDate, + roAmendmentRequestedAt: null, + }); + + if (leadDays < minDays) { + const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`; + await this.contractsRepository.updateCycle(cycle.id, { + roHoldReason: reason, + currentPhase: ContractDocPhase.GlDjCollection, + }); + return { contract: await this.contractsService.findById(contractId), hold: true, holdReason: reason }; + } + + await this.contractsRepository.updateCycle(cycle.id, { + roHoldReason: null, + currentPhase: ContractDocPhase.GlEtOutput, + }); + await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId); + + return { contract: await this.contractsService.findById(contractId), hold: false }; + } + + async requestRoAmendment( + contractId: string, + note?: string, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('RO amendment applies only to export contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + const reason = + note?.trim() || + 'Port amendment requested — vessel departure window is too short. A new Release Order will be required.'; + + await this.contractsRepository.updateCycle(cycle.id, { + roAmendmentRequestedAt: new Date(), + roHoldReason: reason, + currentPhase: ContractDocPhase.GlDjCollection, + }); + + if (userId) { + await this.contractsRepository.createReviewNote( + contractId, + reason, + 'CHANGES_REQUESTED', + userId, + 'GL_DJ', + ); + } + + return this.contractsService.findById(contractId); + } + + async confirmExportRelease(contractId: string, userId?: string): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Export release applies only to export contracts.'); + } + await this.workflowService.assertPriorComplete(contractId, 'EXPORT', 'EXPORT_RELEASED'); + + await this.workflowService.onExportReleased(contractId, userId); + return this.contractsService.findById(contractId); + } + + /** GL ET finalizes export clearance after post-booking transit permit is uploaded. */ + async finalizeExportClearance(contractId: string, userId?: string): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Export clearance finalize applies only to export contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.bookingId) { + throw new BadRequestException( + 'A shipment booking must exist before export clearance can be finalized.', + ); + } + if (cycle.completedAt) { + return this.contractsService.findById(contractId); + } + + const bookingMilestones = await this.workflowService.listMilestonesForBooking( + cycle.bookingId, + ); + const transportDone = bookingMilestones.some( + (m) => m.milestoneCode === 'EXPORT_TRANSPORT_ISSUED' && m.status === 'COMPLETED', + ); + if (!transportDone) { + throw new BadRequestException( + 'Upload the transit permit before finalizing export clearance.', + ); + } + + await this.contractsRepository.updateCycle(cycle.id, { + completedAt: new Date(), + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + + void userId; + return this.contractsService.findById(contractId); + } + + /** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */ + async etQueue(filter: FilterContractDto): Promise { + const base = await this.contractsRepository.findAllPaginated({ + page: 1, + pageSize: 500, + statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES], + customsClearingEnabled: true, + contractKind: 'ONE_TIME', + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); + + const filtered: typeof base.items = []; + for (const c of base.items) { + const milestones = await this.workflowService.listMilestones(c.id); + if (belongsOnEtClearanceQueue(milestones)) filtered.push(c); + } + + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 50; + const start = (page - 1) * pageSize; + const items = filtered.slice(start, start + pageSize); + + return { + items, + total: filtered.length, + meta: { + page, + pageSize, + total: filtered.length, + totalPages: Math.ceil(filtered.length / pageSize) || 1, + hasNextPage: start + pageSize < filtered.length, + hasPreviousPage: page > 1, + }, + }; + } + + /** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */ + async djQueue(filter: FilterContractDto): Promise { + const base = await this.contractsRepository.findAllPaginated({ + page: 1, + pageSize: 500, + statuses: [...DJ_CONTRACT_QUEUE_STATUSES], + customsClearingEnabled: true, + contractKind: 'ONE_TIME', + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); + + const filtered: typeof base.items = []; + for (const c of base.items) { + const cycle = await this.contractsRepository.currentCycle(c.id); + const milestones = await this.workflowService.listMilestones(c.id); + if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) { + filtered.push(c); + } + } + + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 50; + const start = (page - 1) * pageSize; + const items = filtered.slice(start, start + pageSize); + + return { + items, + total: filtered.length, + meta: { + page, + pageSize, + total: filtered.length, + totalPages: Math.ceil(filtered.length / pageSize) || 1, + hasNextPage: start + pageSize < filtered.length, + hasPreviousPage: page > 1, + }, + }; + } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts index 9b108ce75..bc1b4ad49 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts @@ -45,7 +45,7 @@ export function contractClearanceSettingCode( return `contract_clearance_${op}_${freight}`; } -/** The GL-output (customs output) setting code; only container customs sets exist. */ +/** The GL-output (customs output) setting code, keyed on op + freight. */ export function contractClearanceOutputSettingCode( tradeDirection: string, freightType: string, @@ -54,8 +54,8 @@ export function contractClearanceOutputSettingCode( if (!includesCustoms) return null; const op = operationFor(tradeDirection); if (!op) return null; - if (freightFor(freightType) !== 'container') return null; - return `contract_clearance_output_${op}_container`; + const freight = freightFor(freightType); + return `contract_clearance_output_${op}_${freight}`; } /** Convenience: resolve both codes for a loaded contract. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts new file mode 100644 index 000000000..d4f31e570 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -0,0 +1,245 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + NotificationAudience, + NotificationType, + NotifyInput, +} from '@edr/types'; + +import { Contract } from './entities/contract.entity'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; + +/** + * Customer + staff notifications for the contract lifecycle. Every customer + * event fans out over three channels: SMS + email (direct, via + * {@link NotificationsService}) and a persisted in-app notification (via + * {@link NotificationInboxService}) that deep-links to the contract detail page. + * Staff events go to the backoffice inbox. All sends are fire-and-forget and + * never throw — a notification failure must not break a contract transition. + */ +@Injectable() +export class ContractNotifierService { + private readonly logger = new Logger(ContractNotifierService.name); + + constructor( + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} + + private ref(c: Contract): string { + return `${c.reference}${c.isGovernment ? ' (gov)' : ''}`; + } + + /** Send SMS + email to the contract's company contact; log-only on failure. */ + private async notifyContact( + c: Contract, + message: string, + logLabel: string, + ): Promise { + this.logger.log(`${logLabel} — ${this.ref(c)}`); + const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null; + const email = c.company?.email ?? c.company?.generalManagerEmail ?? null; + + if (phone) { + try { + await this.notifications.directSend('sms', phone, message); + } catch (err) { + this.logger.warn(`SMS failed for ${this.ref(c)}: ${(err as Error).message}`); + } + } + if (email) { + try { + await this.notifications.directSend('email', email, message); + } catch (err) { + this.logger.warn(`Email failed for ${this.ref(c)}: ${(err as Error).message}`); + } + } + if (!phone && !email) { + this.logger.warn(`No contact on file for ${this.ref(c)} — notification not sent`); + } + } + + /** Persist + push an in-app item to all portal users of the contract's company. */ + private inApp( + c: Contract, + title: string, + body: string, + overrides: Partial = {}, + ): void { + if (!c.companyId) return; // government/unlinked contracts have no portal users + void this.inbox.notify({ + recipients: { companyId: c.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.CONTRACT_STATUS, + title, + body, + link: `/contracts/${c.id}`, + data: { contractId: c.id, reference: c.reference }, + ...overrides, + }); + } + + /** Persist + push an in-app item to every backoffice staff user. */ + private inAppStaff( + c: Contract, + title: string, + body: string, + overrides: Partial = {}, + ): void { + void this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: `/dashboard/contract-requests/${c.id}`, + data: { contractId: c.id, reference: c.reference }, + ...overrides, + }); + } + + // ── Customer-facing lifecycle events ─────────────────────────────────────── + + /** Line staff accepted intake → contract is under approval. */ + accepted(c: Contract): void { + const msg = + `Your contract ${c.reference} has been accepted and is now under approval. ` + + `We will notify you once it is approved.`; + void this.notifyContact(c, msg, 'ACCEPTED'); + this.inApp(c, 'Contract accepted', msg); + } + + /** All approval steps complete → contract approved. */ + approved(c: Contract): void { + const msg = + `Your contract ${c.reference} has been approved. ` + + `The final document will be prepared for signing.`; + void this.notifyContact(c, msg, 'APPROVED'); + this.inApp(c, 'Contract approved', msg); + } + + /** Fully executed (all parties signed) → contract active, customer can book. */ + signedActive(c: Contract): void { + const msg = + `Your contract ${c.reference} has been signed and is now active. ` + + `You can start booking shipments from the portal.`; + void this.notifyContact(c, msg, 'SIGNED / ACTIVE'); + this.inApp(c, 'Contract active', msg); + } + + /** Staff rejected the contract. */ + rejected(c: Contract, reason: string): void { + const msg = + `Your contract ${c.reference} was rejected. Reason: ${reason}. ` + + `Please contact us for details.`; + void this.notifyContact(c, msg, 'REJECTED'); + this.inApp(c, 'Contract rejected', msg); + } + + /** Staff requested changes before approval. */ + changesRequested(c: Contract, note: string): void { + const msg = + `Changes were requested on your contract ${c.reference}: ${note}. ` + + `Please update and resubmit from the portal.`; + void this.notifyContact(c, msg, 'CHANGES REQUESTED'); + this.inApp(c, 'Contract changes requested', msg); + } + + // ── Clearance milestones needing customer action ────────────────────────── + + /** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */ + dutyAdvised(c: Contract, amount: number, currency: string): void { + const msg = + `Duty & tax of ${amount} ${currency} has been advised for contract ${c.reference}. ` + + `Please pay and upload the payment slip from the portal.`; + void this.notifyContact(c, msg, 'DUTY ADVISED'); + this.inApp(c, 'Duty & tax advised', msg, { + type: NotificationType.INVOICE_ISSUED, + link: `/contracts/${c.id}/clearance`, + }); + } + + /** A clearance document was queried — customer must re-upload it. */ + clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void { + const msg = + `A clearance document on contract ${c.reference} needs attention: "${fileKey}". ` + + `${note}. Please re-upload from the portal.`; + void this.notifyContact(c, msg, 'CLEARANCE DOC QUERIED'); + this.inApp(c, 'Clearance document queried', msg, { + type: NotificationType.DOCUMENT_ACTION, + link: `/contracts/${c.id}/clearance`, + }); + } + + /** Import pre-clearance finalized — the process moves to GL Djibouti collection. */ + preClearanceFinalized(c: Contract): void { + const msg = + `Pre-clearance for contract ${c.reference} is complete. ` + + `Your shipment is proceeding to document collection in Djibouti.`; + void this.notifyContact(c, msg, 'PRE-CLEARANCE FINALIZED'); + this.inApp(c, 'Pre-clearance complete', msg, { + type: NotificationType.CLEARANCE_DECISION, + link: `/contracts/${c.id}/clearance`, + }); + } + + // ── Staff-facing (backoffice inbox) ──────────────────────────────────────── + + /** Customer submitted a contract for review. */ + submittedToStaff(c: Contract): void { + this.inAppStaff( + c, + 'New contract submitted', + `Contract ${this.ref(c)} was submitted and is awaiting intake review.`, + ); + } + + /** Customer signed the contract — staff counter-sign is next. */ + customerSignedToStaff(c: Contract): void { + this.inAppStaff( + c, + 'Customer signed contract', + `Contract ${this.ref(c)} was signed by the customer and awaits the EDR counter-signature.`, + { link: `/dashboard/contract-requests/${c.id}/view` }, + ); + } + + /** Customer uploaded clearance documents — GL review is next. */ + clearanceDocsUploadedToStaff(c: Contract): void { + this.inAppStaff( + c, + 'Clearance documents uploaded', + `Customer uploaded clearance documents for contract ${this.ref(c)} — review them in the clearance queue.`, + { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/contracts/clearance/${c.id}`, + }, + ); + } + + /** Customer uploaded the duty/tax payment slip — GL verifies it. */ + dutySlipUploadedToStaff(c: Contract): void { + this.inAppStaff( + c, + 'Duty slip uploaded', + `Customer uploaded the duty & tax payment slip for contract ${this.ref(c)}.`, + { + type: NotificationType.PAYMENT_RECEIVED, + link: `/dashboard/contracts/clearance/${c.id}`, + }, + ); + } + + /** Customer filed a shipment request under a GENERAL customs contract. */ + shipmentRequestedToStaff(c: Contract, requestId: string, requestRef: string): void { + this.inAppStaff( + c, + 'New shipment request', + `Shipment request ${requestRef} was filed under contract ${this.ref(c)} and awaits GL review.`, + { + link: `/dashboard/shipment-requests/${requestId}`, + data: { contractId: c.id, requestId, reference: requestRef }, + }, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 4fcbc9a6c..9f12b937d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -5,6 +5,7 @@ import { Logger, } from '@nestjs/common'; import { Readable } from 'stream'; +import { insertWithGeneratedReference } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder'; @@ -19,7 +20,9 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FilesService } from '../files/files.service'; import { SignaturesService } from '../signatures/signatures.service'; +import { OtpService } from '../otp/otp.service'; import { ContractPricingService } from './contract-pricing.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService } from './contracts.service'; @@ -63,6 +66,8 @@ export class ContractTransitionService { private readonly renderer: ContractRendererService, private readonly pdfService: ContractPdfService, private readonly minioService: MinioService, + private readonly otpService: OtpService, + private readonly notifier: ContractNotifierService, ) {} /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ @@ -76,7 +81,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'SUBMITTED', } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.submittedToStaff(updated); + return updated; } /** Confirm a price change before submit (mirrors booking confirm-submit). */ @@ -90,7 +97,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'SUBMITTED', } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.submittedToStaff(updated); + return updated; } /** @@ -127,7 +136,9 @@ export class ContractTransitionService { contractValidFrom: validFrom, contractValidUntil: validUntil, } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.accepted(updated); + return updated; } /** @@ -172,15 +183,11 @@ export class ContractTransitionService { const cargoTypeId = (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null; - // US-06 routing: bulk always needs director approval; container needs it only - // when its cargo type flags it. Resolve the chain via the same approval_rules - // source of truth the booking flow uses (no booking row is created here). - let requiresDirectorApproval = contract.freightType === 'BULK'; + // Resolve the chain from the cargo type flag only. + let requiresDirectorApproval = false; if (cargoTypeId) { const cargoType = await this.cargoTypesService.findById(cargoTypeId); - if (cargoType?.requiresDirectorApproval) { - requiresDirectorApproval = true; - } + requiresDirectorApproval = cargoType?.requiresDirectorApproval ?? false; } const chain = await this.approvalRulesService.findChain(requiresDirectorApproval); @@ -219,7 +226,9 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'CHANGES_REQUESTED', } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.changesRequested(updated, note); + return updated; } async reject(contractId: string, reason: string, actorId: string): Promise { @@ -236,7 +245,47 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.rejected(updated, reason); + return updated; + } + + /** + * Reject one approval step (line staff / director / CEO). The rejecting + * approver must supply a reason. A rejection is terminal: the whole contract + * moves to REJECTED and the customer must create a new one — there is no + * resubmit of the same contract. The reason is recorded both on the step and + * as a REJECTION review note so it is visible to the customer and the rest of + * the approval chain. + */ + async rejectStep( + contractId: string, + stepId: string, + actorId: string, + reason: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); + + const step = await this.contractsRepository.findApprovalStepById(contractId, stepId); + if (!step) throw new BadRequestException('Approval step not found'); + + await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason); + + await this.contractsRepository.createReviewNote( + contractId, + reason, + 'REJECTION', + actorId, + 'STAFF', + ); + + await this.contractsRepository.update(contractId, { + status: 'REJECTED', + } as never); + const updated = await this.contractsService.findById(contractId); + this.notifier.rejected(updated, reason); + return updated; } /** Approve one approval step in sequence; → APPROVED when all complete. */ @@ -298,7 +347,11 @@ export class ContractTransitionService { if (Object.keys(updates).length > 0) { await this.contractsRepository.update(contractId, updates as never); } - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + if (allDone) { + this.notifier.approved(updated); + } + return updated; } /** @@ -345,6 +398,14 @@ export class ContractTransitionService { return { view, html, signatures: view.signatures }; } + /** Lazy-generate (or refresh) the stored contract PDF and stream it for download. */ + async streamContractPdf(contractId: string) { + const contract = await this.contractsService.findById(contractId); + const { view } = await this.documentViewModelBuilder.build(contractId); + const record = await this.upsertContractPdf(contractId, contract.reference, view); + return this.filesService.streamById(record.id); + } + /** * Rebuild the stored `contract` PDF from the current aggregate (now including * the latest signatures) so the downloaded/viewed file matches the live HTML @@ -516,13 +577,21 @@ export class ContractTransitionService { if (existing) { throw new BadRequestException('Customer has already signed this contract'); } + // Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone) + // must be verified before the signature is applied. + if (!dto.otpPhone || !dto.otp) { + throw new BadRequestException('OTP verification is required to sign the contract'); + } + await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', customerSignedAt: new Date(), } as never); await this.regenerateContractPdf(contractId, contract.reference); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.customerSignedToStaff(updated); + return updated; } return this.counterSign(contractId, dto, options); @@ -592,15 +661,20 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, updates as never); await this.regenerateContractPdf(contractId, contract.reference); - return this.contractsService.findById(contractId); + const updated = await this.contractsService.findById(contractId); + this.notifier.signedActive(updated); + return updated; } /** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */ async renew(contractId: string, userId?: string): Promise { const source = await this.contractsService.findById(contractId); - const reference = await this.generateRenewalReference(); - const renewal = await this.contractsRepository.create({ + // Retry past a concurrent insert that grabbed the same CTR sequence number. + const renewal = await insertWithGeneratedReference( + () => this.generateRenewalReference(), + (reference) => + this.contractsRepository.create({ reference, companyId: source.companyId, companyProfileId: source.companyProfileId, @@ -628,7 +702,8 @@ export class ContractTransitionService { status: 'RENEWAL_DRAFT', clearanceStatus: 'NOT_APPLICABLE', clearanceCycleNumber: 0, - } as never); + } as never), + ); void userId; return this.contractsService.findById(renewal.id); @@ -636,7 +711,7 @@ export class ContractTransitionService { private async generateRenewalReference(): Promise { const year = new Date().getFullYear(); - const count = await this.contractsRepository.countByYear(year); - return `CTR-${year}-${String(count + 1).padStart(5, '0')}`; + const seq = await this.contractsRepository.maxReferenceSequence(year); + return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`; } } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 0ef013347..4ea7634b6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -9,13 +9,18 @@ import { Patch, Post, Query, + Res, UnauthorizedException, UploadedFiles, + UploadedFile, + UseGuards, UseInterceptors, } from '@nestjs/common'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; import { ApiBearerAuth, ApiBody, @@ -40,11 +45,13 @@ import { ContractsService } from './contracts.service'; import { ContractPricingService } from './contract-pricing.service'; import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; +import { BookingClearanceService } from './booking-clearance.service'; import { ContractBookingService } from './contract-booking.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; import { BookingRequestService } from './booking-request.service'; import { SignaturesService } from '../signatures/signatures.service'; +import { BookingsService } from '../bookings/bookings.service'; import { CreateContractDto } from './dto/create-contract.dto'; import { UpdateContractDto } from './dto/update-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto'; @@ -53,6 +60,7 @@ import { AcceptContractDto } from './dto/accept-contract.dto'; import { ApproveStepDto, RejectContractDto, + RejectStepDto, RequestChangesDto, } from './dto/approve-step.dto'; import { SignContractDto } from './dto/sign-contract.dto'; @@ -70,6 +78,10 @@ import { CompleteMilestoneDto, ReportIncidentDto, } from './dto/gl-operations.dto'; +import { + AdviseContractDutyDto, + RoAmendmentDto, +} from './dto/phased-clearance.dto'; @ApiTags('contracts') @Controller('contracts') @@ -85,6 +97,8 @@ export class ContractsController { private readonly glOperationsService: GlOperationsService, private readonly bookingRequestService: BookingRequestService, private readonly signaturesService: SignaturesService, + private readonly bookingClearanceService: BookingClearanceService, + private readonly bookingsService: BookingsService, ) {} // ── Shipment / booking requests (GENERAL + customs, Path B) ─────────────── @@ -231,6 +245,7 @@ export class ContractsController { } @Get('list-summary') + @BookingStaff([FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.contracts.view]) @ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' }) @ApiOkResponse({ type: ContractListSummaryDto }) findListSummary(@Query() filter: FilterContractDto) { @@ -376,6 +391,27 @@ export class ContractsController { ); } + @Post(':id/approval-steps/:stepId/reject') + @BookingStaff([ + FREIGHT_PERMS.contracts.approveLineStaff, + FREIGHT_PERMS.contracts.approveDirector, + FREIGHT_PERMS.contracts.approveCeo, + ]) + @ApiOperation({ summary: 'Reject one approval step (terminal → REJECTED)' }) + rejectStep( + @Param('id', ParseUUIDPipe) id: string, + @Param('stepId', ParseUUIDPipe) stepId: string, + @Body() dto: RejectStepDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.transitionService.rejectStep( + id, + stepId, + resolveAuthUserId(user), + dto.reason, + ); + } + @Post(':id/contract/generate') @BookingStaff(FREIGHT_PERMS.contracts.generateContract) @ApiOperation({ summary: 'Generate contract document → CONTRACT_READY' }) @@ -417,15 +453,46 @@ export class ContractsController { }; } + @Get(':id/contract/document') + @ApiOperation({ summary: 'Download contract PDF' }) + async downloadContractDocument( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ): Promise { + const contract = await this.contractsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } + const { stream, record } = await this.transitionService.streamContractPdf(id); + res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${record.name}"`, + ); + stream.pipe(res); + } + @Post(':id/contract/sign') + @UseGuards(JwtGuard) @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) signContract( @Param('id', ParseUUIDPipe) id: string, @Body() dto: SignContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { + // Each staff signing role maps to the permission that step already requires; + // customers sign their own contract with no permission key. + const signRolePermission: Record = { + STAFF: FREIGHT_PERMS.contracts.signStaff, + DIRECTOR: FREIGHT_PERMS.contracts.approveDirector, + CEO: FREIGHT_PERMS.contracts.approveCeo, + }; + if (dto.role !== 'CUSTOMER') { + assertFreightPermission(user, signRolePermission[dto.role]); + } return this.transitionService.sign(id, dto, { - signerUserId: user?.id ?? user?.sub, + signerUserId: user?.id, }); } @@ -459,7 +526,10 @@ export class ContractsController { } @Post(':id/clearance/review') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ]) @ApiOperation({ summary: 'GL ET reviews a clearance document (Approve | Query)' }) reviewClearanceDocument( @Param('id', ParseUUIDPipe) id: string, @@ -489,11 +559,170 @@ export class ContractsController { @Post(':id/clearance/finalize') @BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance) - @ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING' }) + @ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)' }) finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { return this.clearanceService.finalize(id); } + @Post(':id/clearance/declaration') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads customs declaration documents (multi-file)' }) + uploadDeclaration( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadDeclaration(id, files ?? [], resolveAuthUserId(user)); + } + + @Post(':id/clearance/duty') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise) + @UseInterceptors(FileInterceptor('attachment')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET sets duty/tax requirement and advises amount with notice attachment' }) + adviseContractDuty( + @Param('id', ParseUUIDPipe) id: string, + @Body('dutyRequired') dutyRequiredRaw: string, + @Body('amount') amountRaw: string | undefined, + @Body('currency') currency: string | undefined, + @Body('declarationSerial') declarationSerial: string | undefined, + @UploadedFile() attachment: Express.Multer.File | undefined, + @CurrentUser() user: AuthUserPayload, + ) { + const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1'; + const dto: AdviseContractDutyDto = { + dutyRequired, + amount: + amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, + currency: currency ?? 'ETB', + declarationSerial, + }; + return this.clearanceService.adviseDuty( + id, + dto, + resolveAuthUserId(user), + attachment, + ); + } + + @Post(':id/clearance/finalize-pre-clearance') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET finalizes import pre-clearance — unlocks Djibouti DO upload' }) + finalizePreClearance(@Param('id', ParseUUIDPipe) id: string) { + return this.clearanceService.finalizePreClearance(id); + } + + @Post(':id/clearance/duty-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on contract' }) + uploadContractDutySlip( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.clearanceService.uploadDutySlip(id, file); + } + + @Post(':id/clearance/transit-permit') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads import transit permit documents (multi-file)' }) + uploadTransitPermit( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadTransitPermit(id, files ?? [], resolveAuthUserId(user)); + } + + @Post(':id/clearance/delivery-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' }) + uploadDeliveryOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadDeliveryOrder( + id, + file, + resolveAuthUserId(user), + vesselDepartureDate, + ); + } + + @Post(':id/clearance/release-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL DJ uploads Release Order + vessel departure date (export)' }) + uploadReleaseOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadReleaseOrder( + id, + file, + vesselDepartureDate, + resolveAuthUserId(user), + ); + } + + @Post(':id/clearance/ro-amendment') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL DJ requests port amendment when RO vessel window is too short' }) + requestRoAmendment( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RoAmendmentDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.requestRoAmendment(id, dto.note, resolveAuthUserId(user)); + } + + @Post(':id/clearance/export-release') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET confirms export release after declaration' }) + confirmExportRelease( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.confirmExportRelease(id, resolveAuthUserId(user)); + } + + @Post(':id/clearance/finalize-export-clearance') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ + summary: 'GL ET finalizes export clearance after post-booking transit permit upload', + }) + finalizeExportClearance( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user)); + } + + @Get('clearance/et-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' }) + etClearanceQueue(@Query() filter: FilterContractDto) { + return this.clearanceService.etQueue(filter); + } + + @Get('clearance/dj-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' }) + djClearanceQueue(@Query() filter: FilterContractDto) { + return this.clearanceService.djQueue(filter); + } + // ── Path A self-clearance — Operations reviews the customer's own docs ─────── @Get('clearance/ops-queue') @@ -570,6 +799,18 @@ export class ContractsController { ); } + @Post(':id/validate-shipment') + @ApiOperation({ + summary: + 'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).', + }) + validateShipment( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CreateBookingUnderContractDto, + ) { + return this.contractBookingService.validateShipment(id, dto); + } + @Get(':id/capacity') @ApiOperation({ summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)', @@ -674,6 +915,144 @@ export class ContractsController { }); } + @Post('bookings/:bookingId/transport-document') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads export transit permit documents (multi-file)' }) + uploadTransportDocument( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []); + } + + @Post('bookings/:bookingId/t1-documents') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: + 'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs', + }) + uploadT1Documents( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.glOperationsService.uploadT1Documents(bookingId, files ?? []); + } + + @Post('bookings/:bookingId/t1-close') + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDjActions, + ]) + @ApiOperation({ + summary: + 'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)', + }) + closeT1( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user)); + } + + @Post('bookings/:bookingId/final-invoice') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'GL DJ raises the post-offload final invoice (amount + invoice document)', + }) + createFinalInvoice( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body('amount') amountRaw: string, + @Body('currency') currency: string | undefined, + @Body('description') description: string | undefined, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.createFinalInvoice( + bookingId, + { + amount: Number(amountRaw), + currency: currency?.trim() || 'ETB', + description, + }, + file, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/final-invoice-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' }) + uploadFinalInvoiceSlip( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.glOperationsService.uploadFinalInvoiceSlip(bookingId, file); + } + + @Post('bookings/:bookingId/final-invoice/confirm') + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceDjActions, + FREIGHT_PERMS.contracts.clearanceEtActions, + ]) + @ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip — settles the final invoice' }) + confirmFinalInvoicePaid( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.confirmFinalInvoicePaid( + bookingId, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/second-duty') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(FileInterceptor('attachment')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'GL ET advises (or skips) the post-arrival additional duty/tax round (import)', + }) + adviseSecondDuty( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body('dutyRequired') dutyRequiredRaw: string, + @Body('amount') amountRaw: string | undefined, + @Body('currency') currency: string | undefined, + @Body('declarationSerial') declarationSerial: string | undefined, + @UploadedFile() attachment: Express.Multer.File | undefined, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.adviseSecondDuty( + bookingId, + { + dutyRequired: dutyRequiredRaw === 'true' || dutyRequiredRaw === '1', + amount: + amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, + currency: currency ?? 'ETB', + declarationSerial, + }, + attachment, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/second-duty-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' }) + uploadSecondDutySlip( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.glOperationsService.uploadSecondDutySlip(bookingId, file); + } + @Post('bookings/:bookingId/documents') @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) @@ -692,11 +1071,16 @@ export class ContractsController { @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' }) - uploadDutySlip( + async uploadDutySlip( @Param('bookingId', ParseUUIDPipe) bookingId: string, @UploadedFiles() files: Express.Multer.File[], ) { - return this.glOperationsService.uploadDutySlip(bookingId, (files ?? [])[0]); + const file = (files ?? [])[0]; + const booking = await this.bookingsService.findById(bookingId); + if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) { + return this.bookingClearanceService.uploadDutySlip(bookingId, file); + } + return this.glOperationsService.uploadDutySlip(bookingId, file); } @Get('bookings/:bookingId/incidents') diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index c595dbf5f..33a547a9f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -1,8 +1,9 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; +import { BillingModule } from '../billing/billing.module'; import { CompaniesModule } from '../companies/companies.module'; import { FilesModule } from '../files/files.module'; import { MinioModule } from '../minio/minio.module'; @@ -10,14 +11,21 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; +import { OtpModule } from '../otp/otp.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { BookingsModule } from '../bookings/bookings.module'; +import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; import { ContractsRepository } from './contracts.repository'; import { ContractPricingService } from './contract-pricing.service'; +import { ContractNotifierService } from './contract-notifier.service'; import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; +import { BookingClearanceService } from './booking-clearance.service'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ContractBookingService } from './contract-booking.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; @@ -62,16 +70,24 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum Booking, BookingContainerUnit, ]), + BillingModule, RuleEngineModule, FileUploadSettingsModule, DropdownSettingsModule, FilesModule, MinioModule, SignaturesModule, + OtpModule, + NotificationsModule, + NotificationInboxModule, CompaniesModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). - BookingsModule, + forwardRef(() => BookingsModule), + // TrainSchedulingModule provides the config-driven booking-window gate used + // by ContractBookingService.createUnderContract. forwardRef because + // TrainSchedulingModule already imports ContractsModule. + forwardRef(() => TrainSchedulingModule), ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => @@ -83,8 +99,11 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractsService, ContractsRepository, ContractPricingService, + ContractNotifierService, ContractTransitionService, ContractClearanceService, + ClearanceWorkflowService, + BookingClearanceService, ContractBookingService, ClearanceMilestoneService, GlOperationsService, @@ -103,6 +122,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractPricingService, ContractTransitionService, ContractClearanceService, + ClearanceWorkflowService, + BookingClearanceService, ContractBookingService, ClearanceMilestoneService, ], diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 354f02e8c..e53ba0e13 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -45,16 +45,23 @@ export class ContractsRepository extends BaseRepository { return this.repository.findOne({ where: { reference } }); } - /** Count contracts created in a specific year. */ - async countByYear(year: number): Promise { - const startDate = new Date(year, 0, 1); - const endDate = new Date(year + 1, 0, 1); - - return this.repository + /** + * Highest NNNNN sequence already issued for `CTR--…` references. + * Includes soft-deleted contracts — their references still occupy the unique + * index, so the next number must move past them. (A created-at count drifts + * below the issued sequence after any delete and then collides forever.) + */ + async maxReferenceSequence(year: number): Promise { + const row = await this.repository .createQueryBuilder('contract') - .where('contract.created_at >= :startDate', { startDate }) - .andWhere('contract.created_at < :endDate', { endDate }) - .getCount(); + .withDeleted() + .select( + "COALESCE(MAX(CAST(SUBSTRING(contract.reference FROM '[0-9]+$') AS int)), 0)", + 'max', + ) + .where('contract.reference LIKE :prefix', { prefix: `CTR-${year}-%` }) + .getRawOne<{ max: string | number | null }>(); + return Number(row?.max ?? 0); } /** Find a contract by ID with all child collections, service type, company and files. */ @@ -135,6 +142,7 @@ export class ContractsRepository extends BaseRepository { // Attach the generated contract PDF to each row so list/home can offer a // direct download. Loaded separately to keep pagination counts correct. await this.attachContractFiles(items); + await this.attachClearancePhases(items); const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0; return { @@ -173,6 +181,30 @@ export class ContractsRepository extends BaseRepository { } } + /** + * Attach each contract's persisted clearance phase (latest cycle's + * current_phase) so list consumers can show step-accurate customer actions + * ("Pay duty & upload slip" vs generic "Update clearance") without a + * per-contract clearance-view request. One query per page, like + * `attachContractFiles`. + */ + private async attachClearancePhases(contracts: Contract[]): Promise { + if (contracts.length === 0) return; + const ids = contracts.map((c) => c.id); + const rows: Array<{ contract_id: string; current_phase: string | null }> = + await this.dataSource.query( + `SELECT DISTINCT ON (contract_id) contract_id, current_phase + FROM freight.contract_clearance_cycles + WHERE contract_id = ANY($1) + ORDER BY contract_id, cycle_number DESC`, + [ids], + ); + const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase])); + for (const contract of contracts) { + contract.clearancePhase = byContract.get(contract.id) ?? null; + } + } + async getStatusCounts(): Promise> { const rows = await this.repository .createQueryBuilder('contract') @@ -518,7 +550,17 @@ export class ContractsRepository extends BaseRepository { cycleId: string, status: string, fields: Partial< - Pick + Pick< + ContractClearanceCycle, + | 'bookingId' + | 'clearanceReadyAt' + | 'completedAt' + | 'dutyRequired' + | 'vesselDepartureDate' + | 'roAmendmentRequestedAt' + | 'roHoldReason' + | 'currentPhase' + > > = {}, ): Promise { await this.dataSource @@ -526,6 +568,25 @@ export class ContractsRepository extends BaseRepository { .update(cycleId, { status, ...fields } as never); } + async updateCycle( + cycleId: string, + fields: Partial< + Pick< + ContractClearanceCycle, + | 'dutyRequired' + | 'vesselDepartureDate' + | 'roAmendmentRequestedAt' + | 'roHoldReason' + | 'currentPhase' + | 'status' + | 'preClearanceFinalizedAt' + | 'completedAt' + > + >, + ): Promise { + await this.dataSource.getRepository(ContractClearanceCycle).update(cycleId, fields as never); + } + /** Link the GL-created booking to a clearance cycle. */ async linkBooking(cycleId: string, bookingId: string): Promise { await this.dataSource diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 70d2632fa..aaf064bff 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -6,11 +6,16 @@ import { } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { insertWithGeneratedReference } from '@edr/api-common'; +import { YardCountry } from '@edr/types'; + +import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { CompaniesService } from '../companies/companies.service'; import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity'; import { CompanyStatus } from '../companies/entities/company.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; import { ContractsRepository } from './contracts.repository'; @@ -57,8 +62,8 @@ export class ContractsService { /** Generate a unique contract reference number (CTR-YYYY-NNNNN). */ private async generateReference(): Promise { const year = new Date().getFullYear(); - const count = await this.contractsRepository.countByYear(year); - return `CTR-${year}-${String(count + 1).padStart(5, '0')}`; + const seq = await this.contractsRepository.maxReferenceSequence(year); + return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`; } /** Whether a service type bundles customs clearance. */ @@ -109,6 +114,49 @@ export class ContractsService { } } + /** + * Every route must match the contract's declared trade direction as derived + * from the yard countries (IMPORT = DJ→ET, EXPORT = ET→DJ, DOMESTIC = + * intercity). Intercity is Ethiopian-domestic only: both yards must be in + * Ethiopia — a Djibouti-internal pair is rejected. Direction mismatches + * (e.g. an export lane on an import contract) are rejected for every kind. + */ + private async assertRoutesMatchDirection( + tradeDirection: string, + routes: CreateContractDto['routes'], + ): Promise { + const yardIds = [ + ...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])), + ]; + const yards = await this.dataSource + .getRepository(Yard) + .find({ where: yardIds.map((id) => ({ id })) }); + const yardById = new Map(yards.map((y) => [y.id, y])); + + for (const route of routes) { + const origin = yardById.get(route.originYardId); + const destination = yardById.get(route.destinationYardId); + if (!origin || !destination) { + throw new BadRequestException('Route references a yard that does not exist'); + } + const derived = deriveTradeDirection(origin, destination); + if (derived !== tradeDirection) { + throw new BadRequestException( + `Route ${origin.label} → ${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`, + ); + } + if ( + derived === 'DOMESTIC' && + (origin.country !== YardCountry.ETHIOPIA || + destination.country !== YardCountry.ETHIOPIA) + ) { + throw new BadRequestException( + `Route ${origin.label} → ${destination.label}: intercity service only runs between Ethiopian yards`, + ); + } + } + } + /** Create a new contract (DRAFT) with its routes and cargo-scope rows. */ async create( dto: CreateContractDto, @@ -143,8 +191,7 @@ export class ContractsService { this.assertCargoScopeShape(dto.freightType, dto.cargoScope); this.assertRouteShape(dto.contractKind, dto.routes); - - const reference = dto.reference || (await this.generateReference()); + await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes); // Stamp the operational profile (importer/exporter) for portal scoping. let companyProfileId: string | null = null; @@ -176,8 +223,69 @@ export class ContractsService { // Customs clearing is owned by the service type, not the customer. const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId); + // Intercity never crosses a border, so a customs-including service type is + // a contradiction — the wizard hides them, the API enforces it. + if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) { + throw new BadRequestException( + 'Intercity contracts cannot use a service type that includes customs clearing', + ); + } - const contract = await this.contractsRepository.create({ + // An explicit reference is caller-chosen — a collision there is a real + // conflict and should surface. Auto-generated references retry past a + // concurrent insert that grabbed the same sequence number. + const contract = dto.reference + ? await this.insertContract(dto.reference, { + companyId, + companyProfileId, + isGovernment, + includesCustoms, + dto, + }) + : await insertWithGeneratedReference( + () => this.generateReference(), + (reference) => + this.insertContract(reference, { + companyId, + companyProfileId, + isGovernment, + includesCustoms, + dto, + }), + ); + + await this.persistRoutes(contract.id, dto.routes); + await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind); + + if (files.length > 0) { + try { + await this.filesService.uploadMany(contract.id, 'contracts', files); + } catch { + warnings.push('File upload failed — contract was created without attached files.'); + } + } + + // Attach the company profile's onboarding / business-license documents to the + // contract by reference. The separate "Documents" intake step was removed — + // the profile documents are simply carried onto every contract automatically. + await this.attachProfileDocuments(contract.id, companyProfileId); + + return { contract: await this.findById(contract.id), warnings }; + } + + /** Insert one DRAFT contract row with the given reference (no children). */ + private insertContract( + reference: string, + ctx: { + companyId: string | null | undefined; + companyProfileId: string | null; + isGovernment: boolean; + includesCustoms: boolean; + dto: CreateContractDto; + }, + ): Promise { + const { companyId, companyProfileId, isGovernment, includesCustoms, dto } = ctx; + return this.contractsRepository.create({ reference, companyId: companyId ?? null, companyProfileId, @@ -200,32 +308,11 @@ export class ContractsService { lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, isHazardous: dto.isHazardous ?? false, isReefer: dto.isReefer ?? false, - estimatedShipmentDate: dto.estimatedShipmentDate - ? new Date(dto.estimatedShipmentDate) - : null, contractType: dto.contractType ?? null, status: 'DRAFT', clearanceStatus: 'NOT_APPLICABLE', clearanceCycleNumber: 0, } as never); - - await this.persistRoutes(contract.id, dto.routes); - await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind); - - if (files.length > 0) { - try { - await this.filesService.uploadMany(contract.id, 'contracts', files); - } catch { - warnings.push('File upload failed — contract was created without attached files.'); - } - } - - // Attach the company profile's onboarding / business-license documents to the - // contract by reference. The separate "Documents" intake step was removed — - // the profile documents are simply carried onto every contract automatically. - await this.attachProfileDocuments(contract.id, companyProfileId); - - return { contract: await this.findById(contract.id), warnings }; } /** @@ -328,6 +415,12 @@ export class ContractsService { if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope); if (dto.routes) this.assertRouteShape(contractKind, dto.routes); + if (dto.routes) { + await this.assertRoutesMatchDirection( + dto.tradeDirection ?? existing.tradeDirection, + dto.routes, + ); + } const updates: Record = { contractKind, @@ -347,15 +440,17 @@ export class ContractsService { lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng, contractType: dto.contractType ?? existing.contractType, }; - if (dto.estimatedShipmentDate) { - updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate); - } if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null; // Customs clearing always mirrors the (possibly changed) service type. const includesCustoms = await this.resolveIncludesCustoms( dto.serviceTypeId ?? existing.serviceTypeId, ); + if ((dto.tradeDirection ?? existing.tradeDirection) === 'DOMESTIC' && includesCustoms) { + throw new BadRequestException( + 'Intercity contracts cannot use a service type that includes customs clearing', + ); + } updates.customsClearingEnabled = includesCustoms; updates.customsClearingAgent = includesCustoms ? null @@ -472,6 +567,21 @@ export class ContractsService { ); } + // Surface the staff "request changes" note so the portal can show the + // customer what to fix. Degrade to null on lookup failure — a missing note + // must never 500 a contract fetch. + if (contract.status === 'CHANGES_REQUESTED') { + try { + const note = await this.contractsRepository.findLatestReviewNote( + contract.id, + 'CHANGES_REQUESTED', + ); + contract.latestChangeRequestNote = note?.body ?? null; + } catch { + contract.latestChangeRequestNote = null; + } + } + return contract; } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 4108986cb..e3130da95 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -101,6 +101,13 @@ export class CreateBulkLineDto { @Min(0) @Transform(({ value }) => Number(value)) hazardousQuantity?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + reeferQuantity?: number; } /** Shipment booking created under a contract (Path A customer, Path B GL ET). */ @@ -113,9 +120,14 @@ export class CreateBookingUnderContractDto { @IsUUID() contractRouteId?: string; - @ApiProperty({ description: 'Binding shipment day.', example: '2026-07-15' }) + @ApiPropertyOptional({ + description: + 'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.', + example: '2026-07-15', + }) + @IsOptional() @IsDateString() - scheduledDate!: string; + scheduledDate?: string; @ApiPropertyOptional({ type: [CreateBookingContainerLineDto] }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index 70dba1e90..b20b99575 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -4,7 +4,6 @@ import { ArrayMinSize, IsArray, IsBoolean, - IsDateString, IsIn, IsNumber, IsOptional, @@ -219,14 +218,6 @@ export class CreateContractDto { @Transform(({ value }) => value === 'true' || value === true) isReefer?: boolean; - @ApiPropertyOptional({ - description: 'Non-binding estimate from the wizard (NOT validated against departures)', - example: '2026-07-15T00:00:00.000Z', - }) - @IsOptional() - @IsDateString() - estimatedShipmentDate?: string; - @ApiPropertyOptional({ description: 'Contract document type (SPOT, etc.)' }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts new file mode 100644 index 000000000..34a903427 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator'; + +export class AdviseContractDutyDto { + @ApiProperty({ description: 'Whether the customer must pay duty/tax' }) + @IsBoolean() + dutyRequired!: boolean; + + @ApiPropertyOptional({ description: 'Duty amount (required when dutyRequired is true)' }) + @IsOptional() + @IsNumber() + @Min(0) + amount?: number; + + @ApiPropertyOptional({ default: 'ETB' }) + @IsOptional() + @IsString() + currency?: string; + + @ApiPropertyOptional({ description: 'Declaration / payment reference code' }) + @IsOptional() + @IsString() + declarationSerial?: string; +} + +export class ReleaseOrderDto { + @ApiProperty({ description: 'Vessel departure date (ISO date YYYY-MM-DD)' }) + @IsString() + vesselDepartureDate!: string; +} + +export class RoAmendmentDto { + @ApiPropertyOptional({ description: 'Note to customer / ET GL about the amendment request' }) + @IsOptional() + @IsString() + note?: string; +} + diff --git a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts index febe7a83b..f0676b629 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; +import { IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator'; export class SignContractDto { @ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] }) @@ -26,4 +26,19 @@ export class SignContractDto { @IsOptional() @IsString() consentText?: string; + + // Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code + // SMS'd to the signer's phone, verified server-side before the signature is + // applied. `otpPhone` is the number the code was sent to (the signed-in + // customer's registered phone). + @ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' }) + @IsOptional() + @IsString() + @Matches(/^\d{6}$/, { message: 'otp must be 6 digits' }) + otp?: string; + + @ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' }) + @IsOptional() + @IsString() + otpPhone?: string; } diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts index 502afaf6a..d4676b8cd 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts @@ -23,6 +23,8 @@ export interface MilestoneMetadata { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string; + /** When the gate pass was physically granted (GL DJ captures the time). */ + gatepassAt?: string; } /** diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts index e29985f2b..3c101f58e 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts @@ -34,4 +34,25 @@ export class ContractClearanceCycle extends BaseEntity { @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) completedAt?: Date | null; + + /** ET GL toggle: whether customer must pay duty/tax before DO collection (import). */ + @Column({ name: 'duty_required', type: 'boolean', nullable: true }) + dutyRequired?: boolean | null; + + /** Export RO vessel departure date (Path B export). */ + @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) + vesselDepartureDate?: string | null; + + @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) + roAmendmentRequestedAt?: Date | null; + + @Column({ name: 'ro_hold_reason', type: 'text', nullable: true }) + roHoldReason?: string | null; + + @Column({ name: 'current_phase', type: 'varchar', length: 40, nullable: true }) + currentPhase?: string | null; + + /** ET GL confirms import pre-clearance complete — unlocks Djibouti DO upload. */ + @Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true }) + preClearanceFinalizedAt?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index 0461d3736..0b0fab41b 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -254,4 +254,17 @@ export class Contract extends BaseEntity { createForeignKeyConstraints: false, }) files?: FileRecord[]; + + /** + * Latest clearance cycle's current_phase, attached by + * ContractsRepository.attachClearancePhases for list responses. Not a column. + */ + clearancePhase?: string | null; + + /** + * Body of the most recent CHANGES_REQUESTED review note, attached by + * ContractsService.findById so the portal can show the customer what staff + * asked them to fix. Lives in contract_review_notes, not a column here. + */ + latestChangeRequestNote?: string | null; } diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index e8f53f4c6..106acdb0b 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -1,13 +1,29 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, IsNull } from 'typeorm'; +import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types'; +import { BillingService } from '../billing/billing.service'; +import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { FilesService } from '../files/files.service'; import { Booking } from '../bookings/entities/booking.entity'; +import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity'; import { ClearanceIncident, IncidentType, } from './entities/clearance-incident.entity'; +import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { + persistExportTransportUploads, + persistT1TransportUploads, +} from './phased-clearance.util'; /** * Maps a GL post-booking document `code` to the milestone it auto-completes when @@ -17,10 +33,12 @@ import { ClearanceMilestoneService } from './clearance-milestone.service'; const DOC_CODE_TO_MILESTONE: Record = { release_order: 'RELEASE_ORDER_SECURED', // export — GL DJ delivery_order: 'DO_COLLECTED', // import — GL DJ - t1_transport_document: 'T1_CLOSED', // import — GL ET + // t1_transport_document intentionally NOT doc-triggered: T1_CLOSED completes only + // when GL Ethiopia accepts the T1 set after the train arrives (closeT1). import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET full_in_interchange: 'OFFLOADED', // export — GL DJ final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET + export_transport_document: 'EXPORT_TRANSPORT_ISSUED', // export — GL ET post-allocation }; /** @@ -35,6 +53,8 @@ export class GlOperationsService { private readonly dataSource: DataSource, private readonly filesService: FilesService, private readonly milestoneService: ClearanceMilestoneService, + private readonly billingService: BillingService, + private readonly notifier: BookingLifecycleNotifierService, ) {} private get bookings() { @@ -46,7 +66,11 @@ export class GlOperationsService { } private async getBooking(bookingId: string): Promise { - const booking = await this.bookings.findOne({ where: { id: bookingId } }); + // company is loaded so customer notifications have a phone/email to target. + const booking = await this.bookings.findOne({ + where: { id: bookingId }, + relations: { company: true }, + }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); return booking; } @@ -158,4 +182,553 @@ export class GlOperationsService { } return { uploaded: files.length, completedMilestones }; } + + /** Wagon-allocation + train-schedule actuals for a booking (both directions). */ + async trainState(bookingId: string): Promise { + const booking = await this.getBooking(bookingId); + const milestones = await this.milestoneService.listForBooking(bookingId); + + const wagonMilestone = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED'); + const wagonAllocated = + wagonMilestone?.status === 'COMPLETED' || + booking.schedulingStatus === 'SCHEDULED' || + booking.schedulingStatus === 'DISPATCHED' || + Boolean(booking.trainScheduleId); + + let schedule: TrainSchedule | null = null; + if (booking.trainScheduleId) { + schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: booking.trainScheduleId } }); + } + + // Per-booking journey first: a booking rides only its own leg, so ITS + // loaded/arrived timestamps gate clearance — a Dire→Djibouti booking that + // unloaded at its own destination clears while the train keeps rolling, + // and a booking still on board does NOT clear just because the train + // arrived. The schedule actuals remain only as fallback for legacy + // in-flight bookings that predate per-booking load/unload (no loadedAt). + const departedAt = booking.loadedAt ?? schedule?.actualDepartureAt ?? null; + const arrivedAt = + booking.arrivedAt ?? + (booking.loadedAt ? null : (schedule?.actualArrivalAt ?? null)); + + return { + scheduleId: schedule?.id ?? null, + wagonAllocated, + departedAt: departedAt ? new Date(departedAt).toISOString() : null, + arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null, + }; + } + + /** + * Gate pass status for a booking, sourced from the train schedule's Djibouti + * gate-pass operation (secured via the train-scheduling "Save as Secured" + * action) rather than a clearance milestone. For EXPORT bookings this also + * backfills the arrival-chain milestones once secured, same as the retired + * clearance-side grant action used to. + */ + async gatepassForBooking( + bookingId: string, + ): Promise<{ granted: boolean; grantedAt: string | null }> { + const train = await this.trainState(bookingId); + if (!train.scheduleId) return { granted: false, grantedAt: null }; + const operation = await this.dataSource + .getRepository(ImportDjiboutiOperation) + .findOne({ where: { trainScheduleId: train.scheduleId } }); + const grantedAt = operation?.gatepassGrantedAt + ? new Date(operation.gatepassGrantedAt).toISOString() + : null; + + if (grantedAt) { + const booking = await this.getBooking(bookingId); + if ((booking.tradeDirection ?? 'IMPORT') === 'EXPORT') { + const milestones = await this.milestoneService.listForBooking(bookingId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { + if (byCode.get(code)?.status === 'PENDING') { + await this.milestoneService.completeForBooking(bookingId, code); + } + } + } + } + + return { granted: Boolean(grantedAt), grantedAt }; + } + + /** + * T1 transit-document lifecycle state for an import shipment booking. The + * gate pass (secured on the train schedule after wagon allocation) opens the + * upload window; train departure locks it; train arrival lets GL Ethiopia + * close (accept) the T1 set. + */ + async t1State(bookingId: string): Promise { + const train = await this.trainState(bookingId); + const milestones = await this.milestoneService.listForBooking(bookingId); + + const closedMilestone = milestones.find( + (m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED', + ); + + return { + bookingId, + wagonAllocated: train.wagonAllocated, + trainDepartedAt: train.departedAt, + trainArrivedAt: train.arrivedAt, + closed: Boolean(closedMilestone), + closedAt: closedMilestone?.triggeredAt + ? new Date(closedMilestone.triggeredAt).toISOString() + : null, + }; + } + + /** + * GL Djibouti uploads T1 transport documents (multi-file) once the gate pass + * is secured on the train schedule (which itself follows wagon allocation). + * Replaces the previous batch; locked only once GL Ethiopia closes the T1. + */ + async uploadT1Documents( + bookingId: string, + files: Express.Multer.File[], + ): Promise<{ uploaded: number }> { + const booking = await this.getBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('T1 transport documents apply to import shipments only.'); + } + + const state = await this.t1State(bookingId); + if (!state.wagonAllocated) { + throw new BadRequestException( + 'Wagons must be allocated before T1 transport documents can be uploaded.', + ); + } + const gatepass = await this.gatepassForBooking(bookingId); + if (!gatepass.granted) { + throw new BadRequestException( + 'Secure the Djibouti gate pass on the train schedule before uploading T1 transport documents.', + ); + } + if (state.closed) { + throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.'); + } + // Departure no longer locks T1 docs — GL DJ may replace them any time until + // GL Ethiopia closes/accepts the T1. + + await persistT1TransportUploads(this.filesService, bookingId, files); + return { uploaded: files.length }; + } + + /** + * Close (accept) the T1/transport document set. + * Import: GL Ethiopia closes once the train has arrived (T1 files required). + * Export: GL Djibouti closes once the train arrives at Djibouti (transport + * document required). + */ + async closeT1( + bookingId: string, + userId?: string, + ): Promise { + const booking = await this.getBooking(bookingId); + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + + const state = await this.t1State(bookingId); + if (state.closed) return state; + + if (tradeDirection === 'IMPORT') { + if (!state.trainArrivedAt) { + throw new BadRequestException( + 'The train has not arrived yet — T1 can be closed only after arrival.', + ); + } + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const hasT1 = files.some((f) => isT1TransportFileCode(f.code)); + if (!hasT1) { + throw new BadRequestException( + 'No T1 transport documents on file — GL Djibouti must upload them first.', + ); + } + } else { + const milestones = await this.milestoneService.listForBooking(bookingId); + const done = (code: string) => + milestones.find((m) => m.milestoneCode === code)?.status === 'COMPLETED'; + if (!done('EXPORT_TRANSPORT_ISSUED')) { + throw new BadRequestException( + 'The transport document must be uploaded before T1 can be closed.', + ); + } + if (!state.trainArrivedAt) { + throw new BadRequestException( + 'The train has not arrived at Djibouti yet — T1 can be closed only after arrival.', + ); + } + // Export bookings seeded before T1_CLOSED joined the catalog lack the row. + await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection); + } + + await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId); + return this.t1State(bookingId); + } + + /** Milestones GL DJ implicitly confirms when granting an export gate pass. */ + private static readonly EXPORT_ARRIVAL_CHAIN = [ + 'CARGO_ARRIVED', + 'READY_FOR_LOADING', + 'LOADED', + 'DEPARTED_TO_DJIBOUTI', + 'ARRIVED_AT_DJIBOUTI', + ]; + + + /** + * GL Djibouti raises the post-offload final invoice (export): manual amount + + * attached invoice document. The customer pays offline and attaches a slip; + * GL (ET or DJ) then confirms to settle it. + */ + async createFinalInvoice( + bookingId: string, + input: { amount: number; currency: string; description?: string }, + file: Express.Multer.File, + userId?: string, + ): Promise { + const booking = await this.getBooking(bookingId); + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Final invoice applies to customs bookings only.'); + } + if (!(input.amount > 0)) { + throw new BadRequestException('Invoice amount must be greater than zero.'); + } + if (!file) throw new BadRequestException('Attach the invoice document.'); + + // Invoiceable once cargo is offloaded, or — for export, where OFFLOADED is a + // DJ doc milestone that may never be recorded — once the Djibouti gate pass + // is secured. The invoice itself stays optional; nothing forces GL DJ to send one. + const milestones = await this.milestoneService.listForBooking(bookingId); + const offloaded = milestones.find( + (m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED', + ); + if (!offloaded) { + const gatepass = await this.gatepassForBooking(bookingId); + if (!gatepass.granted) { + throw new BadRequestException( + 'Cargo must be offloaded (or the gate pass secured) before the final invoice can be raised.', + ); + } + } + + const existing = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if ( + existing && + existing.status !== Freight.InvoiceStatus.Cancelled && + existing.status !== Freight.InvoiceStatus.Expired + ) { + throw new ConflictException('A final invoice already exists for this shipment.'); + } + + const description = input.description?.trim() || 'Post-offload charges (Djibouti)'; + await this.billingService.generateInvoice({ + source: Freight.InvoiceSource.Booking, + sourceId: bookingId, + type: GL_FINAL_INVOICE_TYPE, + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: input.currency, + lines: [ + { + chargeType: GL_FINAL_INVOICE_TYPE, + description, + quantity: 1, + unitRate: input.amount, + amount: input.amount, + }, + ], + status: Freight.InvoiceStatus.Issued, + }); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'final_invoice', + file, + }); + + // Export clearance is administratively done once the final invoice goes out. + await this.dataSource + .getRepository(ContractClearanceCycle) + .update({ bookingId, completedAt: IsNull() }, { completedAt: new Date() }); + + void userId; + const summary = await this.finalInvoiceSummary(bookingId); + if (!summary) throw new NotFoundException('Final invoice could not be created.'); + this.notifier.finalInvoiceCreated(booking, input.amount, input.currency); + return summary; + } + + /** Customer attaches the payment slip for the final invoice. */ + async uploadFinalInvoiceSlip( + bookingId: string, + file: Express.Multer.File, + ): Promise<{ uploaded: boolean }> { + const booking = await this.getBooking(bookingId); + if (!file) throw new BadRequestException('No payment slip uploaded'); + + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) { + throw new BadRequestException('No final invoice has been issued for this shipment.'); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException('The final invoice is already paid.'); + } + if ( + invoice.status === Freight.InvoiceStatus.Cancelled || + invoice.status === Freight.InvoiceStatus.Expired + ) { + throw new BadRequestException('The final invoice is no longer payable.'); + } + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'final_invoice_slip', + file, + }); + this.notifier.dutySlipUploadedToStaff(booking, 'final'); + return { uploaded: true }; + } + + /** GL (ET or DJ) confirms the customer's slip — settles the final invoice. */ + async confirmFinalInvoicePaid( + bookingId: string, + userId?: string, + ): Promise { + const booking = await this.getBooking(bookingId); + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) { + throw new BadRequestException('No final invoice has been issued for this shipment.'); + } + if (invoice.status !== Freight.InvoiceStatus.Paid) { + const files = await this.filesService.findByResource(bookingId, 'bookings'); + if (!files.some((f) => f.code === 'final_invoice_slip')) { + throw new BadRequestException( + 'The customer has not attached a payment slip yet.', + ); + } + await this.billingService.markInvoiceAsPaid(invoice.id); + this.notifier.finalInvoicePaid(booking); + } + + void userId; + const summary = await this.finalInvoiceSummary(bookingId); + if (!summary) throw new NotFoundException('Final invoice not found.'); + return summary; + } + + /** + * GL ET advises (or skips) the post-arrival additional duty/tax round (import). + * Customer then attaches a slip; SECOND_DUTY_PAID completes on that upload. + */ + async adviseSecondDuty( + bookingId: string, + input: { + dutyRequired: boolean; + amount?: number; + currency?: string; + declarationSerial?: string; + }, + attachment?: Express.Multer.File, + userId?: string, + ): Promise<{ advised: boolean; skipped: boolean }> { + const booking = await this.getBooking(bookingId); + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Additional duty applies to customs bookings only.'); + } + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + if (tradeDirection !== 'IMPORT') { + throw new BadRequestException('Additional duty applies to import shipments only.'); + } + + await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_ADVISED', tradeDirection); + await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_PAID', tradeDirection); + + if (!input.dutyRequired) { + await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_ADVISED'); + await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_PAID'); + return { advised: false, skipped: true }; + } + + if (!input.amount || input.amount <= 0) { + throw new BadRequestException('Duty amount must be greater than zero.'); + } + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const hasNotice = files.some((f) => f.code === 'duty_tax_notice_2'); + if (!attachment && !hasNotice) { + throw new BadRequestException('Attach the additional duty/tax notice.'); + } + if (attachment) { + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_notice_2', + file: attachment, + }); + } + + await this.milestoneService.completeWithMetadataForBooking( + bookingId, + 'SECOND_DUTY_ADVISED', + { + dutyAmount: input.amount, + dutyCurrency: input.currency ?? 'ETB', + declarationSerial: input.declarationSerial, + }, + userId, + ); + this.notifier.secondDutyAdvised(booking, input.amount, input.currency ?? 'ETB'); + return { advised: true, skipped: false }; + } + + /** Customer attaches the payment slip for the additional duty round. */ + async uploadSecondDutySlip( + bookingId: string, + file: Express.Multer.File, + ): Promise<{ milestoneCompleted: boolean }> { + const booking = await this.getBooking(bookingId); + if (!file) throw new BadRequestException('No payment slip uploaded'); + + const milestones = await this.milestoneService.listForBooking(bookingId); + const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED'); + if (advised?.status !== 'COMPLETED') { + throw new BadRequestException('No additional duty has been advised for this shipment.'); + } + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_receipt_2', + file, + }); + + await this.milestoneService.ensureForBooking( + bookingId, + 'SECOND_DUTY_PAID', + booking.tradeDirection ?? 'IMPORT', + ); + await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID'); + this.notifier.dutySlipUploadedToStaff(booking, 'second'); + return { milestoneCompleted: true }; + } + + /** Second duty round state for clearance views. */ + secondDutyState( + milestones: Array<{ + milestoneCode: string; + status: string; + metadata?: { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string } | null; + }>, + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + ): Freight.ClearanceSecondDuty | null { + const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED'); + const paid = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_PAID'); + if (!advised && !paid) return null; + + const toRef = (code: string) => { + const f = files.find((x) => x.code === code); + return f ? { id: f.id, name: f.name, url: f.url } : null; + }; + + return { + advised: advised?.status === 'COMPLETED', + skipped: advised?.status === 'SKIPPED', + amount: advised?.metadata?.dutyAmount ?? null, + currency: advised?.metadata?.dutyCurrency ?? null, + declarationSerial: advised?.metadata?.declarationSerial ?? null, + noticeFile: toRef('duty_tax_notice_2'), + slipFile: toRef('duty_tax_receipt_2'), + paid: paid?.status === 'COMPLETED', + }; + } + + /** Final-invoice state joined with its document + slip files, for clearance views. */ + async finalInvoiceSummary( + bookingId: string, + ): Promise { + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) return null; + + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const toRef = (code: string) => { + const f = files.find((x) => x.code === code); + return f ? { id: f.id, name: f.name, url: f.url } : null; + }; + const line = await this.dataSource + .getRepository(InvoiceLine) + .findOne({ where: { invoiceId: invoice.id } }); + + return { + id: invoice.id, + invoiceNumber: invoice.invoiceNumber, + status: invoice.status, + totalAmount: Number(invoice.totalAmount), + currency: invoice.currency, + description: line?.description ?? null, + invoiceFile: toRef('final_invoice'), + slipFile: toRef('final_invoice_slip'), + confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null, + }; + } + + /** + * GL ET uploads export transport document after wagon allocation (export ONE_TIME). + */ + async uploadTransportDocument( + bookingId: string, + files: Express.Multer.File[], + ): Promise<{ uploaded: boolean; milestoneCompleted: boolean }> { + const booking = await this.getBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Transport document upload applies to export shipments only.'); + } + + const milestones = await this.milestoneService.listForBooking(bookingId); + const wagonAllocated = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED'); + const wagonDone = + wagonAllocated?.status === 'COMPLETED' || booking.schedulingStatus === 'SCHEDULED'; + if (!wagonDone) { + throw new BadRequestException( + 'Wagon must be allocated before the transport document can be uploaded.', + ); + } + + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } + + await persistExportTransportUploads(this.filesService, bookingId, files); + + if (wagonAllocated && wagonAllocated.status !== 'COMPLETED') { + await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED'); + } + + await this.milestoneService.completeByDocTrigger( + { bookingId }, + 'EXPORT_TRANSPORT_ISSUED', + ); + + return { uploaded: true, milestoneCompleted: true }; + } } diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts new file mode 100644 index 000000000..40646ac47 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts @@ -0,0 +1,129 @@ +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue } from './phased-clearance.util'; + +describe('buildWorkflowFiles', () => { + const resourceFiles = [ + { code: 'im4', id: 'f-im4', name: 'im4.pdf', url: '/files/im4' }, + { code: 'im5', id: 'f-im5', name: 'im5.pdf', url: '/files/im5' }, + { + code: 'transit_permitted', + id: 'f-transit', + name: 'transit.png', + url: '/files/transit', + }, + { + code: 'duty_tax_notice', + id: 'f-duty', + name: 'notice.pdf', + url: '/files/duty', + }, + { code: 'commercial_invoice', id: 'f-inv', name: 'inv.pdf', url: '/files/inv' }, + ]; + + it('includes declaration and transit files even when they also appear in GL output document settings', () => { + const result = buildWorkflowFiles(resourceFiles, 'IMPORT'); + + expect(result.map((f) => f.code)).toEqual( + expect.arrayContaining(['im4', 'im5', 'transit_permitted', 'duty_tax_notice']), + ); + }); + + it('includes multi-file declaration uploads alongside catalog codes', () => { + const result = buildWorkflowFiles( + [ + ...resourceFiles, + { + code: 'declaration_0', + id: 'f-dec-0', + name: 'decl-a.pdf', + url: '/files/decl-a', + }, + { + code: 'declaration_1', + id: 'f-dec-1', + name: 'decl-b.pdf', + url: '/files/decl-b', + }, + ], + 'IMPORT', + ); + + expect(result.map((f) => f.code)).toEqual( + expect.arrayContaining(['im4', 'im5', 'declaration_0', 'declaration_1']), + ); + expect(result.find((f) => f.code === 'declaration_0')?.label).toBe( + 'Declaration document 1', + ); + }); + + it('includes multi-file import transit permit uploads', () => { + const result = buildWorkflowFiles( + [ + ...resourceFiles, + { + code: 'transit_permit_0', + id: 'f-tp-0', + name: 'permit-a.pdf', + url: '/files/tp-a', + }, + { + code: 'transit_permit_1', + id: 'f-tp-1', + name: 'permit-b.pdf', + url: '/files/tp-b', + }, + ], + 'IMPORT', + ); + + expect(result.map((f) => f.code)).toEqual( + expect.arrayContaining(['transit_permitted', 'transit_permit_0', 'transit_permit_1']), + ); + expect(result.find((f) => f.code === 'transit_permit_0')?.label).toBe('Transit permit 1'); + }); + + it('does not include non-catalog customer document codes', () => { + const result = buildWorkflowFiles(resourceFiles, 'IMPORT'); + + expect(result.some((f) => f.code === 'commercial_invoice')).toBe(false); + }); +}); + +describe('belongsOnDjClearanceQueue', () => { + it('keeps import contracts after pre-clearance is finalized (even post-booking)', () => { + expect( + belongsOnDjClearanceQueue( + 'IMPORT', + { preClearanceFinalizedAt: new Date('2026-01-01') }, + [], + ), + ).toBe(true); + }); + + it('keeps contracts with completed Djibouti milestones', () => { + expect( + belongsOnDjClearanceQueue('IMPORT', null, [ + { ownerRegion: 'DJ', status: 'COMPLETED' }, + ]), + ).toBe(true); + }); + + it('keeps import contracts from the start — DO upload is un-gated', () => { + expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(true); + }); + + it('excludes export contracts with no DJ activity or RO hold', () => { + expect(belongsOnDjClearanceQueue('EXPORT', null, [])).toBe(false); + }); +}); + +describe('belongsOnEtClearanceQueue', () => { + it('keeps contracts once phased clearance milestones exist', () => { + expect( + belongsOnEtClearanceQueue([{ ownerRegion: 'ET', status: 'COMPLETED' }]), + ).toBe(true); + }); + + it('excludes contracts with no clearance milestones', () => { + expect(belongsOnEtClearanceQueue([])).toBe(false); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts new file mode 100644 index 000000000..aa1c956e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -0,0 +1,382 @@ +import { BadRequestException } from '@nestjs/common'; +import { + catalogEntriesForTradeDirection, + declarationFileLabel, + isDeclarationFileCode, + isImportTransitPermitFileCode, + isExportTransportFileCode, + isT1TransportFileCode, + exportTransportFileLabel, + t1TransportFileLabel, + transitPermitFileLabel, + type ClearanceWorkflowFile, +} from '@edr/types'; + +/** Require at least one declaration file in the upload batch. */ +export function assertDeclarationFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No declaration documents uploaded'); + } +} + +/** Assign stable `declaration_*` codes so multi-file uploads always pass validation. */ +export function normalizeDeclarationFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `declaration_${index}`, + })); +} + +type DeclarationFileStore = { + findByResource( + resourceId: string, + resource: string, + ): Promise>; + deleteByCode(resourceId: string, resource: string, code: string): Promise; + upload(input: { + resourceId: string; + resource: string; + code: string; + file: Express.Multer.File; + }): Promise; +}; + +/** Replace all declaration files on a resource with a new multi-file upload batch. */ +export async function persistDeclarationUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeDeclarationFieldNames(files); + assertDeclarationFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isDeclarationFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `declaration_${index}`, + file, + }), + ), + ); +} + +/** Require at least one transit permit file in the upload batch. */ +export function assertTransitPermitFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } +} + +/** Assign stable `transit_permit_*` codes for multi-file import transit uploads. */ +export function normalizeTransitPermitFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `transit_permit_${index}`, + })); +} + +/** Replace all import transit permit files on a resource with a new multi-file batch. */ +export async function persistTransitPermitUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeTransitPermitFieldNames(files); + assertTransitPermitFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isImportTransitPermitFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `transit_permit_${index}`, + file, + }), + ), + ); +} + +/** Require at least one export transport document in the upload batch. */ +export function assertExportTransportFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } +} + +export function normalizeExportTransportFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `export_transport_document_${index}`, + })); +} + +/** Replace all export transport documents on a booking with a new multi-file batch. */ +export async function persistExportTransportUploads( + store: DeclarationFileStore, + bookingId: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeExportTransportFieldNames(files); + assertExportTransportFiles(normalized); + + const existing = await store.findByResource(bookingId, 'bookings'); + await Promise.all( + existing + .filter((f) => f.code && isExportTransportFileCode(f.code)) + .map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId: bookingId, + resource: 'bookings', + code: `export_transport_document_${index}`, + file, + }), + ), + ); +} + +/** Require at least one T1 transport document in the upload batch. */ +export function assertT1TransportFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No T1 transport documents uploaded'); + } +} + +export function normalizeT1TransportFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `t1_transport_document_${index}`, + })); +} + +/** Replace all T1 transport documents on a booking with a new multi-file batch. */ +export async function persistT1TransportUploads( + store: DeclarationFileStore, + bookingId: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeT1TransportFieldNames(files); + assertT1TransportFiles(normalized); + + const existing = await store.findByResource(bookingId, 'bookings'); + await Promise.all( + existing + .filter((f) => f.code && isT1TransportFileCode(f.code)) + .map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId: bookingId, + resource: 'bookings', + code: `t1_transport_document_${index}`, + file, + }), + ), + ); +} + +export function parseDutyRequiredForm(value: string | boolean | undefined): boolean { + if (typeof value === 'boolean') return value; + if (value === undefined || value === '') return false; + return value === 'true' || value === '1'; +} + +type DjQueueMilestone = { + ownerRegion?: string | null; + status: string; +}; + +type DjQueueCycle = { + preClearanceFinalizedAt?: Date | null; + roHoldReason?: string | null; +} | null | undefined; + +/** Whether a customs clearance item belongs on the persistent GL Djibouti list. */ +export function belongsOnDjClearanceQueue( + tradeDirection: string | null | undefined, + cycle: DjQueueCycle, + milestones: DjQueueMilestone[], + extras?: { + roHoldReason?: string | null; + preClearanceFinalizedAt?: Date | null; + }, +): boolean { + const roHold = cycle?.roHoldReason ?? extras?.roHoldReason; + if (roHold) return true; + + const hasDjActivity = milestones.some( + (m) => m.ownerRegion === 'DJ' && (m.status === 'COMPLETED' || m.status === 'PENDING'), + ); + if (hasDjActivity) return true; + + // Import DO upload is un-gated — Djibouti GL must see import customs items from + // the start, not only after Ethiopia finalizes pre-clearance. + if (tradeDirection === 'IMPORT') return true; + + return false; +} + +/** Contract statuses for persistent phased customs clearance lists (ET + DJ). */ +export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [ + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_UNDER_REVIEW', + 'CLEARANCE_READY_FOR_BOOKING', + 'ACTIVE_SHIPMENT_IN_PROGRESS', + 'FULLY_EXECUTED', + 'CONTRACT_ACTIVE', + 'CONTRACT_CLOSED', +] as const; + +/** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */ +export function belongsOnEtClearanceQueue(milestones: DjQueueMilestone[]): boolean { + return milestones.some((m) => m.status === 'PENDING' || m.status === 'COMPLETED'); +} + +/** Contract statuses that may appear on the GL Djibouti clearance list (includes post-booking). */ +export const DJ_CONTRACT_QUEUE_STATUSES = PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES; + +/** Booking statuses for persistent phased customs clearance lists (ET + DJ). */ +export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [ + 'AWAITING_DOCUMENTS', + 'DOCUMENTS_UNDER_REVIEW', + 'CLEARANCE_READY', + 'FULLY_EXECUTED', + 'OPERATION_REQUEST_PENDING', + 'OPERATION_CHANGES_REQUESTED', + 'ROAD_DISPATCH_PENDING', + 'IN_TRANSIT', + 'ARRIVED', + 'PAID', + 'COMPLETED', + 'CONTRACT_ACTIVE', + 'CONTRACT_CLOSED', +] as const; + +/** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */ +export const DJ_BOOKING_QUEUE_STATUSES = PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES; + +/** Build labeled phased-customs file rows from resource files. */ +export function buildWorkflowFiles( + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + tradeDirection: string, +): ClearanceWorkflowFile[] { + const fileByCode = new Map( + files.filter((f) => f.code).map((f) => [f.code as string, f]), + ); + const out: ClearanceWorkflowFile[] = []; + const included = new Set(); + + for (const entry of catalogEntriesForTradeDirection(tradeDirection)) { + const file = fileByCode.get(entry.code) ?? null; + if (!file) continue; + included.add(entry.code); + out.push({ + code: entry.code, + label: entry.label, + uploadedBy: entry.uploadedBy, + category: entry.category, + file: { id: file.id, name: file.name, url: file.url }, + }); + } + + const extraDeclarations = files + .filter((f) => f.code && isDeclarationFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraDeclarations.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: declarationFileLabel(file.code, index), + uploadedBy: 'gl_et', + category: 'declaration', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + + if (tradeDirection === 'IMPORT') { + const extraTransit = files + .filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraTransit.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: transitPermitFileLabel(file.code, index), + uploadedBy: 'gl_et', + category: 'transit', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + + const extraT1 = files + .filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraT1.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: t1TransportFileLabel(file.code, index), + uploadedBy: 'gl_dj', + category: 'djibouti', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + } + + if (tradeDirection === 'EXPORT') { + const extraExportTransport = files + .filter((f) => f.code && isExportTransportFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraExportTransport.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: exportTransportFileLabel(file.code, index), + uploadedBy: 'gl_et', + category: 'transit', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + } + + return out; +} diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts index eb0628b96..d86ee823a 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.controller.ts @@ -8,22 +8,30 @@ import { Body, Query, ParseUUIDPipe, + UploadedFiles, + UseInterceptors, } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { FleetManage, FleetView } from '../../common/booking-guards'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { DriversService } from './drivers.service'; import { CreateDriverDto } from './dto/create-driver.dto'; import { UpdateDriverDto } from './dto/update-driver.dto'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('drivers') @ApiBearerAuth() @Controller('drivers') -@FleetView() +@BookingStaff(FREIGHT_PERMS.drivers.view) export class DriversController { - constructor(private readonly driversService: DriversService) {} + constructor( + private readonly driversService: DriversService, + private readonly fleetHistory: FleetHistoryService, + ) {} @Post() - @FleetManage() + @BookingStaff(FREIGHT_PERMS.drivers.create) @ApiOperation({ summary: 'Create a new driver' }) create(@Body() createDriverDto: CreateDriverDto) { return this.driversService.create(createDriverDto); @@ -55,8 +63,39 @@ export class DriversController { return this.driversService.findById(id); } + @Get(':id/history') + @ApiOperation({ summary: 'Get driver assignment & activity history' }) + history(@Param('id', ParseUUIDPipe) id: string) { + return this.fleetHistory.getDriverHistory(id); + } + + @Post(':id/documents') + @BookingStaff(FREIGHT_PERMS.drivers.update) + @ApiConsumes('multipart/form-data') + @UseInterceptors(AnyFilesInterceptor()) + @ApiOperation({ summary: 'Upload driver documents (code driver_docs)' }) + uploadDocuments( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.driversService.uploadDocuments(id, files ?? []); + } + + @Get(':id/documents') + @ApiOperation({ summary: "List a driver's documents" }) + listDocuments(@Param('id', ParseUUIDPipe) id: string) { + return this.driversService.listDocuments(id); + } + + @Delete(':id/documents/:fileId') + @BookingStaff(FREIGHT_PERMS.drivers.update) + @ApiOperation({ summary: 'Delete a driver document' }) + removeDocument(@Param('fileId', ParseUUIDPipe) fileId: string) { + return this.driversService.removeDocument(fileId); + } + @Patch(':id') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.drivers.update) @ApiOperation({ summary: 'Update a driver' }) update( @Param('id', ParseUUIDPipe) id: string, @@ -66,7 +105,7 @@ export class DriversController { } @Delete(':id') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.drivers.delete) @ApiOperation({ summary: 'Delete a driver' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.driversService.remove(id); diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.module.ts b/apps/edr-freight-api/src/modules/drivers/drivers.module.ts index 9e685dcd6..1a6e29e15 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.module.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Driver } from './entities/driver.entity'; import { DriversService } from './drivers.service'; import { DriversController } from './drivers.controller'; +import { FilesModule } from '../files/files.module'; @Module({ - imports: [TypeOrmModule.forFeature([Driver])], + imports: [TypeOrmModule.forFeature([Driver]), FilesModule], providers: [DriversService], controllers: [DriversController], exports: [DriversService], diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts index d5176d14b..e4fa992e8 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts @@ -1,18 +1,61 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { CreateDriverDto } from './dto/create-driver.dto'; import { UpdateDriverDto } from './dto/update-driver.dto'; import { Driver, DriverStatus } from './entities/driver.entity'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; +import { FilesService } from '../files/files.service'; + +/** Resource + code the driver-documents upload area is stored under. */ +const DRIVER_DOCS_RESOURCE = 'driver'; +const DRIVER_DOCS_CODE = 'driver_docs'; @Injectable() export class DriversService { constructor( @InjectRepository(Driver) private readonly driverRepo: Repository, + private readonly history: FleetHistoryService, + private readonly filesService: FilesService, ) {} + /** Upload one or more driver documents (code "driver_docs"). */ + async uploadDocuments(driverId: string, files: Express.Multer.File[]) { + const driver = await this.driverRepo.findOneBy({ id: driverId }); + if (!driver) throw new NotFoundException(`Driver ${driverId} not found`); + if (!files?.length) throw new BadRequestException('No files provided'); + return Promise.all( + files.map((file) => + this.filesService.upload({ + resourceId: driverId, + resource: DRIVER_DOCS_RESOURCE, + code: DRIVER_DOCS_CODE, + file, + }), + ), + ); + } + + /** List a driver's uploaded documents (code "driver_docs"). */ + async listDocuments(driverId: string) { + const all = await this.filesService.findByResource(driverId, DRIVER_DOCS_RESOURCE); + return all.filter((f) => f.code === DRIVER_DOCS_CODE); + } + + /** Delete a single driver document by file id. */ + async removeDocument(fileId: string): Promise { + await this.filesService.remove(fileId); + } + async create(dto: CreateDriverDto): Promise { + if (dto.faydaVerified !== true) { + throw new BadRequestException( + 'Driver identity must be verified with Fayda before saving', + ); + } + const existing = await this.driverRepo.findOne({ where: [ { licenseNumber: dto.licenseNumber }, @@ -33,8 +76,28 @@ export class DriversService { } } + if (dto.faydaSub) { + const dupe = await this.driverRepo.findOne({ + where: { faydaSub: dto.faydaSub }, + }); + if (dupe) { + throw new ConflictException( + 'A driver is already registered for this Fayda identity', + ); + } + } + const driver = this.driverRepo.create(dto); - return this.driverRepo.save(driver); + const saved = await this.driverRepo.save(driver); + + await this.history.record({ + eventType: FleetEventType.DRIVER_REGISTERED, + driverId: saved.id, + label: `${saved.firstName ?? ''} ${saved.lastName ?? ''}`.trim() || null, + toValue: saved.status ?? null, + }); + + return saved; } async findAll(query: { @@ -48,12 +111,10 @@ export class DriversService { const qb = this.driverRepo.createQueryBuilder('d'); if (query.search) { - const searchTerm = `%${query.search}%`; - qb.where('d.firstName ILIKE :search', { search: searchTerm }) - .orWhere('d.lastName ILIKE :search', { search: searchTerm }) - .orWhere('d.email ILIKE :search', { search: searchTerm }) - .orWhere('d.licenseNumber ILIKE :search', { search: searchTerm }) - .orWhere('d.phoneNumber ILIKE :search', { search: searchTerm }); + qb.where( + '(d.firstName ILIKE :search OR d.lastName ILIKE :search OR d.email ILIKE :search OR d.licenseNumber ILIKE :search OR d.phoneNumber ILIKE :search)', + { search: `%${query.search}%` }, + ); } if (query.status) { @@ -108,7 +169,25 @@ export class DriversService { } } + if (dto.faydaSub && dto.faydaSub !== driver.faydaSub) { + const dupe = await this.driverRepo.findOne({ + where: { faydaSub: dto.faydaSub }, + }); + if (dupe) { + throw new ConflictException( + 'A driver is already registered for this Fayda identity', + ); + } + } + Object.assign(driver, dto); + + if (driver.faydaVerified !== true) { + throw new BadRequestException( + 'Driver identity must be verified with Fayda before saving', + ); + } + return this.driverRepo.save(driver); } diff --git a/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts b/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts index d8e2aec4a..c2f0bca81 100644 --- a/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts +++ b/apps/edr-freight-api/src/modules/drivers/dto/create-driver.dto.ts @@ -1,5 +1,5 @@ -import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray } from 'class-validator'; -import { DriverStatus } from '../entities/driver.entity'; +import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray, IsBoolean } from 'class-validator'; +import { DriverStatus, DriverGender } from '../entities/driver.entity'; export class CreateDriverDto { @IsString() @@ -20,6 +20,10 @@ export class CreateDriverDto { @IsDateString() dateOfBirth!: string; + @IsOptional() + @IsEnum(DriverGender) + gender?: DriverGender; + @IsDateString() licenseExpiryDate!: string; @@ -42,4 +46,12 @@ export class CreateDriverDto { @IsOptional() @IsString() notes?: string; + + @IsOptional() + @IsBoolean() + faydaVerified?: boolean; + + @IsOptional() + @IsString() + faydaSub?: string; } diff --git a/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts b/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts index b3defe2db..58a08b849 100644 --- a/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts +++ b/apps/edr-freight-api/src/modules/drivers/entities/driver.entity.ts @@ -8,6 +8,12 @@ export enum DriverStatus { ON_LEAVE = 'ON_LEAVE', } +export enum DriverGender { + MALE = 'MALE', + FEMALE = 'FEMALE', + OTHER = 'OTHER', +} + @Entity({ name: 'drivers', schema: 'freight' }) export class Driver extends BaseEntity { @Column({ name: 'license_number', unique: true, nullable: true }) @@ -28,6 +34,9 @@ export class Driver extends BaseEntity { @Column({ name: 'date_of_birth', type: 'date', nullable: true }) dateOfBirth?: Date; + @Column({ type: 'varchar', nullable: true }) + gender?: DriverGender | null; + @Column({ name: 'license_expiry_date', type: 'date', nullable: true }) licenseExpiryDate?: Date; @@ -51,4 +60,12 @@ export class Driver extends BaseEntity { @Column({ type: 'numeric', precision: 3, scale: 2, nullable: true }) rating?: number | null; + + @Column({ name: 'fayda_verified', type: 'boolean', default: false, nullable: true }) + faydaVerified?: boolean; + + /** Fayda OIDC subject the identity was verified against. Unique — one driver + * record per verified Fayda identity (NULLs allowed for legacy/unverified). */ + @Column({ name: 'fayda_sub', type: 'varchar', unique: true, nullable: true }) + faydaSub?: string | null; } diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index 4bf8362f9..4966d7ff9 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -61,6 +61,14 @@ export class FilesService { return this.upload(input); } + async deleteByCode( + resourceId: string, + resource: string, + code: string, + ): Promise { + await this.filesRepository.deleteByCode(resourceId, resource, code); + } + async uploadMany( resourceId: string, resource: string, @@ -111,10 +119,25 @@ export class FilesService { return record; } + /** Soft-delete a stored file row by id (object bytes are left in MinIO). */ + async remove(id: string): Promise { + await this.filesRepository.softDelete(id); + } + findByResource(resourceId: string, resource: string): Promise { return this.filesRepository.findByResource(resourceId, resource); } + /** + * Short-lived signed URL for a stored file's raw MinIO URL. The persisted + * `url` is an un-signed object path that a browser cannot fetch directly; + * callers that expose files for preview/download must sign them first. + */ + async signUrl(rawUrl: string, expirySeconds = 300): Promise { + const objectName = this.minioService.getObjectNameFromUrl(rawUrl); + return this.minioService.getSignedUrl(objectName, expirySeconds); + } + async findByCode( resourceId: string, resource: string, diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts deleted file mode 100644 index b750f1147..000000000 --- a/apps/edr-freight-api/src/modules/first-mile/dto/allocate-containers.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -export class FirstMileContainerAllocationDto { - containerId!: string; - vehicleId!: string; -} - -export class AllocateFirstMileContainersDto { - allocations!: FirstMileContainerAllocationDto[]; -} diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts index e2535083e..45e9f5b1a 100644 --- a/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsBoolean, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; import { FIRST_MILE_STATUSES, FirstMileStatus } from '../entities/first-mile.entity'; @@ -58,4 +58,9 @@ export class CreateFirstMileDto { @Transform(({ value }) => (value === '' ? undefined : value)) @IsUUID() vehicleId?: string | null; + + @ApiPropertyOptional({ description: 'Invoice payment status', default: false }) + @IsOptional() + @IsBoolean() + paid?: boolean; } diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts new file mode 100644 index 000000000..84247708b --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/dto/set-distances.dto.ts @@ -0,0 +1,24 @@ +import { IsArray, IsNumber, IsOptional, IsUUID, Min, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class VehicleDistanceInput { + @IsUUID() + vehicleId!: string; + + @IsNumber() + @Min(0) + distanceKm!: number; +} + +/** Per-vehicle actual distances for a first-mile pickup (multi-truck). */ +export class SetDistancesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => VehicleDistanceInput) + distances!: VehicleDistanceInput[]; + + /** Recomputed remaining payment (total km × rate), from the client. */ + @IsOptional() + @IsNumber() + remainingPayment?: number; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts new file mode 100644 index 000000000..8656b2109 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts @@ -0,0 +1,19 @@ +import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class FirstMileVehicleInput { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsString() + containerNumber?: string; +} + +/** Replace the full set of vehicles (with their container numbers) on a pickup. */ +export class SetVehiclesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => FirstMileVehicleInput) + vehicles!: FirstMileVehicleInput[]; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts new file mode 100644 index 000000000..39bf51a50 --- /dev/null +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm'; + +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { FirstMile } from './first-mile.entity'; + +/** + * One row per vehicle assigned to a first-mile pickup. A pickup can be served + * by several vehicles at once (multi-truck bookings); the legacy + * `first_mile.vehicle_id` column keeps pointing at the first assignment for + * backward compatibility. + */ +@Entity({ name: 'first_mile_vehicle_assignments', schema: 'freight' }) +@Unique(['firstMileId', 'vehicleId']) +@Index(['vehicleId']) +export class FirstMileVehicleAssignment extends BaseEntity { + @Column({ name: 'first_mile_id', type: 'uuid' }) + firstMileId!: string; + + @ManyToOne(() => FirstMile, (fm) => fm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'first_mile_id' }) + firstMile?: FirstMile; + + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { nullable: false, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle; + + /** Container this truck carries — auto-filled from the booking's container + * number when known, else entered manually at assignment time. */ + @Column({ name: 'container_number', type: 'varchar', nullable: true }) + containerNumber?: string | null; + + /** Actual distance driven by this truck (km), entered per vehicle. */ + @Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) + distanceKm?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts index 253d2d4c8..45a051028 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity'; +import { FirstMileVehicleAssignment } from './first-mile-vehicle-assignment.entity'; export const FIRST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -35,6 +36,9 @@ export class FirstMile extends BaseEntity { @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + @Column({ name: 'paid', type: 'boolean', default: false }) + paid!: boolean; + // TODO: uncomment after migration creates column // @Column({ type: 'boolean', default: false }) // isPostPaymentCompleted!: boolean; @@ -58,4 +62,7 @@ export class FirstMile extends BaseEntity { { eager: false }, ) containerAllocations!: FirstMileContainerAllocation[]; + + @OneToMany(() => FirstMileVehicleAssignment, (va) => va.firstMile) + vehicleAssignments?: FirstMileVehicleAssignment[]; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts index c63a4c9e1..aa618cdb7 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile-invoice.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { Freight } from '@edr/types'; @@ -48,7 +48,7 @@ export class FirstMileInvoiceService { } // Fetch the booking to get the companyId and companyProfileId - const fm = record.booking ? record : (await this.firstMileRepo.findById(record.bookingId, { relations: { booking: true } })); + const fm = record.booking ? record : (await this.firstMileRepo.findById(record.id, { relations: { booking: true } })); if (!fm) return null; if (!fm.booking?.companyId) { this.logger.warn( @@ -57,7 +57,8 @@ export class FirstMileInvoiceService { return null; } - const totalAmount = record.remainingPayment || 0; + // numeric columns come back as strings — coerce before the finite/>0 check. + const totalAmount = Number(record.remainingPayment) || 0; if (!Number.isFinite(totalAmount) || totalAmount <= 0) { this.logger.warn( `Skipping invoice for first-mile record ${record.id}: no remaining payment.`, @@ -65,13 +66,37 @@ export class FirstMileInvoiceService { return null; } + // Reject mixed-currency truck sets — a single invoice can only be one + // currency, and amounts across currencies can't be summed. + const billableTrucks = (record.vehicleAssignments ?? []).filter( + (a) => Number(a.distanceKm) > 0, + ); + const currencies = [ + ...new Set( + billableTrucks + .map((a) => (a.vehicle as { currency?: string } | undefined)?.currency) + .filter((c): c is string => Boolean(c)), + ), + ]; + if (currencies.length > 1) { + throw new BadRequestException( + `Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`, + ); + } + + // Currency follows the truck (price/km is quoted per vehicle), falling back + // to the booking's currency, then ETB. + const truckCurrency = + (record.vehicle as { currency?: string } | undefined)?.currency || + (record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency; + return this.billing.generateInvoice({ source: 'first_mile' as Freight.InvoiceSource, sourceId: record.id, type: 'DELIVERY_FEE', companyId: fm.booking!.companyId, companyProfileId: fm.booking!.companyProfileId || '', - currency: 'ETB', + currency: truckCurrency || fm.booking!.paymentCurrency || 'ETB', lines: [ { chargeType: 'DELIVERY', diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 6bb307a4b..952f924d6 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -13,11 +14,13 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; -import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto'; +import { SetVehiclesDto } from './dto/set-vehicles.dto'; +import { SetDistancesDto } from './dto/set-distances.dto'; import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileService } from './first-mile.service'; import { FirstMileInvoiceService } from './first-mile-invoice.service'; @@ -25,12 +28,12 @@ import { FirstMileInvoiceService } from './first-mile-invoice.service'; @ApiTags('first-mile') @ApiBearerAuth() @Controller('first-mile') -@TrainSchedulingView() +@BookingStaff(FREIGHT_PERMS.firstMile.view) export class FirstMileController { constructor( private readonly firstMileService: FirstMileService, private readonly firstMileInvoiceService: FirstMileInvoiceService, - ) {} + ) { } @Get() @ApiOperation({ summary: 'List first-mile legs' }) @@ -60,47 +63,75 @@ export class FirstMileController { return this.firstMileService.findById(id); } + @Get('acceptitem/:id') + @BookingStaff(FREIGHT_PERMS.firstMile.accept) + @ApiOperation({ summary: 'Get a first-mile accep by ID' }) + acceptItem(@Param('id', ParseUUIDPipe) id: string) { + return this.firstMileService.acceptBooking(id); + } + @Post('accept/:reference') - @TrainSchedulingManage() + @BookingStaff(FREIGHT_PERMS.firstMile.accept) @ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' }) acceptBooking(@Param('reference') reference: string) { return this.firstMileService.acceptBookingByReference(reference); } @Post() - @TrainSchedulingManage() + @BookingStaff(FREIGHT_PERMS.firstMile.create) @ApiOperation({ summary: 'Create a first-mile leg' }) create(@Body() dto: CreateFirstMileDto) { return this.firstMileService.create(dto); } @Patch(':id') - @TrainSchedulingManage() + @BookingStaff(FREIGHT_PERMS.firstMile.update) @ApiOperation({ summary: 'Update a first-mile leg' }) async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { - const record = await this.firstMileService.update(id, dto); - // Auto-generate invoice if distance or payment was updated - if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) { - await this.firstMileInvoiceService.ensureInvoiceFor(record); + // No invoice side-effects — invoices are generated only via the explicit + // POST :id/invoice endpoint (the "Generate Invoice" action). + return this.firstMileService.update(id, dto); + } + + @Post(':id/invoice') + @BookingStaff(FREIGHT_PERMS.firstMile.generateInvoice) + @ApiOperation({ summary: 'Generate the first-mile delivery-fee invoice' }) + async generateInvoice(@Param('id', ParseUUIDPipe) id: string) { + const record = await this.firstMileService.findById(id); + const invoice = await this.firstMileInvoiceService.ensureInvoiceFor(record); + if (!invoice) { + throw new BadRequestException( + 'Cannot generate invoice: the leg has no billable amount. Add distance and ensure a FIRST_MILE rate is configured, and the booking has a company.', + ); } - return record; + return invoice; + } + + @Post(':id/vehicles') + @BookingStaff(FREIGHT_PERMS.firstMile.assignVehicles) + @ApiOperation({ summary: 'Set the vehicles assigned to a first-mile pickup (multi-truck)' }) + async setVehicles( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetVehiclesDto, + ) { + return this.firstMileService.setVehicles(id, dto.vehicles); + } + + @Post(':id/distances') + @BookingStaff(FREIGHT_PERMS.firstMile.setDistances) + @ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' }) + async setDistances( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetDistancesDto, + ) { + return this.firstMileService.setDistances(id, dto.distances, dto.remainingPayment); } @Delete(':id') - @TrainSchedulingManage() + @BookingStaff(FREIGHT_PERMS.firstMile.delete) @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a first-mile leg' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.firstMileService.remove(id); } - - @Post(':firstMileId/allocate-containers') - @TrainSchedulingManage() - @ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' }) - allocateContainers( - @Param('firstMileId', ParseUUIDPipe) firstMileId: string, - @Body() dto: AllocateFirstMileContainersDto, - ) { - return this.firstMileService.allocateContainers(firstMileId, dto.allocations); - } } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index a69c920f1..51f5ccf4e 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { FirstMile } from './entities/first-mile.entity'; import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; +import { FirstMileVehicleAssignment } from './entities/first-mile-vehicle-assignment.entity'; import { FirstMileController } from './first-mile.controller'; import { FirstMileInvoiceService } from './first-mile-invoice.service'; import { FirstMileRepository } from './first-mile.repository'; @@ -15,8 +16,8 @@ import { FirstMileService } from './first-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]), - BillingModule, + TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation, FirstMileVehicleAssignment]), + forwardRef(() => BillingModule), forwardRef(() => BookingsModule), VehiclesModule, DriversModule, diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 08cd9ab10..00dbb80cc 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,17 +1,22 @@ -import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere } from 'typeorm'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; - -import { BookingsRepository } from '../bookings/bookings.repository'; -import { DriversService } from '../drivers/drivers.service'; -import { SmsClientService } from '../notifications/sms-client.service'; -import { VehiclesService } from '../vehicles/vehicles.service'; -import { CreateFirstMileDto } from './dto/create-first-mile.dto'; -import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; -import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; -import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; -import { FirstMileRepository } from './first-mile.repository'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; +import { BookingsRepository } from "../bookings/bookings.repository"; +import { DriversService } from "../drivers/drivers.service"; +import { SmsClientService } from "../notifications/sms-client.service"; +import { VehiclesService } from "../vehicles/vehicles.service"; +import { CreateFirstMileDto } from "./dto/create-first-mile.dto"; +import { UpdateFirstMileDto } from "./dto/update-first-mile.dto"; +import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity"; +import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity"; +import { FirstMileVehicleAssignment } from "./entities/first-mile-vehicle-assignment.entity"; +import { FirstMileRepository } from "./first-mile.repository"; +import { OnEvent } from "@nestjs/event-emitter"; +import { BillingService, InvoiceEventPayload } from "../billing/billing.service"; +import { FleetHistoryService } from "../fleet-history/fleet-history.service"; +import { FleetEventType } from "../fleet-history/entities/fleet-event.entity"; type FirstMileListFilter = { status?: FirstMileStatus; @@ -24,10 +29,10 @@ type FirstMileListFilter = { }; const SORTABLE_FIELDS: (keyof FirstMile)[] = [ - 'status', - 'advancedPayment', - 'remainingPayment', - 'createdAt', + "status", + "advancedPayment", + "remainingPayment", + "createdAt", ]; @Injectable() @@ -41,26 +46,98 @@ export class FirstMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, - ) {} + private readonly history: FleetHistoryService, + private readonly billing: BillingService, + ) { } + + /** Attach real invoice info so the UI shows an invoice link only when one + * exists — not merely because distance was entered. Batched (no N+1). */ + private async attachInvoices(records: FirstMile[]): Promise { + const invoices = await this.billing.findBySourceIds( + 'first_mile', + records.map((r) => r.id), + ); + const byId = new Map(); + for (const inv of invoices) { + if (!byId.has(inv.sourceId)) { + byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) }); + } + } + for (const r of records) { + (r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; + } + } + + /** Resolve a vehicle's driver + human labels, for stamping mile events onto + * the driver's timeline and naming the vehicle. Best-effort — never throws. */ + private async vehicleInfo( + vehicleId?: string | null, + ): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> { + if (!vehicleId) return { driverId: null, plate: null, driverName: null }; + try { + const v = await this.vehiclesService.findById(vehicleId); + return { + driverId: v.assignedDriverId ?? null, + plate: v.plateNumber ?? v.code ?? null, + driverName: v.assignedDriverName ?? null, + }; + } catch { + return { driverId: null, plate: null, driverName: null }; + } + } + + /** A leg counts as having a vehicle if it has a direct assignment or at least + * one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */ + private async hasAssignedVehicle( + recordId: string, + directVehicleId?: string | null, + ): Promise { + if (directVehicleId) return true; + const [junction, allocations] = await Promise.all([ + this.dataSource.manager.count(FirstMileVehicleAssignment, { + where: { firstMileId: recordId }, + }), + this.dataSource.manager.count(FirstMileContainerAllocation, { + where: { firstMileId: recordId, vehicleId: Not(IsNull()) }, + }), + ]); + return junction > 0 || allocations > 0; + } + + /** Human booking reference for a first-mile record, for the history timeline. */ + private async resolveBookingRef(record: FirstMile): Promise { + const loaded = (record as FirstMile & { booking?: { reference?: string } }) + .booking?.reference; + if (loaded) return loaded; + if (!record.bookingId) return null; + try { + const b = await this.bookingsRepository.findById(record.bookingId); + return (b as { reference?: string } | null)?.reference ?? null; + } catch { + return null; + } + } /** * Look up a booking by its human-readable reference and confirm it has been * paid before any first-mile work proceeds. Throws if the reference is * unknown or the booking has not reached PAID status. */ - async acceptBooking(bookingId: string): Promise { + async acceptBooking(bookingId: string): Promise { const booking = await this.bookingsRepository.findById(bookingId, { relations: { serviceType: true }, }); if (!booking) { - throw new NotFoundException(`Booking ${bookingId} not found`); + return null; } return this.acceptEligibleBooking(booking); } - async acceptBookingByReference(bookingReference: string): Promise { + async acceptBookingByReference( + bookingReference: string, + ): Promise { const [booking] = await this.bookingsRepository.findAll({ where: { reference: bookingReference }, relations: { serviceType: true }, @@ -87,20 +164,17 @@ export class FirstMileService { tradeDirection?: string | null; firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; - }): Promise { - const label = booking.reference ?? booking.id; - - if (booking.paymentStatus !== 'PAID') { - throw new BadRequestException(`Booking ${label} is not paid`); + }): Promise { + if (booking.paymentStatus !== "PAID") { + return null; } - if (!this.bookingRequestsFirstMile(booking)) { - throw new BadRequestException(`Booking ${label} does not require a first mile`); + return null; } const existing = await this.findByBookingId(booking.id); if (existing) { - throw new ConflictException(`Booking ${label} already has a first-mile assignment`); + return null; } return this.create({ @@ -116,8 +190,9 @@ export class FirstMileService { const pageSize = filter.pageSize ?? 50; const sortBy = SORTABLE_FIELDS.includes(filter.sortBy as keyof FirstMile) ? (filter.sortBy as keyof FirstMile) - : 'createdAt'; - const sortOrder = filter.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; + : "createdAt"; + const sortOrder = + filter.sortOrder?.toUpperCase() === "ASC" ? "ASC" : "DESC"; const where: FindOptionsWhere = {}; if (filter.status) where.status = filter.status; @@ -127,14 +202,24 @@ export class FirstMileService { const [data, total] = await this.firstMileRepository.findAndCount({ where, relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { + company: true, + serviceType: true, + originYard: true, + destinationYard: true, + cargoType: true, + bookingContainers: { containerType: true, units: true }, + }, vehicle: true, + vehicleAssignments: { vehicle: true }, }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, take: pageSize, }); + await this.attachInvoices(data); + return { data, meta: { @@ -146,11 +231,35 @@ export class FirstMileService { }; } + @OnEvent("firstmile.invoice.paid") + async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { + try { + await this.firstMileRepository.update(payload.sourceId, { + paid: true, + } as any); + this.logger.log( + `Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`, + ); + } catch (err) { + this.logger.error( + `Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`, + ); + } + } + async findById(id: string): Promise { const record = await this.firstMileRepository.findById(id, { relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { + company: true, + serviceType: true, + originYard: true, + destinationYard: true, + cargoType: true, + bookingContainers: { containerType: true, units: true }, + }, vehicle: true, + vehicleAssignments: { vehicle: true }, }, }); @@ -158,6 +267,8 @@ export class FirstMileService { throw new NotFoundException(`First-mile record ${id} not found`); } + await this.attachInvoices([record]); + return record; } @@ -167,22 +278,49 @@ export class FirstMileService { return existing; } - return this.firstMileRepository.create({ + const record = await this.firstMileRepository.create({ bookingId: dto.bookingId, - status: dto.status ?? 'READY_TO_TRANSIT', + status: dto.status ?? "READY_TO_TRANSIT", advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, estimatedKm: dto.estimatedKm ?? null, exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, + paid: (dto as any).paid ?? false, }); + + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + const info = await this.vehicleInfo(dto.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + firstMileId: record.id, + driverId: info.driverId, + label: record.status, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(record), + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + + return record; } private async findByBookingId(bookingId: string): Promise { const [records] = await this.firstMileRepository.findAndCount({ where: { bookingId }, relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { + company: true, + serviceType: true, + originYard: true, + destinationYard: true, + cargoType: true, + }, vehicle: true, }, take: 1, @@ -195,117 +333,441 @@ export class FirstMileService { firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; }): boolean { - // Export bookings always need a first mile (pickup → origin yard); the - // pickup address is captured at assignment time, not required upfront. return Boolean( - booking.tradeDirection === 'EXPORT' || - booking.firstMilePickupAddress?.trim() || - booking.serviceType?.includesFirstMile, + booking.tradeDirection === 'EXPORT' && + (booking.firstMilePickupAddress?.trim() || + booking.serviceType?.includesFirstMile), ); } async update(id: string, dto: UpdateFirstMileDto): Promise { const existing = await this.findById(id); + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle + // assigned in this same request). + if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + const vehicleId = + dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId; + if (!(await this.hasAssignedVehicle(id, vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this first-mile leg in transit', + ); + } + } + + const dtoAny = dto as any; const updated = await this.firstMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), - ...(dto.advancedPayment !== undefined ? { advancedPayment: dto.advancedPayment } : {}), - ...(dto.remainingPayment !== undefined ? { remainingPayment: dto.remainingPayment } : {}), - ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), + ...(dto.advancedPayment !== undefined + ? { advancedPayment: dto.advancedPayment } + : {}), + ...(dto.remainingPayment !== undefined + ? { remainingPayment: dto.remainingPayment } + : {}), + ...(dto.estimatedKm !== undefined + ? { estimatedKm: dto.estimatedKm } + : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), - }); + ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}), + } as any); if (!updated) { throw new NotFoundException(`First-mile record ${id} not found`); } + // Keep vehicle statuses in sync: new vehicle goes BUSY, replaced one goes back to FREE + if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + } + if (existing.vehicleId) { + await this.vehiclesService.releaseIfUnused([existing.vehicleId]); + } + // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + const bookingRef = await this.resolveBookingRef(existing); + if (existing.vehicleId) { + const info = await this.vehicleInfo(existing.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId: existing.vehicleId, + firstMileId: id, + driverId: info.driverId, + metadata: { + mile: 'FIRST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + if (dto.vehicleId) { + const info = await this.vehicleInfo(dto.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + firstMileId: id, + driverId: info.driverId, + label: updated.status, + metadata: { + mile: 'FIRST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + } + // Notify assigned driver on every explicit vehicle assignment or reassignment if (dto.vehicleId) { void this.notifyDriverAssignment(dto.vehicleId, existing); } + if (dto.status !== undefined && dto.status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + firstMileId: id, + vehicleId, + driverId: info.driverId, + fromValue: existing.status, + toValue: dto.status, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(existing), + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + + // Trip finished — release the vehicles it was holding + if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { + await this.releaseVehicles(updated); + } + return updated; } async updateStatus(id: string, status: FirstMileStatus): Promise { + const existing = await this.findById(id); + + if (status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + if (!(await this.hasAssignedVehicle(id, existing.vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this first-mile leg in transit', + ); + } + } + const updated = await this.firstMileRepository.update(id, { status }); if (!updated) { throw new NotFoundException(`First-mile record ${id} not found`); } + if (status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + firstMileId: id, + vehicleId, + driverId: info.driverId, + fromValue: existing.status, + toValue: status, + metadata: { + mile: 'FIRST', + bookingRef: await this.resolveBookingRef(existing), + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + + if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') { + await this.releaseVehicles(updated); + } + return updated; } + /** + * Free every vehicle held by this record (direct assignment + container + * allocations), unless still in use by another active trip. + */ + private async releaseVehicles(record: FirstMile): Promise { + const [assignments, recordAllocations] = await Promise.all([ + this.dataSource.manager.find(FirstMileVehicleAssignment, { + where: { firstMileId: record.id }, + }), + this.dataSource.manager.find(FirstMileContainerAllocation, { + where: { firstMileId: record.id }, + }), + ]); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...recordAllocations.map((a) => a.vehicleId), + record.vehicleId ?? null, + ].filter((id): id is string => Boolean(id)), + ), + ]; + await this.vehiclesService.releaseIfUnused(vehicleIds); + } + + /** + * Replace the full set of vehicles serving a first-mile pickup (multi-truck). + * Diffs against the current junction rows, syncing availability + audit history + * for each added/removed vehicle. The first vehicle is mirrored onto the legacy + * `vehicleId` column for back-compat with single-vehicle readers. + */ + async setVehicles( + id: string, + inputs: Array<{ vehicleId: string; containerNumber?: string | null }>, + ): Promise { + const existing = await this.findById(id); + // Dedupe by vehicleId, keeping the container number; preserve order. + const desiredMap = new Map(); + for (const inp of inputs) { + if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null); + } + const desired = [...desiredMap.keys()]; + const desiredSet = new Set(desired); + + const manager = this.dataSource.manager; + const current = await manager.find(FirstMileVehicleAssignment, { + where: { firstMileId: id }, + }); + const junctionSet = new Set(current.map((a) => a.vehicleId)); + // Fold the legacy vehicleId into the release set — a vehicle assigned via the + // old single-vehicle path has no junction row but must still be freed. + const releaseIds = [...new Set( + current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []), + )]; + const added = desired.filter((v) => !junctionSet.has(v)); + const removed = releaseIds.filter((v) => !desiredSet.has(v)); + // Vehicles that stay but whose container number changed. + const changed = current.filter( + (a) => + desiredMap.has(a.vehicleId) && + (a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null), + ); + + await this.dataSource.transaction(async (tx) => { + if (removed.length) { + await tx.delete(FirstMileVehicleAssignment, { + firstMileId: id, + vehicleId: In(removed), + }); + } + for (const vehicleId of added) { + await tx.insert(FirstMileVehicleAssignment, { + firstMileId: id, + vehicleId, + containerNumber: desiredMap.get(vehicleId) ?? null, + }); + } + for (const row of changed) { + await tx.update( + FirstMileVehicleAssignment, + { firstMileId: id, vehicleId: row.vehicleId }, + { containerNumber: desiredMap.get(row.vehicleId) ?? null }, + ); + } + }); + + // Legacy primary vehicle = first of the set (null when cleared). + await this.firstMileRepository.update(id, { vehicleId: desired[0] ?? null } as any); + + const bookingRef = await this.resolveBookingRef(existing); + for (const vehicleId of added) { + await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY); + void this.notifyDriverAssignment(vehicleId, existing); + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId, + firstMileId: id, + driverId: info.driverId, + label: existing.status, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + for (const vehicleId of removed) { + await this.vehiclesService.releaseIfUnused([vehicleId]); + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + firstMileId: id, + driverId: info.driverId, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + + return this.findById(id); + } + + /** + * Record each truck's actual distance. The pickup total (exact_km) is their + * sum and drives billing; `remainingPayment` (total km × rate) is recomputed + * client-side. Does NOT generate an invoice — that's a separate explicit step. + */ + async setDistances( + id: string, + distances: Array<{ vehicleId: string; distanceKm: number }>, + remainingPayment?: number, + ): Promise { + await this.findById(id); + + // Distances are locked once the invoice exists. + const invoices = await this.billing.findBySourceIds('first_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Distances cannot be changed after the invoice is generated', + ); + } + + for (const d of distances) { + await this.dataSource.manager.update( + FirstMileVehicleAssignment, + { firstMileId: id, vehicleId: d.vehicleId }, + { distanceKm: d.distanceKm }, + ); + } + + // Billing is per truck: amount = Σ (truck distance × truck price/km). The + // per-vehicle rate + currency live on the vehicle, so we ignore the legacy + // FIRST_MILE flat rate and any client-sent amount. `remainingPayment` param + // kept only for signature back-compat. + void remainingPayment; + const assignments = await this.dataSource.manager.find(FirstMileVehicleAssignment, { + where: { firstMileId: id }, + relations: { vehicle: true }, + }); + const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0); + const amount = assignments.reduce( + (s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0), + 0, + ); + await this.firstMileRepository.update(id, { + exactKm: total, + remainingPayment: amount, + } as any); + return this.findById(id); + } + private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); if (!vehicle.assignedDriverId) { - this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`); + this.logger.warn( + `Vehicle ${vehicleId} has no assigned driver — skipping SMS`, + ); return; } - const driver = await this.driversService.findById(vehicle.assignedDriverId); + const driver = await this.driversService.findById( + vehicle.assignedDriverId, + ); if (!driver.phoneNumber) { - this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`); + this.logger.warn( + `Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`, + ); return; } - const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking; + const booking = ( + record as FirstMile & { + booking?: { + reference?: string; + firstMilePickupAddress?: string | null; + originYard?: { label?: string } | null; + }; + } + ).booking; - const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(); + const driverName = + `${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim(); const message = `Dear ${driverName}, you have been assigned to a first-mile pickup. ` + `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` + - (booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') + - (booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : ''); + (booking?.firstMilePickupAddress + ? `Pickup: ${booking.firstMilePickupAddress}. ` + : "") + + (booking?.originYard?.label + ? `Destination: ${booking.originYard.label}.` + : ""); void this.smsClient.sendSms({ to: driver.phoneNumber, message, }); - this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + this.logger.log( + `SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`, + ); } catch (err) { - this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); + this.logger.error( + `Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`, + ); } } async remove(id: string): Promise { - await this.findById(id); - await this.firstMileRepository.softDelete(id); - } + const existing = await this.findById(id); - async allocateContainers( - firstMileId: string, - allocations: Array<{ containerId: string; vehicleId: string }>, - ) { - const firstMile = await this.findById(firstMileId); - if (!firstMile) { - throw new NotFoundException(`First-mile record ${firstMileId} not found`); + // Can't delete once billed. + const invoices = await this.billing.findBySourceIds('first_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Cannot delete a first-mile leg after its invoice is generated', + ); } - await this.dataSource.transaction(async (manager) => { - for (const allocation of allocations) { - await manager.delete(FirstMileContainerAllocation, { - firstMileId, - containerId: allocation.containerId, - }); - await manager.insert(FirstMileContainerAllocation, { - firstMileId, - containerId: allocation.containerId, - vehicleId: allocation.vehicleId, - containerType: 'CONTAINER', - quantity: 1, + // Every vehicle this pickup holds — junction + legacy + container rows. + const [assignments, allocations] = await Promise.all([ + this.dataSource.manager.find(FirstMileVehicleAssignment, { + where: { firstMileId: id }, + }), + this.dataSource.manager.find(FirstMileContainerAllocation, { + where: { firstMileId: id }, + }), + ]); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...allocations.map((a) => a.vehicleId), + existing.vehicleId ?? null, + ].filter((v): v is string => Boolean(v)), + ), + ]; + + await this.firstMileRepository.softDelete(id); + if (assignments.length) { + await this.dataSource.manager.softDelete(FirstMileVehicleAssignment, { firstMileId: id }); + } + + // Free every vehicle no longer held by another active trip and audit release. + if (vehicleIds.length) { + await this.vehiclesService.releaseIfUnused(vehicleIds); + const bookingRef = await this.resolveBookingRef(existing); + for (const vehicleId of vehicleIds) { + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + firstMileId: id, + driverId: info.driverId, + metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, }); } - }); - - return { - success: true, - allocated: allocations.length, - }; + } } } diff --git a/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts b/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts new file mode 100644 index 000000000..5096adbc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/entities/fleet-event.entity.ts @@ -0,0 +1,55 @@ +import { Entity, Column, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +/** + * Append-only audit log for fleet activity. One row per transition. Queried by + * `vehicleId` (vehicle timeline) or `driverId` (driver timeline); an event may + * carry both so a driver↔vehicle assignment or a mile assignment shows on both. + * `createdAt` (from BaseEntity) is the event time. + */ +export enum FleetEventType { + DRIVER_REGISTERED = 'DRIVER_REGISTERED', + VEHICLE_REGISTERED = 'VEHICLE_REGISTERED', + DRIVER_ASSIGNED = 'DRIVER_ASSIGNED', + DRIVER_UNASSIGNED = 'DRIVER_UNASSIGNED', + VEHICLE_STATUS_CHANGED = 'VEHICLE_STATUS_CHANGED', + VEHICLE_AVAILABILITY_CHANGED = 'VEHICLE_AVAILABILITY_CHANGED', + MILE_VEHICLE_ASSIGNED = 'MILE_VEHICLE_ASSIGNED', + MILE_VEHICLE_RELEASED = 'MILE_VEHICLE_RELEASED', + MILE_STATUS_CHANGED = 'MILE_STATUS_CHANGED', +} + +@Entity({ name: 'fleet_events', schema: 'freight' }) +export class FleetEvent extends BaseEntity { + @Column({ name: 'event_type', type: 'varchar' }) + eventType!: FleetEventType; + + @Index() + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @Index() + @Column({ name: 'driver_id', type: 'uuid', nullable: true }) + driverId?: string | null; + + @Column({ name: 'first_mile_id', type: 'uuid', nullable: true }) + firstMileId?: string | null; + + @Column({ name: 'last_mile_id', type: 'uuid', nullable: true }) + lastMileId?: string | null; + + /** Previous value for a transition (e.g. old status/availability). */ + @Column({ name: 'from_value', type: 'varchar', nullable: true }) + fromValue?: string | null; + + /** New value for a transition (e.g. new status/availability). */ + @Column({ name: 'to_value', type: 'varchar', nullable: true }) + toValue?: string | null; + + /** Human-readable summary token (driver name, plate, booking ref, mile). */ + @Column({ name: 'label', type: 'varchar', nullable: true }) + label?: string | null; + + @Column({ name: 'metadata', type: 'jsonb', nullable: true }) + metadata?: Record | null; +} diff --git a/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts new file mode 100644 index 000000000..14828e0a9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.module.ts @@ -0,0 +1,17 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FleetEvent } from './entities/fleet-event.entity'; +import { FleetHistoryService } from './fleet-history.service'; + +/** + * Global so any fleet-touching service (vehicles, drivers, first/last-mile) can + * inject FleetHistoryService to append audit events without each module having + * to import this one. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([FleetEvent])], + providers: [FleetHistoryService], + exports: [FleetHistoryService], +}) +export class FleetHistoryModule {} diff --git a/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts new file mode 100644 index 000000000..9c61119b9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fleet-history/fleet-history.service.ts @@ -0,0 +1,54 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { FleetEvent, FleetEventType } from './entities/fleet-event.entity'; + +export interface FleetEventInput { + eventType: FleetEventType; + vehicleId?: string | null; + driverId?: string | null; + firstMileId?: string | null; + lastMileId?: string | null; + fromValue?: string | null; + toValue?: string | null; + label?: string | null; + metadata?: Record | null; +} + +@Injectable() +export class FleetHistoryService { + private readonly logger = new Logger(FleetHistoryService.name); + + constructor( + @InjectRepository(FleetEvent) + private readonly eventRepo: Repository, + ) {} + + /** + * Append an audit event. Best-effort: recording history must never break the + * business operation that triggered it, so failures are logged and swallowed. + */ + async record(input: FleetEventInput): Promise { + try { + await this.eventRepo.save(this.eventRepo.create(input)); + } catch (err) { + this.logger.error( + `Failed to record fleet event ${input.eventType}: ${String(err)}`, + ); + } + } + + getVehicleHistory(vehicleId: string): Promise { + return this.eventRepo.find({ + where: { vehicleId }, + order: { createdAt: 'DESC' }, + }); + } + + getDriverHistory(driverId: string): Promise { + return this.eventRepo.find({ + where: { driverId }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts b/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts new file mode 100644 index 000000000..254af7104 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts @@ -0,0 +1,40 @@ +import { IsUUID, IsNumber, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator'; +import { PaymentMethod } from '../entities/fuel-purchase.entity'; + +export class CreateFuelPurchaseDto { + @IsUUID() + vehicleId!: string; + + @IsDateString() + purchaseDate!: string; + + @IsNumber() + liters!: number; + + @IsNumber() + costPerLiter!: number; + + @IsOptional() + @IsString() + fuelStation?: string; + + @IsEnum(PaymentMethod) + @IsOptional() + paymentMethod?: PaymentMethod; + + @IsOptional() + @IsNumber() + odometerReading?: number; + + @IsOptional() + @IsUUID() + driverId?: string; + + @IsOptional() + @IsString() + receiptNumber?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts b/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts new file mode 100644 index 000000000..aabafd17c --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts @@ -0,0 +1,35 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ name: 'fuel_consumption', schema: 'freight' }) +@Index(['vehicleId', 'month']) +export class FuelConsumption extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'month', type: 'date' }) + month!: Date; + + @Column({ name: 'total_liters', type: 'numeric', precision: 10, scale: 2 }) + totalLiters!: number; + + @Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 }) + totalCost!: number; + + @Column({ name: 'total_distance_km', type: 'numeric', precision: 10, scale: 2, default: 0 }) + totalDistanceKm: number = 0; + + @Column({ name: 'fuel_efficiency_km_per_l', type: 'numeric', precision: 10, scale: 2, nullable: true }) + fuelEfficiencyKmPerL?: number; + + @Column({ name: 'number_of_purchases', type: 'integer', default: 0 }) + numberOfPurchases!: number; + + @Column({ name: 'average_cost_per_liter', type: 'numeric', precision: 10, scale: 2, nullable: true }) + averageCostPerLiter?: number; +} diff --git a/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts b/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts new file mode 100644 index 000000000..163618d0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts @@ -0,0 +1,51 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +export enum PaymentMethod { + CASH = 'CASH', + CARD = 'CARD', + FUEL_CARD = 'FUEL_CARD', + TRANSFER = 'TRANSFER', + CHEQUE = 'CHEQUE', +} + +@Entity({ name: 'fuel_purchases', schema: 'freight' }) +export class FuelPurchase extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'purchase_date', type: 'timestamptz' }) + purchaseDate!: Date; + + @Column({ name: 'liters', type: 'numeric', precision: 10, scale: 2 }) + liters!: number; + + @Column({ name: 'cost_per_liter', type: 'numeric', precision: 10, scale: 2 }) + costPerLiter!: number; + + @Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 }) + totalCost!: number; + + @Column({ name: 'fuel_station', nullable: true }) + fuelStation?: string; + + @Column({ name: 'payment_method', type: 'varchar', default: PaymentMethod.CASH }) + paymentMethod!: PaymentMethod; + + @Column({ name: 'odometer_reading', type: 'numeric', nullable: true }) + odometerReading?: number; + + @Column({ name: 'driver_id', type: 'uuid', nullable: true }) + driverId?: string; + + @Column({ name: 'receipt_number', nullable: true }) + receiptNumber?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts b/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts new file mode 100644 index 000000000..2e5cca199 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts @@ -0,0 +1,77 @@ +import { Controller, Post, Get, Body, Param, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { FuelService } from './fuel.service'; +import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto'; + +// Stats feed the Financial Reports + Fleet Dashboard pages, so their viewers may +// read them without full fuel access. +const FUEL_STATS_PERMS = [ + FREIGHT_PERMS.fuel.view, + FREIGHT_PERMS.fleetReports.view, + FREIGHT_PERMS.fleetDashboard.view, +]; + +@ApiTags('Fuel Management') +@ApiBearerAuth() +@Controller('fuel') +export class FuelController { + constructor(private readonly fuelService: FuelService) {} + + @Post('purchases') + @BookingStaff(FREIGHT_PERMS.fuel.create) + @ApiOperation({ summary: 'Record fuel purchase' }) + async recordFuelPurchase(@Body() dto: CreateFuelPurchaseDto) { + return this.fuelService.recordFuelPurchase(dto); + } + + @Get('purchases') + @BookingStaff(FREIGHT_PERMS.fuel.view) + @ApiOperation({ summary: 'Get all fuel purchases' }) + async getAllFuelPurchases() { + return this.fuelService.getAllFuelPurchases(); + } + + @Get('purchases/:vehicleId') + @BookingStaff(FREIGHT_PERMS.fuel.view) + @ApiOperation({ summary: 'Get fuel purchases for vehicle' }) + async getFuelPurchases( + @Param('vehicleId') vehicleId: string, + @Query('startDate') startDate: string, + @Query('endDate') endDate: string, + ) { + return this.fuelService.getFuelPurchases( + vehicleId, + new Date(startDate), + new Date(endDate), + ); + } + + @Get('consumption/:vehicleId/:month') + @BookingStaff(FREIGHT_PERMS.fuel.view) + @ApiOperation({ summary: 'Get monthly fuel consumption' }) + async getMonthlyConsumption( + @Param('vehicleId') vehicleId: string, + @Param('month') month: string, + ) { + return this.fuelService.getMonthlyConsumption(vehicleId, new Date(month)); + } + + @Get('stats') + @BookingStaff(FUEL_STATS_PERMS) + @ApiOperation({ summary: 'Get fleet-wide fuel statistics' }) + async getFleetFuelStats(@Query('months') months: number = 12) { + return this.fuelService.getFleetFuelStats(months); + } + + @Get('stats/:vehicleId') + @BookingStaff(FUEL_STATS_PERMS) + @ApiOperation({ summary: 'Get fuel statistics for vehicle' }) + async getVehicleFuelStats( + @Param('vehicleId') vehicleId: string, + @Query('months') months: number = 12, + ) { + return this.fuelService.getVehicleFuelStats(vehicleId, months); + } +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.module.ts b/apps/edr-freight-api/src/modules/fuel/fuel.module.ts new file mode 100644 index 000000000..258350f1c --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FuelController } from './fuel.controller'; +import { FuelService } from './fuel.service'; +import { FuelRepository } from './fuel.repository'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([FuelPurchase, FuelConsumption])], + controllers: [FuelController], + providers: [FuelService, FuelRepository], + exports: [FuelService], +}) +export class FuelModule {} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts new file mode 100644 index 000000000..d062c38e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository, Between } from 'typeorm'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; + +@Injectable() +export class FuelRepository extends BaseRepository { + constructor( + @InjectRepository(FuelPurchase) + private readonly purchaseRepository: Repository, + @InjectRepository(FuelConsumption) + private readonly consumptionRepository: Repository, + ) { + super(purchaseRepository); + } + + async findByVehicleAndDateRange( + vehicleId: string, + startDate: Date, + endDate: Date, + ): Promise { + return this.purchaseRepository.find({ + where: { + vehicleId, + purchaseDate: Between(startDate, endDate), + }, + order: { purchaseDate: 'DESC' }, + }); + } + + async getMonthlyConsumption( + vehicleId: string, + month: Date, + ): Promise { + return this.consumptionRepository.findOne({ + where: { + vehicleId, + month, + }, + }); + } + + async updateMonthlyConsumption( + vehicleId: string, + month: Date, + data: Partial, + ): Promise { + let consumption = await this.consumptionRepository.findOne({ + where: { + vehicleId, + month, + }, + }); + + if (!consumption) { + consumption = this.consumptionRepository.create({ + vehicleId, + month, + ...data, + }); + } else { + Object.assign(consumption, data); + } + + return this.consumptionRepository.save(consumption); + } + + async findPurchasesByVehicle(vehicleId: string): Promise { + return this.purchaseRepository.find({ + where: { vehicleId }, + order: { purchaseDate: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.service.ts b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts new file mode 100644 index 000000000..54c157c2d --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { FuelRepository } from './fuel.repository'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; +import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto'; + +@Injectable() +export class FuelService { + constructor( + private readonly fuelRepository: FuelRepository, + @InjectRepository(FuelPurchase) + private readonly purchaseRepository: Repository, + ) {} + + async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise { + const totalCost = dto.liters * dto.costPerLiter; + + const purchase = this.purchaseRepository.create({ + ...dto, + totalCost, + }); + + const saved = await this.purchaseRepository.save(purchase); + + // Update monthly consumption + await this.updateMonthlyConsumption(dto.vehicleId, new Date(dto.purchaseDate)); + + return saved; + } + + async getAllFuelPurchases(): Promise { + return this.purchaseRepository + .createQueryBuilder('purchase') + .leftJoinAndSelect('purchase.vehicle', 'vehicle') + .orderBy('purchase.purchaseDate', 'DESC') + .getMany(); + } + + async getFuelPurchases( + vehicleId: string, + startDate: Date, + endDate: Date, + ): Promise { + return this.fuelRepository.findByVehicleAndDateRange(vehicleId, startDate, endDate); + } + + async getMonthlyConsumption( + vehicleId: string, + month: Date, + ): Promise { + return this.fuelRepository.getMonthlyConsumption(vehicleId, month); + } + + async getFleetFuelStats(monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const purchases = await this.purchaseRepository + .createQueryBuilder('purchase') + .where('purchase.purchaseDate BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getMany(); + + const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0); + const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0; + + return { + totalPurchases: purchases.length, + totalLiters, + totalCost, + averagePricePerLiter: averagePrice, + averageEfficiency: 0, // Placeholder - would need distance data + dateRange: { startDate, endDate }, + }; + } + + async getVehicleFuelStats(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const purchases = await this.getFuelPurchases(vehicleId, startDate, endDate); + + const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0); + const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0; + + return { + vehicleId, + totalPurchases: purchases.length, + totalLiters, + totalCost, + averagePricePerLiter: averagePrice, + dateRange: { startDate, endDate }, + }; + } + + private async updateMonthlyConsumption(vehicleId: string, date: Date): Promise { + const monthStart = new Date(date.getFullYear(), date.getMonth(), 1); + const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1); + + const purchases = await this.fuelRepository.findByVehicleAndDateRange( + vehicleId, + monthStart, + monthEnd, + ); + + const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0); + const numberOfPurchases = purchases.length; + const averageCostPerLiter = totalLiters > 0 ? totalCost / totalLiters : 0; + + await this.fuelRepository.updateMonthlyConsumption(vehicleId, monthStart, { + totalLiters, + totalCost, + numberOfPurchases, + averageCostPerLiter, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/dto/gps-device.dto.ts b/apps/edr-freight-api/src/modules/gps-tracking/dto/gps-device.dto.ts new file mode 100644 index 000000000..933699f0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/dto/gps-device.dto.ts @@ -0,0 +1,24 @@ +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +export class RegisterDeviceDto { + @IsString() + imei!: string; + + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsUUID() + vehicleId?: string; +} + +export class UpdateDeviceDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsUUID() + vehicleId?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-device.entity.ts b/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-device.entity.ts new file mode 100644 index 000000000..ac5f7dbc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-device.entity.ts @@ -0,0 +1,55 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +/** + * A physical GPS tracker (GT06). Identified by IMEI, optionally bound to a + * vehicle. Carries the denormalized latest fix so the live map reads one row + * per device without scanning position history. + */ +@Entity({ name: 'gps_devices', schema: 'freight' }) +@Index(['vehicleId']) +export class GpsDevice extends BaseEntity { + @Column({ name: 'imei', type: 'varchar', length: 20, unique: true }) + imei!: string; + + @Column({ name: 'name', type: 'varchar', nullable: true }) + name?: string | null; + + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @ManyToOne(() => Vehicle, { nullable: true, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle | null; + + /** ONLINE once a packet arrives; OFFLINE when stale (derived on read). */ + @Column({ name: 'status', type: 'varchar', length: 16, default: 'REGISTERED' }) + status!: string; + + @Column({ name: 'last_seen_at', type: 'timestamptz', nullable: true }) + lastSeenAt?: Date | null; + + // ── Denormalized latest fix ── + @Column({ name: 'last_lat', type: 'numeric', precision: 10, scale: 6, nullable: true }) + lastLat?: number | null; + + @Column({ name: 'last_lng', type: 'numeric', precision: 10, scale: 6, nullable: true }) + lastLng?: number | null; + + @Column({ name: 'last_speed', type: 'numeric', precision: 6, scale: 2, nullable: true }) + lastSpeed?: number | null; + + @Column({ name: 'last_course', type: 'int', nullable: true }) + lastCourse?: number | null; + + @Column({ name: 'last_fix_at', type: 'timestamptz', nullable: true }) + lastFixAt?: Date | null; + + @Column({ name: 'voltage_level', type: 'int', nullable: true }) + voltageLevel?: number | null; + + @Column({ name: 'gsm_level', type: 'int', nullable: true }) + gsmLevel?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-position.entity.ts b/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-position.entity.ts new file mode 100644 index 000000000..8c63bb78f --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-position.entity.ts @@ -0,0 +1,43 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +/** One GPS fix from a tracker (append-only history). */ +@Entity({ name: 'gps_positions', schema: 'freight' }) +@Index(['deviceId', 'gpsTime']) +@Index(['vehicleId', 'gpsTime']) +export class GpsPosition extends BaseEntity { + @Column({ name: 'device_id', type: 'uuid' }) + deviceId!: string; + + @Column({ name: 'imei', type: 'varchar', length: 20 }) + imei!: string; + + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @Column({ name: 'lat', type: 'numeric', precision: 10, scale: 6 }) + lat!: number; + + @Column({ name: 'lng', type: 'numeric', precision: 10, scale: 6 }) + lng!: number; + + @Column({ name: 'speed', type: 'numeric', precision: 6, scale: 2, default: 0 }) + speed!: number; + + @Column({ name: 'course', type: 'int', default: 0 }) + course!: number; + + @Column({ name: 'satellites', type: 'int', default: 0 }) + satellites!: number; + + @Column({ name: 'positioned', type: 'boolean', default: false }) + positioned!: boolean; + + /** Fix time reported by the device (UTC). */ + @Column({ name: 'gps_time', type: 'timestamptz' }) + gpsTime!: Date; + + /** Non-zero when the fix came in via an alarm packet. */ + @Column({ name: 'alarm', type: 'int', default: 0 }) + alarm!: number; +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts new file mode 100644 index 000000000..e380e541e --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts @@ -0,0 +1,66 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { FleetManage, FleetView } from '../../common/booking-guards'; +import { GpsTrackingService } from './gps-tracking.service'; +import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto'; + +@ApiTags('gps-tracking') +@ApiBearerAuth() +@Controller('gps') +@FleetView() +export class GpsTrackingController { + constructor(private readonly gps: GpsTrackingService) {} + + @Get('positions/latest') + @ApiOperation({ summary: 'Latest fix per device (live map feed)' }) + latest() { + return this.gps.latest(); + } + + @Get('positions/:vehicleId/history') + @ApiOperation({ summary: 'Position history for a vehicle' }) + history( + @Param('vehicleId', ParseUUIDPipe) vehicleId: string, + @Query('limit') limit?: string, + ) { + return this.gps.history(vehicleId, limit ? parseInt(limit, 10) : undefined); + } + + @Get('devices') + @ApiOperation({ summary: 'List GPS trackers' }) + listDevices() { + return this.gps.listDevices(); + } + + @Post('devices') + @FleetManage() + @ApiOperation({ summary: 'Register a GPS tracker' }) + register(@Body() dto: RegisterDeviceDto) { + return this.gps.registerDevice(dto); + } + + @Patch('devices/:id') + @FleetManage() + @ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) { + return this.gps.updateDevice(id, dto); + } + + @Delete('devices/:id') + @FleetManage() + @ApiOperation({ summary: 'Delete a GPS tracker' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.gps.removeDevice(id); + } +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts new file mode 100644 index 000000000..da527fff0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { GpsDevice } from './entities/gps-device.entity'; +import { GpsPosition } from './entities/gps-position.entity'; +import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository'; +import { GpsTrackingService } from './gps-tracking.service'; +import { GpsTrackingController } from './gps-tracking.controller'; +import { Gt06Server } from './gt06/gt06.server'; + +@Module({ + imports: [TypeOrmModule.forFeature([GpsDevice, GpsPosition])], + controllers: [GpsTrackingController], + providers: [GpsDeviceRepository, GpsPositionRepository, GpsTrackingService, Gt06Server], + exports: [GpsTrackingService], +}) +export class GpsTrackingModule {} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.repository.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.repository.ts new file mode 100644 index 000000000..326ef66ac --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.repository.ts @@ -0,0 +1,29 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { GpsDevice } from './entities/gps-device.entity'; +import { GpsPosition } from './entities/gps-position.entity'; + +@Injectable() +export class GpsDeviceRepository extends BaseRepository { + constructor( + @InjectRepository(GpsDevice) repository: Repository, + ) { + super(repository); + } + + findByImei(imei: string): Promise { + return this.repository.findOne({ where: { imei } }); + } +} + +@Injectable() +export class GpsPositionRepository extends BaseRepository { + constructor( + @InjectRepository(GpsPosition) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.service.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.service.ts new file mode 100644 index 000000000..b5bea3a3f --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.service.ts @@ -0,0 +1,124 @@ +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; + +import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository'; +import { GpsDevice } from './entities/gps-device.entity'; +import { Gt06Gps, Gt06Status } from './gt06/gt06.codec'; + +/** A device is considered ONLINE if seen within this window. */ +const ONLINE_WINDOW_MS = 5 * 60 * 1000; + +@Injectable() +export class GpsTrackingService { + private readonly logger = new Logger(GpsTrackingService.name); + + constructor( + private readonly devices: GpsDeviceRepository, + private readonly positions: GpsPositionRepository, + ) {} + + private isOnline(d: GpsDevice): boolean { + return Boolean(d.lastSeenAt && Date.now() - new Date(d.lastSeenAt).getTime() < ONLINE_WINDOW_MS); + } + + /** Find the device for an IMEI, auto-registering it on first contact. */ + private async ensureDevice(imei: string): Promise { + const existing = await this.devices.findByImei(imei); + if (existing) return existing; + this.logger.log(`Auto-registering new GPS tracker ${imei}`); + return this.devices.create({ imei, status: 'REGISTERED', lastSeenAt: new Date() }); + } + + // ── Ingestion (called by the TCP server) ── + + async handleLogin(imei: string): Promise { + const device = await this.ensureDevice(imei); + await this.devices.update(device.id, { lastSeenAt: new Date(), status: 'ONLINE' }); + } + + async handleHeartbeat(imei: string, status: Gt06Status): Promise { + const device = await this.ensureDevice(imei); + await this.devices.update(device.id, { + lastSeenAt: new Date(), + status: 'ONLINE', + voltageLevel: status.voltageLevel, + gsmLevel: status.gsmLevel, + }); + } + + async handleFix(imei: string, gps: Gt06Gps, alarm = 0, status?: Gt06Status): Promise { + const device = await this.ensureDevice(imei); + const now = new Date(); + await this.devices.update(device.id, { + lastSeenAt: now, + status: 'ONLINE', + lastLat: gps.latitude, + lastLng: gps.longitude, + lastSpeed: gps.speed, + lastCourse: gps.course, + lastFixAt: new Date(gps.time), + ...(status ? { voltageLevel: status.voltageLevel, gsmLevel: status.gsmLevel } : {}), + }); + await this.positions.create({ + deviceId: device.id, + imei, + vehicleId: device.vehicleId ?? null, + lat: gps.latitude, + lng: gps.longitude, + speed: gps.speed, + course: gps.course, + satellites: gps.satellites, + positioned: gps.positioned, + gpsTime: new Date(gps.time), + alarm, + }); + } + + // ── Queries / management (REST) ── + + private decorate(d: GpsDevice) { + return { ...d, online: this.isOnline(d) }; + } + + async listDevices() { + const rows = await this.devices.findAll({ relations: { vehicle: true }, order: { createdAt: 'DESC' } }); + return rows.map((d) => this.decorate(d)); + } + + /** Live map feed — devices that have at least one fix. */ + async latest() { + const rows = await this.devices.findAll({ relations: { vehicle: true } }); + return rows.filter((d) => d.lastLat != null && d.lastLng != null).map((d) => this.decorate(d)); + } + + async history(vehicleId: string, limit = 200) { + return this.positions.findAll({ + where: { vehicleId }, + order: { gpsTime: 'DESC' }, + take: Math.min(limit, 1000), + }); + } + + async registerDevice(dto: { imei: string; name?: string; vehicleId?: string | null }) { + const existing = await this.devices.findByImei(dto.imei); + if (existing) throw new BadRequestException(`A device with IMEI ${dto.imei} already exists`); + return this.devices.create({ + imei: dto.imei, + name: dto.name ?? null, + vehicleId: dto.vehicleId ?? null, + status: 'REGISTERED', + }); + } + + async updateDevice(id: string, dto: { name?: string; vehicleId?: string | null }) { + const updated = await this.devices.update(id, { + ...(dto.name !== undefined ? { name: dto.name } : {}), + ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), + }); + if (!updated) throw new NotFoundException(`GPS device ${id} not found`); + return updated; + } + + async removeDevice(id: string): Promise { + await this.devices.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts b/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts new file mode 100644 index 000000000..d54f906a2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts @@ -0,0 +1,207 @@ +/** + * GT06 GPS-tracker protocol codec. + * + * Frame: 0x78 0x78 | len(1) | protocol(1) | content(N) | serial(2) | crc(2) | 0x0D 0x0A + * `len` counts protocol..crc (= 5 + N). CRC-ITU (CRC-16/X.25) is computed over + * len..serial (inclusive) and equals the 2 crc bytes. + */ + +const START = 0x7878; +const STOP = 0x0d0a; + +export const GT06_PROTOCOL = { + LOGIN: 0x01, + LOCATION: 0x12, + HEARTBEAT: 0x13, + STRING: 0x15, + ALARM: 0x16, + ADDRESS_BY_PHONE: 0x1a, + SERVER_COMMAND: 0x80, +} as const; + +/** CRC-16/X.25 (a.k.a. CRC-ITU) used by GT06 — reflected, poly 0x8408, init/xorout 0xFFFF. */ +export function crcItu(bytes: Buffer): number { + let fcs = 0xffff; + for (const b of bytes) { + fcs ^= b; + for (let i = 0; i < 8; i++) { + fcs = fcs & 1 ? (fcs >> 1) ^ 0x8408 : fcs >> 1; + } + } + return (~fcs) & 0xffff; +} + +export interface Gt06Gps { + time: string; // ISO (UTC) + satellites: number; + latitude: number; + longitude: number; + speed: number; // km/h + course: number; // 0-360 + positioned: boolean; +} + +export interface Gt06Lbs { + mcc: number; + mnc: number; + lac: number; + cellId: number; +} + +export interface Gt06Status { + terminalInfo: number; + voltageLevel: number; + gsmLevel: number; + alarm: number; // former byte of alarm/language + charging: boolean; + accOn: boolean; + gpsTracking: boolean; + oilCut: boolean; +} + +export type Gt06Packet = + | { type: 'login'; protocol: number; serial: number; imei: string } + | { type: 'location'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs } + | { type: 'heartbeat'; protocol: number; serial: number; status: Gt06Status } + | { type: 'alarm'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs; status: Gt06Status } + | { type: 'unknown'; protocol: number; serial: number }; + +/** Terminal ID (8 BCD bytes) → 15-digit IMEI (drops the leading pad nibble). */ +function decodeImei(buf: Buffer): string { + return buf.toString('hex').replace(/^0/, ''); +} + +function decodeDateTime(buf: Buffer, off: number): string { + const year = 2000 + buf[off]; + const month = buf[off + 1]; + const day = buf[off + 2]; + const hour = buf[off + 3]; + const min = buf[off + 4]; + const sec = buf[off + 5]; + return new Date(Date.UTC(year, month - 1, day, hour, min, sec)).toISOString(); +} + +/** Convert a GT06 lat/long raw uint32 to decimal degrees (magnitude only). */ +function rawToDegrees(raw: number): number { + return raw / 30000 / 60; +} + +function decodeGps(buf: Buffer, off: number): Gt06Gps { + const time = decodeDateTime(buf, off); + const lenSat = buf[off + 6]; + const satellites = lenSat & 0x0f; + const latRaw = buf.readUInt32BE(off + 7); + const lonRaw = buf.readUInt32BE(off + 11); + const speed = buf[off + 15]; + const cs = buf.readUInt16BE(off + 16); + const hi = (cs >> 8) & 0xff; + const positioned = Boolean(hi & 0x10); // BYTE_1 Bit4 + const isWest = Boolean(hi & 0x08); // BYTE_1 Bit3 (1 = West) + const isNorth = Boolean(hi & 0x04); // BYTE_1 Bit2 (1 = North) + const course = cs & 0x03ff; // BYTE_1 Bit1-0 + BYTE_2 + let latitude = rawToDegrees(latRaw); + let longitude = rawToDegrees(lonRaw); + if (!isNorth) latitude = -latitude; + if (isWest) longitude = -longitude; + return { time, satellites, latitude, longitude, speed, course, positioned }; +} + +function decodeStatus(buf: Buffer, off: number): Gt06Status { + const terminalInfo = buf[off]; + const voltageLevel = buf[off + 1]; + const gsmLevel = buf[off + 2]; + const alarm = buf[off + 3]; // alarm/language former byte + return { + terminalInfo, + voltageLevel, + gsmLevel, + alarm, + oilCut: Boolean(terminalInfo & 0x80), + gpsTracking: Boolean(terminalInfo & 0x40), + charging: Boolean(terminalInfo & 0x04), + accOn: Boolean(terminalInfo & 0x02), + }; +} + +function decodeLbs(buf: Buffer, off: number): Gt06Lbs { + return { + mcc: buf.readUInt16BE(off), + mnc: buf[off + 2], + lac: buf.readUInt16BE(off + 3), + cellId: buf.readUIntBE(off + 5, 3), + }; +} + +function decodeFrame(frame: Buffer): Gt06Packet | null { + // frame = 78 78 len ...content... serial(2) crc(2) 0D 0A + const len = frame[2]; + const protocol = frame[3]; + const serialOff = 3 + (len - 4); // after protocol + content, before serial(2)+crc(2) + const serial = frame.readUInt16BE(serialOff); + const contentOff = 4; // start of content (after protocol) + + switch (protocol) { + case GT06_PROTOCOL.LOGIN: + return { type: 'login', protocol, serial, imei: decodeImei(frame.subarray(contentOff, contentOff + 8)) }; + case GT06_PROTOCOL.LOCATION: + return { type: 'location', protocol, serial, gps: decodeGps(frame, contentOff), lbs: decodeLbs(frame, contentOff + 18) }; + case GT06_PROTOCOL.HEARTBEAT: + return { type: 'heartbeat', protocol, serial, status: decodeStatus(frame, contentOff) }; + case GT06_PROTOCOL.ALARM: { + const gps = decodeGps(frame, contentOff); + // content: date(6)+lenSat(1)+lat(4)+lng(4)+speed(1)+course(2)=18, lbsLen(1), lbs(8), status(1+1+1+2) + const lbs = decodeLbs(frame, contentOff + 18 + 1); + const status = decodeStatus(frame, contentOff + 18 + 1 + 8); + return { type: 'alarm', protocol, serial, gps, lbs, status }; + } + default: + return { type: 'unknown', protocol, serial }; + } +} + +/** + * Pull all complete frames out of a stream buffer. Returns the decoded packets + * (skipping CRC-failed ones) and the trailing bytes that form a partial frame. + */ +export function parseStream(buffer: Buffer): { packets: Gt06Packet[]; rest: Buffer } { + const packets: Gt06Packet[] = []; + let i = 0; + while (i + 5 <= buffer.length) { + if (buffer.readUInt16BE(i) !== START) { + i += 1; // resync + continue; + } + const len = buffer[i + 2]; + const frameLen = 2 + 1 + len + 2; // start + lenByte + (protocol..crc) + stop + if (i + frameLen > buffer.length) break; // incomplete + const frame = buffer.subarray(i, i + frameLen); + if (frame.readUInt16BE(frameLen - 2) === STOP) { + // CRC over len..serial (frame[2 .. frameLen-4]); crc bytes are frameLen-4..frameLen-3. + const crcCalc = crcItu(frame.subarray(2, frameLen - 4)); + const crcRecv = frame.readUInt16BE(frameLen - 4); + if (crcCalc === crcRecv) { + const pkt = decodeFrame(frame); + if (pkt) packets.push(pkt); + } + i += frameLen; + } else { + i += 1; // bad frame, resync + } + } + return { packets, rest: buffer.subarray(i) }; +} + +/** Build a server → terminal ACK (login/heartbeat/alarm) echoing the serial. */ +export function buildAck(protocol: number, serial: number): Buffer { + const body = Buffer.alloc(3); // protocol + serial(2) + body[0] = protocol; + body.writeUInt16BE(serial, 1); + const len = body.length + 2; // + crc(2) + const forCrc = Buffer.concat([Buffer.from([len]), body]); + const crc = crcItu(forCrc); + return Buffer.concat([ + Buffer.from([0x78, 0x78, len]), + body, + Buffer.from([(crc >> 8) & 0xff, crc & 0xff, 0x0d, 0x0a]), + ]); +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts b/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts new file mode 100644 index 000000000..a2095fa12 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common'; +import * as net from 'net'; + +import { GpsTrackingService } from '../gps-tracking.service'; +import { buildAck, GT06_PROTOCOL, parseStream } from './gt06.codec'; + +interface Session { + buffer: Buffer; + imei: string | null; +} + +const MAX_BUFFER = 64 * 1024; + +/** + * Raw TCP listener for GT06 GPS trackers. Trackers open a socket, send a login + * (IMEI), then stream location/heartbeat/alarm packets; we decode, persist via + * {@link GpsTrackingService}, and ACK login/heartbeat/alarm so the device keeps + * the connection alive. Disabled when GT06_TCP_PORT=0. + */ +@Injectable() +export class Gt06Server implements OnApplicationBootstrap, OnModuleDestroy { + private readonly logger = new Logger(Gt06Server.name); + private server?: net.Server; + private readonly sessions = new Map(); + + constructor(private readonly gps: GpsTrackingService) {} + + onApplicationBootstrap(): void { + const port = Number(process.env.GT06_TCP_PORT ?? 5023); + if (!port) { + this.logger.log('GT06 TCP listener disabled (GT06_TCP_PORT=0)'); + return; + } + const host = process.env.GT06_TCP_HOST ?? '0.0.0.0'; + this.server = net.createServer((socket) => this.onConnection(socket)); + this.server.on('error', (err) => this.logger.error(`GT06 server error: ${String(err)}`)); + this.server.listen(port, host, () => this.logger.log(`GT06 GPS tracker listener on ${host}:${port}`)); + } + + onModuleDestroy(): void { + for (const socket of this.sessions.keys()) socket.destroy(); + this.sessions.clear(); + this.server?.close(); + } + + private onConnection(socket: net.Socket): void { + this.sessions.set(socket, { buffer: Buffer.alloc(0), imei: null }); + socket.on('data', (chunk) => void this.onData(socket, chunk)); + socket.on('error', () => this.sessions.delete(socket)); + socket.on('close', () => this.sessions.delete(socket)); + } + + private async onData(socket: net.Socket, chunk: Buffer): Promise { + const session = this.sessions.get(socket); + if (!session) return; + session.buffer = Buffer.concat([session.buffer, chunk]); + if (session.buffer.length > MAX_BUFFER) session.buffer = Buffer.alloc(0); // drop garbage + + const { packets, rest } = parseStream(session.buffer); + session.buffer = rest; + + for (const pkt of packets) { + try { + await this.handle(socket, session, pkt); + } catch (err) { + this.logger.error(`Failed to handle GT06 packet (${pkt.type}): ${String(err)}`); + } + } + } + + private async handle( + socket: net.Socket, + session: Session, + pkt: ReturnType['packets'][number], + ): Promise { + switch (pkt.type) { + case 'login': + session.imei = pkt.imei; + await this.gps.handleLogin(pkt.imei); + socket.write(buildAck(GT06_PROTOCOL.LOGIN, pkt.serial)); + break; + case 'heartbeat': + if (session.imei) await this.gps.handleHeartbeat(session.imei, pkt.status); + socket.write(buildAck(GT06_PROTOCOL.HEARTBEAT, pkt.serial)); + break; + case 'location': + if (session.imei) await this.gps.handleFix(session.imei, pkt.gps); + break; + case 'alarm': + if (session.imei) await this.gps.handleFix(session.imei, pkt.gps, pkt.status.alarm, pkt.status); + socket.write(buildAck(GT06_PROTOCOL.ALARM, pkt.serial)); + break; + default: + break; + } + } +} diff --git a/apps/edr-freight-api/src/modules/incidents/dto/create-incident.dto.ts b/apps/edr-freight-api/src/modules/incidents/dto/create-incident.dto.ts new file mode 100644 index 000000000..5d76885b4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/dto/create-incident.dto.ts @@ -0,0 +1,48 @@ +import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator'; +import { IncidentType, IncidentSeverity, IncidentStatus } from '../entities/incident.entity'; + +export class CreateIncidentDto { + @IsOptional() + @IsUUID() + vehicleId?: string; + + @IsOptional() + @IsUUID() + driverId?: string; + + @IsOptional() + @IsUUID() + bookingId?: string; + + @IsEnum(IncidentType) + type!: IncidentType; + + @IsEnum(IncidentSeverity) + severity!: IncidentSeverity; + + @IsDateString() + occurredAt!: string; + + @IsOptional() + @IsString() + location?: string; + + @IsString() + description!: string; + + @IsOptional() + @IsNumber() + damageEstimate?: number; + + @IsOptional() + @IsEnum(IncidentStatus) + status?: IncidentStatus; + + @IsOptional() + @IsString() + insuranceClaimNumber?: string; + + @IsOptional() + @IsString() + reportedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/incidents/dto/update-incident.dto.ts b/apps/edr-freight-api/src/modules/incidents/dto/update-incident.dto.ts new file mode 100644 index 000000000..b45d478e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/dto/update-incident.dto.ts @@ -0,0 +1,52 @@ +import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator'; +import { IncidentType, IncidentSeverity, IncidentStatus } from '../entities/incident.entity'; + +export class UpdateIncidentDto { + @IsOptional() + @IsUUID() + vehicleId?: string; + + @IsOptional() + @IsUUID() + driverId?: string; + + @IsOptional() + @IsUUID() + bookingId?: string; + + @IsOptional() + @IsEnum(IncidentType) + type?: IncidentType; + + @IsOptional() + @IsEnum(IncidentSeverity) + severity?: IncidentSeverity; + + @IsOptional() + @IsDateString() + occurredAt?: string; + + @IsOptional() + @IsString() + location?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsNumber() + damageEstimate?: number; + + @IsOptional() + @IsEnum(IncidentStatus) + status?: IncidentStatus; + + @IsOptional() + @IsString() + insuranceClaimNumber?: string; + + @IsOptional() + @IsString() + reportedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/incidents/entities/incident.entity.ts b/apps/edr-freight-api/src/modules/incidents/entities/incident.entity.ts new file mode 100644 index 000000000..2c71cc8a3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/entities/incident.entity.ts @@ -0,0 +1,76 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { Driver } from '../../drivers/entities/driver.entity'; + +export enum IncidentType { + ACCIDENT = 'ACCIDENT', + BREAKDOWN = 'BREAKDOWN', + TRAFFIC_VIOLATION = 'TRAFFIC_VIOLATION', + THEFT = 'THEFT', + OTHER = 'OTHER', +} + +export enum IncidentSeverity { + MINOR = 'MINOR', + MODERATE = 'MODERATE', + MAJOR = 'MAJOR', + CRITICAL = 'CRITICAL', +} + +export enum IncidentStatus { + REPORTED = 'REPORTED', + UNDER_REVIEW = 'UNDER_REVIEW', + CLAIM_FILED = 'CLAIM_FILED', + RESOLVED = 'RESOLVED', + CLOSED = 'CLOSED', +} + +@Entity({ name: 'incidents', schema: 'freight' }) +@Index(['driverId', 'occurredAt']) +@Index(['vehicleId', 'occurredAt']) +export class Incident extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string; + + @ManyToOne(() => Vehicle, { eager: false, nullable: true }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle; + + @Column({ name: 'driver_id', type: 'uuid', nullable: true }) + driverId?: string; + + @ManyToOne(() => Driver, { eager: false, nullable: true }) + @JoinColumn({ name: 'driver_id' }) + driver?: Driver; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string; + + @Column({ name: 'type', type: 'varchar' }) + type!: IncidentType; + + @Column({ name: 'severity', type: 'varchar' }) + severity!: IncidentSeverity; + + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + @Column({ name: 'location', type: 'varchar', nullable: true }) + location?: string; + + @Column({ name: 'description', type: 'text' }) + description!: string; + + @Column({ name: 'damage_estimate', type: 'numeric', precision: 14, scale: 2, nullable: true }) + damageEstimate?: number; + + @Column({ name: 'status', type: 'varchar', default: IncidentStatus.REPORTED }) + status!: IncidentStatus; + + @Column({ name: 'insurance_claim_number', type: 'varchar', nullable: true }) + insuranceClaimNumber?: string; + + @Column({ name: 'reported_by', type: 'varchar', nullable: true }) + reportedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts b/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts new file mode 100644 index 000000000..ab6d5ef08 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/incidents.controller.ts @@ -0,0 +1,69 @@ +import { + Controller, + Post, + Get, + Patch, + Delete, + Body, + Param, + Query, +} from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { IncidentsService } from './incidents.service'; +import { CreateIncidentDto } from './dto/create-incident.dto'; +import { UpdateIncidentDto } from './dto/update-incident.dto'; +import { IncidentStatus, IncidentType } from './entities/incident.entity'; + +@ApiTags('Accident & Incident Management') +@Controller('incidents') +export class IncidentsController { + constructor(private readonly incidentsService: IncidentsService) {} + + @Post() + @ApiOperation({ summary: 'Report an incident' }) + async create(@Body() dto: CreateIncidentDto) { + return this.incidentsService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List incidents (optionally filtered)' }) + async findAll( + @Query('vehicleId') vehicleId?: string, + @Query('driverId') driverId?: string, + @Query('status') status?: IncidentStatus, + @Query('type') type?: IncidentType, + ) { + return this.incidentsService.findAll({ vehicleId, driverId, status, type }); + } + + @Get('driver/:driverId/stats') + @ApiOperation({ summary: 'Get incident statistics for a driver' }) + async statsForDriver(@Param('driverId') driverId: string) { + return this.incidentsService.statsForDriver(driverId); + } + + @Get('driver/:driverId') + @ApiOperation({ summary: 'List incidents for a driver (incident history)' }) + async findByDriver(@Param('driverId') driverId: string) { + return this.incidentsService.findByDriver(driverId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get an incident by id' }) + async findById(@Param('id') id: string) { + return this.incidentsService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update an incident' }) + async update(@Param('id') id: string, @Body() dto: UpdateIncidentDto) { + return this.incidentsService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete an incident' }) + async remove(@Param('id') id: string) { + await this.incidentsService.remove(id); + return { success: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.module.ts b/apps/edr-freight-api/src/modules/incidents/incidents.module.ts new file mode 100644 index 000000000..872fbaab1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/incidents.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Incident } from './entities/incident.entity'; +import { IncidentsService } from './incidents.service'; +import { IncidentsRepository } from './incidents.repository'; +import { IncidentsController } from './incidents.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Incident])], + providers: [IncidentsService, IncidentsRepository], + controllers: [IncidentsController], + exports: [IncidentsService], +}) +export class IncidentsModule {} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.repository.ts b/apps/edr-freight-api/src/modules/incidents/incidents.repository.ts new file mode 100644 index 000000000..1d9f17770 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/incidents.repository.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository } from 'typeorm'; +import { Incident } from './entities/incident.entity'; + +@Injectable() +export class IncidentsRepository extends BaseRepository { + constructor( + @InjectRepository(Incident) + incidentRepository: Repository, + ) { + super(incidentRepository); + } +} diff --git a/apps/edr-freight-api/src/modules/incidents/incidents.service.ts b/apps/edr-freight-api/src/modules/incidents/incidents.service.ts new file mode 100644 index 000000000..ea28a96e2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/incidents/incidents.service.ts @@ -0,0 +1,95 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { FindOptionsWhere } from 'typeorm'; +import { IncidentsRepository } from './incidents.repository'; +import { + Incident, + IncidentStatus, + IncidentType, +} from './entities/incident.entity'; +import { CreateIncidentDto } from './dto/create-incident.dto'; +import { UpdateIncidentDto } from './dto/update-incident.dto'; + +export interface IncidentFilter { + vehicleId?: string; + driverId?: string; + status?: IncidentStatus; + type?: IncidentType; +} + +export interface DriverIncidentStats { + total: number; + byType: Record; + lastIncidentAt: Date | null; +} + +@Injectable() +export class IncidentsService { + constructor(private readonly incidentsRepository: IncidentsRepository) {} + + async create(dto: CreateIncidentDto): Promise { + return this.incidentsRepository.create({ + ...dto, + occurredAt: new Date(dto.occurredAt), + }); + } + + async findAll(filter: IncidentFilter = {}): Promise { + const where: FindOptionsWhere = {}; + if (filter.vehicleId) where.vehicleId = filter.vehicleId; + if (filter.driverId) where.driverId = filter.driverId; + if (filter.status) where.status = filter.status; + if (filter.type) where.type = filter.type; + + return this.incidentsRepository.findAll({ + where, + order: { occurredAt: 'DESC' }, + }); + } + + async findByDriver(driverId: string): Promise { + return this.incidentsRepository.findAll({ + where: { driverId }, + order: { occurredAt: 'DESC' }, + }); + } + + async findById(id: string): Promise { + const incident = await this.incidentsRepository.findById(id); + if (!incident) { + throw new NotFoundException(`Incident ${id} not found`); + } + return incident; + } + + async update(id: string, dto: UpdateIncidentDto): Promise { + await this.findById(id); + const updated = await this.incidentsRepository.update(id, { + ...dto, + occurredAt: dto.occurredAt ? new Date(dto.occurredAt) : undefined, + }); + return updated!; + } + + async remove(id: string): Promise { + await this.findById(id); + await this.incidentsRepository.softDelete(id); + } + + async statsForDriver(driverId: string): Promise { + const incidents = await this.incidentsRepository.findAll({ + where: { driverId }, + order: { occurredAt: 'DESC' }, + }); + + const byType: Record = {}; + for (const incident of incidents) { + byType[incident.type] = (byType[incident.type] || 0) + 1; + } + + return { + total: incidents.length, + byType, + lastIncidentAt: incidents.length > 0 ? incidents[0].occurredAt : null, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts deleted file mode 100644 index de86ac883..000000000 --- a/apps/edr-freight-api/src/modules/last-mile/dto/allocate-containers.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -export class LastMileContainerAllocationDto { - containerId!: string; - vehicleId!: string; -} - -export class AllocateLastMileContainersDto { - allocations!: LastMileContainerAllocationDto[]; -} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts index 4f6f5fc8f..08de632f1 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsBoolean, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; import { LAST_MILE_STATUSES, LastMileStatus } from '../entities/last-mile.entity'; @@ -58,4 +58,9 @@ export class CreateLastMileDto { @Transform(({ value }) => (value === '' ? undefined : value)) @IsUUID() vehicleId?: string | null; + + @ApiPropertyOptional({ description: 'Invoice payment status', default: false }) + @IsOptional() + @IsBoolean() + paid?: boolean; } diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-distances.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-distances.dto.ts new file mode 100644 index 000000000..3b8b26bfe --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-distances.dto.ts @@ -0,0 +1,24 @@ +import { IsArray, IsNumber, IsOptional, IsUUID, Min, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class VehicleDistanceInput { + @IsUUID() + vehicleId!: string; + + @IsNumber() + @Min(0) + distanceKm!: number; +} + +/** Per-vehicle actual distances for a last-mile delivery (multi-truck). */ +export class SetDistancesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => VehicleDistanceInput) + distances!: VehicleDistanceInput[]; + + /** Recomputed remaining payment (total km × rate), from the client. */ + @IsOptional() + @IsNumber() + remainingPayment?: number; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts new file mode 100644 index 000000000..e07eec0b4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts @@ -0,0 +1,19 @@ +import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class LastMileVehicleInput { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsString() + containerNumber?: string; +} + +/** Replace the full set of vehicles (with their container numbers) on a delivery. */ +export class SetVehiclesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => LastMileVehicleInput) + vehicles!: LastMileVehicleInput[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/update-last-mile.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/update-last-mile.dto.ts index 9d0c4262d..405273a54 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/update-last-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/update-last-mile.dto.ts @@ -1,5 +1,18 @@ -import { PartialType } from '@nestjs/mapped-types'; +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsISO8601, IsOptional } from 'class-validator'; import { CreateLastMileDto } from './create-last-mile.dto'; -export class UpdateLastMileDto extends PartialType(CreateLastMileDto) {} +export class UpdateLastMileDto extends PartialType(CreateLastMileDto) { + /** Truck-detention clock start (vehicle arrived at destination). Overrides the auto-stamp. */ + @ApiPropertyOptional({ description: 'Vehicle arrival time (ISO 8601) — detention clock start.' }) + @IsOptional() + @IsISO8601() + arrivedAt?: string; + + /** Truck-detention clock end (cargo cleared / vehicle returned). Overrides the auto-stamp. */ + @ApiPropertyOptional({ description: 'Delivery/return time (ISO 8601) — detention clock end.' }) + @IsOptional() + @IsISO8601() + deliveredAt?: string; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts index 8a61c73bf..187d9aea1 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-container-allocation.entity.ts @@ -24,7 +24,7 @@ export class LastMileContainerAllocation extends BaseEntity { @Column('uuid', { name: 'vehicle_id', nullable: true }) vehicleId?: string | null; - @Column('text') + @Column('text', { name: 'container_type' }) containerType!: string; @Column('integer', { default: 1 }) diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts new file mode 100644 index 000000000..eee414275 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm'; + +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { LastMile } from './last-mile.entity'; + +/** + * One row per vehicle assigned to a last-mile delivery. A delivery can be + * served by several vehicles at once (multi-truck bookings); the legacy + * `last_mile.vehicle_id` column keeps pointing at the first assignment for + * backward compatibility. + */ +@Entity({ name: 'last_mile_vehicle_assignments', schema: 'freight' }) +@Unique(['lastMileId', 'vehicleId']) +@Index(['vehicleId']) +export class LastMileVehicleAssignment extends BaseEntity { + @Column({ name: 'last_mile_id', type: 'uuid' }) + lastMileId!: string; + + @ManyToOne(() => LastMile, (lm) => lm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'last_mile_id' }) + lastMile?: LastMile; + + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { nullable: false, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle; + + /** Container this truck carries — auto-filled from the booking's container + * number when known, else entered manually at assignment time. */ + @Column({ name: 'container_number', type: 'varchar', nullable: true }) + containerNumber?: string | null; + + /** Actual distance driven by this truck (km), entered per vehicle. */ + @Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) + distanceKm?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 1747e308c..5dca86cc4 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm import { Booking } from '../../bookings/entities/booking.entity'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; import { LastMileContainerAllocation } from './last-mile-container-allocation.entity'; +import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity'; export const LAST_MILE_STATUSES = [ 'PAYMENT_PENDING', @@ -29,12 +30,24 @@ export class LastMile extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 30, default: 'PAYMENT_PENDING' }) status!: LastMileStatus; + // Truck-detention window. arrivedAt = vehicle reached destination (IN_TRANSIT); + // deliveredAt = cargo cleared / vehicle returned (DELIVERED). Detention accrues + // between them beyond the rule's grace hours (default 3h), per truck per day. + @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) + arrivedAt?: Date | null; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt?: Date | null; + @Column({ name: 'advanced_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) advancedPayment!: number; @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) remainingPayment!: number; + @Column({ name: 'paid', type: 'boolean', default: false }) + paid!: boolean; + // TODO: uncomment after migration creates column // @Column({ type: 'boolean', default: false }) // isPostPaymentCompleted!: boolean; @@ -54,4 +67,7 @@ export class LastMile extends BaseEntity { @OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile) containerAllocations?: LastMileContainerAllocation[]; + + @OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile) + vehicleAssignments?: LastMileVehicleAssignment[]; } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts index c304a89e8..8a6ec0779 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile-invoice.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { Freight } from '@edr/types'; @@ -54,6 +54,39 @@ export class LastMileInvoiceService { return null; } + // numeric columns come back as strings — coerce before billing. + const totalAmount = Number(record.remainingPayment) || 0; + if (!Number.isFinite(totalAmount) || totalAmount <= 0) { + this.logger.warn( + `Skipping invoice for last-mile record ${record.id}: no remaining payment.`, + ); + return null; + } + + // Reject mixed-currency truck sets — a single invoice can only be one + // currency, and amounts across currencies can't be summed. + const billableTrucks = (record.vehicleAssignments ?? []).filter( + (a) => Number(a.distanceKm) > 0, + ); + const currencies = [ + ...new Set( + billableTrucks + .map((a) => (a.vehicle as { currency?: string } | undefined)?.currency) + .filter((c): c is string => Boolean(c)), + ), + ]; + if (currencies.length > 1) { + throw new BadRequestException( + `Cannot generate invoice: assigned trucks use mixed currencies (${currencies.join(', ')}). Assign trucks that share one currency.`, + ); + } + + // Currency follows the truck (price/km is quoted per vehicle), falling back + // to the booking's currency, then ETB. + const truckCurrency = + (record.vehicle as { currency?: string } | undefined)?.currency || + (record.vehicleAssignments?.[0]?.vehicle as { currency?: string } | undefined)?.currency; + // Generate invoice with remainingPayment as totalAmount const input: GenerateInvoiceInput = { source: 'last_mile' as Freight.InvoiceSource, @@ -61,17 +94,17 @@ export class LastMileInvoiceService { type: 'DELIVERY_FEE', companyId: lm.booking!.companyId, companyProfileId: lm.booking!.companyProfileId || '', - currency: 'ETB', + currency: truckCurrency || lm.booking!.paymentCurrency || 'ETB', lines: [ { chargeType: 'DELIVERY', description: 'Last-mile delivery', quantity: 1, - unitRate: record.remainingPayment || 0, - amount: record.remainingPayment || 0, + unitRate: totalAmount, + amount: totalAmount, }, ], - totalAmount: record.remainingPayment || 0, + totalAmount, }; return this.billing.generateInvoice(input); diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 929d97a3e..207b71a25 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -13,11 +14,13 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; -import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto'; +import { SetVehiclesDto } from './dto/set-vehicles.dto'; +import { SetDistancesDto } from './dto/set-distances.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; import { LastMileInvoiceService } from './last-mile-invoice.service'; @@ -25,7 +28,7 @@ import { LastMileInvoiceService } from './last-mile-invoice.service'; @ApiTags('last-mile') @ApiBearerAuth() @Controller('last-mile') -@TrainSchedulingView() +@BookingStaff(FREIGHT_PERMS.lastMile.view) export class LastMileController { constructor( private readonly lastMileService: LastMileService, @@ -61,46 +64,68 @@ export class LastMileController { } @Post('accept/:reference') - @TrainSchedulingManage() + @BookingStaff(FREIGHT_PERMS.lastMile.accept) @ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' }) acceptBooking(@Param('reference') reference: string) { return this.lastMileService.acceptBookingByReference(reference); } @Post() - @TrainSchedulingManage() + @BookingStaff(FREIGHT_PERMS.lastMile.create) @ApiOperation({ summary: 'Create a last-mile leg' }) create(@Body() dto: CreateLastMileDto) { return this.lastMileService.create(dto); } @Patch(':id') - @TrainSchedulingManage() + @BookingStaff(FREIGHT_PERMS.lastMile.update) @ApiOperation({ summary: 'Update a last-mile leg' }) async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { - const record = await this.lastMileService.update(id, dto); - // Auto-generate invoice if distance or payment was updated - if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) { - await this.lastMileInvoiceService.ensureInvoiceFor(record); - } - return record; + // No invoice side-effects here — invoices are generated only via the + // explicit POST :id/invoice endpoint (the "Generate Invoice" action). + return this.lastMileService.update(id, dto); } @Delete(':id') - @TrainSchedulingManage() + @BookingStaff(FREIGHT_PERMS.lastMile.delete) @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a last-mile leg' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.lastMileService.remove(id); } - @Post(':id/allocate-containers') - @TrainSchedulingManage() - @ApiOperation({ summary: 'Allocate containers to vehicles' }) - async allocateContainers( + + @Post(':id/vehicles') + @BookingStaff(FREIGHT_PERMS.lastMile.assignVehicles) + @ApiOperation({ summary: 'Set the vehicles assigned to a last-mile delivery (multi-truck)' }) + async setVehicles( @Param('id', ParseUUIDPipe) id: string, - @Body() dto: AllocateLastMileContainersDto, + @Body() dto: SetVehiclesDto, ) { - return this.lastMileService.allocateContainers(id, dto.allocations); + return this.lastMileService.setVehicles(id, dto.vehicles); + } + + @Post(':id/distances') + @BookingStaff(FREIGHT_PERMS.lastMile.setDistances) + @ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' }) + async setDistances( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetDistancesDto, + ) { + return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment); + } + + @Post(':id/invoice') + @BookingStaff(FREIGHT_PERMS.lastMile.generateInvoice) + @ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' }) + async generateInvoice(@Param('id', ParseUUIDPipe) id: string) { + const record = await this.lastMileService.findById(id); + const invoice = await this.lastMileInvoiceService.ensureInvoiceFor(record); + if (!invoice) { + throw new BadRequestException( + 'Cannot generate invoice: the leg has no billable amount. Add distance and ensure a LAST_MILE rate is configured, and the booking has a company.', + ); + } + return invoice; } } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index 32b688069..e639e4dfd 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; import { LastMile } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; +import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity'; import { LastMileController } from './last-mile.controller'; import { LastMileInvoiceService } from './last-mile-invoice.service'; import { LastMileRepository } from './last-mile.repository'; @@ -15,7 +16,7 @@ import { LastMileService } from './last-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]), + TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation, LastMileVehicleAssignment]), BillingModule, forwardRef(() => BookingsModule), VehiclesModule, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 5faad49b9..15b8df3e7 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,15 +1,21 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { DataSource, FindOptionsWhere } from 'typeorm'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; +import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity'; import { LastMileRepository } from './last-mile.repository'; +import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; +import { OnEvent } from '@nestjs/event-emitter'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; type LastMileListFilter = { status?: LastMileStatus; @@ -39,8 +45,77 @@ export class LastMileService { private readonly driversService: DriversService, private readonly smsClient: SmsClientService, private readonly dataSource: DataSource, + private readonly history: FleetHistoryService, + private readonly billing: BillingService, ) {} + /** Attach real invoice info (number/status) to records so the UI can show an + * invoice link only when one actually exists — NOT merely because distance + * was entered. Batched to avoid N+1. */ + private async attachInvoices(records: LastMile[]): Promise { + const invoices = await this.billing.findBySourceIds( + 'last_mile', + records.map((r) => r.id), + ); + const byId = new Map(); + for (const inv of invoices) { + if (!byId.has(inv.sourceId)) { + byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) }); + } + } + for (const r of records) { + (r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; + } + } + + /** Resolve a vehicle's driver + human labels, for stamping mile events onto + * the driver's timeline and naming the vehicle. Best-effort — never throws. */ + private async vehicleInfo( + vehicleId?: string | null, + ): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> { + if (!vehicleId) return { driverId: null, plate: null, driverName: null }; + try { + const v = await this.vehiclesService.findById(vehicleId); + return { + driverId: v.assignedDriverId ?? null, + plate: v.plateNumber ?? v.code ?? null, + driverName: v.assignedDriverName ?? null, + }; + } catch { + return { driverId: null, plate: null, driverName: null }; + } + } + + /** A leg counts as having a vehicle if it has a direct assignment or at least + * one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */ + private async hasAssignedVehicle( + recordId: string, + directVehicleId?: string | null, + ): Promise { + if (directVehicleId) return true; + const count = await this.dataSource.manager.count(LastMileContainerAllocation, { + where: { lastMileId: recordId, vehicleId: Not(IsNull()) }, + }); + return count > 0; + } + + /** Human booking reference for a last-mile record, for the history timeline. + * Uses the already-loaded relation when present, else looks it up. */ + private async resolveBookingRef( + record: LastMile, + ): Promise { + const loaded = (record as LastMile & { booking?: { reference?: string } }) + .booking?.reference; + if (loaded) return loaded; + if (!record.bookingId) return null; + try { + const booking = await this.bookingsRepository.findById(record.bookingId); + return (booking as { reference?: string } | null)?.reference ?? null; + } catch { + return null; + } + } + async acceptBooking(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); @@ -69,6 +144,7 @@ export class LastMileService { return null; } + return this.create({ bookingId: booking.id, advancedPayment: 0, @@ -94,14 +170,17 @@ export class LastMileService { const [data, total] = await this.lastMileRepository.findAndCount({ where, relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } }, vehicle: true, + vehicleAssignments: { vehicle: true }, }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, take: pageSize, }); + await this.attachInvoices(data); + return { data, meta: { @@ -116,8 +195,9 @@ export class LastMileService { async findById(id: string): Promise { const record = await this.lastMileRepository.findById(id, { relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } }, vehicle: true, + vehicleAssignments: { vehicle: true }, }, }); @@ -125,11 +205,13 @@ export class LastMileService { throw new NotFoundException(`Last-mile record ${id} not found`); } + await this.attachInvoices([record]); + return record; } async create(dto: CreateLastMileDto): Promise { - return this.lastMileRepository.create({ + const record = await this.lastMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', advancedPayment: dto.advancedPayment ?? 0, @@ -137,12 +219,60 @@ export class LastMileService { estimatedKm: dto.estimatedKm ?? null, exactKm: dto.exactKm ?? null, vehicleId: dto.vehicleId ?? null, + paid: (dto as any).paid ?? false, }); + + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + const info = await this.vehicleInfo(dto.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + lastMileId: record.id, + driverId: info.driverId, + label: record.status, + metadata: { + mile: 'LAST', + bookingRef: await this.resolveBookingRef(record), + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + + return record; + } + + @OnEvent("last_mile.invoice.paid") + async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { + try { + // Invoice paid → the delivery is complete. Route through update() so it + // also frees the trucks + records history (same as "Mark Delivered"). + await this.update(payload.sourceId, { status: 'DELIVERED', paid: true } as unknown as UpdateLastMileDto); + this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`); + } catch (err) { + this.logger.error( + `Failed to deliver last-mile ${payload.sourceId} on payment: ${String(err)}`, + ); + } } async update(id: string, dto: UpdateLastMileDto): Promise { const existing = await this.findById(id); + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle + // assigned in this same request). + if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { + const vehicleId = + dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId; + if (!(await this.hasAssignedVehicle(id, vehicleId))) { + throw new BadRequestException( + 'Assign a vehicle before marking this last-mile leg in transit', + ); + } + } + + const dtoAny = dto as any; const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), @@ -151,7 +281,19 @@ export class LastMileService { ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), - }); + ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}), + // Truck-detention clock: stamp arrival when the vehicle goes IN_TRANSIT and + // delivery when it reaches DELIVERED (first time only). Explicit dto values + // below override the auto-stamp so staff can record the real times. + ...(dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT' && !existing.arrivedAt + ? { arrivedAt: new Date() } + : {}), + ...(dto.status === 'DELIVERED' && existing.status !== 'DELIVERED' && !existing.deliveredAt + ? { deliveredAt: new Date() } + : {}), + ...(dtoAny.arrivedAt !== undefined ? { arrivedAt: dtoAny.arrivedAt ? new Date(dtoAny.arrivedAt) : null } : {}), + ...(dtoAny.deliveredAt !== undefined ? { deliveredAt: dtoAny.deliveredAt ? new Date(dtoAny.deliveredAt) : null } : {}), + } as any); if (!updated) { throw new NotFoundException(`Last-mile record ${id} not found`); @@ -162,9 +304,245 @@ export class LastMileService { void this.notifyDriverAssignment(dto.vehicleId, existing); } + // Audit the mile↔vehicle (re)assignment on both vehicle and driver lines. + const bookingRef = await this.resolveBookingRef(existing); + if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { + // Keep vehicle availability in sync: new vehicle goes BUSY, replaced one + // is freed if no other active trip still holds it. + if (dto.vehicleId) { + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); + } + if (existing.vehicleId) { + await this.vehiclesService.releaseIfUnused([existing.vehicleId]); + } + if (existing.vehicleId) { + const info = await this.vehicleInfo(existing.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId: existing.vehicleId, + lastMileId: id, + driverId: info.driverId, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + if (dto.vehicleId) { + const info = await this.vehicleInfo(dto.vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId: dto.vehicleId, + lastMileId: id, + driverId: info.driverId, + label: updated.status, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + } + + if (dto.status !== undefined && dto.status !== existing.status) { + const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null; + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_STATUS_CHANGED, + lastMileId: id, + vehicleId, + driverId: info.driverId, + fromValue: existing.status, + toValue: dto.status, + metadata: { + mile: 'LAST', + bookingRef, + vehiclePlate: info.plate, + driverName: info.driverName, + }, + }); + } + + // Delivery finished — free the vehicles this trip was holding. + if (dto.status === 'DELIVERED' && existing.status !== 'DELIVERED') { + await this.releaseVehicles(updated); + } + return updated; } + /** + * Free every vehicle held by this record — junction assignments, the legacy + * direct vehicle, and container allocations — unless still used by another + * active trip. + */ + private async releaseVehicles(record: LastMile): Promise { + const [assignments, recordAllocations] = await Promise.all([ + this.dataSource.manager.find(LastMileVehicleAssignment, { + where: { lastMileId: record.id }, + }), + this.dataSource.manager.find(LastMileContainerAllocation, { + where: { lastMileId: record.id }, + }), + ]); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...recordAllocations.map((a) => a.vehicleId), + record.vehicleId ?? null, + ].filter((id): id is string => Boolean(id)), + ), + ]; + await this.vehiclesService.releaseIfUnused(vehicleIds); + } + + /** + * Replace the full set of vehicles serving a last-mile delivery (multi-truck). + * Diffs against the current junction rows, syncing availability + audit history + * for each added/removed vehicle. The first vehicle is mirrored onto the legacy + * `vehicleId` column for back-compat with single-vehicle readers. + */ + async setVehicles( + id: string, + inputs: Array<{ vehicleId: string; containerNumber?: string | null }>, + ): Promise { + const existing = await this.findById(id); + // Dedupe by vehicleId, keeping the container number; preserve order. + const desiredMap = new Map(); + for (const inp of inputs) { + if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null); + } + const desired = [...desiredMap.keys()]; + const desiredSet = new Set(desired); + + const manager = this.dataSource.manager; + const current = await manager.find(LastMileVehicleAssignment, { + where: { lastMileId: id }, + }); + const junctionSet = new Set(current.map((a) => a.vehicleId)); + // Fold the legacy vehicleId into the release set — a vehicle assigned via the + // old single-vehicle path has no junction row but must still be freed. + const releaseIds = [...new Set( + current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []), + )]; + const added = desired.filter((v) => !junctionSet.has(v)); + const removed = releaseIds.filter((v) => !desiredSet.has(v)); + // Vehicles that stay but whose container number changed. + const changed = current.filter( + (a) => + desiredMap.has(a.vehicleId) && + (a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null), + ); + + await this.dataSource.transaction(async (tx) => { + if (removed.length) { + await tx.delete(LastMileVehicleAssignment, { + lastMileId: id, + vehicleId: In(removed), + }); + } + for (const vehicleId of added) { + await tx.insert(LastMileVehicleAssignment, { + lastMileId: id, + vehicleId, + containerNumber: desiredMap.get(vehicleId) ?? null, + }); + } + for (const row of changed) { + await tx.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: row.vehicleId }, + { containerNumber: desiredMap.get(row.vehicleId) ?? null }, + ); + } + }); + + // Legacy primary vehicle = first of the set (null when cleared). + await this.lastMileRepository.update(id, { vehicleId: desired[0] ?? null } as any); + + const bookingRef = await this.resolveBookingRef(existing); + for (const vehicleId of added) { + await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY); + void this.notifyDriverAssignment(vehicleId, existing); + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_ASSIGNED, + vehicleId, + lastMileId: id, + driverId: info.driverId, + label: existing.status, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + for (const vehicleId of removed) { + await this.vehiclesService.releaseIfUnused([vehicleId]); + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + lastMileId: id, + driverId: info.driverId, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, + }); + } + + return this.findById(id); + } + + /** + * Record each truck's actual distance. The delivery total (exact_km) is their + * sum and drives billing; `remainingPayment` (total km × rate) is recomputed + * client-side. Does NOT generate an invoice — that's a separate explicit step. + */ + async setDistances( + id: string, + distances: Array<{ vehicleId: string; distanceKm: number }>, + remainingPayment?: number, + ): Promise { + await this.findById(id); + + // Distances are locked once the invoice exists. + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Distances cannot be changed after the invoice is generated', + ); + } + + for (const d of distances) { + await this.dataSource.manager.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: d.vehicleId }, + { distanceKm: d.distanceKm }, + ); + } + + // Billing is per truck: amount = Σ (truck distance × truck price/km). The + // per-vehicle rate + currency live on the vehicle, so we ignore the legacy + // LAST_MILE flat rate and any client-sent amount. `remainingPayment` param + // kept only for signature back-compat. + void remainingPayment; + const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, { + where: { lastMileId: id }, + relations: { vehicle: true }, + }); + const total = assignments.reduce((s, a) => s + (Number(a.distanceKm) || 0), 0); + const amount = assignments.reduce( + (s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0), + 0, + ); + await this.lastMileRepository.update(id, { + exactKm: total, + remainingPayment: amount, + } as any); + return this.findById(id); + } + private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); @@ -205,38 +583,54 @@ export class LastMileService { } async remove(id: string): Promise { - await this.findById(id); - await this.lastMileRepository.softDelete(id); - } + const existing = await this.findById(id); - async allocateContainers( - lastMileId: string, - allocations: Array<{ containerId: string; vehicleId: string }>, - ) { - const lastMile = await this.findById(lastMileId); - if (!lastMile) { - throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + // Can't delete once billed. + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Cannot delete a last-mile delivery after its invoice is generated', + ); } - await this.dataSource.transaction(async (manager) => { - for (const allocation of allocations) { - await manager.delete(LastMileContainerAllocation, { - lastMileId, - containerId: allocation.containerId, - }); - await manager.insert(LastMileContainerAllocation, { - lastMileId, - containerId: allocation.containerId, - vehicleId: allocation.vehicleId, - containerType: 'CONTAINER', - quantity: 1, + // Every vehicle this delivery holds — junction + legacy + container rows. + const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, { + where: { lastMileId: id }, + }); + const allocations = await this.dataSource.manager.find(LastMileContainerAllocation, { + where: { lastMileId: id }, + }); + const vehicleIds = [ + ...new Set( + [ + ...assignments.map((a) => a.vehicleId), + ...allocations.map((a) => a.vehicleId), + existing.vehicleId ?? null, + ].filter((v): v is string => Boolean(v)), + ), + ]; + + await this.lastMileRepository.softDelete(id); + if (assignments.length) { + await this.dataSource.manager.softDelete(LastMileVehicleAssignment, { lastMileId: id }); + } + + // Free every vehicle no longer held by another active trip (releaseIfUnused + // ignores this now soft-deleted record) and audit the release. + if (vehicleIds.length) { + await this.vehiclesService.releaseIfUnused(vehicleIds); + const bookingRef = await this.resolveBookingRef(existing); + for (const vehicleId of vehicleIds) { + const info = await this.vehicleInfo(vehicleId); + await this.history.record({ + eventType: FleetEventType.MILE_VEHICLE_RELEASED, + vehicleId, + lastMileId: id, + driverId: info.driverId, + metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName }, }); } - }); - - return { - success: true, - allocated: allocations.length, - }; + } } + } diff --git a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance-depth.dto.ts b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance-depth.dto.ts new file mode 100644 index 000000000..56c886a74 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance-depth.dto.ts @@ -0,0 +1,171 @@ +import { + IsUUID, + IsString, + IsDateString, + IsNumber, + IsInt, + IsOptional, + IsEnum, + Min, +} from 'class-validator'; +import { WorkOrderStatus, WorkOrderPriority } from '../entities/work-order.entity'; + +export class CreateWorkOrderDto { + @IsUUID() + vehicleId!: string; + + @IsString() + title!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(WorkOrderStatus) + status?: WorkOrderStatus; + + @IsOptional() + @IsEnum(WorkOrderPriority) + priority?: WorkOrderPriority; + + @IsOptional() + @IsString() + assignedTo?: string; + + @IsOptional() + @IsDateString() + openedAt?: string; + + @IsOptional() + @IsDateString() + closedAt?: string; + + @IsOptional() + @IsNumber() + laborCost?: number; + + @IsOptional() + @IsNumber() + partsCost?: number; +} + +export class UpdateWorkOrderDto { + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(WorkOrderStatus) + status?: WorkOrderStatus; + + @IsOptional() + @IsEnum(WorkOrderPriority) + priority?: WorkOrderPriority; + + @IsOptional() + @IsString() + assignedTo?: string; + + @IsOptional() + @IsDateString() + closedAt?: string; + + @IsOptional() + @IsNumber() + laborCost?: number; + + @IsOptional() + @IsNumber() + partsCost?: number; +} + +export class CreatePartDto { + @IsString() + name!: string; + + @IsOptional() + @IsString() + sku?: string; + + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsInt() + @Min(0) + quantityInStock?: number; + + @IsOptional() + @IsInt() + @Min(0) + reorderLevel?: number; + + @IsOptional() + @IsNumber() + unitCost?: number; + + @IsOptional() + @IsString() + location?: string; +} + +export class UpdatePartDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + sku?: string; + + @IsOptional() + @IsString() + category?: string; + + @IsOptional() + @IsInt() + @Min(0) + quantityInStock?: number; + + @IsOptional() + @IsInt() + @Min(0) + reorderLevel?: number; + + @IsOptional() + @IsNumber() + unitCost?: number; + + @IsOptional() + @IsString() + location?: string; +} + +export class CreateWarrantyDto { + @IsUUID() + vehicleId!: string; + + @IsString() + component!: string; + + @IsOptional() + @IsString() + provider?: string; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsDateString() + expiryDate!: string; + + @IsOptional() + @IsString() + coverageNotes?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts new file mode 100644 index 000000000..d3e70acff --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts @@ -0,0 +1,87 @@ +import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator'; +import { MaintenanceType, MaintenanceStatus } from '../entities/maintenance-schedule.entity'; + +export class CreateMaintenanceScheduleDto { + @IsUUID() + vehicleId!: string; + + @IsEnum(MaintenanceType) + maintenanceType!: MaintenanceType; + + @IsString() + description!: string; + + @IsDateString() + scheduledDate!: string; + + @IsOptional() + @IsNumber() + estimatedCost?: number; + + @IsOptional() + @IsString() + serviceProvider?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsNumber() + nextDueKm?: number; + + @IsOptional() + @IsDateString() + nextDueDate?: string; +} + +export class CreateMaintenanceCostDto { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsUUID() + maintenanceScheduleId?: string; + + @IsDateString() + incurredDate!: string; + + @IsNumber() + costAmount!: number; + + @IsString() + costType!: string; + + @IsString() + description!: string; + + @IsOptional() + @IsString() + serviceProvider?: string; + + @IsOptional() + @IsString() + invoiceNumber?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateMaintenanceScheduleDto { + @IsOptional() + @IsEnum(MaintenanceStatus) + status?: MaintenanceStatus; + + @IsOptional() + @IsDateString() + completedDate?: string; + + @IsOptional() + @IsNumber() + actualCost?: number; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts new file mode 100644 index 000000000..5afbaa80f --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts @@ -0,0 +1,43 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { MaintenanceSchedule } from './maintenance-schedule.entity'; + +@Entity({ name: 'maintenance_costs', schema: 'freight' }) +@Index(['vehicleId', 'incurredDate']) +export class MaintenanceCost extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'maintenance_schedule_id', type: 'uuid', nullable: true }) + maintenanceScheduleId?: string; + + @ManyToOne(() => MaintenanceSchedule, { eager: false, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'maintenance_schedule_id' }) + maintenanceSchedule?: MaintenanceSchedule; + + @Column({ name: 'incurred_date', type: 'timestamptz' }) + incurredDate!: Date; + + @Column({ name: 'cost_amount', type: 'numeric', precision: 14, scale: 2 }) + costAmount!: number; + + @Column({ name: 'cost_type' }) + costType!: string; // 'PARTS', 'LABOR', 'DIAGNOSTICS', 'OTHER' + + @Column({ name: 'description' }) + description!: string; + + @Column({ name: 'service_provider', nullable: true }) + serviceProvider?: string; + + @Column({ name: 'invoice_number', nullable: true }) + invoiceNumber?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts new file mode 100644 index 000000000..a4d4d60a0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts @@ -0,0 +1,65 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +export enum MaintenanceType { + PREVENTIVE = 'PREVENTIVE', + CORRECTIVE = 'CORRECTIVE', + INSPECTION = 'INSPECTION', + REPAIR = 'REPAIR', +} + +export enum MaintenanceStatus { + SCHEDULED = 'SCHEDULED', + IN_PROGRESS = 'IN_PROGRESS', + COMPLETED = 'COMPLETED', + CANCELLED = 'CANCELLED', + OVERDUE = 'OVERDUE', +} + +@Entity({ name: 'maintenance_schedules', schema: 'freight' }) +@Index(['vehicleId', 'scheduledDate']) +export class MaintenanceSchedule extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'maintenance_type', type: 'varchar' }) + maintenanceType!: MaintenanceType; + + @Column({ name: 'description' }) + description!: string; + + @Column({ name: 'scheduled_date', type: 'timestamptz' }) + scheduledDate!: Date; + + @Column({ name: 'completed_date', type: 'timestamptz', nullable: true }) + completedDate?: Date; + + @Column({ name: 'estimated_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + estimatedCost?: number; + + @Column({ name: 'actual_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + actualCost?: number; + + @Column({ name: 'status', type: 'varchar', default: MaintenanceStatus.SCHEDULED }) + status!: MaintenanceStatus; + + @Column({ name: 'odometer_reading', type: 'numeric', nullable: true }) + odometerReading?: number; + + @Column({ name: 'service_provider', nullable: true }) + serviceProvider?: string; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string; + + @Column({ name: 'next_due_km', type: 'numeric', nullable: true }) + nextDueKm?: number; + + @Column({ name: 'next_due_date', type: 'timestamptz', nullable: true }) + nextDueDate?: Date; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/part.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/part.entity.ts new file mode 100644 index 000000000..caa478d88 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/part.entity.ts @@ -0,0 +1,27 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, Index } from 'typeorm'; + +@Entity({ name: 'parts', schema: 'freight' }) +@Index(['category']) +export class Part extends BaseEntity { + @Column({ name: 'name', type: 'varchar' }) + name!: string; + + @Column({ name: 'sku', type: 'varchar', nullable: true }) + sku?: string; + + @Column({ name: 'category', type: 'varchar', nullable: true }) + category?: string; // includes 'TIRE' — doubles as tire inventory + + @Column({ name: 'quantity_in_stock', type: 'int', default: 0 }) + quantityInStock!: number; + + @Column({ name: 'reorder_level', type: 'int', default: 0 }) + reorderLevel!: number; + + @Column({ name: 'unit_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + unitCost?: number; + + @Column({ name: 'location', type: 'varchar', nullable: true }) + location?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/warranty.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/warranty.entity.ts new file mode 100644 index 000000000..56c44fcbd --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/warranty.entity.ts @@ -0,0 +1,29 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ name: 'warranties', schema: 'freight' }) +@Index(['vehicleId', 'expiryDate']) +export class Warranty extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'component', type: 'varchar' }) + component!: string; + + @Column({ name: 'provider', type: 'varchar', nullable: true }) + provider?: string; + + @Column({ name: 'start_date', type: 'date', nullable: true }) + startDate?: string; + + @Column({ name: 'expiry_date', type: 'date' }) + expiryDate!: string; + + @Column({ name: 'coverage_notes', type: 'text', nullable: true }) + coverageNotes?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/work-order.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/work-order.entity.ts new file mode 100644 index 000000000..224b74f74 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/work-order.entity.ts @@ -0,0 +1,55 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +export enum WorkOrderStatus { + OPEN = 'OPEN', + IN_PROGRESS = 'IN_PROGRESS', + COMPLETED = 'COMPLETED', + CANCELLED = 'CANCELLED', +} + +export enum WorkOrderPriority { + LOW = 'LOW', + MEDIUM = 'MEDIUM', + HIGH = 'HIGH', + URGENT = 'URGENT', +} + +@Entity({ name: 'work_orders', schema: 'freight' }) +@Index(['vehicleId', 'status']) +export class WorkOrder extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'title', type: 'varchar' }) + title!: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string; + + @Column({ name: 'status', type: 'varchar', default: WorkOrderStatus.OPEN }) + status!: WorkOrderStatus; + + @Column({ name: 'priority', type: 'varchar', default: WorkOrderPriority.MEDIUM }) + priority!: WorkOrderPriority; + + @Column({ name: 'assigned_to', type: 'varchar', nullable: true }) + assignedTo?: string; + + @Column({ name: 'opened_at', type: 'timestamptz' }) + openedAt!: Date; + + @Column({ name: 'closed_at', type: 'timestamptz', nullable: true }) + closedAt?: Date; + + @Column({ name: 'labor_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + laborCost?: number; + + @Column({ name: 'parts_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + partsCost?: number; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-depth.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-depth.service.ts new file mode 100644 index 000000000..212fe909a --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-depth.service.ts @@ -0,0 +1,99 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { WorkOrderRepository } from './work-order.repository'; +import { PartRepository } from './part.repository'; +import { WarrantyRepository } from './warranty.repository'; +import { WorkOrder, WorkOrderStatus } from './entities/work-order.entity'; +import { Part } from './entities/part.entity'; +import { Warranty } from './entities/warranty.entity'; +import { + CreateWorkOrderDto, + UpdateWorkOrderDto, + CreatePartDto, + UpdatePartDto, + CreateWarrantyDto, +} from './dto/create-maintenance-depth.dto'; + +@Injectable() +export class MaintenanceDepthService { + constructor( + private readonly workOrderRepository: WorkOrderRepository, + private readonly partRepository: PartRepository, + private readonly warrantyRepository: WarrantyRepository, + ) {} + + // ---- Work Orders ---- + + async createWorkOrder(dto: CreateWorkOrderDto): Promise { + return this.workOrderRepository.create({ + ...dto, + openedAt: dto.openedAt ? new Date(dto.openedAt) : new Date(), + closedAt: dto.closedAt ? new Date(dto.closedAt) : undefined, + }); + } + + async findWorkOrders(filters: { vehicleId?: string; status?: WorkOrderStatus }) { + return this.workOrderRepository.findFiltered(filters); + } + + async findWorkOrderById(id: string): Promise { + const workOrder = await this.workOrderRepository.findById(id); + if (!workOrder) throw new NotFoundException(`Work order ${id} not found`); + return workOrder; + } + + async updateWorkOrder(id: string, dto: UpdateWorkOrderDto): Promise { + await this.findWorkOrderById(id); + const updated = await this.workOrderRepository.update(id, { + ...dto, + closedAt: dto.closedAt ? new Date(dto.closedAt) : undefined, + }); + return updated!; + } + + async deleteWorkOrder(id: string): Promise<{ id: string; deleted: boolean }> { + await this.findWorkOrderById(id); + await this.workOrderRepository.softDelete(id); + return { id, deleted: true }; + } + + // ---- Parts / Tires ---- + + async createPart(dto: CreatePartDto): Promise { + return this.partRepository.create({ ...dto }); + } + + async findParts(filters: { category?: string; lowStock?: boolean }) { + return this.partRepository.findFiltered(filters); + } + + async updatePart(id: string, dto: UpdatePartDto): Promise { + const part = await this.partRepository.findById(id); + if (!part) throw new NotFoundException(`Part ${id} not found`); + const updated = await this.partRepository.update(id, { ...dto }); + return updated!; + } + + async deletePart(id: string): Promise<{ id: string; deleted: boolean }> { + const part = await this.partRepository.findById(id); + if (!part) throw new NotFoundException(`Part ${id} not found`); + await this.partRepository.softDelete(id); + return { id, deleted: true }; + } + + // ---- Warranties ---- + + async createWarranty(dto: CreateWarrantyDto): Promise { + return this.warrantyRepository.create({ ...dto }); + } + + async findWarranties(filters: { vehicleId?: string }) { + return this.warrantyRepository.findFiltered(filters); + } + + async deleteWarranty(id: string): Promise<{ id: string; deleted: boolean }> { + const warranty = await this.warrantyRepository.findById(id); + if (!warranty) throw new NotFoundException(`Warranty ${id} not found`); + await this.warrantyRepository.softDelete(id); + return { id, deleted: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts new file mode 100644 index 000000000..4ad8026f2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts @@ -0,0 +1,173 @@ +import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { MaintenanceService } from './maintenance.service'; +import { MaintenanceDepthService } from './maintenance-depth.service'; +import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; +import { + CreateWorkOrderDto, + UpdateWorkOrderDto, + CreatePartDto, + UpdatePartDto, + CreateWarrantyDto, +} from './dto/create-maintenance-depth.dto'; +import { WorkOrderStatus } from './entities/work-order.entity'; + +@ApiTags('Maintenance Management') +@ApiBearerAuth() +@Controller('maintenance') +export class MaintenanceController { + constructor( + private readonly maintenanceService: MaintenanceService, + private readonly maintenanceDepthService: MaintenanceDepthService, + ) {} + + @Post('schedules') + @BookingStaff(FREIGHT_PERMS.maintenance.create) + @ApiOperation({ summary: 'Schedule maintenance' }) + async scheduleMaintenanceAsync(@Body() dto: CreateMaintenanceScheduleDto) { + return this.maintenanceService.scheduleMaintenanceAsync(dto); + } + + @Post('costs') + @BookingStaff(FREIGHT_PERMS.maintenance.create) + @ApiOperation({ summary: 'Record maintenance cost' }) + async recordCost(@Body() dto: CreateMaintenanceCostDto) { + return this.maintenanceService.recordMaintenanceCost(dto); + } + + @Patch('schedules/:id') + @BookingStaff(FREIGHT_PERMS.maintenance.update) + @ApiOperation({ summary: 'Update maintenance schedule' }) + async updateSchedule(@Param('id') id: string, @Body() dto: UpdateMaintenanceScheduleDto) { + return this.maintenanceService.updateMaintenanceSchedule(id, dto); + } + + @Get('upcoming/:vehicleId') + @BookingStaff(FREIGHT_PERMS.maintenance.view) + @ApiOperation({ summary: 'Get upcoming maintenance' }) + async getUpcoming(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getUpcomingMaintenance(vehicleId); + } + + @Get('history/:vehicleId') + @BookingStaff(FREIGHT_PERMS.maintenance.view) + @ApiOperation({ summary: 'Get maintenance history' }) + async getHistory(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getMaintenanceHistory(vehicleId); + } + + @Get('stats') + @BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetReports.view, FREIGHT_PERMS.fleetDashboard.view]) + @ApiOperation({ summary: 'Get fleet-wide maintenance statistics' }) + async getFleetStats() { + return this.maintenanceService.getFleetMaintenanceStats(); + } + + @Get('stats/:vehicleId') + @BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetReports.view, FREIGHT_PERMS.fleetDashboard.view]) + @ApiOperation({ summary: 'Get maintenance statistics' }) + async getStats(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getVehicleMaintenanceStats(vehicleId); + } + + // ---- Work Orders ---- + + @Post('work-orders') + @BookingStaff(FREIGHT_PERMS.maintenance.create) + @ApiOperation({ summary: 'Create work order' }) + async createWorkOrder(@Body() dto: CreateWorkOrderDto) { + return this.maintenanceDepthService.createWorkOrder(dto); + } + + @Get('work-orders') + @BookingStaff(FREIGHT_PERMS.maintenance.view) + @ApiOperation({ summary: 'List work orders' }) + async listWorkOrders( + @Query('vehicleId') vehicleId?: string, + @Query('status') status?: WorkOrderStatus, + ) { + return this.maintenanceDepthService.findWorkOrders({ vehicleId, status }); + } + + @Get('work-orders/:id') + @BookingStaff(FREIGHT_PERMS.maintenance.view) + @ApiOperation({ summary: 'Get work order' }) + async getWorkOrder(@Param('id') id: string) { + return this.maintenanceDepthService.findWorkOrderById(id); + } + + @Patch('work-orders/:id') + @BookingStaff(FREIGHT_PERMS.maintenance.update) + @ApiOperation({ summary: 'Update work order' }) + async updateWorkOrder(@Param('id') id: string, @Body() dto: UpdateWorkOrderDto) { + return this.maintenanceDepthService.updateWorkOrder(id, dto); + } + + @Delete('work-orders/:id') + @BookingStaff(FREIGHT_PERMS.maintenance.delete) + @ApiOperation({ summary: 'Delete work order' }) + async deleteWorkOrder(@Param('id') id: string) { + return this.maintenanceDepthService.deleteWorkOrder(id); + } + + // ---- Parts / Tires ---- + + @Post('parts') + @BookingStaff(FREIGHT_PERMS.maintenance.create) + @ApiOperation({ summary: 'Create part' }) + async createPart(@Body() dto: CreatePartDto) { + return this.maintenanceDepthService.createPart(dto); + } + + @Get('parts') + @BookingStaff(FREIGHT_PERMS.maintenance.view) + @ApiOperation({ summary: 'List parts / tire inventory' }) + async listParts( + @Query('category') category?: string, + @Query('lowStock') lowStock?: string, + ) { + return this.maintenanceDepthService.findParts({ + category, + lowStock: lowStock === 'true', + }); + } + + @Patch('parts/:id') + @BookingStaff(FREIGHT_PERMS.maintenance.update) + @ApiOperation({ summary: 'Update part' }) + async updatePart(@Param('id') id: string, @Body() dto: UpdatePartDto) { + return this.maintenanceDepthService.updatePart(id, dto); + } + + @Delete('parts/:id') + @BookingStaff(FREIGHT_PERMS.maintenance.delete) + @ApiOperation({ summary: 'Delete part' }) + async deletePart(@Param('id') id: string) { + return this.maintenanceDepthService.deletePart(id); + } + + // ---- Warranties ---- + + @Post('warranties') + @BookingStaff(FREIGHT_PERMS.maintenance.create) + @ApiOperation({ summary: 'Create warranty' }) + async createWarranty(@Body() dto: CreateWarrantyDto) { + return this.maintenanceDepthService.createWarranty(dto); + } + + @Get('warranties') + @BookingStaff(FREIGHT_PERMS.maintenance.view) + @ApiOperation({ summary: 'List warranties' }) + async listWarranties(@Query('vehicleId') vehicleId?: string) { + return this.maintenanceDepthService.findWarranties({ vehicleId }); + } + + @Delete('warranties/:id') + @BookingStaff(FREIGHT_PERMS.maintenance.delete) + @ApiOperation({ summary: 'Delete warranty' }) + async deleteWarranty(@Param('id') id: string) { + return this.maintenanceDepthService.deleteWarranty(id); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts new file mode 100644 index 000000000..8f4fe1d0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MaintenanceSchedule } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; +import { WorkOrder } from './entities/work-order.entity'; +import { Part } from './entities/part.entity'; +import { Warranty } from './entities/warranty.entity'; +import { MaintenanceService } from './maintenance.service'; +import { MaintenanceDepthService } from './maintenance-depth.service'; +import { MaintenanceRepository } from './maintenance.repository'; +import { WorkOrderRepository } from './work-order.repository'; +import { PartRepository } from './part.repository'; +import { WarrantyRepository } from './warranty.repository'; +import { MaintenanceController } from './maintenance.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]), + ], + providers: [ + MaintenanceService, + MaintenanceDepthService, + MaintenanceRepository, + WorkOrderRepository, + PartRepository, + WarrantyRepository, + ], + controllers: [MaintenanceController], + exports: [MaintenanceService, MaintenanceDepthService], +}) +export class MaintenanceModule {} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts new file mode 100644 index 000000000..9e8cf972e --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository, Between } from 'typeorm'; +import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; + +@Injectable() +export class MaintenanceRepository extends BaseRepository { + constructor( + @InjectRepository(MaintenanceSchedule) + private readonly scheduleRepository: Repository, + @InjectRepository(MaintenanceCost) + private readonly costRepository: Repository, + ) { + super(scheduleRepository); + } + + async getUpcomingMaintenance(vehicleId: string, daysAhead: number = 30) { + const futureDate = new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000); + return this.scheduleRepository.find({ + where: { + vehicleId, + scheduledDate: Between(new Date(), futureDate), + status: MaintenanceStatus.SCHEDULED, + }, + order: { scheduledDate: 'ASC' }, + }); + } + + async getMaintenanceCosts(vehicleId: string, startDate: Date, endDate: Date) { + return this.costRepository.find({ + where: { + vehicleId, + incurredDate: Between(startDate, endDate), + }, + order: { incurredDate: 'DESC' }, + }); + } + + async getTotalMaintenanceCost(vehicleId: string, startDate: Date, endDate: Date) { + const result = await this.costRepository + .createQueryBuilder() + .select('SUM(cost_amount)', 'total') + .where('vehicle_id = :vehicleId', { vehicleId }) + .andWhere('incurred_date BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getRawOne(); + return result?.total || 0; + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts new file mode 100644 index 000000000..e804243a0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { MaintenanceRepository } from './maintenance.repository'; +import { MaintenanceSchedule } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; +import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; + +@Injectable() +export class MaintenanceService { + constructor( + private readonly maintenanceRepository: MaintenanceRepository, + @InjectRepository(MaintenanceSchedule) + private readonly scheduleRepository: Repository, + @InjectRepository(MaintenanceCost) + private readonly costRepository: Repository, + ) {} + + async scheduleMaintenanceAsync(dto: CreateMaintenanceScheduleDto): Promise { + const schedule = this.scheduleRepository.create({ + ...dto, + scheduledDate: new Date(dto.scheduledDate), + nextDueDate: dto.nextDueDate ? new Date(dto.nextDueDate) : undefined, + }); + return this.scheduleRepository.save(schedule); + } + + async recordMaintenanceCost(dto: CreateMaintenanceCostDto): Promise { + const cost = this.costRepository.create({ + ...dto, + incurredDate: new Date(dto.incurredDate), + }); + return this.costRepository.save(cost); + } + + async updateMaintenanceSchedule( + id: string, + dto: UpdateMaintenanceScheduleDto, + ): Promise { + await this.scheduleRepository.update(id, { + ...dto, + completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined, + }); + const updated = await this.scheduleRepository.findOneBy({ id }); + return updated!; + } + + async getUpcomingMaintenance(vehicleId: string) { + return this.maintenanceRepository.getUpcomingMaintenance(vehicleId); + } + + async getMaintenanceHistory(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + return this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate); + } + + async getFleetMaintenanceStats(monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const costs = await this.costRepository + .createQueryBuilder('cost') + .where('cost.incurredDate BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getMany(); + + const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0); + + return { + totalCost, + numberOfMaintenanceItems: costs.length, + averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0, + costByType: this.groupCostsByType(costs), + }; + } + + async getVehicleMaintenanceStats(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const costs = await this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate); + const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0); + + return { + vehicleId, + totalCost, + numberOfMaintenanceItems: costs.length, + averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0, + costByType: this.groupCostsByType(costs), + }; + } + + private groupCostsByType(costs: MaintenanceCost[]) { + const grouped: Record = {}; + costs.forEach((c) => { + if (!grouped[c.costType]) grouped[c.costType] = 0; + grouped[c.costType] += Number(c.costAmount); + }); + return grouped; + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/part.repository.ts b/apps/edr-freight-api/src/modules/maintenance/part.repository.ts new file mode 100644 index 000000000..d6b221332 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/part.repository.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository } from 'typeorm'; +import { Part } from './entities/part.entity'; + +@Injectable() +export class PartRepository extends BaseRepository { + constructor( + @InjectRepository(Part) + private readonly partRepository: Repository, + ) { + super(partRepository); + } + + async findFiltered(filters: { category?: string; lowStock?: boolean }) { + const qb = this.partRepository.createQueryBuilder('part'); + if (filters.category) { + qb.andWhere('part.category = :category', { category: filters.category }); + } + if (filters.lowStock) { + qb.andWhere('part.quantityInStock <= part.reorderLevel'); + } + qb.orderBy('part.name', 'ASC'); + return qb.getMany(); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/warranty.repository.ts b/apps/edr-freight-api/src/modules/maintenance/warranty.repository.ts new file mode 100644 index 000000000..e59bd358d --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/warranty.repository.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository, FindOptionsWhere } from 'typeorm'; +import { Warranty } from './entities/warranty.entity'; + +@Injectable() +export class WarrantyRepository extends BaseRepository { + constructor( + @InjectRepository(Warranty) + private readonly warrantyRepository: Repository, + ) { + super(warrantyRepository); + } + + async findFiltered(filters: { vehicleId?: string }) { + const where: FindOptionsWhere = {}; + if (filters.vehicleId) where.vehicleId = filters.vehicleId; + return this.warrantyRepository.find({ + where, + order: { expiryDate: 'ASC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/work-order.repository.ts b/apps/edr-freight-api/src/modules/maintenance/work-order.repository.ts new file mode 100644 index 000000000..057f1fa6d --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/work-order.repository.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository, FindOptionsWhere } from 'typeorm'; +import { WorkOrder, WorkOrderStatus } from './entities/work-order.entity'; + +@Injectable() +export class WorkOrderRepository extends BaseRepository { + constructor( + @InjectRepository(WorkOrder) + private readonly workOrderRepository: Repository, + ) { + super(workOrderRepository); + } + + async findFiltered(filters: { vehicleId?: string; status?: WorkOrderStatus }) { + const where: FindOptionsWhere = {}; + if (filters.vehicleId) where.vehicleId = filters.vehicleId; + if (filters.status) where.status = filters.status; + return this.workOrderRepository.find({ + where, + order: { openedAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/minio/minio.config.ts b/apps/edr-freight-api/src/modules/minio/minio.config.ts index 10482a325..a6decd1d0 100644 --- a/apps/edr-freight-api/src/modules/minio/minio.config.ts +++ b/apps/edr-freight-api/src/modules/minio/minio.config.ts @@ -7,4 +7,9 @@ export const minioConfig = registerAs("minio", () => ({ accessKey: process.env.MINIO_ACCESS_KEY || "", secretKey: process.env.MINIO_SECRET_KEY || "", bucket: process.env.MINIO_BUCKET || "fhc", + // Preset the region so presignedGetObject signs URLs locally. Without it the + // minio client fires a live GetBucketLocation request to the endpoint on every + // sign — which blocks (no timeout) when MinIO is slow/unreachable and hangs + // API responses that reload a booking's files (e.g. staff accept). + region: process.env.MINIO_REGION || "us-east-1", })); diff --git a/apps/edr-freight-api/src/modules/minio/minio.service.ts b/apps/edr-freight-api/src/modules/minio/minio.service.ts index 086da89f9..4b0804d62 100644 --- a/apps/edr-freight-api/src/modules/minio/minio.service.ts +++ b/apps/edr-freight-api/src/modules/minio/minio.service.ts @@ -29,6 +29,9 @@ export class MinioService { useSSL: config.useSSL, accessKey: config.accessKey, secretKey: config.secretKey, + // Presetting the region keeps presignedGetObject fully local — no live + // GetBucketLocation round-trip to the endpoint on each signed URL. + region: config.region, }); } @@ -108,8 +111,11 @@ export class MinioService { try { return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds); } catch (error) { + // Signing a file URL must never break a booking/transition response — the + // caller only needs SOMETHING to link to. Degrade to the public object URL + // and log, rather than throwing (which would 500 an otherwise-good load). this.logger.error(`Failed to generate signed URL for ${objectName}:`, error); - throw error; + return this.getPublicUrl(objectName); } } } diff --git a/apps/edr-freight-api/src/modules/notification-inbox/dto/list-notifications-query.dto.ts b/apps/edr-freight-api/src/modules/notification-inbox/dto/list-notifications-query.dto.ts new file mode 100644 index 000000000..a92dcef16 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/dto/list-notifications-query.dto.ts @@ -0,0 +1,30 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, Max, Min } from "class-validator"; + +export class ListNotificationsQueryDto { + @ApiPropertyOptional({ + description: "Filter by read state. Omit to return all.", + }) + @IsOptional() + @Transform(({ value }) => + value === "true" ? true : value === "false" ? false : value, + ) + @IsBoolean() + isRead?: boolean; + + @ApiPropertyOptional({ minimum: 1, default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number; +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/entities/notification.entity.ts b/apps/edr-freight-api/src/modules/notification-inbox/entities/notification.entity.ts new file mode 100644 index 000000000..eddb7940b --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/entities/notification.entity.ts @@ -0,0 +1,64 @@ +import { BaseEntity } from "@edr/api-common"; +import { + NotificationAudience, + NotificationChannelsSent, + NotificationPriority, + NotificationType, +} from "@edr/types"; +import { Column, Entity, Index } from "typeorm"; + +/** + * A single persisted in-app notification addressed to one IAM user. Producers + * fan a logical notification out to N recipients by inserting one row per + * resolved user id (see NotificationInboxService.notify). + */ +@Entity({ schema: "freight", name: "notifications" }) +@Index("IDX_NOTIFICATIONS_RECIPIENT_UNREAD", ["recipientUserId", "isRead"]) +@Index("IDX_NOTIFICATIONS_RECIPIENT_CREATED", ["recipientUserId", "createdAt"]) +export class Notification extends BaseEntity { + @Column({ name: "recipient_user_id", type: "uuid" }) + recipientUserId!: string; + + @Column({ name: "audience", type: "varchar", length: 20 }) + audience!: NotificationAudience; + + @Column({ + name: "type", + type: "varchar", + length: 48, + default: NotificationType.GENERIC, + }) + type!: NotificationType; + + @Column({ name: "title", type: "varchar", length: 200 }) + title!: string; + + @Column({ name: "body", type: "text" }) + body!: string; + + /** Deep-link path within the app the item points to (e.g. `/contracts/:id`). */ + @Column({ name: "link", type: "varchar", nullable: true }) + link?: string | null; + + /** Arbitrary structured payload (bookingId, invoiceId, contractId, …). */ + @Column({ name: "data", type: "jsonb", nullable: true }) + data?: Record | null; + + @Column({ + name: "priority", + type: "varchar", + length: 12, + default: NotificationPriority.NORMAL, + }) + priority!: NotificationPriority; + + @Column({ name: "is_read", type: "boolean", default: false }) + isRead!: boolean; + + @Column({ name: "read_at", type: "timestamptz", nullable: true }) + readAt?: Date | null; + + /** Per-channel fan-out outcome for HIGH-priority items (email/SMS). */ + @Column({ name: "channels_sent", type: "jsonb", nullable: true }) + channelsSent?: NotificationChannelsSent | null; +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts new file mode 100644 index 000000000..1d7fd27fc --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts @@ -0,0 +1,79 @@ +import { CurrentUser } from "@edr/api-common"; +import { + NotificationAudience, + NotificationPriority, + NotificationType, +} from "@edr/types"; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { + AuthUserPayload, + resolveAuthUserId, +} from "../../common/resolve-auth-user-id"; +import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto"; +import { NotificationInboxService } from "./notification-inbox.service"; + +@ApiTags("notifications") +@Controller("notifications") +export class NotificationInboxController { + constructor(private readonly service: NotificationInboxService) {} + + @Get() + @ApiOperation({ summary: "List my notifications (paginated, newest first)" }) + list( + @CurrentUser() user: AuthUserPayload, + @Query() query: ListNotificationsQueryDto, + ) { + return this.service.list(resolveAuthUserId(user), query); + } + + @Get("unread-count") + @ApiOperation({ summary: "Count my unread notifications" }) + unreadCount(@CurrentUser() user: AuthUserPayload) { + return this.service.unreadCount(resolveAuthUserId(user)); + } + + @Patch(":id/read") + @ApiOperation({ summary: "Mark one of my notifications as read" }) + markRead( + @CurrentUser() user: AuthUserPayload, + @Param("id", ParseUUIDPipe) id: string, + ) { + return this.service.markRead(id, resolveAuthUserId(user)); + } + + @Post("read-all") + @ApiOperation({ summary: "Mark all my notifications as read" }) + markAllRead(@CurrentUser() user: AuthUserPayload) { + return this.service.markAllRead(resolveAuthUserId(user)); + } + + // TODO: remove before merge — dev/verification helper only. + @Post("test") + @ApiOperation({ + summary: "[dev] Send a test notification to the current user", + }) + sendTest( + @CurrentUser() user: AuthUserPayload, + @Body() + body: { + audience?: NotificationAudience; + type?: NotificationType; + priority?: NotificationPriority; + title?: string; + message?: string; + }, + ) { + return this.service.sendTestToUser(resolveAuthUserId(user), body ?? {}); + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts new file mode 100644 index 000000000..4981a9486 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts @@ -0,0 +1,37 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { BackofficeModule } from "../backoffice/backoffice.module"; +import { CompaniesModule } from "../companies/companies.module"; +import { NotificationsModule } from "../notifications/notifications.module"; +import { Notification } from "./entities/notification.entity"; +import { NotificationInboxController } from "./notification-inbox.controller"; +import { NotificationInboxRepository } from "./notification-inbox.repository"; +import { NotificationInboxService } from "./notification-inbox.service"; +import { NotificationRecipientsService } from "./notification-recipients.service"; +import { NotificationsGateway } from "./notifications.gateway"; +import { WsAuthService } from "./ws-auth.service"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Notification, User, Session]), + // ExternalProfileRepository + CompanyProfileRepository (portal targeting) + CompaniesModule, + // BackofficeService.getOrganizationEmployees (staff targeting) + BackofficeModule, + // EmailClientService + SmsClientService (HIGH-priority fan-out) + NotificationsModule, + ], + controllers: [NotificationInboxController], + providers: [ + NotificationInboxRepository, + NotificationRecipientsService, + NotificationsGateway, + WsAuthService, + NotificationInboxService, + ], + exports: [NotificationInboxService], +}) +export class NotificationInboxModule {} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.repository.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.repository.ts new file mode 100644 index 000000000..a3842c9e3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.repository.ts @@ -0,0 +1,59 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { FindOptionsWhere, Repository } from "typeorm"; + +import { Notification } from "./entities/notification.entity"; + +@Injectable() +export class NotificationInboxRepository extends BaseRepository { + constructor( + @InjectRepository(Notification) + repo: Repository, + ) { + super(repo); + } + + /** Newest-first page of a recipient's notifications, optionally read-filtered. */ + async findForRecipient( + userId: string, + opts: { page?: number; limit?: number; isRead?: boolean } = {}, + ): Promise<[Notification[], number]> { + const page = opts.page && opts.page > 0 ? opts.page : 1; + const limit = opts.limit && opts.limit > 0 ? opts.limit : 20; + const where: FindOptionsWhere = { recipientUserId: userId }; + if (typeof opts.isRead === "boolean") { + where.isRead = opts.isRead; + } + return this.repository.findAndCount({ + where, + order: { createdAt: "DESC" }, + skip: (page - 1) * limit, + take: limit, + }); + } + + async countUnread(userId: string): Promise { + return this.repository.count({ + where: { recipientUserId: userId, isRead: false }, + }); + } + + /** Mark a single notification read (scoped to its recipient). Returns true if it changed. */ + async markRead(id: string, userId: string): Promise { + const result = await this.repository.update( + { id, recipientUserId: userId, isRead: false }, + { isRead: true, readAt: new Date() }, + ); + return (result.affected ?? 0) > 0; + } + + /** Mark all of a recipient's unread notifications read. Returns the count updated. */ + async markAllRead(userId: string): Promise { + const result = await this.repository.update( + { recipientUserId: userId, isRead: false }, + { isRead: true, readAt: new Date() }, + ); + return result.affected ?? 0; + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts new file mode 100644 index 000000000..97ee0380e --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts @@ -0,0 +1,239 @@ +import { + NotificationAudience, + NotificationChannels, + NotificationChannelsSent, + NotificationDto, + NotificationListResult, + NotificationPriority, + NotificationType, + NotifyInput, +} from "@edr/types"; +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { Repository } from "typeorm"; + +import { EmailClientService } from "../notifications/email-client.service"; +import { SmsClientService } from "../notifications/sms-client.service"; +import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto"; +import { Notification } from "./entities/notification.entity"; +import { NotificationInboxRepository } from "./notification-inbox.repository"; +import { NotificationRecipientsService } from "./notification-recipients.service"; +import { NotificationsGateway } from "./notifications.gateway"; + +/** + * The single entry point subsystems use for in-app notifications. Call + * {@link notify}; everything else (reads, mark-read) backs the REST controller. + * + * `notify` is deliberately fault-tolerant: it never throws into the caller so a + * notification failure can't roll back or break the business transaction that + * triggered it. Failures are logged. + */ +@Injectable() +export class NotificationInboxService { + private readonly logger = new Logger(NotificationInboxService.name); + + constructor( + private readonly repo: NotificationInboxRepository, + private readonly recipients: NotificationRecipientsService, + private readonly gateway: NotificationsGateway, + private readonly emailClient: EmailClientService, + private readonly smsClient: SmsClientService, + @InjectRepository(User) + private readonly users: Repository, + ) {} + + /** + * Fan a logical notification out to every resolved recipient: persist one row + * each, push it live over WebSocket, and (for HIGH priority) also queue + * email/SMS via the existing clients. + */ + async notify(input: NotifyInput): Promise { + try { + const userIds = await this.recipients.resolve(input.recipients); + if (userIds.length === 0) { + this.logger.debug( + `notify(${input.type}) resolved 0 recipients — skipped`, + ); + return; + } + const priority = input.priority ?? NotificationPriority.NORMAL; + + for (const userId of userIds) { + await this.deliverToUser(userId, input, priority); + } + } catch (err) { + this.logger.error( + `notify failed: ${(err as Error).message}`, + (err as Error).stack, + ); + } + } + + async list( + userId: string, + query: ListNotificationsQueryDto, + ): Promise { + const [items, count] = await this.repo.findForRecipient(userId, { + page: query.page, + limit: query.limit, + isRead: query.isRead, + }); + const unreadCount = await this.repo.countUnread(userId); + return { items: items.map((n) => this.toDto(n)), count, unreadCount }; + } + + async unreadCount(userId: string): Promise<{ unreadCount: number }> { + return { unreadCount: await this.repo.countUnread(userId) }; + } + + async markRead( + id: string, + userId: string, + ): Promise<{ success: boolean; unreadCount: number }> { + const success = await this.repo.markRead(id, userId); + const unreadCount = await this.repo.countUnread(userId); + this.gateway.emitUnreadCount(userId, unreadCount); + return { success, unreadCount }; + } + + async markAllRead( + userId: string, + ): Promise<{ updated: number; unreadCount: number }> { + const updated = await this.repo.markAllRead(userId); + const unreadCount = await this.repo.countUnread(userId); + this.gateway.emitUnreadCount(userId, unreadCount); + return { updated, unreadCount }; + } + + /** [dev/verification only] Send a canned notification straight to one user. */ + async sendTestToUser( + userId: string, + body: { + audience?: NotificationAudience; + type?: NotificationType; + priority?: NotificationPriority; + title?: string; + message?: string; + }, + ): Promise { + const entity = await this.repo.create({ + recipientUserId: userId, + audience: body.audience ?? NotificationAudience.BACKOFFICE, + type: body.type ?? NotificationType.GENERIC, + title: body.title ?? "Test notification", + body: body.message ?? "This is a test in-app notification.", + priority: body.priority ?? NotificationPriority.NORMAL, + isRead: false, + }); + const dto = this.toDto(entity); + this.gateway.emitNew(userId, dto, await this.repo.countUnread(userId)); + return dto; + } + + private async deliverToUser( + userId: string, + input: NotifyInput, + priority: NotificationPriority, + ): Promise { + const entity = await this.repo.create({ + recipientUserId: userId, + audience: input.audience, + type: input.type, + title: input.title, + body: input.body, + link: input.link ?? null, + data: input.data ?? null, + priority, + isRead: false, + }); + + const unreadCount = await this.repo.countUnread(userId); + this.gateway.emitNew(userId, this.toDto(entity), unreadCount); + + const channels = this.resolveChannels(input, priority); + if (channels.email || channels.sms) { + const channelsSent = await this.fanOut(userId, input, channels); + if (channelsSent) { + await this.repo.update(entity.id, { channelsSent }); + } + } + } + + /** + * Decide which outbound channels to use. An explicit `input.channels` + * selection wins; otherwise fall back to priority (HIGH ⇒ email + SMS). + */ + private resolveChannels( + input: NotifyInput, + priority: NotificationPriority, + ): Required { + if (input.channels) { + return { + email: input.channels.email === true, + sms: input.channels.sms === true, + }; + } + const high = priority === NotificationPriority.HIGH; + return { email: high, sms: high }; + } + + /** + * Best-effort email/SMS fan-out for the requested channels. Skips a channel + * the recipient has no address for. Never throws. + */ + private async fanOut( + userId: string, + input: NotifyInput, + channels: Required, + ): Promise { + try { + const user = await this.users.findOne({ + where: { id: userId } as never, + }); + if (!user) return null; + + const sent: NotificationChannelsSent = {}; + const text = `${input.title}\n\n${input.body}`; + + if (channels.email && user.email) { + const res = await this.emailClient.sendEmail({ + to: user.email, + subject: input.title, + text, + }); + sent.email = res.queued; + } + if (channels.sms && user.phoneNumber) { + const res = await this.smsClient.sendSms({ + to: user.phoneNumber, + message: text, + }); + sent.sms = res.queued; + } + return Object.keys(sent).length ? sent : null; + } catch (err) { + this.logger.warn( + `fan-out failed for user ${userId}: ${(err as Error).message}`, + ); + return null; + } + } + + private toDto(n: Notification): NotificationDto { + return { + id: n.id, + recipientUserId: n.recipientUserId, + audience: n.audience, + type: n.type, + title: n.title, + body: n.body, + link: n.link ?? null, + data: n.data ?? null, + priority: n.priority, + isRead: n.isRead, + readAt: n.readAt ? new Date(n.readAt).toISOString() : null, + createdAt: new Date(n.createdAt).toISOString(), + }; + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts new file mode 100644 index 000000000..be7964a3d --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts @@ -0,0 +1,87 @@ +import { NotificationRecipients } from "@edr/types"; +import { Injectable, Logger } from "@nestjs/common"; + +import { BackofficeService } from "../backoffice/backoffice.service"; +import { CompanyProfileRepository } from "../companies/company-profile.repository"; +import { ExternalProfileRepository } from "../companies/external-profile.repository"; + +/** + * Turns a {@link NotificationRecipients} selector into a de-duplicated set of + * IAM user ids. + * + * - `userIds` → honored as-is. + * - `companyId` → all portal users linked to the company (external_profiles). + * - `companyProfileId` → resolved to its company, then to that company's users. + * - `organizationId` → all current employees of the org (backoffice staff). + * + * NOTE: permission-scoped staff targeting is intentionally unsupported — freight + * has no "users-by-permission" lookup. Target explicit userIds or an org instead. + */ +@Injectable() +export class NotificationRecipientsService { + private readonly logger = new Logger(NotificationRecipientsService.name); + + constructor( + private readonly externalProfiles: ExternalProfileRepository, + private readonly companyProfiles: CompanyProfileRepository, + private readonly backoffice: BackofficeService, + ) {} + + async resolve(recipients: NotificationRecipients): Promise { + const ids = new Set(); + + for (const id of recipients.userIds ?? []) { + if (id) ids.add(id); + } + + let companyId = recipients.companyId; + if (!companyId && recipients.companyProfileId) { + const profile = await this.companyProfiles.findById( + recipients.companyProfileId, + ); + companyId = profile?.companyId ?? undefined; + } + if (companyId) { + const profiles = await this.externalProfiles.findByCompanyId(companyId); + for (const p of profiles) { + if (p.userId) ids.add(p.userId); + } + } + + if (recipients.organizationId) { + try { + const { items } = await this.backoffice.getOrganizationEmployees( + recipients.organizationId, + {}, + ); + for (const employee of items as Array<{ + user?: { id?: string }; + userId?: string; + }>) { + const uid = employee?.user?.id ?? employee?.userId; + if (uid) ids.add(uid); + } + } catch (err) { + this.logger.warn( + `Failed to resolve org recipients for ${recipients.organizationId}: ${ + (err as Error).message + }`, + ); + } + } + + if (recipients.allBackoffice) { + try { + for (const uid of await this.backoffice.getAllCurrentEmployeeUserIds()) { + ids.add(uid); + } + } catch (err) { + this.logger.warn( + `Failed to resolve allBackoffice recipients: ${(err as Error).message}`, + ); + } + } + + return [...ids]; + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts new file mode 100644 index 000000000..c14dcbaa5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts @@ -0,0 +1,75 @@ +import { + NOTIFICATION_WS_EVENTS, + NOTIFICATION_WS_NAMESPACE, + NotificationDto, +} from "@edr/types"; +import { Logger } from "@nestjs/common"; +import { + OnGatewayConnection, + WebSocketGateway, + WebSocketServer, +} from "@nestjs/websockets"; +import { Server, Socket } from "socket.io"; + +import { WsAuthService } from "./ws-auth.service"; + +/** + * Server → client push for in-app notifications. Clients only *listen* (no + * `@SubscribeMessage` handlers), so the global HTTP JwtGuard never applies here; + * the handshake is authenticated in `handleConnection` and each socket joins a + * private `user:` room the service targets. + */ +@WebSocketGateway({ + namespace: NOTIFICATION_WS_NAMESPACE, + cors: { origin: true, credentials: true }, +}) +export class NotificationsGateway implements OnGatewayConnection { + private readonly logger = new Logger(NotificationsGateway.name); + + @WebSocketServer() + private readonly server!: Server; + + constructor(private readonly wsAuth: WsAuthService) {} + + async handleConnection(socket: Socket): Promise { + const userId = await this.wsAuth.resolveUserId(this.extractToken(socket)); + if (!userId) { + this.logger.debug(`Rejected notifications handshake ${socket.id}`); + socket.disconnect(true); + return; + } + socket.data.userId = userId; + await socket.join(this.room(userId)); + } + + /** Push a freshly-created notification + the new unread count to a user. */ + emitNew(userId: string, notification: NotificationDto, unreadCount: number): void { + const room = this.server.to(this.room(userId)); + room.emit(NOTIFICATION_WS_EVENTS.NEW, notification); + room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount); + } + + /** Push only an updated unread count (e.g. after a read on another tab). */ + emitUnreadCount(userId: string, unreadCount: number): void { + this.server + .to(this.room(userId)) + .emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount); + } + + private room(userId: string): string { + return `user:${userId}`; + } + + private extractToken(socket: Socket): string | undefined { + const authToken = socket.handshake.auth?.token as string | undefined; + if (authToken) return authToken; + + const queryToken = socket.handshake.query?.token; + if (typeof queryToken === "string") return queryToken; + + const header = socket.handshake.headers?.authorization; + if (header?.startsWith("Bearer ")) return header.slice(7); + + return undefined; + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/ws-auth.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/ws-auth.service.ts new file mode 100644 index 000000000..11c178e31 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/ws-auth.service.ts @@ -0,0 +1,51 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { verifyToken } from "@tria-plc/api-common/utils/token"; +import { ESessionStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity"; + +/** + * Authenticates a WebSocket handshake by mirroring the HTTP JwtGuard: the access + * token payload is only a *session* pointer (`{ id: }`), not the + * user — so we verify the signature (`verifyToken`), then load the IAM session + * and require it to be ACTIVE and unexpired, and read the real user id out of + * `session.userInfo`. There is no context-free verifier in the auth package, so + * this lookup is unavoidable; using the typed `Session` entity (rather than raw + * SQL) keeps it column-rename-safe and consistent with the package's own model. + * + * Returns the IAM user id, or null for any invalid/expired/revoked/malformed token. + */ +@Injectable() +export class WsAuthService { + private readonly logger = new Logger(WsAuthService.name); + + constructor( + @InjectRepository(Session) + private readonly sessions: Repository, + ) {} + + async resolveUserId(token?: string): Promise { + if (!token) return null; + try { + const payload = verifyToken(token) as { id?: string }; + const sessionId = payload?.id; + if (!sessionId) return null; + + const session = await this.sessions.findOne({ + where: { id: sessionId }, + }); + if (!session) return null; + if (session.status !== ESessionStatus.ACTIVE) return null; + if (!session.expiryTime || new Date(session.expiryTime) <= new Date()) { + return null; + } + + return session.userInfo?.id ?? null; + } catch (err) { + this.logger.debug(`WS auth rejected: ${(err as Error).message}`); + return null; + } + } +} diff --git a/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts b/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts new file mode 100644 index 000000000..79a6547bc --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +export class SendEmailDto { + @ApiProperty({ + description: "Recipient email address", + example: "customer@example.com", + }) + @IsEmail() + @IsNotEmpty() + to!: string; + + @ApiProperty({ + description: "Email subject", + example: "Your EDR Freight verification code", + }) + @IsString() + @IsNotEmpty() + subject!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + text?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + html?: string; +} diff --git a/apps/edr-freight-api/src/modules/notifications/email-client.service.ts b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts new file mode 100644 index 000000000..161b2486a --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts @@ -0,0 +1,51 @@ +import { + Inject, + Injectable, + Logger, + OnApplicationBootstrap, +} from "@nestjs/common"; +import { ClientProxy } from "@nestjs/microservices"; +import { SendEmailDto } from "./dtos/email.dto"; + +@Injectable() +export class EmailClientService implements OnApplicationBootstrap { + private readonly logger = new Logger(EmailClientService.name); + + constructor( + @Inject("EMAIL_SERVICE") + private readonly emailClient: ClientProxy, + ) {} + + private readonly enabled = process.env.RABBITMQ_ENABLED !== "false"; + + async onApplicationBootstrap() { + if (!this.enabled) return; + this.emailClient + .connect() + .then(() => this.logger.log("connected to Email service")) + .catch((err) => { + console.error("Error happened at Email service", err); + }); + } + + async sendEmail(dto: SendEmailDto): Promise<{ queued: boolean }> { + if (!this.enabled) { + this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`); + return { queued: false }; + } + this.emailClient.emit("send-email", { + to: dto.to, + subject: dto.subject, + text: dto.text, + html: dto.html, + appKey: "IFHCRS-LICENSE-MANAGEMENT", + }); + // Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery. + this.logger.log( + `EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`, + ); + // Recipient + content are PII — debug only. + this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`); + return { queued: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts index 663f931ef..16d598eb0 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts @@ -4,9 +4,15 @@ import { ClientsModule, Transport } from "@nestjs/microservices"; import { NotificationsService } from "./notifications.service"; import { SmsClientService } from "./sms-client.service"; +import { EmailClientService } from "./email-client.service"; import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; +// Fall back to a sane broker URL so an unset RABBITMQ_URL can't produce +// `urls: [undefined]` (which crashes amqp-connection-manager on 'heartbeat'). +const RABBITMQ_URL = + process.env.RABBITMQ_URL ?? process.env.PAYMENT_RABBITMQ_URL ?? "amqp://localhost:5672"; + @Module({ imports: [ ConfigModule, @@ -15,15 +21,30 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy" name: "SMS_SERVICE", transport: Transport.RMQ, options: { - urls: [process.env.RABBITMQ_URL as string], + urls: [RABBITMQ_URL], queue: process.env.SMS_QUEUE ?? "sms_queue", queueOptions: { durable: true }, }, }, + { + name: "EMAIL_SERVICE", + transport: Transport.RMQ, + options: { + urls: [RABBITMQ_URL], + queue: process.env.EMAIL_QUEUE ?? "email_queue", + queueOptions: { durable: true }, + }, + }, ]), ], controllers: [], - providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService], - exports: [NotificationsService, SmsClientService], + providers: [ + EmailNotificationStrategy, + SmsNotificationStrategy, + NotificationsService, + SmsClientService, + EmailClientService, + ], + exports: [NotificationsService, SmsClientService, EmailClientService], }) export class NotificationsModule {} diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts new file mode 100644 index 000000000..9d7f32c3d --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts @@ -0,0 +1,37 @@ +import { DataSource } from 'typeorm'; + +import { NotificationsService } from './notifications.service'; + +/** + * Best-effort SMS + email fan-out to a company's contacts. Looks up the + * company's phone/email and sends the message over both channels, swallowing + * per-channel failures so a missing provider never breaks the caller's flow. + */ +export async function sendCompanyChannels( + dataSource: DataSource, + notifications: NotificationsService, + companyId: string, + message: string, +): Promise { + const [contact]: Array<{ phone: string | null; email: string | null }> = + await dataSource.query( + `SELECT COALESCE(phone, etrade_phone) AS phone, email + FROM freight.companies + WHERE id = $1 AND deleted_at IS NULL`, + [companyId], + ); + if (contact?.phone) { + try { + await notifications.directSend('sms', contact.phone, message); + } catch { + /* best-effort: SMS provider unavailable */ + } + } + if (contact?.email) { + try { + await notifications.directSend('email', contact.email, message); + } catch { + /* best-effort: email provider unavailable */ + } + } +} diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.ts index 5850cbb1a..155657a74 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.controller.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.ts @@ -1,15 +1,24 @@ // otp.controller.ts import { + BadRequestException, Body, Controller, Post, } from "@nestjs/common"; -import { OtpService } from "./otp.service"; +import { OtpService, OtpTarget } from "./otp.service"; import { Public } from "@edr/api-common"; +// Exactly one of phone/email must be present per request — the channel the +// code is sent through / checked against. +function toTarget(phone?: string, email?: string): OtpTarget { + if (email) return { email }; + if (phone) return { phone }; + throw new BadRequestException("phone or email is required"); +} + @Controller("otp") @Public() export class OtpController { @@ -24,9 +33,12 @@ export class OtpController { @Post("send") async sendOtp( @Body("phone") - phone: string + phone?: string, + + @Body("email") + email?: string ) { - return this.otpService.sendOtp(phone); + return this.otpService.sendOtp(toTarget(phone, email)); } // --------------------------------------------------------------------------- @@ -36,13 +48,16 @@ export class OtpController { @Post("verify") async verifyOtp( @Body("phone") - phone: string, + phone: string | undefined, + + @Body("email") + email: string | undefined, @Body("otp") otp: string ) { return this.otpService.verifyOtp( - phone, + toTarget(phone, email), otp ); } diff --git a/apps/edr-freight-api/src/modules/otp/otp.entity.ts b/apps/edr-freight-api/src/modules/otp/otp.entity.ts index f5900f6b8..f661bb662 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.entity.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.entity.ts @@ -7,13 +7,27 @@ import { import { BaseEntity } from "@edr/api-common"; @Entity({ + // Table lives in the freight schema like every other freight entity. Without + // this the entity inherits the DataSource default schema (public), so TypeORM + // queries public.otp_verifications — which doesn't exist — and OTP verify + // (e.g. the contract-signature sudo gate) fails with a 500 QueryFailedError. + schema: "freight", name: "otp_verifications", }) export class OtpVerification extends BaseEntity{ + // Exactly one of phone/email is set per row — the channel the code was sent + // through. @Column({ unique: true, + nullable: true, }) - phone!: string; + phone?: string; + + @Column({ + unique: true, + nullable: true, + }) + email?: string; @Column() otp!: string; diff --git a/apps/edr-freight-api/src/modules/otp/otp.module.ts b/apps/edr-freight-api/src/modules/otp/otp.module.ts index ec1d9f9ed..511fe4bbb 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.module.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.module.ts @@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module"; exports: [ OtpRepository, + OtpService, ], }) export class OtpModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.repository.ts b/apps/edr-freight-api/src/modules/otp/otp.repository.ts index 8aa69dcd6..7abd434d8 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.repository.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.repository.ts @@ -31,17 +31,44 @@ export class OtpRepository { }); } + // --------------------------------------------------------------------------- + // Find By Email + // --------------------------------------------------------------------------- + + async findByEmail( + email: string + ) { + return this.repository.findOne({ + where: { + email, + }, + }); + } + + // --------------------------------------------------------------------------- + // Find By Target (either channel) + // --------------------------------------------------------------------------- + + async findByTarget( + target: { phone?: string; email?: string } + ) { + return target.email + ? this.findByEmail(target.email) + : this.findByPhone(target.phone!); + } + // --------------------------------------------------------------------------- // Create OTP // --------------------------------------------------------------------------- async createOtp( - phone: string, + target: { phone?: string; email?: string }, otp: string ) { const entity = this.repository.create({ - phone, + phone: target.phone, + email: target.email, otp, verified: false, }); @@ -70,10 +97,10 @@ export class OtpRepository { } // --------------------------------------------------------------------------- - // Verify Phone + // Mark Verified // --------------------------------------------------------------------------- - async verifyPhone( + async markVerified( otpVerification: OtpVerification ) { otpVerification.verified = @@ -83,4 +110,18 @@ export class OtpRepository { otpVerification ); } + + // --------------------------------------------------------------------------- + // Delete OTP (single-use consume) + // --------------------------------------------------------------------------- + + // Hard delete so the unique `phone` row is freed and a fresh code can be + // requested for the same number on the next action. + async deleteOtp( + otpVerification: OtpVerification + ) { + return this.repository.remove( + otpVerification + ); + } } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index ffa9c4e68..e26ed35c9 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -1,80 +1,86 @@ // otp.service.ts -import { - BadRequestException, - Injectable, -} from "@nestjs/common"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { OtpRepository } from "./otp.repository"; import { SmsClientService } from "../notifications/sms-client.service"; +import { EmailClientService } from "../notifications/email-client.service"; + +// Exactly one of phone/email is set — enforced by the controller before it +// reaches here. +export type OtpTarget = { phone?: string; email?: string }; @Injectable() export class OtpService { + logger = new Logger(OtpService.name); constructor( private readonly otpRepository: OtpRepository, - private readonly smsClient: SmsClientService - ) {} + private readonly smsClient: SmsClientService, + private readonly emailClient: EmailClientService, + ) { } // --------------------------------------------------------------------------- // Generate OTP // --------------------------------------------------------------------------- generateOtp(): string { - return Math.floor( - 100000 + Math.random() * 900000 - ).toString(); + return Math.floor(100000 + Math.random() * 900000).toString(); } // --------------------------------------------------------------------------- // Send OTP // --------------------------------------------------------------------------- - async sendOtp(phone: string) { + async sendOtp(target: OtpTarget) { try { // The verification code is generated server-side — never supplied by the // caller — so the OTP stays a secret known only to the server and the - // recipient of the SMS. + // recipient of the SMS/email. const otp = this.generateOtp(); - // find existing phone - const existingPhone = - await this.otpRepository.findByPhone( - phone - ); + // find existing row for this channel + const existing = await this.otpRepository.findByTarget(target); // update existing otp - if (existingPhone) { - await this.otpRepository.updateOtp( - existingPhone, - otp - ); + if (existing) { + await this.otpRepository.updateOtp(existing, otp); } else { // create new otp - await this.otpRepository.createOtp( - phone, - otp - ); + await this.otpRepository.createOtp(target, otp); } - // send sms (queued to RabbitMQ via the shared SMS service) - await this.smsClient.sendSms({ - to: phone, - message: `Your verification code is ${otp}`, - }); + if (target.email) { + // send email (queued to RabbitMQ via the shared Email service) + await this.emailClient.sendEmail({ + to: target.email, + subject: "Your EDR Freight verification code", + text: `Your verification code is ${otp}`, + }); + } else { + // send sms (queued to RabbitMQ via the shared SMS service) + await this.smsClient.sendSms({ + to: target.phone as string, + message: `Your verification code is ${otp}`, + }); + } + this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`); return { success: true, - message: - "OTP sent successfully", + message: "OTP sent successfully", }; } catch (error) { - console.log(error); - - throw new BadRequestException( - "Failed to send OTP" + // Log the real cause (DB/SMS/email failure) with its stack so a deployed + // "Failed to send OTP" 400 is diagnosable from the API logs, not opaque. + this.logger.error( + `Failed to send OTP to ${target.email ?? target.phone}: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined, ); + throw new BadRequestException("Failed to send OTP"); } } @@ -82,40 +88,70 @@ export class OtpService { // Verify OTP // --------------------------------------------------------------------------- - async verifyOtp( - phone: string, - otp: string - ) { - // find phone - const otpData = - await this.otpRepository.findByPhone( - phone - ); + async verifyOtp(target: OtpTarget, otp: string) { + // find the channel's row + const otpData = await this.otpRepository.findByTarget(target); - // phone not found + // not found if (!otpData) { throw new BadRequestException( - "Phone number not found" + target.email ? "Email address not found" : "Phone number not found", ); } // invalid otp if (otpData.otp !== otp) { - throw new BadRequestException( - "Invalid OTP" - ); + throw new BadRequestException("Invalid OTP"); } - // verify phone - await this.otpRepository.verifyPhone( - otpData - ); + // mark verified + await this.otpRepository.markVerified(otpData); return { success: true, - message: - "Phone verified successfully", + message: target.email + ? "Email verified successfully" + : "Phone verified successfully", }; } -} \ No newline at end of file + + // --------------------------------------------------------------------------- + // Verify OTP for a sensitive action (sudo mode) + // --------------------------------------------------------------------------- + + // Fresh, single-use challenge gating a sensitive action (e.g. applying a + // contract signature). Unlike verifyOtp above — which marks a phone verified + // and leaves the code in place — this enforces a short TTL and consumes the + // code on success so it can never be replayed. + private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000; + + async verifyOtpForAction(phone: string, otp: string) { + const otpData = await this.otpRepository.findByPhone(phone); + + if (!otpData) { + throw new BadRequestException( + "No verification code was requested for this phone", + ); + } + + const ageMs = Date.now() - new Date(otpData.updatedAt).getTime(); + + if (ageMs > this.ACTION_OTP_TTL_MS) { + await this.otpRepository.deleteOtp(otpData); + + throw new BadRequestException( + "Verification code has expired. Request a new one.", + ); + } + + if (otpData.otp !== otp) { + throw new BadRequestException("Invalid verification code"); + } + + // single-use: consume on success + await this.otpRepository.deleteOtp(otpData); + + return { success: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index 0db38a751..0fc5a6ba5 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -1,35 +1,39 @@ import { - Body, - Controller, - HttpCode, - HttpStatus, - Post, - UseGuards, + Body, + Controller, + HttpCode, + HttpStatus, + Logger, + Post, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; -import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; +import { Public } from "@edr/api-common"; import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto"; import { PaymentService } from "./payment.service"; /** * Consumer side of the payment microservice's outbox relay. - * Only the payment service may call this (shared SERVICE_AUTH_TOKEN). + * WARNING: currently unauthenticated — anyone who can reach the API can mark + * payments as paid. Re-add ServiceAuthGuard before exposing beyond a trusted network. * Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless. * Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available; * this HTTP endpoint remains as a transport-agnostic fallback. */ @ApiTags("Internal Payments") -@UseGuards(ServiceAuthGuard) +@Public() @Controller("internal/payments") export class InternalPaymentController { - constructor(private readonly paymentService: PaymentService) { } + private readonly logger = new Logger(InternalPaymentController.name); + constructor(private readonly paymentService: PaymentService) { } - @Post("mark-paid") - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", - }) - async markPaid(@Body() event: PaymentEventDto): Promise { - return this.paymentService.handlePaymentEvent(event); - } + @Post("mark-paid") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: + "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", + }) + async markPaid(@Body() event: PaymentEventDto): Promise { + this.logger.log(`Marking payment ${event} as PAID`); + return this.paymentService.handlePaymentEvent(event); + } } diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index b1b269665..50856c3d7 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -6,8 +6,6 @@ import { ParseUUIDPipe, Query, Res, - Body, - Post, } from "@nestjs/common"; import { ApiTags, @@ -18,9 +16,9 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; -import { BookingView, FreightAdmin } from "../../common/booking-guards"; +import { BookingView } from "../../common/booking-guards"; import { PaymentService } from "./payment.service"; -import { IntentStatusDto, RefundDto } from "./payments.dto"; +import { IntentStatusDto } from "./payments.dto"; @ApiTags("Payment") @Controller("payments") @@ -73,13 +71,6 @@ export class PaymentController { return this.paymentService.getIntentByBookingId(bookingId); } - @Post("refund") - @FreightAdmin() - @ApiOperation({ summary: "Refund a paid booking (staff/admin only)" }) - refund(@Body() dto: RefundDto) { - return this.paymentService.refund(dto); - } - @Get("receipt/:orderId") @Public() @ApiOperation({ summary: "Generate a payment receipt HTML page" }) diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 330521218..05267746d 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -13,6 +13,8 @@ import { import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { BillingModule } from "../billing/billing.module"; +// import { FirstMileModule } from "../first-mile/first-mile.module"; +// import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; import { PaymentRefundEntity } from "./entities/payment-refund.entity"; import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; import { PaymentEntity } from "./entities/payment.entity"; @@ -57,6 +59,8 @@ function rabbitMQImport(): DynamicModule[] { HttpModule.register({ timeout: 10_000 }), ConfigModule, forwardRef(() => BillingModule), + // forwardRef(() => TrainSchedulingModule), + // FirstMileModule, TypeOrmModule.forFeature([ PaymentEntity, PaymentWebhookEventEntity, diff --git a/apps/edr-freight-api/src/modules/payment/payment.repository.ts b/apps/edr-freight-api/src/modules/payment/payment.repository.ts index 25c3bdd6b..96a4994ea 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.repository.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.repository.ts @@ -93,9 +93,8 @@ export class PaymentRepository { p.paid_at, p.created_at FROM freight.payments p - JOIN freight.bookings b ON b.id = p.ref_id + JOIN freight.bookings b ON b.id = p.ref_id::uuid WHERE b.company_id = $1 - AND p.deleted_at IS NULL AND b.deleted_at IS NULL ORDER BY p.created_at DESC`, [companyId], diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 347fedc1e..5b8a3ddca 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -1,13 +1,12 @@ import { - BadRequestException, - forwardRef, - Inject, - Injectable, - InternalServerErrorException, - Logger, - NotFoundException, + BadRequestException, + forwardRef, + Inject, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, } from "@nestjs/common"; -import { DataSource } from "typeorm"; import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; import { PaymentClientService } from "./payment-client.service"; @@ -16,75 +15,70 @@ import { BillingService } from "../billing/billing.service"; import * as fs from "fs"; import * as path from "path"; import * as Handlebars from "handlebars"; -import { Booking } from "../bookings/entities/booking.entity"; +import { ClientAction, ProviderPaymentStatus } from "@edr/payment-providers"; import { - ClientAction, - ProviderPaymentStatus, -} from "@edr/payment-providers"; -import { - PaymentService as PaymentServiceEnum, - PaymentReferenceType, - PaymentIntentSnapshot, - ProviderMethod, + PaymentService as PaymentServiceEnum, + PaymentReferenceType, + PaymentIntentSnapshot, + ProviderMethod, } from "@edr/types"; import { - InitiateResponseDto, - IntentStatusDto, - PaymentPlatformDto, - RefundDto, + InitiateResponseDto, + IntentStatusDto, + PaymentPlatformDto, } from "./payments.dto"; /** Everything the gateway needs to open an intent. Amount/currency are supplied by * the caller (billing) — this service never derives them from a domain record. */ export interface InitiateIntentInput { - /** Opaque domain reference (booking id, …). */ - referenceId: string; - /** Invoice source that owns the intent ('booking', …) — stored on the projection. */ - source: string; - /** Gateway reference type the intent is opened with (caller's domain decides it). */ - referenceType: PaymentReferenceType; - /** Human-readable order ref shown on provider pages. */ - orderRef: string; - /** Authoritative amount in minor units, computed by the caller. */ - amountMinor: number; - currency: string; - /** Stored on the intent projection for receipts/dashboards. */ - reason?: string; - /** Provider/method selector. */ - method: ProviderMethod | string; - platform?: PaymentPlatformDto; - payerAccount?: string; - returnUrl?: string; - failureUrl?: string; + /** Opaque domain reference (booking id, …). */ + referenceId: string; + /** Invoice source that owns the intent ('booking', …) — stored on the projection. */ + source: string; + /** Gateway reference type the intent is opened with (caller's domain decides it). */ + referenceType: PaymentReferenceType; + /** Human-readable order ref shown on provider pages. */ + orderRef: string; + /** Authoritative amount in minor units, computed by the caller. */ + amountMinor: number; + currency: string; + /** Stored on the intent projection for receipts/dashboards. */ + reason?: string; + /** Provider/method selector. */ + method: ProviderMethod | string; + platform?: PaymentPlatformDto; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; } export interface InitiateIntentResult { - intentId: string; - response: InitiateResponseDto; - /** True when the provider settled the charge synchronously during initiate. */ - immediateSuccess: boolean; - providerTxnId?: string; - paidAt?: Date; + intentId: string; + response: InitiateResponseDto; + /** True when the provider settled the charge synchronously during initiate. */ + immediateSuccess: boolean; + providerTxnId?: string; + paidAt?: Date; } const STATUS_MAP: Record = { - "action-required": ProviderPaymentStatus.REQUIRES_ACTION, - "processing": ProviderPaymentStatus.PROCESSING, - "success": ProviderPaymentStatus.SUCCEEDED, - "failed": ProviderPaymentStatus.FAILED, - "canceled": ProviderPaymentStatus.CANCELLED, - "refunded": ProviderPaymentStatus.CANCELLED, + "action-required": ProviderPaymentStatus.REQUIRES_ACTION, + processing: ProviderPaymentStatus.PROCESSING, + success: ProviderPaymentStatus.SUCCEEDED, + failed: ProviderPaymentStatus.FAILED, + canceled: ProviderPaymentStatus.CANCELLED, + refunded: ProviderPaymentStatus.CANCELLED, }; const PROVIDER_TO_METHOD: Record = { - TELEBIRR: "telebirr", - CBE_BIRR: "cbe-birr", - EBIRR: "ebirr", - WAAFI: "waafi", - CARD: "card", - DMONEY: "dmoney", - CAC_BANK: "cac-bank", + TELEBIRR: "telebirr", + CBE_BIRR: "cbe-birr", + EBIRR: "ebirr", + WAAFI: "waafi", + CARD: "card", + DMONEY: "dmoney", + CAC_BANK: "cac-bank", }; /** @@ -96,426 +90,464 @@ const PROVIDER_TO_METHOD: Record = { */ @Injectable() export class PaymentService { - private readonly logger = new Logger(PaymentService.name); + private readonly logger = new Logger(PaymentService.name); - constructor( - private readonly datasource: DataSource, - private readonly paymentRepo: PaymentRepository, - private readonly paymentClient: PaymentClientService, - @Inject(forwardRef(() => BillingService)) - private readonly billing: BillingService, - ) { } + constructor( + private readonly paymentRepo: PaymentRepository, + private readonly paymentClient: PaymentClientService, + @Inject(forwardRef(() => BillingService)) + private readonly billing: BillingService, + ) { } - async getAll(filters: { - search?: string; - status?: string; - method?: string; - page?: number; - pageSize?: number; - }) { - const { search, status, method, page = 1, pageSize = 10 } = filters; - const skip = (page - 1) * pageSize; + async getAll(filters: { + search?: string; + status?: string; + method?: string; + page?: number; + pageSize?: number; + }) { + const { search, status, method, page = 1, pageSize = 10 } = filters; + const skip = (page - 1) * pageSize; - const qb = this.paymentRepo.createQueryBuilder("payment"); + const qb = this.paymentRepo.createQueryBuilder("payment"); - if (search) { - qb.andWhere( - "(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)", - { search: `%${search}%` }, - ); - } - if (status) { - qb.andWhere("payment.status = :status", { status }); - } - if (method) { - qb.andWhere("payment.method = :method", { method }); - } - - const [items, total] = await qb - .orderBy("payment.createdAt", "DESC") - .skip(skip) - .take(pageSize) - .getManyAndCount(); - - return { - items: items.map((p) => ({ - id: p.id, - bookingId: p.refId, - amount: p.amount, - currency: p.currency, - method: p.method, - status: p.status, - merchantOrderId: p.merchantOrderId, - paidAt: p.paidAt, - createdAt: p.createdAt, - })), - total, - page, - pageSize, - }; + if (search) { + qb.andWhere( + "(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)", + { search: `%${search}%` }, + ); + } + if (status) { + qb.andWhere("payment.status = :status", { status }); + } + if (method) { + qb.andWhere("payment.method = :method", { method }); } - /** Aggregate counts across ALL payments for the dashboard summary cards. */ - async getSummary() { - const rows = await this.paymentRepo - .createQueryBuilder("payment") - .select("payment.status", "status") - .addSelect("COUNT(*)::int", "count") - .groupBy("payment.status") - .getRawMany<{ status: string; count: number }>(); + const [items, total] = await qb + .orderBy("payment.createdAt", "DESC") + .skip(skip) + .take(pageSize) + .getManyAndCount(); - const byStatus: Record = {}; - let total = 0; - for (const row of rows) { - byStatus[row.status] = row.count; - total += row.count; - } + return { + items: items.map((p) => ({ + id: p.id, + bookingId: p.refId, + amount: p.amount, + currency: p.currency, + method: p.method, + status: p.status, + merchantOrderId: p.merchantOrderId, + paidAt: p.paidAt, + createdAt: p.createdAt, + })), + total, + page, + pageSize, + }; + } - const paidAgg = await this.paymentRepo - .createQueryBuilder("payment") - .select("COALESCE(SUM(payment.amount), 0)", "sum") - .where("payment.status = :status", { status: "success" }) - .getRawOne<{ sum: string }>(); + /** Aggregate counts across ALL payments for the dashboard summary cards. */ + async getSummary() { + const rows = await this.paymentRepo + .createQueryBuilder("payment") + .select("payment.status", "status") + .addSelect("COUNT(*)::int", "count") + .groupBy("payment.status") + .getRawMany<{ status: string; count: number }>(); - return { - total, - success: byStatus["success"] ?? 0, - processing: - (byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0), - failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0), - refunded: byStatus["refunded"] ?? 0, - paidAmount: Number(paidAgg?.sum ?? 0), - }; + const byStatus: Record = {}; + let total = 0; + for (const row of rows) { + byStatus[row.status] = row.count; + total += row.count; } - /** - * Open a gateway intent for a caller-supplied amount/reference and project it - * locally. Returns the intent id (so billing can correlate the invoice) plus - * the client action. When the provider settles synchronously, the intent is - * marked paid WITHOUT emitting — the caller (billing) settles inline after it - * has stored the intent id, avoiding a settle-before-correlation race. - */ - async initiate(input: InitiateIntentInput): Promise { - const snapshot = await this.paymentClient.initiate({ - service: PaymentServiceEnum.FREIGHT, - referenceType: input.referenceType, - referenceId: input.referenceId, - orderRef: input.orderRef, - amountMinor: input.amountMinor, - currency: input.currency, - provider: input.method as ProviderMethod, - platform: input.platform, - payerAccount: input.payerAccount, - returnUrl: input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", - failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", + const paidAgg = await this.paymentRepo + .createQueryBuilder("payment") + .select("COALESCE(SUM(payment.amount), 0)", "sum") + .where("payment.status = :status", { status: "success" }) + .getRawOne<{ sum: string }>(); + + return { + total, + success: byStatus["success"] ?? 0, + processing: + (byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0), + failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0), + refunded: byStatus["refunded"] ?? 0, + paidAmount: Number(paidAgg?.sum ?? 0), + }; + } + + /** + * Open a gateway intent for a caller-supplied amount/reference and project it + * locally. Returns the intent id (so billing can correlate the invoice) plus + * the client action. When the provider settles synchronously, the intent is + * marked paid WITHOUT emitting — the caller (billing) settles inline after it + * has stored the intent id, avoiding a settle-before-correlation race. + */ + async initiate(input: InitiateIntentInput): Promise { + try { + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.FREIGHT, + referenceType: PaymentReferenceType.SHIPMENT, + referenceId: input.referenceId, + orderRef: input.orderRef, + amountMinor: input.amountMinor, + currency: input.currency, + provider: input.method as ProviderMethod, + platform: input.platform, + payerAccount: input.payerAccount, + returnUrl: + input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", + failureUrl: + input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", + }); + + const immediateSuccess = + snapshot.status === ProviderPaymentStatus.SUCCEEDED; + const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; + + const intent = await this.upsertIntent(input, snapshot); + + if (immediateSuccess) { + // Settle the projection but DO NOT notify billing — billing settles + // inline once it has stored intentId on the invoice (see payInvoice), + // avoiding a settle-before-correlation race. + await this.markIntentSucceeded(intent.id, { + providerTxnId: snapshot.providerTxnId, + paidAt, + notify: false, }); + } - const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED; - const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; + return { + intentId: intent.id, + // `intent` still reflects the projection status ("processing" on immediate + // success — settlement is applied by the caller, not shown synchronously). + response: this.formatIntentResponse(intent), + immediateSuccess, + providerTxnId: snapshot.providerTxnId, + paidAt, + }; + } catch (err) { + console.log(err); + throw err; + } + } - const intent = await this.upsertIntent(input, snapshot); + /** Create or update the local intent projection from a provider snapshot. */ + private async upsertIntent( + input: InitiateIntentInput, + snapshot: PaymentIntentSnapshot, + ): Promise { + const existing = await this.paymentRepo.findOneBy({ + refId: input.referenceId, + }); - if (immediateSuccess) { - // Settle the projection but DO NOT notify billing — billing settles - // inline once it has stored intentId on the invoice (see payInvoice), - // avoiding a settle-before-correlation race. - await this.markIntentSucceeded(intent.id, { - providerTxnId: snapshot.providerTxnId, - paidAt, - notify: false, - }); - } + const method: PaymentEntity["method"] = + PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr"; + const status = + snapshot.status === ProviderPaymentStatus.SUCCEEDED + ? "processing" + : this.toLocalStatus(snapshot.status); + const clientAction = (snapshot.clientAction ?? undefined) as + | Record + | undefined; + const data = { + status, + method, + merchantOrderId: + snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "", + transactionId: snapshot.providerTxnId ?? existing?.transactionId, + expiresAt: snapshot.expiresAt + ? new Date(snapshot.expiresAt) + : existing?.expiresAt, + failerCode: snapshot.failureCode ?? undefined, + failureMessage: snapshot.failureMessage ?? undefined, + }; + + if (existing) { + await this.paymentRepo.update({ id: existing.id }, { + ...data, + clientAction, + } as any); + return { ...existing, ...data, clientAction } as PaymentEntity; + } + + return this.paymentRepo.create({ + refId: input.referenceId, + type: input.source, + referenceType: input.referenceType, + amount: input.amountMinor, + currency: input.currency as PaymentEntity["currency"], + reason: input.reason ?? `Payment for ${input.orderRef}`, + rawInitiation: snapshot as unknown as Record, + clientAction: clientAction ?? {}, + ...data, + } as any); + } + + /** + * Reconcile an intent's status with the gateway by reference. Read-only on the + * domain side: it syncs the local projection and, when the provider reports a + * newly-observed success, notifies billing to settle. `referenceId` is opaque + * (the booking id, but this service does not load it). + */ + async getIntentByBookingId(referenceId: string): Promise { + const local = await this.paymentRepo.findOneBy({ refId: referenceId }); + + let snapshot: PaymentIntentSnapshot | null = null; + try { + snapshot = await this.paymentClient.getIntentByReference( + (local?.referenceType as PaymentReferenceType) ?? + PaymentReferenceType.SHIPMENT, + referenceId, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `payment service lookup failed for reference ${referenceId}: ${message}; using local intent`, + ); + } + + if (!snapshot) { + if (!local) throw new NotFoundException("PaymentIntent not found"); + return this.formatIntentStatus(local); + } + if (!local) throw new NotFoundException("PaymentIntent not found"); + + // Sync local projection with provider-reported status. + const becameSuccess = + snapshot.status === ProviderPaymentStatus.SUCCEEDED && + local.status !== "success"; + + if (becameSuccess) { + await this.markIntentSucceeded(local.id, { + providerTxnId: snapshot.providerTxnId, + paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, + notify: true, + }); + } else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) { + await this.paymentRepo.update( + { id: local.id }, + { + status: this.toLocalStatus(snapshot.status), + failerCode: snapshot.failureCode ?? undefined, + failureMessage: snapshot.failureMessage ?? undefined, + }, + ); + } + + const refreshed = await this.paymentRepo.findOneBy({ id: local.id }); + return this.formatIntentStatus(refreshed ?? local); + } + + /** + * Mark a gateway intent paid and (by default) notify billing to settle the + * linked invoice. Idempotent — no-op when already success. Pass `notify: false` + * when the caller settles inline and will trigger settlement itself. + */ + async markIntentSucceeded( + intentId: string, + opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {}, + ): Promise<{ alreadyFinalized: boolean }> { + const intent = await this.paymentRepo.findOneBy({ id: intentId }); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + if (intent.status === "success") return { alreadyFinalized: true }; + + const paidAt = opts.paidAt ?? new Date(); + await this.paymentRepo.update( + { id: intent.id }, + { + status: "success", + paidAt, + transactionId: opts.providerTxnId ?? intent.transactionId, + }, + ); + + if (opts.notify !== false) { + await this.billing.settleByPaymentId( + intent.id, + opts.providerTxnId, + paidAt, + ); + } + + return { alreadyFinalized: false }; + } + + async markPaymentFailed(input: { + intentId: string; + failureCode?: string; + failureMessage?: string; + }): Promise { + const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + if (intent.status === "success" || intent.status === "canceled") return; + + await this.paymentRepo.update( + { id: intent.id }, + { + status: "failed", + failerCode: input.failureCode, + failureMessage: input.failureMessage, + }, + ); + + // Invoice stays open for retry — nothing to settle. Logged only. + this.logger.warn( + `Payment ${intent.id} failed for ${intent.refId}` + + (input.failureMessage ? `: ${input.failureMessage}` : ""), + ); + } + + async getActivePaymentByOrderIdAndMethod( + orderId: string, + method: PaymentEntity["method"], + ): Promise { + return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method); + } + + async genReceiptHtml(orderId: string) { + const payment = await this.paymentRepo.findOneBy({ + merchantOrderId: orderId, + status: "success", + }); + if (!payment) + throw new BadRequestException( + "No successful payment found for this order", + ); + + const filePath = path.join(__dirname, "templates", "receipt.hbs"); + if (!fs.existsSync(filePath)) throw new InternalServerErrorException(); + + const source = fs.readFileSync(filePath, "utf8"); + const template = Handlebars.compile(source); + return template({ + vendorName: "Ethio Djibouti Railway Freight Booking", + vendorAddress: "Addis Ababa", + receiptDate: payment.paidAt, + paymentMethod: payment.method, + subtotal: payment.amount.toString(), + total: payment.amount.toString(), + currency: payment.currency, + reason: payment.reason, + }); + } + + findBookingById(id: string) { + return this.paymentRepo.findOneBy({ refId: id }); + } + + formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { + const clientAction = + intent.clientAction && typeof intent.clientAction === "object" + ? (intent.clientAction as unknown as ClientAction) + : undefined; + return { + intentId: intent.id, + status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING, + clientAction, + merchantOrderId: intent.merchantOrderId ?? undefined, + }; + } + + private formatIntentStatus(intent: PaymentEntity): IntentStatusDto { + return { + ...this.formatIntentResponse(intent), + paidAt: intent.paidAt?.toISOString(), + failureCode: intent.failerCode ?? undefined, + failureMessage: intent.failureMessage ?? undefined, + }; + } + + async handlePaymentEvent(event: { + eventType: string; + eventId: string; + referenceId: string; + intentId: string; + providerTxnId?: string; + paidAt?: string; + failureCode?: string; + failureMessage?: string; + }): Promise<{ + processed: boolean; + alreadyFinalized?: boolean; + reason?: string; + }> { + this.logger.log(`Received payment event: ${JSON.stringify(event)}`); + if (event.eventType === "payment.succeeded") { + const intent = await this.paymentRepo.findOneBy({ + refId: event.referenceId, + }); + if (!intent) { return { - intentId: intent.id, - // `intent` still reflects the projection status ("processing" on immediate - // success — settlement is applied by the caller, not shown synchronously). - response: this.formatIntentResponse(intent), - immediateSuccess, - providerTxnId: snapshot.providerTxnId, - paidAt, + processed: false, + reason: `No local intent for reference ${event.referenceId}`, }; + } + const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { + providerTxnId: event.providerTxnId, + paidAt: event.paidAt ? new Date(event.paidAt) : undefined, + notify: true, + }); + this.logger.log( + `Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`, + ); + + // The payment service stays domain-agnostic: it settles the intent and + // lets billing settle the invoice (markIntentSucceeded → settleByPaymentId), + // which emits `${source}.invoice.paid`. Per-source advances (booking → PAID, + // warehouse → release, …) live in the domain services that listen for it. + return { processed: true, alreadyFinalized }; } - /** Create or update the local intent projection from a provider snapshot. */ - private async upsertIntent( - input: InitiateIntentInput, - snapshot: PaymentIntentSnapshot, - ): Promise { - const existing = await this.paymentRepo.findOneBy({ - refId: input.referenceId, - }); - - const method: PaymentEntity["method"] = - PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr"; - const status = - snapshot.status === ProviderPaymentStatus.SUCCEEDED - ? "processing" - : this.toLocalStatus(snapshot.status); - - const clientAction = (snapshot.clientAction ?? undefined) as - | Record - | undefined; - const data = { - status, - method, - merchantOrderId: snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "", - transactionId: snapshot.providerTxnId ?? existing?.transactionId, - expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : existing?.expiresAt, - failerCode: snapshot.failureCode ?? undefined, - failureMessage: snapshot.failureMessage ?? undefined, - }; - - if (existing) { - await this.paymentRepo.update({ id: existing.id }, { ...data, clientAction } as any); - return { ...existing, ...data, clientAction } as PaymentEntity; - } - - return this.paymentRepo.create({ - refId: input.referenceId, - type: input.source, - referenceType: input.referenceType, - amount: input.amountMinor, - currency: input.currency as PaymentEntity["currency"], - reason: input.reason ?? `Payment for ${input.orderRef}`, - rawInitiation: snapshot as unknown as Record, - clientAction: clientAction ?? {}, - ...data, - } as any); - } - - /** - * Reconcile an intent's status with the gateway by reference. Read-only on the - * domain side: it syncs the local projection and, when the provider reports a - * newly-observed success, notifies billing to settle. `referenceId` is opaque - * (the booking id, but this service does not load it). - */ - async getIntentByBookingId(referenceId: string): Promise { - const local = await this.paymentRepo.findOneBy({ refId: referenceId }); - - let snapshot: PaymentIntentSnapshot | null = null; - try { - snapshot = await this.paymentClient.getIntentByReference( - (local?.referenceType as PaymentReferenceType) ?? PaymentReferenceType.SHIPMENT, - referenceId, - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.logger.warn( - `payment service lookup failed for reference ${referenceId}: ${message}; using local intent`, - ); - } - - if (!snapshot) { - if (!local) throw new NotFoundException("PaymentIntent not found"); - return this.formatIntentStatus(local); - } - if (!local) throw new NotFoundException("PaymentIntent not found"); - - // Sync local projection with provider-reported status. - const becameSuccess = - snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success"; - - if (becameSuccess) { - await this.markIntentSucceeded(local.id, { - providerTxnId: snapshot.providerTxnId, - paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined, - notify: true, - }); - } else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) { - await this.paymentRepo.update( - { id: local.id }, - { - status: this.toLocalStatus(snapshot.status), - failerCode: snapshot.failureCode ?? undefined, - failureMessage: snapshot.failureMessage ?? undefined, - }, - ); - } - - const refreshed = await this.paymentRepo.findOneBy({ id: local.id }); - return this.formatIntentStatus(refreshed ?? local); - } - - /** - * Mark a gateway intent paid and (by default) notify billing to settle the - * linked invoice. Idempotent — no-op when already success. Pass `notify: false` - * when the caller settles inline and will trigger settlement itself. - */ - async markIntentSucceeded( - intentId: string, - opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {}, - ): Promise<{ alreadyFinalized: boolean }> { - const intent = await this.paymentRepo.findOneBy({ id: intentId }); - if (!intent) throw new NotFoundException("PaymentIntent not found"); - if (intent.status === "success") return { alreadyFinalized: true }; - - const paidAt = opts.paidAt ?? new Date(); - await this.paymentRepo.update( - { id: intent.id }, - { status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId }, - ); - - if (opts.notify !== false) { - await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt); - } - - return { alreadyFinalized: false }; - } - - async markPaymentFailed(input: { - intentId: string; - failureCode?: string; - failureMessage?: string; - }): Promise { - const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); - if (!intent) throw new NotFoundException("PaymentIntent not found"); - if (intent.status === "success" || intent.status === "canceled") return; - - await this.paymentRepo.update( - { id: intent.id }, - { status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage }, - ); - - // Invoice stays open for retry — nothing to settle. Logged only. - this.logger.warn( - `Payment ${intent.id} failed for ${intent.refId}` + - (input.failureMessage ? `: ${input.failureMessage}` : ""), - ); - } - - async refund(dto: RefundDto) { - const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" }); - if (!intent || intent.status !== "success") { - throw new BadRequestException("No successful payment to refund"); - } - - // NOTE: refunding still mutates the booking directly — left intact pending - // the refund redesign. TODO: route refunds through billing.refundPayable + - // a `${source}.invoice.refunded` reaction, like settlement. - await this.datasource.transaction(async (mg) => { - await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() }); - await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" }); - }); - - return { refunded: true, bookingId: dto.bookingId }; - } - - async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise { - return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method); - } - - async genReceiptHtml(orderId: string) { - const payment = await this.paymentRepo.findOneBy({ merchantOrderId: orderId, status: "success" }); - if (!payment) throw new BadRequestException("No successful payment found for this order"); - - const filePath = path.join(__dirname, "templates", "receipt.hbs"); - if (!fs.existsSync(filePath)) throw new InternalServerErrorException(); - - const source = fs.readFileSync(filePath, "utf8"); - const template = Handlebars.compile(source); - return template({ - vendorName: "Ethio Djibouti Railway Freight Booking", - vendorAddress: "Addis Ababa", - receiptDate: payment.paidAt, - paymentMethod: payment.method, - subtotal: payment.amount.toString(), - total: payment.amount.toString(), - currency: payment.currency, - reason: payment.reason, - }); - } - - findBookingById(id: string) { - return this.paymentRepo.findOneBy({ refId: id }); - } - - formatIntentResponse(intent: PaymentEntity): InitiateResponseDto { - const clientAction = - intent.clientAction && typeof intent.clientAction === "object" - ? (intent.clientAction as unknown as ClientAction) - : undefined; + if (event.eventType === "payment.failed") { + const intent = await this.paymentRepo.findOneBy({ + refId: event.referenceId, + }); + if (!intent) { return { - intentId: intent.id, - status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING, - clientAction, - merchantOrderId: intent.merchantOrderId ?? undefined, + processed: false, + reason: `No local intent for reference ${event.referenceId}`, }; + } + await this.markPaymentFailed({ + intentId: intent.id, + failureCode: event.failureCode, + failureMessage: event.failureMessage, + }); + return { processed: true }; } - private formatIntentStatus(intent: PaymentEntity): IntentStatusDto { - return { - ...this.formatIntentResponse(intent), - paidAt: intent.paidAt?.toISOString(), - failureCode: intent.failerCode ?? undefined, - failureMessage: intent.failureMessage ?? undefined, - }; + return { + processed: false, + reason: `Unknown event type: ${event.eventType}`, + }; + } + + private toLocalStatus( + status: ProviderPaymentStatus, + ): PaymentEntity["status"] { + switch (status) { + case ProviderPaymentStatus.SUCCEEDED: + return "success"; + case ProviderPaymentStatus.FAILED: + return "failed"; + case ProviderPaymentStatus.CANCELLED: + return "canceled"; + case ProviderPaymentStatus.PROCESSING: + return "processing"; + default: + return "action-required"; } + } - async handlePaymentEvent(event: { - eventType: string; - eventId: string; - referenceId: string; - intentId: string; - providerTxnId?: string; - paidAt?: string; - failureCode?: string; - failureMessage?: string; - }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { - console.log(`Received payment event: ${JSON.stringify(event)}`); - if (event.eventType === "payment.succeeded") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); - if (!intent) { - return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; - } - console.log(`Processing payment succeeded event for intent: }`,intent); - const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { - providerTxnId: event.providerTxnId, - paidAt: event.paidAt ? new Date(event.paidAt) : undefined, - notify: true, - }); - console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); - - // When the intent references a booking, flip the booking itself paid. - // refId holds the booking id (the domain reference the intent opened with). - if (intent.referenceType === PaymentReferenceType.BOOKING) { - await this.datasource.manager.update( - Booking, - { id: intent.refId }, - { status: "PAID", paymentStatus: "PAID" }, - ); - } - // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); - return { processed: true, alreadyFinalized }; - } - - if (event.eventType === "payment.failed") { - const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); - if (!intent) { - return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; - } - await this.markPaymentFailed({ - intentId: intent.id, - failureCode: event.failureCode, - failureMessage: event.failureMessage, - }); - return { processed: true }; - } - - return { processed: false, reason: `Unknown event type: ${event.eventType}` }; - } - - private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] { - switch (status) { - case ProviderPaymentStatus.SUCCEEDED: return "success"; - case ProviderPaymentStatus.FAILED: return "failed"; - case ProviderPaymentStatus.CANCELLED: return "canceled"; - case ProviderPaymentStatus.PROCESSING: return "processing"; - default: return "action-required"; - } - } - - async findByCompanyId(companyId: string) { - return this.paymentRepo.findByCompanyId(companyId); - } + async findByCompanyId(companyId: string) { + return this.paymentRepo.findByCompanyId(companyId); + } } diff --git a/apps/edr-freight-api/src/modules/payment/payments.dto.ts b/apps/edr-freight-api/src/modules/payment/payments.dto.ts index 67ca68e87..3b86a940c 100644 --- a/apps/edr-freight-api/src/modules/payment/payments.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/payments.dto.ts @@ -15,9 +15,9 @@ export enum PaymentMethodTypeEnum { } export class InitiatePaymentDto { - @ApiProperty({ example: "booking-uuid" }) + @ApiProperty({ example: "invoice-uuid" }) @IsString() - bookingId!: string; + invoiceId!: string; @ApiProperty({ enum: PaymentMethodTypeEnum, diff --git a/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts b/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts new file mode 100644 index 000000000..943d79296 --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts @@ -0,0 +1,193 @@ +import { + IsUUID, + IsString, + IsDateString, + IsNumber, + IsInt, + IsOptional, + IsEnum, + IsBoolean, +} from 'class-validator'; +import { VendorType } from '../entities/vendor.entity'; +import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity'; +import { DisposalMethod } from '../entities/asset-disposal.entity'; + +export class CreateVendorDto { + @IsString() + name!: string; + + @IsOptional() + @IsEnum(VendorType) + type?: VendorType; + + @IsOptional() + @IsString() + contactPerson?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + email?: string; + + @IsOptional() + @IsString() + address?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateVendorDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsEnum(VendorType) + type?: VendorType; + + @IsOptional() + @IsString() + contactPerson?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + email?: string; + + @IsOptional() + @IsString() + address?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class CreateAcquisitionDto { + @IsOptional() + @IsUUID() + vehicleId?: string; + + @IsOptional() + @IsUUID() + vendorId?: string; + + @IsEnum(AcquisitionType) + acquisitionType!: AcquisitionType; + + @IsDateString() + acquisitionDate!: string; + + @IsOptional() + @IsNumber() + cost?: number; + + @IsOptional() + @IsInt() + usefulLifeMonths?: number; + + @IsOptional() + @IsNumber() + salvageValue?: number; + + @IsOptional() + @IsDateString() + leaseStart?: string; + + @IsOptional() + @IsDateString() + leaseEnd?: string; + + @IsOptional() + @IsNumber() + monthlyPayment?: number; + + @IsOptional() + @IsEnum(AcquisitionStatus) + status?: AcquisitionStatus; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateAcquisitionDto { + @IsOptional() + @IsUUID() + vehicleId?: string; + + @IsOptional() + @IsUUID() + vendorId?: string; + + @IsOptional() + @IsEnum(AcquisitionType) + acquisitionType?: AcquisitionType; + + @IsOptional() + @IsDateString() + acquisitionDate?: string; + + @IsOptional() + @IsNumber() + cost?: number; + + @IsOptional() + @IsInt() + usefulLifeMonths?: number; + + @IsOptional() + @IsNumber() + salvageValue?: number; + + @IsOptional() + @IsDateString() + leaseStart?: string; + + @IsOptional() + @IsDateString() + leaseEnd?: string; + + @IsOptional() + @IsNumber() + monthlyPayment?: number; + + @IsOptional() + @IsEnum(AcquisitionStatus) + status?: AcquisitionStatus; + + @IsOptional() + @IsString() + notes?: string; +} + +export class CreateDisposalDto { + @IsUUID() + vehicleId!: string; + + @IsDateString() + disposalDate!: string; + + @IsEnum(DisposalMethod) + method!: DisposalMethod; + + @IsOptional() + @IsNumber() + salePrice?: number; + + @IsOptional() + @IsString() + buyer?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts b/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts new file mode 100644 index 000000000..d4f781c15 --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts @@ -0,0 +1,64 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { Vendor } from './vendor.entity'; + +export enum AcquisitionType { + PURCHASE = 'PURCHASE', + LEASE = 'LEASE', + RENTAL = 'RENTAL', +} + +export enum AcquisitionStatus { + ACTIVE = 'ACTIVE', + LEASE_EXPIRING = 'LEASE_EXPIRING', + DISPOSED = 'DISPOSED', +} + +@Entity({ name: 'asset_acquisitions', schema: 'freight' }) +@Index(['vehicleId', 'acquisitionDate']) +export class AssetAcquisition extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string; + + @ManyToOne(() => Vehicle, { eager: false, nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle; + + @Column({ name: 'vendor_id', type: 'uuid', nullable: true }) + vendorId?: string; + + @ManyToOne(() => Vendor, { eager: false, nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'vendor_id' }) + vendor?: Vendor; + + @Column({ name: 'acquisition_type', type: 'varchar' }) + acquisitionType!: AcquisitionType; + + @Column({ name: 'acquisition_date', type: 'date' }) + acquisitionDate!: string; + + @Column({ name: 'cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + cost?: number; + + @Column({ name: 'useful_life_months', type: 'int', nullable: true }) + usefulLifeMonths?: number; + + @Column({ name: 'salvage_value', type: 'numeric', precision: 14, scale: 2, nullable: true }) + salvageValue?: number; + + @Column({ name: 'lease_start', type: 'date', nullable: true }) + leaseStart?: string; + + @Column({ name: 'lease_end', type: 'date', nullable: true }) + leaseEnd?: string; + + @Column({ name: 'monthly_payment', type: 'numeric', precision: 14, scale: 2, nullable: true }) + monthlyPayment?: number; + + @Column({ name: 'status', type: 'varchar', default: AcquisitionStatus.ACTIVE }) + status!: AcquisitionStatus; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/procurement/entities/asset-disposal.entity.ts b/apps/edr-freight-api/src/modules/procurement/entities/asset-disposal.entity.ts new file mode 100644 index 000000000..301e3ec1c --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/entities/asset-disposal.entity.ts @@ -0,0 +1,31 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, Index } from 'typeorm'; + +export enum DisposalMethod { + SALE = 'SALE', + SCRAP = 'SCRAP', + RETURN_LEASE = 'RETURN_LEASE', + TRADE_IN = 'TRADE_IN', +} + +@Entity({ name: 'asset_disposals', schema: 'freight' }) +@Index(['vehicleId', 'disposalDate']) +export class AssetDisposal extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @Column({ name: 'disposal_date', type: 'date' }) + disposalDate!: string; + + @Column({ name: 'method', type: 'varchar' }) + method!: DisposalMethod; + + @Column({ name: 'sale_price', type: 'numeric', precision: 14, scale: 2, nullable: true }) + salePrice?: number; + + @Column({ name: 'buyer', nullable: true }) + buyer?: string; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/procurement/entities/vendor.entity.ts b/apps/edr-freight-api/src/modules/procurement/entities/vendor.entity.ts new file mode 100644 index 000000000..cbe394d16 --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/entities/vendor.entity.ts @@ -0,0 +1,34 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column } from 'typeorm'; + +export enum VendorType { + DEALER = 'DEALER', + LEASING = 'LEASING', + PARTS = 'PARTS', + SERVICE = 'SERVICE', + OTHER = 'OTHER', +} + +@Entity({ name: 'vendors', schema: 'freight' }) +export class Vendor extends BaseEntity { + @Column({ name: 'name' }) + name!: string; + + @Column({ name: 'type', type: 'varchar', nullable: true }) + type?: VendorType; + + @Column({ name: 'contact_person', nullable: true }) + contactPerson?: string; + + @Column({ name: 'phone', nullable: true }) + phone?: string; + + @Column({ name: 'email', nullable: true }) + email?: string; + + @Column({ name: 'address', nullable: true }) + address?: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts b/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts new file mode 100644 index 000000000..e5c69f37c --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.controller.ts @@ -0,0 +1,98 @@ +import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { ProcurementService } from './procurement.service'; +import { + CreateVendorDto, + UpdateVendorDto, + CreateAcquisitionDto, + UpdateAcquisitionDto, + CreateDisposalDto, +} from './dto/procurement.dto'; + +@ApiTags('Procurement & Asset Lifecycle') +@Controller('procurement') +export class ProcurementController { + constructor(private readonly procurementService: ProcurementService) {} + + // ---- Vendors ---- + @Post('vendors') + @ApiOperation({ summary: 'Create a vendor' }) + async createVendor(@Body() dto: CreateVendorDto) { + return this.procurementService.createVendor(dto); + } + + @Get('vendors') + @ApiOperation({ summary: 'List vendors' }) + async listVendors() { + return this.procurementService.listVendors(); + } + + @Patch('vendors/:id') + @ApiOperation({ summary: 'Update a vendor' }) + async updateVendor(@Param('id') id: string, @Body() dto: UpdateVendorDto) { + return this.procurementService.updateVendor(id, dto); + } + + @Delete('vendors/:id') + @ApiOperation({ summary: 'Delete a vendor' }) + async deleteVendor(@Param('id') id: string) { + return this.procurementService.deleteVendor(id); + } + + // ---- Acquisitions ---- + @Post('acquisitions') + @ApiOperation({ summary: 'Create an asset acquisition' }) + async createAcquisition(@Body() dto: CreateAcquisitionDto) { + return this.procurementService.createAcquisition(dto); + } + + @Get('acquisitions') + @ApiOperation({ summary: 'List asset acquisitions (optionally filtered by vehicleId)' }) + async listAcquisitions(@Query('vehicleId') vehicleId?: string) { + return this.procurementService.listAcquisitions(vehicleId); + } + + @Get('acquisitions/:id') + @ApiOperation({ summary: 'Get an asset acquisition by id' }) + async getAcquisition(@Param('id') id: string) { + return this.procurementService.getAcquisition(id); + } + + @Patch('acquisitions/:id') + @ApiOperation({ summary: 'Update an asset acquisition' }) + async updateAcquisition(@Param('id') id: string, @Body() dto: UpdateAcquisitionDto) { + return this.procurementService.updateAcquisition(id, dto); + } + + @Delete('acquisitions/:id') + @ApiOperation({ summary: 'Delete an asset acquisition' }) + async deleteAcquisition(@Param('id') id: string) { + return this.procurementService.deleteAcquisition(id); + } + + // ---- Disposals ---- + @Post('disposals') + @ApiOperation({ summary: 'Create an asset disposal' }) + async createDisposal(@Body() dto: CreateDisposalDto) { + return this.procurementService.createDisposal(dto); + } + + @Get('disposals') + @ApiOperation({ summary: 'List asset disposals' }) + async listDisposals() { + return this.procurementService.listDisposals(); + } + + @Delete('disposals/:id') + @ApiOperation({ summary: 'Delete an asset disposal' }) + async deleteDisposal(@Param('id') id: string) { + return this.procurementService.deleteDisposal(id); + } + + // ---- Lifecycle ---- + @Get('lifecycle/:vehicleId') + @ApiOperation({ summary: 'Get asset lifecycle (acquisition, disposal, depreciation) for a vehicle' }) + async lifecycle(@Param('vehicleId') vehicleId: string) { + return this.procurementService.lifecycle(vehicleId); + } +} diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.module.ts b/apps/edr-freight-api/src/modules/procurement/procurement.module.ts new file mode 100644 index 000000000..d4b0d8315 --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Vendor } from './entities/vendor.entity'; +import { AssetAcquisition } from './entities/asset-acquisition.entity'; +import { AssetDisposal } from './entities/asset-disposal.entity'; +import { ProcurementService } from './procurement.service'; +import { ProcurementRepository } from './procurement.repository'; +import { ProcurementController } from './procurement.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Vendor, AssetAcquisition, AssetDisposal])], + providers: [ProcurementService, ProcurementRepository], + controllers: [ProcurementController], + exports: [ProcurementService], +}) +export class ProcurementModule {} diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.repository.ts b/apps/edr-freight-api/src/modules/procurement/procurement.repository.ts new file mode 100644 index 000000000..1a049d52b --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.repository.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { DeepPartial, Repository } from 'typeorm'; +import { Vendor } from './entities/vendor.entity'; +import { AssetAcquisition } from './entities/asset-acquisition.entity'; +import { AssetDisposal } from './entities/asset-disposal.entity'; + +@Injectable() +export class ProcurementRepository extends BaseRepository { + constructor( + @InjectRepository(AssetAcquisition) + private readonly acquisitionRepository: Repository, + @InjectRepository(Vendor) + private readonly vendorRepository: Repository, + @InjectRepository(AssetDisposal) + private readonly disposalRepository: Repository, + ) { + super(acquisitionRepository); + } + + // ---- Vendors ---- + async createVendor(data: DeepPartial): Promise { + const vendor = this.vendorRepository.create(data); + return this.vendorRepository.save(vendor); + } + + async findVendors(): Promise { + return this.vendorRepository.find({ order: { createdAt: 'DESC' } }); + } + + async updateVendor(id: string, data: DeepPartial): Promise { + await this.vendorRepository.update(id, data as never); + return this.vendorRepository.findOneBy({ id }); + } + + async softDeleteVendor(id: string): Promise { + await this.vendorRepository.softDelete(id); + } + + // ---- Acquisitions ---- + async createAcquisition(data: DeepPartial): Promise { + const acquisition = this.acquisitionRepository.create(data); + return this.acquisitionRepository.save(acquisition); + } + + async findAcquisitions(vehicleId?: string): Promise { + return this.acquisitionRepository.find({ + where: vehicleId ? { vehicleId } : {}, + relations: ['vehicle', 'vendor'], + order: { acquisitionDate: 'DESC' }, + }); + } + + async findAcquisitionById(id: string): Promise { + return this.acquisitionRepository.findOne({ + where: { id }, + relations: ['vehicle', 'vendor'], + }); + } + + async updateAcquisition( + id: string, + data: DeepPartial, + ): Promise { + await this.acquisitionRepository.update(id, data as never); + return this.findAcquisitionById(id); + } + + async softDeleteAcquisition(id: string): Promise { + await this.acquisitionRepository.softDelete(id); + } + + async findLatestAcquisitionByVehicle(vehicleId: string): Promise { + return this.acquisitionRepository.findOne({ + where: { vehicleId }, + relations: ['vehicle', 'vendor'], + order: { acquisitionDate: 'DESC' }, + }); + } + + // ---- Disposals ---- + async createDisposal(data: DeepPartial): Promise { + const disposal = this.disposalRepository.create(data); + return this.disposalRepository.save(disposal); + } + + async findDisposals(): Promise { + return this.disposalRepository.find({ order: { disposalDate: 'DESC' } }); + } + + async softDeleteDisposal(id: string): Promise { + await this.disposalRepository.softDelete(id); + } + + async findLatestDisposalByVehicle(vehicleId: string): Promise { + return this.disposalRepository.findOne({ + where: { vehicleId }, + order: { disposalDate: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.service.ts b/apps/edr-freight-api/src/modules/procurement/procurement.service.ts new file mode 100644 index 000000000..e799d5ff9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.service.ts @@ -0,0 +1,143 @@ +import { Injectable } from '@nestjs/common'; +import { ProcurementRepository } from './procurement.repository'; +import { Vendor } from './entities/vendor.entity'; +import { AssetAcquisition } from './entities/asset-acquisition.entity'; +import { AssetDisposal } from './entities/asset-disposal.entity'; +import { + CreateVendorDto, + UpdateVendorDto, + CreateAcquisitionDto, + UpdateAcquisitionDto, + CreateDisposalDto, +} from './dto/procurement.dto'; + +export interface DepreciationResult { + method: 'STRAIGHT_LINE'; + cost: number; + salvageValue: number; + usefulLifeMonths: number; + monthsElapsed: number; + monthlyDepreciation: number; + bookValue: number; +} + +export interface LifecycleResult { + vehicleId: string; + acquisition: AssetAcquisition | null; + disposal: AssetDisposal | null; + depreciation: DepreciationResult | null; +} + +@Injectable() +export class ProcurementService { + constructor(private readonly procurementRepository: ProcurementRepository) {} + + // ---- Vendors ---- + async createVendor(dto: CreateVendorDto): Promise { + return this.procurementRepository.createVendor(dto); + } + + async listVendors(): Promise { + return this.procurementRepository.findVendors(); + } + + async updateVendor(id: string, dto: UpdateVendorDto): Promise { + return this.procurementRepository.updateVendor(id, dto); + } + + async deleteVendor(id: string): Promise<{ success: boolean }> { + await this.procurementRepository.softDeleteVendor(id); + return { success: true }; + } + + // ---- Acquisitions ---- + async createAcquisition(dto: CreateAcquisitionDto): Promise { + return this.procurementRepository.createAcquisition(dto); + } + + async listAcquisitions(vehicleId?: string): Promise { + return this.procurementRepository.findAcquisitions(vehicleId); + } + + async getAcquisition(id: string): Promise { + return this.procurementRepository.findAcquisitionById(id); + } + + async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise { + return this.procurementRepository.updateAcquisition(id, dto); + } + + async deleteAcquisition(id: string): Promise<{ success: boolean }> { + await this.procurementRepository.softDeleteAcquisition(id); + return { success: true }; + } + + // ---- Disposals ---- + async createDisposal(dto: CreateDisposalDto): Promise { + return this.procurementRepository.createDisposal(dto); + } + + async listDisposals(): Promise { + return this.procurementRepository.findDisposals(); + } + + async deleteDisposal(id: string): Promise<{ success: boolean }> { + await this.procurementRepository.softDeleteDisposal(id); + return { success: true }; + } + + // ---- Lifecycle ---- + async lifecycle(vehicleId: string): Promise { + const acquisition = await this.procurementRepository.findLatestAcquisitionByVehicle(vehicleId); + const disposal = await this.procurementRepository.findLatestDisposalByVehicle(vehicleId); + + return { + vehicleId, + acquisition, + disposal, + depreciation: this.computeStraightLineDepreciation(acquisition), + }; + } + + /** + * Straight-line depreciation. Requires a cost and a positive useful life. + * monthlyDep = (cost - salvageValue) / usefulLifeMonths + * bookValue = cost - monthlyDep * monthsElapsedSinceAcquisition, floored at salvageValue. + */ + private computeStraightLineDepreciation( + acquisition: AssetAcquisition | null, + ): DepreciationResult | null { + if (!acquisition) return null; + + const cost = acquisition.cost != null ? Number(acquisition.cost) : null; + const usefulLifeMonths = + acquisition.usefulLifeMonths != null ? Number(acquisition.usefulLifeMonths) : null; + + if (cost == null || usefulLifeMonths == null || usefulLifeMonths <= 0) { + return null; + } + + const salvageValue = acquisition.salvageValue != null ? Number(acquisition.salvageValue) : 0; + const monthlyDepreciation = (cost - salvageValue) / usefulLifeMonths; + + const acquiredAt = new Date(acquisition.acquisitionDate); + const now = new Date(); + const monthsElapsed = Math.max( + 0, + (now.getFullYear() - acquiredAt.getFullYear()) * 12 + + (now.getMonth() - acquiredAt.getMonth()), + ); + + const bookValue = Math.max(cost - monthlyDepreciation * monthsElapsed, salvageValue); + + return { + method: 'STRAIGHT_LINE', + cost, + salvageValue, + usefulLifeMonths, + monthsElapsed, + monthlyDepreciation, + bookValue, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts index 45e737607..3f4d2e4ff 100644 --- a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts +++ b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts @@ -1,19 +1,31 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator'; +import { + ArrayMinSize, + IsArray, + IsEnum, + IsNumber, + IsOptional, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; + +import { RouteStatus } from '../entities/route.entity'; export class CreateRouteMilestoneDto { @ApiProperty({ format: 'uuid' }) @IsUUID() yardId!: string; + + @ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' }) + @IsOptional() + @IsNumber() + @Min(0) + distanceKm?: number; } export class CreateRouteDto { - @ApiProperty() - @IsString() - @MaxLength(120) - name!: string; - @ApiProperty({ type: [CreateRouteMilestoneDto] }) @IsArray() @ArrayMinSize(2) @@ -21,8 +33,8 @@ export class CreateRouteDto { @Type(() => CreateRouteMilestoneDto) milestones!: CreateRouteMilestoneDto[]; - @ApiPropertyOptional() + @ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] }) @IsOptional() - @IsBoolean() - isActive?: boolean; + @IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING']) + status?: RouteStatus; } diff --git a/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts index 020a34cdf..59188a2a6 100644 --- a/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts +++ b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts @@ -1,16 +1,16 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; + +import { RouteStatus } from '../entities/route.entity'; export class FilterRoutesDto { - @ApiPropertyOptional() + @ApiPropertyOptional({ description: 'Search origin/destination yard codes or names' }) @IsOptional() @IsString() search?: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] }) @IsOptional() - @Transform(({ value }) => value === 'true' || value === true) - @IsBoolean() - isActive?: boolean; + @IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING']) + status?: RouteStatus; } diff --git a/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts index 63e37b8ec..c40baaa82 100644 --- a/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts +++ b/apps/edr-freight-api/src/modules/routes/entities/route-milestone.entity.ts @@ -23,4 +23,8 @@ export class RouteMilestone extends BaseEntity { @Column({ name: 'sequence_no', type: 'int' }) sequenceNo!: number; + + /** Kilometres from the previous stop (0 for origin). */ + @Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2, nullable: true }) + distanceKm?: number | null; } diff --git a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts index 8c6e4785e..a79a54503 100644 --- a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts +++ b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts @@ -1,16 +1,15 @@ import { BaseEntity } from '@edr/api-common'; +import type { ScheduleTradeDirection } from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { RouteMilestone } from './route-milestone.entity'; -@Entity({ schema: 'freight', name: 'routes' }) -@Index(['name']) -@Index(['isActive']) -export class Route extends BaseEntity { - @Column({ name: 'name', type: 'varchar', length: 120, unique: true }) - name!: string; +export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING'; +@Entity({ schema: 'freight', name: 'routes' }) +@Index(['status']) +export class Route extends BaseEntity { @Column({ name: 'origin_yard_id', type: 'uuid' }) originYardId!: string; @@ -25,9 +24,32 @@ export class Route extends BaseEntity { @JoinColumn({ name: 'destination_yard_id' }) destinationYard?: Yard; - @Column({ name: 'is_active', type: 'boolean', default: true }) - isActive!: boolean; + @Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' }) + status!: RouteStatus; + + /** + * Trade direction frozen from the yard countries at create/update + * (ET→DJ = EXPORT, DJ→ET = IMPORT, same country = DOMESTIC/"Intercity"). + * Consumers (scheduling, booking windows) read this instead of re-deriving. + */ + @Column({ name: 'direction', type: 'varchar', length: 10 }) + direction!: ScheduleTradeDirection; @OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false }) milestones?: RouteMilestone[]; } + +export function formatRouteLabel(route: { + originYard?: { code?: string; name?: string } | null; + destinationYard?: { code?: string; name?: string } | null; +}): string { + const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin'; + const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination'; + return `${origin} → ${dest}`; +} + +export function totalRouteDistanceKm( + milestones: Array<{ distanceKm?: number | string | null }>, +): number { + return milestones.reduce((sum, m) => sum + Number(m.distanceKm ?? 0), 0); +} diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts index 4c8e62498..34d007c63 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.service.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -1,12 +1,13 @@ -import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource, ILike } from 'typeorm'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; import { CreateRouteDto } from './dto/create-route.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto'; import { UpdateRouteDto } from './dto/update-route.dto'; import { RouteMilestone } from './entities/route-milestone.entity'; -import { Route } from './entities/route.entity'; +import { formatRouteLabel, Route } from './entities/route.entity'; import { RoutesRepository } from './routes.repository'; @Injectable() @@ -16,11 +17,10 @@ export class RoutesService { private readonly routesRepository: RoutesRepository, ) {} - findAll(filter: FilterRoutesDto): Promise { - return this.routesRepository.findAll({ + async findAll(filter: FilterRoutesDto): Promise { + const routes = await this.routesRepository.findAll({ where: { - ...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}), - ...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}), + ...(filter.status ? { status: filter.status } : {}), }, relations: { originYard: true, @@ -28,10 +28,33 @@ export class RoutesService { milestones: { yard: true }, }, order: { - name: 'ASC', milestones: { sequenceNo: 'ASC' }, }, }); + + const sorted = [...routes].sort((a, b) => + formatRouteLabel(a).localeCompare(formatRouteLabel(b)), + ); + + const query = filter.search?.trim().toLowerCase(); + if (!query) return sorted; + + return sorted.filter((route) => { + const haystack = [ + formatRouteLabel(route), + route.originYard?.label, + route.originYard?.code, + route.destinationYard?.label, + route.destinationYard?.code, + ...(route.milestones ?? []).map( + (m) => m.yard?.label ?? m.yard?.code ?? m.yardId, + ), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return haystack.includes(query); + }); } async findById(id: string): Promise { @@ -53,16 +76,15 @@ export class RoutesService { } async create(dto: CreateRouteDto): Promise { - await this.validateRouteName(dto.name); const validated = await this.validateMilestones(dto.milestones); const route = await this.dataSource.transaction(async (manager) => { const savedRoute = await manager.getRepository(Route).save( manager.getRepository(Route).create({ - name: dto.name.trim(), originYardId: validated.originYardId, destinationYardId: validated.destinationYardId, - isActive: dto.isActive ?? true, + status: dto.status ?? 'AVAILABLE', + direction: validated.direction, }), ); @@ -72,6 +94,7 @@ export class RoutesService { routeId: savedRoute.id, yardId: milestone.yardId, sequenceNo: milestone.sequenceNo, + distanceKm: milestone.distanceKm, }), ), ); @@ -85,20 +108,17 @@ export class RoutesService { async update(id: string, dto: UpdateRouteDto): Promise { const existing = await this.findById(id); - if (dto.name && dto.name.trim() !== existing.name) { - await this.validateRouteName(dto.name, id); - } - const milestoneInput = dto.milestones ? await this.validateMilestones(dto.milestones) : null; await this.dataSource.transaction(async (manager) => { await manager.getRepository(Route).update(id, { - name: dto.name?.trim() ?? existing.name, originYardId: milestoneInput?.originYardId ?? existing.originYardId, - destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId, - isActive: dto.isActive ?? existing.isActive, + destinationYardId: + milestoneInput?.destinationYardId ?? existing.destinationYardId, + ...(milestoneInput ? { direction: milestoneInput.direction } : {}), + ...(dto.status !== undefined ? { status: dto.status } : {}), }); if (milestoneInput) { @@ -109,6 +129,7 @@ export class RoutesService { routeId: id, yardId: milestone.yardId, sequenceNo: milestone.sequenceNo, + distanceKm: milestone.distanceKm, }), ), ); @@ -120,7 +141,9 @@ export class RoutesService { async deactivate(id: string): Promise { await this.findById(id); - const updated = await this.routesRepository.update(id, { isActive: false }); + const updated = await this.routesRepository.update(id, { + status: 'STOP_WORKING', + } as never); if (!updated) { throw new NotFoundException(`Route ${id} not found`); @@ -129,27 +152,32 @@ export class RoutesService { return this.findById(id); } - private async validateRouteName(name: string, routeId?: string) { - const trimmedName = name.trim(); - const [existing] = await this.routesRepository.findAll({ where: { name: trimmedName } }); - - if (existing && existing.id !== routeId) { - throw new ConflictException(`Route name ${trimmedName} already exists`); - } - } - - private async validateMilestones(milestones: Array<{ yardId: string }>) { + private async validateMilestones( + milestones: Array<{ yardId: string; distanceKm?: number }>, + ) { if (milestones.length < 2) { throw new BadRequestException('A route requires at least two yards'); } - const normalized = milestones.map((milestone, index) => ({ - yardId: milestone.yardId, - sequenceNo: index + 1, - })); + const normalized = milestones.map((milestone, index) => { + const distanceKm = + index === 0 ? 0 : milestone.distanceKm != null ? milestone.distanceKm : null; + if (index > 0 && (distanceKm == null || distanceKm < 0)) { + throw new BadRequestException( + `Enter segment KM for stop ${index + 1} (from previous yard).`, + ); + } + return { + yardId: milestone.yardId, + sequenceNo: index + 1, + distanceKm, + }; + }); const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))]; - const yards = await this.dataSource.getRepository(Yard).find({ where: uniqueYardIds.map((id) => ({ id })) }); + const yards = await this.dataSource + .getRepository(Yard) + .find({ where: uniqueYardIds.map((id) => ({ id })) }); const yardIds = new Set(yards.map((yard) => yard.id)); for (const milestone of normalized) { @@ -162,9 +190,18 @@ export class RoutesService { throw new BadRequestException('Origin and destination yards must be different'); } + const originYardId = normalized[0].yardId; + const destinationYardId = normalized[normalized.length - 1].yardId; + const yardById = new Map(yards.map((yard) => [yard.id, yard])); + const direction = deriveTradeDirection( + yardById.get(originYardId) ?? { country: null }, + yardById.get(destinationYardId) ?? { country: null }, + ); + return { - originYardId: normalized[0].yardId, - destinationYardId: normalized[normalized.length - 1].yardId, + originYardId, + destinationYardId, + direction, milestones: normalized, }; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts index 36b863dd6..8dcc58e77 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts @@ -21,7 +21,7 @@ export class PriorityConfigsController { @ApiOperation({ summary: 'List priority configs' }) findAll(@Query() query: Record) { return this.service.findAll({ - type: (query['type'] as 'WAGON' | 'CURRENCY') || undefined, + type: (query['type'] as 'WAGON' | 'CURRENCY' | 'CUSTOMS') || undefined, isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, page: query['page'] ? parseInt(query['page'], 10) : undefined, pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 794f97f83..76db7fa78 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -21,10 +21,13 @@ export class CreateCargoTypeDto { @IsUUID() parentGroupId?: string; - @ApiPropertyOptional({ default: false }) + @ApiPropertyOptional({ + description: + 'Wagon type used to carry this (bulk) cargo. Drives train scheduling wagon-type resolution; required for bulk commodities that are scheduled.', + }) @IsOptional() - @IsBoolean() - showFreeTextBox?: boolean; + @IsUUID('4') + wagonTypeId?: string | null; @ApiPropertyOptional({ default: false }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts index 52cfe274b..e0baf7251 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts @@ -30,6 +30,14 @@ export class CreateContainerTypeDto { @IsBoolean() isOpenTop?: boolean; + @ApiPropertyOptional({ + description: + 'Wagon type used to carry this container. Drives train scheduling wagon-type resolution; required when this container type is scheduled.', + }) + @IsOptional() + @IsUUID('4') + wagonTypeId?: string | null; + @ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts index d2ca44d93..484d3fbaf 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts @@ -2,9 +2,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; export class CreatePriorityConfigDto { - @ApiProperty({ description: 'Config type: WAGON or CURRENCY', enum: ['WAGON', 'CURRENCY'] }) - @IsIn(['WAGON', 'CURRENCY']) - type!: 'WAGON' | 'CURRENCY'; + @ApiProperty({ + description: 'Config type: WAGON, CURRENCY, or CUSTOMS', + enum: ['WAGON', 'CURRENCY', 'CUSTOMS'], + }) + @IsIn(['WAGON', 'CURRENCY', 'CUSTOMS']) + type!: 'WAGON' | 'CURRENCY' | 'CUSTOMS'; @ApiProperty({ description: 'Human-readable label', maxLength: 100 }) @IsString() @@ -12,7 +15,8 @@ export class CreatePriorityConfigDto { label!: string; @ApiPropertyOptional({ - description: 'Currency code (e.g., USD, ETB). Required for type=CURRENCY, must be null for type=WAGON', + description: + 'Currency code (e.g., USD, ETB). Required for type=CURRENCY, must be null for type=WAGON and type=CUSTOMS', maxLength: 5, }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 995718135..9a4cd642b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; import { RATE_APPLIES_TO, RATE_TRIGGERS, @@ -51,15 +51,6 @@ export class CreateRateDto { @ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' }) @IsIn([...RATE_UNITS]) rateUnit!: string; - - @ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' }) - @IsDateString() - effectiveFrom!: string; - - @ApiPropertyOptional({ description: 'Date when this rate expires. Null = currently active', example: '2025-12-31' }) - @IsOptional() - @IsDateString() - effectiveTo?: string; } export class SubmitRateForApprovalDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts index d68625fdc..a8e030bba 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; export class CreateServiceTypeDto { @ApiProperty({ description: 'Service type display name', maxLength: 255 }) @@ -32,17 +32,6 @@ export class CreateServiceTypeDto { @IsBoolean() includesCustoms?: boolean; - @ApiPropertyOptional({ - description: 'Priority bonus points awarded when this service is used (0–15)', - default: 0, - maximum: 15, - }) - @IsOptional() - @IsInt() - @Min(0) - @Max(15) - priorityBonusPoints?: number; - @ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts index 6be37214b..d60a56944 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const; @@ -22,12 +22,14 @@ export class CreateWeightLimitRuleDto { @Transform(({ value }) => Number(value)) maxVgmTons!: number; - @ApiProperty({ description: 'Date from which this rule is active (ISO date)', example: '2024-01-01' }) - @IsDateString() - effectiveFrom!: string; - - @ApiPropertyOptional({ description: 'Date when this rule expires (ISO date). Null = currently active', example: '2025-12-31' }) + @ApiPropertyOptional({ + description: + 'Hard per-unit weight ceiling in tons — above this the booking cannot be created. Null/omitted = no ceiling.', + minimum: 0, + }) @IsOptional() - @IsDateString() - effectiveTo?: string; + @IsNumber() + @Min(0) + @Transform(({ value }) => (value === null || value === undefined || value === '' ? null : Number(value))) + maxCapacityTons?: number | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts index 38f2bc58b..53583e3e8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts @@ -1,5 +1,6 @@ +import { YardCountry } from '@edr/types'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsEnum, IsInt, IsOptional, IsUUID, MaxLength, Min, IsString } from 'class-validator'; export class CreateYardDto { @ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 }) @@ -7,10 +8,9 @@ export class CreateYardDto { @MaxLength(100) label!: string; - @ApiProperty({ description: 'Country where the yard is located, e.g. Ethiopia, Djibouti', maxLength: 50 }) - @IsString() - @MaxLength(50) - country!: string; + @ApiProperty({ enum: YardCountry, description: 'Country where the yard is located' }) + @IsEnum(YardCountry) + country!: YardCountry; @ApiPropertyOptional({ default: true }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index 2c1ed0e22..ac8a2ea24 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -1,11 +1,13 @@ import { BaseEntity } from '@edr/api-common'; import { CargoUnitOfMeasure } from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; @Entity({ schema: 'freight', name: 'cargo_types' }) @Index(['isActive']) @Index(['displayOrder']) @Index(['parentGroupId']) +@Index(['wagonTypeId']) @Index(['code']) export class CargoType extends BaseEntity { @Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' }) @@ -17,9 +19,6 @@ export class CargoType extends BaseEntity { @Column({ name: 'parent_group_id', type: 'uuid', nullable: true }) parentGroupId?: string | null; - @Column({ name: 'show_free_text_box', type: 'boolean', default: false }) - showFreeTextBox!: boolean; - /** * How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM * (break-bulk). Nullable for container/legacy cargo, which is counted by @@ -28,6 +27,19 @@ export class CargoType extends BaseEntity { @Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true }) unitOfMeasure?: CargoUnitOfMeasure | null; + /** + * Wagon type that carries this (bulk) cargo. Replaces the former hardcoded + * cargo-code → wagon-code map: train scheduling resolves the bulk wagon type + * through this FK. Nullable — grouping rows and container/legacy cargo never + * carry it; scheduling throws if a scheduled bulk cargo type leaves it unset. + */ + @Column({ name: 'wagon_type_id', type: 'uuid', nullable: true }) + wagonTypeId?: string | null; + + @ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType | null; + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts index e03078c19..f7cbeed99 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts @@ -1,10 +1,12 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { WeightLimitRule } from './weight-limit-rule.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; @Entity({ schema: 'freight', name: 'container_types' }) @Index(['code']) @Index(['isActive']) +@Index(['wagonTypeId']) export class ContainerType extends BaseEntity { @Column({ name: 'code', type: 'varchar', length: 20, unique: true }) code!: string; @@ -24,6 +26,19 @@ export class ContainerType extends BaseEntity { @Column({ name: 'is_open_top', type: 'boolean', default: false, nullable: true }) isOpenTop!: boolean; + /** + * Wagon type that carries this container. Replaces the former hardcoded + * container wagon-code default (NW5): train scheduling resolves the container + * wagon type through this FK. Nullable; scheduling throws if a scheduled + * container type leaves it unset. + */ + @Column({ name: 'wagon_type_id', type: 'uuid', nullable: true }) + wagonTypeId?: string | null; + + @ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType | null; + @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/priority-config.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-config.entity.ts index df60b3ea1..e1fa5bfa7 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/priority-config.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-config.entity.ts @@ -6,7 +6,7 @@ import { Column, Entity, Index } from 'typeorm'; @Index(['currency', 'type']) export class PriorityConfig extends BaseEntity { @Column({ name: 'type', type: 'varchar', length: 20 }) - type!: 'WAGON' | 'CURRENCY'; + type!: 'WAGON' | 'CURRENCY' | 'CUSTOMS'; @Column({ name: 'label', type: 'varchar', length: 100 }) label!: string; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts new file mode 100644 index 000000000..cef613412 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -0,0 +1,71 @@ +import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity'; + +/** + * Which rate units make sense for a given rate shape. The weighting basis is + * driven by the *type* of thing being billed — a container leg bills per + * container, bulk freight per ton, an intercity move can be per-km, a + * cancellation is a flat/per-invoice fee, and overweight is always per excess + * ton. This keeps the rate table dynamic yet non-conflicting: the admin can + * only pick a unit the pricing engine knows how to apply. + * + * Returned lists are ordered with the most natural/default unit first. + */ +export function allowedRateUnits(input: { + appliesTo: RateAppliesTo; + trigger: RateTrigger; +}): RateUnit[] { + const { appliesTo, trigger } = input; + + // Surcharges (Applies to = Other) are governed by their trigger. + if (appliesTo === 'OTHER') { + switch (trigger) { + case 'OVERWEIGHT': + // Overweight always bills the excess tonnage — per ton, nothing else. + return ['PER_TON']; + case 'REEFER': + case 'HAZARDOUS': + // Scale with the freight shape: per container for boxes, per ton for bulk. + return ['PER_CONTAINER', 'PER_TON']; + case 'DEMURRAGE': + return ['PER_CONTAINER', 'PER_TON']; + case 'CANCELLATION': + return ['FLAT', 'PER_INVOICE']; + case 'CONSOLIDATION': + return ['PER_CONTAINER', 'FLAT']; + case 'SHIPPING_LINE': + case 'PIL_EXTRA_FEE': + return ['PER_CONTAINER', 'FLAT']; + default: + return ['FLAT', 'PER_TON', 'PER_CONTAINER']; + } + } + + // Base freight + first/last mile scale with the cargo type. + switch (appliesTo) { + case 'CONTAINER': + return ['PER_CONTAINER', 'PER_WAGON']; + case 'BULK': + return ['PER_TON', 'PER_WAGON']; + case 'INTERCITY': + return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM']; + case 'FIRST_MILE': + case 'LAST_MILE': + return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT']; + default: + return ['FLAT']; + } +} + +/** The default (first / most natural) unit for a rate shape. */ +export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: RateTrigger }): RateUnit { + return allowedRateUnits(input)[0]; +} + +/** True when `unit` is a valid weighting basis for the given rate shape. */ +export function isRateUnitAllowed(input: { + appliesTo: RateAppliesTo; + trigger: RateTrigger; + unit: RateUnit; +}): boolean { + return allowedRateUnits(input).includes(input.unit); +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index b57b48cd8..50f8b3b99 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -81,7 +81,6 @@ export type RateTrigger = typeof RATE_TRIGGERS[number]; @Entity({ schema: 'freight', name: 'rates' }) @Index(['rateType']) @Index(['status']) -@Index(['effectiveFrom']) @Index(['containerTypeId']) @Index(['trigger']) export class Rate extends BaseEntity { @@ -131,10 +130,4 @@ export class Rate extends BaseEntity { @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) approvedAt?: Date | null; - - @Column({ name: 'effective_from', type: 'date' }) - effectiveFrom!: Date; - - @Column({ name: 'effective_to', type: 'date', nullable: true }) - effectiveTo?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts index 2b7cb3f23..b882f1a08 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts @@ -27,9 +27,6 @@ export class ServiceType extends BaseEntity { @Column({ name: 'includes_customs', type: 'boolean', default: false }) includesCustoms!: boolean; - @Column({ name: 'priority_bonus_points', type: 'int', default: 0 }) - priorityBonusPoints!: number; - @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts index 39557eec9..7a30cc20d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts @@ -5,7 +5,6 @@ import { ContainerType } from './container-type.entity'; @Entity({ schema: 'freight', name: 'weight_limit_rules' }) @Index(['containerTypeId']) @Index(['tradeDirection']) -@Index(['effectiveFrom']) export class WeightLimitRule extends BaseEntity { @Column({ name: 'container_type_id', type: 'uuid' }) containerTypeId!: string; @@ -20,9 +19,11 @@ export class WeightLimitRule extends BaseEntity { @Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) maxVgmTons!: number; - @Column({ name: 'effective_from', type: 'date', nullable: true }) - effectiveFrom!: Date; - - @Column({ name: 'effective_to', type: 'date', nullable: true }) - effectiveTo?: Date | null; + /** + * Absolute per-unit weight ceiling in tons. Weight above maxVgmTons but at or + * below this is "overweight" (surcharge + warning); weight above this hard- + * blocks booking creation entirely. Null = no ceiling (overweight only). + */ + @Column({ name: 'max_capacity_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) + maxCapacityTons!: number | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts index 249aa1847..3f7f1ae97 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard.entity.ts @@ -1,4 +1,5 @@ import { BaseEntity } from '@edr/api-common'; +import { YardCountry } from '@edr/types'; import { Column, Entity, Index } from 'typeorm'; @Entity({ schema: 'freight', name: 'yards' }) @@ -12,8 +13,11 @@ export class Yard extends BaseEntity { @Column({ name: 'label', type: 'varchar', length: 100 }) label!: string; + // Constrained to YardCountry by DTO validation + a DB CHECK constraint; + // route/schedule trade direction is derived from this value. Typed as the + // enum's literal values so plain strings from seeds/queries still fit. @Column({ name: 'country', type: 'varchar', length: 50 }) - country!: string; + country!: `${YardCountry}`; @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index 52b991155..96db214c4 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -4,6 +4,13 @@ import { Rate } from '../entities/rate.entity'; export interface IRatesRepository { findById(id: string): Promise; findLiveRates(): Promise; + findByPattern(pattern: { + rateType: string; + rateUnit: string; + containerTypeId?: string | null; + cargoTypeId?: string | null; + tradeDirection?: string | null; + }): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[Rate[], number]>; create(data: Partial): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts index cedbd1eee..3c175df4e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts @@ -7,6 +7,11 @@ export interface IWeightLimitRulesRepository { containerTypeId: string, tradeDirection: string, ): Promise; + findByPattern( + containerTypeId: string, + tradeDirection: string, + excludeId?: string, + ): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[WeightLimitRule[], number]>; create(data: Partial): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts index 496c2ce7b..5fba70fe1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts @@ -33,7 +33,7 @@ export class CargoTypesRepository implements ICargoTypesRepository { } async update(id: string, data: Partial): Promise { - await this.repo.update(id, data); + await this.repo.update(id, data as never); return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts index fe0a8f41e..0e4fb2716 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts @@ -33,7 +33,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository { } async update(id: string, data: Partial): Promise { - await this.repo.update(id, data); + await this.repo.update(id, data as never); return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 0d49a0bf3..a7b8e69ab 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -16,15 +16,50 @@ export class RatesRepository implements IRatesRepository { } findLiveRates(): Promise { - const now = new Date(); return this.repo .createQueryBuilder('rate') .where('rate.status = :status', { status: 'LIVE' }) - .andWhere('rate.effective_from <= :now', { now }) - .andWhere('(rate.effective_to IS NULL OR rate.effective_to > :now)', { now }) .getMany(); } + /** + * Find a non-superseded rate matching an identity pattern — the same tuple the + * `UQ_rates_pattern` unique index enforces. Used to reject duplicates before + * insert so the admin gets a friendly error instead of a raw constraint fault. + * NULL scope columns are matched with IS NULL, mirroring the COALESCE index. + */ + findByPattern(pattern: { + rateType: string; + rateUnit: string; + containerTypeId?: string | null; + cargoTypeId?: string | null; + tradeDirection?: string | null; + }): Promise { + const qb = this.repo + .createQueryBuilder('rate') + .where('rate.rate_type = :rateType', { rateType: pattern.rateType }) + .andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit }) + .andWhere('rate.status <> :superseded', { superseded: 'SUPERSEDED' }); + + if (pattern.containerTypeId) { + qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId }); + } else { + qb.andWhere('rate.container_type_id IS NULL'); + } + if (pattern.cargoTypeId) { + qb.andWhere('rate.cargo_type_id = :cargoTypeId', { cargoTypeId: pattern.cargoTypeId }); + } else { + qb.andWhere('rate.cargo_type_id IS NULL'); + } + if (pattern.tradeDirection) { + qb.andWhere('rate.trade_direction = :tradeDirection', { tradeDirection: pattern.tradeDirection }); + } else { + qb.andWhere('rate.trade_direction IS NULL'); + } + + return qb.getOne(); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } @@ -39,7 +74,7 @@ export class RatesRepository implements IRatesRepository { } async update(id: string, data: Partial): Promise { - await this.repo.update(id, data); + await this.repo.update(id, data as never); return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts index 0d151c561..7432dfc34 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts @@ -22,7 +22,6 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { containerTypeId: string, tradeDirection: string, ): Promise { - const now = new Date(); return this.repo .createQueryBuilder('rule') .innerJoinAndSelect('rule.containerType', 'ct') @@ -31,11 +30,27 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { dir: tradeDirection, both: 'BOTH', }) - .andWhere('rule.effective_from <= :now', { now }) - .andWhere('(rule.effective_to IS NULL OR rule.effective_to > :now)', { now }) .getMany(); } + /** + * Find a rule matching the (containerType, tradeDirection) identity — the + * tuple enforced by `UQ_weight_limit_rules_pattern`. Used to reject duplicates + * before insert. Optionally excludes a row by id so updates don't self-collide. + */ + findByPattern( + containerTypeId: string, + tradeDirection: string, + excludeId?: string, + ): Promise { + const qb = this.repo + .createQueryBuilder('rule') + .where('rule.container_type_id = :containerTypeId', { containerTypeId }) + .andWhere('rule.trade_direction = :tradeDirection', { tradeDirection }); + if (excludeId) qb.andWhere('rule.id <> :excludeId', { excludeId }); + return qb.getOne(); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } @@ -50,7 +65,7 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { } async update(id: string, data: Partial): Promise { - await this.repo.update(id, data); + await this.repo.update(id, data as never); return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 16027f9c3..e451098fc 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -136,6 +136,10 @@ export class RuleEngineService { } } + hardBlocked.push( + ...(await this.capacityViolations(input.containers, input.tradeDirection)), + ); + for (const container of input.containers) { const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( container.containerTypeId, @@ -172,13 +176,12 @@ export class RuleEngineService { } const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId); - if (serviceType) { - priorityScore += serviceType.priorityBonusPoints; - } + const includesCustoms = serviceType?.includesCustoms ?? false; // Additive priority blocks, each keyed on the booking's total wagon count: // - WAGON rules apply regardless of currency. // - CURRENCY rules apply only when the payment currency matches. + // - CUSTOMS rules apply only when the service type includes customs. const priorityConfigs = await this.priorityConfigsRepo.findAllActive(); const wagonsInRange = (cfg: { minWagonCount: number; maxWagonCount: number }) => input.totalWagons >= cfg.minWagonCount && @@ -187,7 +190,8 @@ export class RuleEngineService { for (const cfg of priorityConfigs) { const applies = cfg.type === 'WAGON' || - (cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency); + (cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency) || + (cfg.type === 'CUSTOMS' && includesCustoms); if (applies && wagonsInRange(cfg)) { priorityScore += cfg.scorePoints; } @@ -296,6 +300,40 @@ export class RuleEngineService { }; } + /** + * Messages for container lines whose total weight exceeds the hard capacity + * ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking + * must not be created at all. Overweight (above maxVgmTons but within + * capacity) is NOT reported here — that is a surcharge, not a block. + */ + async capacityViolations( + containers: Array<{ + containerTypeId: string; + quantity: number; + totalVgmTons: number; + }>, + tradeDirection: string, + ): Promise { + const violations: string[] = []; + for (const container of containers) { + const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( + container.containerTypeId, + tradeDirection, + ); + const rule = rules[0]; + if (!rule || rule.maxCapacityTons == null) continue; + const perUnit = Number(rule.maxCapacityTons); + const maxTotal = perUnit * container.quantity; + if (container.totalVgmTons > maxTotal) { + const label = rule.containerType?.code ?? container.containerTypeId; + violations.push( + `${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`, + ); + } + } + return violations; + } + /** * Ensure ITMLS default approval chains exist (container + bulk). Idempotent. */ @@ -331,16 +369,14 @@ export class RuleEngineService { ): Promise { await this.ensureDefaultApprovalRules(); - let requiresDirectorApproval = options.freightType === 'BULK'; + let requiresDirectorApproval = false; if (options.cargoTypeId) { const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId); if (!cargoType) { throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`); } - if (cargoType.requiresDirectorApproval) { - requiresDirectorApproval = true; - } + requiresDirectorApproval = cargoType.requiresDirectorApproval; } const chain = await this.approvalRulesRepo.findChainForCargo( diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 5a343f910..5470094a5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -79,10 +79,10 @@ export class CargoTypesService { code, cargoTypeName: dto.cargoTypeName, parentGroupId: dto.parentGroupId ?? null, - showFreeTextBox: dto.showFreeTextBox ?? false, requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, unitOfMeasure: dto.unitOfMeasure ?? null, + wagonTypeId: dto.wagonTypeId ?? null, displayOrder, }); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts index 38407f36a..629bf3023 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -64,6 +64,7 @@ export class ContainerTypesService { isReefer: dto.isReefer ?? false, isOpenTop: dto.isOpenTop ?? false, isActive: dto.isActive ?? true, + wagonTypeId: dto.wagonTypeId ?? null, displayOrder, }); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts index 173c63f21..6d7034ad4 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts @@ -17,7 +17,7 @@ export class PriorityConfigsService { ) {} async findAll(filter: { - type?: 'WAGON' | 'CURRENCY'; + type?: 'WAGON' | 'CURRENCY' | 'CUSTOMS'; isActive?: boolean; page?: number; pageSize?: number; @@ -87,12 +87,15 @@ export class PriorityConfigsService { await this.displayOrder.moveOne(PriorityConfig, 'displayOrder', id, direction); } - private validateCurrencyField(type: 'WAGON' | 'CURRENCY', currency: string | undefined | null): void { + private validateCurrencyField( + type: 'WAGON' | 'CURRENCY' | 'CUSTOMS', + currency: string | undefined | null, + ): void { if (type === 'CURRENCY' && !currency) { throw new BadRequestException('currency field is required when type is CURRENCY'); } - if (type === 'WAGON' && currency) { - throw new BadRequestException('currency field must be null when type is WAGON'); + if (type !== 'CURRENCY' && currency) { + throw new BadRequestException(`currency field must be null when type is ${type}`); } } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index c3ab6bab0..0027e18c1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -1,8 +1,15 @@ -import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { CreateRateDto } from '../dto/create-rate.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; import { Rate } from '../entities/rate.entity'; import { deriveRateType } from '../entities/rate-type.util'; +import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util'; import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; @Injectable() @@ -27,7 +34,7 @@ export class RatesService { const [data, total] = await this.repository.findAndCount({ where, - order: { effectiveFrom: 'DESC' }, + order: { createdAt: 'DESC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -46,6 +53,50 @@ export class RatesService { return entity; } + /** + * Normalise + validate the weighting unit for a rate shape. Overweight is + * always billed per excess ton, so its unit is forced to PER_TON regardless + * of what the client sent. Every other shape must pick a unit the pricing + * engine can actually apply (see `allowedRateUnits`). + */ + private resolveRateUnit( + appliesTo: Rate['appliesTo'], + trigger: Rate['trigger'], + requestedUnit: Rate['rateUnit'], + ): Rate['rateUnit'] { + // Overweight is per-ton, full stop. + if (trigger === 'OVERWEIGHT') return 'PER_TON'; + + if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { + const allowed = allowedRateUnits({ appliesTo, trigger }).join(', '); + throw new BadRequestException( + `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`, + ); + } + return requestedUnit; + } + + /** + * Reject a second rate with the same identity pattern (rateType + scope). With + * effective-date windows gone, two LIVE/DRAFT rates for the same pattern would + * make pricing ambiguous — so we allow exactly one per pattern. + */ + private async assertNoDuplicatePattern(pattern: { + rateType: string; + rateUnit: string; + containerTypeId: string | null; + cargoTypeId: string | null; + tradeDirection: string | null; + ignoreId?: string; + }): Promise { + const existing = await this.repository.findByPattern(pattern); + if (existing && existing.id !== pattern.ignoreId) { + throw new ConflictException( + 'A rate for this exact combination already exists. Edit or delete the existing rate instead of creating a duplicate.', + ); + } + } + /** Create a rate in DRAFT status. */ async create(dto: CreateRateDto, proposedByStaffId: string): Promise { const appliesTo = dto.appliesTo as Rate['appliesTo']; @@ -57,25 +108,28 @@ export class RatesService { const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null); + const rateType = deriveRateType({ + appliesTo, + trigger, + tradeDirection, + isBulk: Boolean(cargoTypeId), + }); + const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']); + + await this.assertNoDuplicatePattern({ rateType, rateUnit, containerTypeId, cargoTypeId, tradeDirection }); + return this.repository.create({ appliesTo, trigger, - rateType: deriveRateType({ - appliesTo, - trigger, - tradeDirection, - isBulk: Boolean(cargoTypeId), - }), + rateType, containerTypeId, cargoTypeId, tradeDirection, currency: dto.currency ?? 'USD', rateValue: dto.rateValue, - rateUnit: dto.rateUnit as Rate['rateUnit'], + rateUnit, status: 'DRAFT', proposedByStaffId, - effectiveFrom: new Date(dto.effectiveFrom), - effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined, }); } @@ -110,22 +164,35 @@ export class RatesService { ? dto.tradeDirection : existing.tradeDirection; - updates.containerTypeId = containerTypeId; - updates.cargoTypeId = cargoTypeId; - updates.tradeDirection = tradeDirection; + updates.containerTypeId = containerTypeId ?? null; + updates.cargoTypeId = cargoTypeId ?? null; + updates.tradeDirection = tradeDirection ?? null; // Keep the derived rateType in sync with whatever changed. - updates.rateType = deriveRateType({ + const rateType = deriveRateType({ appliesTo, trigger, tradeDirection, isBulk: Boolean(cargoTypeId), }); + updates.rateType = rateType; + + // Re-validate the unit against the (possibly changed) shape; overweight is + // forced to PER_TON. + const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit; + updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit); + + // Guard the pattern uniqueness for the new identity, ignoring this row. + await this.assertNoDuplicatePattern({ + rateType, + rateUnit: updates.rateUnit, + containerTypeId: updates.containerTypeId, + cargoTypeId: updates.cargoTypeId, + tradeDirection: updates.tradeDirection, + ignoreId: id, + }); updates.currency = dto.currency ?? existing.currency ?? 'USD'; if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; - if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit']; - if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom); - if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo); const updated = await this.repository.update(id, updates); if (!updated) throw new NotFoundException(`Rate ${id} not found`); return updated; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts index 2ad8753c3..6608749d1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts @@ -76,7 +76,6 @@ export class ServiceTypesService { includesFirstMile: dto.includesFirstMile ?? false, includesLastMile: dto.includesLastMile ?? false, includesCustoms: dto.includesCustoms ?? false, - priorityBonusPoints: dto.priorityBonusPoints ?? 0, isActive: dto.isActive ?? true, displayOrder, }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts index d171f55aa..44f5332f2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts @@ -1,4 +1,10 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; @@ -30,7 +36,7 @@ export class WeightLimitRulesService { const [data, total] = await this.repository.findAndCount({ where, relations: { containerType: true }, - order: { effectiveFrom: 'DESC' }, + order: { createdAt: 'DESC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -44,26 +50,75 @@ export class WeightLimitRulesService { return entity; } + /** + * Reject a second rule for the same container + direction. One VGM limit per + * (container, direction) — otherwise the booking engine can't tell which + * applies. + */ + private async assertNoDuplicate( + containerTypeId: string, + tradeDirection: string, + ignoreId?: string, + ): Promise { + const existing = await this.repository.findByPattern(containerTypeId, tradeDirection, ignoreId); + if (existing) { + throw new ConflictException( + 'A weight limit rule for this container type and trade direction already exists. Edit the existing rule instead.', + ); + } + } + + /** + * Capacity is the hard ceiling; the VGM limit is the soft overweight + * threshold. A ceiling below the threshold would make every overweight + * booking impossible to create, which is never what the operator means. + */ + private assertCapacityAboveVgmLimit( + maxVgmTons: number, + maxCapacityTons: number | null | undefined, + ): void { + if (maxCapacityTons != null && Number(maxCapacityTons) < Number(maxVgmTons)) { + throw new BadRequestException( + 'Max capacity must be greater than or equal to the max VGM limit.', + ); + } + } + /** Create a new weight limit rule. */ async create(dto: CreateWeightLimitRuleDto): Promise { + await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection); + this.assertCapacityAboveVgmLimit(dto.maxVgmTons, dto.maxCapacityTons); return this.repository.create({ containerTypeId: dto.containerTypeId, tradeDirection: dto.tradeDirection, maxVgmTons: dto.maxVgmTons, - effectiveFrom: new Date(dto.effectiveFrom), - effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : null, + maxCapacityTons: dto.maxCapacityTons ?? null, }); } /** Update an existing weight limit rule. */ async update(id: string, dto: UpdateWeightLimitRuleDto): Promise { - await this.findById(id); + const existing = await this.findById(id); const patch: Partial = {}; if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId; if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection; if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons; - if (dto.effectiveFrom !== undefined) patch.effectiveFrom = new Date(dto.effectiveFrom); - if (dto.effectiveTo !== undefined) patch.effectiveTo = new Date(dto.effectiveTo); + if (dto.maxCapacityTons !== undefined) patch.maxCapacityTons = dto.maxCapacityTons; + + this.assertCapacityAboveVgmLimit( + patch.maxVgmTons ?? Number(existing.maxVgmTons), + patch.maxCapacityTons !== undefined ? patch.maxCapacityTons : existing.maxCapacityTons, + ); + + // Re-check uniqueness when the identity (container/direction) changes. + if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) { + await this.assertNoDuplicate( + patch.containerTypeId ?? existing.containerTypeId, + patch.tradeDirection ?? existing.tradeDirection, + id, + ); + } + const updated = await this.repository.update(id, patch); if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`); return updated; diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts index 905e827e8..289e502f1 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts @@ -78,6 +78,11 @@ describe('SchedulingRescheduleService', () => { bookingsRepository as never, trainSchedulingService as never, schedulingRescheduleRepository as never, + { + rescheduled: jest.fn(), + removedFromTrain: jest.fn(), + maintenanceMoved: jest.fn(), + } as never, // notifier ); }); diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index dd20b100d..a9a3ae246 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -10,6 +10,7 @@ import { BookingsRepository } from '../bookings/bookings.repository'; import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { BookingNotifierService } from '../train-scheduling/booking-notifier.service'; import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto'; import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository'; @@ -38,6 +39,7 @@ export class SchedulingRescheduleService { private readonly bookingsRepository: BookingsRepository, private readonly trainSchedulingService: TrainSchedulingService, private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository, + private readonly notifier: BookingNotifierService, ) {} /** Preview who is retained, displaced, and readmitted on a schedule. */ @@ -193,9 +195,64 @@ export class SchedulingRescheduleService { displacedBookingIds: dto.displacedBookingIds, }); + // Notify affected customers (SMS + email). Best-effort — a notification + // failure must never fail the reschedule, so each send is fire-and-forget + // inside the notifier. Government pre-empt already notifies via the batch + // displaced() path, so skip removed-from-train notices for that trigger. + // Use the new departure date when the reschedule moved it (the in-memory + // `schedule` still holds the pre-update date). + const effectiveDeparture = dto.newDepartureDate + ? new Date(dto.newDepartureDate) + : schedule.scheduledDepartureDate; + await this.notifyRescheduleOutcome(dto, effectiveDeparture); + return { plan, schedule: assignResult }; } + /** + * Fan out reschedule notifications: bookings that stayed on the train hear the + * new departure date; bookings dropped off the train (staff reschedule, not a + * government pre-empt) hear they were removed. Loads each booking with its + * company so the notifier has a phone/email to reach. + */ + private async notifyRescheduleOutcome( + dto: ExecuteRescheduleDto, + newDeparture: Date | null, + ): Promise { + const isMaintenance = dto.trigger === 'TRAIN_MAINTENANCE'; + const isGovPreempt = dto.trigger === 'GOVERNMENT_PREEMPT'; + + if (newDeparture) { + for (const bookingId of dto.finalBookingIds) { + const booking = await this.loadBookingForNotify(bookingId); + if (!booking) continue; + if (isMaintenance) { + this.notifier.maintenanceMoved(booking, newDeparture); + } else { + this.notifier.rescheduled(booking, newDeparture); + } + } + } + + // Government pre-empt displacements are already announced by the batch + // displaced() notice — don't double-notify. Staff reschedules are not. + if (!isGovPreempt) { + for (const bookingId of dto.displacedBookingIds) { + const booking = await this.loadBookingForNotify(bookingId); + if (!booking) continue; + this.notifier.removedFromTrain(booking); + } + } + } + + private async loadBookingForNotify(bookingId: string): Promise { + try { + return await this.bookingsRepository.findByIdWithFiles(bookingId); + } catch { + return null; + } + } + /** Maintenance shortcut: new departure + rebalance. */ async maintenanceReschedule( scheduleId: string, diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts index 4ffecea26..352704eea 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts @@ -1,9 +1,15 @@ import { BaseEntity } from '@edr/api-common'; +import { LoadingStatus } from '@edr/types'; import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { TrainSchedule } from './train-schedule.entity'; +export const TRAIN_SCHEDULE_BOOKING_LOADING_STATUSES = [ + LoadingStatus.Unloaded, + LoadingStatus.Loaded, +] as const; + @Entity({ schema: 'freight', name: 'train_schedule_bookings' }) @Index(['trainScheduleId', 'bookingId'], { unique: true }) @Index(['bookingId'], { unique: true }) @@ -23,4 +29,7 @@ export class TrainScheduleBooking extends BaseEntity { @ManyToOne(() => Booking) @JoinColumn({ name: 'booking_id' }) booking?: Booking; + + @Column({ name: 'loading_status', type: 'varchar', length: 20, default: 'UNLOADED' }) + loadingStatus!: string; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index d1ed23ef7..899b302fc 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -61,6 +61,12 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true }) trainNumber?: string | null; + // Human-facing unique schedule reference (S-YYYY-NNNNN). Shown on the schedule + // list, booking windows, and load lists. Assigned at creation from the highest + // sequence issued this year (see TrainSchedulesRepository.maxReferenceSequence). + @Column({ name: 'reference', type: 'varchar', length: 20, nullable: true, unique: true }) + reference?: string | null; + @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true }) direction?: string | null; @@ -83,6 +89,63 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' }) bookingWindowStatus!: string; + /** + * Booking-window lifecycle for the one-booking-day cycle + * (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → reopen | CLOSED_FOR_DAY | DONE). + * NULL on legacy and DOMESTIC schedules — the window engine ignores those. + */ + @Column({ name: 'window_phase', type: 'varchar', length: 20, nullable: true }) + windowPhase?: string | null; + + @Column({ name: 'window_opens_at', type: 'timestamptz', nullable: true }) + windowOpensAt?: Date | null; + + @Column({ name: 'window_closes_at', type: 'timestamptz', nullable: true }) + windowClosesAt?: Date | null; + + @Column({ name: 'doc_review_ends_at', type: 'timestamptz', nullable: true }) + docReviewEndsAt?: Date | null; + + /** Staff finished document review early — starts the batch/payment phase immediately. */ + @Column({ name: 'doc_review_completed_at', type: 'timestamptz', nullable: true }) + docReviewCompletedAt?: Date | null; + + @Column({ name: 'payment_phase_ends_at', type: 'timestamptz', nullable: true }) + paymentPhaseEndsAt?: Date | null; + + /** 1-based count of open→settle cycles run on the booking day. */ + @Column({ name: 'booking_cycle_no', type: 'int', default: 0 }) + bookingCycleNo!: number; + + // ── Booking-window rule snapshot ────────────────────────────────────────── + // The scheduling rule this train was created with, frozen at creation. A later + // global-rules edit applies only to FUTURE schedules — an already-open schedule + // keeps its base rule. The batch board derives its display windows (open time + + // reopen cycles) from THIS snapshot, never from the live global config. NULL on + // legacy rows created before the snapshot existed (board falls back to live cfg). + @Column({ name: 'rule_window_open_hour', type: 'int', nullable: true }) + ruleWindowOpenHour?: number | null; + + /** EAT hour the daily booking desk shuts (equals open hour for a 24h desk). */ + @Column({ name: 'rule_window_close_hour', type: 'int', nullable: true }) + ruleWindowCloseHour?: number | null; + + @Column({ name: 'rule_window_duration_hours', type: 'numeric', precision: 6, scale: 4, nullable: true }) + ruleWindowDurationHours?: number | null; + + /** + * Frozen reopen gap = doc-review + payment minutes at creation. The board + * projects each next cycle at close + this delay, then snaps it into office hours. + */ + @Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true }) + ruleReopenDelayMinutes?: number | null; + + @Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true }) + ruleImportWindowLeadDays?: number | null; + + @Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true }) + ruleExportBookingLeadHours?: number | null; + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) scheduleBookings?: TrainScheduleBooking[]; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts index 4ccfcd469..64607ecb8 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts @@ -47,4 +47,27 @@ export class TrainScheduleBookingsRepository extends BaseRepository { + return this.repo(manager).find({ + where: { trainScheduleId }, + select: { id: true, bookingId: true, trainScheduleId: true, loadingStatus: true }, + }); + } + + async updateLoadingStatusMany( + trainScheduleId: string, + bookingIds: string[], + loadingStatus: string, + manager?: EntityManager, + ): Promise { + if (!bookingIds.length) return; + await this.repo(manager).update( + { trainScheduleId, bookingId: In(bookingIds) }, + { loadingStatus }, + ); + } } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 58e71143c..700a38983 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -58,4 +58,22 @@ export class TrainSchedulesRepository extends BaseRepository { ): Promise { await this.repo(manager).update(id, { status, ...extra } as never); } + + /** + * Highest NNNNN sequence already issued for `S--…` references. Includes + * soft-deleted rows so the next number never reuses one still occupying the + * unique index (see the same pattern on BookingsRepository). + */ + async maxReferenceSequence(year: number): Promise { + const row = await this.repository + .createQueryBuilder('schedule') + .withDeleted() + .select( + "COALESCE(MAX(CAST(SUBSTRING(schedule.reference FROM '[0-9]+$') AS int)), 0)", + 'max', + ) + .where('schedule.reference LIKE :prefix', { prefix: `S-${year}-%` }) + .getRawOne<{ max: string | number | null }>(); + return Number(row?.max ?? 0); + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts index e765e5694..ba0995679 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -3,9 +3,10 @@ import { listBatchWindowsForDate, listBatchWindowsForBookings, BATCH_WINDOW_START_HOURS, - boardWindowForTimestamp, - listBoardWindowsForRange, + listConfigBookingWindows, groupBookingsIntoBoardWindows, + computeImportWindowTimes, + type BoardWindowConfig, } from './batch-window.util'; describe('batch-window.util', () => { @@ -54,83 +55,288 @@ describe('batch-window.util', () => { }); }); -describe('batch-window board windows (midnight-based 3h slots)', () => { - it('maps 04:00 EAT to the 03:00–06:00 slot', () => { - // 01:00 UTC = 04:00 EAT on 11 Jun - const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z')); - expect(w.label).toContain('03:00'); - expect(w.label).toContain('06:00'); - expect(w.date).toBe('2026-06-11'); - expect(w.dateLabel).toContain('11 Jun'); +describe('computeImportWindowTimes — first-window open respects office hours', () => { + // Departs Mon 06 Jul 08:00 EAT (05:00 UTC). Lead 3 days → anchor 03 Jul 08:00 + // EAT (05:00 UTC). Bounded desk 08:00–17:00, 15h window. + const departure = new Date('2026-07-06T05:00:00.000Z'); + const bounded = { + importWindowLeadDays: 3, + windowOpenHour: 8, + windowCloseHour: 17, + windowDurationHours: 15, + }; + + it('opens at the morning anchor when now is before the lead window', () => { + // Now = 02 Jul 06:00 EAT (before the 03 Jul anchor). + const now = new Date('2026-07-02T03:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now); + // Anchor: 03 Jul 08:00 EAT = 05:00 UTC. + expect(windowOpensAt.toISOString()).toBe('2026-07-03T05:00:00.000Z'); }); - it('maps 00:30 EAT to the 00:00–03:00 slot of that EAT day', () => { - // 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun - const w = boardWindowForTimestamp(new Date('2026-06-10T21:30:00.000Z')); - expect(w.label).toContain('00:00'); - expect(w.label).toContain('03:00'); - expect(w.date).toBe('2026-06-11'); + it('opens NOW when inside the lead window and inside office hours (past the anchor)', () => { + // Now = 05 Jul 12:00 EAT (09:00 UTC): inside lead days, inside 08:00–17:00, + // anchor already passed → open immediately. + const now = new Date('2026-07-05T09:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-05T09:00:00.000Z'); }); - it('maps 23:00 EAT to the final 21:00–24:00 slot', () => { - // 20:00 UTC = 23:00 EAT on 11 Jun - const w = boardWindowForTimestamp(new Date('2026-06-11T20:00:00.000Z')); - expect(w.label).toContain('21:00'); - expect(w.label).toContain('24:00'); - expect(w.date).toBe('2026-06-11'); + it('waits for next morning when now is after the desk closes', () => { + // Departs 06 Jul 14:00 EAT (11:00 UTC) so next-morning open sits before departure. + // Now = 05 Jul 18:00 EAT (15:00 UTC): after 17:00 close → open 06 Jul 08:00 EAT. + const lateDeparture = new Date('2026-07-06T11:00:00.000Z'); + const now = new Date('2026-07-05T15:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(lateDeparture, bounded, now); + // 06 Jul 08:00 EAT = 05:00 UTC. + expect(windowOpensAt.toISOString()).toBe('2026-07-06T05:00:00.000Z'); }); - it('lists a continuous range open→departure clamped at both ends', () => { - // open 05 Jun 08:00 EAT (05:00 UTC) → departs 08 Jun 14:00 EAT (11:00 UTC) - const open = new Date('2026-06-05T05:00:00.000Z'); + it('opens this morning when now is before the desk opens on a lead day', () => { + // Now = 05 Jul 06:00 EAT (03:00 UTC): inside lead days but before 08:00 → 08:00 today. + const now = new Date('2026-07-05T03:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z'); + }); + + it('24-hour desk opens NOW at any hour, day or night, once inside the lead window', () => { + // Round-the-clock desk (open === close). Now = 05 Jul 03:00 EAT (00:00 UTC), + // deep night, past the anchor → open immediately. + const roundClock = { ...bounded, windowOpenHour: 8, windowCloseHour: 8 }; + const now = new Date('2026-07-05T00:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, roundClock, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-05T00:00:00.000Z'); + }); + + it('caps the close at departure', () => { + // Opens now (05 Jul 12:00 EAT); a 24h duration would close 06 Jul 12:00 EAT, + // past the 06 Jul 08:00 departure → clamped to departure. + const now = new Date('2026-07-05T09:00:00.000Z'); + const { windowClosesAt } = computeImportWindowTimes( + departure, + { ...bounded, windowDurationHours: 24 }, + now, + ); + expect(windowClosesAt.toISOString()).toBe(departure.toISOString()); + }); + + describe('overnight desk (open > close, wraps past midnight)', () => { + // Desk open 08:00, closes 05:00 next morning — open across midnight. + const overnight = { ...bounded, windowOpenHour: 8, windowCloseHour: 5 }; + + it('opens NOW at 00:00 (deep night is INSIDE the overnight window)', () => { + // Now = 05 Jul 00:00 EAT (04 Jul 21:00 UTC): after midnight, before 05:00 → + // inside the overnight desk → open immediately. This is the reported bug. + const now = new Date('2026-07-04T21:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-04T21:00:00.000Z'); + }); + + it('opens NOW at 22:00 (evening is INSIDE the overnight window)', () => { + // Now = 05 Jul 22:00 EAT (19:00 UTC): after 08:00 open → inside → open now. + const now = new Date('2026-07-05T19:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-05T19:00:00.000Z'); + }); + + it('waits to 08:00 when now is in the daytime gap [05:00, 08:00)', () => { + // Now = 05 Jul 06:00 EAT (03:00 UTC): desk shut (gap) → open 08:00 today. + const now = new Date('2026-07-05T03:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now); + // 05 Jul 08:00 EAT = 05:00 UTC. + expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z'); + }); + }); +}); + +describe('computeImportWindowTimes — overnight desk (open > close, wraps midnight)', () => { + // Overnight desk 08:00 → 07:00 next morning: open across [08:00, 24:00) and + // [00:00, 07:00). Only the daytime gap [07:00, 08:00) is shut. + const overnight = { + importWindowLeadDays: 3, + windowOpenHour: 8, + windowCloseHour: 7, + windowDurationHours: 6, + }; + + it('opens NOW in the evening side of the window (after open hour)', () => { + // Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 05 Jul 20:00 EAT (17:00 UTC): + // ≥ 08:00 → desk open → open immediately. + const departure = new Date('2026-07-06T07:00:00.000Z'); + const now = new Date('2026-07-05T17:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-05T17:00:00.000Z'); + }); + + it('opens NOW after midnight (before close hour)', () => { + // Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 06 Jul 02:00 EAT (05 Jul 23:00 + // UTC): < 07:00 → still inside the overnight window → open immediately. + const departure = new Date('2026-07-06T07:00:00.000Z'); + const now = new Date('2026-07-05T23:00:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-05T23:00:00.000Z'); + }); + + it('waits until open hour in the daytime gap [close, open)', () => { + // Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 05 Jul 07:30 EAT (04:30 UTC): + // in the shut daytime gap → opens 05 Jul 08:00 EAT (05:00 UTC). + const departure = new Date('2026-07-06T07:00:00.000Z'); + const now = new Date('2026-07-05T04:30:00.000Z'); + const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now); + expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z'); + }); +}); + +describe('batch-window board windows (config-driven booking cycles)', () => { + // Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure, + // 3h long, reopen 90m later. + const cfg: BoardWindowConfig = { + importWindowLeadDays: 3, + windowOpenHour: 8, + windowCloseHour: 17, + windowDurationHours: 3, + reopenDelayMinutes: 90, + exportBookingLeadHours: 24, + }; + + it('import: first window opens at windowOpenHour EAT, importWindowLeadDays before departure', () => { + // departs 08 Jun 14:00 EAT (11:00 UTC) → window day = 05 Jun, opens 08:00 EAT (05:00 UTC) const departure = new Date('2026-06-08T11:00:00.000Z'); - const windows = listBoardWindowsForRange(open, departure); + const windows = listConfigBookingWindows('IMPORT', departure, cfg); - // Day 5: 06,09,12,15,18,21 = 6 ; Days 6,7: 8 each ; Day 8: 00,03,06,09,12 = 5 - expect(windows).toHaveLength(6 + 8 + 8 + 5); expect(windows[0].date).toBe('2026-06-05'); - expect(windows[0].label).toContain('06:00'); - expect(windows[0].label).toContain('09:00'); - const last = windows[windows.length - 1]; - expect(last.date).toBe('2026-06-08'); - expect(last.label).toContain('12:00'); - expect(last.label).toContain('15:00'); - // chronological + unique keys - const keys = windows.map((w) => w.key); - expect(new Set(keys).size).toBe(keys.length); + expect(windows[0].label).toContain('08:00'); + expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z'); + // end = open + windowDurationHours (3h) = 08:00 → 11:00 EAT (08:00 UTC) + expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z'); }); - it('handles a same-day open→departure range', () => { - const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (06–09 slot) - const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (12–15 slot) - const windows = listBoardWindowsForRange(open, departure); - // 06,09,12 = 3 slots - expect(windows).toHaveLength(3); - expect(windows.every((w) => w.date === '2026-06-05')).toBe(true); + it('import: reopens reopenDelayMinutes after close while inside office hours', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listConfigBookingWindows('IMPORT', departure, cfg); + // cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT, same day + expect(windows.length).toBeGreaterThanOrEqual(2); + expect(windows[1].start.toISOString()).toBe('2026-06-05T09:30:00.000Z'); // 12:30 EAT + expect(windows[1].date).toBe('2026-06-05'); }); - it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => { - const open = new Date('2026-06-05T05:00:00.000Z'); - const departure = new Date('2026-06-06T11:00:00.000Z'); + it('import: pauses at close hour and resumes next morning at open hour', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listConfigBookingWindows('IMPORT', departure, cfg); + // Day 05 Jun: 08:00, 12:30, 17:00-clamped… the cycle whose reopen lands + // at/after 17:00 EAT rolls to 06 Jun 08:00 EAT (05:00 UTC). + const day6First = windows.find((w) => w.date === '2026-06-06'); + expect(day6First).toBeDefined(); + expect(day6First!.start.toISOString()).toBe('2026-06-06T05:00:00.000Z'); // 08:00 EAT + // Cycles span the office days between the window day and departure. + const days = new Set(windows.map((w) => w.date)); + expect(days.has('2026-06-05')).toBe(true); + expect(days.has('2026-06-06')).toBe(true); + }); + + it('import: 24-hour desk (open hour === close hour) never breaks for the day', () => { + const roundClock: BoardWindowConfig = { ...cfg, windowOpenHour: 8, windowCloseHour: 8 }; + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listConfigBookingWindows('IMPORT', departure, roundClock); + // Reopen chains straight through midnight: an overnight cycle exists. + const crossesNight = windows.some( + (w, i) => i > 0 && windows[i - 1].date !== w.date, + ); + expect(crossesNight).toBe(true); + // Cycles run continuously from the window day up to departure — the last one + // reaches departure, proving the runaway cap did not truncate the projection. + expect(windows[windows.length - 1].end.getTime()).toBe(departure.getTime()); + // Spans the full lead (window day 05 Jun → departure 08 Jun). + expect(new Set(windows.map((w) => w.date)).size).toBeGreaterThanOrEqual(3); + }); + + it('export: single FCFS window exportBookingLeadHours before departure', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listConfigBookingWindows('EXPORT', departure, cfg); + expect(windows).toHaveLength(1); + // 24h before 11:00 UTC on 08 Jun = 11:00 UTC on 07 Jun + expect(windows[0].start.toISOString()).toBe('2026-06-07T11:00:00.000Z'); + expect(windows[0].end.toISOString()).toBe(departure.toISOString()); + }); + + it('buckets bookings into config cycles and keeps empty + pending windows', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); const items = [ - { id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 06–09 on 5th + { id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → inside cycle 1 { id: 'b', ts: null }, // pending ]; const map = groupBookingsIntoBoardWindows( items, (i) => i.ts, - open, + 'IMPORT', departure, + cfg, 'pending-contract', ); const pending = map.get('pending-contract'); expect(pending?.items.map((i) => i.id)).toEqual(['b']); const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a')); expect(withA?.window?.date).toBe('2026-06-05'); - // empty slots are retained for the UI + // empty cycles are retained for the UI const emptyCount = [...map.values()].filter( (b) => b.window && b.items.length === 0, ).length; expect(emptyCount).toBeGreaterThan(0); }); + + it('attaches a booking made before the window opened to the first cycle', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const items = [{ id: 'early', ts: new Date('2026-06-01T00:00:00.000Z') }]; + const map = groupBookingsIntoBoardWindows( + items, + (i) => i.ts, + 'IMPORT', + departure, + cfg, + 'pending-contract', + ); + const withEarly = [...map.values()].find((b) => + b.items.some((i) => i.id === 'early'), + ); + expect(withEarly?.window?.date).toBe('2026-06-05'); + expect(withEarly?.window?.label).toContain('08:00'); + }); +}); + +// Regression: a schedule created INSIDE its own window day must open right away +// when the desk is open, and re-deriving after a settings change (close hour +// extended past "now", or lead pulled so the window day becomes today) must +// yield an immediate open — not tomorrow morning. +describe('computeImportWindowTimes — immediate open inside the window day', () => { + // 19:15:17 EAT on Mon 6 Jul = 16:15:17 UTC + const now = new Date('2026-07-06T16:15:17.000Z'); + // Departs Thu 9 Jul ~08:53 EAT + const departure = new Date('2026-07-09T05:53:00.000Z'); + const base = { importWindowLeadDays: 3, windowOpenHour: 8, windowDurationHours: 0.05 }; + + it('desk 8–23, created 19:15 on the window day → opens NOW', () => { + const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now); + expect(t.windowOpensAt.getTime()).toBe(now.getTime()); + }); + + it('desk 8–17, created 19:15 (desk shut) → opens next morning 08:00 EAT', () => { + const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 17 }, now); + expect(t.windowOpensAt.toISOString()).toBe('2026-07-07T05:00:00.000Z'); + }); + + it('close hour extended 17 → 23 after hours: re-derive opens NOW', () => { + // Same call restampPendingWindows makes after the global-rules edit. + const t = computeImportWindowTimes(departure, { ...base, windowCloseHour: 23 }, now); + expect(t.windowOpensAt.getTime()).toBe(now.getTime()); + }); + + it('lead 3 → 4 pulls the window day to today: re-derive opens NOW', () => { + const departsJul10 = new Date('2026-07-10T05:53:00.000Z'); + const t = computeImportWindowTimes( + departsJul10, + { ...base, importWindowLeadDays: 4, windowCloseHour: 23 }, + now, + ); + expect(t.windowOpensAt.getTime()).toBe(now.getTime()); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index 7316e1610..0fdacc572 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -121,6 +121,216 @@ function windowFromEatStart( }; } +/** Build a UTC Date for an EAT wall-clock time on a `yyyy-MM-dd` EAT calendar day. */ +export function eatDayToUtc(day: string, hour: number, minute = 0): Date { + const [year, month, dayOfMonth] = day.split('-').map(Number); + return eatToUtc(year, month, dayOfMonth, hour, minute); +} + +/** Shift a `yyyy-MM-dd` EAT day key by whole days. */ +export function shiftEatDay(day: string, deltaDays: number): string { + // Noon UTC keeps the +3h EAT offset from crossing a day boundary. + const [year, month, dayOfMonth] = day.split('-').map(Number); + const shifted = new Date(Date.UTC(year, month - 1, dayOfMonth + deltaDays, 12)); + return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, '0')}-${String( + shifted.getUTCDate(), + ).padStart(2, '0')}`; +} + +/** + * The daily office window `[openHour, closeHour)` in EAT: after `closeHour` the + * booking desk is shut and reopens `openHour` the next morning. `openHour === + * closeHour` means a 24-hour desk that never breaks for the day. + */ +export interface OfficeHours { + windowOpenHour: number; + windowCloseHour: number; +} + +/** True when the desk runs round the clock (open hour equals close hour). */ +export function isRoundTheClock(hours: OfficeHours): boolean { + return hours.windowOpenHour === hours.windowCloseHour; +} + +/** + * Where the NEXT booking cycle opens after a cycle closes at `closedAt`, given a + * not-yet-full train and a daily office window. `earliestNextOpen` is the raw + * ready time (close + doc-review + payment); the desk honours it only while + * inside office hours: + * + * • round-the-clock desk → opens at `earliestNextOpen` (no day break) + * • ready time before closeHour → opens at `earliestNextOpen`, same day + * • ready time at/after closeHour → desk shut; opens next morning at openHour + * + * Returns `null` when the next open would fall on/after `departure` — the train + * leaves before another cycle could run, so the window is done. + * + * The desk may run within one EAT day (`closeHour > openHour`), round the clock + * (`openHour === closeHour`), or overnight across midnight (`openHour > + * closeHour`, e.g. 08:00 → 07:00). `officeHoursOpen` handles all three. + */ +/** + * The EAT instant a booking cycle would open if it became ready at `readyAt`, + * honouring the daily office window but WITHOUT any departure bound: + * + * • round-the-clock desk → opens at `readyAt` (no day break) + * • ready before openHour → opens at openHour that EAT morning + * • ready inside office hours → opens at `readyAt` + * • ready at/after closeHour → opens at openHour the next morning + * + * `nextCycleOpensAt` layers the "before departure" gate on top of this; the first + * import window uses it directly and lets its own departure cap apply. + */ +export function officeHoursOpen(readyAt: Date, hours: OfficeHours): Date { + if (isRoundTheClock(hours)) { + return readyAt; + } + const { hour, minute } = eatParts(readyAt); + const readyMinutes = hour * 60 + minute; + const openMinutes = hours.windowOpenHour * 60; + const closeMinutes = hours.windowCloseHour * 60; + + if (hours.windowOpenHour > hours.windowCloseHour) { + // Overnight desk, e.g. open 08:00 → close 07:00 next morning. The desk is + // open across midnight: [openHour, 24:00) on this EAT day and [00:00, + // closeHour) on the next. Only the daytime gap [closeHour, openHour) is shut. + if (readyMinutes >= openMinutes || readyMinutes < closeMinutes) { + // Inside the overnight window (either side of midnight) → open when ready. + return readyAt; + } + // In the daytime gap → the desk opens again at openHour this EAT morning. + return eatDayToUtc(eatDay(readyAt), hours.windowOpenHour); + } + + if (readyMinutes < openMinutes) { + // Ready before the desk opens on its own EAT calendar day → open this morning. + return eatDayToUtc(eatDay(readyAt), hours.windowOpenHour); + } + if (readyMinutes < closeMinutes) { + // Inside office hours → open as soon as ready. + return readyAt; + } + // Desk shut for the day → open tomorrow morning. + return eatDayToUtc(shiftEatDay(eatDay(readyAt), 1), hours.windowOpenHour); +} + +export function nextCycleOpensAt( + earliestNextOpen: Date, + hours: OfficeHours, + departure: Date, +): Date | null { + const opensAt = officeHoursOpen(earliestNextOpen, hours); + return opensAt.getTime() < departure.getTime() ? opensAt : null; +} + +export interface InitialWindowTimes { + windowOpensAt: Date; + windowClosesAt: Date; +} + +/** + * Import booking-day window. The natural anchor is `windowOpenHour` EAT on + * departure-day minus `importWindowLeadDays`. When `now` is at/before that anchor + * (we're still before the lead window) the window opens at the anchor — the normal + * morning wait. + * + * Once `now` is PAST the anchor we're already inside the lead window, so the desk's + * office hours decide the open the same way a reopen cycle does (via + * `nextCycleOpensAt`): + * + * • 24-hour desk (open === close) → opens at `now`, any hour, day or night + * • `now` inside [openHour, closeHour) → opens at `now` (desk is open right now) + * • `now` before openHour that EAT day → opens at openHour that morning + * • `now` at/after closeHour → desk shut; opens openHour next morning + * + * `windowDurationHours` extends from that open, capped at departure. + */ +export function computeImportWindowTimes( + departure: Date, + cfg: { + importWindowLeadDays: number; + windowOpenHour: number; + windowCloseHour: number; + windowDurationHours: number; + }, + now: Date, +): InitialWindowTimes { + const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); + const anchor = eatDayToUtc(windowDay, cfg.windowOpenHour); + + let opensAt: Date; + if (now.getTime() <= anchor.getTime()) { + // Before the lead window → normal morning wait at the anchor. + opensAt = anchor; + } else { + // Inside the lead window → the office-hours rule decides the open, exactly as a + // reopen cycle does: open now if the desk is open now (or round-the-clock), + // else at the next open hour. We use the same primitive as reopen cycles but + // WITHOUT its `< departure` null-gate — when the next open lands on/after + // departure the shared cap below clamps the (zero-length) window to departure, + // which is truthful, rather than masking it as "open now". + opensAt = officeHoursOpen(now, { + windowOpenHour: cfg.windowOpenHour, + windowCloseHour: cfg.windowCloseHour, + }); + } + + let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000); + if (closesAt.getTime() > departure.getTime()) { + closesAt = departure; + } + return { windowOpensAt: opensAt, windowClosesAt: closesAt }; +} + +/** + * Export booking window: a single FCFS window from `exportBookingLeadHours` + * before departure until departure. The open honours the daily desk hours — + * when the raw lead instant lands while the desk is shut, the window opens at + * the next desk opening instead (capped at departure, so a config whose desk + * never opens before the train leaves yields a zero-length window rather than + * one that outlives the train). + */ +export function computeExportWindowTimes( + departure: Date, + cfg: { + exportBookingLeadHours: number; + windowOpenHour: number; + windowCloseHour: number; + }, +): InitialWindowTimes { + const rawOpen = new Date( + departure.getTime() - cfg.exportBookingLeadHours * 3_600_000, + ); + let opensAt = officeHoursOpen(rawOpen, { + windowOpenHour: cfg.windowOpenHour, + windowCloseHour: cfg.windowCloseHour, + }); + if (opensAt.getTime() > departure.getTime()) { + opensAt = departure; + } + return { windowOpensAt: opensAt, windowClosesAt: departure }; +} + +/** + * Earliest departure a train may be scheduled for — staff cannot schedule inside + * the lead window. IMPORT/DOMESTIC lead is in whole EAT days: with lead 3 and + * today the 11th, the 12th and 13th are blocked and the 14th is the first + * allowed departure day (00:00 EAT). EXPORT lead is in hours: earliest departure + * is `now + exportBookingLeadHours` (24h = 1 day). Mirrors the booking-window + * math so a schedulable date always has a real booking window before it. + */ +export function earliestSchedulableDeparture( + direction: string | null | undefined, + cfg: { importWindowLeadDays: number; exportBookingLeadHours: number }, + now: Date, +): Date { + if (direction === 'EXPORT') { + return new Date(now.getTime() + cfg.exportBookingLeadHours * 3_600_000); + } + const earliestDay = shiftEatDay(eatDay(now), cfg.importWindowLeadDays); + return eatDayToUtc(earliestDay, 0); +} + /** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */ export function getBatchWindowForTimestamp(date: Date): BatchWindow { const { year, month, day, hour } = eatParts(date); @@ -170,14 +380,13 @@ export function listBatchWindowsForBookings( } // --------------------------------------------------------------------------- -// Board-display windows: full-day, midnight-based 3h slots over a date range. -// These are used ONLY for the batch-board UI grouping (not persisted, and -// independent of the cron intake hours above). +// Board-display windows: the REAL booking-window cycles derived from the +// train_scheduling_global_rules config (window open hour, lead days, duration, +// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle +// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after +// reopenDelayMinutes until departure). Export shows the single FCFS lead window. // --------------------------------------------------------------------------- -/** Midnight-based 3-hour slot starts (00–03, 03–06, … 21–24). */ -export const BOARD_WINDOW_HOURS = [0, 3, 6, 9, 12, 15, 18, 21] as const; - /** A board window carries an EAT calendar date in addition to the slot times. */ export interface BoardWindow extends BatchWindow { /** EAT calendar day as ISO `YYYY-MM-DD`. */ @@ -186,6 +395,18 @@ export interface BoardWindow extends BatchWindow { dateLabel: string; } +/** Config fields the board needs to reconstruct booking-window cycles. */ +export interface BoardWindowConfig { + importWindowLeadDays: number; + windowOpenHour: number; + /** EAT hour the daily booking desk shuts; equals windowOpenHour for a 24h desk. */ + windowCloseHour: number; + windowDurationHours: number; + /** Gap between a cycle's close and its reopen (doc review + payment minutes). */ + reopenDelayMinutes: number; + exportBookingLeadHours: number; +} + const dayLabelFmt = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: '2-digit', @@ -197,119 +418,143 @@ function pad2(n: number): string { return String(n).padStart(2, '0'); } -/** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */ -function boardWindowFromEatStart( - year: number, - month: number, - day: number, - startHour: number, -): BoardWindow { - const start = eatToUtc(year, month, day, startHour); - const endHour = startHour + 3; // 21 -> 24 (handled by Date.UTC roll-over) - const end = eatToUtc(year, month, day, endHour); - const endLabel = endHour >= 24 ? '24:00' : `${pad2(endHour)}:00`; +/** Wrap a [start, end] interval as a labelled BoardWindow keyed on its EAT day. */ +function boardWindowFromInterval(start: Date, end: Date): BoardWindow { + const { year, month, day } = eatParts(start); return { key: start.toISOString(), start, end, - label: formatWindowLabel(start, end, endLabel), + label: formatWindowLabel(start, end), date: `${year}-${pad2(month)}-${pad2(day)}`, dateLabel: dayLabelFmt.format(start), }; } -/** Which midnight-based 3h EAT slot a timestamp falls in. */ -export function boardWindowForTimestamp(date: Date): BoardWindow { - const { year, month, day, hour } = eatParts(date); - let startHour: (typeof BOARD_WINDOW_HOURS)[number] = 0; - for (const h of BOARD_WINDOW_HOURS) { - if (hour >= h) startHour = h; - } - return boardWindowFromEatStart(year, month, day, startHour); -} - /** - * Continuous list of board windows from `openDate` to `departureDate` (inclusive), - * clamped to the slot containing `openDate` on the first day and the slot - * containing `departureDate` on the last day. Returned in chronological order. + * The real booking-window cycles for a schedule, straight from config. + * + * IMPORT: first window opens at `windowOpenHour` EAT on `departure − importWindowLeadDays` + * for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes` + * after each close, on the same booking day, until departure. This mirrors + * `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the + * exact windows the engine runs. + * EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure, + * with the open shifted to the next desk opening when it lands outside office hours + * (same math as `computeExportWindowTimes`). + * + * `anchorOpensAt` pins the FIRST window's open time to the schedule's stored + * `windowOpensAt` instead of recomputing it from config. Pass it so the board + * shows the real frozen window (and reopen cycles projected from it) even after + * the global rule changed — the recomputed open time would otherwise drift. */ -export function listBoardWindowsForRange( - openDate: Date, - departureDate: Date, +export function listConfigBookingWindows( + direction: string | null | undefined, + departure: Date, + cfg: BoardWindowConfig, + anchorOpensAt?: Date | null, ): BoardWindow[] { - const startWin = boardWindowForTimestamp(openDate); - const endWin = boardWindowForTimestamp(departureDate); - // Guard against an inverted range (departure before open). - if (endWin.start.getTime() < startWin.start.getTime()) { - return [startWin]; + if (direction === 'EXPORT') { + const start = + anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt; + return [boardWindowFromInterval(start, departure)]; } const windows: BoardWindow[] = []; - const seen = new Set(); - // Walk day-by-day in EAT, emitting each day's slots, stepping via UTC noon to - // avoid any boundary ambiguity, then filter to [startWin.start, endWin.start]. - let cursor = new Date(eatToUtc( - Number(startWin.date.slice(0, 4)), - Number(startWin.date.slice(5, 7)), - Number(startWin.date.slice(8, 10)), - 12, - )); - const lastDayMs = eatToUtc( - Number(endWin.date.slice(0, 4)), - Number(endWin.date.slice(5, 7)), - Number(endWin.date.slice(8, 10)), - 12, - ).getTime(); + const durationMs = cfg.windowDurationHours * 3_600_000; + // Post-close gap before the next cycle opens (doc review + payment), subject + // to office hours below. + const reopenMs = cfg.reopenDelayMinutes * 60_000; + const officeHours: OfficeHours = { + windowOpenHour: cfg.windowOpenHour, + windowCloseHour: cfg.windowCloseHour, + }; + const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); - while (cursor.getTime() <= lastDayMs) { - const { year, month, day } = eatParts(cursor); - for (const h of BOARD_WINDOW_HOURS) { - const w = boardWindowFromEatStart(year, month, day, h); - if ( - w.start.getTime() >= startWin.start.getTime() && - w.start.getTime() <= endWin.start.getTime() && - !seen.has(w.key) - ) { - seen.add(w.key); - windows.push(w); - } - } - cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000); + let opensAt: Date | null = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour); + // The loop terminates naturally: every cycle advances opensAt by at least + // (duration + reopen) > 0, and nextCycleOpensAt returns null once opensAt would + // reach departure. maxCycles is a derived runaway backstop sized to the real + // span (first open → departure) over the smallest possible advance, so a + // legitimate config is never silently truncated — only a pathological + // zero-length one would hit it. + const spanMs = departure.getTime() - opensAt.getTime(); + const minAdvanceMs = Math.max(durationMs + reopenMs, 60_000); + const maxCycles = Math.ceil(spanMs / minAdvanceMs) + 2; + for (let cycle = 0; cycle < maxCycles; cycle += 1) { + if (opensAt.getTime() >= departure.getTime()) break; + let closesAt = new Date(opensAt.getTime() + durationMs); + if (closesAt.getTime() > departure.getTime()) closesAt = departure; + windows.push(boardWindowFromInterval(opensAt, closesAt)); + + const earliestNextOpen = new Date(closesAt.getTime() + reopenMs); + opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, departure); + if (opensAt == null) break; } - windows.sort(compareBatchWindows); + // Degenerate config (no window before departure) — surface a single window + // clamped to departure so the board still renders something meaningful. + if (windows.length === 0) { + windows.push(boardWindowFromInterval(new Date(departure.getTime() - durationMs), departure)); + } return windows; } +/** Which config booking-window a timestamp falls in; null if before/after all of them. */ +function configWindowForTimestamp( + windows: BoardWindow[], + date: Date, +): BoardWindow | null { + const ms = date.getTime(); + for (const w of windows) { + if (ms >= w.start.getTime() && ms < w.end.getTime()) return w; + } + return null; +} + /** - * Group items into board windows spanning [openDate, departureDate]. Empty - * windows are kept so the UI shows every slot. Items whose timestamp falls - * outside the range still get their own window (nothing hidden). Items without - * a timestamp go to `pendingKey`. + * Group items into the real config booking-window cycles for a schedule. Empty + * windows are kept so the UI shows every cycle. Items whose timestamp falls + * outside every window (e.g. a booking created before the window opened) are + * attached to the nearest window by start time so nothing is hidden. Items + * without a timestamp go to `pendingKey`. */ export function groupBookingsIntoBoardWindows( items: T[], getTimestamp: (item: T) => Date | null | undefined, - openDate: Date, - departureDate: Date, + direction: string | null | undefined, + departure: Date, + cfg: BoardWindowConfig, pendingKey = 'pending-contract', + anchorOpensAt?: Date | null, ): Map { + const windows = listConfigBookingWindows(direction, departure, cfg, anchorOpensAt); const map = new Map(); - - for (const w of listBoardWindowsForRange(openDate, departureDate)) { + for (const w of windows) { map.set(w.key, { window: w, items: [] }); } map.set(pendingKey, { window: null, items: [] }); + const firstWindow = windows[0] ?? null; + const lastWindow = windows[windows.length - 1] ?? null; + for (const item of items) { const ts = getTimestamp(item); if (!ts) { map.get(pendingKey)!.items.push(item); continue; } - const w = boardWindowForTimestamp(ts); - if (!map.has(w.key)) { - map.set(w.key, { window: w, items: [] }); + let w = configWindowForTimestamp(windows, ts); + if (!w) { + // Booked before the window opened → first cycle; after it closed → last cycle. + w = + firstWindow && ts.getTime() < firstWindow.start.getTime() + ? firstWindow + : lastWindow; + } + if (!w) { + map.get(pendingKey)!.items.push(item); + continue; } map.get(w.key)!.items.push(item); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index 5c9cb6119..d8dc6b116 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -1,20 +1,14 @@ /** * Tunables for the demand-batching booking → allocation flow. - * Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock. + * Times run in EAT so window boundaries match the local operating clock. + * + * Cadence and pay-window durations moved to the train_scheduling_global_rules + * table (TrainSchedulingService.getWindowConfig) — the window engine + * (BookingWindowService) drives all timing off that config. */ -/** Batch boundaries — every 3h from 00:00 (00–03, 03–06, … 21–24), matching the board windows. */ -// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *'; -// export const BATCH_CRON = '*/3 * * * *'; -export const BATCH_CRON = '*/5 * * * *'; -// export const BATCH_CRON = '0 */3 * * *';// - export const BATCH_TIMEZONE = 'Africa/Addis_Ababa'; -/** How long a selected commercial customer has to pay before their slot expires. */ -// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour -export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode) - /** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */ export const DEFAULT_WAGONS_PER_BOOKING = 1; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 5cd06091a..31d3c8855 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -21,6 +21,8 @@ describe('BookingBatchService — PAID reconcile', () => { findPaidUnlinkedForSchedule: jest.Mock; findBatchPool: jest.Mock; findBatchPoolByRouteDay: jest.Mock; + findBatchPoolByCorridorDay: jest.Mock; + findUnacceptedForRouteDay: jest.Mock; findReservedForSchedule: jest.Mock; update: jest.Mock; }; @@ -35,6 +37,7 @@ describe('BookingBatchService — PAID reconcile', () => { let trainSchedulingService: { tryAutoWagonAllocation: jest.Mock; getBookableSchedules: jest.Mock; + getWindowConfig: jest.Mock; }; let dataSource: { getRepository: jest.Mock; @@ -52,6 +55,8 @@ describe('BookingBatchService — PAID reconcile', () => { findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]), findBatchPool: jest.fn().mockResolvedValue([]), findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]), + findBatchPoolByCorridorDay: jest.fn().mockResolvedValue([]), + findUnacceptedForRouteDay: jest.fn().mockResolvedValue([]), findReservedForSchedule: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(undefined), }; @@ -77,6 +82,16 @@ describe('BookingBatchService — PAID reconcile', () => { violations: [], }), getBookableSchedules: jest.fn().mockResolvedValue([]), + getWindowConfig: jest.fn().mockResolvedValue({ + importWindowLeadDays: 3, + exportBookingLeadHours: 24, + windowOpenHour: 8, + windowCloseHour: 17, + windowDurationHours: 3, + docReviewMinutes: 30, + paymentWindowMinutes: 60, + reopenDelayMinutes: 90, + }), }; const bookingRepo = { @@ -110,6 +125,11 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, + { + syncPayableDueDate: jest.fn().mockResolvedValue(undefined), + expirePayable: jest.fn().mockResolvedValue(undefined), + } as never, + { emitPhase: jest.fn() } as never, ); }); @@ -183,20 +203,29 @@ describe('BookingBatchService — PAID reconcile', () => { cargoTotalWeightVgm: 10, freightType: 'CONTAINER', bookingContainers: [], + originYardId, + destinationYardId, }) as unknown as Booking; beforeEach(() => { - // Two OPEN trains on the same route + day, train A earlier than train B. - trainSchedulingService.getBookableSchedules.mockResolvedValue([ + // Two OPEN legacy trains on the same route + day, train A earlier than train B. + // fillRouteDay now selects fillable schedules straight from the repository. + trainSchedulesRepository.findAll.mockResolvedValue([ { id: trainA, - scheduleDate: '2026-06-20T06:00:00.000Z', + originStationId: originYardId, + destinationStationId: destinationYardId, + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), bookingWindowStatus: 'OPEN', + windowPhase: null, }, { id: trainB, - scheduleDate: '2026-06-20T09:00:00.000Z', + originStationId: originYardId, + destinationStationId: destinationYardId, + scheduledDepartureDate: new Date('2026-06-20T09:00:00.000Z'), bookingWindowStatus: 'OPEN', + windowPhase: null, }, ]); trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) => @@ -207,13 +236,15 @@ describe('BookingBatchService — PAID reconcile', () => { trainSetId: `set-${id}`, trainSet: { locomotive: smallLoco }, scheduleBookings: [], + originStationId: originYardId, + destinationStationId: destinationYardId, }), ); }); it('spills overflow to the next train by priority, then reports unplaced', async () => { // 3 commercial bookings, descending priority; only 1 fits per train (2 total). - bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([ + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([ commercial('hi', 30), commercial('mid', 20), commercial('lo', 10), @@ -221,9 +252,8 @@ describe('BookingBatchService — PAID reconcile', () => { const touched = await service.fillRouteDay(originYardId, destinationYardId, day); - expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith( - originYardId, - destinationYardId, + expect(bookingsRepository.findBatchPoolByCorridorDay).toHaveBeenCalledWith( + [originYardId, destinationYardId], day, ); // Both trains were processed. @@ -238,7 +268,7 @@ describe('BookingBatchService — PAID reconcile', () => { }); it('reserves the chosen train id on each commercial booking', async () => { - bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]); + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([commercial('hi', 30)]); await service.fillRouteDay(originYardId, destinationYardId, day); @@ -251,5 +281,233 @@ describe('BookingBatchService — PAID reconcile', () => { }), ); }); + + it('reserves both partners of a consolidated pair together on one train', async () => { + // Two 20ft bookings, 1 container each — a shared wagon. Both in the pool. + const consol = (id: string, partnerId: string, priority: number): Booking => + ({ + id, + reference: id, + isGovernment: false, + priorityScore: priority, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + consolidationPartnerId: partnerId, + bookingContainers: [{ quantity: 1 }], + originYardId, + destinationYardId, + }) as unknown as Booking; + + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([ + consol('a', 'b', 30), + consol('b', 'a', 20), + ]); + + await service.fillRouteDay(originYardId, destinationYardId, day); + + // Both reserved on the same (first) train; neither reported unplaced. + const reservedIds = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id); + expect(reservedIds.sort()).toEqual(['a', 'b']); + expect(notifier.unplaced).not.toHaveBeenCalled(); + }); + + it('skips a consolidated booking whose partner is not in the pool (both-or-neither)', async () => { + const lonely = { + id: 'a', + reference: 'a', + isGovernment: false, + priorityScore: 30, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + consolidationPartnerId: 'missing-partner', + bookingContainers: [{ quantity: 1 }], + originYardId, + destinationYardId, + } as unknown as Booking; + + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([lonely]); + + await service.fillRouteDay(originYardId, destinationYardId, day); + + // Never reserved — waits for its partner in a later cycle. + expect(notifier.payNow).not.toHaveBeenCalled(); + }); + }); + + describe('expireUnacceptedForRouteDay — doc-review sweep', () => { + const originYardId = 'yard-origin'; + const destinationYardId = 'yard-dest'; + const day = '2026-06-20'; + + const pendingBooking = { + id: 'pending-1', + reference: 'BK-PENDING-1', + status: 'OPERATION_REQUEST_PENDING', + isGovernment: false, + originYardId, + destinationYardId, + } as unknown as Booking; + + beforeEach(() => { + // One fillable schedule on this corridor/day so corridorYardsForRouteDay + // resolves a non-empty yard set (legacy two-stop route → [origin, dest]). + trainSchedulesRepository.findAll.mockResolvedValue([ + { + id: 'sched-1', + originStationId: originYardId, + destinationStationId: destinationYardId, + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), + }, + ]); + }); + + it('expires each un-accepted booking and clears its scheduled day', async () => { + bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([pendingBooking]); + + await service.expireUnacceptedForRouteDay({ + originYardId, + destinationYardId, + day, + }); + + expect(bookingsRepository.findUnacceptedForRouteDay).toHaveBeenCalledWith( + expect.arrayContaining([originYardId, destinationYardId]), + day, + ); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'pending-1', + expect.objectContaining({ + status: 'EXPIRED', + schedulingStatus: 'ELIGIBLE', + scheduledDate: null, + }), + ); + expect(notifier.expired).toHaveBeenCalledWith(pendingBooking); + }); + + it('is a no-op when nothing is un-accepted', async () => { + bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([]); + + await service.expireUnacceptedForRouteDay({ + originYardId, + destinationYardId, + day, + }); + + expect(bookingsRepository.update).not.toHaveBeenCalled(); + expect(notifier.expired).not.toHaveBeenCalled(); + }); + + it('does nothing when the route-day has no fillable schedule', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([]); + + await service.expireUnacceptedForRouteDay({ + originYardId, + destinationYardId, + day, + }); + + expect(bookingsRepository.findUnacceptedForRouteDay).not.toHaveBeenCalled(); + }); + }); + + describe('maybeOfferPartial — split-eligibility gate', () => { + const importGeneral = { + id: 'b1', + reference: 'b1', + isGovernment: false, + tradeDirection: 'IMPORT', + contractKind: 'GENERAL', + consolidationPartnerId: null, + } as unknown as Booking; + + const call = (booking: Booking, isPair: boolean): boolean => + ( + service as unknown as { + isSplitEligible: (b: Booking, p: boolean) => boolean; + } + ).isSplitEligible(booking, isPair); + + it('allows IMPORT + GENERAL when splitService is present', () => { + const withSplit = new BookingBatchService( + dataSource as never, + bookingsRepository as never, + trainSchedulesRepository as never, + trainScheduleBookingsRepository as never, + notifier as never, + { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, + trainSchedulingService as never, + { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { emitPhase: jest.fn() } as never, + undefined, + { findOpenOffer: jest.fn() } as never, + ); + const eligible = ( + withSplit as unknown as { + isSplitEligible: (b: Booking, p: boolean) => boolean; + } + ).isSplitEligible(importGeneral, false); + expect(eligible).toBe(true); + }); + + it('allows IMPORT + ONE_TIME (promoted to GENERAL on split)', () => { + const withSplit = new BookingBatchService( + dataSource as never, + bookingsRepository as never, + trainSchedulesRepository as never, + trainScheduleBookingsRepository as never, + notifier as never, + { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, + trainSchedulingService as never, + { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { emitPhase: jest.fn() } as never, + undefined, + { findOpenOffer: jest.fn() } as never, + ); + const eligible = ( + withSplit as unknown as { + isSplitEligible: (b: Booking, p: boolean) => boolean; + } + ).isSplitEligible( + { ...importGeneral, contractKind: 'ONE_TIME' } as Booking, + false, + ); + expect(eligible).toBe(true); + }); + + it('rejects when splitService is absent (default test service)', () => { + // `service` from the outer beforeEach was built without a splitService. + expect(call(importGeneral, false)).toBe(false); + }); + + it('rejects EXPORT, government, consolidated pairs, and other directions', () => { + const withSplit = new BookingBatchService( + dataSource as never, + bookingsRepository as never, + trainSchedulesRepository as never, + trainScheduleBookingsRepository as never, + notifier as never, + { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, + trainSchedulingService as never, + { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { emitPhase: jest.fn() } as never, + undefined, + { findOpenOffer: jest.fn() } as never, + ); + const check = ( + withSplit as unknown as { + isSplitEligible: (b: Booking, p: boolean) => boolean; + } + ).isSplitEligible.bind(withSplit); + + expect(check({ ...importGeneral, tradeDirection: 'EXPORT' } as Booking, false)).toBe(false); + expect(check({ ...importGeneral, isGovernment: true } as Booking, false)).toBe(false); + expect(check(importGeneral, true)).toBe(false); // consolidated pair + expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false); + }); }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index f31d8561d..26f8749be 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -1,17 +1,21 @@ import { BadRequestException, + ConflictException, Injectable, Logger, NotFoundException, OnModuleInit, + Optional, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; -import { Cron, SchedulerRegistry } from '@nestjs/schedule'; -import { DataSource } from 'typeorm'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { DataSource, In } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; +import { formatRouteLabel } from '../routes/entities/route.entity'; +import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; @@ -20,27 +24,33 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; +import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types"; +import { BillingService } from "../billing/billing.service"; + + import { - BATCH_CRON, - BATCH_TIMEZONE, DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_WAGONS_PER_BOOKING, - PAYMENT_WINDOW_MS, -} from './booking-batch.constants'; +} from "./booking-batch.constants"; import { bookingTrainLengthMeters, deriveTrainCapacityFromLocomotive, wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; +import { BookingSplitService } from './booking-split.service'; +import { BookingWindowGateway } from './booking-window.gateway'; +import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util'; +import { + Capacity, + CorridorBudget, + CorridorLeg, + stopYardsFor, +} from './corridor-capacity.util'; -/** A train's remaining capacity along the three physical limits the batch enforces. */ -interface Capacity { - wagons: number; - weightTons: number; - lengthMeters: number; -} +export type { Capacity } from './corridor-capacity.util'; /** A day-level pool key: all trains on this route departing on this EAT day. */ interface RouteDayGroup { @@ -53,12 +63,12 @@ interface RouteDayGroup { type WagonLengths = { container: number; bulk: number }; export type BatchBoardBookingState = - | 'ALLOCATED' - | 'SELECTED_FOR_BATCH' - | 'READY' - | 'WAITING' - | 'PENDING_CONTRACT' - | 'EXPIRED'; + | "ALLOCATED" + | "SELECTED_FOR_BATCH" + | "READY" + | "WAITING" + | "PENDING_CONTRACT" + | "EXPIRED"; export interface BatchBoardBooking { id: string; @@ -70,19 +80,26 @@ export interface BatchBoardBooking { lengthMeters: number; paymentDeadline: string | null; state: BatchBoardBookingState; + /** Rule-engine priority score used to rank the batch (higher = boards first). */ + priorityScore: number; + /** CONTAINER | BULK — for the priority-tracking visuals. */ + freightType: string | null; } export type BookingAllocationStatus = - | 'NOT_ATTEMPTED' - | 'ASSIGNED' - | 'DEFERRED' - | 'FAILED'; + | "NOT_ATTEMPTED" + | "ASSIGNED" + | "DEFERRED" + | "FAILED"; export interface BatchBoardBookingDetail extends BatchBoardBooking { fullyExecutedAt: string | null; selectedForBatchAt: string | null; allocationStatus: BookingAllocationStatus; allocationIssue: string | null; + /** Set when this booking shares a wagon with a consolidation partner. */ + consolidationPartnerId: string | null; + consolidationPartnerRef: string | null; } export interface BatchWindowGroup { @@ -114,9 +131,16 @@ export interface BatchBoardScheduleDetail { scheduleDate: string | null; status: string; bookingWindowStatus: string; - locomotive: BatchBoardSchedule['locomotive']; - capacity: BatchBoardSchedule['capacity']; - counts: BatchBoardSchedule['counts']; + direction: string | null; + windowPhase: string | null; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingCycleNo: number; + locomotive: BatchBoardSchedule["locomotive"]; + capacity: BatchBoardSchedule["capacity"]; + counts: BatchBoardSchedule["counts"]; windows: BatchWindowGroup[]; pendingContract: BatchWindowGroup; allocationViolations: string[]; @@ -131,6 +155,13 @@ export interface BatchBoardSchedule { scheduleDate: string | null; status: string; bookingWindowStatus: string; + direction: string | null; + windowPhase: string | null; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingCycleNo: number; locomotive: { code: string; name: string | null; @@ -146,6 +177,8 @@ export interface BatchBoardSchedule { /** Weight committed on the train (allocated + selected-for-batch). */ usedWeightTons: number; maxWeightTons: number | null; + /** Wagon-slot cap for the train (locomotive/wagon-type derived). */ + maxWagons: number | null; }; counts: { allocated: number; @@ -179,6 +212,12 @@ export class BookingBatchService implements OnModuleInit { private readonly notifier: BookingNotifierService, private readonly scheduler: SchedulerRegistry, private readonly trainSchedulingService: TrainSchedulingService, + private readonly billing: BillingService, + private readonly bookingWindowGateway: BookingWindowGateway, + + @Optional() private readonly milestoneService?: ClearanceMilestoneService, + @Optional() private readonly splitService?: BookingSplitService, + ) {} /** On boot, reconcile OPEN route-days and re-arm settle timers. */ @@ -195,10 +234,10 @@ export class BookingBatchService implements OnModuleInit { } const reserved = await this.dataSource .getRepository(Booking) - .createQueryBuilder('b') - .select('DISTINCT b.train_schedule_id', 'scheduleId') + .createQueryBuilder("b") + .select("DISTINCT b.train_schedule_id", "scheduleId") .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) - .andWhere('b.train_schedule_id IS NOT NULL') + .andWhere("b.train_schedule_id IS NOT NULL") .getRawMany<{ scheduleId: string }>(); for (const { scheduleId } of reserved) this.armSettle(scheduleId); } @@ -253,6 +292,9 @@ export class BookingBatchService implements OnModuleInit { * schedule-scoped — only the fill is day-level). */ async processRouteDay(group: RouteDayGroup): Promise { + this.logger.log( + `[BATCH] processRouteDay START ${group.originYardId}->${group.destinationYardId} ${group.day}`, + ); const scheduleIds = await this.fillRouteDay( group.originYardId, group.destinationYardId, @@ -273,11 +315,20 @@ export class BookingBatchService implements OnModuleInit { await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); } - /** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */ + /** + * Distinct (origin, destination, EAT day) groups across LEGACY OPEN schedules — + * schedules with a `windowPhase` are driven exclusively by the window engine + * (BookingWindowService), never by the periodic legacy fill. + */ private async openRouteDayGroups(): Promise { - const open = await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: 'OPEN' }, - }); + const open = ( + await this.trainSchedulesRepository.findAll({ + where: [ + { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Draft }, + { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Scheduled }, + ], + }) + ).filter((s) => s.windowPhase == null); const groups = new Map(); for (const s of open) { if (!s.scheduledDepartureDate) continue; @@ -310,35 +361,57 @@ export class BookingBatchService implements OnModuleInit { if (!booking?.trainScheduleId) return; const isBatchPaid = - booking.status === 'SELECTED_FOR_BATCH' || - booking.status === 'AWAITING_PAYMENT' || - booking.status === 'PAID' || - booking.paymentStatus === 'PAID'; + booking.status === "SELECTED_FOR_BATCH" || + booking.status === "AWAITING_PAYMENT" || + booking.status === "PAID" || + booking.paymentStatus === "PAID"; if (!isBatchPaid) return; - if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { + if ( + booking.status === "SELECTED_FOR_BATCH" || + booking.status === "AWAITING_PAYMENT" + ) { await this.dataSource .getRepository(Booking) - .update(bookingId, { paymentStatus: 'PAID', status: 'PAID' }); - } else if (booking.paymentStatus !== 'PAID') { + .update(bookingId, { paymentStatus: "PAID", status: "PAID" }); + } else if (booking.paymentStatus !== "PAID") { await this.dataSource .getRepository(Booking) - .update(bookingId, { paymentStatus: 'PAID' }); + .update(bookingId, { paymentStatus: "PAID" }); } - const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); + // Paying inside the window accepts an open partial offer — reduce the booking + // to the offered part before it boards (remainder returns to the contract cap). + if (this.splitService) { + await this.splitService.applySplit(bookingId); + } + + const linked = + await this.trainScheduleBookingsRepository.existsForBooking(bookingId); if (!linked) { - await this.allocate(booking.trainScheduleId, booking, 'paid'); + await this.allocate(booking.trainScheduleId, booking, "paid"); this.logger.log( `Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`, ); + } else { + // Already linked at booking time (export FCFS: the customer books a + // specific train, so allocate() ran up front). allocate() is where the + // payment-settled tracking milestones are written, so on this branch we + // record them here — otherwise a paid, already-linked booking leaves + // FREIGHT_PAYMENT_SETTLED stuck PENDING and the clearance step never ticks. + void this.completeTrackingMilestones(bookingId, [ + "WAGON_REQUESTED", + "FREIGHT_PAYMENT_PENDING", + "FREIGHT_PAYMENT_SETTLED", + ]); + void this.markWagonAllocatedMilestone(bookingId); } const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( booking.trainScheduleId, ); if (schedule && (await this.remainingWagons(schedule)) <= 0) { - await this.setWindow(booking.trainScheduleId, 'FULL'); + await this.setWindow(booking.trainScheduleId, "FULL"); } const result = await this.trainSchedulingService.tryAutoWagonAllocation( @@ -349,7 +422,11 @@ export class BookingBatchService implements OnModuleInit { `Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`, ); } - if (result.issues.some((i) => i.bookingId === bookingId && i.status !== 'ASSIGNED')) { + if ( + result.issues.some( + (i) => i.bookingId === bookingId && i.status !== "ASSIGNED", + ) + ) { const issue = result.issues.find((i) => i.bookingId === bookingId); this.logger.warn( `Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`, @@ -362,20 +439,152 @@ export class BookingBatchService implements OnModuleInit { await this.ensurePaidBookingAllocated(bookingId); } + /** Open partial-capacity offer summary for booking detail payloads (null when none). */ + async getOpenOfferSummary(bookingId: string): Promise<{ + offeredWagons: number; + totalWagons: number; + offeredAmount: number; + paymentDeadline: Date; + } | null> { + if (!this.splitService) return null; + const offer = await this.splitService.findOpenOffer(bookingId); + if (!offer) return null; + return { + offeredWagons: offer.offeredWagons, + totalWagons: offer.totalWagons, + offeredAmount: Number(offer.offeredAmount), + paymentDeadline: offer.paymentDeadline, + }; + } + + // ---- export FCFS ----------------------------------------------------------- + + /** + * Export is first-come-first-serve: no window cycle, no priority, no batch. + * Pick the earliest open export train on the booking's corridor/day that still + * fits the booking. Throws ConflictException when every train is full — the + * staff accept fails and no more export bookings are taken. + */ + async pickExportSchedule(booking: Booking, need?: Capacity): Promise { + if (!booking.scheduledDate) { + throw new BadRequestException('Booking has no scheduled date'); + } + const day = eatDay(new Date(booking.scheduledDate)); + // Corridor-aware: any train whose route carries the booking's origin + // strictly before its destination qualifies — a Dire→Djibouti booking may + // ride an Addis→…→Djibouti train. The leg check below (legOf) enforces the + // stop order, so we fetch the day's open trains without endpoint filters. + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }); + const candidates = corridor + .filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + this.isFillable(s), + ) + .sort( + (a, b) => + a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), + ); + if (!candidates.length) { + throw new ConflictException( + 'No export train is accepting bookings for this day', + ); + } + + const rules = await this.loadGlobalRules(); + const wagonLengths = await this.loadWagonLengths(); + const required = need ?? this.needFor(booking, wagonLengths); + let corridorMatched = false; + for (const candidate of candidates) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + candidate.id, + ); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !locomotive) continue; + const limits = await this.capacityLimits(locomotive, rules); + const budget = await this.remainingBudget(schedule, limits, wagonLengths); + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + if (!leg) continue; // this train's route doesn't carry the booking's leg + corridorMatched = true; + if (budget.fits(required, leg)) return schedule.id; + } + if (!corridorMatched) { + throw new ConflictException( + 'No export train is accepting bookings for this day', + ); + } + throw new ConflictException('Train is full — no export capacity left for this day'); + } + + /** + * Accept an export booking into the FCFS flow. Solo bookings reserve immediately. + * A consolidated booking reserves as a pair only once BOTH partners are ready + * (FULLY_EXECUTED): the second partner's accept triggers the pair reservation + * against the combined shared-wagon need; the first partner's accept just waits. + * Throws ConflictException (before this booking is persisted-ready) when there is + * no export capacity for the day, so staff accept fails. + */ + async acceptExportBooking(booking: Booking): Promise { + const partnerId = booking.consolidationPartnerId ?? null; + if (!partnerId) { + const scheduleId = await this.pickExportSchedule(booking); + await this.reserveOnExport([booking], scheduleId); + return; + } + + const partner = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: partnerId }, relations: { company: true, bookingContainers: true } }); + // Partner not yet accepted → this booking is now FULLY_EXECUTED and simply + // waits; the partner's later accept will reserve the pair. + if (!partner || partner.status !== 'FULLY_EXECUTED') { + return; + } + const wagonLengths = await this.loadWagonLengths(); + const need = this.combinedNeed(booking, partner, wagonLengths); + const scheduleId = await this.pickExportSchedule(booking, need); + await this.reserveOnExport([booking, partner], scheduleId); + } + + /** Reserve one or two (consolidated) export bookings on a train and open pay windows. */ + private async reserveOnExport( + bookings: Booking[], + scheduleId: string, + ): Promise { + for (const b of bookings) await this.reserve(b, scheduleId); + this.armSettle(scheduleId); + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (schedule && (await this.remainingWagons(schedule)) <= 0) { + await this.setWindow(scheduleId, 'FULL'); + } + } + /** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */ async reconcilePaidUnlinked(scheduleId: string): Promise { - const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId); + const unlinked = + await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId); for (const booking of unlinked) { - await this.allocate(scheduleId, booking, 'paid'); + await this.allocate(scheduleId, booking, "paid"); this.logger.log( `Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`, ); } } - // ---- cron entry point ----------------------------------------------------- + // ---- legacy fill entry point ---------------------------------------------- - @Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE }) + /** + * Legacy periodic fill for schedules without a window phase (DOMESTIC and + * pre-migration trains). Invoked by BookingWindowService's tick — the old + * standalone cron was replaced by the window engine. + */ async runBatchFill(): Promise { const groups = await this.openRouteDayGroups(); this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`); @@ -405,7 +614,7 @@ export class BookingBatchService implements OnModuleInit { destinationStation: true, route: true, }, - order: { scheduledDepartureDate: 'ASC' }, + order: { scheduledDepartureDate: "ASC" }, }); const wagonLengths = await this.loadWagonLengths(); @@ -413,7 +622,10 @@ export class BookingBatchService implements OnModuleInit { const board: BatchBoardSchedule[] = []; for (const s of schedules) { - if (s.status === 'ARRIVED' || s.status === 'CANCELLED') continue; + if (s.status === "ARRIVED" || s.status === "CANCELLED") continue; + // Batch board is IMPORT-only: export is FCFS with no batch/priority calc, + // and domestic/legacy schedules run the legacy fill, not the window batch. + if (s.direction !== "IMPORT") continue; const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -425,14 +637,18 @@ export class BookingBatchService implements OnModuleInit { id: b.id, reference: b.reference ?? b.id.slice(0, 8), company: b.isGovernment - ? (b.governmentInstitution ?? 'Government') - : (b.company?.name ?? '—'), + ? (b.governmentInstitution ?? "Government") + : (b.company?.name ?? "—"), isGovernment: Boolean(b.isGovernment), wagons: need.wagons, weightTons: need.weightTons, lengthMeters: need.lengthMeters, - paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, + paymentDeadline: b.paymentDeadline + ? b.paymentDeadline.toISOString() + : null, state: this.boardState(b, linkedIds.has(b.id)), + priorityScore: Number(b.priorityScore ?? 0), + freightType: b.freightType ?? null, }; }); @@ -442,11 +658,21 @@ export class BookingBatchService implements OnModuleInit { } /** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */ - async getBatchBoardDetail(scheduleId: string): Promise { - const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`); - if (s.status === 'ARRIVED' || s.status === 'CANCELLED') { - throw new BadRequestException('Schedule is no longer active'); + async getBatchBoardDetail( + scheduleId: string, + ): Promise { + const s = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!s) + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + if (s.status === "ARRIVED" || s.status === "CANCELLED") { + throw new BadRequestException("Schedule is no longer active"); + } + // Batch board is IMPORT-only (export is FCFS, no batch/priority calc). + if (s.direction !== "IMPORT") { + throw new BadRequestException( + "The batch board only covers import schedules", + ); } const wagonLengths = await this.loadWagonLengths(); @@ -456,17 +682,44 @@ export class BookingBatchService implements OnModuleInit { const bookings = await this.bookingsRepository.findAllBySchedule(s.id); let allocationPreview: Awaited< - ReturnType + ReturnType >; try { - allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule(s.id); + allocationPreview = + await this.trainSchedulingService.previewAllocationForSchedule(s.id); } catch { - allocationPreview = { assignedBookingIds: [], deferred: [], issues: [], violations: [] }; + allocationPreview = { + assignedBookingIds: [], + deferred: [], + issues: [], + violations: [], + }; } const allocationByBooking = new Map( allocationPreview.issues.map((i) => [i.bookingId, i]), ); + // Resolve consolidation-partner references for the shared-wagon badge. Most + // partners are on this same schedule; look up any that aren't in one query. + const refById = new Map( + bookings.map((b) => [b.id, b.reference ?? b.id.slice(0, 8)]), + ); + const missingPartnerIds = [ + ...new Set( + bookings + .map((b) => b.consolidationPartnerId) + .filter((id): id is string => Boolean(id) && !refById.has(id!)), + ), + ]; + if (missingPartnerIds.length) { + const partners = await this.dataSource + .getRepository(Booking) + .find({ where: { id: In(missingPartnerIds) } }); + for (const p of partners) { + refById.set(p.id, p.reference ?? p.id.slice(0, 8)); + } + } + const items: BatchBoardBookingDetail[] = bookings.map((b) => { const need = this.needFor(b, wagonLengths); const alloc = allocationByBooking.get(b.id); @@ -474,32 +727,73 @@ export class BookingBatchService implements OnModuleInit { id: b.id, reference: b.reference ?? b.id.slice(0, 8), company: b.isGovernment - ? (b.governmentInstitution ?? 'Government') - : (b.company?.name ?? '—'), + ? (b.governmentInstitution ?? "Government") + : (b.company?.name ?? "—"), isGovernment: Boolean(b.isGovernment), wagons: need.wagons, weightTons: need.weightTons, lengthMeters: need.lengthMeters, - paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, + paymentDeadline: b.paymentDeadline + ? b.paymentDeadline.toISOString() + : null, state: this.boardState(b, linkedIds.has(b.id)), - fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null, - selectedForBatchAt: b.selectedForBatchAt ? b.selectedForBatchAt.toISOString() : null, - allocationStatus: alloc?.status ?? 'NOT_ATTEMPTED', + priorityScore: Number(b.priorityScore ?? 0), + freightType: b.freightType ?? null, + fullyExecutedAt: b.fullyExecutedAt + ? b.fullyExecutedAt.toISOString() + : null, + selectedForBatchAt: b.selectedForBatchAt + ? b.selectedForBatchAt.toISOString() + : null, + allocationStatus: alloc?.status ?? "NOT_ATTEMPTED", allocationIssue: alloc?.issue ?? null, + consolidationPartnerId: b.consolidationPartnerId ?? null, + consolidationPartnerRef: b.consolidationPartnerId + ? (refById.get(b.consolidationPartnerId) ?? null) + : null, }; }); const loco = s.trainSet?.locomotive ?? null; - // Display windows span the whole booking window: from when it opened - // (schedule creation) through the scheduled departure, in 3-hour EAT slots. - const openDate = s.createdAt ?? s.scheduledDepartureDate ?? new Date(); + // Display windows are the REAL booking-window cycles this schedule was FROZEN + // with at creation (import: opens at its stored window time, lasts its rule's + // duration, reopens per its rule's delay; export: single FCFS lead window) — + // NOT the live global config. A later global-rules edit only re-derives + // not-yet-open schedules (restampPendingWindows), so an already-open schedule + // must keep drawing from its own snapshot, anchored on its stored open time. + // Legacy rows with no snapshot fall back to the live config. + const liveCfg = await this.trainSchedulingService.getWindowConfig(); + const num = (v: unknown, fallback: number) => { + const n = v == null ? NaN : Number(v); + return Number.isFinite(n) ? n : fallback; + }; + const windowCfg = { + windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour), + windowCloseHour: num(s.ruleWindowCloseHour, liveCfg.windowCloseHour), + windowDurationHours: num( + s.ruleWindowDurationHours, + liveCfg.windowDurationHours, + ), + reopenDelayMinutes: num(s.ruleReopenDelayMinutes, liveCfg.reopenDelayMinutes), + importWindowLeadDays: num( + s.ruleImportWindowLeadDays, + liveCfg.importWindowLeadDays, + ), + exportBookingLeadHours: num( + s.ruleExportBookingLeadHours, + liveCfg.exportBookingLeadHours, + ), + }; const departureDate = s.scheduledDepartureDate ?? new Date(); const windowBuckets = groupBookingsIntoBoardWindows( items, (item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null), - openDate, + s.direction ?? null, departureDate, + windowCfg, + undefined, + s.windowOpensAt ?? null, ); const emptyCounts = () => ({ @@ -514,11 +808,11 @@ export class BookingBatchService implements OnModuleInit { const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => { const counts = emptyCounts(); for (const b of bookingsInWindow) { - if (b.state === 'ALLOCATED') counts.allocated += 1; - else if (b.state === 'SELECTED_FOR_BATCH') counts.selectedForBatch += 1; - else if (b.state === 'READY') counts.ready += 1; - else if (b.state === 'WAITING') counts.waiting += 1; - else if (b.state === 'EXPIRED') counts.expired += 1; + if (b.state === "ALLOCATED") counts.allocated += 1; + else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1; + else if (b.state === "READY") counts.ready += 1; + else if (b.state === "WAITING") counts.waiting += 1; + else if (b.state === "EXPIRED") counts.expired += 1; else counts.pendingContract += 1; } return counts; @@ -526,7 +820,7 @@ export class BookingBatchService implements OnModuleInit { const windows: BatchWindowGroup[] = []; for (const [key, bucket] of windowBuckets) { - if (key === 'pending-contract' || !bucket.window) continue; + if (key === "pending-contract" || !bucket.window) continue; const w = bucket.window; windows.push({ key: w.key, @@ -539,44 +833,60 @@ export class BookingBatchService implements OnModuleInit { bookings: bucket.items, }); } - windows.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); + windows.sort( + (a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(), + ); - const pendingBookings = windowBuckets.get('pending-contract')?.items ?? []; + const pendingBookings = windowBuckets.get("pending-contract")?.items ?? []; return { scheduleId: s.id, trainNumber: s.trainNumber ?? null, - routeName: s.route?.name ?? null, + routeName: s.route ? formatRouteLabel(s.route) : null, origin: s.originStation?.label ?? s.originStation?.code ?? null, - destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, - scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, + destination: + s.destinationStation?.label ?? s.destinationStation?.code ?? null, + scheduleDate: s.scheduledDepartureDate + ? s.scheduledDepartureDate.toISOString() + : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, + direction: s.direction ?? null, + windowPhase: s.windowPhase ?? null, + windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null, + windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null, + docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null, + paymentPhaseEndsAt: s.paymentPhaseEndsAt + ? s.paymentPhaseEndsAt.toISOString() + : null, + bookingCycleNo: s.bookingCycleNo ?? 0, locomotive: loco ? { - code: loco.code, - name: loco.name ?? null, - maxPullWeightTons: Number(loco.maxPullWeightTons), - maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), - } + code: loco.code, + name: loco.name ?? null, + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + } : null, - capacity: this.computeBoardCapacity(items, loco), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), counts: { - allocated: items.filter((i) => i.state === 'ALLOCATED').length, - selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length, - ready: items.filter((i) => i.state === 'READY').length, - waiting: items.filter((i) => i.state === 'WAITING').length, - pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length, - expired: items.filter((i) => i.state === 'EXPIRED').length, + allocated: items.filter((i) => i.state === "ALLOCATED").length, + selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") + .length, + ready: items.filter((i) => i.state === "READY").length, + waiting: items.filter((i) => i.state === "WAITING").length, + pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT") + .length, + expired: items.filter((i) => i.state === "EXPIRED").length, }, windows, pendingContract: { - key: 'pending-contract', - label: 'Pending contract', - date: '', - dateLabel: '', - start: '', - end: '', + key: "pending-contract", + label: "Pending contract", + date: "", + dateLabel: "", + start: "", + end: "", counts: countFor(pendingBookings), bookings: pendingBookings, }, @@ -597,18 +907,24 @@ export class BookingBatchService implements OnModuleInit { lengthMeters: number; }>, loco: Locomotive | null, - ): BatchBoardSchedule['capacity'] { - const allocated = items.filter((i) => i.state === 'ALLOCATED'); + maxWagons: number | null, + ): BatchBoardSchedule["capacity"] { + const allocated = items.filter((i) => i.state === "ALLOCATED"); const committed = items.filter( - (i) => i.state === 'ALLOCATED' || i.state === 'SELECTED_FOR_BATCH', + (i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH", ); return { allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0), allocatedLengthMeters: - Math.round(allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100) / 100, + Math.round( + allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100, + ) / 100, maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null, - usedWeightTons: Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100, + usedWeightTons: + Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / + 100, maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null, + maxWagons: maxWagons ?? null, }; } @@ -621,53 +937,93 @@ export class BookingBatchService implements OnModuleInit { return { scheduleId: s.id, trainNumber: s.trainNumber ?? null, - routeName: s.route?.name ?? null, + routeName: s.route ? formatRouteLabel(s.route) : null, origin: s.originStation?.label ?? s.originStation?.code ?? null, - destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, - scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, + destination: + s.destinationStation?.label ?? s.destinationStation?.code ?? null, + scheduleDate: s.scheduledDepartureDate + ? s.scheduledDepartureDate.toISOString() + : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, + direction: s.direction ?? null, + windowPhase: s.windowPhase ?? null, + windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null, + windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null, + docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null, + paymentPhaseEndsAt: s.paymentPhaseEndsAt + ? s.paymentPhaseEndsAt.toISOString() + : null, + bookingCycleNo: s.bookingCycleNo ?? 0, locomotive: loco ? { - code: loco.code, - name: loco.name ?? null, - maxPullWeightTons: Number(loco.maxPullWeightTons), - maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), - } + code: loco.code, + name: loco.name ?? null, + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + } : null, - capacity: this.computeBoardCapacity(items, loco), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), counts: { - allocated: items.filter((i) => i.state === 'ALLOCATED').length, - selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length, - ready: items.filter((i) => i.state === 'READY').length, - waiting: items.filter((i) => i.state === 'WAITING').length, - pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length, - expired: items.filter((i) => i.state === 'EXPIRED').length, + allocated: items.filter((i) => i.state === "ALLOCATED").length, + selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") + .length, + ready: items.filter((i) => i.state === "READY").length, + waiting: items.filter((i) => i.state === "WAITING").length, + pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT") + .length, + expired: items.filter((i) => i.state === "EXPIRED").length, }, bookings: items.slice(0, 3), }; } - private boardState(booking: Booking, linked: boolean): BatchBoardBookingState { - if (linked) return 'ALLOCATED'; - if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { - return 'SELECTED_FOR_BATCH'; + private boardState( + booking: Booking, + linked: boolean, + ): BatchBoardBookingState { + if (linked) return "ALLOCATED"; + if ( + booking.status === "SELECTED_FOR_BATCH" || + booking.status === "AWAITING_PAYMENT" + ) { + return "SELECTED_FOR_BATCH"; } - if (booking.status === 'EXPIRED') return 'EXPIRED'; - if (booking.status === 'FULLY_EXECUTED' && booking.fullyExecutedAt) return 'READY'; - if (booking.status === 'PAID') return 'WAITING'; - return 'PENDING_CONTRACT'; + if (booking.status === "EXPIRED") return "EXPIRED"; + if (booking.status === "FULLY_EXECUTED" && booking.fullyExecutedAt) + return "READY"; + if (booking.status === "PAID") return "WAITING"; + return "PENDING_CONTRACT"; } // ---- core fill ------------------------------------------------------------ + /** + * Whether the batch engine may reserve/allocate onto this schedule right now. + * Legacy (no window phase): the customer-facing OPEN gate doubles as the fill gate. + * Import window cycle: the engine fills while the customer window is CLOSED — + * during DOC_REVIEW (early staff trigger) and PAYMENT (batch run + top-ups). + * Export: FCFS while the booking window is open. + */ + isFillable(schedule: TrainSchedule): boolean { + if (schedule.bookingWindowStatus === "FULL") return false; + if (!schedule.windowPhase) return schedule.bookingWindowStatus === "OPEN"; + if (schedule.direction === "EXPORT") { + return schedule.windowPhase === "OPEN" && schedule.bookingWindowStatus === "OPEN"; + } + return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT"; + } + /** Fill one schedule from its priority-ordered pool until full. */ async fillSchedule(scheduleId: string): Promise { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!schedule || schedule.bookingWindowStatus !== 'OPEN') return; + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule || !this.isFillable(schedule)) return; const locomotive = schedule.trainSet?.locomotive; if (!schedule.trainSetId || !locomotive) { - this.logger.warn(`Schedule ${scheduleId} has no locomotive/train set — skipped.`); + this.logger.warn( + `Schedule ${scheduleId} has no locomotive/train set — skipped.`, + ); return; } @@ -675,38 +1031,98 @@ export class BookingBatchService implements OnModuleInit { const wagonLengths = await this.loadWagonLengths(); const limits = await this.capacityLimits(locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules); - let budget = await this.remainingCapacity(schedule, limits, wagonLengths); - if (budget.wagons <= 0) { - await this.setWindow(scheduleId, 'FULL'); + const budget = await this.remainingBudget(schedule, limits, wagonLengths); + if (budget.maxRemaining().wagons <= 0) { + await this.setWindow(scheduleId, "FULL"); return; } const pool = await this.bookingsRepository.findBatchPool(scheduleId); + const units = this.groupConsolidatedPool(pool); let armed = false; + let reservedThisPass = 0; - for (const booking of pool) { - const need = this.needFor(booking, wagonLengths); + // Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when + // reservations trickle instead of landing in one pass (a reserve() throwing + // mid-loop, e.g. schema drift, or a mis-synced capacity cap). + this.logger.debug( + `[fillSchedule ${scheduleId}] limits=${JSON.stringify(limits)} ` + + `maxWagons=${schedule.maxWagons} remaining=${JSON.stringify(budget.maxRemaining())} ` + + `poolSize=${pool.length} units=${units.length}`, + ); - if (!this.fits(need, budget)) { - if (booking.isGovernment) { - budget = await this.preemptForGovernment(scheduleId, need, budget, wagonLengths); - if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt + for (const unit of units) { + const { primary: booking, partner } = unit; + const isPair = partner != null; + const need = isPair + ? this.combinedNeed(booking, partner, wagonLengths) + : this.needFor(booking, wagonLengths); + const isGov = booking.isGovernment || (partner?.isGovernment ?? false); + // Consolidated partners always share one corridor, so the primary's leg + // stands for the pair. + const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); + + // Per-unit fit trace: which axis (wagons/weight/length) admits or rejects. + this.logger.debug( + `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` + + `roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`, + ); + + if (!budget.fits(need, leg)) { + if (isGov) { + const freed = await this.preemptForGovernment( + scheduleId, + need, + leg, + budget, + wagonLengths, + ); + if (!freed) continue; // still doesn't fit even after preempt } else { - continue; // skip a booking that exceeds weight/length/wagons, try the next + // Doesn't fit whole. A split-eligible import booking is offered the part + // that fits in the remaining room (top-up path splits the boundary + // booking, mirroring fillRouteDay); otherwise skip and try the next. + const cand: { id: string; budget: CorridorBudget; armed: boolean } = { + id: scheduleId, + budget, + armed, + }; + if (await this.maybeOfferPartial(booking, isPair, [cand], need)) { + armed = cand.armed; + continue; + } + continue; // skip a unit that exceeds weight/length/wagons, try the next } } - if (booking.isGovernment) { - await this.allocate(scheduleId, booking, 'gov'); - } else { - await this.reserve(booking, scheduleId); - armed = true; + // Isolate each unit so a throw in reserve/allocate (e.g. billing hiccup) + // can't abort the whole top-up pass and leave the rest to trickle in one + // per tick. Log + skip the failing unit, keep going. + try { + if (isGov) { + await this.allocate(scheduleId, booking, "gov"); + if (partner) await this.allocate(scheduleId, partner, "gov"); + } else { + await this.reserve(booking, scheduleId); + if (partner) await this.reserve(partner, scheduleId); + armed = true; + } + budget.subtract(need, leg); + reservedThisPass += 1; + } catch (err) { + this.logger.error( + `[fillSchedule ${scheduleId}] reserve/allocate FAILED for ${booking.reference} ` + + `— skipping this unit, continuing: ${(err as Error).message}`, + ); + continue; } - budget = this.subtract(budget, need); - if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board + if (budget.maxRemaining().wagons <= 0) break; // every leg exhausted — nothing more can board } - if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL'); + this.logger.log( + `[fillSchedule ${scheduleId}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`, + ); + if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL"); if (armed) this.armSettle(scheduleId); void this.triggerWagonAllocation(scheduleId); } @@ -724,21 +1140,33 @@ export class BookingBatchService implements OnModuleInit { destinationYardId: string, day: string, ): Promise { - // The day's OPEN bookable schedules on this exact corridor, earliest first. - const bookable = await this.trainSchedulingService.getBookableSchedules( - originYardId, - destinationYardId, - ); - const scheduleIds = bookable + // The day's fillable schedules on this exact corridor, earliest first. Fillable + // covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT — + // the batch must run while the customer window is closed. + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: originYardId, + destinationStationId: destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: originYardId, + destinationStationId: destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const scheduleIds = corridor .filter( (s) => - s.bookingWindowStatus === 'OPEN' && - s.scheduleDate != null && - eatDay(new Date(s.scheduleDate)) === day, + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + this.isFillable(s), ) .sort( (a, b) => - new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(), + a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), ) .map((s) => s.id); @@ -747,40 +1175,90 @@ export class BookingBatchService implements OnModuleInit { const rules = await this.loadGlobalRules(); const wagonLengths = await this.loadWagonLengths(); - // Live per-schedule budget + arm flag, in departure order. - const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = []; + // Live per-schedule corridor budget + arm flag, in departure order. + const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = []; for (const id of scheduleIds) { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(id); const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !schedule.trainSetId || !locomotive) { - this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`); + this.logger.warn( + `Schedule ${id} has no locomotive/train set — skipped.`, + ); continue; } const limits = await this.capacityLimits(locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules); - const budget = await this.remainingCapacity(schedule, limits, wagonLengths); + const budget = await this.remainingBudget(schedule, limits, wagonLengths); trains.push({ id, budget, armed: false }); } if (trains.length === 0) return []; - const pool = await this.bookingsRepository.findBatchPoolByRouteDay( - originYardId, - destinationYardId, + // The day pool covers every booking whose leg lies somewhere on one of the + // day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an + // Addis→Djibouti train). Which train actually takes a booking is decided + // by the per-train legOf check below. + const corridorYards = [...new Set(trains.flatMap((t) => t.budget.stops))]; + const pool = await this.bookingsRepository.findBatchPoolByCorridorDay( + corridorYards, day, ); + // Consolidated partners collapse into one atomic unit (both-or-neither); a + // consolidated booking whose partner isn't ready this cycle is skipped. + const units = this.groupConsolidatedPool(pool); - for (const booking of pool) { - const need = this.needFor(booking, wagonLengths); + // Batch fill trace: each train's caps + the day pool size at entry. + this.logger.debug( + `[fillRouteDay ${originYardId}->${destinationYardId} ${day}] ` + + `trains=${trains.map((t) => `${t.id}:${JSON.stringify(t.budget.maxRemaining())}`).join(",")} ` + + `poolSize=${pool.length} units=${units.length}`, + ); + let reservedThisPass = 0; - // First train (earliest departure) that fits this booking as-is. - let target = trains.find((t) => this.fits(need, t.budget)); + for (const unit of units) { + const { primary: booking, partner } = unit; + const isPair = partner != null; + const need = isPair + ? this.combinedNeed(booking, partner, wagonLengths) + : this.needFor(booking, wagonLengths); + const isGov = booking.isGovernment || (partner?.isGovernment ?? false); - if (!target && booking.isGovernment) { - // Government booking fits nowhere on its own — try to preempt commercial - // on each train (earliest first) until one frees enough room. + const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null => + t.budget.legOf(booking.originYardId, booking.destinationYardId); + + // First train (earliest departure) whose corridor carries this booking's + // leg and still fits it as-is. + let target = trains.find((t) => { + const leg = legOn(t); + return leg != null && t.budget.fits(need, leg); + }); + + // Per-unit trace: chosen train + each train's remaining room on this leg. + this.logger.debug( + `[fillRouteDay] unit ${booking.reference}: need=${JSON.stringify(need)} ` + + `targetTrain=${target?.id ?? "none"} ` + + `rooms=${trains + .map((t) => { + const leg = legOn(t); + return leg ? `${t.id}:${JSON.stringify(t.budget.remainingFor(leg))}` : `${t.id}:offleg`; + }) + .join(",")}`, + ); + + if (!target && isGov) { + // Government fits nowhere on its own — try to preempt commercial + // on each corridor-matching train (earliest first) until one frees room. for (const t of trains) { - t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths); - if (this.fits(need, t.budget)) { + const leg = legOn(t); + if (!leg) continue; + const freed = await this.preemptForGovernment( + t.id, + need, + leg, + t.budget, + wagonLengths, + ); + if (freed) { target = t; break; } @@ -788,22 +1266,51 @@ export class BookingBatchService implements OnModuleInit { } if (!target) { - // Fits no train this day — stays in the pool, retried next batch. + // Fits no train whole. A split-eligible booking is offered the largest + // part that fits on the train with the most free wagons on its leg (this + // covers both "fits nowhere" and the boundary case where earlier bookings + // already consumed most of the room). Consolidated pairs / government / + // non-import never split — isSplitEligible guards that. Passing the live + // `trains` entries lets maybeOfferPartial mutate the chosen budget/armed. + const offered = await this.maybeOfferPartial(booking, isPair, trains, need); + if (offered) continue; + // Stays in the pool, retried next batch/window cycle. this.notifier.unplaced(booking, day); + if (partner) this.notifier.unplaced(partner, day); continue; } - if (booking.isGovernment) { - await this.allocate(target.id, booking, 'gov'); - } else { - await this.reserve(booking, target.id); - target.armed = true; + // A throw here (e.g. a billing/invoice hiccup inside reserve) must NOT abort + // the whole pass — otherwise only the bookings before the failure get a pay + // window and the rest trickle in one-per-tick on later retries (the + // "selected one at a time / staggered" symptom). Isolate each unit: log + + // skip a failing one, keep reserving the others. The skipped unit stays in + // the pool and is retried next cycle. + try { + if (isGov) { + await this.allocate(target.id, booking, "gov"); + if (partner) await this.allocate(target.id, partner, "gov"); + } else { + await this.reserve(booking, target.id); + if (partner) await this.reserve(partner, target.id); + target.armed = true; + } + target.budget.subtract(need, legOn(target)!); + reservedThisPass += 1; + } catch (err) { + this.logger.error( + `[fillRouteDay] reserve/allocate FAILED for ${booking.reference} on ${target.id} ` + + `— skipping this unit, continuing the batch: ${(err as Error).message}`, + ); } - target.budget = this.subtract(target.budget, need); } + this.logger.log( + `[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`, + ); + for (const t of trains) { - if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL'); + if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL"); if (t.armed) this.armSettle(t.id); void this.triggerWagonAllocation(t.id); } @@ -811,28 +1318,189 @@ export class BookingBatchService implements OnModuleInit { return trains.map((t) => t.id); } - /** Durable settle: allocate paid / expire overdue reservations, then top up. */ - async settleDueReservations(scheduleId: string): Promise { - const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); + /** + * A lone commercial IMPORT booking on a GENERAL or ONE_TIME contract may be + * offered a partial (split-on-payment). Consolidated pairs never split (both-or- + * neither shared wagon) and government bookings never split (they preempt). + */ + private isSplitEligible(booking: Booking, isPair: boolean): boolean { + return ( + !isPair && + !booking.isGovernment && + booking.tradeDirection === "IMPORT" && + (booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") && + this.splitService != null + ); + } + + /** + * Offer the largest fitting part of a booking that does not fit any candidate + * train whole, on the train with the most free wagons on the booking's leg. + * Mutates the chosen candidate's budget + armed flag in place. Returns true when + * an offer was opened (caller should `continue` past this unit), false otherwise. + * Shared by fillRouteDay (multi-train) and fillSchedule (single train). The leg + * is computed per candidate from the booking's yards, so callers pass their live + * train entries and only leg-carrying trains are considered. + */ + private async maybeOfferPartial( + booking: Booking, + isPair: boolean, + candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>, + need: Capacity, + ): Promise { + if (!this.isSplitEligible(booking, isPair)) return false; + const target = candidates + .map((c) => { + const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId); + return leg ? { c, leg, room: c.budget.remainingFor(leg) } : null; + }) + .filter((x): x is NonNullable => x != null && x.room.wagons >= 1) + .sort((a, b) => b.room.wagons - a.room.wagons)[0]; + if (!target) return false; + const offered = await this.tryPartialOffer( + booking, + target.c.id, + target.room, + need, + ); + if (!offered) return false; + target.c.budget.subtract(offered, target.leg); + target.c.armed = true; + return true; + } + + /** + * Offer the largest fitting part of an over-capacity booking as a partial + * (split-on-payment). Returns the capacity the offer consumes, or null when no + * meaningful partial fits / an offer is already open. + */ + private async tryPartialOffer( + booking: Booking, + scheduleId: string, + budget: Capacity, + need: Capacity, + ): Promise { + if (!this.splitService) return null; + // A consolidated booking is already half of a shared wagon — never split it. + if (booking.consolidationPartnerId) return null; + if (await this.splitService.findOpenOffer(booking.id)) return null; + + const wagonLengths = await this.loadWagonLengths(); + const bulkCapacityTons = await this.loadBulkWagonCapacityTons(); + const sized = await this.splitService.sizeOffer( + booking, + budget.wagons, + need.wagons, + bulkCapacityTons, + ); + if (!sized) return null; + + const offeredNeed: Capacity = { + wagons: sized.offeredWagons, + weightTons: sized.offeredWeightTons, + lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, { + container: wagonLengths.container, + bulk: wagonLengths.bulk, + }), + }; + if (!this.fits(offeredNeed, budget)) return null; + + const deadline = new Date(Date.now() + (await this.paymentWindowMs())); + await this.splitService.createOffer(booking, scheduleId, sized, deadline); + // Reserve like a normal batch selection, but the partial invoice + partial + // pay-now notification were already produced by createOffer. + await this.bookingsRepository.update(booking.id, { + trainScheduleId: scheduleId, + status: "SELECTED_FOR_BATCH", + selectedForBatchAt: new Date(), + paymentDeadline: deadline, + } as never); + booking.trainScheduleId = scheduleId; + return offeredNeed; + } + + private async loadBulkWagonCapacityTons(): Promise { + const cw3 = await this.dataSource + .getRepository(WagonType) + .findOne({ where: { code: "CW3" } }); + const capacity = cw3 ? wagonTypeDimensionsFromEntity(cw3).capacityTons : 60; + return capacity > 0 ? capacity : 60; + } + + /** + * Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides + * how to treat a reservation with no deadline (durable path: leave it; timeout + * path: expire it). Consolidated pairs settle atomically: both allocate only + * when both paid; if either partner expires, both expire (a half-paid shared + * wagon must not ship). Returns whether anything changed. + */ + private async settleReserved( + scheduleId: string, + expireUnpaidUnknownDeadline: boolean, + ): Promise { + const reserved = + await this.bookingsRepository.findReservedForSchedule(scheduleId); const now = Date.now(); + const byId = new Map(reserved.map((b) => [b.id, b])); + const done = new Set(); let anySettled = false; + this.logger.debug( + `[settleReserved ${scheduleId}] ${reserved.length} reserved booking(s) to settle`, + ); + + const isPaid = (b: Booking) => + b.paymentStatus === "PAID" || b.status === "PAID"; + const isExpired = (b: Booking) => + b.paymentDeadline + ? b.paymentDeadline.getTime() <= now + : expireUnpaidUnknownDeadline; for (const booking of reserved) { - const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID'; - const expired = booking.paymentDeadline - ? booking.paymentDeadline.getTime() <= now - : false; + if (done.has(booking.id)) continue; + const partner = booking.consolidationPartnerId + ? (byId.get(booking.consolidationPartnerId) ?? null) + : null; - if (paid) { - await this.allocate(scheduleId, booking, 'paid'); + if (partner) { + done.add(booking.id); + done.add(partner.id); + // Both-or-neither: allocate the shared wagon only when both partners paid; + // if either lapsed, expire both so no half-paid wagon rides. + if (isPaid(booking) && isPaid(partner)) { + await this.allocate(scheduleId, booking, "paid"); + await this.allocate(scheduleId, partner, "paid"); + anySettled = true; + } else if (isExpired(booking) || isExpired(partner)) { + await this.expire(booking); + await this.expire(partner); + anySettled = true; + } + continue; + } + + done.add(booking.id); + if (isPaid(booking)) { + await this.allocate(scheduleId, booking, "paid"); anySettled = true; - } else if (expired) { + } else if (isExpired(booking)) { await this.expire(booking); anySettled = true; } } + return anySettled; + } - if (anySettled) await this.fillSchedule(scheduleId); + /** Durable settle: allocate paid / expire overdue reservations, then top up. */ + async settleDueReservations(scheduleId: string): Promise { + const anySettled = await this.settleReserved(scheduleId, false); + // A settle that allocated/expired anything frees or fills capacity → re-run the + // fill so the next waiting-list bookings get a fresh pay window (top-up). + if (anySettled) { + this.logger.log( + `[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`, + ); + await this.fillSchedule(scheduleId); + } } // ---- settle (1h after a batch) ------------------------------------------- @@ -840,33 +1508,19 @@ export class BookingBatchService implements OnModuleInit { /** Allocate paid reservations, expire the rest, then top up. */ async settleBatch(scheduleId: string): Promise { this.removeTimeout(scheduleId); - const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); - const now = Date.now(); - - for (const booking of reserved) { - const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID'; - const expired = booking.paymentDeadline - ? booking.paymentDeadline.getTime() <= now - : true; - - if (paid) { - await this.allocate(scheduleId, booking, 'paid'); - } else if (expired) { - await this.expire(booking); - } - // else: still within window (rare at settle) → leave for the re-armed timeout - } - + await this.settleReserved(scheduleId, true); await this.fillSchedule(scheduleId); void this.triggerWagonAllocation(scheduleId); } private triggerWagonAllocation(scheduleId: string): void { - void this.trainSchedulingService.tryAutoWagonAllocation(scheduleId).catch((err) => - this.logger.warn( - `Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`, - ), - ); + void this.trainSchedulingService + .tryAutoWagonAllocation(scheduleId) + .catch((err) => + this.logger.warn( + `Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`, + ), + ); } // ---- staff override actions ---------------------------------------------- @@ -878,18 +1532,20 @@ export class BookingBatchService implements OnModuleInit { .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); if (!booking.trainScheduleId) { - throw new BadRequestException('Booking has no target schedule to allocate to'); + throw new BadRequestException( + "Booking has no target schedule to allocate to", + ); } await this.dataSource .getRepository(Booking) - .update(bookingId, { paymentStatus: 'PAID' }); - await this.allocate(booking.trainScheduleId, booking, 'paid'); + .update(bookingId, { paymentStatus: "PAID" }); + await this.allocate(booking.trainScheduleId, booking, "paid"); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( booking.trainScheduleId, ); if (schedule && (await this.remainingWagons(schedule)) <= 0) { - await this.setWindow(booking.trainScheduleId, 'FULL'); + await this.setWindow(booking.trainScheduleId, "FULL"); } void this.triggerWagonAllocation(booking.trainScheduleId!); } @@ -898,7 +1554,10 @@ export class BookingBatchService implements OnModuleInit { * Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority). * Used for EXPIRED or full-schedule bookings — no re-approval. */ - async moveToSchedule(bookingId: string, newScheduleId: string): Promise { + async moveToSchedule( + bookingId: string, + newScheduleId: string, + ): Promise { const booking = await this.dataSource .getRepository(Booking) .findOne({ where: { id: bookingId } }); @@ -907,15 +1566,20 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: newScheduleId } }); - if (!schedule) throw new NotFoundException(`Train schedule ${newScheduleId} not found`); - if (schedule.bookingWindowStatus !== 'OPEN') { - throw new BadRequestException('Target schedule is not accepting bookings'); + if (!schedule) + throw new NotFoundException(`Train schedule ${newScheduleId} not found`); + if (schedule.bookingWindowStatus !== "OPEN") { + throw new BadRequestException( + "Target schedule is not accepting bookings", + ); } - if ( - schedule.originStationId !== booking.originYardId || - schedule.destinationStationId !== booking.destinationYardId - ) { - throw new BadRequestException('Target schedule is not on the booking route'); + const stops = await this.stopsForSchedule(schedule); + const fromIdx = stops.indexOf(booking.originYardId); + const toIdx = stops.indexOf(booking.destinationYardId); + if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) { + throw new BadRequestException( + "Target schedule is not on the booking route", + ); } await this.dataSource.transaction(async (manager) => { @@ -927,15 +1591,15 @@ export class BookingBatchService implements OnModuleInit { ); } const restoredStatus = - booking.status === 'EXPIRED' + booking.status === "EXPIRED" ? booking.isGovernment - ? 'APPROVED' - : 'FULLY_EXECUTED' + ? "APPROVED" + : "FULLY_EXECUTED" : booking.status; await manager.getRepository(Booking).update(bookingId, { trainScheduleId: newScheduleId, status: restoredStatus, - schedulingStatus: 'ELIGIBLE', + schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); @@ -949,7 +1613,51 @@ export class BookingBatchService implements OnModuleInit { .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); await this.expire(booking); - if (booking.trainScheduleId) await this.fillSchedule(booking.trainScheduleId); + if (booking.trainScheduleId) + await this.fillSchedule(booking.trainScheduleId); + } + + // ---- intercity ride-along API --------------------------------------------- + + /** + * Remaining corridor capacity budget (per-edge 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. Segment-based: an intercity booking fits whenever + * ITS leg has room, even if the train is full on other legs. + */ + async intercityCapacity(scheduleId: string): Promise<{ + budget: CorridorBudget; + 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.remainingBudget(schedule, limits, wagonLengths); + return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) }; + } + + /** + * Accept an intercity booking onto the given train. Commercial bookings get + * the same pay-window lifecycle as a batch reservation (deadline, invoice + * due-date sync, pay-now notify, settle on the window tick), so payment → + * allocation needs no special path. Government bookings allocate directly. + */ + async acceptIntercity(booking: Booking, scheduleId: string): Promise { + if (booking.isGovernment) { + await this.dataSource + .getRepository(Booking) + .update(booking.id, { trainScheduleId: scheduleId }); + booking.trainScheduleId = scheduleId; + await this.allocate(scheduleId, booking, 'gov'); + return; + } + await this.reserve(booking, scheduleId); + this.armSettle(scheduleId); } // ---- mutations ------------------------------------------------------------ @@ -964,28 +1672,48 @@ export class BookingBatchService implements OnModuleInit { */ private async reserve(booking: Booking, scheduleId: string): Promise { const now = new Date(); - const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS); + const deadline = new Date(now.getTime() + (await this.paymentWindowMs())); await this.bookingsRepository.update(booking.id, { trainScheduleId: scheduleId, - status: 'SELECTED_FOR_BATCH', + status: "SELECTED_FOR_BATCH", selectedForBatchAt: now, paymentDeadline: deadline, } as never); booking.trainScheduleId = scheduleId; + // The invoice was generated at booking creation/approval, before this pay + // window opened — refresh its printed due date to the real deadline. + await this.billing.syncPayableDueDate( + Freight.InvoiceSource.Booking, + booking.id, + deadline, + "PREPAID", + ); await this.notifier.payNow(booking, deadline); + this.logger.log( + `[BATCH] RESERVED ${booking.reference} (${this.wagonsFor(booking)}w, ` + + `priority ${booking.priorityScore ?? 0}) on schedule ${scheduleId} — ` + + `pay by ${deadline.toISOString()}`, + ); + // 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). */ private async allocate( scheduleId: string, booking: Booking, - reason: 'paid' | 'gov', + reason: "paid" | "gov", ): Promise { await this.dataSource.transaction(async (manager) => { - const exists = await this.trainScheduleBookingsRepository.existsForBooking( - booking.id, - manager, - ); + const exists = + await this.trainScheduleBookingsRepository.existsForBooking( + booking.id, + manager, + ); if (!exists) { await this.trainScheduleBookingsRepository.createMany( [{ trainScheduleId: scheduleId, bookingId: booking.id }], @@ -993,15 +1721,58 @@ export class BookingBatchService implements OnModuleInit { ); } await manager.getRepository(Booking).update(booking.id, { - status: reason === 'paid' ? 'PAID' : booking.status, - schedulingStatus: 'SCHEDULED', + status: reason === "paid" ? "PAID" : booking.status, + schedulingStatus: "SCHEDULED", scheduledAt: new Date(), paymentDeadline: null, selectedForBatchAt: null, } as never); }); + this.logger.log( + `[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`, + ); this.notifier.secured(booking, reason); void this.triggerWagonAllocation(scheduleId); + void this.markWagonAllocatedMilestone(booking.id); + // Customer tracking: freight payment settled (commercial pay-window path). + // Government allocations don't pay upfront — theirs stay pending. + if (reason === 'paid') { + void this.completeTrackingMilestones(booking.id, [ + 'WAGON_REQUESTED', + 'FREIGHT_PAYMENT_PENDING', + 'FREIGHT_PAYMENT_SETTLED', + ]); + } + } + + private async markWagonAllocatedMilestone(bookingId: string): Promise { + if (!this.milestoneService) return; + try { + await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED'); + } catch { + // Booking may have no milestone rows (non-contract path). + } + } + + /** + * Complete customer-tracking milestones on lifecycle events via the + * doc-trigger path — a silent no-op for bookings without milestone rows + * (non-customs bookings). Never blocks the batch action. + */ + private async completeTrackingMilestones( + bookingId: string, + codes: string[], + ): Promise { + if (!this.milestoneService) return; + for (const code of codes) { + try { + await this.milestoneService.completeByDocTrigger({ bookingId }, code); + } catch (err) { + this.logger.warn( + `Milestone ${code} completion failed for booking ${bookingId}: ${(err as Error).message}`, + ); + } + } } /** @@ -1012,39 +1783,142 @@ export class BookingBatchService implements OnModuleInit { private async expire(booking: Booking): Promise { await this.bookingsRepository.update(booking.id, { trainScheduleId: null, - status: 'EXPIRED', - schedulingStatus: 'ELIGIBLE', + status: "EXPIRED", + schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); booking.trainScheduleId = null; + // An unpaid partial offer dies with the reservation — the booking stays whole. + if (this.splitService) { + await this.splitService.expireOpenOffer(booking.id); + } + // Pay window closed before settlement → expire the booking's open invoice too + // (emits `booking.invoice.expired`). Domain owns the reaction; billing stays + // source-agnostic. + await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID"); this.notifier.expired(booking); + this.logger.log( + `[BATCH] EXPIRED ${booking.reference} — payment window passed; freed its ` + + `wagons back to the pool for top-up`, + ); + } + + /** + * Union of stop yards across the day's fillable schedules on this corridor — + * the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings + * are covered. Empty when no fillable schedule exists for the group. + */ + private async corridorYardsForRouteDay( + group: RouteDayGroup, + ): Promise { + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: group.originYardId, + destinationStationId: group.destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: group.originYardId, + destinationStationId: group.destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const yards = new Set(); + for (const schedule of corridor) { + if ( + schedule.scheduledDepartureDate == null || + eatDay(schedule.scheduledDepartureDate) !== group.day + ) { + continue; + } + for (const yardId of await this.stopsForSchedule(schedule)) { + yards.add(yardId); + } + } + return [...yards]; + } + + /** + * Sweep bookings on a route-day whose operation request staff did NOT accept by + * the time the window's document-review phase ends. They never reached + * FULLY_EXECUTED, so they never enter the batch — expire them (customer must + * rebook a new window). No reservation and no invoice exists yet at this stage, + * so this is a lighter expiry than `expire()`: just flip status + notify, and + * best-effort close any payable if one was issued early. Government/export are + * excluded by the query. + */ + async expireUnacceptedForRouteDay(group: RouteDayGroup): Promise { + const corridorYards = await this.corridorYardsForRouteDay(group); + if (corridorYards.length === 0) return; + const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay( + corridorYards, + group.day, + ); + if (unaccepted.length > 0) { + this.logger.log( + `[BATCH] doc-review end: expiring ${unaccepted.length} un-accepted booking(s) ` + + `on ${group.originYardId}->${group.destinationYardId} ${group.day}`, + ); + } + for (const booking of unaccepted) { + await this.bookingsRepository.update(booking.id, { + status: "EXPIRED", + schedulingStatus: "ELIGIBLE", + // Free the shipment day so the customer can rebook a fresh window. + scheduledDate: null, + } as never); + // Close any payable issued before doc-review end (normally none — the invoice + // is created at ops-accept, which by definition has not happened here). + await this.billing + .expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID") + .catch(() => undefined); + this.notifier.expired(booking); + this.logger.log( + `[BATCH] EXPIRED (unaccepted) ${booking.reference}:${booking.id} at doc-review end`, + ); + } } /** * Free capacity for a government booking by displacing the lowest-priority commercial * bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified. + * Only victims whose legs overlap the government booking's leg actually free useful + * room, so others are skipped. Mutates `budget`; returns whether the need now fits. */ private async preemptForGovernment( scheduleId: string, need: Capacity, - budget: Capacity, + leg: CorridorLeg, + budget: CorridorBudget, wagonLengths: WagonLengths, - ): Promise { + ): Promise { + if (budget.fits(need, leg)) return true; const reservedCommercial = ( await this.bookingsRepository.findReservedForSchedule(scheduleId) ).filter((b) => !b.isGovernment); const allocatedCommercial = - await this.bookingsRepository.findAllocatedCommercialForSchedule(scheduleId); + await this.bookingsRepository.findAllocatedCommercialForSchedule( + scheduleId, + ); // lowest priority first; reserved are cheaper to free than allocated const candidates = [...reservedCommercial, ...allocatedCommercial].sort( (a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0), ); - let freed = budget; for (const victim of candidates) { - if (this.fits(need, freed)) break; + if (budget.fits(need, leg)) break; + const victimLeg = budget.legForYards( + victim.originYardId, + victim.destinationYardId, + ); + // Displacing a booking on a disjoint leg frees nothing the government + // booking can use — don't kill it for nothing. + const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge; + if (!overlaps) continue; await this.dataSource.transaction(async (manager) => { await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( scheduleId, @@ -1052,20 +1926,96 @@ export class BookingBatchService implements OnModuleInit { manager, ); await manager.getRepository(Booking).update(victim.id, { - status: 'EXPIRED', - schedulingStatus: 'ELIGIBLE', + status: "EXPIRED", + schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); + // Displaced → EXPIRED: close its open invoice too, so a dead booking + // can't still be paid (mirrors `expire()`; enlisted in this txn). + await this.billing.expirePayable( + Freight.InvoiceSource.Booking, + victim.id, + "PREPAID", + manager, + ); }); this.notifier.displaced(victim); - freed = this.add(freed, this.needFor(victim, wagonLengths)); + budget.add(this.needFor(victim, wagonLengths), victimLeg); } - return freed; + return budget.fits(need, leg); } // ---- capacity helpers ----------------------------------------------------- + /** + * Collapse consolidated partners into single pool entries so the fill treats a + * shared-wagon pair as one atomic unit (both-or-neither). For each pool entry: + * - no `consolidationPartnerId` → passes through as a lone booking. + * - consolidated + partner also in this pool → emitted ONCE (at the position of + * whichever partner ranks first) as a pair; the partner is not emitted again. + * - consolidated + partner NOT in this pool → dropped (can't ship half a wagon; + * it waits for the partner to become ready in a later cycle). + * The pool is already priority-ordered, so emitting the pair at the first-seen + * partner's slot ranks it by the stronger (max-priority) partner automatically. + */ + private groupConsolidatedPool( + pool: Booking[], + ): Array<{ primary: Booking; partner: Booking | null }> { + const byId = new Map(pool.map((b) => [b.id, b])); + const emitted = new Set(); + const units: Array<{ primary: Booking; partner: Booking | null }> = []; + for (const booking of pool) { + if (emitted.has(booking.id)) continue; + const partnerId = booking.consolidationPartnerId ?? null; + if (!partnerId) { + emitted.add(booking.id); + units.push({ primary: booking, partner: null }); + continue; + } + const partner = byId.get(partnerId) ?? null; + if (!partner) { + // Both-or-neither: partner not ready in this pool → skip the pair entirely. + emitted.add(booking.id); + continue; + } + emitted.add(booking.id); + emitted.add(partner.id); + units.push({ primary: booking, partner }); + } + return units; + } + + /** + * Combined capacity need of a consolidated pair sharing wagons. The whole point of + * consolidation is that the two partial 20ft counts pack onto the SAME wagons, so + * the shared wagon count is ceil((c1+c2)/2) — strictly fewer than summing the two + * independently-rounded-up needs (that is the capacity consolidation saves). + */ + private combinedNeed( + primary: Booking, + partner: Booking, + wagonLengths: WagonLengths, + ): Capacity { + const containers = (b: Booking): number => + (b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); + const totalContainers = containers(primary) + containers(partner); + const sharedWagons = + totalContainers > 0 + ? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON) + : this.wagonsFor(primary) + this.wagonsFor(partner); + const weightTons = + Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0); + return { + wagons: sharedWagons, + weightTons, + lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, { + container: wagonLengths.container, + bulk: wagonLengths.bulk, + }), + }; + } + private wagonsFor(booking: Booking): number { if (booking.wagonsRequired && booking.wagonsRequired > 0) { return Math.ceil(booking.wagonsRequired); @@ -1074,7 +2024,10 @@ export class BookingBatchService implements OnModuleInit { (sum, c) => sum + Number(c.quantity ?? 0), 0, ); - return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers || DEFAULT_WAGONS_PER_BOOKING); + return Math.max( + DEFAULT_WAGONS_PER_BOOKING, + fromContainers || DEFAULT_WAGONS_PER_BOOKING, + ); } /** What one booking consumes along all three capacity axes. */ @@ -1098,22 +2051,6 @@ export class BookingBatchService implements OnModuleInit { ); } - private subtract(budget: Capacity, need: Capacity): Capacity { - return { - wagons: budget.wagons - need.wagons, - weightTons: budget.weightTons - need.weightTons, - lengthMeters: budget.lengthMeters - need.lengthMeters, - }; - } - - private add(budget: Capacity, freed: Capacity): Capacity { - return { - wagons: budget.wagons + freed.wagons, - weightTons: budget.weightTons + freed.weightTons, - lengthMeters: budget.lengthMeters + freed.lengthMeters, - }; - } - /** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */ private async capacityLimits( locomotive: Locomotive, @@ -1161,7 +2098,7 @@ export class BookingBatchService implements OnModuleInit { Array<{ lengthMeters: number; capacityTons: number }> > { const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: 'NW5' }, { code: 'CW3' }], + where: [{ code: "NW5" }, { code: "CW3" }], }); if (types.length) return types.map(wagonTypeDimensionsFromEntity); return [ @@ -1172,77 +2109,157 @@ export class BookingBatchService implements OnModuleInit { private async loadWagonLengths(): Promise { const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: 'NW5' }, { code: 'CW3' }], + where: [{ code: "NW5" }, { code: "CW3" }], }); - const byCode = new Map(types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)])); + const byCode = new Map( + types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]), + ); return { - container: byCode.get('NW5')?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS, - bulk: byCode.get('CW3')?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, + container: + byCode.get("NW5")?.lengthMeters ?? + DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, }; } private async loadGlobalRules(): Promise { - return this.dataSource.getRepository(TrainSchedulingGlobalRules).findOne({ where: {} }); + return this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .findOne({ where: {} }); } - /** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */ - private async remainingCapacity( + /** + * Ordered stop yards of the schedule's route (origin → milestones → + * destination); the legacy two-stop pseudo-route when milestones are absent. + */ + private async stopsForSchedule(schedule: TrainSchedule): Promise { + let milestoneYards: string[] | null = null; + if (schedule.routeId) { + const milestones = await this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } }); + if (milestones.length >= 2) milestoneYards = milestones.map((m) => m.yardId); + } + return stopYardsFor( + milestoneYards, + schedule.originStationId, + schedule.destinationStationId, + ); + } + + /** + * Remaining capacity per corridor edge = hard caps minus what allocated + + * reserved bookings already use ON THEIR OWN LEGS. A booking riding only + * Dire→Djibouti leaves the Addis→Dire edges untouched. + */ + private async remainingBudget( schedule: TrainSchedule, limits: Capacity, wagonLengths: WagonLengths, - ): Promise { + ): Promise { + const stops = await this.stopsForSchedule(schedule); + const budget = new CorridorBudget(stops, limits); const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); - const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id); - const used = [...allocated, ...reserved].reduce( - (acc, b) => this.add(acc, this.needFor(b, wagonLengths)), - { wagons: 0, weightTons: 0, lengthMeters: 0 }, + const reserved = await this.bookingsRepository.findReservedForSchedule( + schedule.id, ); - return this.subtract(limits, used); + for (const b of [...allocated, ...reserved]) { + budget.subtract( + this.needFor(b, wagonLengths), + budget.legForYards(b.originYardId, b.destinationYardId), + ); + } + return budget; } - /** maxWagons minus wagons already taken by allocated + reserved bookings. */ + /** + * Wagon slots still boardable somewhere on the corridor (most-open edge). + * ≤ 0 means no leg can take another booking — the train-wide FULL signal. + */ private async remainingWagons(schedule: TrainSchedule): Promise { - const allocated = (schedule.scheduleBookings ?? []) - .map((sb) => sb.booking) - .filter((b): b is Booking => Boolean(b)); - const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id); - const used = - allocated.reduce((s, b) => s + this.wagonsFor(b), 0) + - reserved.reduce((s, b) => s + this.wagonsFor(b), 0); - return (schedule.maxWagons ?? 0) - used; + const wagonLengths = await this.loadWagonLengths(); + const budget = await this.remainingBudget( + schedule, + { + wagons: schedule.maxWagons ?? 0, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + wagonLengths, + ); + return budget.maxRemaining().wagons; } - private async setWindow( + async setWindow( scheduleId: string, - status: 'OPEN' | 'FULL' | 'CLOSED', + status: "OPEN" | "FULL" | "CLOSED", ): Promise { await this.dataSource .getRepository(TrainSchedule) .update(scheduleId, { bookingWindowStatus: status }); + // Push the change (open / train full / closed) so portal home and GL cards + // flip in real time — FULL in particular happens outside the window tick + // (batch fill, staff mark-paid) and had no live signal before. + try { + const fresh = await this.trainSchedulesRepository.findById(scheduleId); + if (fresh) this.bookingWindowGateway.emitPhase(fresh); + } catch (err) { + this.logger.warn( + `Booking-window push failed for ${scheduleId}: ${(err as Error).message}`, + ); + } + } + + /** No wagon slots left for allocated + reserved bookings. */ + async isScheduleFull(scheduleId: string): Promise { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) return false; + return (await this.remainingWagons(schedule)) <= 0; } // ---- timer plumbing ------------------------------------------------------- + /** Configured customer pay window in ms (global rules, with defaults). */ + private async paymentWindowMs(): Promise { + const cfg = await this.trainSchedulingService.getWindowConfig(); + return cfg.paymentWindowMinutes * 60_000; + } + private timeoutName(scheduleId: string): string { return `settle:${scheduleId}`; } + /** + * In-process accelerator only — the durable settle enforcement is the window + * engine's minute tick calling settleDueReservations off `paymentDeadline`. + */ private armSettle(scheduleId: string): void { - this.removeTimeout(scheduleId); - const handle = setTimeout(() => { - void this.settleBatch(scheduleId).catch((err) => - this.logger.error(`settleBatch ${scheduleId} failed: ${(err as Error).message}`), + void this.paymentWindowMs() + .then((delayMs) => { + this.removeTimeout(scheduleId); + const handle = setTimeout(() => { + void this.settleBatch(scheduleId).catch((err) => + this.logger.error( + `settleBatch ${scheduleId} failed: ${(err as Error).message}`, + ), + ); + }, delayMs); + this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); + }) + .catch((err) => + this.logger.warn( + `armSettle ${scheduleId} skipped: ${(err as Error).message}`, + ), ); - }, PAYMENT_WINDOW_MS); - this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); } private removeTimeout(scheduleId: string): void { const name = this.timeoutName(scheduleId); try { - if (this.scheduler.doesExist('timeout', name)) { + if (this.scheduler.doesExist("timeout", name)) { this.scheduler.deleteTimeout(name); } } catch { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts new file mode 100644 index 000000000..cdc58083c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -0,0 +1,394 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + Optional, +} from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager, In } from 'typeorm'; +import { Freight } from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; +import { Yard } from '../rule-engine/entities/yard.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; +import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; + +/** + * Per-booking journey along a train's corridor — for EVERY trade direction. + * + * A booking rides only its own origin→destination leg, so "dispatched" and + * "arrived" are per-booking facts confirmed by the yard operator, not train + * facts: load at the booking's origin yard (PAID → IN_TRANSIT, loadedAt) and + * unload at its destination yard (IN_TRANSIT → ARRIVED for import/export, + * → COMPLETED for intercity), possibly long before the train's final arrival. + * Both are gated on the train's latest recorded checkpoint being at that yard. + * + * Unloading also settles the physical wagons: each wagon that alights with the + * booking is released at that yard and the move is written to the + * wagon_movements ledger. + */ +@Injectable() +export class BookingJourneyService { + private readonly logger = new Logger(BookingJourneyService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + @Optional() private readonly milestoneService?: ClearanceMilestoneService, + ) {} + + /** Statuses from which a booking may be loaded (gov bookings don't prepay). */ + private canLoad(booking: Booking): boolean { + if (booking.status === 'PAID') return true; + return booking.isGovernment && booking.status === 'APPROVED'; + } + + async loadBooking(scheduleId: string, bookingId: string, userId?: string | null) { + const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId); + if (booking.loadedAt || booking.status === 'IN_TRANSIT') { + throw new BadRequestException('Booking is already loaded'); + } + if (!this.canLoad(booking)) { + throw new BadRequestException( + `Booking must be paid before loading (currently ${booking.status})`, + ); + } + await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); + + const now = new Date(); + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(Booking).update(bookingId, { + status: 'IN_TRANSIT', + loadedAt: now, + loadedByUserId: userId ?? null, + } as never); + await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED'); + }); + + // Customer tracking: cargo is on the train — loading milestones plus the + // direction's "departed" handoff. Doc-trigger path no-ops non-customs + // bookings (intercity) and already-completed codes. + void this.completeMilestones(booking, [ + 'CARGO_ARRIVED', + 'READY_FOR_LOADING', + 'LOADED', + ...(booking.tradeDirection === 'IMPORT' + ? ['DEPARTED_FROM_DJIBOUTI'] + : booking.tradeDirection === 'EXPORT' + ? ['DEPARTED_TO_DJIBOUTI'] + : []), + ]); + + return { bookingId, status: 'IN_TRANSIT' as const, loadedAt: now.toISOString() }; + } + + async unloadBooking(scheduleId: string, bookingId: string, userId?: string | null) { + const { schedule, booking } = await this.getScheduleBooking(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'); + + // Intercity has no clearance/delivery tail — unloading completes it. Import/ + // export continue into clearance, keyed on the booking's own arrival. + const nextStatus = booking.tradeDirection === 'DOMESTIC' ? 'COMPLETED' : 'ARRIVED'; + const now = new Date(); + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(Booking).update(bookingId, { + status: nextStatus, + arrivedAt: now, + arrivedByUserId: userId ?? null, + } as never); + await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED'); + await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null); + }); + + // Customer tracking: THIS booking arrived (train may still be rolling). + void this.completeMilestones(booking, [ + ...(booking.tradeDirection === 'IMPORT' + ? ['ARRIVED_ETHIOPIA'] + : booking.tradeDirection === 'EXPORT' + ? ['ARRIVED_AT_DJIBOUTI'] + : []), + ]); + + return { bookingId, status: nextStatus, arrivedAt: now.toISOString() }; + } + + /** + * Per-yard operator worklist for a schedule: which bookings board / alight at + * each stop, with their journey state, so the yard operator at Dire sees + * exactly what to load and unload when the train is there. + */ + async listYardWork(scheduleId: string) { + const schedule = await this.getSchedule(scheduleId); + const bookings = await this.dataSource + .getRepository(Booking) + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .innerJoin( + 'freight.train_schedule_bookings', + 'tsb', + 'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL', + { scheduleId }, + ) + .getMany(); + + const latest = await this.latestCheckpoint(scheduleId); + const yardIds = [ + ...new Set( + bookings.flatMap((b) => [b.originYardId, b.destinationYardId]).filter(Boolean), + ), + ]; + const yards = yardIds.length + ? await this.dataSource.getRepository(Yard).find({ where: { id: In(yardIds) } }) + : []; + const yardById = new Map(yards.map((y) => [y.id, y])); + const yardLabel = (id: string) => + yardById.get(id)?.label ?? yardById.get(id)?.code ?? id; + + const mapBooking = (b: Booking) => ({ + id: b.id, + reference: b.reference, + status: b.status, + tradeDirection: b.tradeDirection, + isGovernment: b.isGovernment, + customer: b.company?.name ?? 'Unknown customer', + originYardId: b.originYardId, + destinationYardId: b.destinationYardId, + origin: yardLabel(b.originYardId), + destination: yardLabel(b.destinationYardId), + loadedAt: b.loadedAt?.toISOString() ?? null, + arrivedAt: b.arrivedAt?.toISOString() ?? null, + canLoad: !b.loadedAt && this.canLoad(b), + canUnload: b.status === 'IN_TRANSIT', + }); + + const byYard = new Map< + string, + { yardId: string; yard: string; toLoad: ReturnType[]; toUnload: ReturnType[] } + >(); + const bucket = (yardId: string) => { + let entry = byYard.get(yardId); + if (!entry) { + entry = { yardId, yard: yardLabel(yardId), toLoad: [], toUnload: [] }; + byYard.set(yardId, entry); + } + return entry; + }; + for (const b of bookings) { + bucket(b.originYardId).toLoad.push(mapBooking(b)); + bucket(b.destinationYardId).toUnload.push(mapBooking(b)); + } + + return { + scheduleId, + scheduleStatus: schedule.status, + trainAtYardId: latest?.yardId ?? (schedule.status === 'DISPATCHED' ? null : schedule.originStationId), + yards: [...byYard.values()], + }; + } + + /** + * Bulk fallback at the train's FINAL arrival: any booking destined for the + * final yard that operators didn't unload individually gets its per-booking + * arrival stamped now, so nothing stays stuck. Mid-corridor bookings are NOT + * touched — their arrival is their own unload. Returns the affected ids. + */ + async autoArriveAtFinalYard( + manager: EntityManager, + schedule: TrainSchedule, + now: Date, + ): Promise { + const rows: Array<{ id: string; trade_direction: string }> = await manager.query( + `UPDATE freight.bookings b + SET status = CASE WHEN b.trade_direction = 'DOMESTIC' THEN 'COMPLETED' ELSE 'ARRIVED' END, + scheduling_status = 'DISPATCHED', + arrived_at = COALESCE(b.arrived_at, $3), + loaded_at = COALESCE(b.loaded_at, b.created_at) + FROM freight.train_schedule_bookings tsb + WHERE tsb.booking_id = b.id + AND tsb.train_schedule_id = $1 + AND tsb.deleted_at IS NULL + AND b.deleted_at IS NULL + AND b.destination_yard_id = $2 + AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED', 'ARRIVED', 'DELIVERED') + RETURNING b.id, b.trade_direction`, + [schedule.id, schedule.destinationStationId, now], + ); + return rows.map((r) => r.id); + } + + // ---- helpers --------------------------------------------------------------- + + private async getSchedule(scheduleId: string): Promise { + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: scheduleId } }); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + return schedule; + } + + private async getScheduleBooking(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'); + } + return { schedule, booking }; + } + + private async latestCheckpoint(scheduleId: string): Promise { + return this.dataSource.getRepository(TrainCheckpointEvent).findOne({ + where: { trainScheduleId: scheduleId }, + order: { occurredAt: 'DESC', createdAt: 'DESC' }, + }); + } + + /** + * The train is "at" a yard when the latest recorded checkpoint is that yard, + * or — for a booking boarding at the train's own origin — when the train has + * not recorded any checkpoint yet (still sitting at its origin). + */ + private async assertTrainAtYard( + schedule: TrainSchedule, + yardId: string, + side: 'origin' | 'destination', + ): Promise { + const latest = await this.latestCheckpoint(schedule.id); + 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 async setAllocationStatuses( + manager: EntityManager, + scheduleId: string, + bookingId: string, + status: 'LOADED' | 'DEPARTED', + ): Promise { + const allocations = await this.allocationsForBooking(manager, scheduleId, bookingId); + if (!allocations.length) return; + await manager + .getRepository(WagonBookingAllocation) + .update({ id: In(allocations.map((a) => a.id)) }, { status }); + } + + private async allocationsForBooking( + manager: EntityManager, + scheduleId: string, + bookingId: string, + ): Promise> { + return manager + .getRepository(WagonBookingAllocation) + .createQueryBuilder('alloc') + .innerJoinAndSelect('alloc.trainSetWagon', 'slot') + .innerJoin( + 'freight.train_schedules', + 'schedule', + 'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId', + { scheduleId }, + ) + .where('alloc.booking_id = :bookingId', { bookingId }) + .getMany(); + } + + /** + * On unload: write the wagon_movements ledger rows (board yard → unload yard, + * kind LOADED) for the booking's pinned wagons, and release each wagon whose + * slot alights here — it detaches, stays at this yard, and becomes Available + * (dynamic consist). Wagons shared with a still-loaded consolidated partner + * stay pinned until the last booking on the slot unloads. + */ + private async settleWagonsOnUnload( + manager: EntityManager, + schedule: TrainSchedule, + booking: Booking, + now: Date, + userId: string | null, + ): Promise { + const allocations = await this.allocationsForBooking(manager, schedule.id, booking.id); + for (const alloc of allocations) { + const slot = alloc.trainSetWagon; + if (!slot?.physicalWagonId) continue; + + const boardYardId = slot.boardYardId ?? schedule.originStationId; + await manager.getRepository(WagonMovement).save( + manager.getRepository(WagonMovement).create({ + wagonId: slot.physicalWagonId, + fromYardId: boardYardId, + toYardId: booking.destinationYardId, + trainScheduleId: schedule.id, + bookingId: booking.id, + kind: Freight.WagonMovementKind.Loaded, + movedByUserId: userId, + occurredAt: now, + }), + ); + + // Detach only when this yard is where the slot's leg ends and no other + // booking on the wagon is still in transit. + const slotAlightYardId = slot.alightYardId ?? schedule.destinationStationId; + if (slotAlightYardId !== booking.destinationYardId) continue; + const siblings = await manager + .getRepository(WagonBookingAllocation) + .createQueryBuilder('alloc') + .innerJoin('alloc.booking', 'b') + .where('alloc.train_set_wagon_id = :slotId', { slotId: slot.id }) + .andWhere('alloc.booking_id != :bookingId', { bookingId: booking.id }) + .andWhere(`b.status = 'IN_TRANSIT'`) + .getCount(); + if (siblings > 0) continue; + + await manager.getRepository(TrainSetWagon).update(slot.id, { status: 'DEPARTED' }); + const wagon = await manager + .getRepository(Wagon) + .findOne({ where: { id: slot.physicalWagonId } }); + // Only settle a wagon still bound to this schedule (it may have been + // re-pinned elsewhere already). + if (wagon && wagon.currentTrainScheduleId === schedule.id) { + await manager.getRepository(Wagon).update(wagon.id, { + currentYardId: booking.destinationYardId, + currentTrainScheduleId: null, + trainSetWagonId: null, + status: Freight.WagonStatus.Available, + }); + } + } + } + + private async completeMilestones(booking: Booking, codes: string[]): Promise { + if (!this.milestoneService || !codes.length) return; + for (const code of codes) { + try { + await this.milestoneService.completeByDocTrigger({ bookingId: booking.id }, code); + } catch (err) { + this.logger.warn( + `Milestone ${code} completion failed for booking ${booking.id}: ${(err as Error).message}`, + ); + } + } + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index e8f272123..48985bdf0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -1,14 +1,23 @@ import { Injectable, Logger } from '@nestjs/common'; +import { + NotificationAudience, + NotificationType, + NotifyInput, +} from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; -import { PAYMENT_WINDOW_MS } from './booking-batch.constants'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { BATCH_TIMEZONE } from './booking-batch.constants'; @Injectable() export class BookingNotifierService { private readonly logger = new Logger(BookingNotifierService.name); - constructor(private readonly notifications: NotificationsService) {} + constructor( + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} private ref(b: Booking): string { return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; @@ -42,11 +51,75 @@ export class BookingNotifierService { } } + /** Persist + push an in-app item to all portal users of the booking's company. */ + private inApp( + b: Booking, + title: string, + body: string, + overrides: Partial = {}, + ): void { + if (!b.companyId) return; // government/unlinked bookings have no portal users + void this.inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title, + body, + link: `/bookings/${b.id}`, + data: { bookingId: b.id, reference: b.reference }, + ...overrides, + }); + } + + /** Train carrying the booking departed — dispatched origin → destination. */ + dispatched(b: Booking, origin: string | null, destination: string | null): void { + const msg = + `Your booking ${b.reference ?? b.id} has been dispatched` + + `${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`; + void this.notifyContact(b, msg, 'DISPATCHED'); + this.inApp(b, 'Shipment dispatched', msg); + } + + /** Train carrying the booking arrived at destination. */ + arrived(b: Booking, origin: string | null, destination: string | null): void { + const msg = + `Your booking ${b.reference ?? b.id} has arrived` + + `${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`; + void this.notifyContact(b, msg, 'ARRIVED'); + this.inApp(b, 'Shipment arrived', msg); + } + async payNow(b: Booking, deadline: Date): Promise { - const payMinutes = Math.round(PAYMENT_WINDOW_MS / 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 msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW'); + this.inApp(b, 'Payment window open', msg, { + type: NotificationType.INVOICE_ISSUED, + }); + } + + /** + * Partial-capacity offer: only `offeredWagons` of the booking's `totalWagons` fit + * this train. Paying accepts the split; letting the deadline pass keeps the + * booking whole and expires it for this train. + */ + async payNowPartial( + b: Booking, + deadline: Date, + offeredWagons: number, + totalWagons: number, + ): Promise { + const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); + const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); + const msg = + `Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` + + `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` + + `(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; + await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)'); + this.inApp(b, 'Partial allocation offer', msg, { + type: NotificationType.INVOICE_ISSUED, + }); } secured(b: Booking, reason: 'paid' | 'gov'): void { @@ -54,11 +127,13 @@ export class BookingNotifierService { reason === 'gov' ? ' (government)' : '' }.`; void this.notifyContact(b, msg, 'ALLOCATED'); + this.inApp(b, 'Wagon allocated', msg); } expired(b: Booking): void { const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`; void this.notifyContact(b, msg, 'EXPIRED'); + this.inApp(b, 'Payment window expired', msg); } scheduleFull(b: Booking): void { @@ -81,5 +156,42 @@ export class BookingNotifierService { displaced(b: Booking): void { const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`; void this.notifyContact(b, msg, 'DISPLACED'); + this.inApp(b, 'Booking displaced', msg); + } + + /** + * Staff rescheduled the train carrying this booking to a new departure date. + * The booking stays on the train — only the date moved. + */ + rescheduled(b: Booking, newDeparture: Date): void { + const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); + const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`; + void this.notifyContact(b, msg, 'RESCHEDULED'); + this.inApp(b, 'Booking rescheduled', msg); + } + + /** + * Booking was removed from its train during a staff reschedule (not a government + * pre-empt). It returns to eligible — the customer must rebook or reschedule. + */ + removedFromTrain(b: Booking): void { + const msg = + `Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` + + `Please rebook or select a new schedule from the portal.`; + void this.notifyContact(b, msg, 'REMOVED FROM TRAIN'); + this.inApp(b, 'Removed from train', msg); + } + + /** + * The train carrying this booking was moved for maintenance to a new departure + * date. The booking stays on the train — only the date moved. + */ + maintenanceMoved(b: Booking, newDeparture: Date): void { + const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); + const msg = + `The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` + + `New departure date: ${when}.`; + void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE'); + this.inApp(b, 'Train maintenance reschedule', msg); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts new file mode 100644 index 000000000..6d706c423 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts @@ -0,0 +1,101 @@ +import { BookingSplitService } from './booking-split.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; +import { Contract } from '../contracts/entities/contract.entity'; +import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; + +/** + * applySplit promotion behaviour: a ONE_TIME contract must be flipped to GENERAL + * (both the parent contract row and the booking's denormalized copy) so the split + * remainder can be rebooked. A GENERAL booking is left untouched. + */ +describe('BookingSplitService — applySplit ONE_TIME promotion', () => { + const bookingId = 'bk-1'; + const contractId = 'ct-1'; + const offerId = 'of-1'; + + const buildService = (bookingContractKind: 'ONE_TIME' | 'GENERAL') => { + const offer = { + id: offerId, + bookingId, + status: 'OFFERED', + offeredWagons: 3, + totalWagons: 5, + offeredWeightTons: 30, + offeredAmount: 300, + offeredPricingBreakdown: {}, + offeredLines: null, + } as unknown as BookingBatchOffer; + + const bookingRepo = { + update: jest.fn().mockResolvedValue(undefined), + findOne: jest.fn().mockResolvedValue({ + id: bookingId, + contractId, + contractKind: bookingContractKind, + }), + find: jest.fn().mockResolvedValue([]), + softDelete: jest.fn().mockResolvedValue(undefined), + }; + const contractRepo = { update: jest.fn().mockResolvedValue(undefined) }; + const offerRepo = { + findOne: jest.fn().mockResolvedValue(offer), + update: jest.fn().mockResolvedValue(undefined), + }; + const containerRepo = { + find: jest.fn().mockResolvedValue([]), + update: jest.fn(), + softDelete: jest.fn(), + }; + const unitRepo = { find: jest.fn().mockResolvedValue([]), softDelete: jest.fn() }; + + const repoFor = (entity: unknown) => { + if (entity === Booking) return bookingRepo; + if (entity === Contract) return contractRepo; + if (entity === BookingBatchOffer) return offerRepo; + if (entity === BookingContainer) return containerRepo; + if (entity === BookingContainerUnit) return unitRepo; + return { find: jest.fn().mockResolvedValue([]), update: jest.fn() }; + }; + + const dataSource = { + getRepository: jest.fn(repoFor), + transaction: jest.fn(async (fn: (m: unknown) => Promise) => { + await fn({ getRepository: repoFor }); + }), + }; + + const service = new BookingSplitService( + dataSource as never, + {} as never, + {} as never, + { expirePayable: jest.fn() } as never, + { payNowPartial: jest.fn() } as never, + ); + return { service, bookingRepo, contractRepo }; + }; + + it('promotes a ONE_TIME booking + parent contract to GENERAL', async () => { + const { service, bookingRepo, contractRepo } = buildService('ONE_TIME'); + + await service.applySplit(bookingId); + + expect(bookingRepo.update).toHaveBeenCalledWith( + bookingId, + expect.objectContaining({ contractKind: 'GENERAL' }), + ); + expect(contractRepo.update).toHaveBeenCalledWith( + contractId, + expect.objectContaining({ contractKind: 'GENERAL' }), + ); + }); + + it('leaves a GENERAL booking untouched (no contract promotion)', async () => { + const { service, contractRepo } = buildService('GENERAL'); + + await service.applySplit(bookingId); + + expect(contractRepo.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts new file mode 100644 index 000000000..44886f116 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts @@ -0,0 +1,291 @@ +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { Freight } from '@edr/types'; + +import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { BookingInvoiceService } from '../bookings/booking-invoice.service'; +import { BillingService } from '../billing/billing.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; +import { Contract } from '../contracts/entities/contract.entity'; +import { + BookingBatchOffer, + OfferedLine, +} from './entities/booking-batch-offer.entity'; +import { BookingNotifierService } from './booking-notifier.service'; + +export interface SizedOffer { + offeredWagons: number; + totalWagons: number; + offeredLines: OfferedLine[] | null; + offeredWeightTons: number; + offeredAmount: number; + offeredPricingBreakdown: Record; +} + +/** + * Partial-capacity booking splits (import batch). The offer is sized and priced + * against an in-memory clone — the booking row is untouched until the customer + * pays, which is the act of accepting the split (applySplit). No payment → + * offer expires and the booking stays whole. + * + * GENERAL and ONE_TIME commercial bookings are offered partials: the remainder + * returns to the contract's quantity cap (derived live from booking_container + * rows, so reducing the lines releases it automatically) and can be rebooked in + * any later window within contract validity. A ONE_TIME contract is promoted to + * GENERAL on split (see applySplit) so its remainder is actually rebookable. + */ +@Injectable() +export class BookingSplitService { + private readonly logger = new Logger(BookingSplitService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + @Inject(forwardRef(() => BookingPricingService)) + private readonly pricing: BookingPricingService, + @Inject(forwardRef(() => BookingInvoiceService)) + private readonly invoiceService: BookingInvoiceService, + private readonly billing: BillingService, + private readonly notifier: BookingNotifierService, + ) {} + + /** + * Size the largest part of the booking that fits `freeWagons`, priced via an + * in-memory clone. Returns null when nothing meaningful fits (no whole + * container unit / no bulk tonnage, or pricing failed). + */ + async sizeOffer( + booking: Booking, + freeWagons: number, + totalWagons: number, + bulkWagonCapacityTons: number, + ): Promise { + if (freeWagons < 1 || freeWagons >= totalWagons) return null; + + const containers = booking.bookingContainers ?? []; + let offeredLines: OfferedLine[] | null = null; + let offeredWeightTons = 0; + let offeredWagons = 0; + const clone: Booking = Object.assign(Object.create(Object.getPrototypeOf(booking)), booking); + clone.adjustedTotalAmount = null; + + if (containers.length) { + offeredLines = []; + let remaining = freeWagons; + const clonedContainers: BookingContainer[] = []; + for (const line of containers) { + const quantity = Number(line.quantity ?? 0); + const lineWagons = Number(line.wagonsRequired ?? 0); + if (quantity <= 0 || lineWagons <= 0 || remaining <= 0) continue; + const perUnit = lineWagons / quantity; + // Largest unit count whose wagon need still fits the remaining budget. + let take = Math.min(quantity, Math.floor(remaining / perUnit)); + while (take > 0 && Math.ceil(take * perUnit) > remaining) take -= 1; + if (take <= 0) continue; + const takeWagons = Math.ceil(take * perUnit); + const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0); + offeredLines.push({ + bookingContainerId: line.id, + quantity: take, + wagonsRequired: takeWagons, + totalVgmTons: Math.round(take * vgmPerUnit * 1000) / 1000, + }); + offeredWeightTons += take * vgmPerUnit; + offeredWagons += takeWagons; + remaining -= takeWagons; + + const clonedLine: BookingContainer = Object.assign( + Object.create(Object.getPrototypeOf(line)), + line, + { + quantity: take, + wagonsRequired: takeWagons, + totalVgmTons: take * vgmPerUnit, + }, + ); + clonedContainers.push(clonedLine); + } + if (!offeredLines.length || offeredWagons <= 0) return null; + clone.bookingContainers = clonedContainers; + } else { + // Bulk: split by weight — the offered part is what freeWagons can carry. + const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0); + if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null; + offeredWeightTons = Math.min(totalWeight, freeWagons * bulkWagonCapacityTons); + if (offeredWeightTons <= 0) return null; + offeredWagons = Math.min( + freeWagons, + Math.max(1, Math.ceil(offeredWeightTons / bulkWagonCapacityTons)), + ); + } + + offeredWeightTons = Math.round(offeredWeightTons * 1000) / 1000; + clone.cargoTotalWeightVgm = offeredWeightTons; + clone.wagonsRequired = offeredWagons; + + try { + const priced = await this.pricing.computePriceForBooking(clone); + return { + offeredWagons, + totalWagons, + offeredLines, + offeredWeightTons, + offeredAmount: priced.totalAmount, + offeredPricingBreakdown: { + lineItems: priced.lineItems, + totalAmount: priced.totalAmount, + currency: priced.currency, + generatedAt: new Date().toISOString(), + partialOfWagons: totalWagons, + }, + }; + } catch (err) { + this.logger.warn( + `Partial pricing failed for ${booking.reference ?? booking.id}: ${(err as Error).message}`, + ); + return null; + } + } + + /** + * Persist the offer and swap the booking's payable to a partial invoice for the + * offered amount. Any previous open offer for the booking is superseded. + */ + async createOffer( + booking: Booking, + scheduleId: string, + sized: SizedOffer, + deadline: Date, + ): Promise { + const repo = this.dataSource.getRepository(BookingBatchOffer); + await repo.update({ bookingId: booking.id, status: 'OFFERED' }, { status: 'EXPIRED' }); + + // The full-amount invoice must not stay payable next to the partial one. + await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, 'PREPAID'); + const invoice = await this.invoiceService.ensureInvoiceForBooking( + { ...booking, pricingBreakdown: sized.offeredPricingBreakdown, adjustedTotalAmount: null } as Booking, + { dueDate: deadline, invoiceStatus: Freight.InvoiceStatus.Pending }, + ); + + const offer = await repo.save( + repo.create({ + bookingId: booking.id, + trainScheduleId: scheduleId, + offeredWagons: sized.offeredWagons, + totalWagons: sized.totalWagons, + offeredLines: sized.offeredLines, + offeredWeightTons: sized.offeredWeightTons, + offeredAmount: sized.offeredAmount, + offeredPricingBreakdown: sized.offeredPricingBreakdown, + invoiceId: invoice.id, + paymentDeadline: deadline, + status: 'OFFERED', + }), + ); + await this.notifier.payNowPartial(booking, deadline, sized.offeredWagons, sized.totalWagons); + return offer; + } + + /** + * Payment received inside the window — the customer accepted the split. + * Reduce the booking to the offered lines/weight; the remainder returns to the + * contract cap automatically (bookedQuantities derives from live lines). + * Idempotent: no OFFERED offer → no-op. + */ + async applySplit(bookingId: string): Promise { + const offer = await this.dataSource.getRepository(BookingBatchOffer).findOne({ + where: { bookingId, status: 'OFFERED' }, + order: { createdAt: 'DESC' }, + }); + if (!offer) return; + + await this.dataSource.transaction(async (manager) => { + if (offer.offeredLines?.length) { + const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l])); + const lines = await manager.getRepository(BookingContainer).find({ + where: { bookingId }, + }); + for (const line of lines) { + const kept = keptByLine.get(line.id); + if (!kept) { + await manager.getRepository(BookingContainer).softDelete(line.id); + await manager + .getRepository(BookingContainerUnit) + .softDelete({ bookingContainerId: line.id }); + continue; + } + const dropCount = Number(line.quantity) - kept.quantity; + await manager.getRepository(BookingContainer).update(line.id, { + quantity: kept.quantity, + wagonsRequired: kept.wagonsRequired, + totalVgmTons: kept.totalVgmTons, + hazardousQuantity: Math.min(Number(line.hazardousQuantity ?? 0), kept.quantity), + reeferQuantity: Math.min(Number(line.reeferQuantity ?? 0), kept.quantity), + }); + if (dropCount > 0) { + // Trim surplus physical units, last-entered first. + const units = await manager.getRepository(BookingContainerUnit).find({ + where: { bookingContainerId: line.id }, + order: { sortOrder: 'DESC', createdAt: 'DESC' }, + take: dropCount, + }); + if (units.length) { + await manager + .getRepository(BookingContainerUnit) + .softDelete(units.map((u) => u.id)); + } + } + } + } + + await manager.getRepository(Booking).update(bookingId, { + wagonsRequired: offer.offeredWagons, + cargoTotalWeightVgm: offer.offeredWeightTons, + totalAmount: offer.offeredAmount, + pricingBreakdown: offer.offeredPricingBreakdown, + } as never); + + // A ONE_TIME contract permits a single active booking, which would block the + // split remainder from ever being rebooked. Promote the parent contract (and + // the booking's denormalized copy) to GENERAL so the leftover quantity draws + // down against the cap like any general contract, within the same validity. + const booking = await manager.getRepository(Booking).findOne({ + where: { id: bookingId }, + select: { id: true, contractId: true, contractKind: true }, + }); + if (booking?.contractKind === 'ONE_TIME') { + await manager + .getRepository(Booking) + .update(bookingId, { contractKind: 'GENERAL' } as never); + if (booking.contractId) { + await manager + .getRepository(Contract) + .update(booking.contractId, { contractKind: 'GENERAL' } as never); + } + } + + await manager + .getRepository(BookingBatchOffer) + .update(offer.id, { status: 'APPLIED' }); + }); + this.logger.log( + `Split applied for booking ${bookingId}: ${offer.offeredWagons}/${offer.totalWagons} wagons ride schedule ${offer.trainScheduleId}`, + ); + } + + /** Pay window closed without payment — offer dies, booking stays whole. */ + async expireOpenOffer(bookingId: string): Promise { + await this.dataSource + .getRepository(BookingBatchOffer) + .update({ bookingId, status: 'OFFERED' }, { status: 'EXPIRED' }); + } + + async findOpenOffer(bookingId: string): Promise { + return this.dataSource.getRepository(BookingBatchOffer).findOne({ + where: { bookingId, status: 'OFFERED' }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts new file mode 100644 index 000000000..25d569171 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts @@ -0,0 +1,36 @@ +/** + * Booking-window timings sourced from the train_scheduling_global_rules singleton, + * with hardcoded fallbacks when the row is missing (see TrainSchedulingService.getWindowConfig). + */ +export interface BookingWindowConfig { + /** Days before departure the single import booking-window day falls on. */ + importWindowLeadDays: number; + /** Hours before departure an export booking becomes acceptable (FCFS). */ + exportBookingLeadHours: number; + /** Local (Africa/Addis_Ababa) hour at which the import window opens each day. */ + windowOpenHour: number; + /** + * Local (Africa/Addis_Ababa) hour the booking desk shuts for the day: once a + * cycle's reopen would fall at/after this hour, the window pauses and resumes + * next morning at windowOpenHour. Equal to windowOpenHour ⇒ 24-hour desk. + */ + windowCloseHour: number; + windowDurationHours: number; + /** Max staff document-review time after the window closes. */ + docReviewMinutes: number; + paymentWindowMinutes: number; + /** Delay after window close before reopening when the train is not full. */ + reopenDelayMinutes: number; +} + +/** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */ +export const WINDOW_PHASES = [ + 'PRE_WINDOW', + 'OPEN', + 'DOC_REVIEW', + 'PAYMENT', + 'CLOSED_FOR_DAY', + 'DONE', +] as const; + +export type WindowPhase = (typeof WINDOW_PHASES)[number]; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts new file mode 100644 index 000000000..a439e442d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.spec.ts @@ -0,0 +1,109 @@ +import { BOOKING_WINDOW_WS_EVENTS, BOOKING_WINDOW_WS_NAMESPACE } from '@edr/types'; +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { io, type Socket } from 'socket.io-client'; + +import { WsAuthService } from '../notification-inbox/ws-auth.service'; +import { BookingWindowGateway } from './booking-window.gateway'; +import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; + +/** + * End-to-end proof the booking-window socket works: boots a real Nest app with + * the gateway, connects a real socket.io client to the namespace, emits a phase + * change, and asserts the client receives the exact payload. If this passes, + * any "no live update" report is environmental (stale server process, wrong + * checkout running, client not connecting) — not the gateway. + */ +describe('BookingWindowGateway (e2e)', () => { + let app: INestApplication; + let gateway: BookingWindowGateway; + let client: Socket; + let baseUrl: string; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + BookingWindowGateway, + // Accept any token — auth plumbing is covered by the real WsAuthService. + { provide: WsAuthService, useValue: { resolveUserId: async () => 'user-1' } }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.listen(0); + const address = app.getHttpServer().address() as { port: number }; + baseUrl = `http://127.0.0.1:${address.port}`; + gateway = app.get(BookingWindowGateway); + }); + + afterAll(async () => { + client?.disconnect(); + await app?.close(); + }); + + it('authenticated client receives the phase event with the schedule state', async () => { + client = io(`${baseUrl}/${BOOKING_WINDOW_WS_NAMESPACE}`, { + auth: { token: 'any' }, + transports: ['websocket'], + }); + await new Promise((resolve, reject) => { + client.on('connect', () => resolve()); + client.on('connect_error', (err) => reject(err)); + }); + + const received = new Promise>((resolve) => { + client.on(BOOKING_WINDOW_WS_EVENTS.PHASE, (payload) => resolve(payload)); + }); + + gateway.emitPhase({ + id: 'sched-1', + originStationId: 'yard-a', + destinationStationId: 'yard-b', + direction: 'IMPORT', + windowPhase: 'OPEN', + bookingWindowStatus: 'OPEN', + bookingCycleNo: 2, + windowOpensAt: new Date('2026-07-06T16:15:00Z'), + windowClosesAt: new Date('2026-07-06T16:18:00Z'), + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + scheduledDepartureDate: new Date('2026-07-09T05:53:00Z'), + } as unknown as TrainSchedule); + + const payload = await received; + expect(payload).toMatchObject({ + scheduleId: 'sched-1', + phase: 'OPEN', + bookingWindowStatus: 'OPEN', + bookingCycleNo: 2, + windowOpensAt: '2026-07-06T16:15:00.000Z', + }); + }); + + it('rejects a client whose token does not resolve to a user', async () => { + const moduleRef = await Test.createTestingModule({ + providers: [ + BookingWindowGateway, + { provide: WsAuthService, useValue: { resolveUserId: async () => null } }, + ], + }).compile(); + const rejectingApp = moduleRef.createNestApplication(); + await rejectingApp.listen(0); + const addr = rejectingApp.getHttpServer().address() as { port: number }; + + const rejected = io(`http://127.0.0.1:${addr.port}/${BOOKING_WINDOW_WS_NAMESPACE}`, { + auth: { token: 'bad' }, + transports: ['websocket'], + reconnection: false, + }); + const outcome = await new Promise((resolve) => { + rejected.on('disconnect', () => resolve('disconnected')); + rejected.on('connect_error', () => resolve('rejected')); + // The server accepts the transport then drops it in handleConnection. + setTimeout(() => resolve(rejected.connected ? 'still-connected' : 'disconnected'), 500); + }); + rejected.disconnect(); + await rejectingApp.close(); + expect(outcome).not.toBe('still-connected'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts new file mode 100644 index 000000000..699471d90 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts @@ -0,0 +1,80 @@ +import { + BOOKING_WINDOW_WS_EVENTS, + BOOKING_WINDOW_WS_NAMESPACE, + type BookingWindowPhaseEvent, +} from '@edr/types'; +import { Logger } from '@nestjs/common'; +import { + OnGatewayConnection, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; + +import { WsAuthService } from '../notification-inbox/ws-auth.service'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; + +/** + * Server → client push for booking-window state changes. Same handshake model + * as the notifications gateway: clients only listen, the token is verified on + * connect. Events are broadcast namespace-wide — window state is route-scoped + * public information for signed-in users, and clients filter/invalidate their + * own queries. + */ +@WebSocketGateway({ + namespace: BOOKING_WINDOW_WS_NAMESPACE, + cors: { origin: true, credentials: true }, +}) +export class BookingWindowGateway implements OnGatewayConnection { + private readonly logger = new Logger(BookingWindowGateway.name); + + @WebSocketServer() + private readonly server!: Server; + + constructor(private readonly wsAuth: WsAuthService) {} + + async handleConnection(socket: Socket): Promise { + const userId = await this.wsAuth.resolveUserId(this.extractToken(socket)); + if (!userId) { + this.logger.debug(`Rejected booking-window handshake ${socket.id}`); + socket.disconnect(true); + return; + } + socket.data.userId = userId; + // Log at info so "is anyone actually connected?" is answerable from the + // API log when diagnosing missing live updates. + this.logger.log(`Booking-window client connected (user ${userId})`); + } + + /** Push a schedule's current window state to every connected client. */ + emitPhase(schedule: TrainSchedule): void { + const payload: BookingWindowPhaseEvent = { + scheduleId: schedule.id, + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + direction: schedule.direction ?? null, + phase: (schedule.windowPhase ?? 'PRE_WINDOW') as BookingWindowPhaseEvent['phase'], + bookingWindowStatus: schedule.bookingWindowStatus ?? null, + bookingCycleNo: schedule.bookingCycleNo, + windowOpensAt: schedule.windowOpensAt?.toISOString() ?? null, + windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null, + docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null, + paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null, + scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null, + }; + this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload); + } + + private extractToken(socket: Socket): string | undefined { + const authToken = socket.handshake.auth?.token as string | undefined; + if (authToken) return authToken; + + const queryToken = socket.handshake.query?.token; + if (typeof queryToken === 'string') return queryToken; + + const header = socket.handshake.headers?.authorization; + if (header?.startsWith('Bearer ')) return header.slice(7); + + return undefined; + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts new file mode 100644 index 000000000..f96c388f8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -0,0 +1,201 @@ +import { BookingWindowService } from './booking-window.service'; +import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; + +/** + * Window state-machine tests: exercise the real advanceImport transitions and the + * concludeCycle reopen/done decision with mocked collaborators. Drives the exact + * production phase logic (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → conclude) and + * asserts the side effects the batch/settle/reopen flow depends on. + */ +describe('BookingWindowService — window state machine', () => { + const scheduleId = 'sched-1'; + + let service: BookingWindowService; + let batch: { + setWindow: jest.Mock; + processRouteDay: jest.Mock; + expireUnacceptedForRouteDay: jest.Mock; + settleDueReservations: jest.Mock; + isScheduleFull: jest.Mock; + }; + let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock }; + let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock }; + let updateMock: jest.Mock; + + const cfg = { + importWindowLeadDays: 3, + exportBookingLeadHours: 24, + windowOpenHour: 0, // 24h desk → reopen opens immediately + windowCloseHour: 0, + windowDurationHours: 1, + docReviewMinutes: 30, + paymentWindowMinutes: 60, + reopenDelayMinutes: 0, + }; + + const baseSchedule = (over: Partial): TrainSchedule => + ({ + id: scheduleId, + direction: 'IMPORT', + originStationId: 'yard-o', + destinationStationId: 'yard-d', + scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'), + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + bookingCycleNo: 0, + windowOpensAt: null, + windowClosesAt: null, + docReviewEndsAt: null, + docReviewCompletedAt: null, + paymentPhaseEndsAt: null, + ...over, + }) as unknown as TrainSchedule; + + const advanceImport = (s: TrainSchedule, now: Date): Promise => + (service as unknown as { + advanceImport: (s: TrainSchedule, c: unknown, n: Date) => Promise; + }).advanceImport(s, cfg, now); + const concludeCycle = (s: TrainSchedule, now: Date): Promise => + (service as unknown as { + concludeCycle: (s: TrainSchedule, c: unknown, n: Date) => Promise; + }).concludeCycle(s, cfg, now); + + beforeEach(() => { + updateMock = jest.fn().mockResolvedValue(undefined); + batch = { + setWindow: jest.fn().mockResolvedValue(undefined), + processRouteDay: jest.fn().mockResolvedValue(undefined), + expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined), + settleDueReservations: jest.fn().mockResolvedValue(undefined), + isScheduleFull: jest.fn().mockResolvedValue(false), + }; + trainSchedulesRepository = { + findById: jest.fn().mockResolvedValue(null), + findAll: jest.fn().mockResolvedValue([]), + }; + trainSchedulingService = { + finalizeSchedule: jest.fn().mockResolvedValue(undefined), + getWindowConfig: jest.fn().mockResolvedValue(cfg), + }; + + service = new BookingWindowService( + { getRepository: () => ({ update: updateMock }) } as never, + trainSchedulesRepository as never, + batch as never, + trainSchedulingService as never, + { directSend: jest.fn() } as never, + { notify: jest.fn() } as never, + { emitPhase: jest.fn() } as never, + ); + }); + + it('PRE_WINDOW → OPEN at windowOpensAt (opens the customer window)', async () => { + const s = baseSchedule({ + windowPhase: 'PRE_WINDOW', + windowOpensAt: new Date('2026-07-01T00:00:00.000Z'), + }); + const advanced = await advanceImport(s, new Date('2026-07-01T00:00:01.000Z')); + expect(advanced).toBe(true); + expect(s.windowPhase).toBe('OPEN'); + expect(s.bookingCycleNo).toBe(1); + expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'OPEN'); + }); + + it('OPEN → DOC_REVIEW at windowClosesAt (closes booking, sets doc-review deadline)', async () => { + const closesAt = new Date('2026-07-01T01:00:00.000Z'); + const s = baseSchedule({ + windowPhase: 'OPEN', + bookingWindowStatus: 'OPEN', + windowClosesAt: closesAt, + }); + const advanced = await advanceImport(s, new Date('2026-07-01T01:00:01.000Z')); + expect(advanced).toBe(true); + expect(s.windowPhase).toBe('DOC_REVIEW'); + expect(s.docReviewEndsAt).toEqual(new Date(closesAt.getTime() + 30 * 60_000)); + expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'CLOSED'); + }); + + it('DOC_REVIEW → PAYMENT expires un-accepted, then runs the batch', async () => { + const s = baseSchedule({ + windowPhase: 'DOC_REVIEW', + docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'), + }); + const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z')); + expect(advanced).toBe(true); + expect(s.windowPhase).toBe('PAYMENT'); + expect(s.paymentPhaseEndsAt).not.toBeNull(); + // Expiry sweep runs BEFORE the batch (unaccepted must not compete for capacity). + expect(batch.expireUnacceptedForRouteDay).toHaveBeenCalledTimes(1); + expect(batch.processRouteDay).toHaveBeenCalledTimes(1); + const expireOrder = batch.expireUnacceptedForRouteDay.mock.invocationCallOrder[0]; + const batchOrder = batch.processRouteDay.mock.invocationCallOrder[0]; + expect(expireOrder).toBeLessThan(batchOrder); + }); + + it('DOC_REVIEW → PAYMENT also fires when staff finished review early (docReviewCompletedAt)', async () => { + const s = baseSchedule({ + windowPhase: 'DOC_REVIEW', + docReviewEndsAt: new Date('2026-07-01T05:00:00.000Z'), // far future + docReviewCompletedAt: new Date('2026-07-01T01:31:00.000Z'), // staff clicked done + }); + const advanced = await advanceImport(s, new Date('2026-07-01T01:31:01.000Z')); + expect(advanced).toBe(true); + expect(s.windowPhase).toBe('PAYMENT'); + }); + + it('PAYMENT → conclude at paymentPhaseEndsAt settles due reservations', async () => { + const s = baseSchedule({ + windowPhase: 'PAYMENT', + paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'), + }); + const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z')); + expect(advanced).toBe(true); + // settleDueReservations runs (allocate paid / expire unpaid, then top-up). + expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId); + }); + + it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => { + batch.isScheduleFull.mockResolvedValue(true); + const s = baseSchedule({ windowPhase: 'PAYMENT' }); + await concludeCycle(s, new Date('2026-07-01T02:30:02.000Z')); + expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'FULL'); + expect(s.windowPhase).toBe('DONE'); + expect(trainSchedulingService.finalizeSchedule).toHaveBeenCalledWith(scheduleId); + }); + + it('conclude: NOT full + a cycle fits before departure → REOPEN (back to PRE_WINDOW)', async () => { + batch.isScheduleFull.mockResolvedValue(false); + const s = baseSchedule({ + windowPhase: 'PAYMENT', + // departure well in the future so nextCycleOpensAt returns a real time. + scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'), + }); + await concludeCycle(s, new Date('2026-07-01T02:30:03.000Z')); + expect(s.windowPhase).toBe('PRE_WINDOW'); + expect(s.windowOpensAt).not.toBeNull(); + expect(trainSchedulingService.finalizeSchedule).not.toHaveBeenCalled(); + }); + + it('conclude: NOT full but NO cycle fits before departure → DONE', async () => { + batch.isScheduleFull.mockResolvedValue(false); + const s = baseSchedule({ + windowPhase: 'PAYMENT', + // departure already passed → nextCycleOpensAt returns null → finish. + scheduledDepartureDate: new Date('2026-07-01T00:00:00.000Z'), + }); + await concludeCycle(s, new Date('2026-07-01T02:30:04.000Z')); + expect(s.windowPhase).toBe('DONE'); + }); + + it('no transition fires before its deadline (idempotent tick)', async () => { + const s = baseSchedule({ + windowPhase: 'OPEN', + bookingWindowStatus: 'OPEN', + windowClosesAt: new Date('2026-07-01T10:00:00.000Z'), // future + }); + const advanced = await advanceImport(s, new Date('2026-07-01T01:00:00.000Z')); + expect(advanced).toBe(false); + expect(s.windowPhase).toBe('OPEN'); + expect(batch.setWindow).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts new file mode 100644 index 000000000..0d9087419 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -0,0 +1,506 @@ +import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { + NotificationAudience, + NotificationType, + TrainScheduleStatus as TrainScheduleStatusEnum, +} from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { BookingBatchService } from './booking-batch.service'; +import { BookingWindowGateway } from './booking-window.gateway'; +import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; +import { BATCH_TIMEZONE } from './booking-batch.constants'; +import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util'; +import { type BookingWindowConfig } from './booking-window.config'; + +/** + * Drives the one-booking-day window cycle for IMPORT schedules and the FCFS + * booking window for EXPORT schedules. All state lives in DB timestamps on the + * schedule row, so every transition is derived purely from the clock — a restart + * resumes mid-phase with no loss (onModuleInit runs one tick immediately). + * + * Import & domestic phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW + * (staff accept documents) → PAYMENT (batch reserves in priority order, customers + * pay) → reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized). + * Export phases: PRE_WINDOW → OPEN → DONE (no batch, no priority). + * Only PRE-MIGRATION rows have windowPhase NULL; those are served by the legacy + * fill (runBatchFill), which this tick invokes every 5th minute. New schedules of + * every direction get a window phase. + */ +@Injectable() +export class BookingWindowService implements OnModuleInit { + private readonly logger = new Logger(BookingWindowService.name); + private ticking = false; + private tickCount = 0; + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly trainSchedulesRepository: TrainSchedulesRepository, + private readonly bookingBatchService: BookingBatchService, + private readonly trainSchedulingService: TrainSchedulingService, + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + private readonly gateway: BookingWindowGateway, + ) {} + + async onModuleInit(): Promise { + await this.tick().catch((err) => + this.logger.warn(`Boot window tick failed: ${(err as Error).message}`), + ); + } + + // 10-second cadence: every transition is derived from persisted timestamps + // and applied idempotently, so a finer tick only shrinks the lag between a + // deadline passing and the phase actually moving (was a full minute). + @Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE }) + async tick(): Promise { + if (this.ticking) return; + this.ticking = true; + try { + const now = new Date(); + const liveCfg = await this.trainSchedulingService.getWindowConfig(); + + const active = ( + await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }) + ).filter( + // CLOSED_FOR_DAY is legacy (the daily desk now reopens via PRE_WINDOW): + // still pick those rows up so advanceImport can revive them next morning. + (s) => s.windowPhase != null && s.windowPhase !== 'DONE', + ); + + for (const schedule of active) { + try { + // Each train runs under its OWN frozen rule snapshot, not the live global + // config — a later global-rules edit must not retro-change the window an + // existing train already advertised, and the reopen cycles must match the + // board (which is drawn from the same snapshot). + await this.advanceSchedule( + schedule, + effectiveWindowConfig(schedule, liveCfg), + now, + ); + } catch (err) { + // This is THE line to watch when a window freezes mid-phase: the tick + // catches a throw here per-schedule and moves on, so a schedule whose + // transition keeps throwing stays stuck in its phase forever. Log the + // phase + stack so the failing step is obvious. + this.logger.error( + `[WINDOW] transition FAILED for schedule ${schedule.id} ` + + `(phase=${schedule.windowPhase}, cycle=${schedule.bookingCycleNo}): ` + + `${(err as Error).message}`, + ); + this.logger.error( + `[WINDOW] stack: ${((err as Error).stack ?? "").split("\n").slice(0, 5).join(" | ")}`, + ); + } + } + + await this.settleOverdueReservations(); + + // Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes + // (30 ticks at the 10-second cadence). + this.tickCount += 1; + if (this.tickCount % 30 === 0) { + await this.bookingBatchService.runBatchFill(); + } + } finally { + this.ticking = false; + } + } + + /** Staff finished document review early — start the batch/payment phase now. */ + async completeDocReview(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.windowPhase !== 'DOC_REVIEW') { + // Idempotent for the whole route-day group: only DOC_REVIEW schedules move. + return schedule; + } + const now = new Date(); + const liveCfg = await this.trainSchedulingService.getWindowConfig(); + // Stamp the whole route-day group so one staff action releases every train + // sharing this booking day's pool. + const group = ( + await this.trainSchedulesRepository.findAll({ + where: { + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + }, + }) + ).filter( + (s) => + s.windowPhase === 'DOC_REVIEW' && + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === eatDay(schedule.scheduledDepartureDate), + ); + for (const s of group) { + await this.dataSource + .getRepository(TrainSchedule) + .update(s.id, { docReviewCompletedAt: now }); + s.docReviewCompletedAt = now; + await this.advanceSchedule(s, effectiveWindowConfig(s, liveCfg), now); + } + const fresh = await this.trainSchedulesRepository.findById(scheduleId); + return fresh ?? schedule; + } + + // ---- transitions ------------------------------------------------------------ + + private async advanceSchedule( + schedule: TrainSchedule, + cfg: BookingWindowConfig, + now: Date, + ): Promise { + // Apply every transition that is due, in order (fast-forwards after downtime). + for (let guard = 0; guard < 6; guard += 1) { + const advanced = + schedule.direction === 'EXPORT' + ? await this.advanceExport(schedule, now) + : await this.advanceImport(schedule, cfg, now); + if (!advanced) return; + // Push the new window state to portal home / backoffice GL sections so + // they refresh instantly instead of waiting out their poll interval. + this.gateway.emitPhase(schedule); + } + } + + /** Export: PRE_WINDOW → OPEN at opensAt, OPEN → DONE at closesAt (= departure). */ + private async advanceExport(schedule: TrainSchedule, now: Date): Promise { + if ( + schedule.windowPhase === 'PRE_WINDOW' && + schedule.windowOpensAt && + now >= schedule.windowOpensAt + ) { + await this.setPhase(schedule, { + windowPhase: 'OPEN', + bookingCycleNo: schedule.bookingCycleNo + 1, + }); + if (schedule.bookingWindowStatus !== 'FULL') { + await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); + schedule.bookingWindowStatus = 'OPEN'; + } + // Fire-and-forget: a slow SMS/email gateway must not stall the tick loop + // (the `ticking` guard would otherwise delay every schedule's transition). + void this.notifyWindowOpened(schedule); + this.logger.log(`Export booking window opened for schedule ${schedule.id}`); + return true; + } + if ( + schedule.windowPhase === 'OPEN' && + schedule.windowClosesAt && + now >= schedule.windowClosesAt + ) { + await this.setPhase(schedule, { windowPhase: 'DONE' }); + if (schedule.bookingWindowStatus === 'OPEN') { + await this.bookingBatchService.setWindow(schedule.id, 'CLOSED'); + schedule.bookingWindowStatus = 'CLOSED'; + } + return true; + } + return false; + } + + private async advanceImport( + schedule: TrainSchedule, + cfg: BookingWindowConfig, + now: Date, + ): Promise { + const { windowPhase, windowOpensAt, windowClosesAt } = schedule; + + // Legacy rows parked at CLOSED_FOR_DAY predate the daily-desk reopen: revive + // them through the same not-full conclude path so they resume next morning + // (or finalize as DONE if no cycle fits before departure). + if (windowPhase === 'CLOSED_FOR_DAY') { + await this.concludeCycle(schedule, cfg, now); + return true; + } + + if (windowPhase === 'PRE_WINDOW' && windowOpensAt && now >= windowOpensAt) { + await this.setPhase(schedule, { + windowPhase: 'OPEN', + bookingCycleNo: schedule.bookingCycleNo + 1, + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }); + if (schedule.bookingWindowStatus !== 'FULL') { + await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); + schedule.bookingWindowStatus = 'OPEN'; + } + // Only announce the first opening of the day; reopen cycles don't re-notify. + // 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( + `[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` + + `(cycle ${schedule.bookingCycleNo})`, + ); + return true; + } + + if (windowPhase === 'OPEN' && windowClosesAt && now >= windowClosesAt) { + const docReviewEndsAt = new Date( + windowClosesAt.getTime() + cfg.docReviewMinutes * 60_000, + ); + await this.setPhase(schedule, { windowPhase: 'DOC_REVIEW', docReviewEndsAt }); + if (schedule.bookingWindowStatus === 'OPEN') { + await this.bookingBatchService.setWindow(schedule.id, 'CLOSED'); + schedule.bookingWindowStatus = 'CLOSED'; + } + this.logger.log( + `[WINDOW] ${schedule.id} OPEN→DOC_REVIEW — booking closed; staff document ` + + `review until ${docReviewEndsAt.toISOString()}`, + ); + return true; + } + + if ( + windowPhase === 'DOC_REVIEW' && + (schedule.docReviewCompletedAt != null || + (schedule.docReviewEndsAt != null && now >= schedule.docReviewEndsAt)) + ) { + const paymentPhaseEndsAt = new Date(now.getTime() + cfg.paymentWindowMinutes * 60_000); + await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt }); + const routeDay = { + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + day: eatDay(schedule.scheduledDepartureDate), + }; + // Doc review is over: bookings staff never accepted (still pending) can no + // longer make this train — expire them BEFORE the batch so they never + // compete for capacity and never reach the pool. + await this.bookingBatchService.expireUnacceptedForRouteDay(routeDay); + // Run the batch: priority fill over the route-day pool, reserving pay windows + // (or allocating government) — skipped automatically for everyone who fits + // is handled inside the fill (all fit → all reserved → all notified). + await this.bookingBatchService.processRouteDay(routeDay); + this.logger.log( + `[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` + + `until ${paymentPhaseEndsAt.toISOString()}`, + ); + return true; + } + + if ( + windowPhase === 'PAYMENT' && + schedule.paymentPhaseEndsAt != null && + now >= schedule.paymentPhaseEndsAt + ) { + this.logger.log( + `[WINDOW] ${schedule.id} PAYMENT window ended — settling reservations ` + + `(allocate paid / expire unpaid) then concluding the cycle`, + ); + await this.bookingBatchService.settleDueReservations(schedule.id); + await this.concludeCycle(schedule, cfg, now); + return true; + } + + return false; + } + + /** After settle: full → finalize + DONE; space left → reopen same day or close for the day. */ + private async concludeCycle( + schedule: TrainSchedule, + cfg: BookingWindowConfig, + now: Date, + ): Promise { + const full = await this.bookingBatchService.isScheduleFull(schedule.id); + if (full) { + await this.bookingBatchService.setWindow(schedule.id, 'FULL'); + await this.setPhase(schedule, { windowPhase: 'DONE' }); + await this.tryAutoFinalize(schedule.id); + this.logger.log( + `[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`, + ); + return; + } + + // Doc review + payment have already run, so the desk is ready to reopen NOW — + // office hours decide whether that is this afternoon or tomorrow morning. Past + // the last cycle before departure, nextCycleOpensAt returns null and we finish. + const officeHours: OfficeHours = { + windowOpenHour: cfg.windowOpenHour, + windowCloseHour: cfg.windowCloseHour, + }; + const nextOpensAt = nextCycleOpensAt( + now, + officeHours, + schedule.scheduledDepartureDate, + ); + if (nextOpensAt == null) { + await this.setPhase(schedule, { windowPhase: 'DONE' }); + this.logger.log( + `[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` + + `departure — window DONE`, + ); + return; + } + + let nextClosesAt = new Date( + nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000, + ); + if (nextClosesAt > schedule.scheduledDepartureDate) { + nextClosesAt = schedule.scheduledDepartureDate; + } + // Stays PRE_WINDOW (not CLOSED_FOR_DAY): the tick reopens it at nextOpensAt, + // whether that is later today or next morning after the office-hours break. + await this.setPhase(schedule, { + windowPhase: 'PRE_WINDOW', + windowOpensAt: nextOpensAt, + windowClosesAt: nextClosesAt, + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }); + const sameDay = eatDay(nextOpensAt) === eatDay(now); + this.logger.log( + `[WINDOW] ${schedule.id} conclude → NOT full, waiting list may remain — ` + + `REOPENS ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`, + ); + } + + private async tryAutoFinalize(scheduleId: string): Promise { + try { + await this.trainSchedulingService.finalizeSchedule(scheduleId); + this.logger.log(`Schedule ${scheduleId} is full — auto-finalized`); + } catch (err) { + // Not DRAFT / no linked bookings yet — staff finalize manually. + this.logger.warn( + `Auto-finalize skipped for ${scheduleId}: ${(err as Error).message}`, + ); + } + } + + /** Durable settle backstop: expire/allocate reservations whose deadline passed. */ + private async settleOverdueReservations(): Promise { + const overdue = await this.dataSource + .getRepository(Booking) + .createQueryBuilder('b') + .select('DISTINCT b.train_schedule_id', 'scheduleId') + .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .andWhere('b.payment_deadline <= now()') + .andWhere('b.train_schedule_id IS NOT NULL') + .getRawMany<{ scheduleId: string }>(); + for (const { scheduleId } of overdue) { + try { + await this.bookingBatchService.settleDueReservations(scheduleId); + } catch (err) { + this.logger.warn( + `Overdue settle failed for ${scheduleId}: ${(err as Error).message}`, + ); + } + } + } + + /** + * SMS + email every active-contract customer on this schedule's route when its + * booking window opens, so they can book from the portal home before it closes. + * Fire-and-forget; a failed notification never blocks the window transition. + */ + private async notifyWindowOpened(schedule: TrainSchedule): Promise { + try { + const rows: Array<{ + company_id: string; + phone: string | null; + email: string | null; + }> = await this.dataSource.query( + `SELECT DISTINCT + c.company_id, + COALESCE(co.contact_person_phone, co.phone) AS phone, + COALESCE(co.email, co.general_manager_email) AS email + FROM freight.contract_routes cr + JOIN freight.contracts c + ON c.id = cr.contract_id + AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') + AND c.deleted_at IS NULL + JOIN freight.companies co ON co.id = c.company_id + WHERE cr.origin_yard_id = $1 + AND cr.destination_yard_id = $2 + AND cr.deleted_at IS NULL`, + [schedule.originStationId, schedule.destinationStationId], + ); + if (!rows.length) return; + + const closes = schedule.windowClosesAt + ? schedule.windowClosesAt.toLocaleString('en-GB', { timeZone: BATCH_TIMEZONE }) + : 'later today'; + const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { + timeZone: BATCH_TIMEZONE, + }); + const msg = + `Booking is now open for the train departing ${depart}. ` + + `Book your shipment from the portal home page before ${closes} EAT.`; + + const seenPhone = new Set(); + const seenEmail = new Set(); + const seenCompany = new Set(); + for (const r of rows) { + if (r.phone && !seenPhone.has(r.phone)) { + seenPhone.add(r.phone); + await this.notifications + .directSend('sms', r.phone, msg) + .catch((e) => this.logger.warn(`Window-open SMS failed: ${(e as Error).message}`)); + } + if (r.email && !seenEmail.has(r.email)) { + seenEmail.add(r.email); + await this.notifications + .directSend('email', r.email, msg) + .catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`)); + } + // In-app inbox item for every portal user of each eligible company, + // deep-linking to the new-booking page. + if (r.company_id && !seenCompany.has(r.company_id)) { + seenCompany.add(r.company_id); + void this.inbox.notify({ + recipients: { companyId: r.company_id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title: 'Booking window open', + body: msg, + link: '/bookings/new', + data: { trainScheduleId: schedule.id }, + }); + } + } + this.logger.log( + `Notified ${seenPhone.size} phone / ${seenEmail.size} email / ${seenCompany.size} companies (in-app) of open window for schedule ${schedule.id}`, + ); + } catch (err) { + this.logger.warn( + `notifyWindowOpened failed for ${schedule.id}: ${(err as Error).message}`, + ); + } + } + + private async setPhase( + schedule: TrainSchedule, + patch: Partial< + Pick< + TrainSchedule, + | 'windowPhase' + | 'windowOpensAt' + | 'windowClosesAt' + | 'docReviewEndsAt' + | 'docReviewCompletedAt' + | 'paymentPhaseEndsAt' + | 'bookingCycleNo' + > + >, + ): Promise { + await this.dataSource.getRepository(TrainSchedule).update(schedule.id, patch); + Object.assign(schedule, patch); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts new file mode 100644 index 000000000..b16b25179 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts @@ -0,0 +1,148 @@ +/** + * Segment (leg) aware capacity accounting for corridor bookings. + * + * A train's route is an ordered list of stops; a booking occupies only the + * edges between its own origin and destination. Capacity (wagons / weight / + * length) is therefore tracked PER EDGE, not per train: two bookings whose + * legs don't overlap (Addis→Dire and Dire→Djibouti) consume the same wagon + * budget on disjoint edges and can share physical wagons. + * + * Legacy schedules without route milestones degrade to a single-edge corridor + * ([origin, destination]) where this is exactly the old train-wide math. + */ + +export interface Capacity { + wagons: number; + weightTons: number; + lengthMeters: number; +} + +/** Half-open edge span along the stop list: occupies edges [fromEdge, toEdge). */ +export interface CorridorLeg { + fromEdge: number; + toEdge: number; +} + +export function addCapacity(a: Capacity, b: Capacity): Capacity { + return { + wagons: a.wagons + b.wagons, + weightTons: a.weightTons + b.weightTons, + lengthMeters: a.lengthMeters + b.lengthMeters, + }; +} + +export function subtractCapacity(a: Capacity, b: Capacity): Capacity { + return { + wagons: a.wagons - b.wagons, + weightTons: a.weightTons - b.weightTons, + lengthMeters: a.lengthMeters - b.lengthMeters, + }; +} + +export function capacityFits(need: Capacity, budget: Capacity): boolean { + return ( + need.wagons <= budget.wagons && + need.weightTons <= budget.weightTons && + need.lengthMeters <= budget.lengthMeters + ); +} + +/** + * Ordered stop yard ids for a schedule. Route milestones (already ordered by + * sequence) when there are at least two; otherwise the schedule's own + * origin/destination pair — the legacy two-stop pseudo-route. + */ +export function stopYardsFor( + milestoneYardIdsInOrder: string[] | null | undefined, + originStationId: string, + destinationStationId: string, +): string[] { + if (milestoneYardIdsInOrder && milestoneYardIdsInOrder.length >= 2) { + return milestoneYardIdsInOrder; + } + return [originStationId, destinationStationId]; +} + +/** Per-edge capacity budget along a schedule's stop list. */ +export class CorridorBudget { + private readonly edges: Capacity[]; + private readonly stopIndex: Map; + + constructor( + readonly stops: string[], + initial: Capacity, + ) { + const edgeCount = Math.max(1, stops.length - 1); + this.edges = Array.from({ length: edgeCount }, () => ({ ...initial })); + this.stopIndex = new Map(stops.map((yardId, i) => [yardId, i])); + } + + /** The leg between two stops, or null when they aren't on this corridor in order. */ + legOf(originYardId: string, destinationYardId: string): CorridorLeg | null { + const from = this.stopIndex.get(originYardId); + const to = this.stopIndex.get(destinationYardId); + if (from == null || to == null || from >= to) return null; + return { fromEdge: from, toEdge: to }; + } + + /** Every edge — for whole-route consumers and unknown-leg fallbacks. */ + fullLeg(): CorridorLeg { + return { fromEdge: 0, toEdge: this.edges.length }; + } + + /** + * The leg a booking occupies; bookings whose yards aren't on the corridor + * (legacy data drift) conservatively occupy the whole route so capacity is + * never double-booked against them. + */ + legForYards(originYardId: string, destinationYardId: string): CorridorLeg { + return this.legOf(originYardId, destinationYardId) ?? this.fullLeg(); + } + + /** Remaining capacity usable by this leg = min across its edges. */ + remainingFor(leg: CorridorLeg): Capacity { + let min = { ...this.edges[leg.fromEdge] }; + for (let i = leg.fromEdge + 1; i < leg.toEdge; i++) { + const e = this.edges[i]; + min = { + wagons: Math.min(min.wagons, e.wagons), + weightTons: Math.min(min.weightTons, e.weightTons), + lengthMeters: Math.min(min.lengthMeters, e.lengthMeters), + }; + } + return min; + } + + fits(need: Capacity, leg: CorridorLeg): boolean { + return capacityFits(need, this.remainingFor(leg)); + } + + subtract(need: Capacity, leg: CorridorLeg): void { + for (let i = leg.fromEdge; i < leg.toEdge; i++) { + this.edges[i] = subtractCapacity(this.edges[i], need); + } + } + + add(freed: Capacity, leg: CorridorLeg): void { + for (let i = leg.fromEdge; i < leg.toEdge; i++) { + this.edges[i] = addCapacity(this.edges[i], freed); + } + } + + /** + * The most open edge — when even this has no wagon slots left, nothing can + * board anywhere and the schedule's window is genuinely FULL. (A train can be + * full on one leg while another still has room, so train-wide FULL keys on + * the max, not the min.) + */ + maxRemaining(): Capacity { + return this.edges.reduce( + (max, e) => ({ + wagons: Math.max(max.wagons, e.wagons), + weightTons: Math.max(max.weightTons, e.weightTons), + lengthMeters: Math.max(max.lengthMeters, e.lengthMeters), + }), + { ...this.edges[0] }, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/accept-intercity-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/accept-intercity-bookings.dto.ts new file mode 100644 index 000000000..bf1cec26d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/accept-intercity-bookings.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator'; + +export class AcceptIntercityBookingsDto { + @ApiProperty({ + type: [String], + description: + 'Waiting intercity booking ids to accept onto this train, in priority order', + }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + bookingIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts index 9bd9f3957..d5adce129 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/import-djibouti-operation.dto.ts @@ -1,7 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsString } from 'class-validator'; +import { IsDateString, IsIn, IsOptional, IsString } from 'class-validator'; export const IMPORT_DJIBOUTI_DOCUMENT_TYPES = [ + 'GATE_PASS', 'DELIVERY_ORDER', 'PORT_INVOICE', 'DJIBOUTI_T1', @@ -43,6 +44,26 @@ export class UploadImportDjiboutiDocumentDto { } export class ImportDjiboutiActionDto { + @ApiPropertyOptional({ description: 'Gate pass secured date/time. Defaults to now.' }) + @IsOptional() + @IsDateString() + securedAt?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + fileId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + fileUrl?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + reference?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts new file mode 100644 index 000000000..25943050c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts @@ -0,0 +1,15 @@ +import { LoadingStatus } from '@edr/types'; +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsEnum, IsUUID } from 'class-validator'; + +export class UpdateImportLoadingStatusDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds!: string[]; + + @ApiProperty({ enum: LoadingStatus }) + @IsEnum(LoadingStatus) + loadingStatus!: LoadingStatus; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-date.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-date.dto.ts new file mode 100644 index 000000000..4792f7d3b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-date.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsISO8601 } from 'class-validator'; + +/** + * Reschedule a train's departure date (staff action on the ops board). Only + * allowed before the booking window opens; the new date must still leave room + * for the booking lead window before departure. + */ +export class UpdateScheduleDateDto { + @ApiProperty({ + example: '2026-07-20T05:00:00.000Z', + description: 'New scheduled departure date/time (ISO 8601)', + }) + @IsISO8601() + scheduleDate!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts new file mode 100644 index 000000000..a0371178f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts @@ -0,0 +1,73 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator'; + +/** + * Per-schedule booking-window rule override (staff action on the ops board). + * Every field is optional — only the ones sent are changed; the rest keep the + * schedule's existing snapshot. Mirrors the window fields of the global rules DTO. + */ +export class UpdateScheduleWindowRuleDto { + @ApiPropertyOptional({ example: 8, description: 'Local EAT hour the booking desk opens each day' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + windowOpenHour?: number; + + @ApiPropertyOptional({ + example: 17, + description: + 'Local EAT hour the booking desk shuts each day; a not-yet-full window resumes next morning at windowOpenHour. Equal to windowOpenHour = 24-hour desk', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + windowCloseHour?: number; + + @ApiPropertyOptional({ example: 3, description: 'How long each booking cycle stays open, in hours' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.0166) + @Max(12) + windowDurationHours?: number; + + @ApiPropertyOptional({ example: 30, description: 'Max staff document-review minutes after the window closes' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + docReviewMinutes?: number; + + @ApiPropertyOptional({ example: 60, description: 'Customer payment window minutes' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + paymentWindowMinutes?: number; + + @ApiPropertyOptional({ + example: 3, + description: 'Days before departure the booking window starts (re-derives the window start)', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + importWindowLeadDays?: number; + + @ApiPropertyOptional({ + example: 24, + description: + 'Hours before departure the single FCFS export window opens (EXPORT schedules; re-derives the window start)', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + exportBookingLeadHours?: number; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index d47195976..0e252240b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsInt, IsNumber, IsOptional, Min } from 'class-validator'; +import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator'; export class UpdateTrainSchedulingGlobalRulesDto { @ApiPropertyOptional({ example: 760 }) @@ -37,4 +37,69 @@ export class UpdateTrainSchedulingGlobalRulesDto { @IsNumber() @Min(0) max20ftPairWeightDiffTons?: number; + + @ApiPropertyOptional({ example: 3, description: 'Days before departure the import booking-window day falls on' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + importWindowLeadDays?: number; + + @ApiPropertyOptional({ example: 24, description: 'Hours before departure an export booking becomes acceptable' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + exportBookingLeadHours?: number; + + @ApiPropertyOptional({ example: 8, description: 'Local EAT hour the import window opens each day' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + windowOpenHour?: number; + + @ApiPropertyOptional({ + example: 17, + description: + 'Local EAT hour the booking desk shuts each day; a not-yet-full window resumes next morning at windowOpenHour. Equal to windowOpenHour = 24-hour desk', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + windowCloseHour?: number; + + // Stored in hours. The UI enters this in minutes/hours/days and converts to + // hours before sending, so the floor is 1 minute (0.0166h) — not 15 min. + @ApiPropertyOptional({ example: 3 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.0166) + @Max(12) + windowDurationHours?: number; + + @ApiPropertyOptional({ example: 30 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + docReviewMinutes?: number; + + @ApiPropertyOptional({ example: 60 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + paymentWindowMinutes?: number; + + @ApiPropertyOptional({ example: 90 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + reopenDelayMinutes?: number; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/booking-batch-offer.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/booking-batch-offer.entity.ts new file mode 100644 index 000000000..4b6f7c685 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/booking-batch-offer.entity.ts @@ -0,0 +1,75 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; + +export const BOOKING_BATCH_OFFER_STATUSES = ['OFFERED', 'APPLIED', 'EXPIRED'] as const; +export type BookingBatchOfferStatus = (typeof BOOKING_BATCH_OFFER_STATUSES)[number]; + +/** One reduced container line of a partial offer (per original booking_container row). */ +export interface OfferedLine { + bookingContainerId: string; + /** Units of this line that ride the offered train (≤ original quantity). */ + quantity: number; + wagonsRequired: number; + totalVgmTons: number; +} + +/** + * A partial-capacity payment offer made by the batch when a booking needs more + * wagons than the train has left (e.g. needs 20, 3 free). The booking itself is + * NOT mutated at offer time — paying inside the window accepts the split + * (BookingSplitService.applySplit reduces the booking to the offered lines and + * the remainder returns to the contract's quantity cap); letting the deadline + * pass expires the offer and the booking stays whole. + */ +@Entity({ schema: 'freight', name: 'booking_batch_offers' }) +@Index(['bookingId']) +@Index(['trainScheduleId']) +@Index(['status']) +export class BookingBatchOffer extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @ManyToOne(() => TrainSchedule, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'offered_wagons', type: 'int' }) + offeredWagons!: number; + + /** Booking's full wagon need at offer time (for messaging / audit). */ + @Column({ name: 'total_wagons', type: 'int' }) + totalWagons!: number; + + /** Reduced container lines (null for bulk offers — bulk splits by weight). */ + @Column({ name: 'offered_lines', type: 'jsonb', nullable: true }) + offeredLines?: OfferedLine[] | null; + + @Column({ name: 'offered_weight_tons', type: 'numeric', precision: 12, scale: 3 }) + offeredWeightTons!: number; + + @Column({ name: 'offered_amount', type: 'numeric', precision: 14, scale: 2 }) + offeredAmount!: number; + + @Column({ name: 'offered_pricing_breakdown', type: 'jsonb', nullable: true }) + offeredPricingBreakdown?: Record | null; + + /** The partial PREPAID invoice generated for the offered part. */ + @Column({ name: 'invoice_id', type: 'uuid', nullable: true }) + invoiceId?: string | null; + + @Column({ name: 'payment_deadline', type: 'timestamptz' }) + paymentDeadline!: Date; + + @Column({ name: 'status', type: 'varchar', length: 10, default: 'OFFERED' }) + status!: BookingBatchOfferStatus; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts index 792792070..47ced8738 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/import-djibouti-operation.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm'; import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; export type ImportDjiboutiDocumentType = + | 'GATE_PASS' | 'DELIVERY_ORDER' | 'PORT_INVOICE' | 'DJIBOUTI_T1' diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 326915933..ffa42fd7e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -41,4 +41,46 @@ export class TrainSchedulingGlobalRules extends BaseEntity { default: 10, }) max20ftPairWeightDiffTons!: number; + + /** Days before departure the single import booking-window day falls on. */ + @Column({ name: 'import_window_lead_days', type: 'int', default: 3 }) + importWindowLeadDays!: number; + + /** Hours before departure an export booking becomes acceptable (FCFS, no window cycle). */ + @Column({ name: 'export_booking_lead_hours', type: 'int', default: 24 }) + exportBookingLeadHours!: number; + + /** Local (Africa/Addis_Ababa) hour at which the import window opens on its window day. */ + @Column({ name: 'window_open_hour', type: 'int', default: 8 }) + windowOpenHour!: number; + + /** + * Local (Africa/Addis_Ababa) hour the booking desk shuts each day. A not-yet-full + * train whose next cycle would reopen at/after this hour pauses until the next + * morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk. + */ + @Column({ name: 'window_close_hour', type: 'int', default: 17 }) + windowCloseHour!: number; + + // Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h) + // are exact. See WidenWindowDurationHoursPrecision migration. + @Column({ + name: 'window_duration_hours', + type: 'numeric', + precision: 6, + scale: 4, + default: 3, + }) + windowDurationHours!: number; + + /** Max time staff have to accept booking documents after the window closes. */ + @Column({ name: 'doc_review_minutes', type: 'int', default: 30 }) + docReviewMinutes!: number; + + @Column({ name: 'payment_window_minutes', type: 'int', default: 60 }) + paymentWindowMinutes!: number; + + /** Delay after window close before the window reopens when the train is not yet full. */ + @Column({ name: 'reopen_delay_minutes', type: 'int', default: 90 }) + reopenDelayMinutes!: number; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts new file mode 100644 index 000000000..bce4207d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -0,0 +1,308 @@ +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 } from './booking-batch.service'; +import { BookingJourneyService } from './booking-journey.service'; + +/** + * 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, + private readonly bookingJourneyService: BookingJourneyService, + ) {} + + /** + * 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, + // Segment-based: "remaining" is the most-open edge; each candidate's + // `fits` is judged against ITS OWN leg, so a booking on a free leg fits + // even when the train is full elsewhere. + remaining: capacity?.budget.maxRemaining() ?? null, + candidates: waiting.map((booking) => { + const need = capacity?.needFor(booking) ?? null; + const leg = capacity?.budget.legOf( + booking.originYardId, + booking.destinationYardId, + ); + return { + ...this.mapBooking(booking), + need, + fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)), + }; + }), + 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 }> = []; + const 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); + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + // Segment-based: only the booking's own leg must have room, so an + // intercity booking still boards a train that is full on other legs. + if (!leg || !budget.fits(need, leg)) { + rejected.push({ + bookingId, + reason: + 'Does not fit the remaining wagon/weight/length capacity on its leg', + }); + continue; + } + await this.bookingBatchService.acceptIntercity(booking, scheduleId); + budget.subtract(need, leg); + accepted.push(bookingId); + this.logger.log( + `Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`, + ); + } + + return { accepted, rejected, remaining: budget.maxRemaining() }; + } + + /** + * Mark an accepted intercity booking's cargo as loaded. Delegates to the + * shared per-booking journey flow (same checkpoint gating as import/export). + */ + async loadBooking(scheduleId: string, bookingId: string) { + await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard + return this.bookingJourneyService.loadBooking(scheduleId, bookingId); + } + + /** + * 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) { + await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard + return this.bookingJourneyService.unloadBooking(scheduleId, bookingId); + } + + // ---- helpers --------------------------------------------------------------- + + private async getSchedule(scheduleId: string): Promise { + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: scheduleId } }); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + return schedule; + } + + /** + * yardId → sequenceNo for the schedule's route. Falls back to a two-stop + * origin/destination pseudo-route for legacy schedules without a routeId, + * so an intercity booking exactly matching the train's own corridor still + * qualifies. + */ + private async routeMilestoneSequence( + schedule: TrainSchedule, + ): Promise | null> { + if (schedule.routeId) { + const milestones = await this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } }); + if (milestones.length >= 2) { + return new Map(milestones.map((m) => [m.yardId, m.sequenceNo])); + } + } + if (schedule.originStationId && schedule.destinationStationId) { + return new Map([ + [schedule.originStationId, 1], + [schedule.destinationStationId, 2], + ]); + } + return null; + } + + /** Waiting = ready intercity bookings not yet on any train, corridor on this route. */ + private async findWaitingIntercityBookings( + milestoneSeq: Map, + ): Promise { + const pool = await this.dataSource + .getRepository(Booking) + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .where(`booking.trade_direction = 'DOMESTIC'`) + .andWhere('booking.train_schedule_id IS NULL') + .andWhere( + `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') + OR (booking.is_government = true AND booking.status = 'APPROVED'))`, + ) + .orderBy('booking.is_government', 'DESC') + .addOrderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + + return pool.filter((b) => this.corridorOnRoute(b, milestoneSeq)); + } + + /** Intercity bookings already reserved/allocated on this schedule. */ + private async findAcceptedIntercityBookings( + scheduleId: string, + ): Promise { + return this.dataSource + .getRepository(Booking) + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .where(`booking.trade_direction = 'DOMESTIC'`) + .andWhere('booking.train_schedule_id = :scheduleId', { scheduleId }) + .orderBy('booking.created_at', 'ASC') + .getMany(); + } + + private corridorOnRoute( + booking: Booking, + milestoneSeq: Map, + ): boolean { + const originSeq = milestoneSeq.get(booking.originYardId); + const destinationSeq = milestoneSeq.get(booking.destinationYardId); + return ( + originSeq != null && destinationSeq != null && originSeq < destinationSeq + ); + } + + private whyNotWaiting( + booking: Booking, + milestoneSeq: Map, + ): string | null { + if (booking.tradeDirection !== 'DOMESTIC') { + return 'Not an intercity booking'; + } + if (booking.trainScheduleId) { + return 'Already assigned to a train'; + } + const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED'; + if (booking.status !== readyStatus) { + return `Not ready to board (status ${booking.status})`; + } + if (!this.corridorOnRoute(booking, milestoneSeq)) { + return "Corridor is not on this schedule's route"; + } + return null; + } + + private async getAcceptedBooking(scheduleId: string, bookingId: string) { + const schedule = await this.getSchedule(scheduleId); + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + if (booking.trainScheduleId !== scheduleId) { + throw new BadRequestException('Booking is not assigned to this schedule'); + } + if (booking.tradeDirection !== 'DOMESTIC') { + throw new BadRequestException('Not an intercity booking'); + } + return { schedule, booking }; + } + + 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, + }; + } +} + diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 1c15ab801..7dba44f83 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -20,6 +20,7 @@ import { TrainSchedulingManage, TrainSchedulingView, } from "../../common/booking-guards"; +import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto"; import { AssignBookingsDto } from "./dto/assign-bookings.dto"; import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto"; import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; @@ -28,6 +29,7 @@ import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; import { PinWagonsDto } from "./dto/pin-wagons.dto"; import { UpdateContainerItemDto } from "./dto/update-container-item.dto"; +import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto"; import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto"; import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto"; @@ -41,8 +43,14 @@ import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; import { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto"; import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; +import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto"; +import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; +import { BookingJourneyService } from "./booking-journey.service"; +import { BookingWindowService } from "./booking-window.service"; +import { IntercityService } from "./intercity.service"; +import { BillingService } from "../billing/billing.service"; @ApiTags("train-scheduling") @ApiBearerAuth() @@ -51,8 +59,47 @@ export class TrainSchedulingController { constructor( private readonly trainSchedulingService: TrainSchedulingService, private readonly bookingBatchService: BookingBatchService, + private readonly bookingWindowService: BookingWindowService, + private readonly intercityService: IntercityService, + private readonly bookingJourneyService: BookingJourneyService, + private readonly billingService: BillingService, ) { } + @Get("my-booking-windows") + @ApiOperation({ + summary: + "Upcoming/open booking windows announced to the signed-in customer (all window-engine schedules; their own contract lanes carry a Book-now target)", + }) + async getMyBookingWindows(@CurrentUser() user: AuthUserPayload) { + // Every customer sees announced windows; companyId (when resolvable) just + // enriches lanes they hold a contract on so "Book now" can target it. + const companyId = await this.billingService.resolveCompanyId( + resolveAuthUserId(user), + ); + return this.trainSchedulingService.getBookingWindowsForCompany(companyId); + } + + @Get("contracts/:contractId/booking-windows") + @ApiOperation({ + summary: + "Upcoming/open booking windows on a contract's routes — gates the booking form for customer + Ethiopian GL", + }) + getContractBookingWindows( + @Param("contractId", ParseUUIDPipe) contractId: string, + ) { + return this.trainSchedulingService.getBookingWindowsForContract(contractId); + } + + @Get("booking-windows") + @TrainSchedulingView() + @ApiOperation({ + summary: + "All announced booking windows across lanes (import cycle + export FCFS), for staff dashboards", + }) + listBookingWindows() { + return this.trainSchedulingService.listAllBookingWindows(); + } + @Get("global-rules") @TrainSchedulingView() @ApiOperation({ summary: "Get global train scheduling rules (singleton)" }) @@ -308,6 +355,41 @@ export class TrainSchedulingController { return this.trainSchedulingService.getCompositionRemovals(id); } + @Get("schedules/:id/import-loading-bookings") + @TrainSchedulingView() + @ApiOperation({ + summary: "List import bookings eligible for loading confirmation on this schedule", + }) + getImportLoadingBookings(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getImportLoadingBookings(id); + } + + @Patch("schedules/:id/import-loading-status") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", + }) + updateImportLoadingStatus( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateImportLoadingStatusDto, + ) { + return this.trainSchedulingService.updateImportLoadingStatus(id, dto); + } + + @Patch("schedules/:id/loading-status") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", + }) + setBookingLoadingStatus( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateImportLoadingStatusDto, + ) { + return this.trainSchedulingService.setBookingLoadingStatus(id, dto); + } + @Post("schedules/:id/pin-wagons") @TrainSchedulingManage() @ApiOperation({ summary: "Pin physical wagons to train set slots" }) @@ -329,6 +411,90 @@ export class TrainSchedulingController { return this.trainSchedulingService.dispatchSchedule(id); } + @Get("schedules/:id/intercity-candidates") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Waiting intercity bookings this train could carry (corridor on route) + remaining wagon/weight/length capacity", + }) + getIntercityCandidates(@Param("id", ParseUUIDPipe) id: string) { + return this.intercityService.listCandidates(id); + } + + @Post("schedules/:id/intercity/accept") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", + }) + acceptIntercityBookings( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: AcceptIntercityBookingsDto, + ) { + return this.intercityService.acceptBookings(id, dto.bookingIds); + } + + @Get("schedules/:id/yard-work") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Per-yard operator worklist: which bookings board/alight at each stop, with journey state", + }) + getYardWork(@Param("id", ParseUUIDPipe) id: string) { + return this.bookingJourneyService.listYardWork(id); + } + + @Post("schedules/:id/bookings/:bookingId/load") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", + }) + loadScheduleBooking( + @Param("id", ParseUUIDPipe) id: string, + @Param("bookingId", ParseUUIDPipe) bookingId: string, + ) { + return this.bookingJourneyService.loadBooking(id, bookingId); + } + + @Post("schedules/:id/bookings/:bookingId/unload") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", + }) + unloadScheduleBooking( + @Param("id", ParseUUIDPipe) id: string, + @Param("bookingId", ParseUUIDPipe) bookingId: string, + ) { + return this.bookingJourneyService.unloadBooking(id, bookingId); + } + + @Post("schedules/:id/intercity/:bookingId/load") + @TrainSchedulingManage() + @ApiOperation({ + summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)", + }) + loadIntercityBooking( + @Param("id", ParseUUIDPipe) id: string, + @Param("bookingId", ParseUUIDPipe) bookingId: string, + ) { + return this.intercityService.loadBooking(id, bookingId); + } + + @Post("schedules/:id/intercity/:bookingId/unload") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", + }) + unloadIntercityBooking( + @Param("id", ParseUUIDPipe) id: string, + @Param("bookingId", ParseUUIDPipe) bookingId: string, + ) { + return this.intercityService.unloadBooking(id, bookingId); + } + @Get("schedules/:id/import-djibouti") @TrainSchedulingView() @ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" }) @@ -376,6 +542,19 @@ export class TrainSchedulingController { return this.trainSchedulingService.confirmImportLoadedOnTrain(id, dto); } + @Post("schedules/:id/confirm-loading") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)", + }) + confirmScheduleLoading( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ImportDjiboutiActionDto, + ) { + return this.trainSchedulingService.confirmScheduleLoading(id, dto); + } + @Post("schedules/:id/import-djibouti/depart") @TrainSchedulingManage() @ApiOperation({ summary: "Depart loaded import train from Djibouti" }) @@ -457,6 +636,45 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Patch("schedules/:id/window-rule") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", + }) + async updateScheduleWindowRule( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateScheduleWindowRuleDto, + ) { + await this.trainSchedulingService.updateScheduleWindowRule(id, dto); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + + @Patch("schedules/:id/schedule-date") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window", + }) + async updateScheduleDate( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateScheduleDateDto, + ) { + await this.trainSchedulingService.updateScheduleDate(id, dto); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + + @Post("schedules/:id/doc-review-complete") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", + }) + async completeDocReview(@Param("id", ParseUUIDPipe) id: string) { + await this.bookingWindowService.completeDocReview(id); + return this.bookingBatchService.getBatchBoardDetail(id); + } + @Post("bookings/:bookingId/mark-paid") @TrainSchedulingManage() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 9b8efd9e0..1e1eb1695 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -1,6 +1,8 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; +import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; import { LocomotivesModule } from '../locomotives/locomotives.module'; @@ -24,7 +26,17 @@ import { TrainSchedulingController } from './train-scheduling.controller'; import { TrainSchedulingService } from './train-scheduling.service'; import { BookingBatchService } from './booking-batch.service'; import { BookingNotifierService } from './booking-notifier.service'; +import { BookingWindowGateway } from './booking-window.gateway'; +import { BookingWindowService } from './booking-window.service'; +import { IntercityService } from './intercity.service'; +import { WsAuthService } from '../notification-inbox/ws-auth.service'; +import { BookingJourneyService } from './booking-journey.service'; +import { BookingSplitService } from './booking-split.service'; +import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; +import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { ContractsModule } from '../contracts/contracts.module'; @Module({ imports: [ @@ -40,15 +52,22 @@ import { NotificationsModule } from '../notifications/notifications.module'; TrainSchedulingGlobalRules, TrainCheckpointEvent, ImportDjiboutiOperation, + BookingBatchOffer, + WagonMovement, + // WsAuthService (booking-window gateway handshake) verifies IAM sessions. + Session, ]), forwardRef(() => BookingsModule), + BillingModule, NotificationsModule, + NotificationInboxModule, LocomotivesModule, WagonTypesModule, TrainSetsModule, TrainSchedulesModule, forwardRef(() => WarehousesModule), RuleEngineModule, + forwardRef(() => ContractsModule), ], controllers: [TrainSchedulingController], providers: [ @@ -56,7 +75,18 @@ import { NotificationsModule } from '../notifications/notifications.module'; TrainCheckpointEventsRepository, BookingBatchService, BookingNotifierService, + BookingWindowGateway, + WsAuthService, + BookingWindowService, + BookingSplitService, + IntercityService, + BookingJourneyService, + ], + exports: [ + TrainSchedulingService, + BookingBatchService, + BookingWindowService, + BookingNotifierService, ], - exports: [TrainSchedulingService, BookingBatchService], }) export class TrainSchedulingModule {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index b449669ec..04a972ed7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -154,6 +154,11 @@ describe('TrainSchedulingService', () => { { htmlToPdfBuffer: jest.fn(), } as never, + { emitPhase: jest.fn() } as never, // bookingWindowGateway + { + autoArriveAtFinalYard: jest.fn().mockResolvedValue([]), + } as never, // bookingJourneyService + { dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier ); const defaultFleetWagons = [ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index c71634ccb..754a302d3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1,27 +1,34 @@ import { AllocationLoadType, + LoadingStatus, SchedulingStatus, TrainCheckpointKind, TrainScheduleStatus as TrainScheduleStatusEnum, + WagonMovementKind, WagonStatus, } from '@edr/types'; import { BadRequestException, ConflictException, Injectable, + Logger, NotFoundException, + Optional, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In } from 'typeorm'; +import { DataSource, EntityManager, In, IsNull, Not, QueryFailedError } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; +import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; -import { Route } from '../routes/entities/route.entity'; +import { formatRouteLabel, Route } from '../routes/entities/route.entity'; +import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity'; @@ -36,6 +43,8 @@ import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-all import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; import { Wagon } from '../wagons/entities/wagon.entity'; import { AssignBookingsDto } from './dto/assign-bookings.dto'; @@ -45,6 +54,7 @@ import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto'; import { UpdateContainerItemDto } from './dto/update-container-item.dto'; +import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; @@ -54,11 +64,15 @@ import { type ImportDjiboutiDocumentType, } from './entities/import-djibouti-operation.entity'; import { - IMPORT_DJIBOUTI_DOCUMENT_TYPES, ImportDjiboutiActionDto, UploadImportDjiboutiDocumentDto, } from './dto/import-djibouti-operation.dto'; import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; +import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto'; +import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto'; +import { type BookingWindowConfig } from './booking-window.config'; +import { BookingWindowGateway } from './booking-window.gateway'; +import { BookingNotifierService } from './booking-notifier.service'; import { buildCappedWagonPlan, computeFleetAvailability, @@ -84,10 +98,6 @@ import { type ContainerPlacementInput, type WagonPlanSlot, } from './wagon-plan.util'; -import { - getDefaultContainerWagonTypeCode, - pickBulkWagonType, -} from './wagon-type-resolver.util'; import { deriveScheduleDirection } from './derive-schedule-direction.util'; import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; import { @@ -99,8 +109,14 @@ import { DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, } from './booking-batch.constants'; -import { eatDay } from './batch-window.util'; +import { + computeExportWindowTimes, + computeImportWindowTimes, + earliestSchedulableDeparture, + eatDay, +} from './batch-window.util'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; +import { BookingJourneyService } from './booking-journey.service'; import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; @@ -117,6 +133,66 @@ import { const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const; +/** + * The booking-window rule fields frozen onto a train schedule at creation (and + * refreshed by restampPendingWindows for not-yet-open schedules). The board draws + * its display cycles from this snapshot, so a later global-rules edit never redraws + * an already-open schedule's windows. The reopen gap is derived here — doc review + + * payment — because that is the real delay between a cycle closing and reopening. + */ +function windowRuleSnapshot(cfg: BookingWindowConfig) { + return { + ruleWindowOpenHour: cfg.windowOpenHour, + ruleWindowCloseHour: cfg.windowCloseHour, + ruleWindowDurationHours: cfg.windowDurationHours, + ruleReopenDelayMinutes: cfg.docReviewMinutes + cfg.paymentWindowMinutes, + ruleImportWindowLeadDays: cfg.importWindowLeadDays, + ruleExportBookingLeadHours: cfg.exportBookingLeadHours, + }; +} + +/** + * The booking-window config a specific schedule runs under: its frozen rule + * snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live + * config, with the live config filling any snapshot field a legacy row lacks. + * + * The window SHAPE (hours, duration, lead, reopen gap) comes from the snapshot so + * the runtime cycle engine matches exactly what the board drew and the customer + * saw — a later global-rule edit must not retro-change an existing train. The + * doc-review / payment split is an internal process timing (not part of the + * window the customer sees) and is not stored split in the snapshot, so it always + * takes the live values; their sum is only used as a fallback reopen gap when the + * row predates `ruleReopenDelayMinutes`. + */ +export function effectiveWindowConfig( + schedule: { + ruleWindowOpenHour?: number | null; + ruleWindowCloseHour?: number | null; + ruleWindowDurationHours?: number | null; + ruleReopenDelayMinutes?: number | null; + ruleImportWindowLeadDays?: number | null; + ruleExportBookingLeadHours?: number | null; + }, + liveCfg: BookingWindowConfig, +): BookingWindowConfig { + return { + importWindowLeadDays: + schedule.ruleImportWindowLeadDays ?? liveCfg.importWindowLeadDays, + exportBookingLeadHours: + schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours, + windowOpenHour: schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour, + windowCloseHour: schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour, + windowDurationHours: + schedule.ruleWindowDurationHours != null + ? Number(schedule.ruleWindowDurationHours) + : liveCfg.windowDurationHours, + docReviewMinutes: liveCfg.docReviewMinutes, + paymentWindowMinutes: liveCfg.paymentWindowMinutes, + reopenDelayMinutes: + schedule.ruleReopenDelayMinutes ?? liveCfg.reopenDelayMinutes, + }; +} + export type BookingWagonAllocationStatus = | 'NOT_ATTEMPTED' | 'ASSIGNED' @@ -164,8 +240,31 @@ const DEFAULT_TRAIN_LIMITS: Required = { max20ftPairWeightDiffTons: 10, }; +/** Raw row shape for the booking-window queries (company- and contract-scoped). */ +interface BookingWindowRow { + schedule_id: string; + reference: string | null; + contract_id: string | null; + contract_kind: string | null; + direction: string | null; + window_phase: string | null; + window_opens_at: Date | null; + window_closes_at: Date | null; + doc_review_ends_at: Date | null; + payment_phase_ends_at: Date | null; + booking_window_status: string; + booking_cycle_no: number; + scheduled_departure_date: Date; + origin_label: string | null; + origin_code: string | null; + destination_label: string | null; + destination_code: string | null; +} + @Injectable() export class TrainSchedulingService { + private readonly logger = new Logger(TrainSchedulingService.name); + constructor( @InjectDataSource() private readonly dataSource: DataSource, @@ -181,9 +280,103 @@ export class TrainSchedulingService { private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository, private readonly warehouseInventoryService: WarehouseInventoryService, private readonly pdfDocuments: WarehouseReleaseDocumentService, + private readonly bookingWindowGateway: BookingWindowGateway, + private readonly bookingJourneyService: BookingJourneyService, + private readonly bookingNotifier: BookingNotifierService, + @Optional() private readonly milestoneService?: ClearanceMilestoneService, private readonly configService?: ConfigService, ) {} + /** + * Notify each booking's customer that their shipment was dispatched / arrived, + * with a deep-link to the booking. Fire-and-forget — never blocks the action. + */ + private async notifyScheduleBookings( + schedule: TrainSchedule, + event: 'dispatched' | 'arrived', + ): Promise { + try { + const ids = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId).filter(Boolean); + if (!ids.length) return; + const origin = schedule.originStation?.label ?? schedule.originStation?.code ?? null; + const destination = + schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null; + const bookings = await this.dataSource.getRepository(Booking).find({ + where: { id: In(ids) }, + relations: { company: true }, + }); + for (const b of bookings) { + if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination); + else this.bookingNotifier.arrived(b, origin, destination); + } + } catch (err) { + this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`); + } + } + + /** + * 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[], + filter?: { originYardId?: string; destinationYardId?: string }, + ): Promise { + if (!this.milestoneService || codes.length === 0) return; + try { + const conditions = ['tsb.train_schedule_id = $1', 'tsb.deleted_at IS NULL']; + const params: unknown[] = [scheduleId]; + if (filter?.originYardId) { + params.push(filter.originYardId); + conditions.push(`b.origin_yard_id = $${params.length}`); + } + if (filter?.destinationYardId) { + params.push(filter.destinationYardId); + conditions.push(`b.destination_yard_id = $${params.length}`); + } + const rows: Array<{ booking_id: string }> = await this.dataSource.query( + `SELECT tsb.booking_id + FROM freight.train_schedule_bookings tsb + JOIN freight.bookings b ON b.id = tsb.booking_id + WHERE ${conditions.join(' AND ')}`, + params, + ); + for (const { booking_id } of rows) { + for (const code of codes) { + await this.milestoneService.completeByDocTrigger( + { bookingId: booking_id }, + code, + ); + } + } + } catch (err) { + this.logger.warn( + `Milestone completion (${codes.join(', ')}) failed for schedule ${scheduleId}: ${(err as Error).message}`, + ); + } + } + + /** + * Push a schedule's current booking-window state over the socket so the + * portal home card and backoffice GL/batch views update in real time — + * used for lifecycle changes outside the window tick (create, cancel, + * finalize, restamp). A push failure must never break the mutation. + */ + private async emitWindowState(scheduleId: string): Promise { + try { + const fresh = await this.trainSchedulesRepository.findById(scheduleId); + if (fresh) this.bookingWindowGateway.emitPhase(fresh); + } catch (err) { + this.logger.warn( + `Booking-window push failed for ${scheduleId}: ${(err as Error).message}`, + ); + } + } + async getEligibleBookings(query: GetEligibleBookingsDto) { // Day-level pooling: when the wizard targets a schedule, surface the whole // (route, EAT day) pool — not just bookings pre-pinned to that train — by @@ -237,7 +430,273 @@ export class TrainSchedulingService { if (dto.max20ftPairWeightDiffTons != null) { row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons; } - return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row); + if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays; + if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours; + if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour; + if (dto.windowCloseHour != null) row.windowCloseHour = dto.windowCloseHour; + if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours; + if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes; + if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes; + if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes; + + // The booking desk supports three shapes: a same-day range + // (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an + // overnight range that wraps past midnight (openHour > closeHour, e.g. + // 08:00 → 07:00). officeHoursOpen handles all three, so no ordering guard. + + // Fields that change the STAMPED open/close times of a schedule. docReview/ + // payment/reopen are read live by the cron each tick, so they need no + // re-stamp; only the four below feed computeImport/ExportWindowTimes. + const windowTimingChanged = + dto.importWindowLeadDays != null || + dto.windowOpenHour != null || + dto.windowCloseHour != null || + dto.windowDurationHours != null || + dto.docReviewMinutes != null || + dto.paymentWindowMinutes != null || + dto.exportBookingLeadHours != null; + + const saved = await this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .save(row); + + // The cron reads config fresh every tick, so derived timings (doc review, + // payment, reopen) take effect on the next tick with no restart. But each + // schedule's initial open/close times were FROZEN at creation — re-stamp the + // ones whose window has not opened yet so a config edit applies to them too. + if (windowTimingChanged) { + await this.restampPendingWindows(); + } + + return saved; + } + + /** + * Override the booking-window rule for ONE schedule (staff action on the ops + * board). Only the fields provided are changed; the rest keep the schedule's + * existing snapshot (falling back to the live global config for legacy rows). + * The window must not have opened yet — an OPEN/past schedule stays frozen so + * customers keep the times they were shown. windowOpensAt/ClosesAt are + * re-derived from the merged rule, and the snapshot is updated so the board + * draws the new cycles. + */ + async updateScheduleWindowRule( + id: string, + dto: UpdateScheduleWindowRuleDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findById(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + if (schedule.windowPhase !== 'PRE_WINDOW') { + throw new BadRequestException( + 'Booking window settings can only be changed before the window opens ' + + `(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`, + ); + } + const now = new Date(); + if (!schedule.scheduledDepartureDate || schedule.scheduledDepartureDate <= now) { + throw new BadRequestException( + 'This schedule has already departed or has no departure date.', + ); + } + + // Merge the override onto the schedule's current effective rule (its snapshot, + // or the live config where a legacy row has no snapshot). + const liveCfg = await this.getWindowConfig(); + const merged: BookingWindowConfig = { + importWindowLeadDays: + dto.importWindowLeadDays ?? + schedule.ruleImportWindowLeadDays ?? + liveCfg.importWindowLeadDays, + exportBookingLeadHours: + dto.exportBookingLeadHours ?? + schedule.ruleExportBookingLeadHours ?? + liveCfg.exportBookingLeadHours, + windowOpenHour: + dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour, + windowCloseHour: + dto.windowCloseHour ?? schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour, + windowDurationHours: + dto.windowDurationHours ?? + (schedule.ruleWindowDurationHours != null + ? Number(schedule.ruleWindowDurationHours) + : liveCfg.windowDurationHours), + // The reopen gap is doc review + payment; keep the config values unless the + // override changes them, so the derived snapshot delay stays consistent. + docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes, + paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes, + reopenDelayMinutes: liveCfg.reopenDelayMinutes, + }; + + // Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid + // — officeHoursOpen resolves each, so no close-vs-open ordering guard here. + + const times = + schedule.direction === 'EXPORT' + ? computeExportWindowTimes(schedule.scheduledDepartureDate, merged) + : computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now); + if (times.windowOpensAt.getTime() >= times.windowClosesAt.getTime()) { + throw new BadRequestException( + 'These settings leave no booking window before departure — with the ' + + 'desk hours applied, the window would only open once the train has left.', + ); + } + + await this.dataSource.getRepository(TrainSchedule).update(id, { + windowOpensAt: times.windowOpensAt, + windowClosesAt: times.windowClosesAt, + ...windowRuleSnapshot(merged), + }); + this.logger.log( + `Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`, + ); + void this.emitWindowState(id); + + const fresh = await this.trainSchedulesRepository.findById(id); + return fresh ?? schedule; + } + + /** + * Reschedule ONE train's departure date (staff action on the ops board). Only + * allowed while the booking window has not opened yet — an OPEN/past schedule + * stays frozen so customers keep the times they were shown. The new date must + * still leave room for the booking lead window before departure (same floor as + * schedule creation); INTERCITY/DOMESTIC uses the import lead. The window + * open/close times are re-derived from the schedule's existing rule snapshot. + */ + async updateScheduleDate( + id: string, + dto: UpdateScheduleDateDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findById(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + if (schedule.windowPhase !== 'PRE_WINDOW') { + throw new BadRequestException( + 'The departure date can only be changed before the booking window opens ' + + `(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`, + ); + } + + const now = new Date(); + const departure = new Date(dto.scheduleDate); + if (Number.isNaN(departure.getTime())) { + throw new BadRequestException('Invalid departure date.'); + } + + // Staff cannot schedule inside the lead window — there must be room for a + // booking window before departure. IMPORT/DOMESTIC lead is in whole EAT days; + // EXPORT lead is in hours. Mirrors the create-schedule check. + const windowCfg = await this.getWindowConfig(); + const earliest = earliestSchedulableDeparture( + schedule.direction, + windowCfg, + now, + ); + if (departure.getTime() < earliest.getTime()) { + const detail = + schedule.direction === 'EXPORT' + ? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead` + : `at least ${windowCfg.importWindowLeadDays} day(s) ahead`; + throw new BadRequestException( + `Departure ${departure.toISOString()} is inside the booking lead window; ` + + `${schedule.direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` + + `(earliest ${earliest.toISOString()}).`, + ); + } + + // Re-derive the window from the schedule's own rule snapshot (falling back to + // the live config where a legacy row has no snapshot) against the new date. + const merged = effectiveWindowConfig(schedule, windowCfg); + + const times = + schedule.direction === 'EXPORT' + ? computeExportWindowTimes(departure, merged) + : computeImportWindowTimes(departure, merged, now); + + await this.dataSource.getRepository(TrainSchedule).update(id, { + scheduledDepartureDate: departure, + windowOpensAt: times.windowOpensAt, + windowClosesAt: times.windowClosesAt, + }); + this.logger.log( + `Departure date changed for schedule ${id} → ${departure.toISOString()} ` + + `(window reopens ${times.windowOpensAt.toISOString()})`, + ); + void this.emitWindowState(id); + + const fresh = await this.trainSchedulesRepository.findById(id); + return fresh ?? schedule; + } + + /** + * Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has + * not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure + * in the future) using the CURRENT global-rules config. Schedules already OPEN or + * past their window are left untouched — customers may have booked against the + * times they were shown, so those stay frozen. Returns the count re-stamped. + */ + async restampPendingWindows(): Promise { + const cfg = await this.getWindowConfig(); + const now = new Date(); + const schedules = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft, windowPhase: 'PRE_WINDOW' }, + { status: TrainScheduleStatusEnum.Scheduled, windowPhase: 'PRE_WINDOW' }, + ], + }); + + const repo = this.dataSource.getRepository(TrainSchedule); + let restamped = 0; + for (const s of schedules) { + if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue; + const times = + s.direction === 'EXPORT' + ? computeExportWindowTimes(s.scheduledDepartureDate, cfg) + : computeImportWindowTimes(s.scheduledDepartureDate, cfg, now); + // A not-yet-open schedule legitimately adopts the new rule, so refresh its + // snapshot alongside the re-stamped times — the board then draws the new + // window from this same rule. + await repo.update(s.id, { + windowOpensAt: times.windowOpensAt, + windowClosesAt: times.windowClosesAt, + ...windowRuleSnapshot(cfg), + }); + restamped += 1; + // New times take effect immediately on every card (the tick then opens + // the window within seconds if the re-derived open is already due). + void this.emitWindowState(s.id); + } + if (restamped > 0) { + this.logger.log( + `Re-stamped booking windows for ${restamped} pending schedule(s) after a global-rules change`, + ); + } + return restamped; + } + + /** + * Booking-window timings with hardcoded fallbacks for a missing/legacy config row. + * Numeric columns come back from pg as strings — normalize every field. + */ + async getWindowConfig(): Promise { + const row = await this.loadGlobalRulesRow(); + const num = (v: unknown, fallback: number) => { + const n = v == null ? NaN : Number(v); + return Number.isFinite(n) ? n : fallback; + }; + return { + importWindowLeadDays: num(row?.importWindowLeadDays, 3), + exportBookingLeadHours: num(row?.exportBookingLeadHours, 24), + windowOpenHour: num(row?.windowOpenHour, 8), + windowCloseHour: num(row?.windowCloseHour, 17), + windowDurationHours: num(row?.windowDurationHours, 3), + docReviewMinutes: num(row?.docReviewMinutes, 30), + paymentWindowMinutes: num(row?.paymentWindowMinutes, 60), + reopenDelayMinutes: num(row?.reopenDelayMinutes, 90), + }; } async previewTrainSchedule(dto: PreviewTrainScheduleDto) { @@ -304,15 +763,19 @@ export class TrainSchedulingService { } async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { - const route = await this.getActiveRoute(dto.routeId); + const route = await this.getSchedulableRoute(dto.routeId); const locomotiveIds = [...new Set(dto.locomotiveIds)]; if (locomotiveIds.length < 2) { throw new BadRequestException('A train must be pulled by at least two locomotives'); } + const scheduleWarnings: string[] = []; const createdScheduleId = await this.dataSource.transaction(async (manager) => { - // Lock and validate every locomotive: all must be AVAILABLE and at the origin yard. + // Lock every locomotive. Advance scheduling is allowed: a locomotive may sit on + // multiple future schedules and does not need to be at the origin yard yet — staff + // plan around its arrival. Only decommissioned locomotives are hard-blocked; + // everything else surfaces as a warning. const lockedLocomotives: Locomotive[] = []; for (const locomotiveId of locomotiveIds) { const locked = await manager.getRepository(Locomotive).findOne({ @@ -322,46 +785,99 @@ export class TrainSchedulingService { if (!locked) { throw new NotFoundException(`Locomotive ${locomotiveId} not found`); } + if (locked.status === 'OUT_OF_SERVICE') { + throw new ConflictException(`Locomotive ${locked.code} is out of service`); + } if (locked.status !== 'AVAILABLE') { - throw new ConflictException(`Locomotive ${locked.code} is not available`); + scheduleWarnings.push( + `Locomotive ${locked.code} is currently ${locked.status}; it must be released before this train dispatches`, + ); } if (locked.currentYardId !== route.originYardId) { - throw new ConflictException( - `Locomotive ${locked.code} is at yard ${locked.currentYardId} but schedule originates from ${route.originYardId}`, + scheduleWarnings.push( + `Locomotive ${locked.code} is not at the origin yard yet; it must arrive before this train dispatches`, ); } lockedLocomotives.push(locked); } - const direction = deriveScheduleDirection( - route.originYard ?? { country: null }, - route.destinationYard ?? { country: null }, - ); + // Frozen on the route at create/update from the yard-country enum; + // getSchedulableRoute already rejected DOMESTIC (intercity). + const direction = this.resolveRouteDirection(route); const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives); // Effective capacity is capped by the weakest locomotive in the set. const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined; - const schedule = manager.getRepository(TrainSchedule).create({ - trainSetId: trainSet.id, - routeId: route.id, - originStationId: route.originYardId, - destinationStationId: route.destinationYardId, - scheduledDepartureDate: new Date(dto.scheduleDate), - status: TrainScheduleStatusEnum.Draft, - direction, - maxWagons: ( - await this.resolveTrainLimitConfig(dto, limitLoco) - ).maxWagonsPerTrain, - }); - const saved = await manager.getRepository(TrainSchedule).save(schedule); - await manager.getRepository(Locomotive).update( - { id: In(lockedLocomotives.map((l) => l.id)) }, - { status: 'ASSIGNED' }, + const departure = new Date(dto.scheduleDate); + // Every schedule starts with a CLOSED customer window; the window engine opens + // it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT + // (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens + // 24h before departure (FCFS). No schedule is ever always-open now. + const windowCfg = await this.getWindowConfig(); + + // Staff cannot schedule inside the lead window — there must be room for a + // booking window before departure. IMPORT/DOMESTIC lead is in whole EAT + // days (lead 3, today 11th → first allowed departure is the 14th); EXPORT + // lead is in hours (24h = 1 day ahead). + const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date()); + if (departure.getTime() < earliest.getTime()) { + const detail = + direction === 'EXPORT' + ? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead` + : `at least ${windowCfg.importWindowLeadDays} day(s) ahead`; + throw new BadRequestException( + `Departure ${departure.toISOString()} is inside the booking lead window; ` + + `${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` + + `(earliest ${earliest.toISOString()})`, + ); + } + // Freeze the rule this schedule is born with. A later global-rules edit + // only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an + // already-open schedule keeps this snapshot, and the batch board draws its + // windows from it rather than the live config. + const ruleSnapshot = windowRuleSnapshot(windowCfg); + const windowFields = + direction === 'EXPORT' + ? { + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + ...ruleSnapshot, + ...computeExportWindowTimes(departure, windowCfg), + } + : { + // IMPORT and DOMESTIC share the import booking-day window cycle. + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + ...ruleSnapshot, + ...computeImportWindowTimes(departure, windowCfg, new Date()), + }; + const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco)) + .maxWagonsPerTrain; + // Retry past a concurrent insert that grabbed the same S- sequence + // (the unique index rejects the loser; it re-reads the max and tries again). + const saved = await this.insertScheduleWithReference(manager, (reference) => + manager.getRepository(TrainSchedule).create({ + reference, + trainSetId: trainSet.id, + routeId: route.id, + originStationId: route.originYardId, + destinationStationId: route.destinationYardId, + scheduledDepartureDate: departure, + status: TrainScheduleStatusEnum.Draft, + direction, + maxWagons, + ...windowFields, + }), ); + // Locomotives stay in their current status until dispatch — advance scheduling + // must not block the locomotive from serving earlier trains. return saved.id; }); - return 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 }; } async assignBookingsToSchedule( @@ -409,27 +925,71 @@ export class TrainSchedulingService { const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet); const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined; const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco); + + // Callers that add bookings without hand-picking container slots (the + // workspace "Add from pool" button, re-adding a removed booking) send no + // containerPlacements. Auto-fill them the same way the batch engine does: + // preview the wagon plan first, then lay containers into the plan's slots. + // Without this the placement validator rejects container bookings outright + // ("Container placements are required for container bookings"). + let containerPlacements = dto.containerPlacements; + if (!containerPlacements?.length) { + const preview = await this.validateBookingsForScheduling( + previewDto, + freightType ?? null, + dto.forceAssign, + [], + false, + limits, + scheduleId, + ); + const containerBookings = preview.bookings.filter( + (b) => b.freightType === 'CONTAINER', + ); + if (containerBookings.length) { + const units = expandBookingContainerUnits(containerBookings); + const slots = getContainerSlotSequenceNos(preview.wagonPlan); + const generated = autoFillPlacements(units, slots); + const missing = findMissingContainerNumberIssues(units, generated); + if (missing.length) { + throw new BadRequestException({ + message: `Booking validation failed: ${missing + .map((m) => m.issue) + .join('; ')}`, + violations: missing.map((m) => m.issue), + }); + } + containerPlacements = generated; + } + } + const validation = await this.validateBookingsForScheduling( previewDto, freightType ?? null, dto.forceAssign, - dto.containerPlacements, + containerPlacements, true, limits, scheduleId, ); if (!validation.valid) { + // Put the violation detail in the message itself — global exception + // filters flatten the body, and "Booking validation failed" alone tells + // staff nothing (e.g. which wagon type is missing at the yard). throw new BadRequestException({ - message: 'Booking validation failed', + message: `Booking validation failed: ${validation.violations.join('; ')}`, violations: validation.violations, warnings: validation.warnings, }); } if (!validation.bookings.length) { + const shortfall = validation.deferredBookings + .map((d) => `${d.reference}: ${d.reason}`) + .join('; '); throw new BadRequestException({ - message: 'No bookings fit on available fleet wagons', + message: `No wagons available for the selected bookings${shortfall ? ` — ${shortfall}` : ''}`, violations: ['Insufficient fleet wagons for the selected bookings'], warnings: validation.warnings, deferredBookings: validation.deferredBookings, @@ -443,12 +1003,14 @@ export class TrainSchedulingService { if (!limitLoco) { throw new BadRequestException('Schedule train set has no locomotives'); } - if (limitLoco.maxPullWeightTons < totalWeightTons) { + // forceAssign lets staff overload the locomotive set knowingly — the + // validator has already surfaced it as a warning in that case. + if (!dto.forceAssign && limitLoco.maxPullWeightTons < totalWeightTons) { throw new BadRequestException( `Train set locomotives cannot pull ${totalWeightTons}T`, ); } - if (limitLoco.maxTrainLengthMeters < totalLengthMeters) { + if (!dto.forceAssign && limitLoco.maxTrainLengthMeters < totalLengthMeters) { throw new BadRequestException( `Train set locomotives cannot support ${totalLengthMeters}m`, ); @@ -501,7 +1063,7 @@ export class TrainSchedulingService { savedWagons, wagonPlan, bookings, - dto.containerPlacements ?? [], + containerPlacements ?? [], ); for (const booking of bookings) { @@ -617,53 +1179,61 @@ export class TrainSchedulingService { } private async runWarehouseArrivalAutomation(scheduleId: string) { - const [schedule]: Array<{ - originCountry: string | null; - destinationCountry: string | null; - destinationCode: string | null; - destinationName: string | null; - }> = await this.dataSource.query( - `SELECT oy.country AS "originCountry", - dy.country AS "destinationCountry", - dy.code AS "destinationCode", - dy.name AS "destinationName" - 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.id = $1 AND ts.deleted_at IS NULL - LIMIT 1`, - [scheduleId], - ); - - if (!schedule) return { status: 'SKIPPED', reason: 'Train schedule not found' }; - - const direction = deriveTradeDirection( - { country: schedule.originCountry }, - { country: schedule.destinationCountry }, - ); - + // Runs after the arrival transaction has committed — must never throw, or a + // successfully arrived train reports a 500 and looks stuck to the operator. + let direction: string | undefined; try { + const [schedule]: Array<{ + originCountry: string | null; + destinationCountry: string | null; + destinationCode: string | null; + destinationName: string | null; + }> = await this.dataSource.query( + `SELECT oy.country AS "originCountry", + dy.country AS "destinationCountry", + dy.code AS "destinationCode", + dy.label AS "destinationName" + 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.id = $1 AND ts.deleted_at IS NULL + LIMIT 1`, + [scheduleId], + ); + + if (!schedule) return { status: 'SKIPPED', reason: 'Train schedule not found' }; + + direction = deriveTradeDirection( + { country: schedule.originCountry }, + { country: schedule.destinationCountry }, + ); if (direction === 'IMPORT') { + const result = await this.warehouseInventoryService.autoUnloadArrivedBookings( + scheduleId, + 'SYSTEM_TRAIN_ARRIVAL', + ); + // Customer tracking: cargo is off the train at the destination yard. + void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']); return { direction, action: 'IMPORT_AUTO_UNLOAD', status: 'COMPLETED', - result: await this.warehouseInventoryService.autoUnloadArrivedBookings( - scheduleId, - 'SYSTEM_TRAIN_ARRIVAL', - ), + result, }; } if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) { + const result = await this.warehouseInventoryService.autoUnloadExportAtDjibouti( + scheduleId, + 'SYSTEM_TRAIN_ARRIVAL', + ); + // Customer tracking: cargo is off the train at the Djibouti port. + void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']); return { direction, action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD', status: 'COMPLETED', - result: await this.warehouseInventoryService.autoUnloadExportAtDjibouti( - scheduleId, - 'SYSTEM_TRAIN_ARRIVAL', - ), + result, }; } @@ -684,6 +1254,131 @@ export class TrainSchedulingService { ); } + async getImportLoadingBookings(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const [scheduleBookings, allocations] = await Promise.all([ + this.trainScheduleBookingsRepository.findByScheduleId(scheduleId), + this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId), + ]); + if (!scheduleBookings.length) { + return { count: 0, items: [] }; + } + + const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId)); + const statusByBookingId = new Map( + scheduleBookings.map((sb) => [sb.bookingId, sb.loadingStatus]), + ); + const candidateIds = scheduleBookings + .map((sb) => sb.bookingId) + .filter((id) => allocatedBookingIds.has(id)); + if (!candidateIds.length) { + return { count: 0, items: [] }; + } + + const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds); + const items = bookings + .filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID') + .map((b) => ({ + id: b.id, + reference: b.reference ?? null, + customer: b.company?.name ?? null, + weightTons: b.cargoTotalWeightVgm, + loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded, + })); + return { count: items.length, items }; + } + + async updateImportLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const [scheduleBookings, allocations, bookings] = await Promise.all([ + this.trainScheduleBookingsRepository.findByScheduleId(scheduleId), + this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId), + this.bookingsRepository.findByIdsForScheduling(dto.bookingIds), + ]); + + const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId)); + const allocatedIds = new Set(allocations.map((a) => a.bookingId)); + const bookingById = new Map(bookings.map((b) => [b.id, b])); + + const invalid: string[] = []; + for (const id of dto.bookingIds) { + const booking = bookingById.get(id); + if ( + !scheduledIds.has(id) || + !allocatedIds.has(id) || + !booking || + booking.tradeDirection !== 'IMPORT' || + booking.paymentStatus !== 'PAID' + ) { + invalid.push(id); + } + } + if (invalid.length) { + throw new BadRequestException( + `Not eligible for import loading confirmation on this schedule: ${invalid.join(', ')}`, + ); + } + + await this.trainScheduleBookingsRepository.updateLoadingStatusMany( + scheduleId, + dto.bookingIds, + dto.loadingStatus, + ); + return this.getImportLoadingBookings(scheduleId); + } + + /** + * Flip loaded/unloaded on the schedule↔booking link from the workspace, for any + * direction (import/export/domestic). Distinct from unassign: the booking stays + * on its wagon; this only records whether cargo is physically loaded. Allowed + * only before dispatch — once the train is DISPATCHED/ARRIVED the on-arrival + * warehouse automation owns unload, so staff can no longer hand-edit the flag. + */ + async setBookingLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException( + `Loading status can only be changed before dispatch (schedule is ${schedule.status})`, + ); + } + + const [scheduleBookings, allocations] = await Promise.all([ + this.trainScheduleBookingsRepository.findByScheduleId(scheduleId), + this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId), + ]); + const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId)); + const allocatedIds = new Set(allocations.map((a) => a.bookingId)); + + // Only bookings that are on this train AND pinned to a wagon can be loaded — + // no direction/payment filter, staff load whatever is physically on the set. + const invalid = dto.bookingIds.filter( + (id) => !scheduledIds.has(id) || !allocatedIds.has(id), + ); + if (invalid.length) { + throw new BadRequestException( + `Not allocated to a wagon on this schedule: ${invalid.join(', ')}`, + ); + } + + await this.trainScheduleBookingsRepository.updateLoadingStatusMany( + scheduleId, + dto.bookingIds, + dto.loadingStatus, + ); + return this.getTrainScheduleById(scheduleId); + } + async pinWagons(scheduleId: string, dto: PinWagonsDto) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { @@ -767,6 +1462,8 @@ export class TrainSchedulingService { } }); + // Finalized — push so portal/GL cards reflect the new state instantly. + void this.emitWindowState(scheduleId); return this.getTrainScheduleById(scheduleId); } @@ -779,10 +1476,19 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); + // A locomotive may sit on many future schedules, but it can only pull one train + // at a time — block dispatch while any set locomotive is out on a dispatched train. + const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); + await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId); const now = new Date(); await this.dataSource.transaction(async (manager) => { const trainNumber = await this.assignTrainNumber(manager, schedule); + if (setLocomotiveIds.length) { + await manager + .getRepository(Locomotive) + .update({ id: In(setLocomotiveIds) }, { status: 'ASSIGNED' }); + } await this.trainSchedulesRepository.updateStatus( scheduleId, @@ -800,6 +1506,24 @@ export class TrainSchedulingService { manager, ); } + // Per-booking journey fallback: bookings boarding at the TRAIN's origin + // that the operator didn't load individually are auto-loaded now — the + // train is leaving with them. Mid-corridor boarders stay PAID until the + // operator loads them at their own yard. + await manager.query( + `UPDATE freight.bookings b + SET status = 'IN_TRANSIT', + loaded_at = COALESCE(b.loaded_at, $3) + FROM freight.train_schedule_bookings tsb + WHERE tsb.booking_id = b.id + AND tsb.train_schedule_id = $1 + AND tsb.deleted_at IS NULL + AND b.deleted_at IS NULL + AND b.origin_yard_id = $2 + AND b.loaded_at IS NULL + AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))`, + [scheduleId, schedule.originStationId, now], + ); // Close the booking window; any still-pending (unallocated) reservations don't ride this train. await manager .getRepository(TrainSchedule) @@ -828,11 +1552,34 @@ 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. Restricted to bookings + // that BOARD at the train's origin; mid-corridor boarders get their loading + // milestones from their own operator load at their own yard. + 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', + ], + { originYardId: schedule.originStationId }, + ); + } + void this.notifyScheduleBookings(schedule, 'dispatched'); return this.getTrainScheduleById(scheduleId); } async getImportDjiboutiOperation(scheduleId: string) { - const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); return this.mapImportDjiboutiOperation(schedule, operation); } @@ -841,7 +1588,7 @@ export class TrainSchedulingService { scheduleId: string, dto: UploadImportDjiboutiDocumentDto, ) { - const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); const documents = { ...(operation.documents ?? {}), @@ -865,25 +1612,69 @@ export class TrainSchedulingService { } async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { - const schedule = await this.getImportDjiboutiSchedule(scheduleId); + const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); - const missing = this.missingImportDjiboutiDocuments(operation); - if (missing.length) { - throw new BadRequestException(`Gatepass cannot be granted until documents are uploaded: ${missing.join(', ')}`); + const securedAt = dto.securedAt ? new Date(dto.securedAt) : new Date(); + const documents = { ...(operation.documents ?? {}) }; + if (dto.fileId || dto.fileUrl || dto.reference || dto.notes) { + documents.GATE_PASS = { + fileId: dto.fileId ?? null, + fileUrl: dto.fileUrl ?? null, + reference: dto.reference ?? null, + uploadedAt: new Date().toISOString(), + uploadedBy: dto.performedBy ?? null, + notes: dto.notes ?? null, + }; } await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, { - gatepassGrantedAt: operation.gatepassGrantedAt ?? new Date(), + documents, + gatepassGrantedAt: securedAt, performedBy: dto.performedBy ?? operation.performedBy ?? null, notes: dto.notes ?? operation.notes ?? null, }); + await this.completeGatepassMilestoneForSchedule(scheduleId, securedAt); + console.log( - `[NOTIFY] Import gatepass granted for train ${schedule.trainNumber ?? schedule.id}; loading may proceed.`, + `[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`, ); return this.getImportDjiboutiOperation(schedule.id); } + /** + * Bridge write: also flips the legacy clearance-side GATEPASS_GRANTED + * milestone for every customs booking on this schedule, so contract/booking + * clearance views still reading that milestone (older deployed builds) see + * the gate pass as done. Drop once every clearance-api deployment reads + * ImportDjiboutiOperation.gatepassGrantedAt directly. + */ + private async completeGatepassMilestoneForSchedule( + scheduleId: string, + securedAt: Date, + ): Promise { + const bookings = await this.dataSource.getRepository(Booking).find({ + where: { trainScheduleId: scheduleId, customsClearingEnabled: true }, + }); + if (bookings.length === 0) return; + + const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone); + const rows = await milestoneRepo.find({ + where: { + bookingId: In(bookings.map((b) => b.id)), + milestoneCode: 'GATEPASS_GRANTED', + }, + }); + + for (const row of rows) { + if (row.status === 'COMPLETED') continue; + row.status = 'COMPLETED'; + row.triggeredAt = securedAt; + row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() }; + await milestoneRepo.save(row); + } + } + async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { const schedule = await this.getImportDjiboutiSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); @@ -913,13 +1704,48 @@ export class TrainSchedulingService { return this.getImportDjiboutiOperation(schedule.id); } + /** + * Confirm cargo is loaded on the train from the workspace, for any direction. + * For import-from-Djibouti trains this stamps the ImportDjiboutiOperation's + * loadedOnTrainAt (the flag dispatch checks) — gatepass must already be granted. + * For every other schedule there is no departure loading gate, so this is a + * success no-op and simply returns the current detail. + */ + async confirmScheduleLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + // Import-Djibouti trains gate dispatch on the operation's loadedOnTrainAt. + if (this.isImportDjiboutiSchedule(schedule)) { + await this.confirmImportLoadedOnTrain(scheduleId, dto); + } + // Confirming loading also marks every wagon-assigned booking LOADED, so the + // per-booking loading flag and the dispatch gate agree (otherwise the + // dispatch pre-check keeps reporting these bookings as unloaded). + const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); + if (wagonAssignedIds.size) { + await this.trainScheduleBookingsRepository.updateLoadingStatusMany( + scheduleId, + [...wagonAssignedIds], + 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); + } + async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { const schedule = await this.getImportDjiboutiSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); this.assertImportDjiboutiGatepassGranted(operation); - if (!operation.loadedOnTrainAt) { - throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed'); - } + // Loading confirmation does not block departure (see assertImportDjiboutiMayDepart). if (schedule.status === TrainScheduleStatusEnum.Scheduled) { await this.dispatchSchedule(schedule.id); @@ -949,7 +1775,7 @@ export class TrainSchedulingService { generatedAt: generatedAt.toISOString(), trainScheduleId: schedule.id, trainNumber: schedule.trainNumber ?? null, - route: schedule.route?.name ?? null, + route: schedule.route ? formatRouteLabel(schedule.route) : null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, totalBookings: schedule.scheduleBookings?.length ?? 0, @@ -975,7 +1801,9 @@ export class TrainSchedulingService { performedBy: 'DOCUMENT_GENERATION', }); const html = this.buildImportLoadListHtml(loadList); - const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + // Styled table-aware fallback (marshalling grid) when Chromium is unavailable — + // NOT the release-order fallback (would mislabel this as a gate-clearance order). + const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list'); const reference = loadList.trainNumber ?? loadList.trainScheduleId; return { filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`, @@ -993,7 +1821,8 @@ export class TrainSchedulingService { } const html = this.buildExportLoadListHtml(schedule); - const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. + const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list'); const reference = schedule.trainNumber ?? schedule.id; return { filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`, @@ -1293,22 +2122,34 @@ export class TrainSchedulingService { where: { trainScheduleId: schedule.id }, }); this.assertImportDjiboutiGatepassGranted(operation); - if (!operation?.loadedOnTrainAt) { - throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed'); - } + // Loading confirmation does NOT gate dispatch. Per-booking loading is + // tracking only and the loaded-on-train step is optional — a scheduled train + // dispatches without waiting on loading. } private async getImportDjiboutiSchedule(scheduleId: string): Promise { + const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); + if (!this.isImportDjiboutiSchedule(schedule)) { + throw new BadRequestException('This action applies only to IMPORT schedules originating from Djibouti'); + } + return schedule; + } + + private async getDjiboutiGatepassSchedule(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } - if (!this.isImportDjiboutiSchedule(schedule)) { - throw new BadRequestException('Batch 7 actions apply only to IMPORT schedules originating from Djibouti'); + if (!this.isDjiboutiGatepassSchedule(schedule)) { + throw new BadRequestException('Gate pass applies only to trains entering Djibouti Port on import or export routes'); } return schedule; } + private isDjiboutiGatepassSchedule(schedule: TrainSchedule): boolean { + return this.isImportDjiboutiSchedule(schedule) || this.isExportDjiboutiSchedule(schedule); + } + private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean { const direction = (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ?? @@ -1323,6 +2164,20 @@ export class TrainSchedulingService { ); } + private isExportDjiboutiSchedule(schedule: TrainSchedule): boolean { + const direction = + (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ?? + (schedule.originStation && schedule.destinationStation + ? deriveScheduleDirection(schedule.originStation, schedule.destinationStation) + : null); + return ( + direction === 'EXPORT' && + this.isDjiboutiPortDestination( + `${schedule.destinationStation?.code ?? ''} ${schedule.destinationStation?.label ?? ''}`, + ) + ); + } + private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise { const repo = this.dataSource.getRepository(ImportDjiboutiOperation); const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } }); @@ -1331,8 +2186,8 @@ export class TrainSchedulingService { } private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] { - const documents = operation?.documents ?? {}; - return IMPORT_DJIBOUTI_DOCUMENT_TYPES.filter((type) => !documents[type]); + void operation; + return []; } private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void { @@ -1343,6 +2198,7 @@ export class TrainSchedulingService { private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) { const missingDocuments = this.missingImportDjiboutiDocuments(operation); + const gatepassStatus = operation.gatepassGrantedAt ? 'SECURED' : 'NOT_SECURED'; return { trainScheduleId: schedule.id, trainNumber: schedule.trainNumber ?? null, @@ -1350,6 +2206,7 @@ export class TrainSchedulingService { status: { documentsComplete: missingDocuments.length === 0, missingDocuments, + gatepassStatus, gatepassGranted: Boolean(operation.gatepassGrantedAt), readyForLoading: Boolean(operation.readyForLoadingAt), loadedOnTrain: Boolean(operation.loadedOnTrainAt), @@ -1358,6 +2215,8 @@ export class TrainSchedulingService { }, documents: operation.documents ?? {}, gatepassGrantedAt: operation.gatepassGrantedAt ?? null, + gatepassSecuredAt: operation.gatepassGrantedAt ?? null, + gatepassStatus, readyForLoadingAt: operation.readyForLoadingAt ?? null, loadedOnTrainAt: operation.loadedOnTrainAt ?? null, departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null, @@ -1423,9 +2282,16 @@ export class TrainSchedulingService { /** Open or close a schedule's booking window (staff override). */ async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise { + if (status === 'OPEN') { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (schedule?.bookingWindowStatus === 'FULL') { + throw new ConflictException('Train is full — the booking window cannot be reopened'); + } + } await this.dataSource .getRepository(TrainSchedule) .update(scheduleId, { bookingWindowStatus: status }); + void this.emitWindowState(scheduleId); } /** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */ @@ -1634,29 +2500,20 @@ export class TrainSchedulingService { }); } - await manager.query( - `UPDATE freight.bookings b - SET status = $2, - scheduling_status = $3 - FROM freight.train_schedule_bookings tsb - WHERE tsb.booking_id = b.id - AND tsb.train_schedule_id = $1 - AND tsb.deleted_at IS NULL - AND b.deleted_at IS NULL - AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')`, - [scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched], - ); + // Per-booking journey: bookings destined for the FINAL yard that the + // operator didn't unload individually get their arrival stamped now as a + // bulk fallback. Mid-corridor bookings are NOT touched — their arrival is + // their own unload (possibly already done while the train kept rolling). + await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now); - if (schedule.trainSet?.locomotiveId) { - const loco = await manager - .getRepository(Locomotive) - .findOne({ where: { id: schedule.trainSet.locomotiveId } }); - if (loco) { - await manager.getRepository(Locomotive).update(loco.id, { - status: 'AVAILABLE', - currentYardId: schedule.destinationStationId, - }); - } + // Release every locomotive of the set (not just the legacy primary) and move it + // to the destination yard where it physically arrived. + const arrivedLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); + if (arrivedLocoIds.length) { + await manager.getRepository(Locomotive).update( + { id: In(arrivedLocoIds) }, + { status: 'AVAILABLE', currentYardId: schedule.destinationStationId }, + ); } for (const slot of schedule.trainSet?.wagons ?? []) { @@ -1665,12 +2522,33 @@ export class TrainSchedulingService { .getRepository(Wagon) .findOne({ where: { id: slot.physicalWagonId } }); if (!wagon) continue; + // A wagon that already alighted mid-route (unload released it, possibly + // re-pinned elsewhere since) is no longer this schedule's to move. + if (wagon.currentTrainScheduleId !== scheduleId) continue; + // Dynamic consist: the wagon settles at its slot's alight yard, not + // blanket at the train's destination. + const settleYardId = slot.alightYardId ?? schedule.destinationStationId; await manager.getRepository(Wagon).update(wagon.id, { currentTrainScheduleId: null, trainSetWagonId: null, status: WagonStatus.Available, - currentYardId: schedule.destinationStationId, + currentYardId: settleYardId, }); + // Ledger: the wagon rode this schedule to its settle yard. + const slotAllocations = slot.allocations ?? []; + await manager.getRepository(WagonMovement).save( + manager.getRepository(WagonMovement).create({ + wagonId: wagon.id, + fromYardId: slot.boardYardId ?? schedule.originStationId, + toYardId: settleYardId, + trainScheduleId: scheduleId, + bookingId: slotAllocations[0]?.bookingId ?? null, + kind: slotAllocations.length + ? WagonMovementKind.Loaded + : WagonMovementKind.EmptyReposition, + occurredAt: now, + }), + ); } // Ensure a destination checkpoint exists so the timeline shows ARRIVED. @@ -1692,6 +2570,18 @@ export class TrainSchedulingService { } }); + // Customer tracking: the train reached the corridor's far end. Restricted + // to bookings destined for the FINAL yard — mid-corridor bookings get their + // arrival milestone from their own operator unload at their own yard. + if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') { + void this.completeMilestonesForScheduleBookings( + scheduleId, + [schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI'], + { destinationYardId: schedule.destinationStationId }, + ); + } + void this.notifyScheduleBookings(schedule, 'arrived'); + const detail = await this.getTrainScheduleById(scheduleId); const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId); return Object.assign(detail, { warehouseAutomation }); @@ -1706,7 +2596,8 @@ export class TrainSchedulingService { destinationStation: true, scheduleBookings: { booking: true }, }, - order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' }, + // Newest-created first (the client can re-sort; this is the default order). + order: { createdAt: 'DESC', scheduledDepartureDate: 'DESC' }, }); return schedules.map((s) => this.mapScheduleListItem(s)); } @@ -1725,17 +2616,29 @@ export class TrainSchedulingService { await this.trainSchedulesRepository.updateStatus( id, TrainScheduleStatusEnum.Cancelled, - {}, + // Retire the booking window so a canceled schedule never lingers as an + // "open window" in booking-window lists or the legacy batch fill. + { bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' }, manager, ); if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' }); } + // Locomotives are only ASSIGNED while out on a dispatched train. Release ours, + // but never stomp a locomotive that is currently pulling another dispatched train. const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); if (cancelledLocoIds.length) { - await manager - .getRepository(Locomotive) - .update({ id: In(cancelledLocoIds) }, { status: 'AVAILABLE' }); + const busyElsewhere = await this.findLocomotiveIdsDispatchedElsewhere( + cancelledLocoIds, + id, + manager, + ); + const releasable = cancelledLocoIds.filter((locoId) => !busyElsewhere.has(locoId)); + if (releasable.length) { + await manager + .getRepository(Locomotive) + .update({ id: In(releasable), status: 'ASSIGNED' }, { status: 'AVAILABLE' }); + } } for (const wagon of schedule.trainSet?.wagons ?? []) { if (wagon.physicalWagonId) { @@ -1756,6 +2659,8 @@ export class TrainSchedulingService { } }); + // Window retired (DONE) — remove the card from portal/GL lists right away. + void this.emitWindowState(id); return this.getTrainScheduleById(id); } @@ -1824,17 +2729,26 @@ export class TrainSchedulingService { } if ( - bookings.some((b) => { - if (targetScheduleId && b.trainScheduleId === targetScheduleId) { - return false; + await (async () => { + // Corridor-aware: a booking belongs on this train when its origin and + // destination lie on the schedule's stop list in order — sub-corridor + // bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. + let stops = [dto.originStationId, dto.destinationStationId]; + if (targetScheduleId) { + const target = await this.trainSchedulesRepository.findById(targetScheduleId); + if (target) stops = await this.stopYardsForSchedule(target); } - return ( - b.originYardId !== dto.originStationId || - b.destinationYardId !== dto.destinationStationId - ); - }) + return bookings.some((b) => { + if (targetScheduleId && b.trainScheduleId === targetScheduleId) { + return false; + } + const fromIdx = stops.indexOf(b.originYardId); + const toIdx = stops.indexOf(b.destinationYardId); + return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx; + }); + })() ) { - violations.push('Selected bookings must share the same origin and destination as the schedule'); + violations.push('Selected bookings must lie on the schedule route (origin before destination)'); } if (!forceAssign) { @@ -1884,7 +2798,37 @@ export class TrainSchedulingService { } const originYardId = dto.originStationId; - const fleetCounts = await this.countFleetAvailability(originYardId, targetScheduleId); + // Dynamic consist: a slot's physical wagon may ride from the train's origin + // OR already sit at the booking's own boarding yard and attach there — so + // the usable fleet is the union across the origin and every boarding yard. + const boardYardIds = [ + ...new Set( + [originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean), + ), + ]; + const fleetCountsByYard = await Promise.all( + boardYardIds.map((yardId) => + this.countFleetAvailability(yardId, targetScheduleId), + ), + ); + const mergedFleet = new Map(); + for (const rows of fleetCountsByYard) { + for (const row of rows) { + const existing = mergedFleet.get(row.wagonTypeId) ?? { + code: row.wagonTypeCode, + available: 0, + }; + existing.available += row.available; + mergedFleet.set(row.wagonTypeId, existing); + } + } + const fleetCounts = [...mergedFleet.entries()].map( + ([wagonTypeId, value]) => ({ + wagonTypeId, + wagonTypeCode: value.code, + available: value.available, + }), + ); const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available])); fleetAvailability = computeFleetAvailability( demandPlan, @@ -1909,6 +2853,12 @@ export class TrainSchedulingService { containerWagonType, bulkWagonType, }); + this.stampSlotLegs( + wagonPlan, + fittingBookings, + dto.originStationId, + dto.destinationStationId, + ); violations.push( ...(await this.validatePhysicalFleetForPlan( @@ -1923,9 +2873,16 @@ export class TrainSchedulingService { max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons, }; + // With forceAssign, capacity-shaped rules (train limits, total weight, + // locomotive capability) become warnings — staff owns the override. Physical + // impossibilities (no wagon of the required type at the yard, wrong route, + // wrong status) can never be forced and stay violations. + const pushLimit = (issues: string[]) => + forceAssign ? warnings.push(...issues) : violations.push(...issues); + if (resolvedMode === 'MIXED') { - violations.push( - ...validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), + pushLimit( + validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), ); if (requireContainerPlacements) { const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER'); @@ -1942,7 +2899,7 @@ export class TrainSchedulingService { ); } } else { - violations.push(...validateTrainLimits(wagonPlan, wagonType, trainLimits)); + pushLimit(validateTrainLimits(wagonPlan, wagonType, trainLimits)); if (requireContainerPlacements && resolvedMode === 'CONTAINER') { violations.push( @@ -1965,8 +2922,8 @@ export class TrainSchedulingService { ); if (totalWeightTons > trainLimits.maxWeightTons) { const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`; - if (!violations.includes(message)) { - violations.push(message); + if (!violations.includes(message) && !warnings.includes(message)) { + pushLimit([message]); } } @@ -1978,39 +2935,42 @@ export class TrainSchedulingService { } if (assignedLocomotives.length) { - // Every locomotive of the set must sit at the origin yard, and the weakest - // one must still be able to pull the train (min limits across the set). + // Advance scheduling: a locomotive that hasn't reached the origin yard yet is a + // warning (it must arrive before dispatch), but a set too weak to pull the train + // is a hard violation. const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId); const setLimits = minLocomotiveLimits(assignedLocomotives); if (offYard) { - violations.push( - `Locomotive ${offYard.code} is not at the schedule origin yard`, + warnings.push( + `Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`, ); - } else if ( + } + if ( setLimits && (setLimits.maxPullWeightTons < totalWeightTons || setLimits.maxTrainLengthMeters < totalLengthMeters) ) { - violations.push( + pushLimit([ 'Assigned locomotives cannot support the total train weight and length', - ); + ]); } } else { - const availableLocomotives = ( - await this.locomotivesRepository.findAll({ - where: { status: 'AVAILABLE' }, - }) - ).filter((l) => l.currentYardId === originYardId); - if (!availableLocomotives.length) { - violations.push('No available locomotive at the schedule origin yard'); - } else if ( - !availableLocomotives.some( + const inServiceLocomotives = await this.locomotivesRepository.findAll({ + where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) }, + }); + if (!inServiceLocomotives.some((l) => l.currentYardId === originYardId)) { + warnings.push( + 'No locomotive is at the schedule origin yard yet; one must arrive before dispatch', + ); + } + if ( + !inServiceLocomotives.some( (l) => Number(l.maxPullWeightTons) >= totalWeightTons && Number(l.maxTrainLengthMeters) >= totalLengthMeters, ) ) { - violations.push('No available locomotive can support the total train weight and length'); + pushLimit(['No locomotive can support the total train weight and length']); } } @@ -2042,7 +3002,16 @@ export class TrainSchedulingService { take: 1, }); return rows[0] ?? null; - } catch { + } catch (err) { + // A read failure here silently downgrades every booking window to the + // hardcoded defaults (desk 8–17, duration 3h, lead 3) while the settings + // UI keeps showing the saved row — a maddening mismatch. The usual cause + // is a missing column (migrations not run on this database). Scream. + this.logger.error( + `Failed to read train-scheduling global rules — booking windows are ` + + `running on HARDCODED DEFAULTS (8–17). Run pending migrations. ` + + `Cause: ${(err as Error).message}`, + ); return null; } } @@ -2217,6 +3186,7 @@ export class TrainSchedulingService { wagonTypeId: slot.wagonTypeId, wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId, trainSetWagonId: slot.id, + boardYardId: slot.boardYardId ?? null, })); const unpinnable = this.findUnpinnableWagonSlots( @@ -2270,6 +3240,7 @@ export class TrainSchedulingService { sequenceNo: slot.sequenceNo, wagonTypeId: slot.wagonTypeId, wagonTypeCode: slot.wagonTypeCode, + boardYardId: slot.boardYardId ?? null, })), wagons, targetScheduleId, @@ -2278,7 +3249,12 @@ export class TrainSchedulingService { } private findUnpinnableWagonSlots( - slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>, + slots: Array<{ + sequenceNo: number; + wagonTypeId: string; + wagonTypeCode: string; + boardYardId?: string | null; + }>, wagons: Wagon[], scheduleId: string | undefined, originYardId: string, @@ -2306,22 +3282,35 @@ export class TrainSchedulingService { return violations; } + /** + * Dynamic consist: a slot's wagon may either ride from the train's origin + * yard (attaching there, possibly empty until the slot's board yard) or + * already sit AT the slot's board yard and hook on when the train arrives. + */ private pickPhysicalWagonForSlot( - slot: { wagonTypeId: string }, + slot: { wagonTypeId: string; boardYardId?: string | null }, wagons: Wagon[], scheduleId: string | undefined, originYardId: string, assignedPhysicalIds: Set, ): Wagon | undefined { - return wagons.find((wagon) => { + const usable = (wagon: Wagon): boolean => { if (wagon.wagonTypeId !== slot.wagonTypeId) return false; if (assignedPhysicalIds.has(wagon.id)) return false; const pinnedOnSchedule = scheduleId ? wagon.currentTrainScheduleId === scheduleId : false; - if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false; - return wagon.currentYardId === originYardId; - }); + return wagon.status === WagonStatus.Available || pinnedOnSchedule; + }; + // Prefer a wagon already waiting at the slot's board yard (no empty haul); + // fall back to one riding from the train's origin. + if (slot.boardYardId) { + const atBoardYard = wagons.find( + (w) => usable(w) && w.currentYardId === slot.boardYardId, + ); + if (atBoardYard) return atBoardYard; + } + return wagons.find((w) => usable(w) && w.currentYardId === originYardId); } private positiveNumber(value: number | undefined, fallback: number): number { @@ -2379,28 +3368,134 @@ export class TrainSchedulingService { return violations; } + /** + * Resolve the wagon type for a batch through the cargo-type / container-type + * `wagon_type_id` FK (replaces the former load-type string matching). Throws + * when the relevant type has no wagon type configured — scheduling is blocked + * until an admin assigns one on the cargo-type / container-type config screen. + */ private async resolveWagonType( freightType: 'CONTAINER' | 'BULK', bookingIds: string[], ): Promise { + const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds); + if (freightType === 'CONTAINER') { - const [wagonType] = await this.wagonTypesRepository.findAll({ - where: { code: getDefaultContainerWagonTypeCode(), isActive: true }, - }); - if (!wagonType) { - throw new NotFoundException(`Wagon type ${getDefaultContainerWagonTypeCode()} not found`); + // First container type present on the batch drives the container wagon + // type (matches the prior single-wagon-type-per-consist behavior). + const containerType = bookings + .flatMap((b) => b.bookingContainers ?? []) + .map((line) => line.containerType) + .find((ct): ct is NonNullable => Boolean(ct)); + if (!containerType) { + throw new BadRequestException('No container type found on the container booking(s)'); } + const wagonType = await this.loadWagonTypeForType( + containerType.wagonTypeId ?? null, + `Container type "${containerType.label ?? containerType.code}"`, + ); return wagonType; } - const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds); - const cargoCode = bookings[0]?.cargoType?.code ?? null; - const wagonTypes = await this.wagonTypesRepository.findAll({ where: { isActive: true } }); - const picked = pickBulkWagonType(wagonTypes, cargoCode); - if (!picked) { - throw new NotFoundException('No suitable bulk wagon type found'); + const cargoType = bookings.map((b) => b.cargoType).find((ct) => Boolean(ct)); + if (!cargoType) { + throw new BadRequestException('No cargo type found on the bulk booking(s)'); + } + return this.loadWagonTypeForType( + cargoType.wagonTypeId ?? null, + `Cargo type "${cargoType.cargoTypeName ?? cargoType.code}"`, + ); + } + + /** + * Load an active wagon type by FK id, throwing a clear error when the id is + * unset (type not configured) or points at a missing/inactive wagon type. + */ + private async loadWagonTypeForType( + wagonTypeId: string | null, + typeLabel: string, + ): Promise { + if (!wagonTypeId) { + throw new BadRequestException( + `${typeLabel} has no wagon type configured — set one on its configuration before scheduling.`, + ); + } + const [wagonType] = await this.wagonTypesRepository.findAll({ + where: { id: wagonTypeId, isActive: true }, + }); + if (!wagonType) { + throw new NotFoundException( + `${typeLabel} references wagon type ${wagonTypeId}, which was not found or is inactive.`, + ); + } + return wagonType; + } + + /** + * Soft wagon-type resolution for the customer-facing availability preview + * (getAvailableDaysForCargo). Reads the configured FK by cargo/container type; + * returns null (→ "no days") instead of throwing when nothing is configured, + * since this only estimates which days have wagons and creates no booking. + */ + private async resolveWagonTypeForPreview( + freightType: 'CONTAINER' | 'BULK', + cargoTypeCode: string | null, + ): Promise { + if (freightType === 'BULK') { + if (!cargoTypeCode) return null; + const cargoType = await this.dataSource.getRepository(CargoType).findOne({ + where: { code: cargoTypeCode }, + relations: { wagonType: true }, + }); + return cargoType?.wagonType?.isActive ? cargoType.wagonType : null; + } + + // Container preview: the input carries no specific container type, so use the + // wagon type of the first configured (active) container type. + const containerType = await this.dataSource + .getRepository(ContainerType) + .findOne({ + where: { isActive: true, wagonTypeId: Not(IsNull()) }, + relations: { wagonType: true }, + order: { displayOrder: 'ASC' }, + }); + return containerType?.wagonType?.isActive ? containerType.wagonType : null; + } + + /** + * Stamp each plan slot with the leg it occupies (dynamic consist): the + * boarding/alighting yards of the bookings it carries. Null means the + * schedule's own endpoint (whole-route slot, legacy behavior). A slot + * carrying bookings with mixed corridors stays whole-route (conservative). + */ + private stampSlotLegs( + wagonPlan: WagonPlanSlot[], + bookings: Booking[], + scheduleOriginYardId: string, + scheduleDestinationYardId: string, + ): void { + const bookingById = new Map(bookings.map((b) => [b.id, b])); + for (const slot of wagonPlan) { + const slotBookings = [ + ...new Set(slot.allocations.map((a) => a.bookingId)), + ] + .map((id) => bookingById.get(id)) + .filter((b): b is Booking => Boolean(b)); + if (!slotBookings.length) continue; + const [first] = slotBookings; + const sameCorridor = slotBookings.every( + (b) => + b.originYardId === first.originYardId && + b.destinationYardId === first.destinationYardId, + ); + if (!sameCorridor) continue; + slot.boardYardId = + first.originYardId === scheduleOriginYardId ? null : first.originYardId; + slot.alightYardId = + first.destinationYardId === scheduleDestinationYardId + ? null + : first.destinationYardId; } - return picked; } private async persistTrainSetWagons( @@ -2418,6 +3513,8 @@ export class TrainSchedulingService { lengthMeters: slot.lengthMeters, assignedWeightTons: slot.assignedWeightTons, status: 'PLANNED', + boardYardId: slot.boardYardId ?? null, + alightYardId: slot.alightYardId ?? null, }), ); return manager.getRepository(TrainSetWagon).save(wagons); @@ -2558,6 +3655,58 @@ export class TrainSchedulingService { return trainSet.locomotive ? [trainSet.locomotive] : []; } + /** + * Locomotive ids (among the given ones) that are attached to a DISPATCHED train + * other than `excludeScheduleId`. Covers both the multi-loco link rows and the + * legacy single-locomotive column on the train set. + */ + private async findLocomotiveIdsDispatchedElsewhere( + locomotiveIds: string[], + excludeScheduleId: string, + manager?: EntityManager, + ): Promise> { + if (!locomotiveIds.length) return new Set(); + const runner = manager ?? this.dataSource; + const rows: { locomotive_id: string }[] = await runner.query( + `SELECT DISTINCT loco.locomotive_id + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN ( + SELECT tsl.train_set_id, tsl.locomotive_id + FROM freight.train_set_locomotives tsl + WHERE tsl.deleted_at IS NULL + UNION + SELECT t.id AS train_set_id, t.locomotive_id + FROM freight.train_sets t + WHERE t.locomotive_id IS NOT NULL + ) loco ON loco.train_set_id = tset.id + WHERE ts.status = 'DISPATCHED' + AND ts.deleted_at IS NULL + AND ts.id <> $1 + AND loco.locomotive_id = ANY($2)`, + [excludeScheduleId, locomotiveIds], + ); + return new Set(rows.map((r) => r.locomotive_id)); + } + + private async assertLocomotivesNotDispatchedElsewhere( + locomotiveIds: string[], + excludeScheduleId: string, + ): Promise { + const busy = await this.findLocomotiveIdsDispatchedElsewhere( + locomotiveIds, + excludeScheduleId, + ); + if (!busy.size) return; + const locos = await this.dataSource + .getRepository(Locomotive) + .find({ where: { id: In([...busy]) } }); + const codes = locos.map((l) => l.code).join(', '); + throw new ConflictException( + `Locomotive(s) ${codes} are currently out on another dispatched train`, + ); + } + async selectOrValidateLocomotive( locomotiveId: string, totalWeightTons: number, @@ -2605,16 +3754,38 @@ export class TrainSchedulingService { return saved; } - private async getActiveRoute(routeId: string) { + private async getSchedulableRoute(routeId: string) { const route = await this.dataSource.getRepository(Route).findOne({ where: { id: routeId }, relations: { originYard: true, destinationYard: true }, }); if (!route) throw new NotFoundException(`Route ${routeId} not found`); - if (!route.isActive) throw new BadRequestException(`Route ${route.name} is inactive`); + if (route.status !== 'AVAILABLE') { + throw new BadRequestException( + `Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`, + ); + } + // Intercity (same-country) service is not offered yet — only import/export + // trains can be scheduled. + if (this.resolveRouteDirection(route) === 'DOMESTIC') { + throw new BadRequestException( + `Route ${formatRouteLabel(route)} is an intercity route; intercity scheduling is not available yet`, + ); + } return route; } + /** Stored route direction, deriving from yard countries for pre-migration rows. */ + private resolveRouteDirection(route: Route) { + return ( + route.direction ?? + deriveScheduleDirection( + route.originYard ?? { country: null }, + route.destinationYard ?? { country: null }, + ) + ); + } + private mapEligibleBooking(booking: Booking) { return { id: booking.id, @@ -2651,12 +3822,44 @@ export class TrainSchedulingService { return null; } + /** + * Insert a schedule with a freshly generated S--NNNNN reference, retrying + * past a concurrent insert that grabbed the same sequence (the unique index + * rejects the loser). Mirrors insertWithGeneratedReference for bookings, but + * runs inside the caller's transaction manager so the row joins the same commit. + */ + private async insertScheduleWithReference( + manager: EntityManager, + build: (reference: string) => TrainSchedule, + ): Promise { + const year = new Date().getFullYear(); + const repo = manager.getRepository(TrainSchedule); + for (let attempt = 0; attempt < 5; attempt += 1) { + const seq = await this.trainSchedulesRepository.maxReferenceSequence(year); + const reference = `S-${year}-${String(seq + 1).padStart(5, '0')}`; + try { + return await repo.save(build(reference)); + } catch (err) { + // 23505 = unique_violation on ux_train_schedules_reference; re-read + retry. + const code = (err as { driverError?: { code?: string } })?.driverError?.code; + if (err instanceof QueryFailedError && code === '23505' && attempt < 4) { + continue; + } + throw err; + } + } + // Unreachable — the loop either returns or throws — but satisfies the compiler. + throw new ConflictException('Could not allocate a unique schedule reference'); + } + private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) { return { id: schedule.id, + reference: schedule.reference ?? null, + createdAt: schedule.createdAt ?? null, scheduleDate: schedule.scheduledDepartureDate, trainNumber: schedule.trainNumber ?? null, - routeName: schedule.route?.name ?? null, + routeName: schedule.route ? formatRouteLabel(schedule.route) : null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, @@ -2690,15 +3893,217 @@ export class TrainSchedulingService { } /** AVAILABLE locomotives at the route's origin yard. */ - async getAvailableLocomotivesForRoute(routeId: string): Promise { - const route = await this.getActiveRoute(routeId); + /** + * All in-service locomotives, annotated for the schedule-creation picker. + * Advance scheduling means nothing is filtered out — staff see status, whether the + * locomotive is at the origin yard yet, and how many future schedules it already has. + */ + async getAvailableLocomotivesForRoute(routeId: string) { + const route = await this.getSchedulableRoute(routeId); const locomotives = await this.locomotivesRepository.findAll({ - where: { status: 'AVAILABLE', currentYardId: route.originYardId }, + where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) }, order: { code: 'ASC' }, }); - return locomotives; + const counts: { locomotive_id: string; future_count: string }[] = locomotives.length + ? await this.dataSource.query( + `SELECT loco.locomotive_id, COUNT(DISTINCT ts.id) AS future_count + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN ( + SELECT tsl.train_set_id, tsl.locomotive_id + FROM freight.train_set_locomotives tsl + WHERE tsl.deleted_at IS NULL + UNION + SELECT t.id AS train_set_id, t.locomotive_id + FROM freight.train_sets t + WHERE t.locomotive_id IS NOT NULL + ) loco ON loco.train_set_id = tset.id + WHERE ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.deleted_at IS NULL + AND loco.locomotive_id = ANY($1) + GROUP BY loco.locomotive_id`, + [locomotives.map((l) => l.id)], + ) + : []; + const futureCounts = new Map(counts.map((c) => [c.locomotive_id, Number(c.future_count)])); + + return locomotives.map((loco) => ({ + ...loco, + atOriginYard: loco.currentYardId === route.originYardId, + futureScheduleCount: futureCounts.get(loco.id) ?? 0, + })); + } + + /** + * Upcoming/open booking windows announced on the portal home "booking + * windows" section. ALL window-engine schedules (IMPORT cycle / EXPORT lead) + * are listed so every customer sees what is opening — not just those on their + * contract lanes; DOMESTIC trains are always open and need no announcement. + * + * When `companyId` is given, a matching active contract on the lane is + * LEFT-JOINed in so the row carries `contractId`/`contractKind` (enabling + * "Book now"); customers with no covering contract still see the window with a + * null contract, and the portal routes them to the contract list to get one. + */ + async getBookingWindowsForCompany(companyId: string | null) { + const rows: Array = await this.dataSource.query( + `SELECT DISTINCT ON (ts.id) + ts.id AS schedule_id, + ts.reference AS reference, + cr.contract_id AS contract_id, + c.contract_kind AS contract_kind, + ts.direction, + ts.window_phase, + ts.window_opens_at, + ts.window_closes_at, + ts.doc_review_ends_at, + ts.payment_phase_ends_at, + ts.booking_window_status, + ts.booking_cycle_no, + ts.scheduled_departure_date, + oy.label AS origin_label, oy.code AS origin_code, + dy.label AS destination_label, dy.code AS destination_code + FROM freight.train_schedules ts + LEFT JOIN freight.contract_routes cr + ON cr.origin_yard_id = ts.origin_station_id + AND cr.destination_yard_id = ts.destination_station_id + AND cr.deleted_at IS NULL + LEFT JOIN freight.contracts c + ON c.id = cr.contract_id + AND c.company_id = $1 + AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') + AND c.deleted_at IS NULL + 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 IN ('DRAFT', 'SCHEDULED') + AND ts.window_phase IS NOT NULL + AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') + AND ts.scheduled_departure_date >= now() + ORDER BY ts.id, c.id NULLS LAST, ts.scheduled_departure_date ASC NULLS LAST`, + [companyId], + ); + // Nearest dispatch (departure) date first — the DISTINCT ON above forces a + // per-row ordering, so re-sort the mapped rows by departure for the client. + return rows + .map((r) => this.mapBookingWindowRow(r)) + .sort((a, b) => { + const ta = a.departureDate ? new Date(a.departureDate).getTime() : Infinity; + const tb = b.departureDate ? new Date(b.departureDate).getTime() : Infinity; + return ta - tb; + }); + } + + /** + * Upcoming/open booking windows on a single contract's routes. Used to gate the + * booking form for the customer AND Ethiopian GL (who books on the customer's + * behalf): no window row with isOpenNow=true → booking entry is hidden. + */ + async getBookingWindowsForContract(contractId: string) { + const rows: Array = await this.dataSource.query( + `SELECT DISTINCT ts.id AS schedule_id, + ts.reference AS reference, + cr.contract_id AS contract_id, + c.contract_kind AS contract_kind, + ts.direction, + ts.window_phase, + ts.window_opens_at, + ts.window_closes_at, + ts.doc_review_ends_at, + ts.payment_phase_ends_at, + ts.booking_window_status, + ts.booking_cycle_no, + ts.scheduled_departure_date, + oy.label AS origin_label, oy.code AS origin_code, + dy.label AS destination_label, dy.code AS destination_code + FROM freight.train_schedules ts + JOIN freight.contract_routes cr + ON cr.origin_yard_id = ts.origin_station_id + AND cr.destination_yard_id = ts.destination_station_id + AND cr.contract_id = $1 + AND cr.deleted_at IS NULL + JOIN freight.contracts c + ON c.id = cr.contract_id + AND c.deleted_at IS NULL + 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 IN ('DRAFT', 'SCHEDULED') + AND ts.window_phase IS NOT NULL + AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') + AND ts.scheduled_departure_date >= now() + ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, + [contractId], + ); + return rows.map((r) => this.mapBookingWindowRow(r)); + } + + /** + * All announced booking windows across every lane — import window cycles AND + * export FCFS lead windows — for staff dashboards (GL clearance queue). Same + * phase filter as the customer-facing lists, no contract scoping. + */ + async listAllBookingWindows() { + const rows: Array< + Omit & { + train_number: string | null; + } + > = await this.dataSource.query( + `SELECT ts.id AS schedule_id, + ts.reference AS reference, + ts.train_number, + ts.direction, + ts.window_phase, + ts.window_opens_at, + ts.window_closes_at, + ts.doc_review_ends_at, + ts.payment_phase_ends_at, + ts.booking_window_status, + ts.booking_cycle_no, + ts.scheduled_departure_date, + oy.label AS origin_label, oy.code AS origin_code, + dy.label AS destination_label, dy.code AS destination_code + 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 IN ('DRAFT', 'SCHEDULED') + AND ts.window_phase IS NOT NULL + AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') + AND ts.scheduled_departure_date >= now() + ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, + ); + return rows.map((r) => ({ + ...this.mapBookingWindowRow({ + ...r, + contract_id: null, + contract_kind: null, + }), + trainNumber: r.train_number, + })); + } + + private mapBookingWindowRow(r: BookingWindowRow) { + return { + scheduleId: r.schedule_id, + reference: r.reference ?? null, + contractId: r.contract_id, + contractKind: r.contract_kind, + direction: r.direction, + windowPhase: r.window_phase, + isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN', + windowOpensAt: r.window_opens_at, + windowClosesAt: r.window_closes_at, + docReviewEndsAt: r.doc_review_ends_at, + paymentPhaseEndsAt: r.payment_phase_ends_at, + bookingWindowStatus: r.booking_window_status, + bookingCycleNo: r.booking_cycle_no, + departureDate: r.scheduled_departure_date, + origin: r.origin_label ?? r.origin_code ?? null, + destination: r.destination_label ?? r.destination_code ?? null, + }; } /** OPEN schedules a new booking may target (with rough remaining capacity). @@ -2731,7 +4136,18 @@ export class TrainSchedulingService { order: { scheduledDepartureDate: 'ASC' }, }); + // A train that has already departed can never be booked, even if the window + // engine hasn't yet flipped its bookingWindowStatus off OPEN. Mirror the + // `scheduled_departure_date >= now()` guard the booking-window SQL uses so a + // past-departure schedule never leaks into the portal day pool, the schedule + // calendar, or the ET GL create-booking gate. + const now = new Date(); return schedules + .filter( + (s) => + s.scheduledDepartureDate != null && + s.scheduledDepartureDate > now, + ) .filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status)) .filter((s) => { // Build the full stop list: origin -> milestones (ordered) -> destination @@ -2821,39 +4237,54 @@ export class TrainSchedulingService { ); if (schedules.length === 0) return { days: [] }; - const wagonTypes = await this.dataSource.getRepository(WagonType).find(); - - // Resolve the wagon type this cargo needs. - const requiredType = - input.freightType === 'BULK' - ? pickBulkWagonType(wagonTypes, input.cargoTypeCode) - : wagonTypes.find( - (wt) => wt.code === getDefaultContainerWagonTypeCode() && wt.isActive, - ); + // Resolve the wagon type this cargo needs via the cargo/container-type FK. + // Soft (customer availability preview): no days if unresolved, never throws. + const requiredType = await this.resolveWagonTypeForPreview( + input.freightType, + input.cargoTypeCode ?? null, + ); if (!requiredType) return { days: [] }; // How many wagons of that type the cargo needs. const slotsNeeded = this.wagonsNeededForCargo(input, requiredType); + void slotsNeeded; // TEMP: unused while the wagon-availability filter is off. - // AVAILABLE wagons of the required type, counted once per origin yard. - const availableByYard = new Map(); - const availableAt = async (yardId: string): Promise => { - const cached = availableByYard.get(yardId); - if (cached !== undefined) return cached; - const counts = await this.countFleetAvailability(yardId); - const n = - counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0; - availableByYard.set(yardId, n); - return n; - }; + // TEMP (per request): wagon-availability filtering is DISABLED. A day is now + // offered whenever a bookable schedule that day has remaining train capacity + // — regardless of whether matching wagons are actually available at the + // origin / boarding yard. This surfaces days even when no wagon is on hand. + // Restore the block below to bring back the "enough matching wagons" gate. + // + // // AVAILABLE wagons of the required type, counted once per origin yard. + // const availableByYard = new Map(); + // const availableAt = async (yardId: string): Promise => { + // const cached = availableByYard.get(yardId); + // if (cached !== undefined) return cached; + // const counts = await this.countFleetAvailability(yardId); + // const n = + // counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0; + // availableByYard.set(yardId, n); + // return n; + // }; const days = new Set(); for (const s of schedules) { const hasCapacity = Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0; if (!hasCapacity) continue; - const enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded; - if (!enoughWagons) continue; + // TEMP (per request): wagon-availability check commented out — see note + // above. Dynamic consist: wagons may ride from the train's origin OR + // already sit at the booking's own boarding yard and attach when the train + // arrives — either pool can serve a sub-corridor booking. + // let enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded; + // if ( + // !enoughWagons && + // input.originYardId && + // input.originYardId !== s.originStationId + // ) { + // enoughWagons = (await availableAt(input.originYardId)) >= slotsNeeded; + // } + // if (!enoughWagons) continue; if (s.scheduledDepartureDate) days.add(eatDay(new Date(s.scheduledDepartureDate))); } @@ -2885,6 +4316,33 @@ export class TrainSchedulingService { return Math.max(1, Math.ceil(teu / 2)); } + /** + * Ordered stop yards of a schedule's route: origin → milestones → destination, + * de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule + * has no route milestones. Shared by corridor (sub-leg) validation everywhere. + */ + async stopYardsForSchedule(schedule: TrainSchedule): Promise { + let milestoneYards: string[] = []; + if (schedule.route?.milestones?.length) { + milestoneYards = [...schedule.route.milestones] + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((m) => m.yardId); + } else if (schedule.routeId) { + const milestones = await this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } }); + milestoneYards = milestones.map((m) => m.yardId); + } + const raw = milestoneYards.length >= 2 + ? milestoneYards + : [schedule.originStationId, ...milestoneYards, schedule.destinationStationId]; + const unique: string[] = []; + for (const yardId of raw) { + if (yardId && !unique.includes(yardId)) unique.push(yardId); + } + return unique; + } + /** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */ async existsOpenScheduleOnRouteDay( originYardId: string, @@ -2895,12 +4353,75 @@ export class TrainSchedulingService { return days.includes(day); } + /** + * Enforce the config-driven booking window at booking-create time. + * + * A booking is only allowed when the route has an OPEN departure the customer + * can join for the requested day — which, because the window engine keeps + * `bookingWindowStatus === 'OPEN'` in lockstep with the live window, means: + * - IMPORT: the day's window is currently open (opens at `windowOpenHour` EAT, + * `importWindowLeadDays` before departure, for `windowDurationHours`). + * - EXPORT: now is within `exportBookingLeadHours` before that departure (FCFS). + * + * `getBookableScheduleEntities` filters on `bookingWindowStatus === 'OPEN'`, so + * both gates are satisfied by checking that route for open departures. When a + * specific day is requested, require an open departure on that EAT day; when no + * day is given, require at least one open departure on the route at all. + * Throws `BadRequestException` when the window is closed. No-ops when the route + * yards are unknown (nothing to gate against). + */ + async assertBookingWindowOpen(input: { + originYardId?: string | null; + destinationYardId?: string | null; + scheduledDate?: Date | string | null; + direction?: string | null; + }): Promise { + const { originYardId, destinationYardId } = input; + if (!originYardId || !destinationYardId) return; + + const { days } = await this.getAvailableDays(originYardId, destinationYardId); + if (days.length === 0) { + throw new BadRequestException( + input.direction === 'EXPORT' + ? 'The export booking window for this route is not open yet' + : 'The import booking window for this route is closed right now', + ); + } + + if (input.scheduledDate) { + const day = eatDay(new Date(input.scheduledDate)); + if (!days.includes(day)) { + throw new BadRequestException( + input.direction === 'EXPORT' + ? 'No departure is within the export booking window on the selected day' + : 'The import booking window is not open for the selected day', + ); + } + } + } + private async mapScheduleDetail( schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, ) { - const allocationIds = (schedule.trainSet?.wagons ?? []) - .flatMap((w) => w.allocations ?? []) - .map((a) => a.id); + const allocations = (schedule.trainSet?.wagons ?? []).flatMap( + (w) => w.allocations ?? [], + ); + const allocationIds = allocations.map((a) => a.id); + const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId)); + + // Import-from-Djibouti trains can only dispatch once loading is confirmed + // (loadedOnTrainAt on the operation). Other directions have no departure + // loading gate, so the workspace shows the confirm button as already done. + const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule); + let loadingConfirmed = !requiresLoadingConfirmation; + if (requiresLoadingConfirmation) { + const op = await this.dataSource + .getRepository(ImportDjiboutiOperation) + .findOne({ where: { trainScheduleId: schedule.id } }); + loadingConfirmed = Boolean(op?.loadedOnTrainAt); + } + + const windowCfg = await this.getWindowConfig(); const [containerItems, bulkLoads] = await Promise.all([ allocationIds.length @@ -2929,11 +4450,48 @@ export class TrainSchedulingService { return { id: schedule.id, + reference: schedule.reference ?? null, status: schedule.status, freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, direction: schedule.direction ?? null, - route: schedule.route ? { id: schedule.route.id, name: schedule.route.name } : null, + requiresLoadingConfirmation, + loadingConfirmed, + // Booking-window phase + phase deadlines drive the countdown timers in the + // operations workspace (display only — the window engine enforces them). + windowPhase: schedule.windowPhase ?? null, + windowOpensAt: schedule.windowOpensAt + ? schedule.windowOpensAt.toISOString() + : null, + windowClosesAt: schedule.windowClosesAt + ? schedule.windowClosesAt.toISOString() + : null, + docReviewEndsAt: schedule.docReviewEndsAt + ? schedule.docReviewEndsAt.toISOString() + : null, + paymentPhaseEndsAt: schedule.paymentPhaseEndsAt + ? schedule.paymentPhaseEndsAt.toISOString() + : null, + // Per-schedule booking-window rule snapshot — powers the "Booking window + // settings" editor on the ops board (prefill + save one schedule's + // override). docReview/payment are not snapshotted per schedule (only their + // sum, as reopenDelayMinutes), so the editor prefills them from live config. + windowRule: { + windowOpenHour: schedule.ruleWindowOpenHour ?? null, + windowCloseHour: schedule.ruleWindowCloseHour ?? null, + windowDurationHours: + schedule.ruleWindowDurationHours != null + ? Number(schedule.ruleWindowDurationHours) + : null, + reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null, + importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null, + exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null, + docReviewMinutes: windowCfg.docReviewMinutes, + paymentWindowMinutes: windowCfg.paymentWindowMinutes, + }, + route: schedule.route + ? { id: schedule.route.id, name: formatRouteLabel(schedule.route) } + : null, scheduledDepartureDate: schedule.scheduledDepartureDate, scheduledArrivalDate: schedule.scheduledArrivalDate, actualDepartureAt: schedule.actualDepartureAt ?? null, @@ -3024,6 +4582,11 @@ export class TrainSchedulingService { status: sb.booking?.status ?? null, schedulingStatus: sb.booking?.schedulingStatus ?? null, freightType: sb.booking?.freightType ?? null, + // Loaded/unloaded is tracked on the schedule↔booking link, not the + // booking itself — staff flip it per booking in the workspace before + // dispatch. Defaults UNLOADED for links written before the column. + loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded, + wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId), })) ?? [], }; } @@ -3093,7 +4656,7 @@ export class TrainSchedulingService { if (!validation.valid) { throw new BadRequestException({ - message: 'Booking validation failed', + message: `Booking validation failed: ${validation.violations.join('; ')}`, violations: validation.violations, warnings: validation.warnings, }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 8c3199461..21dd7b985 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -38,6 +38,13 @@ export type WagonPlanSlot = { assignedWeightTons: number; allocations: WagonAllocationRecord[]; slotLoadType?: SlotLoadType; + /** + * Leg occupancy for sub-corridor bookings (dynamic consist): the slot boards + * at boardYardId and alights at alightYardId. Null = the schedule's own + * endpoint (whole-route slot, legacy behavior). + */ + boardYardId?: string | null; + alightYardId?: string | null; }; export type ContainerUnitRow = { @@ -210,7 +217,14 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5)); const perWagon = containersPerWagonFromType(wagonsPerUnit); const teuSlots = teuSlotsForSizeFt(sizeFt); + // The REAL per-container numbers/weights entered at booking time. Unit i of + // the line maps to units[i] (sortOrder order); the line-level number is only + // a legacy fallback — never invent numbers here. + const units = [...(line.units ?? [])].sort( + (a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0), + ); for (let i = 0; i < qty; i += 1) { + const unit = units[i]; rows.push({ bookingId: booking.id, bookingReference: booking.reference, @@ -219,12 +233,13 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR containerTypeId: line.containerTypeId ?? '', containerTypeCode: code, label: `${booking.reference} · ${i + 1}/${qty} · ${code}`, - grossWeightTons: Number(line.vgmPerUnitTons), + grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons), sizeFt, wagonsPerUnit, containersPerWagon: perWagon, teuSlots, - containerNumber: line.containerNumber ?? null, + containerNumber: + unit?.containerNumber?.trim() || line.containerNumber || null, }); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts deleted file mode 100644 index bac0330f2..000000000 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { WagonType } from '../wagon-types/entities/wagon-type.entity'; - -const CARGO_CODE_TO_WAGON_TYPE: Record = { - COFFEE: 'KW2', - GRAIN: 'KW2', - WHEAT: 'KW2', - SORGHUM: 'KW2', - CORN: 'KW2', - FERTILIZER: 'PW2', - SUGAR: 'PW2', - COAL: 'KW3', - STEEL: 'CW3', - ORE: 'CW3', -}; - -const DEFAULT_BULK_WAGON_TYPE = 'CW3'; -const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5'; - -/** - * Resolve wagon type code from cargo type code for bulk freight. - */ -export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string { - if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE; - const normalized = cargoTypeCode.trim().toUpperCase(); - return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE; -} - -/** - * Pick the best matching wagon type entity for bulk cargo. - */ -export function pickBulkWagonType( - wagonTypes: WagonType[], - cargoTypeCode?: string | null, -): WagonType | undefined { - const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode); - const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive); - if (direct) return direct; - - return wagonTypes.find( - (wt) => - wt.isActive && - !wt.supportsContainer && - wt.code !== DEFAULT_CONTAINER_WAGON_TYPE, - ); -} - -export function getDefaultContainerWagonTypeCode(): string { - return DEFAULT_CONTAINER_WAGON_TYPE; -} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts index a220218e0..5deedb12d 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts @@ -55,6 +55,17 @@ export class TrainSetWagon extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' }) status!: string; + // ── Leg occupancy (segment corridor bookings) ────────────────────────────── + // A slot may occupy only part of the route: it boards (attaches/loads) at + // board_yard_id and alights (unloads/detaches) at alight_yard_id. NULL on both + // means the slot rides the whole route (legacy full-route bookings). Slots + // whose legs don't overlap coexist without consuming each other's capacity. + @Column({ name: 'board_yard_id', type: 'uuid', nullable: true }) + boardYardId?: string | null; + + @Column({ name: 'alight_yard_id', type: 'uuid', nullable: true }) + alightYardId?: string | null; + @OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon) allocations?: WagonBookingAllocation[]; } diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index b5f9abcb8..33d441ecb 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -1,5 +1,5 @@ import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator'; -import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity'; +import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity'; export class CreateVehicleDto { @IsString() @@ -26,6 +26,10 @@ export class CreateVehicleDto { @IsEnum(VehicleStatus) status!: VehicleStatus; + @IsOptional() + @IsEnum(VehicleAvailability) + availability?: VehicleAvailability; + @IsOptional() @IsString() description?: string; @@ -57,4 +61,16 @@ export class CreateVehicleDto { @IsOptional() @IsNumber() actualDistanceKm?: number; + + @IsOptional() + @IsUUID() + locationId?: string; + + @IsOptional() + @IsNumber() + pricePerKm?: number; + + @IsOptional() + @IsString() + currency?: string; } diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index fef078ef8..534019bc4 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -25,11 +25,25 @@ export enum VehicleStatus { OUT_OF_SERVICE = 'OUT_OF_SERVICE', } +export enum VehicleAvailability { + FREE = 'FREE', + BUSY = 'BUSY', +} + @Entity({ name: 'vehicles', schema: 'freight' }) export class Vehicle extends BaseEntity { + @Column({ nullable: true }) + code?: string; + @Column({ name: 'plate_number', unique: true, nullable: true }) plateNumber?: string; + @Column({ name: 'power_plate_no', nullable: true }) + powerPlateNo?: string; + + @Column({ name: 'trailer_plate_no', nullable: true }) + trailerPlateNo?: string; + @Column({ name: 'registration_number', unique: true, nullable: true }) registrationNumber?: string; @@ -54,6 +68,9 @@ export class Vehicle extends BaseEntity { @Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE, nullable: true }) status?: VehicleStatus; + @Column({ name: 'availability', type: 'varchar', default: VehicleAvailability.FREE, nullable: true }) + availability?: VehicleAvailability; + @Column({ type: 'text', nullable: true }) description?: string | null; @@ -68,4 +85,32 @@ export class Vehicle extends BaseEntity { @Column({ name: 'actual_distance_km', type: 'numeric', nullable: true }) actualDistanceKm?: number; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; + + // --- Haulage pricing --- + @Column({ name: 'price_per_km', type: 'numeric', precision: 14, scale: 2, nullable: true }) + pricePerKm?: number; + + /** Currency for pricePerKm: ETB | USD */ + @Column({ name: 'currency', type: 'varchar', length: 8, default: 'ETB' }) + currency?: string; + + // --- Compliance / expiry tracking --- + @Column({ name: 'vin', type: 'varchar', nullable: true }) + vin?: string; + + /** Owned | Leased | Rented */ + @Column({ name: 'ownership', type: 'varchar', nullable: true }) + ownership?: string; + + @Column({ name: 'insurance_expiry', type: 'date', nullable: true }) + insuranceExpiry?: string; + + @Column({ name: 'registration_expiry', type: 'date', nullable: true }) + registrationExpiry?: string; + + @Column({ name: 'next_inspection_date', type: 'date', nullable: true }) + nextInspectionDate?: string; } diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts index 24ff2d022..737574dd9 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -10,20 +10,25 @@ import { ParseUUIDPipe, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { FleetManage, FleetView } from '../../common/booking-guards'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { VehiclesService } from './vehicles.service'; import { CreateVehicleDto } from './dto/create-vehicle.dto'; import { UpdateVehicleDto } from './dto/update-vehicle.dto'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @ApiTags('vehicles') @ApiBearerAuth() @Controller('vehicles') -@FleetView() +@BookingStaff(FREIGHT_PERMS.vehicles.view) export class VehiclesController { - constructor(private readonly vehiclesService: VehiclesService) {} + constructor( + private readonly vehiclesService: VehiclesService, + private readonly fleetHistory: FleetHistoryService, + ) {} @Post() - @FleetManage() + @BookingStaff(FREIGHT_PERMS.vehicles.create) @ApiOperation({ summary: 'Create a new vehicle' }) create(@Body() createVehicleDto: CreateVehicleDto) { return this.vehiclesService.create(createVehicleDto); @@ -34,6 +39,7 @@ export class VehiclesController { findAll( @Query('search') search?: string, @Query('status') status?: string, + @Query('availability') availability?: string, @Query('page') page?: string, @Query('limit') limit?: string, @Query('sortBy') sortBy?: string, @@ -42,6 +48,7 @@ export class VehiclesController { return this.vehiclesService.findAll({ search, status: status as any, + availability: availability as any, page: page ? parseInt(page) : undefined, limit: limit ? parseInt(limit) : undefined, sortBy, @@ -55,8 +62,14 @@ export class VehiclesController { return this.vehiclesService.findById(id); } + @Get(':id/history') + @ApiOperation({ summary: 'Get vehicle assignment, status & mile history' }) + history(@Param('id', ParseUUIDPipe) id: string) { + return this.fleetHistory.getVehicleHistory(id); + } + @Patch(':id') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.vehicles.update) @ApiOperation({ summary: 'Update a vehicle' }) update( @Param('id', ParseUUIDPipe) id: string, @@ -66,7 +79,7 @@ export class VehiclesController { } @Delete(':id') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.vehicles.delete) @ApiOperation({ summary: 'Delete a vehicle' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.vehiclesService.remove(id); diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 2bc882591..25260e86f 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -1,15 +1,23 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Not, Repository } from 'typeorm'; import { CreateVehicleDto } from './dto/create-vehicle.dto'; import { UpdateVehicleDto } from './dto/update-vehicle.dto'; -import { Vehicle, VehicleStatus } from './entities/vehicle.entity'; +import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity'; +import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity'; +import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity'; +import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity'; +import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity'; +import { BookingContainerAllocation } from '../bookings/entities/booking-container-allocation.entity'; +import { FleetHistoryService } from '../fleet-history/fleet-history.service'; +import { FleetEventType } from '../fleet-history/entities/fleet-event.entity'; @Injectable() export class VehiclesService { constructor( @InjectRepository(Vehicle) private readonly vehicleRepo: Repository, + private readonly history: FleetHistoryService, ) {} async create(dto: CreateVehicleDto): Promise { @@ -29,12 +37,34 @@ export class VehiclesService { registrationNumber, }); - return this.vehicleRepo.save(vehicle); + const saved = await this.vehicleRepo.save(vehicle); + + await this.history.record({ + eventType: FleetEventType.VEHICLE_REGISTERED, + vehicleId: saved.id, + label: saved.plateNumber ?? saved.code ?? null, + toValue: saved.availability ?? null, + }); + if (saved.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_ASSIGNED, + vehicleId: saved.id, + driverId: saved.assignedDriverId, + label: saved.assignedDriverName ?? null, + metadata: { + vehiclePlate: saved.plateNumber ?? saved.code ?? null, + driverName: saved.assignedDriverName ?? null, + }, + }); + } + + return saved; } async findAll(query: { search?: string; status?: VehicleStatus | string; + availability?: VehicleAvailability | string; page?: number; limit?: number; sortBy?: string; @@ -44,7 +74,7 @@ export class VehiclesService { if (query.search) { qb = qb.where( - 'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search', + '(v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search OR v.model ILIKE :search OR v.code ILIKE :search OR v.trailerPlateNo ILIKE :search)', { search: `%${query.search}%` }, ); } @@ -53,6 +83,10 @@ export class VehiclesService { qb = qb.andWhere('v.status = :status', { status: query.status }); } + if (query.availability) { + qb = qb.andWhere('v.availability = :availability', { availability: query.availability }); + } + const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes( query.sortBy ?? '', ) @@ -87,8 +121,114 @@ export class VehiclesService { } } + const prev = { + assignedDriverId: vehicle.assignedDriverId, + assignedDriverName: vehicle.assignedDriverName, + status: vehicle.status, + availability: vehicle.availability, + }; + Object.assign(vehicle, dto); - return this.vehicleRepo.save(vehicle); + const saved = await this.vehicleRepo.save(vehicle); + + // Driver (re)assignment — emit an unassign for the old driver and/or an + // assign for the new one so both drivers' timelines and the vehicle's line up. + if ( + dto.assignedDriverId !== undefined && + dto.assignedDriverId !== prev.assignedDriverId + ) { + const vehiclePlate = saved.plateNumber ?? saved.code ?? null; + if (prev.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_UNASSIGNED, + vehicleId: id, + driverId: prev.assignedDriverId, + label: prev.assignedDriverName ?? null, + metadata: { vehiclePlate, driverName: prev.assignedDriverName ?? null }, + }); + } + if (saved.assignedDriverId) { + await this.history.record({ + eventType: FleetEventType.DRIVER_ASSIGNED, + vehicleId: id, + driverId: saved.assignedDriverId, + label: saved.assignedDriverName ?? null, + metadata: { vehiclePlate, driverName: saved.assignedDriverName ?? null }, + }); + } + } + if (dto.status !== undefined && dto.status !== prev.status) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_STATUS_CHANGED, + vehicleId: id, + fromValue: prev.status ?? null, + toValue: saved.status ?? null, + }); + } + if (dto.availability !== undefined && dto.availability !== prev.availability) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED, + vehicleId: id, + fromValue: prev.availability ?? null, + toValue: saved.availability ?? null, + }); + } + + return saved; + } + + async setAvailability(id: string, availability: VehicleAvailability): Promise { + // Read the current value so the audit event records an accurate from→to and + // we skip logging no-op writes (setAvailability is called in release loops). + const vehicle = await this.vehicleRepo.findOne({ where: { id } }); + const previous = vehicle?.availability; + await this.vehicleRepo.update(id, { availability }); + if (previous !== availability) { + await this.history.record({ + eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED, + vehicleId: id, + fromValue: previous ?? null, + toValue: availability, + }); + } + } + + /** + * Set vehicles back to FREE, but only when no active (non-completed) + * first/last-mile record or container allocation still references them. + * First-mile trips ending in RECEIVED_TO_PORT and last-mile trips ending + * in DELIVERED no longer hold the vehicle. + */ + async releaseIfUnused(vehicleIds: string[]): Promise { + const manager = this.vehicleRepo.manager; + for (const vehicleId of [...new Set(vehicleIds)]) { + const [fmRecords, lmRecords, fmAllocations, lmAllocations, bookingAllocations] = await Promise.all([ + manager.count(FirstMile, { + where: { vehicleId, status: Not('RECEIVED_TO_PORT') }, + }), + manager.count(LastMile, { + where: { vehicleId, status: Not('DELIVERED') }, + }), + manager + .createQueryBuilder(FirstMileContainerAllocation, 'alloc') + .innerJoin(FirstMile, 'fm', 'fm.id = alloc.firstMileId') + .where('alloc.vehicleId = :vehicleId', { vehicleId }) + .andWhere('fm.status != :done', { done: 'RECEIVED_TO_PORT' }) + .andWhere('fm.deletedAt IS NULL') + .getCount(), + manager + .createQueryBuilder(LastMileContainerAllocation, 'alloc') + .innerJoin(LastMile, 'lm', 'lm.id = alloc.lastMileId') + .where('alloc.vehicleId = :vehicleId', { vehicleId }) + .andWhere('lm.status != :done', { done: 'DELIVERED' }) + .andWhere('lm.deletedAt IS NULL') + .getCount(), + manager.count(BookingContainerAllocation, { where: { vehicleId } }), + ]); + if (fmRecords + lmRecords + fmAllocations + lmAllocations + bookingAllocations === 0) { + await this.setAvailability(vehicleId, VehicleAvailability.FREE); + } + } } async remove(id: string): Promise { diff --git a/apps/edr-freight-api/src/modules/verifayda/entities/fayda-verification-session.entity.ts b/apps/edr-freight-api/src/modules/verifayda/entities/fayda-verification-session.entity.ts new file mode 100644 index 000000000..3435acabc --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/entities/fayda-verification-session.entity.ts @@ -0,0 +1,49 @@ +import { Column, Entity, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +/** + * One row per started Fayda verification. Mirrors the passenger-api Prisma + * model `FaydaVerificationSession`, but stored in the freight schema via + * TypeORM. `state` is the single-use CSRF token that links the eSignet + * redirect back to this session. + */ +@Entity({ name: 'fayda_verification_sessions', schema: 'freight' }) +@Index(['expiresAt']) +@Index(['iamUserId']) +export class FaydaVerificationSession extends BaseEntity { + @Column({ name: 'state', unique: true }) + state!: string; + + @Column({ name: 'code_verifier' }) + codeVerifier!: string; + + /** VERIFY | LOGIN */ + @Column({ name: 'purpose', default: 'VERIFY' }) + purpose!: string; + + /** WEB | MOBILE — recorded for audit */ + @Column({ name: 'platform', default: 'WEB' }) + platform!: string; + + @Column({ name: 'save_to_account', type: 'boolean', default: false }) + saveToAccount!: boolean; + + /** PENDING | COMPLETED | FAILED */ + @Column({ name: 'status', default: 'PENDING' }) + status!: string; + + @Column({ name: 'error_code', type: 'varchar', nullable: true }) + errorCode?: string | null; + + @Column({ name: 'error_description', type: 'text', nullable: true }) + errorDescription?: string | null; + + @Column({ name: 'iam_user_id', type: 'uuid', nullable: true }) + iamUserId?: string | null; + + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt!: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts new file mode 100644 index 000000000..1c5569750 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/fayda-callback.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; +import { VerifaydaCallbackDto } from './verifayda.dto'; + +/** + * Plain acknowledgement endpoint for the Fayda redirect_uri when it points at + * the API instead of the web app (e.g. MOBILE clients or connectivity checks). + * Registered at /callback (excluded from the global /api prefix in main.ts). + * It does NOT consume the verification session — the client must still call + * GET /api/fayda/verification/complete with the echoed code+state. + */ +@ApiTags('Fayda Verification') +@Controller('callback') +export class FaydaCallbackController { + @Get() + @IsPublic() + @ApiOperation({ summary: 'Acknowledge a Fayda redirect (returns OK, echoes code/state)' }) + @ApiOkResponse({ + schema: { example: { status: 'ok', code: '...', state: '...' } }, + }) + ok(@Query() query: VerifaydaCallbackDto) { + return { + status: 'ok', + ...(query.code ? { code: query.code } : {}), + ...(query.state ? { state: query.state } : {}), + ...(query.error ? { error: query.error } : {}), + ...(query.error_description ? { error_description: query.error_description } : {}), + }; + } +} + +// return res.redirect(url.toString()); diff --git a/apps/edr-freight-api/src/modules/verifayda/optional-jwt.guard.ts b/apps/edr-freight-api/src/modules/verifayda/optional-jwt.guard.ts new file mode 100644 index 000000000..8673aa60e --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/optional-jwt.guard.ts @@ -0,0 +1,30 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { DataSource } from 'typeorm'; + +/** + * Like the IAM JwtGuard, but never rejects the request. + * + * When a valid IAM bearer token is present, `request.user` is populated with + * the package `TCurrentUser`. Missing or invalid tokens continue as guests. + */ +@Injectable() +export class OptionalJwtGuard extends IamJwtGuard implements CanActivate { + constructor( + reflector: Reflector, + @InjectDataSource() dataSource: DataSource, + ) { + super(reflector, dataSource); + } + + async canActivate(context: ExecutionContext): Promise { + try { + await super.canActivate(context); + } catch { + context.switchToHttp().getRequest().user = undefined; + } + return true; + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.spec.ts b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.spec.ts new file mode 100644 index 000000000..9b4316fc7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.spec.ts @@ -0,0 +1,71 @@ +import { exportJWK, generateKeyPair, importJWK, jwtVerify, type JWK } from 'jose'; +import { generateClientAssertion } from './client-assertion.util'; + +describe('generateClientAssertion', () => { + let privateJwk: JWK; + let publicJwk: JWK; + + beforeAll(async () => { + const kp = await generateKeyPair('RS256', { extractable: true }); + privateJwk = await exportJWK(kp.privateKey); + publicJwk = await exportJWK(kp.publicKey); + }); + + it('produces a JWT verifiable with the matching public key', async () => { + const jwt = await generateClientAssertion({ + clientId: 'edr-passenger-test', + audience: 'https://esignet.example.com/token', + privateJwk, + }); + + const verifier = await importJWK(publicJwk, 'RS256'); + const { payload, protectedHeader } = await jwtVerify(jwt, verifier, { + issuer: 'edr-passenger-test', + subject: 'edr-passenger-test', + audience: 'https://esignet.example.com/token', + }); + + expect(protectedHeader.alg).toBe('RS256'); + expect(protectedHeader.typ).toBe('JWT'); + expect(payload.iss).toBe('edr-passenger-test'); + expect(payload.sub).toBe('edr-passenger-test'); + expect(payload.aud).toBe('https://esignet.example.com/token'); + expect(typeof payload.iat).toBe('number'); + expect(typeof payload.exp).toBe('number'); + }); + + it('defaults exp to 120 seconds after iat', async () => { + const jwt = await generateClientAssertion({ + clientId: 'c', + audience: 'https://a/token', + privateJwk, + }); + const verifier = await importJWK(publicJwk, 'RS256'); + const { payload } = await jwtVerify(jwt, verifier); + expect(payload.exp! - payload.iat!).toBe(120); + }); + + it('honors a custom expiresIn', async () => { + const jwt = await generateClientAssertion({ + clientId: 'c', + audience: 'https://a/token', + privateJwk, + expiresIn: '5m', + }); + const verifier = await importJWK(publicJwk, 'RS256'); + const { payload } = await jwtVerify(jwt, verifier); + expect(payload.exp! - payload.iat!).toBe(300); + }); + + it('fails verification against a wrong audience', async () => { + const jwt = await generateClientAssertion({ + clientId: 'c', + audience: 'https://a/token', + privateJwk, + }); + const verifier = await importJWK(publicJwk, 'RS256'); + await expect( + jwtVerify(jwt, verifier, { audience: 'https://other/token' }), + ).rejects.toThrow(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts new file mode 100644 index 000000000..dc3558ccc --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/utils/client-assertion.util.ts @@ -0,0 +1,22 @@ +import { SignJWT, importJWK, type JWK } from 'jose'; + +export interface GenerateClientAssertionInput { + clientId: string; + audience: string; + privateJwk: JWK; + expiresIn?: string; +} + +export async function generateClientAssertion( + input: GenerateClientAssertionInput, +): Promise { + const privateKey = await importJWK(input.privateJwk, 'RS256'); + return new SignJWT({}) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setIssuer(input.clientId) + .setSubject(input.clientId) + .setAudience(input.audience) + .setIssuedAt() + .setExpirationTime(input.expiresIn ?? '2m') + .sign(privateKey); +} diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.spec.ts b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.spec.ts new file mode 100644 index 000000000..d359a07f2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.spec.ts @@ -0,0 +1,65 @@ +import { createHash } from 'crypto'; +import { + base64Url, + generateCodeChallenge, + generateCodeVerifier, + generateState, +} from './pkce.util'; + +describe('pkce.util', () => { + describe('base64Url', () => { + it('strips padding and replaces + and / with - and _', () => { + const input = Buffer.from([0xfb, 0xff, 0xbf, 0xfe]); + const out = base64Url(input); + expect(out).not.toMatch(/[+/=]/); + }); + }); + + describe('generateCodeVerifier', () => { + it('returns a base64url-safe string', () => { + expect(generateCodeVerifier()).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('produces unique values across calls', () => { + const a = generateCodeVerifier(); + const b = generateCodeVerifier(); + expect(a).not.toEqual(b); + }); + + it('produces at least 43 characters (RFC 7636 minimum)', () => { + expect(generateCodeVerifier().length).toBeGreaterThanOrEqual(43); + }); + }); + + describe('generateCodeChallenge', () => { + it('equals base64url(sha256(verifier))', () => { + const verifier = 'fixed-test-verifier'; + const expected = createHash('sha256') + .update(verifier) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + expect(generateCodeChallenge(verifier)).toBe(expected); + }); + + it('is deterministic for the same verifier', () => { + const verifier = generateCodeVerifier(); + expect(generateCodeChallenge(verifier)).toBe(generateCodeChallenge(verifier)); + }); + + it('differs for different verifiers', () => { + expect(generateCodeChallenge('a')).not.toBe(generateCodeChallenge('b')); + }); + }); + + describe('generateState', () => { + it('returns a base64url-safe string', () => { + expect(generateState()).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('produces unique values across calls', () => { + expect(generateState()).not.toEqual(generateState()); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.ts b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.ts new file mode 100644 index 000000000..89e9437d2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/utils/pkce.util.ts @@ -0,0 +1,21 @@ +import { createHash, randomBytes } from 'crypto'; + +export function base64Url(buffer: Buffer): string { + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +export function generateCodeVerifier(): string { + return base64Url(randomBytes(64)); +} + +export function generateCodeChallenge(codeVerifier: string): string { + return base64Url(createHash('sha256').update(codeVerifier).digest()); +} + +export function generateState(): string { + return base64Url(randomBytes(32)); +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts new file mode 100644 index 000000000..977e677f8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.controller.ts @@ -0,0 +1,107 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { OptionalJwtGuard } from './optional-jwt.guard'; +import { + CompleteVerificationResultDto, + StartVerificationDto, + VerifaydaCallbackDto, + VerificationStatusDto, +} from './verifayda.dto'; +import { VerifaydaService } from './verifayda.service'; + +/** Minimal slices of the Express req we touch (avoids a hard dependency on + * `@types/express`, which isn't resolved in this package). */ +interface RequestWithOptionalUser { + user?: TCurrentUser; +} +interface RequestWithUser { + user: TCurrentUser; +} + +@ApiTags('Fayda Verification') +@Controller('fayda/verification') +export class VerifaydaController { + constructor(private readonly service: VerifaydaService) {} + + @Post('start') + @IsPublic() + @HttpCode(HttpStatus.OK) + @UseGuards(OptionalJwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: 'Start a VeriFayda 2.0 verification session', + description: `Creates a verification session and returns the eSignet authorize URL the frontend should send the user to. + +- Works for **logged-in users** and **guests**. If a valid bearer token is present, the verification is tied to that user. +- **VERIFY** (default): the user proves their identity and \`/complete\` returns the verified attributes (name, email, phone, dob, gender). +- **LOGIN**: \`/complete\` resolves/creates the user and returns a JWT. +- The returned \`authorizationUrl\` already carries the PKCE \`code_challenge\`, CSRF \`state\`, requested \`claims\`, and \`code_challenge_method=S256\`. The frontend simply navigates to it (full page or popup).`, + }) + @ApiOkResponse({ + description: 'Authorize URL the frontend should redirect the user to.', + schema: { + example: { + authorizationUrl: + 'https://esignet.example.com/authorize?client_id=...&state=...&code_challenge=...', + }, + }, + }) + async start( + @Body() dto: StartVerificationDto, + @Req() req: RequestWithOptionalUser, + ): Promise<{ authorizationUrl: string }> { + const authorizationUrl = await this.service.startVerification({ + purpose: dto.purpose ?? 'VERIFY', + platform: dto.platform ?? 'WEB', + userId: req.user?.id, + wantsPasswordSetup: dto.wantsPasswordSetup ?? false, + }); + return { authorizationUrl }; + } + + @Get('complete') + @IsPublic() + @ApiOperation({ + summary: 'Complete a verification (Fayda redirect / client callback lands here)', + description: `This is the registered Fayda \`redirect_uri\`. Fayda redirects the browser here with \`?code&state\``, + }) + @ApiOkResponse({ type: CompleteVerificationResultDto }) + async complete( + @Query() dto: VerifaydaCallbackDto, + ): Promise { + return this.service.completeVerification(dto); + } + + @Get('status') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: "Get the current user's Fayda verification status", + description: + 'Returns whether the authenticated user has linked a verified Fayda identity to their account, when, and the name on file.', + }) + @ApiOkResponse({ type: VerificationStatusDto }) + async status( + @Req() req: RequestWithUser, + ): Promise { + return this.service.getVerificationStatus(req.user.id); + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts new file mode 100644 index 000000000..1885b11e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts @@ -0,0 +1,105 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString } from 'class-validator'; + +export class StartVerificationDto { + @ApiPropertyOptional({ + enum: ['LOGIN', 'VERIFY'], + default: 'VERIFY', + description: + 'Reason for verification. VERIFY returns the verified identity attributes; LOGIN resolves/creates a user and returns a JWT.', + }) + @IsOptional() + @IsIn(['LOGIN', 'VERIFY']) + purpose?: 'LOGIN' | 'VERIFY'; + + @ApiPropertyOptional({ + enum: ['WEB', 'MOBILE'], + default: 'WEB', + description: + 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.', + }) + @IsOptional() + @IsIn(['WEB', 'MOBILE']) + platform?: 'WEB' | 'MOBILE'; + + @ApiPropertyOptional({ + type: Boolean, + default: false, + description: + 'Set to true when the user opts in to full account registration (checkbox). ' + + 'When true, the /complete response includes a short-lived token and promptPasswordSetup=true ' + + 'so the frontend can immediately prompt for a password via POST /v1/auth/set-fayda-password.', + }) + @IsOptional() + wantsPasswordSetup?: boolean; +} + +export class CompleteVerificationResultDto { + @ApiProperty({ enum: ['LOGIN', 'VERIFY'] }) + purpose!: 'LOGIN' | 'VERIFY'; + + @ApiProperty() verified!: boolean; + + @ApiPropertyOptional({ description: 'JWT. LOGIN: session token for the authenticated user. VERIFY: short-lived token for calling /v1/auth/set-fayda-password.' }) + token?: string; + + @ApiPropertyOptional() + refreshToken?: string; + + @ApiPropertyOptional({ + description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).', + }) + user?: { + id: string; + email: string; + role: string; + passengerId?: string; + agentId?: string; + }; + + @ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' }) + fullName?: string; + + @ApiPropertyOptional({ description: 'Verified email from Fayda (VERIFY flow).' }) + email?: string; + + @ApiPropertyOptional({ description: 'Verified phone number from Fayda (VERIFY flow).' }) + phoneNumber?: string; + + @ApiPropertyOptional({ + description: 'Verified date of birth from Fayda, ISO yyyy-MM-dd (VERIFY flow).', + }) + birthdate?: string; + + @ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' }) + gender?: string; + + @ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' }) + userDataSaved?: boolean; + + @ApiPropertyOptional({ description: 'IAM user ID of the verified identity (VERIFY flow).' }) + iamUserId?: string; + + @ApiPropertyOptional({ description: 'True when the IAM account has not yet set a password (VERIFY flow).' }) + requiresPassword?: boolean; + + @ApiPropertyOptional({ + description: + 'True when the user opted in to immediate password setup (wantsPasswordSetup=true at start) ' + + 'AND they have not yet set a password. Frontend should navigate to the set-password screen.', + }) + promptPasswordSetup?: boolean; +} + +export class VerifaydaCallbackDto { + @ApiPropertyOptional() @IsOptional() @IsString() code?: string; + @ApiPropertyOptional() @IsOptional() @IsString() state?: string; + @ApiPropertyOptional() @IsOptional() @IsString() error?: string; + @ApiPropertyOptional() @IsOptional() @IsString() error_description?: string; +} + +export class VerificationStatusDto { + @ApiProperty() verified!: boolean; + @ApiPropertyOptional() verifiedAt?: Date; + @ApiPropertyOptional() fullName?: string; +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.errors.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.errors.ts new file mode 100644 index 000000000..a7d531102 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.errors.ts @@ -0,0 +1,19 @@ +import { BadGatewayException, ConflictException } from '@nestjs/common'; + +export class FaydaTokenExchangeException extends BadGatewayException { + constructor(message = 'Fayda token exchange failed') { + super({ code: 'FAYDA_TOKEN_EXCHANGE_FAILED', message }); + } +} + +export class FaydaUserInfoException extends BadGatewayException { + constructor(message = 'Fayda userinfo fetch failed') { + super({ code: 'FAYDA_USERINFO_FAILED', message }); + } +} + +export class FaydaIdentityConflictException extends ConflictException { + constructor(message = 'This Fayda identity is already linked to another account') { + super({ code: 'FAYDA_IDENTITY_CONFLICT', message }); + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts new file mode 100644 index 000000000..82fb9435c --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { VerifaydaController } from './verifayda.controller'; +import { FaydaCallbackController } from './fayda-callback.controller'; +import { VerifaydaService } from './verifayda.service'; +import { FaydaVerificationSession } from './entities/fayda-verification-session.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([FaydaVerificationSession])], + controllers: [VerifaydaController, FaydaCallbackController], + providers: [VerifaydaService], + exports: [VerifaydaService], +}) +export class VerifaydaModule {} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts new file mode 100644 index 000000000..6e3e2e095 --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts @@ -0,0 +1,597 @@ +import { + BadRequestException, + Injectable, + Logger, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { generateToken, generateRefreshToken } from '@tria-plc/api-common/utils/token'; +import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config'; +import { FaydaVerificationSession } from './entities/fayda-verification-session.entity'; +import { + generateCodeChallenge, + generateCodeVerifier, + generateState, +} from './utils/pkce.util'; +import { generateClientAssertion } from './utils/client-assertion.util'; +import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto'; +import { + FaydaTokenExchangeException, + FaydaUserInfoException, +} from './verifayda.errors'; +import { + FaydaTokenResponse, + FaydaUserInfo, + NormalizedFaydaUserInfo, + VerifaydaPurpose, +} from './verifayda.types'; + +export interface StartVerificationInput { + purpose: VerifaydaPurpose; + platform?: FaydaPlatform; + userId?: string; // iamUserId of the authenticated user, if any + wantsPasswordSetup?: boolean; +} + +export interface FaydaUserSummary { + id: string; + email: string; + role: string; +} + +/** + * Result of completing a verification. `verified` is always true on success. + * LOGIN additionally returns a JWT + user; VERIFY returns the verified identity + * attributes (name, email, phone, dob, gender) for the caller to consume. + */ +export interface CompleteVerificationResult { + purpose: VerifaydaPurpose; + verified: boolean; + token?: string; + refreshToken?: string; + requiresPassword?: boolean; + promptPasswordSetup?: boolean; + iamUserId?: string; + user?: FaydaUserSummary; + fullName?: string; + email?: string; + phoneNumber?: string; + birthdate?: string; + gender?: string; + userDataSaved?: boolean; +} + +@Injectable() +export class VerifaydaService { + private readonly logger = new Logger(VerifaydaService.name); + + private readonly faydaConfig: FaydaConfig; + + constructor( + private readonly config: ConfigService, + @InjectRepository(FaydaVerificationSession) + private readonly sessionRepo: Repository, + @InjectDataSource() private readonly dataSource: DataSource, + ) { + const fayda = this.config.get('fayda'); + if (!fayda) { + throw new Error('Fayda config namespace not registered'); + } + this.faydaConfig = fayda; + } + + // ========================================================================== + // OIDC flow + // ========================================================================== + + async startVerification(input: StartVerificationInput): Promise { + if (!this.faydaConfig.enabled) { + throw new ServiceUnavailableException({ + code: 'FAYDA_DISABLED', + message: 'Fayda integration is not enabled', + }); + } + + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const expiresAt = new Date( + Date.now() + this.faydaConfig.sessionTtlMinutes * 60_000, + ); + + await this.sessionRepo.save( + this.sessionRepo.create({ + state, + codeVerifier, + purpose: input.purpose, + platform: input.platform ?? 'WEB', + saveToAccount: input.wantsPasswordSetup ?? false, + iamUserId: input.userId ?? null, + expiresAt, + }), + ); + + this.logger.log( + `Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`, + ); + + return this.buildAuthorizationUrl({ + state, + codeChallenge, + redirectUri: this.redirectUriForPlatform(input.platform ?? 'WEB'), + }); + } + + /** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */ + private redirectUriForPlatform(platform?: FaydaPlatform): string { + return platform === 'MOBILE' + ? this.faydaConfig.redirectUri + : this.faydaConfig.webRedirectUri; + } + + async completeVerification( + query: VerifaydaCallbackDto, + ): Promise { + if (query.error) { + this.logger.warn(`Fayda callback returned error: ${query.error}`); + if (query.state) { + await this.markSessionFailed( + query.state, + query.error, + query.error_description, + ); + } + throw new BadRequestException({ + code: 'FAYDA_AUTH_ERROR', + message: query.error, + description: query.error_description, + }); + } + + if (!query.code || !query.state) { + throw new BadRequestException({ + code: 'FAYDA_MISSING_PARAMETERS', + message: 'code and state are required', + }); + } + + const session = await this.sessionRepo.findOne({ + where: { state: query.state }, + }); + if (!session || session.status !== 'PENDING') { + this.logger.warn('Fayda complete with unknown or non-pending state'); + throw new BadRequestException({ + code: 'FAYDA_INVALID_STATE', + message: 'Verification session is invalid or already used', + }); + } + if (session.expiresAt.getTime() < Date.now()) { + await this.markSessionFailed(query.state, 'session_expired'); + throw new BadRequestException({ + code: 'FAYDA_SESSION_EXPIRED', + message: 'Verification session has expired; start again', + }); + } + + try { + const tokens = await this.exchangeCodeForTokens( + query.code, + session.codeVerifier, + this.redirectUriForPlatform(session.platform as FaydaPlatform), + ); + const userInfo = await this.fetchUserInfo(tokens.access_token); + const normalized = this.normalizeUserInfo(userInfo); + + if (!normalized.sub) { + throw new FaydaUserInfoException('Fayda userinfo missing required sub'); + } + + let result: CompleteVerificationResult; + if (session.purpose === 'LOGIN') { + const { userId } = await this.handleLoginSuccess(normalized); + const login = await this.issueLoginToken(userId); + result = { purpose: 'LOGIN', verified: true, ...login }; + } else { + // VERIFY — prove identity, save to IAM, return verified attributes + short-lived token. + const { iamUserId, userDataSaved } = await this.upsertIamUser(normalized); + + let sessionToken: { token: string; refreshToken: string; requiresPassword: boolean } | undefined; + if (iamUserId) { + try { + sessionToken = await this.createFaydaSession(iamUserId); + } catch (err) { + this.logger.warn(`Fayda session creation failed: ${(err as Error).message}`); + } + } + + result = { + purpose: 'VERIFY', + verified: true, + fullName: normalized.fullName, + email: normalized.email, + phoneNumber: normalized.phoneNumber, + birthdate: normalized.birthdate, + gender: normalized.gender, + userDataSaved, + iamUserId: iamUserId ?? undefined, + token: sessionToken?.token, + refreshToken: sessionToken?.refreshToken, + requiresPassword: sessionToken?.requiresPassword, + promptPasswordSetup: session.saveToAccount && (sessionToken?.requiresPassword ?? false), + }; + } + + await this.sessionRepo.update(session.id, { + status: 'COMPLETED', + completedAt: new Date(), + codeVerifier: '', + }); + + this.logger.log( + `Fayda verification completed: purpose=${session.purpose} platform=${session.platform}`, + ); + return result; + } catch (err) { + const reason = this.classifyFailureReason(err); + this.logger.error( + `Fayda verification failed: reason=${reason} message=${(err as Error).message}`, + ); + await this.markSessionFailed( + query.state, + reason, + (err as Error).message, + ); + throw err; + } + } + + private async issueLoginToken( + _userId: string, + ): Promise<{ token: string; user: FaydaUserSummary }> { + throw new UnauthorizedException({ + code: 'FAYDA_LOGIN_MIGRATED_TO_IAM', + message: 'Fayda login tokens are issued by the IAM package auth endpoints.', + }); + } + + async getVerificationStatus(iamUserId: string): Promise { + const rows = await this.dataSource.query<{ verified_by: string | null; updated_at: Date | null; name: { en: string; am: string } | null }[]>( + `SELECT verified_by, updated_at, name FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + const iam = rows[0] ?? null; + const faydaVerified = iam?.verified_by === 'fayda'; + const faydaVerifiedAt = faydaVerified && iam?.updated_at ? new Date(iam.updated_at) : undefined; + const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined; + return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName }; + } + + // ========================================================================== + // OIDC internals + // ========================================================================== + + private buildAuthorizationUrl(args: { + state: string; + codeChallenge: string; + redirectUri: string; + }): string { + const params = new URLSearchParams({ + client_id: this.faydaConfig.clientId, + response_type: 'code', + redirect_uri: args.redirectUri, + scope: this.faydaConfig.scope, + state: args.state, + code_challenge: args.codeChallenge, + code_challenge_method: 'S256', + acr_values: this.faydaConfig.acrValues, + claims_locales: this.faydaConfig.claimsLocales, + }); + + // Every claim is marked essential so eSignet shows them locked/pre-checked + // on the consent screen — the user cannot toggle any off; they either + // consent to all of them or the whole flow is cancelled (?error=...). + const claims = { + userinfo: { + name: { essential: true }, + phone_number: { essential: true }, + email: { essential: true }, + birthdate: { essential: true }, + gender: { essential: true }, + address: { essential: true }, + nationality: { essential: true }, + picture: { essential: true }, + }, + id_token: {}, + }; + params.set('claims', JSON.stringify(claims)); + + return `${this.faydaConfig.authorizationEndpoint}?${params.toString()}`; + } + + private async exchangeCodeForTokens( + code: string, + codeVerifier: string, + redirectUri: string, + ): Promise { + const clientAssertion = await generateClientAssertion({ + clientId: this.faydaConfig.clientId, + audience: this.faydaConfig.tokenEndpoint, + privateJwk: this.faydaConfig.privateJwk, + }); + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: this.faydaConfig.clientId, + client_assertion_type: + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion: clientAssertion, + code_verifier: codeVerifier, + }); + + const response = await fetch(this.faydaConfig.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!response.ok) { + let detail = ''; + try { + detail = await response.text(); + } catch { + // ignore + } + throw new FaydaTokenExchangeException( + `Fayda token endpoint returned ${response.status}${detail ? `: ${detail}` : ''}`, + ); + } + + return (await response.json()) as FaydaTokenResponse; + } + + private async fetchUserInfo(accessToken: string): Promise { + const response = await fetch(this.faydaConfig.userInfoEndpoint, { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!response.ok) { + throw new FaydaUserInfoException( + `Fayda userinfo endpoint returned ${response.status}`, + ); + } + + const contentType = response.headers.get('content-type') ?? ''; + const raw = await response.text(); + + if (contentType.includes('application/json')) { + return JSON.parse(raw) as FaydaUserInfo; + } + + // Signed JWT response — decode payload (signature verification = production TODO) + if (raw.split('.').length === 3) { + const payloadB64 = raw.split('.')[1]; + const normalizedB64 = payloadB64.replace(/-/g, '+').replace(/_/g, '/'); + const json = Buffer.from(normalizedB64, 'base64').toString('utf8'); + return JSON.parse(json) as FaydaUserInfo; + } + + throw new FaydaUserInfoException( + 'Unsupported Fayda userinfo response format', + ); + } + + private normalizeUserInfo(raw: FaydaUserInfo): NormalizedFaydaUserInfo { + const nameEn = raw['name#en'] as string | undefined; + const nameAm = raw['name#am'] as string | undefined; + const genderEn = raw['gender#en'] as string | undefined; + const genderAm = raw['gender#am'] as string | undefined; + const addressEn = raw['address#en'] as string | undefined; + const addressAm = raw['address#am'] as string | undefined; + const rawPhone = (raw.phone_number ?? raw['phone_number#en'] ?? raw['phone_number#am'] ?? raw.phone) as string | undefined; + + return { + sub: raw.sub, + fullName: (raw.name as string | undefined) ?? nameEn ?? nameAm, + phoneNumber: rawPhone ? this.standardizePhoneNumber(rawPhone) : undefined, + rawPhoneNumber: rawPhone, + email: raw.email as string | undefined, + gender: genderEn ?? genderAm ?? (raw.gender as string | undefined), + birthdate: raw.birthdate as string | undefined, + picture: raw.picture as string | undefined, + nameEn, + nameAm, + genderEn, + genderAm, + addressEn, + addressAm, + }; + } + + private standardizePhoneNumber(phone: string): string { + const digits = phone.replace(/\D/g, ''); + if (digits.startsWith('251')) return `+${digits}`; + if (digits.startsWith('0')) return `+251${digits.slice(1)}`; + return `+${digits}`; + } + + // LOGIN via Fayda is handled entirely by the IAM package's own OIDC flow. + // This method is kept as a stub so completeVerification() still compiles; + // it throws immediately without touching the database. + private async handleLoginSuccess( + _normalized: NormalizedFaydaUserInfo, + ): Promise<{ userId: string }> { + throw new UnauthorizedException({ + code: 'FAYDA_LOGIN_MIGRATED_TO_IAM', + message: 'Fayda login tokens are issued by the IAM package at /v1/auth/fayda endpoints.', + }); + } + + private async upsertIamUser( + normalized: NormalizedFaydaUserInfo, + ): Promise<{ iamUserId: string | null; userDataSaved: boolean }> { + try { + const iamMetadata = { + sub: normalized.sub, + address: { am: normalized.addressAm ?? '', en: normalized.addressEn ?? '' }, + email: normalized.email ?? '', + gender: { am: normalized.genderAm ?? '', en: normalized.genderEn ?? '' }, + name: { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' }, + phoneNumber: normalized.rawPhoneNumber ?? '', + }; + + // Step 1 — already linked to this Fayda sub; ensure verified_by is set + const bySub = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`, + [normalized.sub], + ); + if (bySub.length > 0) { + await this.dataSource.query( + `UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`, + [bySub[0].id], + ); + return { iamUserId: bySub[0].id, userDataSaved: true }; + } + + // Step 2 — existing user by phone or email, not yet Fayda-verified + const conditions: string[] = []; + const params: unknown[] = []; + if (normalized.phoneNumber) { + params.push(normalized.phoneNumber); + conditions.push(`phone_number = $${params.length}`); + } + if (normalized.email) { + params.push(normalized.email); + conditions.push(`email = $${params.length}`); + } + if (conditions.length > 0) { + const byContact = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE ${conditions.join(' OR ')} LIMIT 1`, + params, + ); + if (byContact.length > 0) { + const existingId = byContact[0].id; + await this.dataSource.query( + `UPDATE iam.users + SET metadata = COALESCE(metadata, '{}'::jsonb) || $1::jsonb, + verified_by = 'fayda', + updated_at = NOW() + WHERE id = $2`, + [JSON.stringify(iamMetadata), existingId], + ); + return { iamUserId: existingId, userDataSaved: true }; + } + } + + // Step 3 — new user + const name = { am: normalized.nameAm ?? '', en: normalized.nameEn ?? '' }; + const username = normalized.phoneNumber ?? normalized.email ?? normalized.sub; + const inserted = await this.dataSource.query<{ id: string }[]>( + `INSERT INTO iam.users ( + id, name, username, email, phone_number, metadata, + user_type, status, is_active, has_set_password, + is_phone_number_verified, verified_by, + created_at, updated_at + ) VALUES ( + gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb, + 'individual', 'submitted', true, false, + false, 'fayda', + NOW(), NOW() + ) RETURNING id`, + [ + JSON.stringify(name), + username, + normalized.email ?? null, + normalized.phoneNumber ?? null, + JSON.stringify(iamMetadata), + ], + ); + return { iamUserId: inserted[0].id, userDataSaved: true }; + } catch (err) { + this.logger.error(`Fayda IAM upsert failed: ${(err as Error).message}`); + return { iamUserId: null, userDataSaved: false }; + } + } + + private async createFaydaSession( + iamUserId: string, + ): Promise<{ token: string; refreshToken: string; requiresPassword: boolean }> { + const rows = await this.dataSource.query<{ + id: string; + email: string; + name: { en: string; am: string } | null; + username: string; + phone_number: string | null; + has_set_password: boolean; + status: string; + }[]>( + `SELECT id, email, name, username, phone_number, has_set_password, status + FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + if (!rows.length) throw new Error(`IAM user ${iamUserId} not found`); + const u = rows[0]; + + const userInfo = { + id: u.id, + email: u.email ?? '', + name: u.name ?? { en: '', am: '' }, + userType: 'individual', + status: u.status, + hasSetPassword: u.has_set_password, + isPhoneNumberVerified: false, + hasFinishedRegistration: false, + hasFinishedDMSOnboarding: false, + username: u.username, + phoneNumber: u.phone_number ?? '', + roles: [], + permissions: [], + employee: [], + }; + + const sessions = await this.dataSource.query<{ id: string }[]>( + `INSERT INTO iam.sessions + (id, email, device, "userInfo", expiry_time, refresh_count, status, user_id) + VALUES (gen_random_uuid(), $1, 'fayda-verify', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3) + ON CONFLICT (user_id, device) DO UPDATE + SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo", + expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW() + RETURNING id`, + [u.email ?? '', JSON.stringify(userInfo), iamUserId], + ); + + const sessionId = sessions[0].id; + const token = generateToken({ id: sessionId }); + const refreshToken = generateRefreshToken({ id: sessionId }); + + return { token, refreshToken, requiresPassword: !u.has_set_password }; + } + + private async markSessionFailed( + state: string, + errorCode: string, + errorDescription?: string, + ): Promise { + await this.sessionRepo.update( + { state, status: 'PENDING' }, + { + status: 'FAILED', + errorCode, + errorDescription: errorDescription ?? null, + completedAt: new Date(), + codeVerifier: '', + }, + ); + } + + private classifyFailureReason(err: unknown): string { + if (err instanceof FaydaTokenExchangeException) return 'token_exchange_failed'; + if (err instanceof FaydaUserInfoException) return 'userinfo_failed'; + return 'verification_failed'; + } +} diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts new file mode 100644 index 000000000..442a22d2f --- /dev/null +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.types.ts @@ -0,0 +1,45 @@ +export type VerifaydaPurpose = 'LOGIN' | 'VERIFY'; + +export interface FaydaTokenResponse { + access_token: string; + id_token?: string; + token_type: string; + expires_in?: number; + scope?: string; +} + +export interface FaydaUserInfo { + sub: string; + name?: string; + 'name#en'?: string; + 'name#am'?: string; + phone_number?: string; + 'phone_number#en'?: string; + 'phone_number#am'?: string; + phone?: string; + email?: string; + gender?: string; + birthdate?: string; + picture?: string; + address?: Record; + [key: string]: unknown; +} + +export interface NormalizedFaydaUserInfo { + sub: string; + // Convenience / display fields + fullName?: string; + phoneNumber?: string; // standardized e.g. +251911234567 + email?: string; + gender?: string; + birthdate?: string; + picture?: string; + // Raw localized fields — preserved for IAM-identical writes + nameEn?: string; + nameAm?: string; + genderEn?: string; + genderAm?: string; + addressEn?: string; + addressAm?: string; + rawPhoneNumber?: string; // unstandardized, stored in IAM metadata +} diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts new file mode 100644 index 000000000..7c5ed092c --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts @@ -0,0 +1,59 @@ +import { BaseEntity } from '@edr/api-common'; +import { WagonMovementKind } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { Wagon } from './wagon.entity'; + +/** + * Ledger of every physical wagon relocation between yards — one row per move. + * Written when a wagon carries a booking's leg (LOADED), rides a train empty to + * reposition (EMPTY_REPOSITION), or staff manually correct its yard (MANUAL). + * `wagons.current_yard_id` is the derived "where is it now"; this table is the + * auditable history of how it got there and by whom. + */ +@Entity({ schema: 'freight', name: 'wagon_movements' }) +@Index(['wagonId', 'occurredAt']) +export class WagonMovement extends BaseEntity { + @Column({ name: 'wagon_id', type: 'uuid' }) + wagonId!: string; + + @ManyToOne(() => Wagon, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'wagon_id' }) + wagon?: Wagon; + + /** Null when the prior location is unknown (e.g. first manual registration). */ + @Column({ name: 'from_yard_id', type: 'uuid', nullable: true }) + fromYardId?: string | null; + + @ManyToOne(() => Yard, { nullable: true }) + @JoinColumn({ name: 'from_yard_id' }) + fromYard?: Yard | null; + + @Column({ name: 'to_yard_id', type: 'uuid' }) + toYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'to_yard_id' }) + toYard?: Yard | null; + + /** Set when the move happened by riding a scheduled train (LOADED / EMPTY_REPOSITION). */ + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; + + /** Set when the move carried a specific booking's cargo (kind LOADED). */ + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @Column({ name: 'kind', type: 'varchar', length: 30 }) + kind!: WagonMovementKind; + + @Column({ name: 'moved_by_user_id', type: 'uuid', nullable: true }) + movedByUserId?: string | null; + + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + @Column({ name: 'note', type: 'text', nullable: true }) + note?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index ec98a4a4b..1d5287dba 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -43,6 +43,14 @@ export class WagonsController { return this.wagonsService.findById(id); } + @Get(':id/movements') + @ApiOperation({ + summary: "Wagon movement ledger (loaded legs, empty repositions, manual moves), newest first", + }) + listMovements(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonsService.listMovements(id); + } + @Patch(':id') @FleetManage() @ApiOperation({ summary: 'Update a wagon' }) diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index ac10a2ef3..9d1f1b41f 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -1,4 +1,4 @@ -import { WagonStatus } from '@edr/types'; +import { WagonMovementKind, WagonStatus } from '@edr/types'; import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm'; @@ -8,6 +8,7 @@ import { UpdateWagonDto } from './dto/update-wagon.dto'; import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; import { Wagon } from './entities/wagon.entity'; +import { WagonMovement } from './entities/wagon-movement.entity'; import { Train } from '../trains/entities/train.entity'; @Injectable() @@ -74,8 +75,9 @@ export class WagonsService { return wagon; } - async update(id: string, dto: UpdateWagonDto): Promise { + async update(id: string, dto: UpdateWagonDto, userId?: string | null): Promise { const wagon = await this.findById(id); + const previousYardId = wagon.currentYardId ?? null; Object.assign(wagon, dto); // `findById` eager-loads `currentYard`; when the DTO changes the scalar FK // TypeORM otherwise re-derives `current_yard_id` from the STALE relation @@ -85,11 +87,40 @@ export class WagonsService { wagon.currentYard = null; } await this.wagonRepo.save(wagon); + // Staff manually relocated the wagon — write the movement ledger row so the + // wagon's yard history stays auditable (who moved it, from where, when). + if ( + dto.currentYardId !== undefined && + dto.currentYardId !== null && + dto.currentYardId !== previousYardId + ) { + const movementRepo = this.dataSource.getRepository(WagonMovement); + await movementRepo.save( + movementRepo.create({ + wagonId: id, + fromYardId: previousYardId, + toYardId: dto.currentYardId, + kind: WagonMovementKind.Manual, + movedByUserId: userId ?? null, + occurredAt: new Date(), + }), + ); + } // Re-read with the relation so the response reflects the new yard label // instead of the stale relation object loaded before the assign. return this.findById(id); } + /** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */ + async listMovements(wagonId: string): Promise { + await this.findById(wagonId); // 404 on unknown wagon + return this.dataSource.getRepository(WagonMovement).find({ + where: { wagonId }, + relations: { fromYard: true, toYard: true }, + order: { occurredAt: 'DESC', createdAt: 'DESC' }, + }); + } + async remove(id: string): Promise { const wagon = await this.findById(id); await this.wagonRepo.remove(wagon); diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts index 43cd6f61a..8d18d2a1f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/allocation-rule.dto.ts @@ -1,9 +1,10 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, Matches } from 'class-validator'; export class CreateAllocationRuleDto { @ApiProperty() @IsString() + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiPropertyOptional({ default: 100 }) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts index 5e31c8593..8dd0681ce 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; import { ValidateNested } from 'class-validator'; export class TruckEntranceDto { @@ -90,6 +90,11 @@ export class TruckEntranceDto { @Min(0) grossWeightKg?: number; + @ApiPropertyOptional({ description: 'Whether the customer truck was weighed at receipt.' }) + @IsOptional() + @IsBoolean() + weighingRequired?: boolean; + @ApiPropertyOptional() @IsOptional() @IsNumber() @@ -135,10 +140,11 @@ export class TruckEntranceDto { @IsString() truckType?: string; - @ApiProperty() + @ApiPropertyOptional() + @IsOptional() @IsNumber() @Min(0) - entranceTareWeightKg!: number; + entranceTareWeightKg?: number; @ApiPropertyOptional() @IsOptional() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts index ccdda90d8..56b9d0810 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts @@ -35,7 +35,7 @@ export class CreateWarehouseYardDto { @Min(0) capacityContainers?: number; - @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' }) @IsOptional() @IsNumber() @Min(0) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts index fbb057fd5..eb29f751f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts @@ -35,7 +35,7 @@ export class CreateWarehouseZoneDto { @Min(0) capacityContainers?: number; - @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' }) @IsOptional() @IsNumber() @Min(0) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts index 788c798bf..a99ca4f46 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -1,12 +1,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator'; -import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity'; +import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity'; export class CreateWarehouseDto { @ApiProperty() @IsString() @MaxLength(160) + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiProperty() @@ -46,7 +47,7 @@ export class CreateWarehouseDto { @Min(0) capacityContainers?: number; - @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' }) @IsOptional() @IsNumber() @Min(0) @@ -57,4 +58,9 @@ export class CreateWarehouseDto { @IsNumber() @Min(0) maxVolume?: number; + + @ApiPropertyOptional({ enum: WAREHOUSE_STATUSES, default: 'ACTIVE' }) + @IsOptional() + @IsEnum(WAREHOUSE_STATUSES) + status?: WarehouseStatus; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts index 873f97a6b..a688f3e1a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -1,11 +1,31 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; -import { IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { Type } from 'class-transformer'; +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 { + @ApiProperty({ example: 4 }) + @IsInt() + @Min(1) + fromDay!: number; + + @ApiPropertyOptional({ example: 4, description: 'Inclusive. Leave empty for an open-ended tier.' }) + @IsOptional() + @IsInt() + @Min(1) + toDay?: number | null; + + @ApiProperty({ example: 2500 }) + @IsNumber() + @Min(0) + ratePerDay!: number; +} export class CreateFeeRuleDto { @ApiProperty() @IsString() + @Matches(/^[A-Za-z\s]+$/, { message: 'name may only contain letters and spaces' }) name!: string; @ApiProperty({ enum: FEE_RULE_TYPES }) @@ -62,11 +82,37 @@ export class CreateFeeRuleDto { @Min(0) freeDays!: number; - @ApiProperty() + @ApiProperty({ description: 'Day-based fees: rate/day. Double handling: flat rate per basis unit.' }) @IsNumber() @Min(0) ratePerDay!: number; + @ApiPropertyOptional({ description: 'Truck detention only: grace window in hours (default 3).' }) + @IsOptional() + @IsInt() + @Min(0) + freeHours?: number; + + @ApiPropertyOptional({ description: 'Truck detention only: scope by vehicle type (TRUCK | VAN | TRAILER | …). Null = any.' }) + @IsOptional() + @IsString() + vehicleType?: string; + + @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] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => FeeRuleTierDto) + tiers?: FeeRuleTierDto[]; + @ApiPropertyOptional({ default: 'USD' }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts index 9f89e7c2c..49fb22fde 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -52,6 +52,11 @@ export class FilterWarehouseInventoryDto { @IsEnum(WAREHOUSE_INVENTORY_STATUSES) status?: WarehouseInventoryStatus; + @ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT'] }) + @IsOptional() + @IsEnum(['IMPORT', 'EXPORT']) + direction?: 'IMPORT' | 'EXPORT'; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts index 063bb7d1d..c81550cd0 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts @@ -6,7 +6,7 @@ export class LoadInventoryDto { @IsUUID() wagonId!: string; - @ApiPropertyOptional({ description: 'Weight loaded onto the wagon (kg)' }) + @ApiPropertyOptional({ description: 'Weight loaded onto the wagon (t)' }) @IsOptional() @IsNumber() @Min(0) diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts new file mode 100644 index 000000000..08b15d536 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +/** + * Optional explicit storage location. When warehouse/yard/zone are all provided, + * the item is stored there directly; otherwise store() falls back to the + * allocation-rule / capacity-balanced auto pick. + */ +export class StoreInventoryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts new file mode 100644 index 000000000..f5a730ea8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts deleted file mode 100644 index 8b14dcea3..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice-item.entity.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; - -import { WarehouseFeeInvoice } from './warehouse-fee-invoice.entity'; - -export const WAREHOUSE_FEE_TYPES = [ - 'CONTAINER_DEMURRAGE', - 'BULK_DEMURRAGE', - 'STORAGE_FEE', - 'HANDLING_FEE', -] as const; -export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number]; - -@Entity({ schema: 'freight', name: 'warehouse_fee_invoice_items' }) -@Index(['invoiceId']) -export class WarehouseFeeInvoiceItem extends BaseEntity { - @Column({ name: 'invoice_id', type: 'uuid' }) - invoiceId!: string; - - @ManyToOne(() => WarehouseFeeInvoice, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'invoice_id' }) - invoice?: WarehouseFeeInvoice; - - @Column({ name: 'fee_rule_id', type: 'uuid', nullable: true }) - feeRuleId?: string | null; - - @Column({ name: 'fee_type', type: 'varchar', length: 32 }) - feeType!: WarehouseFeeType; - - @Column({ name: 'description', type: 'varchar', length: 255 }) - description!: string; - - @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 }) - quantity!: number; - - @Column({ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 }) - unitRate!: number; - - @Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - amount!: number; - - @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) - currency!: string; - - @Column({ name: 'chargeable_days', type: 'int', nullable: true }) - chargeableDays?: number | null; - - @Column({ name: 'free_days', type: 'int', nullable: true }) - freeDays?: number | null; -} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts deleted file mode 100644 index e57d626d5..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-invoice.entity.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index } from 'typeorm'; - -export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const; -export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number]; - -export const WAREHOUSE_INVOICE_STATUSES = [ - 'DRAFT', - 'ISSUED', - 'PARTIALLY_PAID', - 'PAID', - 'CANCELLED', -] as const; -export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number]; - -/** A single recorded payment against a warehouse fee invoice (history). */ -export interface WarehouseInvoicePayment { - amount: number; - method?: string | null; - reference?: string | null; - paidAt: string; -} - -/** - * Batch 6 — invoice generated from Batch 5 demurrage/storage fee calculation. - * Owns warehouse fees; links to booking/customer/inventory/location so it can - * connect to the existing payment module without duplicating it. - */ -@Entity({ schema: 'freight', name: 'warehouse_fee_invoices' }) -@Index(['invoiceNumber'], { unique: true }) -@Index(['bookingId']) -@Index(['inventoryId']) -@Index(['status']) -export class WarehouseFeeInvoice extends BaseEntity { - @Column({ name: 'invoice_number', type: 'varchar', length: 40, unique: true }) - invoiceNumber!: string; - - @Column({ name: 'booking_id', type: 'uuid', nullable: true }) - bookingId?: string | null; - - @Column({ name: 'customer_id', type: 'uuid', nullable: true }) - customerId?: string | null; - - @Column({ name: 'inventory_id', type: 'uuid' }) - inventoryId!: string; - - @Column({ name: 'facility_id', type: 'uuid', nullable: true }) - facilityId?: string | null; - - @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) - warehouseId?: string | null; - - @Column({ name: 'yard_id', type: 'uuid', nullable: true }) - yardId?: string | null; - - @Column({ name: 'zone_id', type: 'uuid', nullable: true }) - zoneId?: string | null; - - @Column({ name: 'invoice_type', type: 'varchar', length: 32, default: 'MIXED_WAREHOUSE_FEES' }) - invoiceType!: WarehouseInvoiceType; - - @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) - status!: WarehouseInvoiceStatus; - - @Column({ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - subtotalAmount!: number; - - @Column({ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - taxAmount!: number; - - @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - totalAmount!: number; - - @Column({ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - paidAmount!: number; - - @Column({ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) - balanceAmount!: number; - - @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) - currency!: string; - - /** Charge window covered by this invoice — used to allow a later invoice for a new period. */ - @Column({ name: 'period_start', type: 'timestamptz', nullable: true }) - periodStart?: Date | null; - - @Column({ name: 'period_end', type: 'timestamptz', nullable: true }) - periodEnd?: Date | null; - - @Column({ name: 'issued_at', type: 'timestamptz', nullable: true }) - issuedAt?: Date | null; - - @Column({ name: 'due_date', type: 'timestamptz', nullable: true }) - dueDate?: Date | null; - - @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) - paidAt?: Date | null; - - @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) - cancelledAt?: Date | null; - - @Column({ name: 'payments', type: 'jsonb', default: () => "'[]'" }) - payments!: WarehouseInvoicePayment[]; - - @Column({ name: 'notes', type: 'text', nullable: true }) - notes?: string | null; -} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts index f346be282..a233143cf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts @@ -1,9 +1,29 @@ import { BaseEntity } from '@edr/api-common'; 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]; +/** + * 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 { + fromDay: number; + toDay: number | null; + ratePerDay: number; +} + /** * Batch 5 — configurable storage / demurrage fee rules (no invoice/payment here — that is Batch 6). * The most specific active rule (highest `specificity` then lowest `priority`) applies to an item. @@ -35,6 +55,11 @@ export class WarehouseFeeRule extends BaseEntity { @Column({ name: 'container_type', type: 'varchar', length: 40, nullable: true }) containerType?: string | null; + // Truck detention only: scope by vehicle type (TRUCK | VAN | TRAILER | TANKER + // | FLATBED | …). Null = any truck type. + @Column({ name: 'vehicle_type', type: 'varchar', length: 20, nullable: true }) + vehicleType?: string | null; + @Column({ name: 'facility_id', type: 'uuid', nullable: true }) facilityId?: string | null; @@ -51,9 +76,23 @@ export class WarehouseFeeRule extends BaseEntity { @Column({ name: 'free_days', type: 'int', default: 0 }) freeDays!: number; + // Truck detention only: grace window in HOURS before detention accrues + // (contract default 3h). Null/0 → the 3-hour default. + @Column({ name: 'free_hours', type: 'int', nullable: true }) + freeHours?: number | null; + @Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }) 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: () => "'[]'" }) + tiers!: WarehouseFeeTier[]; + @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) currency!: string; diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index 290b6f0c2..a54f40973 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -34,7 +34,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record { + try { + const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( + `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!b?.companyId) return; + const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`; + await this.inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title: 'Handover — signature needed', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, reference }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body); + } catch (err) { + this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`); + } + } + + list(bookingId: string): Promise { + return this.dataSource.getRepository(BookingHandover).find({ + where: { bookingId }, + order: { generatedAt: 'ASC' }, + }); + } + + /** + * Ask the customer to sign the booking's handover. Ensures a handover exists + * (creates a booking-level self-haul one if none yet), then fires the + * sign-needed notification (in-app + SMS + email). Idempotent to re-send. + */ + async requestSignature( + bookingId: string, + ): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> { + const repo = this.dataSource.getRepository(BookingHandover); + const existing = await repo.find({ where: { bookingId }, order: { generatedAt: 'ASC' } }); + + if (existing.length === 0) { + // No handover yet (truck not arrived): create a booking-level one so the + // customer has something to sign. ensureForArrivedTruck notifies on create. + const created = await this.ensureForArrivedTruck(bookingId, {}); + return { notified: true, reference: created.reference, alreadySigned: false }; + } + + const unsigned = existing.find((h) => !h.signedAt); + if (!unsigned) { + return { notified: false, reference: existing[0].reference, alreadySigned: true }; + } + await this.notifySignNeeded(bookingId, unsigned.reference); + return { notified: true, reference: unsigned.reference, alreadySigned: false }; + } + + /** + * 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 { + 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}`); + void this.notifySignNeeded(bookingId, reference); + 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 { + 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 { + 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 { + 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 { + 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')}`; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index c1faeed22..41ca7facb 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -35,6 +35,7 @@ export interface ImportTrainItemRow { wagonNumber: string | null; sequenceNo: number | null; allocatedWeightTons: number | null; + freightType: string | null; containerNumber: string | null; cargoType: string | null; weight: number | null; @@ -274,6 +275,7 @@ export class SchedulingReadFacade { w.wagon_number AS "wagonNumber", tsw.sequence_no AS "sequenceNo", wba.allocated_weight_tons AS "allocatedWeightTons", + b.freight_type AS "freightType", (SELECT c.container_number FROM freight.containers c WHERE c.booking_id = b.id AND c.deleted_at IS NULL ORDER BY c.container_number LIMIT 1) AS "containerNumber", @@ -338,6 +340,7 @@ export class SchedulingReadFacade { 'LOADED', 'DISPATCHED', 'IN_TRANSIT', + 'ARRIVED', 'ARRIVED_AT_DJIBOUTI', 'ARRIVED_AT_PORT', 'ARRIVED_AT_DESTINATION', diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts deleted file mode 100644 index 5b5df396e..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice-item.repository.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BaseRepository } from '@edr/api-common'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity'; - -@Injectable() -export class WarehouseFeeInvoiceItemRepository extends BaseRepository { - constructor(@InjectRepository(WarehouseFeeInvoiceItem) repository: Repository) { - super(repository); - } -} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts deleted file mode 100644 index 97328f46d..000000000 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee-invoice.repository.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BaseRepository } from '@edr/api-common'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; - -@Injectable() -export class WarehouseFeeInvoiceRepository extends BaseRepository { - constructor(@InjectRepository(WarehouseFeeInvoice) repository: Repository) { - super(repository); - } -} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index e0a0f2b6c..14a3375c7 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -1,9 +1,9 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { ExchangeService } from '@edr/api-common'; import { DataSource } from 'typeorm'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; -import { FeeRuleType, WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; +import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; interface ItemAttributes { @@ -14,8 +14,12 @@ interface ItemAttributes { tradeDirection: string | null; cargoTypeCode: string | null; containerTypeCode: string | null; + /** Vehicle type of the truck (truck detention scoping); null otherwise. */ + vehicleType: string | null; inventoryQuantity: 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; warehouseId: string | null; yardId: string | null; @@ -24,6 +28,8 @@ interface ItemAttributes { export interface FeePreview { ruleType: FeeRuleType; + /** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */ + basis: FeeRuleBasis | null; ruleId: string | null; ruleName: string | null; freeDays: number; @@ -39,6 +45,25 @@ export interface FeePreview { containerCount: number; billableUnits: number; amount: number; + tiers: Array<{ + fromDay: number; + toDay: number | null; + appliedFromDay: number; + appliedToDay: number; + days: number; + ratePerDay: number; + amount: number; + }>; + /** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */ + groups?: Array<{ + vehicleType: string | null; + truckCount: number; + chargeableDays: number; + ratePerDay: number; + amount: number; + ruleId: string | null; + ruleName: string | null; + }>; } const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -57,11 +82,16 @@ export class WarehouseFeeService { } createRule(dto: CreateFeeRuleDto): Promise { - return this.feeRuleRepository.create({ isActive: true, priority: 100, currency: 'USD', ...dto }); + return this.feeRuleRepository.create({ + isActive: true, + priority: 100, + currency: 'USD', + ...this.normalizeRuleInput(dto), + }); } async updateRule(id: string, dto: UpdateFeeRuleDto): Promise { - const updated = await this.feeRuleRepository.update(id, dto); + const updated = await this.feeRuleRepository.update(id, this.normalizeRuleInput(dto)); if (!updated) throw new NotFoundException(`Fee rule ${id} not found`); return updated; } @@ -70,6 +100,40 @@ export class WarehouseFeeService { return this.feeRuleRepository.softDelete(id); } + private normalizeRuleInput(dto: T): T { + if (dto.tiers === undefined) return dto; + const tiers = (dto.tiers ?? []) + .map((tier) => ({ + fromDay: Number(tier.fromDay), + toDay: tier.toDay == null ? null : Number(tier.toDay), + ratePerDay: Number(tier.ratePerDay), + })) + .filter((tier) => tier.fromDay > 0 || tier.toDay != null || tier.ratePerDay > 0); + + for (const tier of tiers) { + if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) { + throw new BadRequestException('Fee tier from day must be a positive whole number.'); + } + if (tier.toDay != null && (!Number.isInteger(tier.toDay) || tier.toDay < tier.fromDay)) { + throw new BadRequestException('Fee tier to day must be empty or greater than/equal to from day.'); + } + if (!Number.isFinite(tier.ratePerDay) || tier.ratePerDay < 0) { + throw new BadRequestException('Fee tier rate per day must be zero or greater.'); + } + } + + const sorted = [...tiers].sort((a, b) => a.fromDay - b.fromDay || (a.toDay ?? Infinity) - (b.toDay ?? Infinity)); + for (let i = 1; i < sorted.length; i += 1) { + const prev = sorted[i - 1]; + const current = sorted[i]; + if (prev.toDay == null || current.fromDay <= prev.toDay) { + throw new BadRequestException('Fee tiers cannot overlap. Use separate from/to day ranges.'); + } + } + + return { ...dto, tiers: sorted } as T; + } + private async loadItem(inventoryId: string): Promise { const [row] = await this.dataSource.query( `SELECT inv.arrived_at AS "arrivedAt", @@ -82,16 +146,28 @@ export class WarehouseFeeService { w.facility_id AS "facilityId", b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", - cgt.code AS "cargoTypeCode", - ctt.code AS "containerTypeCode", - COALESCE(container_lines.container_count, 0) AS "bookingContainerCount" + COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode", + COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode", + COALESCE(container_lines.container_count, 0) AS "bookingContainerCount", + COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity" FROM freight.warehouse_inventory inv 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.cargoes cg ON cg.id = inv.cargo_id LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id + LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id LEFT JOIN freight.containers ct ON ct.id = inv.container_id LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id + LEFT JOIN LATERAL ( + SELECT bc.container_type_id + FROM freight.booking_container bc + WHERE bc.booking_id = inv.booking_id + AND bc.deleted_at IS NULL + AND bc.container_type_id IS NOT NULL + ORDER BY bc.created_at ASC + LIMIT 1 + ) booking_container_type ON true + LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id LEFT JOIN LATERAL ( SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count FROM freight.booking_container bc @@ -108,18 +184,29 @@ export class WarehouseFeeService { private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null { // Returns specificity score (#matched non-null scope fields), or null if any constraint fails. let score = 0; - const check = (ruleVal: string | null | undefined, itemVal: string | null) => { - if (ruleVal == null) return true; - if (itemVal != null && ruleVal.toUpperCase() === itemVal.toUpperCase()) { + const normalized = (value: string | null | undefined) => value?.trim().toUpperCase() || null; + const check = ( + ruleVal: string | null | undefined, + itemVal: string | null, + opts: { allowBoth?: boolean } = {}, + ) => { + const ruleCode = normalized(ruleVal); + if (ruleCode == null || ruleCode === 'ANY' || ruleCode === 'ALL') return true; + if (opts.allowBoth && ruleCode === 'BOTH') { + score += 1; + return true; + } + if (ruleCode === normalized(itemVal)) { score += 1; return true; } return false; }; if (!check(rule.freightType, item.freightType)) return null; - if (!check(rule.tradeDirection, item.tradeDirection)) return null; + if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null; if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null; if (!check(rule.containerType, item.containerTypeCode)) return null; + if (!check(rule.vehicleType, item.vehicleType)) return null; if (!check(rule.facilityId, item.facilityId)) return null; if (!check(rule.warehouseId, item.warehouseId)) return null; if (!check(rule.yardId, item.yardId)) return null; @@ -153,6 +240,60 @@ export class WarehouseFeeService { return Math.round(amount * rate * 100) / 100; } + private calculateTieredAmount( + tiers: WarehouseFeeTier[] | null | undefined, + elapsedDays: number, + containerCount: number, + ): { + sourceAmount: number; + billableUnits: number; + chargeableDays: number; + weightedRatePerDay: number; + tiers: FeePreview['tiers']; + } { + const sourceTiers = (tiers ?? []) + .map((tier) => ({ + fromDay: Number(tier.fromDay), + toDay: tier.toDay == null ? null : Number(tier.toDay), + ratePerDay: Number(tier.ratePerDay), + })) + .filter((tier) => Number.isFinite(tier.fromDay) && tier.fromDay > 0 && Number.isFinite(tier.ratePerDay)) + .sort((a, b) => a.fromDay - b.fromDay); + + let sourceAmount = 0; + let tierDays = 0; + const appliedTiers: FeePreview['tiers'] = []; + + for (const tier of sourceTiers) { + if (elapsedDays < tier.fromDay) continue; + const appliedFromDay = tier.fromDay; + const appliedToDay = Math.min(elapsedDays, tier.toDay ?? elapsedDays); + const days = Math.max(0, appliedToDay - appliedFromDay + 1); + if (days <= 0) continue; + + const amount = Math.round(days * containerCount * tier.ratePerDay * 100) / 100; + sourceAmount += amount; + tierDays += days; + appliedTiers.push({ + fromDay: tier.fromDay, + toDay: tier.toDay, + appliedFromDay, + appliedToDay, + days, + ratePerDay: tier.ratePerDay, + amount, + }); + } + + return { + sourceAmount: Math.round(sourceAmount * 100) / 100, + billableUnits: tierDays * containerCount, + chargeableDays: tierDays, + weightedRatePerDay: tierDays > 0 ? Math.round((sourceAmount / tierDays / containerCount) * 100) / 100 : 0, + tiers: appliedTiers, + }; + } + private async compute( ruleType: FeeRuleType, rule: WarehouseFeeRule | null, @@ -160,6 +301,10 @@ export class WarehouseFeeService { now: Date, billingCurrency: string, ): Promise { + // 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 endDate = item.gateClearedAt ?? item.releaseDate ?? now; const endIsOpen = !item.gateClearedAt && !item.releaseDate; @@ -176,16 +321,29 @@ export class WarehouseFeeService { const elapsedDays = start ? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY)) : 0; - const chargeableDays = Math.max(0, elapsedDays - freeDays); - const billableUnits = chargeableDays * containerCount; - const sourceAmount = Math.round(billableUnits * ratePerDay * 100) / 100; + const tiered = this.calculateTieredAmount(rule?.tiers, elapsedDays, containerCount); + const hasTiers = Boolean(rule?.tiers?.length); + const chargeableDays = hasTiers ? tiered.chargeableDays : Math.max(0, elapsedDays - freeDays); + const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * containerCount; + const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100; const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0; + const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay; const convertedRatePerDay = ruleCurrency - ? await this.convertAmount(ratePerDay, ruleCurrency, targetCurrency) + ? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency) : 0; + const convertedTiers = ruleCurrency + ? await Promise.all( + tiered.tiers.map(async (tier) => ({ + ...tier, + ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency), + amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency), + })), + ) + : []; return { ruleType, + basis: null, ruleId: rule?.id ?? null, ruleName: rule?.name ?? null, freeDays, @@ -201,6 +359,60 @@ export class WarehouseFeeService { containerCount, billableUnits, amount, + tiers: hasTiers ? convertedTiers : [], + }; + } + + /** + * 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 { + 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); + // Double handling applies to IMPORT only — no charge for export/domestic. + const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT'; + const quantity = !isImport ? 0 : 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: [], }; } @@ -210,7 +422,9 @@ export class WarehouseFeeService { const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const now = new Date(); - const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE']; + // Truck detention is a per-truck last-mile charge, not a per-inventory fee — + // it is computed separately via previewTruckDetention(), not here. + const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE', 'DOUBLE_HANDLING_FEE']; return Promise.all( byType.map((type) => this.compute( @@ -223,4 +437,197 @@ export class WarehouseFeeService { ), ); } + + /** + * Truck detention preview for an EDR last-mile leg. The vehicle should be + * returned within the rule's grace window (default 3h) of arriving; beyond + * that, detention accrues per truck per day (flat rate/day or progressive + * tiers by detention day) until it is delivered/returned (or now, if open). + */ + async previewTruckDetention(lastMileId: string, billingCurrency = 'USD'): Promise { + const [leg] = await this.dataSource.query( + `SELECT lm.arrived_at AS "arrivedAt", + lm.delivered_at AS "deliveredAt", + b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection" + FROM freight.last_mile lm + LEFT JOIN freight.bookings b ON b.id = lm.booking_id + WHERE lm.id = $1 AND lm.deleted_at IS NULL`, + [lastMileId], + ); + if (!leg) throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + + // Truck detention applies to IMPORT only — no charge for export/domestic. + if ((leg.tradeDirection ?? '').toUpperCase() !== 'IMPORT') { + const cur = this.normalizeCurrency(billingCurrency); + return { + ruleType: 'TRUCK_DETENTION_FEE', + basis: null, + ruleId: null, + ruleName: null, + freeDays: 0, + ratePerDay: 0, + currency: cur, + ruleCurrency: null, + billingCurrency: cur, + startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null, + endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : new Date()).toISOString(), + endIsOpen: !leg.deliveredAt, + elapsedDays: 0, + chargeableDays: 0, + containerCount: 0, + billableUnits: 0, + amount: 0, + tiers: [], + groups: [], + }; + } + + // Group the leg's vehicles by type so each truck type is billed by its own + // matching rule (rates differ by truck type). Falls back to one untyped group. + const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> = + await this.dataSource.query( + `SELECT v.vehicle_type AS "vehicleType", count(*)::int AS "truckCount" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL + WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL + GROUP BY v.vehicle_type`, + [lastMileId], + ); + const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }]; + + const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); + const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'); + const now = new Date(); + const targetCurrency = this.normalizeCurrency(billingCurrency); + + const computed = await Promise.all( + groups.map(async (g) => { + const item: ItemAttributes = { + arrivedAt: null, + gateClearedAt: null, + releaseDate: null, + freightType: leg.freightType ?? null, + tradeDirection: leg.tradeDirection ?? null, + cargoTypeCode: null, + containerTypeCode: null, + vehicleType: g.vehicleType ?? null, + inventoryQuantity: 1, + bookingContainerCount: 1, + cargoQuantity: 0, + facilityId: null, + warehouseId: null, + yardId: null, + zoneId: null, + }; + const rule = this.bestRule(detentionRules, item); + const c = await this.computeTruckDetention( + rule, + { arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount }, + now, + billingCurrency, + ); + return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c }; + }), + ); + + const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100; + const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0); + const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0); + const chargeableDays = computed[0]?.c.chargeableDays ?? 0; + const single = computed.length === 1 ? computed[0].c : null; + const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null; + + return { + ruleType: 'TRUCK_DETENTION_FEE', + basis: null, + ruleId: single?.ruleId ?? null, + ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName, + freeDays: 0, + ratePerDay: single?.ratePerDay ?? 0, + currency: targetCurrency, + ruleCurrency: single?.ruleCurrency ?? null, + billingCurrency: targetCurrency, + startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null, + endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(), + endIsOpen: !leg.deliveredAt, + elapsedDays: chargeableDays, + chargeableDays, + containerCount: totalTrucks, + billableUnits: totalBillable, + amount: totalAmount, + tiers: single ? single.tiers : [], + groups: computed.map((x) => ({ + vehicleType: x.vehicleType, + truckCount: x.truckCount, + chargeableDays: x.c.chargeableDays, + ratePerDay: x.c.ratePerDay, + amount: x.c.amount, + ruleId: x.c.ruleId, + ruleName: x.c.ruleName, + })), + }; + } + + private async computeTruckDetention( + rule: WarehouseFeeRule | null, + row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number | string }, + now: Date, + billingCurrency: string, + ): Promise { + const graceHours = rule?.freeHours && Number(rule.freeHours) > 0 ? Number(rule.freeHours) : 3; + const truckCount = Math.max(1, Math.round(Number(row.truckCount) || 1)); + const start = row.arrivedAt ? new Date(row.arrivedAt) : null; + const end = row.deliveredAt ? new Date(row.deliveredAt) : now; + const endIsOpen = !row.deliveredAt; + + let chargeableDays = 0; + if (start) { + const detentionMs = end.getTime() - start.getTime() - graceHours * 60 * 60 * 1000; + chargeableDays = detentionMs > 0 ? Math.ceil(detentionMs / MS_PER_DAY) : 0; + } + + const ratePerDay = Number(rule?.ratePerDay ?? 0); + const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null; + const targetCurrency = this.normalizeCurrency(billingCurrency); + const hasTiers = Boolean(rule?.tiers?.length); + const tiered = this.calculateTieredAmount(rule?.tiers, chargeableDays, truckCount); + const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * truckCount; + const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100; + const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0; + const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay; + const convertedRatePerDay = ruleCurrency + ? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency) + : 0; + const convertedTiers = ruleCurrency + ? await Promise.all( + tiered.tiers.map(async (tier) => ({ + ...tier, + ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency), + amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency), + })), + ) + : []; + + return { + ruleType: 'TRUCK_DETENTION_FEE', + basis: null, + ruleId: rule?.id ?? null, + ruleName: rule?.name ?? null, + freeDays: 0, + ratePerDay: convertedRatePerDay, + currency: targetCurrency, + ruleCurrency, + billingCurrency: targetCurrency, + startDate: start ? start.toISOString() : null, + endDate: end.toISOString(), + endIsOpen, + elapsedDays: chargeableDays, + chargeableDays, + containerCount: truckCount, // reused as the per-truck count + billableUnits, + amount, + tiers: hasTiers ? convertedTiers : [], + }; + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts index 1de6daf81..b5894c21b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -1,8 +1,13 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { NotificationAudience, NotificationType } from '@edr/types'; + import { FilesService } from '../files/files.service'; import { LastMileService } from '../last-mile/last-mile.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; @@ -13,11 +18,15 @@ const INSPECTION_RESOURCE = 'warehouse-inspection-report'; @Injectable() export class WarehouseInspectionService { + private readonly logger = new Logger(WarehouseInspectionService.name); + constructor( private readonly dataSource: DataSource, private readonly inspectionRepository: WarehouseInspectionRepository, private readonly filesService: FilesService, private readonly lastMileService: LastMileService, + private readonly inbox: NotificationInboxService, + private readonly notifications: NotificationsService, ) {} /** Create or update the inspection report for an inventory item and sync its inspectionStatus. */ @@ -44,7 +53,7 @@ export class WarehouseInspectionService { expectedWeight: expected, actualWeight: actual, weightLoss, - weightLossUnit: weightLoss !== null ? 'kg' : null, + weightLossUnit: weightLoss !== null ? 't' : null, hasMissingItems: dto.hasMissingItems ?? false, missingItemsDescription: dto.missingItemsDescription ?? null, remarks: dto.remarks ?? null, @@ -83,10 +92,14 @@ export class WarehouseInspectionService { const [row] = await this.dataSource.query( `SELECT inv.booking_id AS "bookingId", b.reference AS "bookingReference", + b.company_id AS "companyId", b.trade_direction AS "tradeDirection", - b.last_mile_delivery_address AS "lastMileDeliveryAddress" + b.last_mile_delivery_address AS "lastMileDeliveryAddress", + b.customer_truck_assigned_at AS "customerTruckAssignedAt", + COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile" FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id + LEFT JOIN freight.service_types st ON st.id = b.service_type_id WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, [inventoryId], @@ -98,8 +111,41 @@ export class WarehouseInspectionService { readyForPickupAt: new Date(), }); - if (row.bookingReference && row.lastMileDeliveryAddress) { + const hasLastMile = + Boolean(row.lastMileDeliveryAddress?.trim?.()) || Boolean(row.serviceIncludesLastMile); + + if (row.bookingReference && hasLastMile) { await this.lastMileService.acceptBooking(row.bookingReference); + } else if (!hasLastMile && !row.customerTruckAssignedAt) { + // Self-haul import: goods are pickup-ready but no collection truck is + // assigned yet — nudge the customer to assign one from the portal. + void this.notifyTruckAssignmentNeeded(row); + } + } + + /** Portal nudge: import goods are ready for pickup but no customer truck is assigned. */ + private async notifyTruckAssignmentNeeded(row: { + bookingId?: string | null; + bookingReference?: string | null; + companyId?: string | null; + }): Promise { + if (!row.companyId || !row.bookingId) return; + const body = `Booking ${row.bookingReference ?? row.bookingId} has passed inspection and is ready for pickup. Please assign your collection truck(s) from the portal to proceed.`; + try { + await this.inbox.notify({ + recipients: { companyId: row.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Assign a truck for pickup', + body, + link: `/bookings/${row.bookingId}`, + data: { bookingId: row.bookingId, action: 'ASSIGN_TRUCK' }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, row.companyId, body); + } catch (err) { + this.logger.warn( + `Truck-assignment notify failed for ${row.bookingId}: ${(err as Error).message}`, + ); } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 6b2bd8c28..9eb4ea502 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -9,12 +9,14 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; +import { StoreInventoryDto } from './dto/store-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; import { SchedulingReadFacade } from './scheduling-read.facade'; import { WarehouseInventoryService } from './warehouse-inventory.service'; +import { HandoverService } from './handover.service'; @ApiTags('warehouse-inventory') @ApiBearerAuth() @@ -23,6 +25,7 @@ export class WarehouseInventoryController { constructor( private readonly inventoryService: WarehouseInventoryService, private readonly scheduling: SchedulingReadFacade, + private readonly handoverService: HandoverService, ) {} @Get() @@ -98,6 +101,27 @@ export class WarehouseInventoryController { 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') @ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' }) bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) { @@ -139,8 +163,18 @@ export class WarehouseInventoryController { @Post('import/auto-unload-arrived-bookings') @ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' }) - autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; performedBy?: string }) { - return this.inventoryService.autoUnloadArrivedBookings(dto.scheduleId, dto.performedBy); + autoUnloadArrivedBookings(@Body() dto: { + scheduleId: string; + warehouseId?: string; + performedBy?: string; + assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[]; + }) { + return this.inventoryService.autoUnloadArrivedBookings( + dto.scheduleId, + dto.performedBy, + dto.warehouseId, + dto.assignments, + ); } @Get('import/unloaded-queue') @@ -234,9 +268,9 @@ export class WarehouseInventoryController { } @Post(':id/store') - @ApiOperation({ summary: 'Mark received inventory as STORED' }) - store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { - return this.inventoryService.store(id, performedBy); + @ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' }) + store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) { + return this.inventoryService.store(id, dto.performedBy, dto); } @Post(':id/ready-for-loading') @@ -273,6 +307,19 @@ export class WarehouseInventoryController { 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') @ApiOperation({ summary: 'View goods received note PDF' }) async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { @@ -302,6 +349,30 @@ export class WarehouseInventoryController { 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); + } + + @Post('bookings/:bookingId/request-handover-signature') + @ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' }) + requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.handoverService.requestSignature(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); + } + + @Get('bookings/:bookingId/container-weights') + @ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" }) + containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.inventoryService.bookingContainerWeights(bookingId); + } + @Post(':id/deliver') @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 001897b3f..14f4e27f3 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; import { SignaturesService } from '../signatures/signatures.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; @@ -38,6 +39,9 @@ import { WarehouseActivityLogService } from './warehouse-activity-log.service'; import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseLoadingRepository } from './warehouse-loading.repository'; import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; +import { HandoverService } from './handover.service'; +import { NotificationAudience, NotificationType } from '@edr/types'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; /** Wagon states that may receive a load (besides being part of an existing schedule). */ const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED']; @@ -180,6 +184,10 @@ interface LocationRef { zoneId: string; } +interface BookingUnloadLocation extends LocationRef { + bookingId: string; +} + interface LocationNode { capacityWeight?: number | null; capacityContainers?: number | null; @@ -221,6 +229,11 @@ export interface EligibleBookingRow { firstMileDriverPhone: string | null; firstMileDriverLicenseNumber: string | null; firstMileTruckType: string | null; + customerTruckPlateNumber: string | null; + customerTruckDriverName: string | null; + customerTruckType: string | null; + customerTruckContainerNumber: string | null; + customerTruckAssignedAt: string | null; } export interface BulkReceiveResult { @@ -263,6 +276,45 @@ export interface BulkDispatchResult { 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 { unloadedCount: number; skippedCount: number; @@ -302,12 +354,19 @@ export interface ImportUnloadedRow { inspectionStatus: string | null; pickupOption: string; lastMileRequested: boolean; + customerTruckPlateNumber: string | null; + customerTruckDriverName: string | null; + customerTruckType: string | null; + customerTruckContainerNumber: string | null; + customerTruckAssignedAt: string | null; + hasAssignedTruck: boolean; currentStatus: string; releaseDate: string | null; releaseOrderReference: string | null; handoverDocumentReference: string | null; handoverDocumentDate: string | null; deliveredAt: string | null; + notes: string | null; } @Injectable() @@ -328,8 +387,42 @@ export class WarehouseInventoryService { private readonly lastMileService: LastMileService, private readonly notifications: NotificationsService, private readonly signatures: SignaturesService, + private readonly handover: HandoverService, + private readonly inbox: NotificationInboxService, ) {} + /** + * When a self-haul booking (no EDR first/last mile) is received to the warehouse + * but has no customer truck assigned yet, nudge the customer to assign one — with + * a deep-link to the booking's truck-assignment card. Fire-and-forget. + */ + private async notifyTruckAssignmentNeeded(booking: { + companyId?: string | null; + reference?: string | null; + hasFirstMile?: boolean; + hasLastMile?: boolean; + customerTruckAssignedAt?: string | null; + }, bookingId: string): Promise { + if (!booking.companyId) return; + if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck + if (booking.customerTruckAssignedAt) return; // already assigned + const body = `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`; + try { + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Assign a truck for pickup', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, action: 'ASSIGN_TRUCK' }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); + } catch (err) { + this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`); + } + } + /** * Batch 6 — final terminal release / gate clearance. * Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch @@ -416,6 +509,7 @@ export class WarehouseInventoryService { ...(filter.status ? { status: filter.status } : {}), ...(createdAt ? { createdAt } : {}), ...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}), + ...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}), }; const search = filter.search?.trim(); @@ -454,7 +548,7 @@ export class WarehouseInventoryService { // ── Batch 4.5: Arrival / Unload / Load automation ────────────────────────── /** Bookings whose goods have arrived and may be unloaded into the warehouse. */ - private readonly ARRIVED_BOOKING_STATUSES = ['IN_TRANSIT']; + private readonly ARRIVED_BOOKING_STATUSES = ['IN_TRANSIT', 'ARRIVED']; /** Arrived bookings + their current inventory/inspection state (queue view). */ async arrivalQueue(): Promise { @@ -504,8 +598,9 @@ export class WarehouseInventoryService { })); } - /** First warehouse that has at least one yard + zone (fallback location for auto-unload). */ - private async pickDefaultLocation(): Promise { + /** First matching warehouse that has at least one yard + zone (fallback location for auto-unload). */ + private async pickDefaultLocation(warehouseId?: string): Promise { + const params = warehouseId ? [warehouseId] : []; const [row]: DefaultLocation[] = await this.dataSource.query( `SELECT wh.id AS "warehouseId", wh.facility_id AS "facilityId", yard.id AS "yardId", zone.id AS "zoneId" @@ -513,8 +608,10 @@ export class WarehouseInventoryService { JOIN freight.warehouse_yards yard ON yard.warehouse_id = wh.id AND yard.deleted_at IS NULL JOIN freight.warehouse_zones zone ON zone.yard_id = yard.id AND zone.deleted_at IS NULL WHERE wh.deleted_at IS NULL + ${warehouseId ? 'AND wh.id = $1' : ''} ORDER BY wh.created_at ASC - LIMIT 1`, + LIMIT 1`, + params, ); return row ?? null; } @@ -592,6 +689,7 @@ export class WarehouseInventoryService { dto.warehouseId && dto.yardId && dto.zoneId ? { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, facilityId: dto.facilityId ?? null } : null; + if (!location && dto.warehouseId) location = await this.pickDefaultLocation(dto.warehouseId); if (!location) location = await this.pickDefaultLocation(); if (!location) { throw new BadRequestException('No warehouse/yard/zone provided or configured for unloading'); @@ -704,7 +802,12 @@ export class WarehouseInventoryService { ) AS "firstMileDriverName", driver.phone_number AS "firstMileDriverPhone", driver.license_number AS "firstMileDriverLicenseNumber", - v.vehicle_type AS "firstMileTruckType" + v.vehicle_type AS "firstMileTruckType", + b.customer_truck_plate_number AS "customerTruckPlateNumber", + b.customer_truck_driver_name AS "customerTruckDriverName", + b.customer_truck_type AS "customerTruckType", + b.customer_truck_container_number AS "customerTruckContainerNumber", + b.customer_truck_assigned_at AS "customerTruckAssignedAt" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id @@ -796,7 +899,15 @@ export class WarehouseInventoryService { ) AS "firstMileDriverName", driver.phone_number AS "firstMileDriverPhone", driver.license_number AS "firstMileDriverLicenseNumber", - v.vehicle_type AS "firstMileTruckType" + v.vehicle_type AS "firstMileTruckType", + b.customer_truck_plate_number AS "customerTruckPlateNumber", + b.customer_truck_driver_name AS "customerTruckDriverName", + b.customer_truck_type AS "customerTruckType", + b.customer_truck_container_number AS "customerTruckContainerNumber", + b.customer_truck_assigned_at AS "customerTruckAssignedAt", + b.company_id AS "companyId", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id @@ -891,6 +1002,23 @@ export class WarehouseInventoryService { }), ); + // Receiving the booking flags every container unit as received into the + // port (self-haul export: the delivering truck's goods are now in) so + // staff can raise the per-container GRN over what's received. + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_container bc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND bc.deleted_at IS NULL + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [bookingId], + ); + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -915,6 +1043,7 @@ export class WarehouseInventoryService { result.receivedCount += 1; result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); + void this.notifyTruckAssignmentNeeded(booking, bookingId); } }); @@ -1022,6 +1151,169 @@ export class WarehouseInventoryService { 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 { + 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 { + const rows: Array> = 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 { + 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(); + + 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. */ private async importQueueByStatuses(statuses: string[]): Promise { const rows: Array< @@ -1041,21 +1333,35 @@ export class WarehouseInventoryService { COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", ts.train_number AS "trainSchedule", inv.inspection_status AS "inspectionStatus", - CASE WHEN b.last_mile_delivery_address IS NOT NULL + CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false) THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption", - (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL + OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested", + b.customer_truck_plate_number AS "customerTruckPlateNumber", + b.customer_truck_driver_name AS "customerTruckDriverName", + b.customer_truck_type AS "customerTruckType", + b.customer_truck_container_number AS "customerTruckContainerNumber", + b.customer_truck_assigned_at AS "customerTruckAssignedAt", + (b.customer_truck_assigned_at IS NOT NULL + OR EXISTS (SELECT 1 FROM freight.last_mile lm + WHERE lm.booking_id = b.id + AND lm.vehicle_id IS NOT NULL + AND lm.deleted_at IS NULL)) AS "hasAssignedTruck", inv.status AS "currentStatus", inv.release_date AS "releaseDate", inv.release_order_reference AS "releaseOrderReference", substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference", substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate", inv.delivered_at AS "deliveredAt", + inv.notes AS "notes", oy.country AS "originCountry", dy.country AS "destinationCountry" FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.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.service_types st ON st.id = b.service_type_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL @@ -1157,6 +1463,8 @@ export class WarehouseInventoryService { async autoUnloadArrivedBookings( scheduleId: string, performedBy?: string, + warehouseId?: string, + assignments: BookingUnloadLocation[] = [], ): Promise { const result: AutoUnloadArrivedResult = { unloadedCount: 0, skippedCount: 0, failedCount: 0, results: [] }; @@ -1203,7 +1511,21 @@ export class WarehouseInventoryService { [scheduleId], ); - const fallback = await this.pickDefaultLocation(); + const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null; + if (warehouseId && !requestedLocation) { + throw new BadRequestException('Selected warehouse has no yard/zone configured for unloading'); + } + const fallback = requestedLocation ?? (await this.pickDefaultLocation()); + const assignmentByBooking = new Map( + assignments.map((assignment) => [ + assignment.bookingId, + { + warehouseId: assignment.warehouseId, + yardId: assignment.yardId, + zoneId: assignment.zoneId, + } satisfies LocationRef, + ]), + ); const now = new Date(); for (const booking of bookings) { @@ -1223,6 +1545,8 @@ export class WarehouseInventoryService { try { const existing = (await this.inventoryRepository.findAll({ where: { bookingId: booking.id } }))[0]; + const assignedLocation = assignmentByBooking.get(booking.id) ?? null; + const unloadLocation = assignedLocation ?? requestedLocation; // Already unloaded or further along — leave it (do not regress the lifecycle). if (existing && existing.status !== 'RECEIVED') { @@ -1232,6 +1556,13 @@ export class WarehouseInventoryService { if (existing) { await this.inventoryRepository.update(existing.id, { + ...(unloadLocation + ? { + warehouseId: unloadLocation.warehouseId, + yardId: unloadLocation.yardId, + zoneId: unloadLocation.zoneId, + } + : {}), status: 'UNLOADED', unloadedAt: now, arrivedAt: existing.arrivedAt ?? now, @@ -1239,7 +1570,7 @@ export class WarehouseInventoryService { await this.activityLog.record({ activityType: 'INVENTORY_UNLOADED', inventoryId: existing.id, - warehouseId: existing.warehouseId, + warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId, description: 'Unloaded from arrived import train', performedBy, }); @@ -1254,7 +1585,7 @@ export class WarehouseInventoryService { tradeDirection: booking.tradeDirection, cargoTypeCode: booking.cargoTypeCode, }); - const location = allocated ?? fallback; + const location = assignedLocation ?? requestedLocation ?? allocated ?? fallback; if (!location) { fail('No warehouse/yard/zone configured'); continue; @@ -1336,6 +1667,16 @@ export class WarehouseInventoryService { if (!['ARRIVED', 'ARRIVED_AT_DJIBOUTI'].includes(schedule.status)) { throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`); } + const [gatepass] = await this.dataSource.query( + `SELECT gatepass_granted_at AS "gatepassSecuredAt" + FROM freight.import_djibouti_operations + WHERE train_schedule_id = $1 AND deleted_at IS NULL + LIMIT 1`, + [scheduleId], + ); + if (!gatepass?.gatepassSecuredAt) { + throw new BadRequestException('Djibouti Port entry blocked: gate pass status is NOT_SECURED'); + } const items: Array<{ bookingId: string; @@ -1631,13 +1972,17 @@ export class WarehouseInventoryService { if (!bookingId) return; const [booking] = await this.dataSource.query( `SELECT reference, - last_mile_delivery_address AS "lastMileDeliveryAddress" - FROM freight.bookings - WHERE id = $1 AND deleted_at IS NULL + last_mile_delivery_address AS "lastMileDeliveryAddress", + COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile" + FROM freight.bookings b + LEFT JOIN freight.service_types st ON st.id = b.service_type_id + WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, [bookingId], ); - if (!booking?.reference || !booking.lastMileDeliveryAddress) return; + const hasLastMile = + Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile); + if (!booking?.reference || !hasLastMile) return; await this.lastMileService.acceptBooking(booking.reference); } @@ -1698,12 +2043,32 @@ export class WarehouseInventoryService { await this.applyCapacityDelta(manager, dto, weight, volume, containerCount); + // Per-container receive: flag this container's unit as received into the + // port so staff can raise the GRN over what's received. + if (dto.bookingId && dto.containerId) { + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_container bc, freight.containers cont + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND bc.deleted_at IS NULL + AND cont.id = $2 + AND cont.container_number = bcu.container_number + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [dto.bookingId, dto.containerId], + ); + } + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', inventoryId: saved.id, warehouseId: dto.warehouseId, - description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`, + description: `GRN ${grnNumber}: received ${weight}t via truck ${truckEntrance.truckPlateNumber}`, performedBy: dto.performedBy, }, manager, @@ -1787,13 +2152,30 @@ export class WarehouseInventoryService { // ── Lifecycle transitions ──────────────────────────────────────────────── - async store(id: string, performedBy?: string): Promise { + async store( + id: string, + performedBy?: string, + chosen?: { warehouseId?: string; yardId?: string; zoneId?: string }, + ): Promise { const item = await this.findById(id); this.assertTransition(item.status, 'STORED'); + // Explicit location wins when the operator picked warehouse + yard + zone; + // otherwise fall back to the allocation-rule / capacity-balanced auto pick. + const manualLocation = + chosen?.warehouseId && chosen?.yardId && chosen?.zoneId + ? { + warehouseId: chosen.warehouseId, + yardId: chosen.yardId, + zoneId: chosen.zoneId, + path: undefined as string | undefined, + } + : null; + const criteria = await this.getInventoryAllocationCriteria(item); - const ruleLocation = await this.allocation.resolveLocation(criteria); - const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria)); + const ruleLocation = manualLocation ? null : await this.allocation.resolveLocation(criteria); + const location = + manualLocation ?? ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria)); if (!location) { throw new BadRequestException('No active warehouse yard/zone is available for this inventory item'); @@ -1837,18 +2219,19 @@ export class WarehouseInventoryService { await this.applyCapacityDelta(manager, location, weight, volume, containerCount); } + const storedReason = manualLocation + ? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}` + : ruleLocation?.rule + ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}` + : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`; + await manager.getRepository(WarehouseInventory).update(id, { status: 'STORED', storedAt: new Date(), warehouseId: location.warehouseId, yardId: location.yardId, zoneId: location.zoneId, - notes: this.appendNote( - locked.notes, - ruleLocation?.rule - ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}` - : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`, - ), + notes: this.appendNote(locked.notes, storedReason), }); await this.activityLog.record( @@ -1856,9 +2239,7 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_STORED', inventoryId: id, warehouseId: location.warehouseId, - description: ruleLocation?.rule - ? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}` - : `Inventory stored at ${location.path ?? 'assigned yard/zone'}`, + description: storedReason.replace(/^Stored/, 'Inventory stored'), performedBy, }, manager, @@ -1955,11 +2336,60 @@ export class WarehouseInventoryService { } const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); + if (isTruckLeaving) { + await this.invoices.assertClearanceAllowed(id); + + if (item.bookingId) { + const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> = + await this.dataSource.query( + `SELECT customer_truck_assigned_at AS "customerTruckAssignedAt" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [item.bookingId], + ); + const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); + // 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( + 'Customer must sign the handover before the exit paper can be generated', + ); + } + + // Authoritative weight match: the truck's net (gross − tare) must equal the + // total VGM cargo weight of the containers selected as loaded on it. + if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) { + const selected = dto.containerNumber + .split(/[,;\n]+/) + .map((n) => n.trim()) + .filter(Boolean); + if (selected.length) { + const weights = await this.bookingContainerWeights(item.bookingId); + const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons])); + const expected = selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0); + const computedNet = Number((dto.grossWeight - dto.tareWeight).toFixed(3)); + if (expected > 0 && Math.abs(computedNet - expected) > 0.001) { + throw new BadRequestException( + `Weight mismatch: gross − tare (${computedNet} t) must equal the selected containers' cargo weight (${expected} t).`, + ); + } + } + } + } + } const releaseDate = isTruckLeaving ? dto.releaseDate ? new Date(dto.releaseDate) : new Date() : item.releaseDate ?? null; - const reference = dto.reference?.trim() || (await this.generateReleaseReference(item)); - const exitInspectionNote = this.buildExitInspectionNote(dto); + const reference = isTruckLeaving + ? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item)) + : dto.reference?.trim() || (await this.generateReleaseReference(item)); + const exitInspectionDto = isTruckLeaving + ? this.preserveTruckArrivalForExit(dto, item.notes) + : dto; + const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto); await this.dataSource.transaction(async (manager) => { await manager.getRepository(WarehouseInventory).update(id, { @@ -1967,6 +2397,50 @@ export class WarehouseInventoryService { releaseOrderReference: reference, notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote), }); + if (!isTruckLeaving && item.bookingId) { + // Per-truck arrival: mark the customer truck carrying THIS item's + // container as arrived (matched via the physical container number). + if (item.containerId) { + await manager.query( + `UPDATE freight.customer_truck_assignments a + SET arrived_at = COALESCE(a.arrived_at, NOW()), updated_at = NOW() + FROM freight.customer_truck_containers c + JOIN freight.containers cont ON cont.container_number = c.container_number + WHERE c.assignment_id = a.id + AND c.deleted_at IS NULL + AND c.booking_id = $1 + AND cont.id = $2 + AND a.arrived_at IS NULL + AND a.deleted_at IS NULL`, + [item.bookingId, item.containerId], + ); + // NB: import arrival changes nothing on the goods — received_to_port is + // an EXPORT concept (set when a truck delivers into the port). Import + // load + weight are captured on truck departure, not arrival. + } + // Booking-level flag stamped on the FIRST truck arrival. The import + // handover is signed ONCE (before the first truck leaves), even though + // trucks pick up per-container — COALESCE keeps the first timestamp. + await manager.query( + `UPDATE freight.bookings + SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()), + updated_at = NOW() + WHERE id = $1 + AND customer_truck_assigned_at IS NOT NULL + AND deleted_at IS NULL`, + [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( { activityType: 'INVENTORY_RELEASED', @@ -2034,6 +2508,49 @@ export class WarehouseInventoryService { if (!row.releaseDate) { throw new BadRequestException('A release order must be issued before downloading the exit paper'); } + await this.invoices.assertClearanceAllowed(id); + + // Import self-haul: the exit paper names the pickup truck + all containers it + // carries, so gate staff can verify the goods leaving on that truck. + let truck: { + plateNumber: string; + driverName: string; + truckType: string; + containerNumbers: string; + truckWeightTons: string | number | null; + grossWeightKg: string | number | null; + departedAt: string | null; + } | null = null; + if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { + const [truckRow] = await this.dataSource.query( + `SELECT 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", + string_agg(DISTINCT c2.container_number, ', ' ORDER BY c2.container_number) AS "containerNumbers", + COALESCE(( + SELECT SUM(bcu.vgm_tons) + FROM freight.customer_truck_containers cc + JOIN freight.booking_container_units bcu + ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + AND bc.booking_id = c.booking_id + WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL + ), 0) AS "truckWeightTons" + FROM freight.customer_truck_containers c + JOIN freight.customer_truck_assignments a + ON a.id = c.assignment_id AND a.deleted_at IS NULL + JOIN freight.customer_truck_containers c2 + ON c2.assignment_id = a.id AND c2.deleted_at IS NULL + WHERE c.booking_id = $1 AND c.container_number = $2 AND c.deleted_at IS NULL + GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, c.booking_id + LIMIT 1`, + [row.bookingId, row.containerNumber], + ); + truck = truckRow ?? null; + } const bookingReference = row?.bookingReference || 'N/A'; const reference = @@ -2058,6 +2575,17 @@ export class WarehouseInventoryService { inventoryStatus: row?.status ?? null, clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT', exitInspectionSummary: this.extractExitInspectionNote(row?.notes), + truckPlateNumber: truck?.plateNumber ?? null, + truckDriverName: truck?.driverName ?? null, + truckType: truck?.truckType ?? null, + truckGateOut: truck?.departedAt ?? null, + // Prefer the weighed gross captured on departure; fall back to the summed + // container VGM when the truck hasn't been weighed yet. + truckWeightKg: truck + ? Number(truck.grossWeightKg ?? 0) > 0 + ? Number(truck.grossWeightKg) + : Number(truck.truckWeightTons ?? 0) * 1000 + : null, }); return { @@ -2066,6 +2594,235 @@ 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; + handoverSigned: 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], + ); + + // Booking-level gate: the per-truck exit paper is blocked until the handover + // is fully signed, so the UI can disable "Exit Paper" with a clear reason. + const handoverSigned = await this.handover.isFullySigned(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, + handoverSigned, + })); + } + + /** + * The booking's containers with their VGM cargo weight (tonnes), keyed by + * container number. Drives the truck-leaving exit weighing: the selected + * containers' total cargo weight must match (gross − tare). + */ + async bookingContainerWeights( + bookingId: string, + ): Promise> { + const rows: Array<{ containerNumber: string; weightTons: string }> = + await this.dataSource.query( + `SELECT bcu.container_number AS "containerNumber", + COALESCE(bcu.vgm_tons, 0) AS "weightTons" + 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.deleted_at IS NULL + ORDER BY bcu.container_number`, + [bookingId], + ); + return rows.map((r) => ({ + containerNumber: r.containerNumber, + weightTons: Number(r.weightTons) || 0, + })); + } + + /** + * 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, '>'); + 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()} t`], + ['Gate-Out Time', gateOut], + ['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'], + ]; + const containerRows = data.containers.length + ? data.containers + .map((c) => `${esc(c.containerNumber)}${esc(c.goods)}`) + .join('') + : 'No containers loaded on this truck.'; + return `Warehouse Exit Paper + + +
Ethio-Djibouti Railway S.C.
+

Warehouse Release / Exit Paper

+
Document / Release No. ${esc(data.reference)}
+
Release Particulars
+ ${rows.map(([l, v]) => ``).join('')}
${esc(l)}${esc(v)}
+
Containers Leaving on This Truck
+ + ${containerRows}
Container NumberGoods
+ `; + } + /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */ async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( @@ -2163,7 +2920,17 @@ export class WarehouseInventoryService { return { 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', + ), }; } @@ -2180,11 +2947,19 @@ export class WarehouseInventoryService { throw new BadRequestException('Please save your signature before approving delivery'); } - const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> = + const [item]: Array<{ + id: string; + warehouseId: string | null; + notes: string | null; + customerTruckAssignedAt: string | null; + customerTruckArrivedAt: string | null; + }> = await this.dataSource.query( `SELECT inv.id, inv.warehouse_id AS "warehouseId", - inv.notes + inv.notes, + b.customer_truck_assigned_at AS "customerTruckAssignedAt", + b.customer_truck_arrived_at AS "customerTruckArrivedAt" FROM freight.warehouse_inventory inv JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL WHERE inv.booking_id = $1 @@ -2198,6 +2973,10 @@ export class WarehouseInventoryService { if (!item) { throw new BadRequestException('Delivery can be approved after warehouse inspection has passed'); } + if (item.customerTruckAssignedAt && !item.customerTruckArrivedAt) { + throw new BadRequestException('Customer truck arrival must be recorded before delivery approval'); + } + await this.invoices.assertClearanceAllowed(item.id); const approvedAt = new Date(); const approval = { @@ -2225,6 +3004,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 { bookingId, inventoryId: item.id, @@ -2301,6 +3084,7 @@ export class WarehouseInventoryService { if (!row) { throw new NotFoundException(`Inventory item ${id} not found`); } + await this.invoices.assertClearanceAllowed(id); if (row.inspectionStatus !== 'PASSED') { throw new BadRequestException('Handover document is available after inspection has passed'); } @@ -2351,7 +3135,17 @@ export class WarehouseInventoryService { return { 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', + ), }; } @@ -2363,6 +3157,29 @@ export class WarehouseInventoryService { 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 deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date(); const weight = Number(item.weight) || 0; @@ -2407,6 +3224,27 @@ export class WarehouseInventoryService { }, 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); @@ -2885,8 +3723,8 @@ export class WarehouseInventoryService { ['Booking Containers', data.bookingContainerSummary], ['Cargo / Goods Description', data.cargoDescription], ['Quantity', data.quantity], - ['Received Weight', `${data.weight.toLocaleString()} kg`], - ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null], + ['Received Weight', `${data.weight.toLocaleString()} t`], + ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null], ['Volume', data.volume == null ? null : data.volume.toLocaleString()], ['Warehouse', data.warehouse], ['Yard', data.yard], @@ -2974,6 +3812,11 @@ export class WarehouseInventoryService { inventoryStatus: string | null; clearanceStatus: string; exitInspectionSummary?: string | null; + truckPlateNumber?: string | null; + truckDriverName?: string | null; + truckType?: string | null; + truckGateOut?: string | null; + truckWeightKg?: number | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -2998,12 +3841,37 @@ export class WarehouseInventoryService { ['Container Number', data.containerNumber], ['Cargo / Goods Description', data.cargoDescription], ['Quantity', data.quantity], - ['Declared Weight', `${data.weight.toLocaleString()} kg`], + [ + data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight', + `${(data.truckPlateNumber && data.truckWeightKg + ? data.truckWeightKg + : data.weight + ).toLocaleString()} t`, + ], ['Warehouse', data.warehouse], ['Yard', data.yard], ['Zone', data.zone], ['Inventory Status', data.inventoryStatus], ['Clearance Status', data.clearanceStatus], + ...(data.truckPlateNumber + ? ([ + ['Pickup Truck Plate', data.truckPlateNumber], + ['Truck Driver', data.truckDriverName], + ['Truck Type', data.truckType], + [ + 'Gate-Out Time', + data.truckGateOut + ? new Date(data.truckGateOut).toLocaleString('en-GB', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + : null, + ], + ] as [string, string | null][]) + : []), ...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []), ]; @@ -3141,8 +4009,8 @@ export class WarehouseInventoryService { ['Booking Containers', data.bookingContainerSummary], ['Cargo / Goods Description', data.cargoDescription], ['Quantity', data.quantity], - ['Inventory Weight', `${data.weight.toLocaleString()} kg`], - ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null], + ['Inventory Weight', `${data.weight.toLocaleString()} t`], + ['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null], ['Warehouse', data.warehouse], ['Yard', data.yard], ['Zone', data.zone], @@ -3215,8 +4083,8 @@ export class WarehouseInventoryService { 1. Goods${esc(data.cargoDescription || data.containerNumber || data.bookingReference)} Container${esc(data.containerNumber)} Booking Containers${esc(data.bookingContainerSummary)} - Inventory Weight${esc(`${data.weight.toLocaleString()} kg`)} - Booking Declared Weight${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null)} + Inventory Weight${esc(`${data.weight.toLocaleString()} t`)} + Booking Declared Weight${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null)}
Handover Clause
@@ -3302,8 +4170,13 @@ export class WarehouseInventoryService { if (!truckEntrance.driverPhone?.trim()) { throw new BadRequestException('Driver phone is required for entrance registration'); } - if (truckEntrance.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) { - throw new BadRequestException('Entrance tare weight is required for entrance registration'); + if (truckEntrance.weighingRequired) { + if (truckEntrance.grossWeightKg === undefined || Number(truckEntrance.grossWeightKg) < 0) { + throw new BadRequestException('Gross weight is required when customer truck weighing is Yes'); + } + if (truckEntrance.exitTareWeightKg === undefined || Number(truckEntrance.exitTareWeightKg) < 0) { + throw new BadRequestException('Exit tare weight is required when customer truck weighing is Yes'); + } } } @@ -3325,6 +4198,11 @@ export class WarehouseInventoryService { firstMileDriverPhone?: string | null; firstMileDriverLicenseNumber?: string | null; firstMileTruckType?: string | null; + customerTruckPlateNumber?: string | null; + customerTruckDriverName?: string | null; + customerTruckType?: string | null; + customerTruckContainerNumber?: string | null; + customerTruckAssignedAt?: string | null; }, ): TruckEntranceDto { return { @@ -3340,16 +4218,22 @@ export class WarehouseInventoryService { booking.containerQuantity !== undefined && booking.containerQuantity !== null ? Number(booking.containerQuantity) : submitted.unitCount, - grossWeightKg: - booking.weight !== undefined && booking.weight !== null - ? Number(booking.weight) - : submitted.grossWeightKg, - truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber, + grossWeightKg: submitted.grossWeightKg, + truckPlateNumber: + booking.firstMileTruckPlateNumber?.trim() || + booking.customerTruckPlateNumber?.trim() || + submitted.truckPlateNumber, trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber, - driverName: booking.firstMileDriverName?.trim() || submitted.driverName, + driverName: + booking.firstMileDriverName?.trim() || + booking.customerTruckDriverName?.trim() || + submitted.driverName, driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone, driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber, - truckType: booking.firstMileTruckType?.trim() || submitted.truckType, + truckType: + booking.firstMileTruckType?.trim() || + booking.customerTruckType?.trim() || + submitted.truckType, }; } @@ -3534,15 +4418,33 @@ export class WarehouseInventoryService { dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null, dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null, dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null, - `Tare Weight: ${tareWeight} kg`, - grossWeight == null ? null : `Gross Weight: ${grossWeight} kg`, - computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} kg`, + `Tare Weight: ${tareWeight} t`, + grossWeight == null ? null : `Gross Weight: ${grossWeight} t`, + computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`, dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null, ]; return rows.filter(Boolean).join('\n'); } + private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto { + const inspection = this.extractExitInspectionNote(notes); + if (!inspection) return dto; + + return { + ...dto, + truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber, + trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber, + driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName, + driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense, + driverPhone: this.extractExitInspectionLine(inspection, 'Driver Phone') || dto.driverPhone, + truckType: this.extractExitInspectionLine(inspection, 'Truck Type') || dto.truckType, + containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber, + gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime, + tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight, + }; + } + private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null { const trimmed = notes?.trim(); if (!exitInspectionNote) return trimmed || null; @@ -3564,6 +4466,18 @@ export class WarehouseInventoryService { return notes.slice(index + marker.length).trim() || null; } + private extractExitInspectionLine(note: string | null | undefined, label: string): string | null { + const match = note?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im')); + return match?.[1]?.trim() || null; + } + + private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined { + const value = this.extractExitInspectionLine(note, label)?.replace(/\s*(kg|t)$/i, ''); + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + private extractReceiveSummary(notes?: string | null): string | null { if (!notes?.trim()) return null; const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes; @@ -3621,8 +4535,9 @@ export class WarehouseInventoryService { truck?.driverName ? `Driver: ${truck.driverName}` : null, truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null, truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null, - truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null, - truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null, + truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} t` : null, + truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null, + truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} t` : null, truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null, truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null, truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null, @@ -3630,8 +4545,8 @@ export class WarehouseInventoryService { truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null, truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null, truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null, - truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null, - truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null, + truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} t` : null, + truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} t` : null, truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null, truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null, truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index a66cf91d0..a1c5db837 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; +import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto'; import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; @@ -17,6 +18,15 @@ export class WarehouseInvoiceController { return this.invoiceService.generateForInventory(id, dto); } + @Post('last-mile/:id/generate-truck-detention-invoice') + @ApiOperation({ summary: 'Generate a truck-detention invoice for a last-mile leg (per truck per day)' }) + generateTruckDetention( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: GenerateInvoiceDto, + ) { + return this.invoiceService.generateTruckDetentionInvoice(id, dto); + } + @Get('warehouse-inventory/:id/fee-invoices') @ApiOperation({ summary: 'List fee invoices for an inventory item' }) listForInventory(@Param('id', ParseUUIDPipe) id: string) { @@ -86,4 +96,10 @@ export class WarehouseInvoiceController { pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) { return this.invoiceService.pay(id, dto); } + + @Post('warehouse-fee-invoices/:id/pay-online') + @ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' }) + payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) { + return this.invoiceService.initiatePayment(id, dto); + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 1fe184662..2508e373a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,22 +1,43 @@ -import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { DataSource } from 'typeorm'; - -import { NotificationsService } from '../notifications/notifications.service'; import { - WarehouseFeeInvoice, + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { Freight, NotificationAudience, NotificationType } from "@edr/types"; +import { DataSource } from "typeorm"; + +import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; + +import { + BillingService, + InvoiceEventPayload, + InvoiceLineInput, +} from "../billing/billing.service"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { InvoiceLine } from "../billing/entities/invoice-line.entity"; + +import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto"; +import { + InvoiceDocumentModel, + InvoiceDocumentService, +} from "../billing/documents/invoice-document.service"; +import { NotificationsService } from "../notifications/notifications.service"; +import { WarehouseFeeService } from "./warehouse-fee.service"; +import { + WarehouseFeeInvoiceView, + WarehouseFeeType, + WarehouseInvoiceItemView, WarehouseInvoiceStatus, WarehouseInvoiceType, -} from './entities/warehouse-fee-invoice.entity'; -import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity'; -import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; -import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; -import { WarehouseFeeService } from './warehouse-fee.service'; -import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; +} from "./warehouse-invoice.types"; interface GenerateOptions { confirmZero?: boolean; performedBy?: string; - billingCurrency?: 'ETB' | 'USD'; + billingCurrency?: "ETB" | "USD"; } export interface PayInvoiceDto { @@ -27,9 +48,21 @@ export interface PayInvoiceDto { driverPhone?: string; } -/** Invoices that still owe money and therefore block terminal release. */ -const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID']; -const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID']; +/** Warehouse fee invoices live in the global billing system under this source. */ +const SOURCE = Freight.InvoiceSource.Warehouse; + +/** Global statuses that still owe money and therefore block terminal release. */ +const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [ + Freight.InvoiceStatus.Issued, + Freight.InvoiceStatus.Pending, + Freight.InvoiceStatus.PartiallyPaid, + Freight.InvoiceStatus.Overdue, +]; +/** Global statuses considered an "active" invoice for per-inventory dedup. */ +const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [ + ...BLOCKING_STATUSES, + Freight.InvoiceStatus.Paid, +]; export interface InvoiceDocumentDetails { bookingReference: string | null; @@ -45,64 +78,145 @@ export interface InvoiceDocumentDetails { zoneName: string | null; } -export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial; +export type WarehouseFeeInvoiceDetail = WarehouseFeeInvoiceView & + Partial & { items: WarehouseInvoiceItemView[] }; +/** The warehouse-specific columns derived from the linked inventory item. */ +interface InventoryContext { + bookingId: string | null; + facilityId: string | null; + warehouseId: string | null; + yardId: string | null; + zoneId: string | null; + periodStart: Date | null; +} + +/** Source fields a view is projected from — satisfied by the global {@link Invoice}. */ +interface ViewSource { + id: string; + invoiceNumber: string; + companyId: string; + sourceId: string; + type: string; + status: Freight.InvoiceStatus | string; + subtotalAmount: number | string; + taxAmount: number | string; + totalAmount: number | string; + paidAmount: number | string; + balanceAmount: number | string; + currency: string; + issuedAt?: Date | null; + dueAt?: Date | null; + paidAt?: Date | null; + createdAt: Date; + updatedAt: Date; + payments?: Array<{ + amount: number | string; + method?: string | null; + reference?: string | null; + paidAt: string; + }> | null; +} + +/** + * Thin warehouse layer over the central {@link BillingService}. Warehouse fee + * invoices are global `Invoice` rows (`source = warehouse`, `sourceId = + * inventoryId`); this service owns only the warehouse-specific concerns — + * computing fees, per-inventory dedup, release-blocking, SMS notifications, the + * sealed PDF, and reshaping the global invoice back into the historical + * `WarehouseFeeInvoice` JSON the portal/backoffice expect. All money, numbering, + * status, and payment math live in billing. + */ @Injectable() export class WarehouseInvoiceService { private readonly logger = new Logger(WarehouseInvoiceService.name); constructor( private readonly dataSource: DataSource, - private readonly invoiceRepository: WarehouseFeeInvoiceRepository, - private readonly itemRepository: WarehouseFeeInvoiceItemRepository, + private readonly billing: BillingService, + private readonly invoiceDocuments: InvoiceDocumentService, private readonly feeService: WarehouseFeeService, - private readonly documents: WarehouseReleaseDocumentService, private readonly notifications: NotificationsService, - ) {} + private readonly inbox: NotificationInboxService, + ) { } // ── Generation ─────────────────────────────────────────────────────────── - async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise { + async generateForInventory( + inventoryId: string, + opts: GenerateOptions = {}, + ): Promise { const [item] = await this.dataSource.query( `SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt", w.facility_id AS "facilityId", - b.company_id AS "customerId", b.freight_type AS "freightType" + b.company_id AS "companyId", b.company_profile_id AS "companyProfileId", + b.freight_type AS "freightType" FROM freight.warehouse_inventory inv LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id WHERE inv.id = $1 AND inv.deleted_at IS NULL`, [inventoryId], ); - if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`); + if (!item) + throw new NotFoundException(`Inventory item ${inventoryId} not found`); - // Dedup: only one active (non-cancelled) invoice per inventory item. - const active = await this.invoiceRepository.findAll({ where: { inventoryId } }); - if (active.some((inv) => ACTIVE_STATUSES.includes(inv.status))) { - throw new ConflictException( - 'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.', + // Routing through the global invoice requires a billable company + profile, + // both of which come from the inventory's booking. + if (!item.companyId || !item.companyProfileId) { + throw new BadRequestException( + "Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).", ); } - const billingCurrency = opts.billingCurrency === 'ETB' ? 'ETB' : 'USD'; - const previews = await this.feeService.previewForInventory(inventoryId, billingCurrency); - const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; + // Dedup: only one active (non-cancelled) invoice per inventory item. + if (await this.hasActiveInvoice(inventoryId)) { + throw new ConflictException( + "An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.", + ); + } + + const billingCurrency = opts.billingCurrency === "ETB" ? "ETB" : "USD"; + const previews = await this.feeService.previewForInventory( + inventoryId, + billingCurrency, + ); + const isContainer = (item.freightType ?? "").toUpperCase() === "CONTAINER"; const items = previews .filter((p) => p.amount > 0) .map((p) => { - const feeType: WarehouseFeeType = - p.ruleType === 'STORAGE_FEE' - ? 'STORAGE_FEE' - : isContainer - ? 'CONTAINER_DEMURRAGE' - : 'BULK_DEMURRAGE'; + const days = `${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)`; + const tierSuffix = p.tiers.length ? " using tiered tariff" : ` after ${p.freeDays} free`; + let feeType: WarehouseFeeType; + let description: string; + switch (p.ruleType) { + 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 { feeRuleId: p.ruleId, feeType, - description: - p.ruleType === 'STORAGE_FEE' - ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free` - : `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`, + description, quantity: p.billableUnits, unitRate: p.ratePerDay, amount: p.amount, @@ -112,186 +226,572 @@ export class WarehouseInvoiceService { }; }); - const subtotal = items.reduce((s, i) => s + i.amount, 0); - const total = subtotal; // tax model can be layered on later - + const total = items.reduce((s, i) => s + i.amount, 0); if (total <= 0 && !opts.confirmZero) { - throw new BadRequestException('No payable warehouse fee found for this item.'); + throw new BadRequestException( + "No payable warehouse fee found for this item.", + ); } - const hasDemurrage = items.some((i) => i.feeType !== 'STORAGE_FEE'); - const hasStorage = items.some((i) => i.feeType === 'STORAGE_FEE'); + const hasDemurrage = items.some((i) => i.feeType !== "STORAGE_FEE"); + const hasStorage = items.some((i) => i.feeType === "STORAGE_FEE"); const invoiceType: WarehouseInvoiceType = - hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE'; + hasDemurrage && hasStorage + ? "MIXED_WAREHOUSE_FEES" + : hasStorage + ? "STORAGE_FEE" + : "DEMURRAGE"; - const currency = billingCurrency; - const now = new Date(); - const periodEnd = previews[0] ? new Date(previews[0].endDate) : now; + const lines: InvoiceLineInput[] = items.map((it) => ({ + chargeType: it.feeType, + description: it.description, + quantity: it.quantity, + unitRate: it.unitRate, + amount: it.amount, + currency: it.currency, + metadata: { + feeRuleId: it.feeRuleId ?? null, + chargeableDays: it.chargeableDays ?? null, + freeDays: it.freeDays ?? null, + }, + })); - const invoice = await this.invoiceRepository.create({ - invoiceNumber: await this.nextInvoiceNumber(), - bookingId: item.bookingId ?? null, - customerId: item.customerId ?? null, - inventoryId, - facilityId: item.facilityId ?? null, - warehouseId: item.warehouseId ?? null, - yardId: item.yardId ?? null, - zoneId: item.zoneId ?? null, - invoiceType, - status: 'ISSUED', - subtotalAmount: subtotal, - taxAmount: 0, - totalAmount: total, - paidAmount: 0, - balanceAmount: total, - currency, - periodStart: item.arrivedAt ?? null, - periodEnd, - issuedAt: now, - payments: [], - notes: opts.performedBy ? `Generated by ${opts.performedBy}` : null, + const invoice = await this.billing.generateInvoice({ + source: SOURCE, + sourceId: inventoryId, + type: invoiceType, + companyId: item.companyId, + companyProfileId: item.companyProfileId, + currency: billingCurrency, + lines, + status: Freight.InvoiceStatus.Issued, }); - for (const it of items) { - await this.itemRepository.create({ invoiceId: invoice.id, ...it }); - } - - const saved = await this.findById(invoice.id); - await this.notifyWarehouseFeeIssued(saved); - return saved; + const detail = await this.findById(invoice.id); + await this.notifyWarehouseFeeIssued(detail); + return detail; } - /** WHF-YYYYMMDD-00001 — sequential per day. */ - private async nextInvoiceNumber(): Promise { - const now = new Date(); - const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; - const prefix = `WHF-${ymd}-`; - const [row] = await this.dataSource.query( - `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq - FROM freight.warehouse_fee_invoices WHERE invoice_number LIKE $1`, - [`${prefix}%`], + /** + * Generate a truck-detention invoice for a last-mile leg. Unlike warehouse fees + * (per inventory item), detention is a per-truck charge on the last-mile leg, so + * it becomes a `last_mile` invoice with its own `TRUCK_DETENTION_FEE` type — kept + * separate from the delivery-fee invoice. Returns the global Invoice. + */ + async generateTruckDetentionInvoice( + lastMileId: string, + opts: { billingCurrency?: "ETB" | "USD"; confirmZero?: boolean } = {}, + ): Promise { + const [lm] = await this.dataSource.query( + `SELECT lm.id, + b.company_id AS "companyId", + b.company_profile_id AS "companyProfileId", + b.payment_currency AS "paymentCurrency" + FROM freight.last_mile lm + LEFT JOIN freight.bookings b ON b.id = lm.booking_id + WHERE lm.id = $1 AND lm.deleted_at IS NULL`, + [lastMileId], ); - const next = Number(row?.seq ?? 0) + 1; - return `${prefix}${String(next).padStart(5, '0')}`; + if (!lm) throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + if (!lm.companyId) { + throw new BadRequestException( + "Cannot invoice truck detention: the last-mile leg has no billable company (no associated booking).", + ); + } + + const existing = await this.billing.findPayable( + "last_mile" as Freight.InvoiceSource, + lastMileId, + "TRUCK_DETENTION_FEE", + ); + if (existing) { + throw new ConflictException( + "An active truck detention invoice already exists for this last-mile leg. Cancel it before generating a new one.", + ); + } + + const billingCurrency: "ETB" | "USD" = + opts.billingCurrency ?? (lm.paymentCurrency === "ETB" ? "ETB" : "USD"); + const preview = await this.feeService.previewTruckDetention(lastMileId, billingCurrency); + if (preview.amount <= 0 && !opts.confirmZero) { + throw new BadRequestException( + "No truck detention is currently payable for this last-mile leg.", + ); + } + + // One line per truck-type group (each billed by its own matching rule). Groups + // with no matching rule bill 0 and are dropped. Falls back to a single line. + const groups = preview.groups && preview.groups.length ? preview.groups : null; + const lines: InvoiceLineInput[] = groups + ? groups + .filter((g) => g.amount > 0) + .map((g) => ({ + chargeType: "TRUCK_DETENTION", + description: `Truck detention${g.vehicleType ? ` (${g.vehicleType})` : ""} - ${g.chargeableDays} day(s) x ${g.truckCount} truck(s)`, + quantity: g.truckCount * g.chargeableDays, + unitRate: g.ratePerDay, + amount: g.amount, + currency: preview.currency, + metadata: { + feeRuleId: g.ruleId ?? null, + chargeableDays: g.chargeableDays, + vehicleType: g.vehicleType ?? null, + }, + })) + : [ + { + chargeType: "TRUCK_DETENTION", + description: `Truck detention - ${preview.chargeableDays} day(s) x ${preview.containerCount} truck(s)`, + quantity: preview.billableUnits, + unitRate: preview.ratePerDay, + amount: preview.amount, + currency: preview.currency, + metadata: { + feeRuleId: preview.ruleId ?? null, + chargeableDays: preview.chargeableDays ?? null, + }, + }, + ]; + if (lines.length === 0) { + throw new BadRequestException( + "No truck detention is currently payable for this last-mile leg.", + ); + } + + return this.billing.generateInvoice({ + source: "last_mile" as Freight.InvoiceSource, + sourceId: lastMileId, + type: "TRUCK_DETENTION_FEE", + companyId: lm.companyId, + companyProfileId: lm.companyProfileId || "", + currency: billingCurrency, + lines, + status: Freight.InvoiceStatus.Issued, + }); } // ── Reads ──────────────────────────────────────────────────────────────── - async findById(id: string): Promise { - const invoice = await this.invoiceRepository.findById(id); - if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); - const items = await this.itemRepository.findAll({ where: { invoiceId: id } }); + async findById(id: string): Promise { + const invoice = await this.loadWarehouseInvoice(id); + const ctx = await this.getInventoryContext(invoice.sourceId); const details = await this.getInvoiceDocumentDetails(invoice); - return { ...invoice, ...details, items } as WarehouseFeeInvoiceWithDisplay & { items: unknown[] }; + const items = invoice.lines.map((l) => this.lineToItem(l)); + return { ...this.buildView(invoice, ctx), ...details, items }; + } + + listForInventory(inventoryId: string): Promise { + return this.queryViews("AND i.source_id = $1", [inventoryId]); + } + + listForBooking(bookingId: string): Promise { + return this.queryViews("AND inv.booking_id = $1", [bookingId]); + } + + async findAll( + filter: Partial< + Pick< + WarehouseFeeInvoiceView, + | "status" + | "invoiceType" + | "warehouseId" + | "facilityId" + | "customerId" + | "bookingId" + > + >, + ): Promise { + const conditions: string[] = []; + const params: unknown[] = []; + const add = (sql: (p: string) => string, value: unknown) => { + params.push(value); + conditions.push(sql(`$${params.length}`)); + }; + + if (filter.status) + add( + (p) => `i.status::text = ${p}`, + this.toGlobalStatus(filter.status as WarehouseInvoiceStatus), + ); + if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType); + if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId); + if (filter.warehouseId) + add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId); + if (filter.facilityId) + add((p) => `w.facility_id = ${p}`, filter.facilityId); + if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId); + + return this.queryViews(conditions.map((c) => `AND ${c}`).join(" "), params); } async document(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); - const details = await this.getInvoiceDocumentDetails(invoice); - const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE', details); - return { - filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`, - buffer: await this.documents.htmlToPdfBuffer(html), - }; + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "INVOICE"), + ); } async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); if (Number(invoice.paidAmount) <= 0) { - throw new BadRequestException('A receipt is available only after payment is recorded.'); + throw new BadRequestException( + "A receipt is available only after payment is recorded.", + ); } - const details = await this.getInvoiceDocumentDetails(invoice); - const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT', details); - return { - filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`, - buffer: await this.documents.htmlToPdfBuffer(html), - }; - } - - listForInventory(inventoryId: string): Promise { - return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } }); - } - - listForBooking(bookingId: string): Promise { - return this.invoiceRepository.findAll({ where: { bookingId }, order: { createdAt: 'DESC' } }); - } - - findAll(filter: Partial>): Promise { - const where = Object.fromEntries(Object.entries(filter).filter(([, v]) => v != null)); - return this.invoiceRepository.findAll({ where, order: { createdAt: 'DESC' } }); + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "RECEIPT"), + ); } // ── State changes ──────────────────────────────────────────────────────── - async cancel(id: string): Promise { - const invoice = await this.invoiceRepository.findById(id); - if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); - if (invoice.status === 'PAID') throw new BadRequestException('A paid invoice cannot be cancelled.'); - const updated = await this.invoiceRepository.update(id, { status: 'CANCELLED', cancelledAt: new Date() }); - return updated as WarehouseFeeInvoice; + async cancel(id: string): Promise { + const invoice = await this.loadWarehouseInvoice(id); + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException("A paid invoice cannot be cancelled."); + } + await this.billing.cancelInvoice(id); + return this.findById(id); } - /** Record a payment against the invoice and sync status (links to existing payment flow). */ - async pay(id: string, dto: PayInvoiceDto): Promise { - const invoice = await this.invoiceRepository.findById(id); - if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); - if (invoice.status === 'CANCELLED') throw new BadRequestException('Cannot pay a cancelled invoice.'); - if (invoice.status === 'PAID') throw new BadRequestException('Invoice is already fully paid.'); - if (!(dto.amount > 0)) throw new BadRequestException('Payment amount must be greater than zero.'); - - const paidAmount = Number(invoice.paidAmount) + dto.amount; - const total = Number(invoice.totalAmount); - const balance = Math.max(0, Math.round((total - paidAmount) * 100) / 100); - const fullyPaid = paidAmount >= total; - - const payments = [ - ...(invoice.payments ?? []), - { amount: dto.amount, method: dto.method ?? null, reference: dto.reference ?? null, paidAt: new Date().toISOString() }, - ]; - - const updated = await this.invoiceRepository.update(id, { - paidAmount: Math.round(paidAmount * 100) / 100, - balanceAmount: balance, - status: fullyPaid ? 'PAID' : 'PARTIALLY_PAID', - paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null, - payments, + /** Record a payment against the invoice (delegates settlement to billing). */ + async pay( + id: string, + dto: PayInvoiceDto, + ): Promise { + // Guard that this is a warehouse invoice before recording (404 otherwise). + await this.loadWarehouseInvoice(id); + await this.billing.recordPayment(id, { + amount: dto.amount, + method: dto.method ?? null, + reference: dto.reference ?? null, + metadata: + dto.driverName || dto.driverPhone + ? { + driverName: dto.driverName ?? null, + driverPhone: dto.driverPhone ?? null, + } + : null, + }); + const detail = await this.findById(id); + await this.notifyWarehouseFeePayment(detail, dto); + return detail; + } + + /** Initiate a wallet/gateway payment for the invoice. */ + async initiatePayment(id: string, dto: GatewayPayInvoiceDto = {}) { + const invoice = await this.loadWarehouseInvoice(id); + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException("Invoice is already fully paid."); + } + + return this.billing.payInvoice(invoice.id, { + method: dto.method ?? (invoice.currency === "USD" ? "WAAFI" : "TELEBIRR"), + platform: dto.platform ?? "web", + payerAccount: dto.payerAccount, + returnUrl: dto.returnUrl, + failureUrl: dto.failureUrl, + }); + } + + /** + * Notify on online (gateway) settlement — the domain side-effect of a warehouse + * fee being paid through billing's payment flow. The counter {@link pay} path + * notifies inline (and carries driver details from the request), so this only + * handles gateway payments: those stamp the invoice `paymentId`, whereas a + * counter settlement leaves it null. Skipping null-`paymentId` events avoids + * double-notifying a counter payment that already sent its SMS. + */ + @OnEvent("warehouse.invoice.paid") + async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise { + if (!payload.paymentId) return; + const detail = await this.findById(payload.invoiceId); + await this.notifyWarehouseFeePayment(detail, { + amount: Number(detail.totalAmount), }); - const paidInvoice = updated as WarehouseFeeInvoice; - await this.notifyWarehouseFeePayment(paidInvoice, dto); - return paidInvoice; } // ── Release blocking ────────────────────────────────────────────────────── /** Returns the first unpaid invoice that blocks terminal release, or null. */ - async findBlockingInvoice(inventoryId: string): Promise { - const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); - return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null; + async findBlockingInvoice( + inventoryId: string, + ): Promise { + const blocking = await this.queryViews( + `AND i.source_id = $1 AND i.status::text = ANY($2::text[])`, + [inventoryId, BLOCKING_STATUSES], + ); + return blocking[0] ?? null; } async assertClearanceAllowed(inventoryId: string): Promise { - const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); - const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)); + const invoices = await this.queryViews("AND i.source_id = $1", [ + inventoryId, + ]); + const blocking = invoices.find( + (inv) => inv.status === "ISSUED" || inv.status === "PARTIALLY_PAID", + ); if (blocking) { throw new BadRequestException( `Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`, ); } - if (invoices.some((inv) => inv.status === 'PAID')) return; + if (invoices.some((inv) => inv.status === "PAID")) return; - const previews = await this.feeService.previewForInventory(inventoryId, 'USD'); - const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0); + const previews = await this.feeService.previewForInventory( + inventoryId, + "USD", + ); + const payableAmount = previews.reduce( + (sum, fee) => sum + Number(fee.amount || 0), + 0, + ); if (payableAmount > 0) { throw new BadRequestException( - 'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.', + "Generate and fully pay the warehouse demurrage/storage invoice before terminal release.", ); } } - private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise { + // ── Internal: loading & projection ───────────────────────────────────────── + + /** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */ + private async loadWarehouseInvoice( + id: string, + ): Promise { + const invoice = await this.billing.findById(id); + if (invoice.source !== SOURCE) { + throw new NotFoundException(`Invoice ${id} not found`); + } + return invoice; + } + + private async hasActiveInvoice(inventoryId: string): Promise { + const [row] = await this.dataSource.query( + `SELECT 1 + FROM freight.invoices + WHERE source = $1 AND source_id = $2 AND status::text = ANY($3::text[]) AND deleted_at IS NULL + LIMIT 1`, + [SOURCE, inventoryId, ACTIVE_STATUSES], + ); + return Boolean(row); + } + + /** + * Project warehouse-source global invoices into the historical view, joined to + * their inventory item for the typed FKs. Powers every list/filter read. + */ + private async queryViews( + extraWhere: string, + params: unknown[], + ): Promise { + const rows = await this.dataSource.query( + `SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId", + i.source_id AS "sourceId", i.type, i.status, + i.subtotal_amount AS "subtotalAmount", i.tax_amount AS "taxAmount", + i.total_amount AS "totalAmount", i.paid_amount AS "paidAmount", + i.balance_amount AS "balanceAmount", i.currency, i.payments, + i.issued_at AS "issuedAt", i.due_at AS "dueAt", i.paid_at AS "paidAt", + i.created_at AS "createdAt", i.updated_at AS "updatedAt", + inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", + inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart", + w.facility_id AS "facilityId" + FROM freight.invoices i + LEFT JOIN freight.warehouse_inventory inv ON inv.id::text = i.source_id AND inv.deleted_at IS NULL + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere} + ORDER BY i.created_at DESC`, + [...params, SOURCE], + ); + + return (rows as Array).map((row) => + this.buildView(row, { + bookingId: row.bookingId ?? null, + facilityId: row.facilityId ?? null, + warehouseId: row.warehouseId ?? null, + yardId: row.yardId ?? null, + zoneId: row.zoneId ?? null, + periodStart: row.periodStart ?? null, + }), + ); + } + + /** Reshape a global invoice (+ derived inventory context) into the warehouse view. */ + private buildView( + inv: ViewSource, + ctx: InventoryContext, + ): WarehouseFeeInvoiceView { + const status = this.toWarehouseStatus(inv.status); + return { + id: inv.id, + invoiceNumber: inv.invoiceNumber, + bookingId: ctx.bookingId, + customerId: inv.companyId ?? null, + inventoryId: inv.sourceId, + facilityId: ctx.facilityId, + warehouseId: ctx.warehouseId, + yardId: ctx.yardId, + zoneId: ctx.zoneId, + invoiceType: inv.type as WarehouseInvoiceType, + status, + subtotalAmount: Number(inv.subtotalAmount), + taxAmount: Number(inv.taxAmount), + totalAmount: Number(inv.totalAmount), + paidAmount: Number(inv.paidAmount), + balanceAmount: Number(inv.balanceAmount), + currency: inv.currency, + periodStart: ctx.periodStart, + // No standalone period column once centralized: the charge window ends at + // issuance, so `issuedAt` is the period end. + periodEnd: inv.issuedAt ?? null, + issuedAt: inv.issuedAt ?? null, + dueDate: inv.dueAt ?? null, + paidAt: inv.paidAt ?? null, + cancelledAt: status === "CANCELLED" ? inv.updatedAt : null, + payments: (inv.payments ?? []).map((p) => ({ + amount: Number(p.amount), + method: p.method ?? null, + reference: p.reference ?? null, + paidAt: p.paidAt, + })), + notes: null, + createdAt: inv.createdAt, + updatedAt: inv.updatedAt, + }; + } + + private lineToItem(line: InvoiceLine): WarehouseInvoiceItemView { + const meta = (line.metadata ?? {}) as { + feeRuleId?: string | null; + chargeableDays?: number | null; + freeDays?: number | null; + }; + return { + feeRuleId: meta.feeRuleId ?? null, + feeType: line.chargeType as WarehouseFeeType, + description: line.description ?? "", + quantity: Number(line.quantity), + unitRate: Number(line.unitRate), + amount: Number(line.amount), + currency: line.currency, + chargeableDays: meta.chargeableDays ?? null, + freeDays: meta.freeDays ?? null, + }; + } + + private toWarehouseStatus( + status: Freight.InvoiceStatus | string, + ): WarehouseInvoiceStatus { + switch (status) { + case Freight.InvoiceStatus.Draft: + return "DRAFT"; + case Freight.InvoiceStatus.PartiallyPaid: + return "PARTIALLY_PAID"; + case Freight.InvoiceStatus.Paid: + return "PAID"; + case Freight.InvoiceStatus.Cancelled: + case Freight.InvoiceStatus.Refunded: + return "CANCELLED"; + default: + // Issued / Pending / Overdue → an issued, still-owed invoice. + return "ISSUED"; + } + } + + private toGlobalStatus( + status: WarehouseInvoiceStatus, + ): Freight.InvoiceStatus { + switch (status) { + case "DRAFT": + return Freight.InvoiceStatus.Draft; + case "PARTIALLY_PAID": + return Freight.InvoiceStatus.PartiallyPaid; + case "PAID": + return Freight.InvoiceStatus.Paid; + case "CANCELLED": + return Freight.InvoiceStatus.Cancelled; + default: + return Freight.InvoiceStatus.Issued; + } + } + + /** Map a warehouse fee invoice view onto the shared document model. */ + private toDocumentModel( + invoice: WarehouseFeeInvoiceDetail, + kind: "INVOICE" | "RECEIPT", + ): InvoiceDocumentModel { + const lastPayment = [...(invoice.payments ?? [])].pop(); + const date = (value: unknown) => + value + ? new Date(value as string | Date).toLocaleDateString("en-GB") + : null; + + return { + kind, + title: "Warehouse Fee", + documentNumber: invoice.invoiceNumber, + issuedAt: invoice.issuedAt ?? invoice.createdAt, + status: invoice.status, + currency: invoice.currency, + summary: [ + { label: "Status", value: invoice.status.replace(/_/g, " ") }, + { + label: "Invoice type", + value: invoice.invoiceType.replace(/_/g, " "), + }, + { label: "Booking reference", value: invoice.bookingReference ?? null }, + { label: "Customer", value: invoice.customerName ?? null }, + { + label: "Inventory reference", + value: invoice.inventoryReference ?? null, + }, + { label: "Inventory info", value: invoice.inventoryInfo ?? null }, + { label: "Clearance", value: invoice.clearanceStatus ?? null }, + { label: "Warehouse", value: invoice.warehouseName ?? null }, + { + label: "Yard / Zone", + value: + [invoice.yardName, invoice.zoneName].filter(Boolean).join(" / ") || + null, + }, + { + label: "Period", + value: `${date(invoice.periodStart) ?? "-"} - ${date(invoice.periodEnd) ?? "-"}`, + }, + { + label: "Payment", + value: lastPayment + ? `${lastPayment.method ?? "MANUAL"} / ${date(lastPayment.paidAt) ?? "-"}` + : null, + }, + ], + categoryHeader: "Fee type", + lines: invoice.items.map((item) => ({ + description: item.description ?? null, + category: item.feeType ?? null, + quantity: item.quantity ?? item.chargeableDays ?? 0, + unitRate: item.unitRate, + amount: item.amount, + currency: item.currency ?? invoice.currency, + })), + totals: [ + { label: "Subtotal", amount: Number(invoice.subtotalAmount) }, + { label: "Tax", amount: Number(invoice.taxAmount) }, + { label: "Total", amount: Number(invoice.totalAmount), grand: true }, + { label: "Paid", amount: Number(invoice.paidAmount) }, + { label: "Balance", amount: Number(invoice.balanceAmount) }, + ], + }; + } + + /** Warehouse-specific display details, derived from the linked inventory item. */ + private async getInvoiceDocumentDetails( + invoice: ViewSource, + ): Promise { const [row] = await this.dataSource.query( `SELECT b.reference AS "bookingReference", company.name AS "customerName", COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference", inv.status AS "inventoryStatus", + inv.release_date AS "releaseDate", COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", CONCAT_WS( @@ -302,16 +802,10 @@ export class WarehouseInvoiceService { ) AS "inventoryInfo", wh.name AS "warehouseName", yard.name AS "yardName", - zone.name AS "zoneName", - CASE - WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED' - WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE' - ELSE 'PENDING PAYMENT' - END AS "clearanceStatus" - FROM freight.warehouse_fee_invoices fee - LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL - LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL - LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id) + zone.name AS "zoneName" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL LEFT JOIN freight.booking_container booking_container ON ( booking_container.booking_id = b.id @@ -319,14 +813,21 @@ export class WarehouseInvoiceService { ) LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) - LEFT JOIN freight.warehouses wh ON wh.id = fee.warehouse_id - LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id - LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id - WHERE fee.id = $1 + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id + LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id + WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, - [invoice.id, invoice.status], + [invoice.sourceId], ); + const fullyPaid = this.toWarehouseStatus(invoice.status) === "PAID"; + const clearanceStatus = row?.releaseDate + ? "RELEASE ISSUED" + : fullyPaid + ? "FEE PAID - READY FOR RELEASE" + : "PENDING PAYMENT"; + return { bookingReference: row?.bookingReference ?? null, customerName: row?.customerName ?? null, @@ -338,11 +839,35 @@ export class WarehouseInvoiceService { warehouseName: row?.warehouseName ?? null, yardName: row?.yardName ?? null, zoneName: row?.zoneName ?? null, - clearanceStatus: row?.clearanceStatus ?? (invoice.status === 'PAID' ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT'), + clearanceStatus, }; } - private async getInvoiceNotificationContacts(invoice: WarehouseFeeInvoice): Promise<{ + private async getInventoryContext( + inventoryId: string, + ): Promise { + const [row] = await this.dataSource.query( + `SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", + inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart", + w.facility_id AS "facilityId" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + WHERE inv.id = $1 AND inv.deleted_at IS NULL + LIMIT 1`, + [inventoryId], + ); + return { + bookingId: row?.bookingId ?? null, + facilityId: row?.facilityId ?? null, + warehouseId: row?.warehouseId ?? null, + yardId: row?.yardId ?? null, + zoneId: row?.zoneId ?? null, + periodStart: row?.periodStart ?? null, + }; + } + + // ── Notifications ────────────────────────────────────────────────────────── + private async getInvoiceNotificationContacts(inventoryId: string): Promise<{ bookingReference: string | null; customerName: string | null; customerPhone: string | null; @@ -364,10 +889,9 @@ export class WarehouseInvoiceService { COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone", COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription" - FROM freight.warehouse_fee_invoices fee - LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL - LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL - LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id) + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL LEFT JOIN freight.booking_container booking_container ON ( booking_container.booking_id = b.id @@ -393,9 +917,9 @@ export class WarehouseInvoiceService { ) latest_first_mile ON true LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id LEFT JOIN freight.drivers first_driver ON first_driver.id = first_vehicle.assigned_driver_id - WHERE fee.id = $1 + WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, - [invoice.id], + [inventoryId], ); return { @@ -409,182 +933,108 @@ export class WarehouseInvoiceService { }; } - private async sendSms(recipient: string | null | undefined, message: string, context: string): Promise { + private async sendSms( + recipient: string | null | undefined, + message: string, + context: string, + ): Promise { const phone = recipient?.trim(); if (!phone) return; try { - await this.notifications.directSend('sms', phone, message); + await this.notifications.directSend("sms", phone, message); } catch (error) { - this.logger.error(`Failed to send ${context} SMS to ${phone}: ${String(error)}`); + this.logger.error( + `Failed to send ${context} SMS to ${phone}: ${String(error)}`, + ); } } - private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise { - const contacts = await this.getInvoiceNotificationContacts(invoice); - const customerName = contacts.customerName?.trim() || 'Customer'; - const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; + private async notifyWarehouseFeeIssued( + invoice: WarehouseFeeInvoiceView, + ): Promise { + const contacts = await this.getInvoiceNotificationContacts( + invoice.inventoryId, + ); + const customerName = contacts.customerName?.trim() || "Customer"; + const bookingReference = contacts.bookingReference + ? ` Booking: ${contacts.bookingReference}.` + : ""; const cargo = contacts.containerNumber || contacts.cargoDescription; - const cargoText = cargo ? ` Cargo: ${cargo}.` : ''; + const cargoText = cargo ? ` Cargo: ${cargo}.` : ""; const message = - `Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, ' ').toLowerCase()} fee ` + + `Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ` + `${invoice.invoiceNumber} is due.${bookingReference}${cargoText} Amount: ` + `${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Please pay before cargo pickup.`; - await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`); + await this.sendSms( + contacts.customerPhone, + message, + `warehouse fee invoice ${invoice.invoiceNumber}`, + ); + + // In-app deep-link to pay the fee from the booking. + if (invoice.customerId && invoice.bookingId) { + try { + await this.inbox.notify({ + recipients: { companyId: invoice.customerId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.INVOICE_ISSUED, + title: "Warehouse fee due", + body: + `Warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ${invoice.invoiceNumber} is due — ` + + `${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Pay from the portal before cargo pickup.`, + link: `/bookings/${invoice.bookingId}`, + data: { bookingId: invoice.bookingId, invoiceNumber: invoice.invoiceNumber }, + }); + } catch (err) { + this.logger.warn(`In-app warehouse fee notify failed: ${(err as Error).message}`); + } + } } - private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise { - const contacts = await this.getInvoiceNotificationContacts(invoice); - const customerName = contacts.customerName?.trim() || 'Customer'; - const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; + private async notifyWarehouseFeePayment( + invoice: WarehouseFeeInvoiceView, + dto: PayInvoiceDto, + ): Promise { + const contacts = await this.getInvoiceNotificationContacts( + invoice.inventoryId, + ); + const customerName = contacts.customerName?.trim() || "Customer"; + const bookingReference = contacts.bookingReference + ? ` Booking: ${contacts.bookingReference}.` + : ""; const statusText = - invoice.status === 'PAID' - ? 'fully paid and ready for pickup release' + invoice.status === "PAID" + ? "fully paid and ready for pickup release" : `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`; const customerMessage = `Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` + `was recorded for warehouse fee ${invoice.invoiceNumber}.${bookingReference} Status: ${statusText}.`; - await this.sendSms(contacts.customerPhone, customerMessage, `warehouse fee payment ${invoice.invoiceNumber}`); + await this.sendSms( + contacts.customerPhone, + customerMessage, + `warehouse fee payment ${invoice.invoiceNumber}`, + ); - if (invoice.status !== 'PAID') return; + if (invoice.status !== "PAID") return; const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone; - const driverName = dto.driverName?.trim() || contacts.driverName || 'Driver'; + const driverName = + dto.driverName?.trim() || contacts.driverName || "Driver"; const cargo = contacts.containerNumber || contacts.cargoDescription; const driverMessage = `Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` + - (contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '') + - (cargo ? ` Cargo: ${cargo}.` : '') + - ' Proceed with pickup after gate verification.'; + (contacts.bookingReference + ? ` Booking: ${contacts.bookingReference}.` + : "") + + (cargo ? ` Cargo: ${cargo}.` : "") + + " Proceed with pickup after gate verification."; - await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`); - } - - private buildInvoiceDocumentHtml( - invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] }, - kind: 'INVOICE' | 'RECEIPT', - details: InvoiceDocumentDetails, - ): string { - const esc = (value: unknown) => - String(value ?? '-') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - const money = (amount: unknown, currency = invoice.currency) => - `${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`; - const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-'); - const items = invoice.items as Array<{ - id?: string; - description?: string; - feeType?: string; - quantity?: number; - unitRate?: number; - amount?: number; - currency?: string; - chargeableDays?: number | null; - }>; - const lastPayment = [...(invoice.payments ?? [])].pop(); - const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR'; - - return ` - - - - Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'} - - - -
-
-
-
Ethio-Djibouti Railway S.C.
-

Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}

-
-
- Document no. - ${esc(invoice.invoiceNumber)} - Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))} -
-
-
${esc(sealText)}
-
-
Status${esc(invoice.status.replace(/_/g, ' '))}
-
Invoice type${esc(invoice.invoiceType.replace(/_/g, ' '))}
-
Booking reference${esc(details.bookingReference)}
-
Customer${esc(details.customerName)}
-
Inventory reference${esc(details.inventoryReference)}
-
Inventory info${esc(details.inventoryInfo)}
-
Clearance${esc(details.clearanceStatus)}
-
Warehouse${esc(details.warehouseName)}
-
Yard / Zone${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}
-
Period${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}
-
Payment${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}
-
- - - - - - - - - - - - ${items - .map( - (item) => ` - - - - - - `, - ) - .join('')} - -
DescriptionFee typeQtyRateAmount
${esc(item.description)}${esc((item.feeType ?? '').replace(/_/g, ' '))}${esc(item.quantity ?? item.chargeableDays ?? 0)}${esc(money(item.unitRate, item.currency ?? invoice.currency))}${esc(money(item.amount, item.currency ?? invoice.currency))}
-
-
Subtotal${esc(money(invoice.subtotalAmount))}
-
Tax${esc(money(invoice.taxAmount))}
-
Total${esc(money(invoice.totalAmount))}
-
Paid${esc(money(invoice.paidAmount))}
-
Balance${esc(money(invoice.balanceAmount))}
-
- -
- -`; - } - - private safeFilename(value: string): string { - return value.replace(/[^a-zA-Z0-9_-]+/g, '-'); + await this.sendSms( + driverPhone, + driverMessage, + `warehouse pickup driver ${invoice.invoiceNumber}`, + ); } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts new file mode 100644 index 000000000..418816beb --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts @@ -0,0 +1,90 @@ +/** + * Public shapes for warehouse fee invoices. + * + * Warehouse fee invoices are no longer a standalone table — they are global + * `Invoice` rows (`source = "warehouse"`, `sourceId = inventoryId`) owned by the + * central {@link BillingService}. These types preserve the warehouse-facing API + * contract: `WarehouseInvoiceService` reshapes the global invoice (+ lines + + * inventory context) back into the historical `WarehouseFeeInvoice` JSON so the + * portal/backoffice stay untouched. + */ + +export const WAREHOUSE_INVOICE_TYPES = ['DEMURRAGE', 'STORAGE_FEE', 'MIXED_WAREHOUSE_FEES'] as const; +export type WarehouseInvoiceType = (typeof WAREHOUSE_INVOICE_TYPES)[number]; + +export const WAREHOUSE_INVOICE_STATUSES = [ + 'DRAFT', + 'ISSUED', + 'PARTIALLY_PAID', + 'PAID', + 'CANCELLED', +] as const; +export type WarehouseInvoiceStatus = (typeof WAREHOUSE_INVOICE_STATUSES)[number]; + +export const WAREHOUSE_FEE_TYPES = [ + 'CONTAINER_DEMURRAGE', + 'BULK_DEMURRAGE', + 'STORAGE_FEE', + 'HANDLING_FEE', + 'DOUBLE_HANDLING', + 'TRUCK_DETENTION', +] as const; +export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number]; + +/** A single recorded payment against a warehouse fee invoice (history). */ +export interface WarehouseInvoicePayment { + amount: number; + method?: string | null; + reference?: string | null; + paidAt: string; +} + +/** A billed warehouse fee line, projected from a global `InvoiceLine`. */ +export interface WarehouseInvoiceItemView { + feeRuleId: string | null; + feeType: WarehouseFeeType; + description: string; + quantity: number; + unitRate: number; + amount: number; + currency: string; + chargeableDays: number | null; + freeDays: number | null; +} + +/** + * The warehouse-facing invoice header — same field set the old + * `WarehouseFeeInvoice` entity exposed, projected from a global `Invoice`. The + * typed FKs (`bookingId`/`facilityId`/`warehouseId`/`yardId`/`zoneId`) and the + * charge `period` are derived from the linked inventory item; `customerId` is the + * billed company; `invoiceType` is the invoice `type`. + */ +export interface WarehouseFeeInvoiceView { + id: string; + invoiceNumber: string; + bookingId: string | null; + customerId: string | null; + inventoryId: string; + facilityId: string | null; + warehouseId: string | null; + yardId: string | null; + zoneId: string | null; + invoiceType: WarehouseInvoiceType; + status: WarehouseInvoiceStatus; + subtotalAmount: number; + taxAmount: number; + totalAmount: number; + paidAmount: number; + balanceAmount: number; + currency: string; + periodStart: Date | null; + periodEnd: Date | null; + issuedAt: Date | null; + dueDate: Date | null; + paidAt: Date | null; + cancelledAt: Date | null; + payments: WarehouseInvoicePayment[]; + notes: string | null; + createdAt: Date; + updatedAt: Date; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index a77a46c29..1d02633d5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -1,113 +1,83 @@ -import { existsSync } from 'fs'; +import { Injectable } from '@nestjs/common'; -import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; +import { PdfRenderService } from '../billing/documents/pdf-render.service'; +import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; const MIN_VALID_PDF_BYTES = 2_000; -const RELEASE_DOCUMENT_PRINT_STYLES = ` -`; - @Injectable() export class WarehouseReleaseDocumentService { - private readonly logger = new Logger(WarehouseReleaseDocumentService.name); + constructor(private readonly pdf: PdfRenderService) {} - async htmlToPdfBuffer(html: string): Promise { - const preparedHtml = this.injectPdfPrintStyles(html); - const executablePath = this.resolveExecutablePath(); - - try { - const puppeteer = await import('puppeteer'); - const launchOptions: import('puppeteer').LaunchOptions = { - headless: true, - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], - ...(executablePath ? { executablePath } : {}), - }; - - const browser = await puppeteer.default.launch(launchOptions); - try { - const page = await browser.newPage(); - await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); - await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 }); - await page.emulateMediaType('print'); - await new Promise((resolve) => setTimeout(resolve, 250)); - - const pdf = await page.pdf({ - format: 'A4', - printBackground: true, - margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' }, - }); - - const buffer = Buffer.from(pdf); - if (!this.isValidPdf(buffer)) { - throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`); - } - this.logger.log( - `Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`, - ); - return buffer; - } finally { - await browser.close(); - } - } catch (error) { - this.logger.error( - `Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`, - ); - const fallback = this.htmlToBasicPdfBuffer(preparedHtml); - if (this.isValidPdf(fallback)) { - this.logger.warn( - `Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, - ); - return fallback; - } - throw new InternalServerErrorException( - 'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', - ); - } + /** + * Render the gate-clearance release document to PDF via the shared renderer, + * falling back to the release-specific hand-built layout when Chromium is + * unavailable. + */ + htmlToPdfBuffer(html: string): Promise { + return this.pdf.htmlToPdfBuffer(html, { + label: 'Warehouse release', + fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml), + }); } - private injectPdfPrintStyles(html: string): string { - if (html.includes('warehouse-release-document-print-fix')) return html; - if (html.includes('')) { - return html.replace('', `${RELEASE_DOCUMENT_PRINT_STYLES}`); - } - return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`; + /** + * Render arbitrary document HTML to PDF via the shared renderer WITHOUT the + * release-order fallback. Non-release documents (e.g. the import/export + * marshalling load list) must use this so a Chromium-less fallback degrades to + * a plain-text dump of *their own* content — instead of masquerading as a + * "Warehouse Gate Clearance / Release Order", which the release-specific + * fallback would otherwise draw regardless of the input HTML. + */ + renderDocumentHtml(html: string, label = 'Document'): Promise { + return this.pdf.htmlToPdfBuffer(html, { label }); } - private resolveExecutablePath(): string | undefined { - const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim(); - if (fromEnv && existsSync(fromEnv)) return fromEnv; - - const candidates = [ - '/usr/bin/chromium', - '/usr/bin/chromium-browser', - '/usr/bin/google-chrome-stable', - '/usr/bin/google-chrome', - ]; - return candidates.find((path) => existsSync(path)); + /** + * 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 { + return this.pdf.htmlToPdfBuffer(html, { + label, + fallback: (preparedHtml) => buildTabularFallbackPdf(preparedHtml), + }); } - private isValidPdf(buffer: Buffer): boolean { - return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-'; + /** + * 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 { + return this.pdf.htmlToPdfBuffer(html, { + label, + fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml, fallbackOpts), + }); } - private htmlToBasicPdfBuffer(html: string): Buffer { + private htmlToBasicPdfBuffer( + html: string, + opts?: { titleLines?: string[]; subtitle?: string; sectionTitle?: string; refLabel?: string }, + ): Buffer { 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[] = [ 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('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'), - this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'), - this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'), - 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'), + ...titleLines.map((line, i) => this.textOp(line, 36, 764 - i * 22, 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(refLabel, 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(`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), @@ -116,7 +86,7 @@ export class WarehouseReleaseDocumentService { ...this.wrapLines(doc.notice, 68) .slice(0, 4) .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; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts index 715080612..d35587d9e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -85,4 +85,13 @@ export class WarehouseRulesController { ) { return this.feeService.previewForInventory(id, billingCurrency); } + + @Get('last-mile/:id/truck-detention-preview') + @ApiOperation({ summary: 'Preview truck detention for a last-mile leg (per truck per day after grace)' }) + truckDetentionPreview( + @Param('id', ParseUUIDPipe) id: string, + @Query('billingCurrency') billingCurrency?: string, + ) { + return this.feeService.previewTruckDetention(id, billingCurrency); + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index d880a3554..4011bc14f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -3,18 +3,21 @@ import { ConfigService } from '@nestjs/config'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BillingModule } from '../billing/billing.module'; +import { DocumentsModule } from '../billing/documents/documents.module'; import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { LastMileModule } from '../last-mile/last-mile.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity'; -import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity'; -import { WarehouseFeeInvoiceItem } from './entities/warehouse-fee-invoice-item.entity'; import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.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 { WarehouseLoading } from './entities/warehouse-loading.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity'; @@ -38,8 +41,6 @@ import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.r import { WarehouseAllocationService } from './warehouse-allocation.service'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; import { WarehouseFeeService } from './warehouse-fee.service'; -import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; -import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; import { WarehouseInvoiceController } from './warehouse-invoice.controller'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; import { WarehouseRulesController } from './warehouse-rules.controller'; @@ -67,13 +68,15 @@ import { WarehousesService } from './warehouses.service'; WarehouseInspectionReport, WarehouseAllocationRule, WarehouseFeeRule, - WarehouseFeeInvoice, - WarehouseFeeInvoiceItem, + BookingHandover, ]), + BillingModule, + DocumentsModule, FilesModule, InterchangeDocumentsModule, forwardRef(() => LastMileModule), NotificationsModule, + NotificationInboxModule, SignaturesModule, ExchangeModule.forRootAsync({ inject: [ConfigService], @@ -102,8 +105,6 @@ import { WarehousesService } from './warehouses.service'; WarehouseInspectionRepository, WarehouseAllocationRuleRepository, WarehouseFeeRuleRepository, - WarehouseFeeInvoiceRepository, - WarehouseFeeInvoiceItemRepository, WarehousesService, WarehouseYardsService, WarehouseZonesService, @@ -117,6 +118,7 @@ import { WarehousesService } from './warehouses.service'; WarehouseSchedulingAdapterService, WarehouseReleaseDocumentService, SchedulingReadFacade, + HandoverService, ], exports: [ WarehousesService, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts index f92401dfc..140d9f6b4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -64,8 +64,8 @@ export class WarehousesService { currentWeight: 0, currentContainers: 0, currentVolume: 0, - status: 'ACTIVE', - isActive: true, + status: dto.status ?? 'ACTIVE', + isActive: (dto.status ?? 'ACTIVE') === 'ACTIVE', }); } catch (error) { this.mapDbError(error); diff --git a/apps/edr-freight-api/src/scripts/main.ts b/apps/edr-freight-api/src/scripts/main.ts new file mode 100644 index 000000000..f3a304233 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/main.ts @@ -0,0 +1,35 @@ +import "reflect-metadata"; +import { config } from "dotenv"; + +config(); + +import Vorpal from "vorpal"; + +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "../app.module"; + +const vorpal = new Vorpal(); + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: false, + }); + + try { + // registerCommands(vorpal, { app }); + + const args = process.argv.slice(2); + if (args.length > 0) { + await vorpal.exec(args.join(" ")); + } else { + vorpal.parse(process.argv); + } + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error("Script failed:", err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts new file mode 100644 index 000000000..9aa9e169c --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts @@ -0,0 +1,626 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { WagonStatus } from '@edr/types'; +import { In } from 'typeorm'; + +config({ path: resolve(__dirname, '../../.env') }); + +import { AppDataSource } from '../data-source'; +import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { CompanyProfile, ProfileStatus, ProfileType } from '../modules/companies/entities/company-profile.entity'; +import { Company, CompanyKind, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; +import { Container } from '../modules/container-management/entities/container.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity'; +import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; +import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; +import { Wagon } from '../modules/wagons/entities/wagon.entity'; +import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; +import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; + +type Direction = 'IMPORT' | 'EXPORT'; +type TrainStatus = 'SCHEDULED' | 'ARRIVED'; + +interface ScenarioTrain { + trainNumber: string; + direction: Direction; + status: TrainStatus; + departureOffsetHours: number; + arrivalOffsetHours: number; + bookings: Array<{ + reference: string; + mileVariant: 'FIRST_MILE' | 'LAST_MILE' | 'TERMINAL'; + containerNumber: string; + weightTons: number; + }>; +} + +const SCENARIOS: ScenarioTrain[] = [ + { + trainNumber: 'GP-IMP-ARR-01', + direction: 'IMPORT', + status: 'ARRIVED', + departureOffsetHours: -18, + arrivalOffsetHours: -6, + bookings: [ + { reference: 'GP-IMP-ARR-LM-001', mileVariant: 'LAST_MILE', containerNumber: 'GPIM0000001', weightTons: 22 }, + { reference: 'GP-IMP-ARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPIM0000002', weightTons: 24 }, + ], + }, + { + trainNumber: 'GP-IMP-NARR-01', + direction: 'IMPORT', + status: 'SCHEDULED', + departureOffsetHours: 6, + arrivalOffsetHours: 18, + bookings: [ + { reference: 'GP-IMP-NARR-LM-001', mileVariant: 'LAST_MILE', containerNumber: 'GPIM0000003', weightTons: 21 }, + { reference: 'GP-IMP-NARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPIM0000004', weightTons: 23 }, + ], + }, + { + trainNumber: 'GP-EXP-ARR-01', + direction: 'EXPORT', + status: 'ARRIVED', + departureOffsetHours: -16, + arrivalOffsetHours: -4, + bookings: [ + { reference: 'GP-EXP-ARR-FM-001', mileVariant: 'FIRST_MILE', containerNumber: 'GPEX0000001', weightTons: 20 }, + { reference: 'GP-EXP-ARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPEX0000002', weightTons: 22 }, + ], + }, + { + trainNumber: 'GP-EXP-NARR-01', + direction: 'EXPORT', + status: 'SCHEDULED', + departureOffsetHours: 8, + arrivalOffsetHours: 20, + bookings: [ + { reference: 'GP-EXP-NARR-FM-001', mileVariant: 'FIRST_MILE', containerNumber: 'GPEX0000003', weightTons: 19 }, + { reference: 'GP-EXP-NARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPEX0000004', weightTons: 21 }, + ], + }, +]; + +const addHours = (date: Date, hours: number): Date => new Date(date.getTime() + hours * 60 * 60 * 1000); + +async function main() { + const dataSource = await AppDataSource.initialize(); + + try { + const seeded = await dataSource.transaction(async (manager) => { + if (await isAlreadySeeded(manager)) { + return null; + } + const refs = await ensureReferences(manager); + const now = new Date(); + const result: Array<{ trainNumber: string; bookings: string[] }> = []; + + for (const scenario of SCENARIOS) { + const schedule = await seedScenarioTrain(manager, scenario, refs, now); + result.push({ + trainNumber: schedule.trainNumber ?? scenario.trainNumber, + bookings: scenario.bookings.map((booking) => booking.reference), + }); + } + + return result; + }); + + console.log('Gate-pass train scenario seed complete.'); + if (seeded) { + for (const row of seeded) { + console.log(`${row.trainNumber}: ${row.bookings.join(', ')}`); + } + } else { + console.log('Gate-pass train scenarios already seeded; nothing changed.'); + } + } finally { + await dataSource.destroy(); + } +} + +async function isAlreadySeeded(manager: any): Promise { + const scheduleRepo = manager.getRepository(TrainSchedule); + const bookingRepo = manager.getRepository(Booking); + const trainNumbers = SCENARIOS.map((scenario) => scenario.trainNumber); + const bookingRefs = SCENARIOS.flatMap((scenario) => scenario.bookings.map((booking) => booking.reference)); + + const [scheduleCount, bookingCount] = await Promise.all([ + scheduleRepo.count({ where: { trainNumber: In(trainNumbers) } }), + bookingRepo.count({ where: { reference: In(bookingRefs) } }), + ]); + + return scheduleCount === trainNumbers.length && bookingCount === bookingRefs.length; +} + +async function ensureReferences(manager: any) { + const yardRepo = manager.getRepository(Yard); + const serviceTypeRepo = manager.getRepository(ServiceType); + const containerTypeRepo = manager.getRepository(ContainerType); + const wagonTypeRepo = manager.getRepository(WagonType); + const companyRepo = manager.getRepository(Company); + const profileRepo = manager.getRepository(CompanyProfile); + const warehouseRepo = manager.getRepository(Warehouse); + const warehouseYardRepo = manager.getRepository(WarehouseYard); + const warehouseZoneRepo = manager.getRepository(WarehouseZone); + + const djiboutiYard = + (await yardRepo.findOne({ where: { code: 'NAGAD' } })) ?? + (await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })) ?? + (await yardRepo.save( + yardRepo.create({ + code: 'NAGAD', + label: 'Nagad Port', + country: 'Djibouti', + isActive: true, + displayOrder: 90, + }), + )); + + const ethiopiaYard = + (await yardRepo.findOne({ where: { code: 'INDODE' } })) ?? + (await yardRepo.findOne({ where: { code: 'MOJO' } })) ?? + (await yardRepo.findOne({ where: { country: 'Ethiopia' } })) ?? + (await yardRepo.save( + yardRepo.create({ + code: 'INDODE', + label: 'Indode Dry Port', + country: 'Ethiopia', + isActive: true, + displayOrder: 91, + }), + )); + + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.save( + serviceTypeRepo.create({ + code: 'RAIL_CONTAINER', + serviceName: 'Rail Container Service', + description: 'Rail container service for gate-pass scenario seed', + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + isActive: true, + displayOrder: 1, + }), + )); + + const containerType = + (await containerTypeRepo.findOne({ where: { code: '40FT' } })) ?? + (await containerTypeRepo.findOne({ where: { isActive: true } })) ?? + (await containerTypeRepo.save( + containerTypeRepo.create({ + code: '40FT', + label: '40FT', + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 1, + }), + )); + + const wagonType = + (await wagonTypeRepo.findOne({ where: { code: 'GP-FLAT' } })) ?? + (await wagonTypeRepo.findOne({ where: { supportsContainer: true } })) ?? + (await wagonTypeRepo.findOne({ where: { isActive: true } })) ?? + (await wagonTypeRepo.save( + wagonTypeRepo.create({ + code: 'GP-FLAT', + name: 'Gate Pass Demo Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + equatedLengthM: 14, + tareWeightTons: 20, + supportsContainer: true, + maxContainerGrossT: 70, + }), + )); + + const company = + (await companyRepo.findOne({ where: { tin: 'GTPASS001' } })) ?? + (await companyRepo.save( + companyRepo.create({ + name: 'Gate Pass Scenario Customer', + type: CompanyType.Customer, + kind: CompanyKind.Commercial, + status: CompanyStatus.Active, + tin: 'GTPASS001', + vatNumber: 'GTPASS001', + fanNumber: 'GTPASS0000001', + country: 'Ethiopia', + address: 'Indode Dry Port', + phone: '251900000555', + email: 'gate-pass-scenarios@edr.local', + contactPersonName: 'Gate Pass Tester', + contactPersonPhone: '251900000555', + }), + )); + + const importerProfile = await ensureProfile(profileRepo, company.id, ProfileType.importer, 'GP-IMP'); + const exporterProfile = await ensureProfile(profileRepo, company.id, ProfileType.exporter, 'GP-EXP'); + + const warehouse = + (await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } })) ?? + (await warehouseRepo.findOne({ where: {} })); + if (!warehouse) { + throw new Error('No warehouse found. Run the Indode/warehouse seed before gate-pass scenarios.'); + } + const warehouseYard = await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } }); + if (!warehouseYard) { + throw new Error(`No warehouse yard found for ${warehouse.code ?? warehouse.id}.`); + } + const warehouseZone = await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } }); + if (!warehouseZone) { + throw new Error(`No warehouse zone found for yard ${warehouseYard.id}.`); + } + + return { + djiboutiYard, + ethiopiaYard, + serviceType, + containerType, + wagonType, + company, + importerProfile, + exporterProfile, + warehouse, + warehouseYard, + warehouseZone, + }; +} + +async function ensureProfile(repo: any, companyId: string, type: ProfileType, reference: string): Promise { + const existing = await repo.findOne({ where: { companyId, type } }); + if (existing) return existing; + return repo.save( + repo.create({ + companyId, + type, + reference, + status: ProfileStatus.Active, + businessLicense: `${reference}-LICENSE`, + }), + ); +} + +async function seedScenarioTrain(manager: any, scenario: ScenarioTrain, refs: Awaited>, now: Date) { + const locomotiveRepo = manager.getRepository(Locomotive); + const trainSetRepo = manager.getRepository(TrainSet); + const scheduleRepo = manager.getRepository(TrainSchedule); + const trainSetWagonRepo = manager.getRepository(TrainSetWagon); + const wagonRepo = manager.getRepository(Wagon); + + const departure = addHours(now, scenario.departureOffsetHours); + const arrival = addHours(now, scenario.arrivalOffsetHours); + const isArrived = scenario.status === 'ARRIVED'; + const originYard = scenario.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard; + const destinationYard = scenario.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard; + const totalWeightTons = scenario.bookings.reduce((sum, booking) => sum + booking.weightTons, 0); + + const locomotive = + (await locomotiveRepo.findOne({ where: { code: 'GP-DEMO-LOCO' } })) ?? + (await locomotiveRepo.save( + locomotiveRepo.create({ + code: 'GP-DEMO-LOCO', + name: 'Gate Pass Scenario Locomotive', + locomotiveType: 'DIESEL', + maxPullWeightTons: 4200, + maxTrainLengthMeters: 760, + status: 'AVAILABLE', + currentYardId: originYard.id, + }), + )); + + let schedule = await scheduleRepo.findOne({ where: { trainNumber: scenario.trainNumber } }); + let trainSet: TrainSet | null = schedule?.trainSetId + ? await trainSetRepo.findOne({ where: { id: schedule.trainSetId } }) + : null; + + if (!trainSet) { + trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: locomotive.id, + totalWeightTons, + totalLengthMeters: scenario.bookings.length * 14, + wagonCount: scenario.bookings.length, + status: isArrived ? 'COMPLETED' : 'ASSIGNED', + }), + ); + } else { + await trainSetRepo.update(trainSet.id, { + locomotiveId: locomotive.id, + totalWeightTons, + totalLengthMeters: scenario.bookings.length * 14, + wagonCount: scenario.bookings.length, + status: isArrived ? 'COMPLETED' : 'ASSIGNED', + }); + } + if (!trainSet) { + throw new Error(`Could not create train set for ${scenario.trainNumber}`); + } + const trainSetId = trainSet.id; + + if (!schedule) { + schedule = scheduleRepo.create({ trainNumber: scenario.trainNumber }); + } + Object.assign(schedule, { + trainSetId, + originStationId: originYard.id, + destinationStationId: destinationYard.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualDepartureAt: isArrived ? departure : null, + actualArrivalAt: isArrived ? arrival : null, + status: scenario.status, + direction: scenario.direction, + maxWagons: 53, + bookingWindowStatus: 'CLOSED', + }); + schedule = await scheduleRepo.save(schedule); + + for (const [index, bookingSpec] of scenario.bookings.entries()) { + const sequenceNo = index + 1; + const wagon = await ensureWagon(manager, scenario, sequenceNo, refs.wagonType.id, originYard.id, schedule.id); + const trainSetWagon = await ensureTrainSetWagon( + trainSetWagonRepo, + trainSetId, + refs.wagonType.id, + wagon.id, + sequenceNo, + bookingSpec.weightTons, + isArrived, + ); + await wagonRepo.update(wagon.id, { trainSetWagonId: trainSetWagon.id }); + + const booking = await ensureBooking(manager, scenario, bookingSpec, refs, departure, now, schedule.id); + const bookingContainer = await ensureBookingContainer(manager, booking.id, refs.containerType.id, bookingSpec); + const allocation = await ensureAllocation(manager, trainSetWagon.id, booking.id, bookingSpec.weightTons, isArrived, now); + const container = await ensureContainer(manager, booking.id, bookingContainer.id, allocation.id, wagon.id, sequenceNo, bookingSpec, refs.containerType.id, isArrived); + await ensureContainerItem(manager, allocation.id, bookingContainer.id, container.id, refs.containerType.id, sequenceNo, bookingSpec); + await ensureScheduleBooking(manager, schedule.id, booking.id); + if (scenario.direction === 'EXPORT') { + await ensureExportInventory(manager, refs, booking.id, container.id, bookingSpec.weightTons, isArrived, now); + } + } + + if (scenario.direction === 'IMPORT') { + await ensureImportOperation(manager, schedule.id, scenario, departure, isArrived); + } + + return schedule; +} + +async function ensureWagon(manager: any, scenario: ScenarioTrain, sequenceNo: number, wagonTypeId: string, yardId: string, scheduleId: string): Promise { + const repo = manager.getRepository(Wagon); + const wagonNumber = `${scenario.trainNumber}-W${String(sequenceNo).padStart(2, '0')}`; + const existing = await repo.findOne({ where: { wagonNumber } }); + const values = { + wagonNumber, + wagonTypeId, + trainId: null, + sequenceNumber: sequenceNo, + tareWeight: 20, + maxPayloadWeight: 70, + status: WagonStatus.Assigned, + currentYardId: yardId, + currentTrainScheduleId: scheduleId, + notes: 'Gate-pass scenario seed wagon', + }; + return repo.save(repo.create({ ...(existing ?? {}), ...values })); +} + +async function ensureTrainSetWagon(repo: any, trainSetId: string, wagonTypeId: string, wagonId: string, sequenceNo: number, weightTons: number, isArrived: boolean): Promise { + const existing = await repo.findOne({ where: { trainSetId, sequenceNo } }); + return repo.save( + repo.create({ + ...(existing ?? {}), + trainSetId, + wagonTypeId, + physicalWagonId: wagonId, + sequenceNo, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: weightTons, + status: isArrived ? 'DEPARTED' : 'LOADED', + }), + ); +} + +async function ensureBooking(manager: any, scenario: ScenarioTrain, bookingSpec: ScenarioTrain['bookings'][number], refs: Awaited>, departure: Date, now: Date, scheduleId: string): Promise { + const repo = manager.getRepository(Booking); + const originYard = scenario.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard; + const destinationYard = scenario.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard; + const existing = await repo.findOne({ where: { reference: bookingSpec.reference } }); + const profile = scenario.direction === 'IMPORT' ? refs.importerProfile : refs.exporterProfile; + const hasFirstMile = bookingSpec.mileVariant === 'FIRST_MILE'; + const hasLastMile = bookingSpec.mileVariant === 'LAST_MILE'; + + return repo.save( + repo.create({ + ...(existing ?? {}), + reference: bookingSpec.reference, + companyId: refs.company.id, + companyProfileId: profile.id, + originYardId: originYard.id, + destinationYardId: destinationYard.id, + serviceTypeId: refs.serviceType.id, + status: scenario.status === 'ARRIVED' ? 'IN_TRANSIT' : 'PAID', + paymentStatus: 'PAID', + scheduledDate: departure, + estimatedShipmentDate: departure, + contractType: 'SPOT', + equipmentReturn: 'TERMINAL', + paymentCurrency: 'ETB', + totalAmount: 0, + isGovernment: false, + tradeDirection: scenario.direction, + freightType: 'CONTAINER', + cargoTypeId: null, + cargoFreeText: `${scenario.direction} gate-pass scenario ${bookingSpec.mileVariant.toLowerCase().replace('_', ' ')}`, + cargoTotalWeightVgm: bookingSpec.weightTons * 1000, + firstMilePickupAddress: hasFirstMile ? 'Customer factory pickup - Addis Ababa' : null, + firstMilePickupLat: hasFirstMile ? 9.03 : null, + firstMilePickupLng: hasFirstMile ? 38.74 : null, + lastMileDeliveryAddress: hasLastMile ? 'Customer warehouse delivery - Addis Ababa' : null, + lastMileDeliveryLat: hasLastMile ? 8.98 : null, + lastMileDeliveryLng: hasLastMile ? 38.8 : null, + trainScheduleId: scheduleId, + schedulingStatus: scenario.status === 'ARRIVED' ? 'DISPATCHED' : 'SCHEDULED', + scheduledAt: now, + wagonsRequired: 1, + }), + ); +} + +async function ensureBookingContainer(manager: any, bookingId: string, containerTypeId: string, bookingSpec: ScenarioTrain['bookings'][number]): Promise { + const repo = manager.getRepository(BookingContainer); + const existing = await repo.findOne({ where: { bookingId } }); + return repo.save( + repo.create({ + ...(existing ?? {}), + bookingId, + containerTypeId, + containerNumber: bookingSpec.containerNumber, + containerSize: '40', + quantity: 1, + hazardousQuantity: 0, + reeferQuantity: 0, + vgmPerUnitTons: bookingSpec.weightTons, + totalVgmTons: bookingSpec.weightTons, + wagonsRequired: 1, + weightLimitRuleId: null, + isOverweight: false, + overweightExcessTons: null, + }), + ); +} + +async function ensureAllocation(manager: any, trainSetWagonId: string, bookingId: string, weightTons: number, isArrived: boolean, now: Date): Promise { + const repo = manager.getRepository(WagonBookingAllocation); + const existing = await repo.findOne({ where: { trainSetWagonId, bookingId } }); + return repo.save( + repo.create({ + ...(existing ?? {}), + trainSetWagonId, + bookingId, + allocatedWeightTons: weightTons, + loadType: 'CONTAINER', + status: isArrived ? 'DEPARTED' : 'LOADED', + confirmedAt: now, + }), + ); +} + +async function ensureContainer(manager: any, bookingId: string, bookingContainerId: string, allocationId: string, wagonId: string, position: number, bookingSpec: ScenarioTrain['bookings'][number], containerTypeId: string, isArrived: boolean): Promise { + const repo = manager.getRepository(Container); + const existing = await repo.findOne({ where: { containerNumber: bookingSpec.containerNumber } }); + return repo.save( + repo.create({ + ...(existing ?? {}), + containerNumber: bookingSpec.containerNumber, + containerTypeId, + wagonId, + position, + tareWeight: 3800, + maxGrossWeight: 30480, + sealNumber: `SEAL-${bookingSpec.containerNumber}`, + status: isArrived ? 'IN_TRANSIT' : 'LOADED', + bookingId, + wagonBookingAllocationId: allocationId, + bookingContainerId, + }), + ); +} + +async function ensureContainerItem(manager: any, allocationId: string, bookingContainerId: string, containerId: string, containerTypeId: string, position: number, bookingSpec: ScenarioTrain['bookings'][number]): Promise { + const repo = manager.getRepository(WagonAllocationContainerItem); + await repo.delete({ wagonBookingAllocationId: allocationId }); + await repo.save( + repo.create({ + wagonBookingAllocationId: allocationId, + bookingContainerId, + containerId, + containerNumber: bookingSpec.containerNumber, + containerTypeId, + positionOnWagon: position, + sealNumber: `SEAL-${bookingSpec.containerNumber}`, + chassisNumber: `CHS-${bookingSpec.containerNumber}`, + grossWeightTons: bookingSpec.weightTons, + }), + ); +} + +async function ensureScheduleBooking(manager: any, scheduleId: string, bookingId: string): Promise { + const repo = manager.getRepository(TrainScheduleBooking); + const existing = await repo.findOne({ where: { bookingId } }); + await repo.save(repo.create({ ...(existing ?? {}), trainScheduleId: scheduleId, bookingId })); +} + +async function ensureExportInventory(manager: any, refs: Awaited>, bookingId: string, containerId: string, weightTons: number, isArrived: boolean, now: Date): Promise { + const repo = manager.getRepository(WarehouseInventory); + const existing = await repo.findOne({ where: { bookingId } }); + await repo.save( + repo.create({ + ...(existing ?? {}), + warehouseId: refs.warehouse.id, + yardId: refs.warehouseYard.id, + zoneId: refs.warehouseZone.id, + bookingId, + containerId, + quantity: 1, + weight: weightTons * 1000, + status: 'LOADED', + inspectionStatus: 'PASSED', + arrivedAt: addHours(now, -24), + inspectedAt: addHours(now, -22), + readyForLoadingAt: addHours(now, -20), + loadedAt: isArrived ? addHours(now, -16) : null, + notes: '[GP-SCENARIO] Export train gate-pass scenario inventory', + }), + ); +} + +async function ensureImportOperation(manager: any, scheduleId: string, scenario: ScenarioTrain, departure: Date, isArrived: boolean): Promise { + const repo = manager.getRepository(ImportDjiboutiOperation); + const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } }); + await repo.save( + repo.create({ + ...(existing ?? {}), + trainScheduleId: scheduleId, + documents: existing?.documents ?? {}, + gatepassGrantedAt: null, + readyForLoadingAt: null, + loadedOnTrainAt: null, + departedFromDjiboutiAt: isArrived ? departure : null, + loadListGeneratedAt: null, + performedBy: 'Gate Pass Scenario Seeder', + notes: `[GP-SCENARIO] ${scenario.trainNumber}; fill gate-pass dates during testing`, + }), + ); +} + +main().catch((error) => { + console.error('Gate-pass train scenario seed failed:', error); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index ff4a34493..3ebcca6ab 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -138,7 +138,6 @@ async function main() { includesFirstMile: false, includesLastMile: false, includesCustoms: false, - priorityBonusPoints: 0, isActive: true, displayOrder: 1, }), diff --git a/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts b/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts new file mode 100644 index 000000000..6b2a422e8 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../.env') }); + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { PaidImportExportMileDemoSeeder } from '../seed/paid-import-export-mile-demo.seeder'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + try { + const seeder = app.get(PaidImportExportMileDemoSeeder); + await seeder.run(); + console.log('Paid import/export mile demo bookings seeded.'); + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error('Paid import/export mile demo booking seed failed:', err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts index 6b0465112..989e18bf1 100644 --- a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -20,8 +20,8 @@ const COMPANY_TIN = 'FLMDEMO001'; const COMPANY_EMAIL = 'first-last-mile-demo@edr.local'; const YARDS = [ - { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, - { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, + { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 }, + { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 }, ]; const CONTAINER_TYPES = [ @@ -214,7 +214,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { includesFirstMile: true, includesLastMile: true, includesCustoms: false, - priorityBonusPoints: 0, isActive: true, displayOrder: 10, }, diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 9670243c9..c42d831bc 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -28,17 +28,22 @@ const COMPANY_EMAIL = "train-scheduling-demo@edr.local"; const COMPANY_TIN = "1234567890"; const YARDS = [ - { code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 }, + { + code: "DJIBOUTI", + label: "Djibouti", + country: "Djibouti" as const, + displayOrder: 1, + }, { code: "ADDIS_ABABA", label: "Addis Ababa", - country: "Ethiopia", + country: "Ethiopia" as const, displayOrder: 2, }, { code: "DIRE_DAWA", label: "Dire Dawa", - country: "Ethiopia", + country: "Ethiopia" as const, displayOrder: 3, }, ]; @@ -290,7 +295,6 @@ export class DemoBookingsSeeder { includesFirstMile: false, includesLastMile: false, includesCustoms: false, - priorityBonusPoints: 0, isActive: true, displayOrder: 1, }, @@ -468,15 +472,15 @@ export class DemoBookingsSeeder { const djibouti = yardByCode.get("DJIBOUTI"); const addis = yardByCode.get("ADDIS_ABABA"); if (djibouti && addis) { - const routeName = "Djibouti → Addis Ababa"; - let route = await manager.getRepository(Route).findOneBy({ name: routeName }); + let route = await manager.getRepository(Route).findOne({ + where: { originYardId: djibouti.id, destinationYardId: addis.id }, + }); if (!route) { route = await manager.getRepository(Route).save( manager.getRepository(Route).create({ - name: routeName, originYardId: djibouti.id, destinationYardId: addis.id, - isActive: true, + status: 'AVAILABLE', }), ); await manager.getRepository(RouteMilestone).save([ @@ -484,11 +488,13 @@ export class DemoBookingsSeeder { routeId: route.id, yardId: djibouti.id, sequenceNo: 1, + distanceKm: 0, }), manager.getRepository(RouteMilestone).create({ routeId: route.id, yardId: addis.id, sequenceNo: 2, + distanceKm: 780, }), ]); } diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 3c295c9d0..e6ea89fbe 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -1,6 +1,7 @@ import { BOOKING_RULE_ENGINE_PERMISSIONS, BOOKING_RULE_ENGINE_PERMISSION_KEYS, + POSITION_PERMISSION_PRESETS, ROLE_PERMISSION_PRESETS, } from './freight-permissions.registry'; @@ -10,6 +11,13 @@ export type FreightSeedRole = { permissionKeys: string[]; }; +export type FreightSeedPosition = { + key: string; + name: { en: string }; + rank: number; + permissionKeys: string[]; +}; + const IAM_PERMISSION_KEYS = { activateEmployee: "can:activateEmployee", activateUser: "can:activateUser", @@ -282,3 +290,18 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ permissionKeys: [], }, ]; + +/** + * Operational positions (positions-as-roles). Seeded as Position + + * PositionPermission rows (NOT Role/RolePermission). Users get their access by + * being assigned to a Position via EmployeePosition. + */ +export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [ + { key: "chief", name: { en: "Chief" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.chief] }, + { key: "director", name: { en: "Director" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.director] }, + { key: "ceo", name: { en: "CEO" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.ceo] }, + { key: "ethiopian_gl", name: { en: "Ethiopian GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.ethiopianGl] }, + { key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] }, + { key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] }, + { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, +]; diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index 8243ef267..6b8529225 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -1,16 +1,20 @@ import { Injectable, Logger } from "@nestjs/common"; import { + Application, Organization, OrganizationConfiguration, Permission, - Role, - RolePermission, + Unit, } from "@tria-plc/iamapi-common"; -import { DataSource, EntityManager, In } from "typeorm"; +import { DataSource, EntityManager } from "typeorm"; -import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum"; -import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry"; -import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed"; +import { + EDR_FREIGHT_APPLICATION, + EDR_FREIGHT_PERMISSIONS, +} from "./edr-freight.seed"; + +const EDR_UNIT_KEY = "edr_freight_app"; +const EDR_UNIT_NAME = { en: "EDR Freight App" }; const EDR_ORG_KEY = "edr_freight"; const EDR_ORG_NAME = { en: "EDR Freight" }; @@ -37,9 +41,15 @@ export class EdrOrgSeeder { const organization = await this.ensureOrganization(manager); await this.ensureOrganizationConfiguration(manager, organization.id); - await this.ensureRoles(manager, EDR_FREIGHT_ROLES); - await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES); - await this.ensureSuperAdminPermissions(manager); + await this.ensureDefaultUnit(manager, organization.id); + + const application = await this.ensureApplication(manager); + await this.ensurePermissions(manager, application.id); + + // Roles, positions and their permission links are intentionally NOT + // seeded for now — only the application-scoped permission catalog, + // mirroring how the default IAM seed relates permissions to their + // application. Grants are assigned later through the IAM UI. }); this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); @@ -101,108 +111,76 @@ 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( + private async ensureDefaultUnit( manager: EntityManager, - seedRoles: FreightSeedRole[], - ) { - const permissionKeys = [...new Set(seedRoles.flatMap((role) => role.permissionKeys))]; + organizationId: string, + ): Promise<{ id: string }> { + const unitRepository = manager.getRepository(Unit); - if (!permissionKeys.length) { - this.logger.log("No EDR role permissions configured; skipping role-permission links"); - return; + let unit = await unitRepository.findOne({ + where: { key: EDR_UNIT_KEY, organizationId }, + select: { id: true }, + }); + + if (!unit) { + const insertResult = await unitRepository.insert({ + key: EDR_UNIT_KEY, + name: EDR_UNIT_NAME, + organizationId, + }); + this.logger.log(`Seeded EDR unit '${EDR_UNIT_KEY}'`); + return { id: insertResult.identifiers[0]?.id as string }; } - 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`); + this.logger.log(`Ensured EDR unit '${EDR_UNIT_KEY}'`); + return { id: unit.id }; } - private async ensureSuperAdminPermissions(manager: EntityManager) { - const role = await manager.getRepository(Role).findOne({ - where: { key: ERoleKey.SUPER_ADMIN }, - select: { id: true, key: true }, + private async ensureApplication( + manager: EntityManager, + ): Promise<{ id: string }> { + const applicationRepository = manager.getRepository(Application); + + const application = await applicationRepository.findOne({ + where: { key: EDR_FREIGHT_APPLICATION.key }, + select: { id: true }, }); - if (!role) { - this.logger.warn( - `Role ${ERoleKey.SUPER_ADMIN} not found; skipping booking/rule-engine super_admin links`, - ); - return; + if (!application?.id) { + const insertResult = await applicationRepository.insert({ + id: EDR_FREIGHT_APPLICATION.id, + key: EDR_FREIGHT_APPLICATION.key, + name: { ...EDR_FREIGHT_APPLICATION.name }, + }); + this.logger.log(`Seeded EDR application '${EDR_FREIGHT_APPLICATION.key}'`); + return { id: insertResult.identifiers[0]?.id as string }; } - const permissions = await manager.getRepository(Permission).find({ - where: { key: In(BOOKING_RULE_ENGINE_PERMISSION_KEYS) }, - select: { id: true, key: true }, - }); + this.logger.log(`Ensured EDR application '${EDR_FREIGHT_APPLICATION.key}'`); + return { id: application.id }; + } - if (!permissions.length) { - this.logger.warn('No booking/rule-engine permissions found for super_admin'); - return; - } + private async ensurePermissions( + manager: EntityManager, + applicationId: string, + ) { + const permissionRepository = manager.getRepository(Permission); - await manager.getRepository(RolePermission).upsert( - permissions.map((permission) => ({ - roleId: role.id, - permissionId: permission.id, + // Upsert by key so reruns are idempotent; applicationId ties every + // permission to the EDR Freight application (also backfills rows that + // were previously seeded without the relation). + await permissionRepository.upsert( + EDR_FREIGHT_PERMISSIONS.map((permission) => ({ + id: permission.id, + key: permission.key, + name: { ...permission.name }, + applicationId, })), - { conflictPaths: { roleId: true, permissionId: true } }, + { conflictPaths: { key: true } }, ); this.logger.log( - `Ensured ${permissions.length} booking+rule-engine permissions on super_admin`, + `Ensured ${EDR_FREIGHT_PERMISSIONS.length} permissions on application '${EDR_FREIGHT_APPLICATION.key}'`, ); } } diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 3466e6653..1aef33801 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -350,6 +350,20 @@ const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ entity: CLEARANCE_ENTITY, fields: EXPORT_CONTAINER_OUTPUT_FIELDS, }, + // Bulk output sets mirror the container output docs so customs+bulk bookings + // can finalize (previously bulk had no output set and got stuck at finalize). + { + code: "clearance_output_import_bulk", + label: "Customs output documents (import bulk)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_OUTPUT_FIELDS, + }, + { + code: "clearance_output_export_bulk", + label: "Customs output documents (export bulk)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_OUTPUT_FIELDS, + }, ]; // ── Contract pre-booking clearance settings (Path B) ──────────────────────── @@ -398,6 +412,20 @@ const CONTRACT_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ entity: CONTRACT_CLEARANCE_ENTITY, fields: EXPORT_CONTAINER_OUTPUT_FIELDS, }, + // Bulk output sets mirror the container output docs so customs+bulk contracts + // can finalize (previously bulk had no output set and got stuck at finalize). + { + code: "contract_clearance_output_import_bulk", + label: "Contract customs output documents (import bulk)", + entity: CONTRACT_CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_OUTPUT_FIELDS, + }, + { + code: "contract_clearance_output_export_bulk", + label: "Contract customs output documents (export bulk)", + entity: CONTRACT_CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_OUTPUT_FIELDS, + }, ]; // ── Path A self-clearance settings (no EDR customs service) ────────────────── @@ -484,6 +512,32 @@ const CONTRACT_INTAKE_SETTINGS: OnboardingDocumentSetting[] = [ const CLEARANCE_DESCRIPTION = "Operation/clearance documents collected after contract execution, by operation, freight type and customs."; +// ── Driver documents ──────────────────────────────────────────────────────── +// Configurable upload area (code "driver_docs") attached to a driver profile — +// license, national ID, contracts, training certificates, etc. +const DRIVER_DOCUMENT_FIELDS: OnboardingField[] = [ + { + fileKey: "driver_docs", + fileLabel: "Driver documents", + helpText: "License, national ID, contracts, training certificates, etc.", + isRequired: false, + isMultiple: true, + maxFiles: 20, + allowedExtensions: ["pdf", "jpg", "jpeg", "png", "doc", "docx"], + maxSizeMb: 10, + displayOrder: 1, + }, +]; + +const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ + { + code: "driver_docs", + label: "Driver documents", + entity: "driver", + fields: DRIVER_DOCUMENT_FIELDS, + }, +]; + @Injectable() export class FileUploadSettingsSeeder { private readonly logger = new Logger(FileUploadSettingsSeeder.name); @@ -521,6 +575,11 @@ export class FileUploadSettingsSeeder { description: "Commercial/framework documents attached at contract submission.", })), + ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: + "Documents uploaded against a driver profile (license, ID, contracts, etc.).", + })), ]; for (const documentSetting of allSettings) { diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index ba0d6da19..d4d85e84c 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -60,6 +60,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'), perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'), perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'), + perm('a1000001-0001-4000-8000-000000000024', 'edr_freight_app:bookings:create', 'Create booking'), ]; /** @@ -80,6 +81,9 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'), perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'), perm('a3000001-0001-4000-8000-00000000000d', 'edr_freight_app:contracts:ops_clearance_review', 'Operations review of self-clearance docs (Path A)'), + perm('a3000001-0001-4000-8000-00000000000e', 'edr_freight_app:contracts:clearance_et_actions', 'GL Ethiopia phased clearance actions'), + perm('a3000001-0001-4000-8000-00000000000f', 'edr_freight_app:contracts:clearance_dj_actions', 'GL Djibouti phased clearance actions'), + perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'), ]; const RULE_ENGINE_PERMISSION_IDS: Record = { @@ -106,10 +110,217 @@ export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESO }, ); +/** + * Container-allocation permission for the previously-unguarded + * booking allocate-containers endpoint. + */ +export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [ + perm('c1000001-0001-4000-8000-000000000001', 'edr_freight_app:allocation:manage', 'Allocate containers to vehicles'), +]; + +/** + * Advanced backoffice resources — full CRUD + workflow-action keys. + * See docs/rbac/freight-backoffice-permissions.md. Additive only: the existing + * bookings/contracts/rule-engine/allocation keys above are unchanged. + */ + +// C. Customers +export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [ + perm('d1a00001-0001-4000-8000-000000000001', 'edr_freight_app:customers:view', 'View customers'), + perm('d1a00001-0001-4000-8000-000000000002', 'edr_freight_app:customers:create', 'Create customer'), + perm('d1a00001-0001-4000-8000-000000000003', 'edr_freight_app:customers:update', 'Update customer'), + perm('d1a00001-0001-4000-8000-000000000004', 'edr_freight_app:customers:deactivate', 'Deactivate customer'), + perm('d1a00001-0001-4000-8000-000000000005', 'edr_freight_app:customers:verify', 'Verify customer (KYC/Fayda)'), +]; + +// D. Finance — payments + invoices +export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ + perm('d2a00001-0001-4000-8000-000000000001', 'edr_freight_app:payments:view', 'View payments'), + perm('d2a00001-0001-4000-8000-000000000002', 'edr_freight_app:payments:verify', 'Verify/settle payment'), + perm('d2a00001-0001-4000-8000-000000000003', 'edr_freight_app:payments:refund', 'Refund payment'), + perm('d2b00001-0001-4000-8000-000000000001', 'edr_freight_app:invoices:view', 'View invoices'), + perm('d2b00001-0001-4000-8000-000000000002', 'edr_freight_app:invoices:create', 'Generate invoice'), + perm('d2b00001-0001-4000-8000-000000000003', 'edr_freight_app:invoices:cancel', 'Cancel invoice'), + perm('d2b00001-0001-4000-8000-000000000004', 'edr_freight_app:invoices:export', 'Download invoice document'), +]; + +// E. First / last mile operations +export const MILE_PERMISSIONS: FreightPermissionSeed[] = [ + perm('d3a00001-0001-4000-8000-000000000001', 'edr_freight_app:first_mile:view', 'View first-mile'), + perm('d3a00001-0001-4000-8000-000000000002', 'edr_freight_app:first_mile:accept', 'Accept first-mile request'), + perm('d3a00001-0001-4000-8000-000000000003', 'edr_freight_app:first_mile:create', 'Create first-mile'), + perm('d3a00001-0001-4000-8000-000000000004', 'edr_freight_app:first_mile:update', 'Update first-mile'), + perm('d3a00001-0001-4000-8000-000000000005', 'edr_freight_app:first_mile:delete', 'Delete first-mile'), + perm('d3a00001-0001-4000-8000-000000000006', 'edr_freight_app:first_mile:assign_vehicles', 'Assign first-mile vehicles'), + perm('d3a00001-0001-4000-8000-000000000007', 'edr_freight_app:first_mile:set_distances', 'Set first-mile distances'), + perm('d3a00001-0001-4000-8000-000000000008', 'edr_freight_app:first_mile:generate_invoice', 'Generate first-mile invoice'), + perm('d3b00001-0001-4000-8000-000000000001', 'edr_freight_app:last_mile:view', 'View last-mile'), + perm('d3b00001-0001-4000-8000-000000000002', 'edr_freight_app:last_mile:accept', 'Accept last-mile request'), + perm('d3b00001-0001-4000-8000-000000000003', 'edr_freight_app:last_mile:create', 'Create last-mile'), + perm('d3b00001-0001-4000-8000-000000000004', 'edr_freight_app:last_mile:update', 'Update last-mile'), + perm('d3b00001-0001-4000-8000-000000000005', 'edr_freight_app:last_mile:delete', 'Delete last-mile'), + perm('d3b00001-0001-4000-8000-000000000006', 'edr_freight_app:last_mile:assign_vehicles', 'Assign last-mile vehicles'), + perm('d3b00001-0001-4000-8000-000000000007', 'edr_freight_app:last_mile:set_distances', 'Set last-mile distances'), + perm('d3b00001-0001-4000-8000-000000000008', 'edr_freight_app:last_mile:generate_invoice', 'Generate last-mile invoice'), +]; + +// F. Fleet — rail assets (splits the flat fleet:view/manage) +export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ + perm('e1a00001-0001-4000-8000-000000000001', 'edr_freight_app:locomotives:view', 'View locomotives'), + perm('e1a00001-0001-4000-8000-000000000002', 'edr_freight_app:locomotives:create', 'Create locomotive'), + perm('e1a00001-0001-4000-8000-000000000003', 'edr_freight_app:locomotives:update', 'Update locomotive'), + perm('e1a00001-0001-4000-8000-000000000004', 'edr_freight_app:locomotives:delete', 'Delete locomotive'), + perm('e1b00001-0001-4000-8000-000000000001', 'edr_freight_app:wagons:view', 'View wagons'), + perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'), + perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'), + perm('e1b00001-0001-4000-8000-000000000004', 'edr_freight_app:wagons:delete', 'Delete wagon'), + perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'), + perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'), + perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'), + perm('e1c00001-0001-4000-8000-000000000004', 'edr_freight_app:trains:delete', 'Delete train'), + perm('e1c00001-0001-4000-8000-000000000005', 'edr_freight_app:trains:assign_wagons', 'Assign wagons to train'), + perm('e1d00001-0001-4000-8000-000000000001', 'edr_freight_app:routes:view', 'View routes'), + perm('e1d00001-0001-4000-8000-000000000002', 'edr_freight_app:routes:create', 'Create route'), + perm('e1d00001-0001-4000-8000-000000000003', 'edr_freight_app:routes:update', 'Update route'), + perm('e1d00001-0001-4000-8000-000000000004', 'edr_freight_app:routes:delete', 'Delete route'), + perm('e1e00001-0001-4000-8000-000000000001', 'edr_freight_app:containers:view', 'View containers'), + perm('e1e00001-0001-4000-8000-000000000002', 'edr_freight_app:containers:create', 'Create container'), + perm('e1e00001-0001-4000-8000-000000000003', 'edr_freight_app:containers:update', 'Update container'), + perm('e1e00001-0001-4000-8000-000000000004', 'edr_freight_app:containers:delete', 'Delete container'), + perm('e1f00001-0001-4000-8000-000000000001', 'edr_freight_app:cargoes:view', 'View cargoes'), + perm('e1f00001-0001-4000-8000-000000000002', 'edr_freight_app:cargoes:create', 'Create cargo'), + perm('e1f00001-0001-4000-8000-000000000003', 'edr_freight_app:cargoes:update', 'Update cargo'), + perm('e1f00001-0001-4000-8000-000000000004', 'edr_freight_app:cargoes:delete', 'Delete cargo'), +]; + +// G. Fleet — road & telemetry +export const FLEET_ROAD_PERMISSIONS: FreightPermissionSeed[] = [ + perm('e2a00001-0001-4000-8000-000000000001', 'edr_freight_app:vehicles:view', 'View vehicles'), + perm('e2a00001-0001-4000-8000-000000000002', 'edr_freight_app:vehicles:create', 'Create vehicle'), + perm('e2a00001-0001-4000-8000-000000000003', 'edr_freight_app:vehicles:update', 'Update vehicle'), + perm('e2a00001-0001-4000-8000-000000000004', 'edr_freight_app:vehicles:delete', 'Delete vehicle'), + perm('e2b00001-0001-4000-8000-000000000001', 'edr_freight_app:drivers:view', 'View drivers'), + perm('e2b00001-0001-4000-8000-000000000002', 'edr_freight_app:drivers:create', 'Create driver'), + perm('e2b00001-0001-4000-8000-000000000003', 'edr_freight_app:drivers:update', 'Update driver'), + perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete driver'), + perm('e2c00001-0001-4000-8000-000000000001', 'edr_freight_app:tracking:view', 'Track vehicles'), + perm('e2d00001-0001-4000-8000-000000000001', 'edr_freight_app:fuel:view', 'View fuel purchases'), + perm('e2d00001-0001-4000-8000-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'), + perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update fuel purchase'), + perm('e2d00001-0001-4000-8000-000000000004', 'edr_freight_app:fuel:delete', 'Delete fuel purchase'), + perm('e2d00001-0001-4000-8000-000000000005', 'edr_freight_app:fuel:approve', 'Approve fuel purchase'), + perm('e2e00001-0001-4000-8000-000000000001', 'edr_freight_app:maintenance:view', 'View maintenance'), + perm('e2e00001-0001-4000-8000-000000000002', 'edr_freight_app:maintenance:create', 'Create maintenance'), + perm('e2e00001-0001-4000-8000-000000000003', 'edr_freight_app:maintenance:update', 'Update maintenance'), + perm('e2e00001-0001-4000-8000-000000000004', 'edr_freight_app:maintenance:delete', 'Delete maintenance'), + perm('e2e00001-0001-4000-8000-000000000005', 'edr_freight_app:maintenance:complete', 'Complete maintenance'), + perm('e2f00001-0001-4000-8000-000000000001', 'edr_freight_app:fleet_reports:view', 'View fleet financial reports'), + perm('e2f00001-0001-4000-8000-000000000002', 'edr_freight_app:fleet_reports:export', 'Export fleet financial reports'), + perm('e2000001-0001-4000-8000-000000000001', 'edr_freight_app:fleet_dashboard:view', 'View fleet dashboard'), +]; + +// H. Warehouse management +export const WAREHOUSE_PERMISSIONS: FreightPermissionSeed[] = [ + perm('f1000001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_dashboard:view', 'View warehouse dashboard'), + perm('f1a00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouses:view', 'View warehouses'), + perm('f1a00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouses:create', 'Create warehouse'), + perm('f1a00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouses:update', 'Update warehouse'), + perm('f1a00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouses:delete', 'Delete warehouse'), + perm('f1b00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_yards:view', 'View warehouse yards'), + perm('f1b00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_yards:create', 'Create warehouse yard'), + perm('f1b00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_yards:update', 'Update warehouse yard'), + perm('f1b00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_yards:delete', 'Delete warehouse yard'), + perm('f1c00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_zones:view', 'View warehouse zones'), + perm('f1c00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_zones:create', 'Create warehouse zone'), + perm('f1c00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_zones:update', 'Update warehouse zone'), + perm('f1d00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_allocation_rules:view', 'View allocation rules'), + perm('f1d00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_allocation_rules:create', 'Create allocation rule'), + perm('f1d00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_allocation_rules:update', 'Update allocation rule'), + perm('f1d00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_allocation_rules:delete', 'Delete allocation rule'), + perm('f1e00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_fee_rules:view', 'View fee rules'), + perm('f1e00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_fee_rules:create', 'Create fee rule'), + perm('f1e00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_fee_rules:update', 'Update fee rule'), + perm('f1e00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_fee_rules:delete', 'Delete fee rule'), + perm('f1f00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_inspection_reports:view', 'View inspection reports'), + perm('f1f00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_inspection_reports:create', 'Create inspection report'), + perm('f1f00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_inspection_reports:update', 'Update inspection report'), +]; + +// I. Port & terminal — inventory movement + interchange + fee invoices +export const PORT_TERMINAL_PERMISSIONS: FreightPermissionSeed[] = [ + perm('f2a00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_inventory:view', 'View terminal inventory'), + perm('f2a00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_inventory:receive', 'Receive inventory'), + perm('f2a00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_inventory:move', 'Move/store/reserve inventory'), + perm('f2a00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_inventory:load', 'Load inventory'), + perm('f2a00001-0001-4000-8000-000000000005', 'edr_freight_app:warehouse_inventory:unload', 'Unload inventory'), + perm('f2a00001-0001-4000-8000-000000000006', 'edr_freight_app:warehouse_inventory:dispatch', 'Dispatch inventory'), + perm('f2a00001-0001-4000-8000-000000000007', 'edr_freight_app:warehouse_inventory:gate_pass', 'Gate-clearance inventory'), + perm('f2a00001-0001-4000-8000-000000000008', 'edr_freight_app:warehouse_inventory:release', 'Release inventory'), + perm('f2a00001-0001-4000-8000-000000000009', 'edr_freight_app:warehouse_inventory:deliver', 'Deliver inventory'), + perm('f2a00001-0001-4000-8000-00000000000a', 'edr_freight_app:warehouse_inventory:inspect', 'Inspect inventory'), + perm('f2b00001-0001-4000-8000-000000000001', 'edr_freight_app:interchange_documents:view', 'View interchange documents'), + perm('f2b00001-0001-4000-8000-000000000002', 'edr_freight_app:interchange_documents:generate', 'Generate interchange document'), + perm('f2b00001-0001-4000-8000-000000000003', 'edr_freight_app:interchange_documents:acknowledge', 'Acknowledge interchange document'), + perm('f2b00001-0001-4000-8000-000000000004', 'edr_freight_app:interchange_documents:dispute', 'Dispute interchange document'), + perm('f2b00001-0001-4000-8000-000000000005', 'edr_freight_app:interchange_documents:cancel', 'Cancel interchange document'), + perm('f2c00001-0001-4000-8000-000000000001', 'edr_freight_app:warehouse_fee_invoices:view', 'View warehouse fee invoices'), + perm('f2c00001-0001-4000-8000-000000000002', 'edr_freight_app:warehouse_fee_invoices:generate', 'Generate warehouse fee invoice'), + perm('f2c00001-0001-4000-8000-000000000003', 'edr_freight_app:warehouse_fee_invoices:cancel', 'Cancel warehouse fee invoice'), + perm('f2c00001-0001-4000-8000-000000000004', 'edr_freight_app:warehouse_fee_invoices:pay', 'Pay warehouse fee invoice'), +]; + +// E'. Train-scheduling finer actions (augment existing view/manage) +export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ + perm('a2a00001-0001-4000-8000-000000000001', 'edr_freight_app:train_scheduling:create', 'Create train schedule'), + perm('a2a00001-0001-4000-8000-000000000002', 'edr_freight_app:train_scheduling:update', 'Update train schedule'), + perm('a2a00001-0001-4000-8000-000000000003', 'edr_freight_app:train_scheduling:cancel', 'Cancel train schedule'), + perm('a2a00001-0001-4000-8000-000000000004', 'edr_freight_app:train_scheduling:reschedule', 'Reschedule train'), + perm('a2a00001-0001-4000-8000-000000000005', 'edr_freight_app:train_scheduling:rules_manage', 'Manage global scheduling rules'), +]; + +// L. Administration & settings (split from the coarse admin umbrella) +export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ + perm('b3a00001-0001-4000-8000-000000000001', 'edr_freight_app:config:contract_validity:view', 'View contract validity periods'), + perm('b3a00001-0001-4000-8000-000000000002', 'edr_freight_app:config:contract_validity:manage', 'Manage contract validity periods'), + perm('b4a00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:file_upload:view', 'View file-upload settings'), + perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'), + perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'), + perm('b4b00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:dropdown:manage', 'Manage dropdown settings'), +]; + +// M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment +// / hierarchy_* / position_types:view keys are seeded separately in edr-freight.seed.ts. +export const STAFF_IAM_PERMISSIONS: FreightPermissionSeed[] = [ + perm('c2a00001-0001-4000-8000-000000000001', 'edr_freight_app:staff:roles:view', 'View roles'), + perm('c2a00001-0001-4000-8000-000000000002', 'edr_freight_app:staff:roles:create', 'Create role'), + perm('c2a00001-0001-4000-8000-000000000003', 'edr_freight_app:staff:roles:update', 'Update role'), + perm('c2a00001-0001-4000-8000-000000000004', 'edr_freight_app:staff:roles:delete', 'Delete role'), + perm('c2b00001-0001-4000-8000-000000000001', 'edr_freight_app:staff:permissions:view', 'View permission assignments'), + perm('c2b00001-0001-4000-8000-000000000002', 'edr_freight_app:staff:permissions:assign', 'Assign permissions'), + perm('c2c00001-0001-4000-8000-000000000001', 'edr_freight_app:position_types:create', 'Create position type'), + perm('c2c00001-0001-4000-8000-000000000002', 'edr_freight_app:position_types:update', 'Update position type'), + perm('c2c00001-0001-4000-8000-000000000003', 'edr_freight_app:position_types:delete', 'Delete position type'), +]; + +export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ + ...CUSTOMER_PERMISSIONS, + ...FINANCE_PERMISSIONS, + ...MILE_PERMISSIONS, + ...FLEET_RAIL_PERMISSIONS, + ...FLEET_ROAD_PERMISSIONS, + ...WAREHOUSE_PERMISSIONS, + ...PORT_TERMINAL_PERMISSIONS, + ...SCHEDULING_EXTRA_PERMISSIONS, + ...CONFIG_SETTINGS_PERMISSIONS, + ...STAFF_IAM_PERMISSIONS, +]; + export const BOOKING_RULE_ENGINE_PERMISSIONS = [ ...BOOKING_PERMISSIONS, ...CONTRACT_PERMISSIONS, ...RULE_ENGINE_PERMISSIONS, + ...GAP_CONTROLLER_PERMISSIONS, + ...ADVANCED_BACKOFFICE_PERMISSIONS, ]; export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map( @@ -119,6 +330,7 @@ export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIO export const FREIGHT_PERMS = { bookings: { view: 'edr_freight_app:bookings:view', + create: 'edr_freight_app:bookings:create', clearanceView: 'edr_freight_app:bookings:clearance_view', staffAccept: 'edr_freight_app:bookings:staff_accept', requestChanges: 'edr_freight_app:bookings:request_changes', @@ -149,10 +361,18 @@ export const FREIGHT_PERMS = { finalizeClearance: 'edr_freight_app:contracts:finalize_clearance', createBooking: 'edr_freight_app:contracts:create_booking', opsClearanceReview: 'edr_freight_app:contracts:ops_clearance_review', + clearanceEtActions: 'edr_freight_app:contracts:clearance_et_actions', + clearanceDjActions: 'edr_freight_app:contracts:clearance_dj_actions', + clearanceDutyAdvise: 'edr_freight_app:contracts:clearance_duty_advise', }, trainScheduling: { view: 'edr_freight_app:train_scheduling:view', manage: 'edr_freight_app:train_scheduling:manage', + create: 'edr_freight_app:train_scheduling:create', + update: 'edr_freight_app:train_scheduling:update', + cancel: 'edr_freight_app:train_scheduling:cancel', + reschedule: 'edr_freight_app:train_scheduling:reschedule', + rulesManage: 'edr_freight_app:train_scheduling:rules_manage', }, fleet: { view: 'edr_freight_app:fleet:view', @@ -165,6 +385,247 @@ export const FREIGHT_PERMS = { manage: (slug: RuleEngineResourceSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`, }, + allocation: { + manage: 'edr_freight_app:allocation:manage', + }, + customers: { + view: 'edr_freight_app:customers:view', + create: 'edr_freight_app:customers:create', + update: 'edr_freight_app:customers:update', + deactivate: 'edr_freight_app:customers:deactivate', + verify: 'edr_freight_app:customers:verify', + }, + payments: { + view: 'edr_freight_app:payments:view', + verify: 'edr_freight_app:payments:verify', + refund: 'edr_freight_app:payments:refund', + }, + invoices: { + view: 'edr_freight_app:invoices:view', + create: 'edr_freight_app:invoices:create', + cancel: 'edr_freight_app:invoices:cancel', + export: 'edr_freight_app:invoices:export', + }, + firstMile: { + view: 'edr_freight_app:first_mile:view', + accept: 'edr_freight_app:first_mile:accept', + create: 'edr_freight_app:first_mile:create', + update: 'edr_freight_app:first_mile:update', + delete: 'edr_freight_app:first_mile:delete', + assignVehicles: 'edr_freight_app:first_mile:assign_vehicles', + setDistances: 'edr_freight_app:first_mile:set_distances', + generateInvoice: 'edr_freight_app:first_mile:generate_invoice', + }, + lastMile: { + view: 'edr_freight_app:last_mile:view', + accept: 'edr_freight_app:last_mile:accept', + create: 'edr_freight_app:last_mile:create', + update: 'edr_freight_app:last_mile:update', + delete: 'edr_freight_app:last_mile:delete', + assignVehicles: 'edr_freight_app:last_mile:assign_vehicles', + setDistances: 'edr_freight_app:last_mile:set_distances', + generateInvoice: 'edr_freight_app:last_mile:generate_invoice', + }, + locomotives: { + view: 'edr_freight_app:locomotives:view', + create: 'edr_freight_app:locomotives:create', + update: 'edr_freight_app:locomotives:update', + delete: 'edr_freight_app:locomotives:delete', + }, + wagons: { + view: 'edr_freight_app:wagons:view', + create: 'edr_freight_app:wagons:create', + update: 'edr_freight_app:wagons:update', + delete: 'edr_freight_app:wagons:delete', + }, + trains: { + view: 'edr_freight_app:trains:view', + create: 'edr_freight_app:trains:create', + update: 'edr_freight_app:trains:update', + delete: 'edr_freight_app:trains:delete', + assignWagons: 'edr_freight_app:trains:assign_wagons', + }, + routes: { + view: 'edr_freight_app:routes:view', + create: 'edr_freight_app:routes:create', + update: 'edr_freight_app:routes:update', + delete: 'edr_freight_app:routes:delete', + }, + containers: { + view: 'edr_freight_app:containers:view', + create: 'edr_freight_app:containers:create', + update: 'edr_freight_app:containers:update', + delete: 'edr_freight_app:containers:delete', + }, + cargoes: { + view: 'edr_freight_app:cargoes:view', + create: 'edr_freight_app:cargoes:create', + update: 'edr_freight_app:cargoes:update', + delete: 'edr_freight_app:cargoes:delete', + }, + vehicles: { + view: 'edr_freight_app:vehicles:view', + create: 'edr_freight_app:vehicles:create', + update: 'edr_freight_app:vehicles:update', + delete: 'edr_freight_app:vehicles:delete', + }, + drivers: { + view: 'edr_freight_app:drivers:view', + create: 'edr_freight_app:drivers:create', + update: 'edr_freight_app:drivers:update', + delete: 'edr_freight_app:drivers:delete', + }, + tracking: { + view: 'edr_freight_app:tracking:view', + }, + fuel: { + view: 'edr_freight_app:fuel:view', + create: 'edr_freight_app:fuel:create', + update: 'edr_freight_app:fuel:update', + delete: 'edr_freight_app:fuel:delete', + approve: 'edr_freight_app:fuel:approve', + }, + maintenance: { + view: 'edr_freight_app:maintenance:view', + create: 'edr_freight_app:maintenance:create', + update: 'edr_freight_app:maintenance:update', + delete: 'edr_freight_app:maintenance:delete', + complete: 'edr_freight_app:maintenance:complete', + }, + fleetReports: { + view: 'edr_freight_app:fleet_reports:view', + export: 'edr_freight_app:fleet_reports:export', + }, + fleetDashboard: { + view: 'edr_freight_app:fleet_dashboard:view', + }, + warehouseDashboard: { + view: 'edr_freight_app:warehouse_dashboard:view', + }, + warehouses: { + view: 'edr_freight_app:warehouses:view', + create: 'edr_freight_app:warehouses:create', + update: 'edr_freight_app:warehouses:update', + delete: 'edr_freight_app:warehouses:delete', + }, + warehouseYards: { + view: 'edr_freight_app:warehouse_yards:view', + create: 'edr_freight_app:warehouse_yards:create', + update: 'edr_freight_app:warehouse_yards:update', + delete: 'edr_freight_app:warehouse_yards:delete', + }, + warehouseZones: { + view: 'edr_freight_app:warehouse_zones:view', + create: 'edr_freight_app:warehouse_zones:create', + update: 'edr_freight_app:warehouse_zones:update', + }, + warehouseAllocationRules: { + view: 'edr_freight_app:warehouse_allocation_rules:view', + create: 'edr_freight_app:warehouse_allocation_rules:create', + update: 'edr_freight_app:warehouse_allocation_rules:update', + delete: 'edr_freight_app:warehouse_allocation_rules:delete', + }, + warehouseFeeRules: { + view: 'edr_freight_app:warehouse_fee_rules:view', + create: 'edr_freight_app:warehouse_fee_rules:create', + update: 'edr_freight_app:warehouse_fee_rules:update', + delete: 'edr_freight_app:warehouse_fee_rules:delete', + }, + warehouseInspectionReports: { + view: 'edr_freight_app:warehouse_inspection_reports:view', + create: 'edr_freight_app:warehouse_inspection_reports:create', + update: 'edr_freight_app:warehouse_inspection_reports:update', + }, + warehouseInventory: { + view: 'edr_freight_app:warehouse_inventory:view', + receive: 'edr_freight_app:warehouse_inventory:receive', + move: 'edr_freight_app:warehouse_inventory:move', + load: 'edr_freight_app:warehouse_inventory:load', + unload: 'edr_freight_app:warehouse_inventory:unload', + dispatch: 'edr_freight_app:warehouse_inventory:dispatch', + gatePass: 'edr_freight_app:warehouse_inventory:gate_pass', + release: 'edr_freight_app:warehouse_inventory:release', + deliver: 'edr_freight_app:warehouse_inventory:deliver', + inspect: 'edr_freight_app:warehouse_inventory:inspect', + }, + interchangeDocuments: { + view: 'edr_freight_app:interchange_documents:view', + generate: 'edr_freight_app:interchange_documents:generate', + acknowledge: 'edr_freight_app:interchange_documents:acknowledge', + dispute: 'edr_freight_app:interchange_documents:dispute', + cancel: 'edr_freight_app:interchange_documents:cancel', + }, + warehouseFeeInvoices: { + view: 'edr_freight_app:warehouse_fee_invoices:view', + generate: 'edr_freight_app:warehouse_fee_invoices:generate', + cancel: 'edr_freight_app:warehouse_fee_invoices:cancel', + pay: 'edr_freight_app:warehouse_fee_invoices:pay', + }, + config: { + contractValidity: { + view: 'edr_freight_app:config:contract_validity:view', + manage: 'edr_freight_app:config:contract_validity:manage', + }, + }, + settings: { + fileUpload: { + view: 'edr_freight_app:settings:file_upload:view', + manage: 'edr_freight_app:settings:file_upload:manage', + }, + dropdown: { + view: 'edr_freight_app:settings:dropdown:view', + manage: 'edr_freight_app:settings:dropdown:manage', + }, + }, + staff: { + roles: { + view: 'edr_freight_app:staff:roles:view', + create: 'edr_freight_app:staff:roles:create', + update: 'edr_freight_app:staff:roles:update', + delete: 'edr_freight_app:staff:roles:delete', + }, + permissions: { + view: 'edr_freight_app:staff:permissions:view', + assign: 'edr_freight_app:staff:permissions:assign', + }, + // Seeded in edr-freight.seed.ts (EDR_FREIGHT_PERMISSIONS) — surfaced here for gating. + employeeRegistration: { + view: 'edr_freight_app:employee_registration:view', + create: 'edr_freight_app:employee_registration:create', + update: 'edr_freight_app:employee_registration:update', + activate: 'edr_freight_app:employee_registration:activate', + deactivate: 'edr_freight_app:employee_registration:deactivate', + }, + roleAssignment: { + view: 'edr_freight_app:role_assignment:view', + assign: 'edr_freight_app:role_assignment:assign', + replace: 'edr_freight_app:role_assignment:replace', + }, + hierarchyUnits: { + view: 'edr_freight_app:hierarchy_units:view', + create: 'edr_freight_app:hierarchy_units:create', + update: 'edr_freight_app:hierarchy_units:update', + delete: 'edr_freight_app:hierarchy_units:delete', + }, + hierarchyPositions: { + view: 'edr_freight_app:hierarchy_positions:view', + create: 'edr_freight_app:hierarchy_positions:create', + update: 'edr_freight_app:hierarchy_positions:update', + delete: 'edr_freight_app:hierarchy_positions:delete', + changeParent: 'edr_freight_app:hierarchy_positions:change_parent', + }, + hierarchyEmployeeAssignment: { + view: 'edr_freight_app:hierarchy_employee_assignment:view', + invite: 'edr_freight_app:hierarchy_employee_assignment:invite', + assign: 'edr_freight_app:hierarchy_employee_assignment:assign', + }, + positionTypes: { + view: 'edr_freight_app:position_types:view', + create: 'edr_freight_app:position_types:create', + update: 'edr_freight_app:position_types:update', + delete: 'edr_freight_app:position_types:delete', + }, + }, } as const; const allRuleEngineViewKeys = () => @@ -237,6 +698,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.clearanceReview, FREIGHT_PERMS.contracts.finalizeClearance, FREIGHT_PERMS.contracts.createBooking, + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDutyAdvise, FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.uploadClearanceOutput, @@ -247,6 +710,7 @@ export const ROLE_PERMISSION_PRESETS = { // damage reports. Read-only on the contract; no booking creation. glDjibouti: [ FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.contracts.clearanceDjActions, FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.uploadClearanceOutput, FREIGHT_PERMS.bookings.operations, @@ -277,12 +741,39 @@ export const ROLE_PERMISSION_PRESETS = { orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; +/** + * Position permission presets (positions-as-roles). Grants flow to users via + * Position → PositionPermission (NOT Role/RolePermission). Each reuses the + * matching ROLE_PERMISSION_PRESETS key-array as a building block and adds the + * gap-controller keys the position needs. Deduped via Set. + */ +const dedupe = (keys: string[]): string[] => [...new Set(keys)]; + +export const POSITION_PERMISSION_PRESETS = { + // Chief: senior operational role — intake/line-staff approval + director + // approval + scheduling/ops, plus container allocation. + chief: dedupe([ + ...ROLE_PERMISSION_PRESETS.lineStaff, + ...ROLE_PERMISSION_PRESETS.director, + ...ROLE_PERMISSION_PRESETS.operationsOfficer, + FREIGHT_PERMS.allocation.manage, + ]), + director: dedupe([...ROLE_PERMISSION_PRESETS.director]), + ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]), + ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]), + djiboutiGl: dedupe([...ROLE_PERMISSION_PRESETS.glDjibouti]), + marketer: dedupe([...ROLE_PERMISSION_PRESETS.marketing]), + operation: dedupe([ + ...ROLE_PERMISSION_PRESETS.operationsOfficer, + FREIGHT_PERMS.allocation.manage, + ]), +} as const; + +/** Derive the module bucket from the resource segment of a permission key. */ +const moduleOf = (key: string): string => key.split(':')[1] ?? 'other'; + export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ key: p.key, label: p.name.en, - module: p.key.includes(':bookings:') - ? 'bookings' - : p.key.includes(':contracts:') - ? 'contracts' - : 'rule_engine', + module: moduleOf(p.key), })); diff --git a/apps/edr-freight-api/src/seed/freight-positions.seeder.ts b/apps/edr-freight-api/src/seed/freight-positions.seeder.ts new file mode 100644 index 000000000..09901b502 --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-positions.seeder.ts @@ -0,0 +1,171 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + Organization, + Permission, + Position, + PositionPermission, + Unit, +} from '@tria-plc/iamapi-common'; +import { DataSource, EntityManager, In } from 'typeorm'; + +import { EDR_FREIGHT_POSITIONS } from './edr-freight.seed'; + +const SEED_FLAG = 'SEED_EDR_ORG'; +const EDR_ORG_KEY = 'edr_freight'; +const EDR_UNIT_KEY = 'edr_freight_app'; + +/** + * Seeds the operational freight positions (CEO, Chief, Director, Marketer, + * Operation, Ethiopian GL, Djibouti GL) as Position + PositionPermission rows + * on the `edr_freight_app` unit. Positions-as-roles: users get their freight + * access by being assigned to a Position (via EmployeePosition), and the + * position's PositionPermission grants come from EDR_FREIGHT_POSITIONS. + * + * Gated behind the same SEED_EDR_ORG flag as EdrOrgSeeder and depends on the + * org/unit/permission catalog it seeds, so it must run AFTER EdrOrgSeeder. + * Idempotent: positions upsert by (key, unitId); grants insert only the + * permission ids a position is still missing. + */ +@Injectable() +export class FreightPositionsSeeder { + private readonly logger = new Logger(FreightPositionsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { + this.logger.log( + `Skipping freight positions seed because ${SEED_FLAG} is not enabled`, + ); + return; + } + + await this.dataSource.transaction(async (manager) => { + const organization = await manager.getRepository(Organization).findOne({ + where: { key: EDR_ORG_KEY }, + select: { id: true }, + }); + + if (!organization) { + throw new Error(`missing_organization:${EDR_ORG_KEY}`); + } + + const unit = await manager.getRepository(Unit).findOne({ + where: { key: EDR_UNIT_KEY, organizationId: organization.id }, + select: { id: true }, + }); + + if (!unit) { + throw new Error(`missing_unit:${EDR_UNIT_KEY}`); + } + + const permissionKeyToId = await this.loadPermissionIds(manager); + + for (const seed of EDR_FREIGHT_POSITIONS) { + const positionId = await this.ensurePosition( + manager, + seed, + unit.id as string, + organization.id as string, + ); + + await this.ensurePositionPermissions( + manager, + positionId, + seed, + permissionKeyToId, + ); + } + }); + + this.logger.log( + `Ensured ${EDR_FREIGHT_POSITIONS.length} freight positions on unit '${EDR_UNIT_KEY}'`, + ); + } + + /** Resolve every permission key referenced by any position to its id. */ + private async loadPermissionIds( + manager: EntityManager, + ): Promise> { + const keys = [ + ...new Set(EDR_FREIGHT_POSITIONS.flatMap((p) => p.permissionKeys)), + ]; + + const permissions = await manager.getRepository(Permission).find({ + where: { key: In(keys) }, + select: { id: true, key: true }, + }); + + const map = new Map(permissions.map((p) => [p.key, p.id as string])); + + const missing = keys.filter((key) => !map.has(key)); + if (missing.length > 0) { + throw new Error(`missing_permissions:${missing.join(',')}`); + } + + return map; + } + + private async ensurePosition( + manager: EntityManager, + seed: (typeof EDR_FREIGHT_POSITIONS)[number], + unitId: string, + organizationId: string, + ): Promise { + const positionRepository = manager.getRepository(Position); + + const existing = await positionRepository.findOne({ + where: { key: seed.key, unitId }, + select: { id: true }, + }); + + if (existing) { + return existing.id as string; + } + + const inserted = await positionRepository.insert({ + key: seed.key, + name: { ...seed.name }, + rank: seed.rank, + unitId, + organizationId, + }); + + this.logger.log(`Seeded freight position '${seed.key}'`); + + return inserted.identifiers[0]?.id as string; + } + + private async ensurePositionPermissions( + manager: EntityManager, + positionId: string, + seed: (typeof EDR_FREIGHT_POSITIONS)[number], + permissionKeyToId: Map, + ) { + const positionPermissionRepository = + manager.getRepository(PositionPermission); + + const existing = await positionPermissionRepository.find({ + where: { positionId }, + select: { permissionId: true }, + }); + const existingPermissionIds = new Set( + existing.map((row) => row.permissionId), + ); + + const rowsToInsert = seed.permissionKeys + .map((key) => permissionKeyToId.get(key) as string) + .filter((permissionId) => !existingPermissionIds.has(permissionId)) + .map((permissionId) => ({ positionId, permissionId })); + + if (rowsToInsert.length === 0) { + return; + } + + await positionPermissionRepository.insert(rowsToInsert); + + this.logger.log( + `Granted ${rowsToInsert.length} permissions to position '${seed.key}'`, + ); + } +} diff --git a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts index b6d83c138..6de3bc4de 100644 --- a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts +++ b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts @@ -3,8 +3,11 @@ import { hashPassword } from '@tria-plc/api-common/utils/argon'; import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; import { Employee, + EmployeePosition, Organization, + Position, Role, + Unit, User, UserCredential, UserRole, @@ -13,13 +16,19 @@ import { DataSource } from 'typeorm'; const SEED_FLAG = 'SEED_FREIGHT_STAFF'; const EDR_ORG_KEY = 'edr_freight'; +const EDR_UNIT_KEY = 'edr_freight_app'; +// roleKey is kept only for backwards compatibility with existing UserRole rows; +// access is granted via the assigned position (positionKey) + PositionPermission. const STAFF_USERS = [ - { email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' }, - { email: 'director@edr.local', username: 'director', roleKey: 'edr_director' }, - { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' }, - { email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia' }, - { email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti' }, + { email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff', positionKey: 'operation' }, + { email: 'chief@edr.local', username: 'chief', roleKey: 'edr_org_manager', positionKey: 'chief' }, + { email: 'director@edr.local', username: 'director', roleKey: 'edr_director', positionKey: 'director' }, + { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo', positionKey: 'ceo' }, + { email: 'marketer@edr.local', username: 'marketer', roleKey: 'edr_marketing', positionKey: 'marketer' }, + { email: 'operation@edr.local', username: 'operation', roleKey: 'edr_operations_officer', positionKey: 'operation' }, + { email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia', positionKey: 'ethiopian_gl' }, + { email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti', positionKey: 'djibouti_gl' }, ] as const; @Injectable() @@ -47,11 +56,22 @@ export class FreightStaffUsersSeeder { throw new Error(`missing_organization:${EDR_ORG_KEY}`); } + const unit = await manager.getRepository(Unit).findOne({ + where: { key: EDR_UNIT_KEY, organizationId: organization.id }, + select: { id: true }, + }); + + if (!unit) { + throw new Error(`missing_unit:${EDR_UNIT_KEY}`); + } + const roleRepository = manager.getRepository(Role); const userRepository = manager.getRepository(User); const userCredentialRepository = manager.getRepository(UserCredential); const userRoleRepository = manager.getRepository(UserRole); const employeeRepository = manager.getRepository(Employee); + const positionRepository = manager.getRepository(Position); + const employeePositionRepository = manager.getRepository(EmployeePosition); const hashedPassword = await hashPassword(password); @@ -105,20 +125,50 @@ export class FreightStaffUsersSeeder { { conflictPaths: { userId: true, roleId: true } }, ); - const employeeExists = await employeeRepository.exists({ + let employee = await employeeRepository.findOne({ where: { userId: user.id, organizationId: organization.id, isCurrent: true, }, + select: { id: true }, }); - if (!employeeExists) { - await employeeRepository.insert({ - userId: user.id, - organizationId: organization.id, + if (!employee) { + employee = await employeeRepository.save( + employeeRepository.create({ + userId: user.id, + organizationId: organization.id, + unitId: unit.id, + isCurrent: true, + name: { en: staff.username }, + }), + ); + } + + // Grant access via the assigned position (positions-as-roles). + const position = await positionRepository.findOne({ + where: { key: staff.positionKey, unitId: unit.id }, + select: { id: true, key: true }, + }); + + if (!position) { + throw new Error(`missing_position:${staff.positionKey}`); + } + + const employeePositionExists = await employeePositionRepository.exists({ + where: { + employeeId: employee.id as string, + positionId: position.id as string, + }, + }); + + if (!employeePositionExists) { + await employeePositionRepository.insert({ + employeeId: employee.id as string, + positionId: position.id as string, + unitId: unit.id, isCurrent: true, - name: { en: staff.username }, }); } } diff --git a/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts index 2d3c47c26..53d6c9eec 100644 --- a/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts +++ b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts @@ -3,6 +3,7 @@ import { WagonStatus } from '@edr/types'; import { DataSource } from 'typeorm'; import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Company } from '../modules/companies/entities/company.entity'; import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; @@ -116,6 +117,11 @@ export class MarshallingDemoTrainsSeeder { ? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } }) : null; + // bookings.company_id is NOT NULL — reuse any seeded company for the demo. + const company = await this.dataSource + .getRepository(Company) + .findOne({ where: {}, order: { createdAt: 'ASC' } }); + const missing = [ !djiboutiYard ? 'Djibouti yard' : '', !ethiopiaYard ? 'Ethiopia yard' : '', @@ -124,6 +130,7 @@ export class MarshallingDemoTrainsSeeder { !warehouse ? 'INDODE_OPEN warehouse' : '', !warehouseYard ? 'warehouse yard' : '', !warehouseZone ? 'warehouse zone' : '', + !company ? 'company' : '', ].filter(Boolean); if (missing.length) { this.logger.warn(`Cannot seed marshalling demo trains, missing: ${missing.join(', ')}`); @@ -141,6 +148,7 @@ export class MarshallingDemoTrainsSeeder { warehouse: warehouse!, warehouseYard: warehouseYard!, warehouseZone: warehouseZone!, + company: company!, }); if (created) seeded += 1; } @@ -164,6 +172,7 @@ export class MarshallingDemoTrainsSeeder { warehouse: Warehouse; warehouseYard: WarehouseYard; warehouseZone: WarehouseZone; + company: Company; }, ): Promise { const bookingRepo = this.dataSource.getRepository(Booking); @@ -231,6 +240,7 @@ export class MarshallingDemoTrainsSeeder { const booking = await bookingRepo.save( bookingRepo.create({ reference: bookingReference, + companyId: refs.company.id, originYardId: originYard.id, destinationYardId: destinationYard.id, serviceTypeId: refs.serviceType.id, diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts new file mode 100644 index 000000000..e1c46d168 --- /dev/null +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -0,0 +1,298 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { DataSource } from 'typeorm'; + +import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; +import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; +import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; + +const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE'; +const COMPANY_TIN = 'PAIDMILE001'; +const COMPANY_EMAIL = 'paid-mile-demo@edr.local'; + +const YARDS = [ + { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 }, + { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 }, +]; + +const CONTAINER_TYPES = [ + { code: '20FT', label: '20FT', sizeFt: 20 }, + { code: '40FT', label: '40FT', sizeFt: 40 }, +]; + +/** + * Six paid, approved container bookings that mirror the real trucking legs: + * - EXPORT (Ethiopia -> Djibouti) carries a FIRST-MILE leg (factory -> rail terminal). + * - IMPORT (Djibouti -> Ethiopia) carries a LAST-MILE leg (dry port -> final delivery). + * Each booking is paymentStatus PAID and its single mile leg is marked paid + ready to transit. + */ +const DEMO_BOOKINGS = [ + // ── IMPORT: last mile only ───────────────────────────────────────────── + { + reference: 'PAID-IMP-001', + tradeDirection: 'IMPORT', + containerCode: '40FT', + quantity: 8, + totalWeightTons: 224, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-01T08:00:00.000Z', + lastMileDeliveryAddress: 'Akaki Industrial Zone, Addis Ababa', + lastMileDeliveryLat: 8.8808, + lastMileDeliveryLng: 38.7876, + }, + { + reference: 'PAID-IMP-002', + tradeDirection: 'IMPORT', + containerCode: '20FT', + quantity: 12, + totalWeightTons: 240, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-02T08:00:00.000Z', + lastMileDeliveryAddress: 'Kality Logistics Hub, Addis Ababa', + lastMileDeliveryLat: 8.9137, + lastMileDeliveryLng: 38.7815, + }, + { + reference: 'PAID-IMP-003', + tradeDirection: 'IMPORT', + containerCode: '40FT', + quantity: 6, + totalWeightTons: 180, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-03T08:00:00.000Z', + lastMileDeliveryAddress: 'Bole Lemi Industrial Park, Addis Ababa', + lastMileDeliveryLat: 8.9806, + lastMileDeliveryLng: 38.8736, + }, + // ── EXPORT: first mile only ──────────────────────────────────────────── + { + reference: 'PAID-EXP-001', + tradeDirection: 'EXPORT', + containerCode: '40FT', + quantity: 7, + totalWeightTons: 196, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-01T10:00:00.000Z', + firstMilePickupAddress: 'Bole Lemi Industrial Park, Addis Ababa', + firstMilePickupLat: 8.9806, + firstMilePickupLng: 38.8736, + }, + { + reference: 'PAID-EXP-002', + tradeDirection: 'EXPORT', + containerCode: '20FT', + quantity: 11, + totalWeightTons: 220, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-02T10:00:00.000Z', + firstMilePickupAddress: 'Akaki Industrial Zone, Addis Ababa', + firstMilePickupLat: 8.8808, + firstMilePickupLng: 38.7876, + }, + { + reference: 'PAID-EXP-003', + tradeDirection: 'EXPORT', + containerCode: '40FT', + quantity: 4, + totalWeightTons: 128, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-03T10:00:00.000Z', + firstMilePickupAddress: 'Kality Logistics Hub, Addis Ababa', + firstMilePickupLat: 8.9137, + firstMilePickupLng: 38.7815, + }, +] as const; + +@Injectable() +export class PaidImportExportMileDemoSeeder { + private readonly logger = new Logger(PaidImportExportMileDemoSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(Yard).upsert( + YARDS.map((yard) => ({ ...yard, isActive: true })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ServiceType).upsert( + { + code: SERVICE_TYPE_CODE, + serviceName: 'Rail Container with Paid First/Last Mile', + description: 'Demo service type for paid import/export bookings with a single mile leg', + canBeBookedAlone: true, + includesFirstMile: true, + includesLastMile: true, + includesCustoms: false, + isActive: true, + displayOrder: 11, + }, + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ContainerType).upsert( + CONTAINER_TYPES.map((containerType, index) => ({ + ...containerType, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: index + 1, + })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Company).upsert( + { + name: 'Paid Import/Export Mile Demo Customer', + type: CompanyType.Customer, + status: CompanyStatus.Active, + tin: COMPANY_TIN, + vatNumber: COMPANY_TIN, + fanNumber: 'PMD0000000000001', + country: 'Ethiopia', + address: 'Addis Ababa', + phone: '251900000202', + email: COMPANY_EMAIL, + website: null, + contactPersonName: 'Paid Mile Demo', + contactPersonPhone: '251900000202', + generalManagerName: 'Demo Manager', + generalManagerEmail: COMPANY_EMAIL, + generalManagerPhone: '251900000202', + }, + { conflictPaths: { tin: true } }, + ); + + const [serviceType, company, yards, containerTypes] = await Promise.all([ + manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }), + manager.getRepository(Company).findOneByOrFail({ tin: COMPANY_TIN }), + manager.getRepository(Yard).find(), + manager.getRepository(ContainerType).find(), + ]); + + const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); + const containerTypeByCode = new Map( + containerTypes.map((containerType) => [containerType.code, containerType]), + ); + + for (const demoBooking of DEMO_BOOKINGS) { + const origin = yardByCode.get(demoBooking.originCode); + const destination = yardByCode.get(demoBooking.destinationCode); + const containerType = containerTypeByCode.get(demoBooking.containerCode); + + if (!origin || !destination || !containerType) { + throw new Error(`paid_import_export_mile_demo_dependency_missing:${demoBooking.reference}`); + } + + const isImport = demoBooking.tradeDirection === 'IMPORT'; + const wagonsRequired = + Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; + + await manager.getRepository(Booking).upsert( + { + reference: demoBooking.reference, + companyId: company.id, + status: 'APPROVED', + scheduledDate: new Date(demoBooking.scheduledDate), + estimatedShipmentDate: new Date(demoBooking.scheduledDate), + totalAmount: demoBooking.totalWeightTons * 25, + paymentStatus: 'PAID', + contractType: 'NEW', + serviceTypeId: serviceType.id, + // Only the leg that matches the trade direction carries an address. + firstMilePickupAddress: isImport ? null : demoBooking.firstMilePickupAddress, + firstMilePickupLat: isImport ? null : demoBooking.firstMilePickupLat, + firstMilePickupLng: isImport ? null : demoBooking.firstMilePickupLng, + lastMileDeliveryAddress: isImport ? demoBooking.lastMileDeliveryAddress : null, + lastMileDeliveryLat: isImport ? demoBooking.lastMileDeliveryLat : null, + lastMileDeliveryLng: isImport ? demoBooking.lastMileDeliveryLng : null, + equipmentReturn: 'WITHOUT_RETURN', + originYardId: origin.id, + destinationYardId: destination.id, + tradeDirection: demoBooking.tradeDirection, + freightType: 'CONTAINER', + cargoTypeId: null, + cargoFreeText: 'Demo container cargo', + shippingLineId: null, + cargoTotalWeightVgm: demoBooking.totalWeightTons, + isHazardous: false, + isReefer: false, + paymentCurrency: 'ETB', + approvedByStaffAt: new Date(), + priorityScore: 20, + wagonsRequired, + schedulingStatus: 'NOT_SCHEDULED', + versionNumber: 1, + }, + { conflictPaths: { reference: true } }, + ); + + const booking = await manager.getRepository(Booking).findOneByOrFail({ + reference: demoBooking.reference, + }); + + await manager.getRepository(BookingContainer).delete({ bookingId: booking.id }); + await manager.getRepository(BookingContainer).insert({ + id: randomUUID(), + bookingId: booking.id, + containerTypeId: containerType.id, + quantity: demoBooking.quantity, + vgmPerUnitTons, + totalVgmTons: demoBooking.totalWeightTons, + wagonsRequired, + weightLimitRuleId: null, + isOverweight: vgmPerUnitTons > 35, + overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null, + }); + + // Reset any existing legs for idempotency, then create the single paid leg. + await manager.getRepository(FirstMile).delete({ bookingId: booking.id }); + await manager.getRepository(LastMile).delete({ bookingId: booking.id }); + + const paidAmount = demoBooking.totalWeightTons * 25; + + if (isImport) { + await manager.getRepository(LastMile).insert({ + bookingId: booking.id, + status: 'READY_TO_TRANSIT', + advancedPayment: paidAmount, + remainingPayment: 0, + paid: true, + estimatedKm: 22, + exactKm: null, + vehicleId: null, + }); + } else { + await manager.getRepository(FirstMile).insert({ + bookingId: booking.id, + status: 'READY_TO_TRANSIT', + advancedPayment: paidAmount, + remainingPayment: 0, + paid: true, + estimatedKm: 18, + exactKm: null, + vehicleId: null, + }); + } + } + }); + + this.logger.log( + 'Seeded 6 paid bookings: 3 import (last-mile) + 3 export (first-mile).', + ); + } +} diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index ae48ada66..aa3f2c9bb 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -286,15 +286,15 @@ export class PricingDataSeeder { const routeRepo = manager.getRepository(Route); const milestoneRepo = manager.getRepository(RouteMilestone); - const routeName = "Addis Ababa → Dire Dawa"; - let route = await routeRepo.findOneBy({ name: routeName }); + let route = await routeRepo.findOne({ + where: { originYardId: addis.id, destinationYardId: direDawa.id }, + }); if (!route) { route = await routeRepo.save( routeRepo.create({ - name: routeName, originYardId: addis.id, destinationYardId: direDawa.id, - isActive: true, + status: 'AVAILABLE', }), ); await milestoneRepo.save([ @@ -302,11 +302,13 @@ export class PricingDataSeeder { routeId: route.id, yardId: addis.id, sequenceNo: 1, + distanceKm: 0, }), milestoneRepo.create({ routeId: route.id, yardId: direDawa.id, sequenceNo: 2, + distanceKm: 445, }), ]); this.logger.log("Seeded domestic route Addis Ababa → Dire Dawa"); @@ -317,37 +319,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { const twenty = await ctRepo.findOneByOrFail({ code: "20FT" }); const forty = await ctRepo.findOneByOrFail({ code: "40FT" }); - const base = new Date("2026-01-01"); - const rules = [ - { - containerTypeId: twenty.id, - tradeDirection: "IMPORT", - maxVgmTons: 26, - effectiveFrom: base, - isActive: true, - }, - { - containerTypeId: twenty.id, - tradeDirection: "EXPORT", - maxVgmTons: 26, - effectiveFrom: base, - isActive: true, - }, - { - containerTypeId: forty.id, - tradeDirection: "IMPORT", - maxVgmTons: 28, - effectiveFrom: base, - isActive: true, - }, - { - containerTypeId: forty.id, - tradeDirection: "EXPORT", - maxVgmTons: 28, - effectiveFrom: base, - isActive: true, - }, + { containerTypeId: twenty.id, tradeDirection: "IMPORT", maxVgmTons: 26 }, + { containerTypeId: twenty.id, tradeDirection: "EXPORT", maxVgmTons: 26 }, + { containerTypeId: forty.id, tradeDirection: "IMPORT", maxVgmTons: 28 }, + { containerTypeId: forty.id, tradeDirection: "EXPORT", maxVgmTons: 28 }, ]; for (const rule of rules) { @@ -359,10 +335,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { }); if (existing) { - await wlRepo.update(existing.id, { - maxVgmTons: rule.maxVgmTons, - effectiveFrom: rule.effectiveFrom, - }); + await wlRepo.update(existing.id, { maxVgmTons: rule.maxVgmTons }); } else { await wlRepo.insert(rule); } @@ -407,7 +380,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { ctByCode: Map, cargoByCode: Map, ): Promise { - const effectiveFrom = new Date("2026-01-01"); const now = new Date(); // Each rate is self-describing: `appliesTo` + `trigger` decide how the @@ -443,6 +415,9 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 2, rateUnit: "PER_TON" }, { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, + // ── First/last-mile road haulage (per km) — drives the mile invoices ── + { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" }, + { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" }, ]; // Idempotent: insert each canonical rate only if no row with the same @@ -477,7 +452,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { proposedByStaffId: STAFF_USER_ID, approvedByCeoId: CEO_USER_ID, approvedAt: now, - effectiveFrom, })) .filter((d) => !existingBySignature.has(signature(d))); diff --git a/apps/edr-freight-api/src/types/multer-globals.d.ts b/apps/edr-freight-api/src/types/multer-globals.d.ts new file mode 100644 index 000000000..0bc672a9e --- /dev/null +++ b/apps/edr-freight-api/src/types/multer-globals.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/edr-freight-api/tsconfig.json b/apps/edr-freight-api/tsconfig.json index 467c474ee..52598cb95 100644 --- a/apps/edr-freight-api/tsconfig.json +++ b/apps/edr-freight-api/tsconfig.json @@ -7,6 +7,7 @@ "noEmit": false, "incremental": true, "tsBuildInfoFile": "./.tsbuildinfo", + "preserveWatchOutput": true, "module": "node16", "moduleResolution": "node16" }, diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 93caae0a4..b89807b72 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 5183", + "dev": "vite --port 5183 --clearScreen false", "prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"", "build": "vite build", "preview": "vite preview --port 5183", @@ -17,10 +17,12 @@ "@edr/ui-common": "workspace:*", "@hello-pangea/dnd": "^18.0.1", "@mantine/core": "^9.3.0", + "@mantine/dates": "^9.3.0", "@mantine/hooks": "^9.3.0", "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.100.11", "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz", + "@vis.gl/react-google-maps": "^1.8.3", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -32,6 +34,7 @@ "react-hot-toast": "^2.6.0", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", + "socket.io-client": "^4.8.3", "sonner": "^2.0.7", "stream-browserify": "^3.0.0", "tailwind-merge": "^3.6.0", @@ -42,6 +45,7 @@ "@edr/eslint-config": "workspace:*", "@edr/tsconfig": "workspace:*", "@tailwindcss/vite": "^4.3.0", + "@types/google.maps": "^3.65.2", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.2", diff --git a/apps/edr-freight-web/backoffice/public/assets/edr_image.jpg b/apps/edr-freight-web/backoffice/public/assets/edr_image.jpg new file mode 100644 index 000000000..b89941eea Binary files /dev/null and b/apps/edr-freight-web/backoffice/public/assets/edr_image.jpg differ diff --git a/apps/edr-freight-web/backoffice/public/assets/edr_image.png b/apps/edr-freight-web/backoffice/public/assets/edr_image.png new file mode 100644 index 000000000..1654c747c Binary files /dev/null and b/apps/edr-freight-web/backoffice/public/assets/edr_image.png differ diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 542a848a4..df8db49d4 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -6,23 +6,39 @@ import { FileText, LayoutDashboard, LayoutGrid, + MapPin, Network, Package, PackageCheck, PackageOpen, Paperclip, + Receipt, Send, Settings, ShieldCheck, + Ship, SlidersHorizontal, Train, Truck, Users, Wallet, } from "lucide-react"; -import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; +import { useEffect } from "react"; +import { + Navigate, + Outlet, + Route, + Routes, + useLocation, + useNavigate, + useParams, +} from "react-router-dom"; -import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; +import { + FreightDashboardLayout, + type SidebarItem, + type SidebarSection, +} from "@/components/layout"; import { useAuth } from "./auth/useAuth"; import LoadingScreen from "./components/LoadingScreen"; import LoginPage from "./pages/auth/LoginPage"; @@ -35,10 +51,17 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa import ContractViewPage from "./pages/contracts/ContractViewPage"; import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage"; import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage"; +import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage"; +import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage"; +// Hidden for now — Shipment Requests pages disabled (imports kept commented). +// import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; +// import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage"; import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; -import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage"; +import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; +import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; +import InvoicesPage from "./pages/invoices/InvoicesPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; @@ -47,7 +70,13 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage import PaymentsPage from "./pages/payments/PaymentsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import { RequirePermission } from "./components/auth/RequirePermission"; -import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; +import { + FREIGHT_PERMS, + hasPermission as hasFreightPermission, + isDjiboutiGl, + isEthiopianGl, + isSuperAdmin, +} from "./lib/permissions"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; @@ -56,7 +85,19 @@ import UsersPage from "./pages/dashboard/user-management/UsersPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import VehicleDetailPage from "./pages/fleet/VehicleDetailPage"; +import DriverDetailPage from "./pages/fleet/DriverDetailPage"; import RoutesPage from "./pages/fleet/RoutesPage"; +import FuelPurchasePage from "./pages/fleet/FuelPurchasePage"; +import FuelStatsPage from "./pages/fleet/FuelStatsPage"; +import { MaintenancePage } from "./pages/fleet/MaintenancePage"; +import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage"; +import { FleetDashboard } from "./pages/fleet/FleetDashboard"; +import { TrackingPage } from "./pages/fleet/TrackingPage"; +import CompliancePage from "./pages/fleet/CompliancePage"; +import IncidentsPage from "./pages/fleet/IncidentsPage"; +import WorkOrdersPage from "./pages/fleet/WorkOrdersPage"; +import ProcurementPage from "./pages/fleet/ProcurementPage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; @@ -67,6 +108,7 @@ import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetail import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; @@ -86,6 +128,7 @@ import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; import { HealthCheck } from "./features/health/HealthCheck"; +import FaydaCallbackPage from "./pages/FaydaCallbackPage"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -97,17 +140,17 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , }, { - label: "User Management", + label: "Staff", href: "/um", icon: , }, { - label: "Booking requests", + label: "Bookings", href: "/dashboard/booking-requests", icon: , }, { - label: "Contract requests", + label: "Contracts", href: "/dashboard/contract-requests", icon: , permission: FREIGHT_PERMS.contracts.view, @@ -123,6 +166,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.bookings.view, }, + { + label: "Invoices", + href: "/dashboard/invoices", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, ...demoItems, ], }, @@ -130,10 +179,26 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ title: "Operations", items: [ { - label: "Document Clearance", + label: "Clearance", href: "/dashboard/contracts/clearance", icon: , - permission: FREIGHT_PERMS.contracts.clearanceReview, + permission: [ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ], + }, + // Hidden for now — Shipment Requests nav item disabled. + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, + { + label: "GL Djibouti Clearance", + href: "/dashboard/gl-djibouti/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceDjActions, }, { label: "Train Schedules", @@ -151,19 +216,25 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "First Mile", href: "/dashboard/operations/first-mile", icon: , - permission: FREIGHT_PERMS.trainScheduling.view, + permission: FREIGHT_PERMS.firstMile.view, }, { label: "Last Mile", href: "/dashboard/operations/last-mile", icon: , - permission: FREIGHT_PERMS.trainScheduling.view, + permission: FREIGHT_PERMS.lastMile.view, }, ], }, { title: "Fleet Management", items: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleetDashboard.view, + }, { label: "Routes", href: "/dashboard/routes", @@ -192,14 +263,68 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Vehicles", href: "/dashboard/vehicles", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: FREIGHT_PERMS.vehicles.view, }, { label: "Drivers", href: "/dashboard/drivers", icon: , + permission: FREIGHT_PERMS.drivers.view, + }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.tracking.view, + }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fuel.view, + }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Work Orders", + href: "/dashboard/work-orders", + icon: , + permission: FREIGHT_PERMS.maintenance.view, + }, + { + label: "Compliance & Alerts", + href: "/dashboard/compliance", + icon: , permission: FREIGHT_PERMS.fleet.view, }, + { + label: "Incidents", + href: "/dashboard/incidents", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Procurement", + href: "/dashboard/procurement", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleetReports.view, + }, // { // label: "Containers", // href: "/dashboard/containers", @@ -216,7 +341,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ title: "Port & Terminal", items: [ { - label: "Import Operations", + label: "Imports", href: "/dashboard/import-warehouse", icon: , children: [ @@ -237,7 +362,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=IMPORT", icon: , }, { @@ -248,7 +373,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ ], }, { - label: "Export Operations", + label: "Exports", href: "/dashboard/export-warehouse", icon: , children: [ @@ -284,7 +409,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=EXPORT", icon: , }, ], @@ -343,10 +468,14 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , children: [ ...getCategorySidebarChildren("configuration"), - // { - // label: "Train scheduling rules", - // href: "/dashboard/configuration/train-scheduling-rules", - // }, + { + label: "Contract validity", + href: "/dashboard/configuration/contract-validity-periods", + }, + { + label: "Train scheduling rules", + href: "/dashboard/configuration/train-scheduling-rules", + }, ], }, { @@ -359,12 +488,38 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ]; -/** Keep only items the user is permitted to see; drop now-empty sections. */ +/** Hrefs of the two document-clearance menu items (stable identifiers). */ +const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance"; +const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance"; + +const isEtClearanceItem = (item: SidebarItem): boolean => + item.href === ET_CLEARANCE_HREF; +const isDjClearanceItem = (item: SidebarItem): boolean => + item.href === DJ_CLEARANCE_HREF; +const isClearanceItem = (item: SidebarItem): boolean => + isEtClearanceItem(item) || isDjClearanceItem(item); + +/** + * Keep only items the user is permitted to see; drop now-empty sections. + * + * Position-scoped visibility (super_admin sees everything): + * - Super Admin → sees all items (all permissions pass, all tabs visible) + * - Ethiopian GL → sees ONLY the ET document-clearance page. + * - Djibouti GL → sees ONLY the DJ clearance page. + * - Everyone else → sees everything they have permission for, EXCEPT the two + * clearance pages (those are GL-only). + */ const filterSidebarByPermission = ( sections: SidebarSection[], user: ReturnType["user"], ): SidebarSection[] => { - const itemAllowed = (item: SidebarItem): boolean => { + // Superadmin sees every section and item — no permission filtering. + if (isSuperAdmin(user)) return sections; + + const etGl = isEthiopianGl(user); + const djGl = isDjiboutiGl(user); + + const permissionAllowed = (item: SidebarItem): boolean => { if (!item.permission) return true; const keys = Array.isArray(item.permission) ? item.permission @@ -372,6 +527,17 @@ const filterSidebarByPermission = ( return keys.some((key) => hasFreightPermission(user, key)); }; + const itemAllowed = (item: SidebarItem): boolean => { + // GL positions are locked to their single clearance page. + if (etGl) return isEtClearanceItem(item); + if (djGl) return isDjClearanceItem(item); + + // Everyone else: hide the GL-only clearance pages entirely. + if (isClearanceItem(item)) return false; + + return permissionAllowed(item); + }; + return sections .map((section) => ({ ...section, @@ -380,6 +546,38 @@ const filterSidebarByPermission = ( .filter((section) => section.items.length > 0); }; +const APP_TITLE = "EDR Freight Backoffice"; + +/** Flatten sidebar sections (incl. nested children) into {href, label} pairs. */ +const flattenSidebarItems = ( + sections: SidebarSection[], +): { href: string; label: string }[] => + sections.flatMap((section) => + section.items.flatMap((item) => [ + ...(item.href ? [{ href: item.href, label: item.label }] : []), + ...(item.children ?? []) + .filter((child): child is SidebarItem & { href: string } => + Boolean(child.href), + ) + .map((child) => ({ href: child.href, label: child.label })), + ]), + ); + +/** Find the sidebar label whose href matches (exactly or as a prefix of) the current path. */ +const findActiveSidebarLabel = ( + pathname: string, + sections: SidebarSection[], +): string | undefined => { + const path = pathname.toLowerCase(); + const candidates = flattenSidebarItems(sections) + .map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() })) + .sort((a, b) => b.href.length - a.href.length); + + return candidates.find( + ({ href }) => path === href || path.startsWith(`${href}/`), + )?.label; +}; + const DashboardShell = () => { const navigate = useNavigate(); const location = useLocation(); @@ -393,9 +591,36 @@ const DashboardShell = () => { ); const displayName = user?.name?.en || user?.username || user?.email || "User"; + // GL positions are locked to their single clearance page: if they navigate + // (or deep-link) anywhere else, send them back to their clearance hub. + // Super admin is exempt. Allow the clearance path + its detail sub-routes. + const superAdmin = isSuperAdmin(user); + const glClearanceHome = !superAdmin + ? isEthiopianGl(user) + ? ET_CLEARANCE_HREF + : isDjiboutiGl(user) + ? DJ_CLEARANCE_HREF + : null + : null; + + useEffect(() => { + const activeLabel = findActiveSidebarLabel( + location.pathname, + sidebarSections, + ); + document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE; + }, [location.pathname, sidebarSections]); + + if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) { + return ; + } + return ( { } /> } /> + } /> } /> ); @@ -429,8 +655,12 @@ const App = () => { } /> } /> + } /> } /> - } /> + } + /> }> } /> } /> @@ -446,8 +676,27 @@ const App = () => { /> } /> } /> + + + + } + /> + + + + } + /> } /> - } /> + } + /> } @@ -459,7 +708,13 @@ const App = () => { /> } + element={ + + + + } /> {/* Contracts (Path A/B) */} @@ -487,11 +742,48 @@ const App = () => { } /> + + + + } + /> + {/* Hidden for now — Shipment Requests pages disabled. + + + + } + /> + + + + } + /> + */} {/* GL (Path B) contract clearance review hub */} + } @@ -499,11 +791,44 @@ const App = () => { + } /> + } + /> + } + /> + + + + } + /> + + + + } + /> {/* Path A ops queue out of scope for now → fold into the GL hub. */} { + } /> - - - } + element={} /> } /> } /> - } /> + } + /> } /> } /> } /> } /> } /> } /> - } /> - } /> + } + /> + } + /> } /> } /> - } /> - } /> + } + /> + } + /> - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> } + element={ + + } /> { } /> + + + + } + /> + + + + } + /> { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> {/* Legacy embedded user management routes */} } /> } /> - } /> + } + /> {/* } /> */} - } /> + } + /> } /> { } + element={ + + } /> { } /> + + + + } + /> } /> - } /> - } /> + } + /> + } + /> { } + element={ + + } + /> + } /> - } /> } /> } /> @@ -839,4 +1304,19 @@ const App = () => { ); }; +/** Redirect removed milestones page to document clearance. */ +function BookingMilestonesRedirect() { + const { id } = useParams(); + return ; +} + +/** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */ +function LegacyGlEthiopiaClearanceRedirect() { + const { id } = useParams(); + if (id) { + return ; + } + return ; +} + export default App; diff --git a/apps/edr-freight-web/backoffice/src/auth/types.ts b/apps/edr-freight-web/backoffice/src/auth/types.ts index 41742039c..6279c8491 100644 --- a/apps/edr-freight-web/backoffice/src/auth/types.ts +++ b/apps/edr-freight-web/backoffice/src/auth/types.ts @@ -21,6 +21,8 @@ interface AuthEmployeePosition { isDelegate?: boolean; parentPositionId?: string | null; permissions?: AuthPermission[]; + /** Some IAM payloads nest the position record instead of flattening its key. */ + position?: { id?: string; key?: string; name?: LocaleText }; } interface AuthEmployeeRecord { diff --git a/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx index 950cfd476..67407486f 100644 --- a/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx @@ -33,7 +33,6 @@ export interface ContainerAllocationTableProps { * Displays containers with type/qty, vehicle dropdown per row, and save action. */ export function ContainerAllocationTable({ - bookingId, containers, onSave, }: ContainerAllocationTableProps) { @@ -42,8 +41,11 @@ export function ContainerAllocationTable({ ); const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "active"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), + queryKey: ["vehicles", "free"], + queryFn: async () => { + const res = await vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }); + return res.data ?? []; + }, }); const vehicleOptions = useMemo( @@ -85,13 +87,12 @@ export function ContainerAllocationTable({ }); const allocatedCount = Object.values(allocations).filter(Boolean).length; - const allAllocated = allocatedCount === containers.length; if (vehiclesLoading) { return ( - + - + ); } @@ -99,7 +100,7 @@ export function ContainerAllocationTable({ {vehicles.length === 0 && ( } color="yellow"> - No active vehicles available. Add vehicles before allocating containers. + No free vehicles available. Free up or add vehicles before allocating containers. )} diff --git a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx deleted file mode 100644 index 85bba1dc4..000000000 --- a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useState, useMemo } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { - Box, - Button, - Group, - Loader, - Select, - Stack, - Table, - Text, - Alert, -} from "@mantine/core"; -import { AlertCircle } from "lucide-react"; -import toast from "react-hot-toast"; - -import { vehiclesService } from "@/services/vehicles.service"; - -export interface ContainerAllocationRow { - id: string; - type: string; - qty: number; -} - -export interface FirstMileContainerAllocationTableProps { - firstMileId: string; - containers: ContainerAllocationRow[]; - onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; -} - -/** - * Manual container-to-vehicle allocation table for first-mile pickups. - * Displays containers with type/qty, vehicle dropdown per row, and save action. - */ -export function FirstMileContainerAllocationTable({ - firstMileId, - containers, - onSave, -}: FirstMileContainerAllocationTableProps) { - const [allocations, setAllocations] = useState>( - () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - - const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "active"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), - }); - - const vehicleOptions = useMemo( - () => - vehicles.map((v) => ({ - value: v.id, - label: `${v.plateNumber} (${v.vehicleType})`, - description: `${v.model} · ${v.manufacturer}`, - })), - [vehicles], - ); - - const saveAllocation = useMutation({ - mutationFn: async () => { - const mappings = containers - .filter((c) => allocations[c.id]) - .map((c) => ({ - containerId: c.id, - vehicleId: allocations[c.id]!, - })); - - if (mappings.length === 0) { - throw new Error("No containers allocated to vehicles"); - } - - await onSave(mappings); - }, - onSuccess: () => { - toast.success("Container allocations saved"); - setAllocations( - containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - }, - onError: (error) => { - toast.error( - error instanceof Error ? error.message : "Failed to save allocations", - ); - }, - }); - - const allocatedCount = Object.values(allocations).filter(Boolean).length; - const allAllocated = allocatedCount === containers.length; - - if (vehiclesLoading) { - return ( - - - - ); - } - - return ( - - {vehicles.length === 0 && ( - } color="yellow"> - No active vehicles available. Add vehicles before allocating containers. - - )} - - - - - - Container ID - Type - Qty - Assigned Vehicle - - - - {containers.map((container) => ( - - - - {container.id} - - - {container.type} - {container.qty} - -
-
- - - - {allocatedCount} of {containers.length} containers allocated - - - -
- ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx deleted file mode 100644 index d11d99a4a..000000000 --- a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useState, useMemo } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { - Box, - Button, - Group, - Loader, - Select, - Stack, - Table, - Text, - Alert, -} from "@mantine/core"; -import { AlertCircle } from "lucide-react"; -import toast from "react-hot-toast"; - -import { vehiclesService } from "@/services/vehicles.service"; - -export interface LastMileContainerRow { - id: string; - type: string; - qty: number; -} - -export interface LastMileContainerAllocationTableProps { - lastMileId: string; - containers: LastMileContainerRow[]; - onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise; -} - -/** - * Manual container-to-vehicle allocation table for last-mile deliveries. - * Displays containers with type/qty, vehicle dropdown per row, and save action. - */ -export function LastMileContainerAllocationTable({ - lastMileId, - containers, - onSave, -}: LastMileContainerAllocationTableProps) { - const [allocations, setAllocations] = useState>( - () => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - - const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ - queryKey: ["vehicles", "active"], - queryFn: () => vehiclesService.getAll({ status: "ACTIVE" }), - }); - - const vehicleOptions = useMemo( - () => - vehicles.map((v) => ({ - value: v.id, - label: `${v.plateNumber} (${v.vehicleType})`, - description: `${v.model} · ${v.manufacturer}`, - })), - [vehicles], - ); - - const saveAllocation = useMutation({ - mutationFn: async () => { - const mappings = containers - .filter((c) => allocations[c.id]) - .map((c) => ({ - containerId: c.id, - vehicleId: allocations[c.id]!, - })); - - if (mappings.length === 0) { - throw new Error("No containers allocated to vehicles"); - } - - await onSave(mappings); - }, - onSuccess: () => { - toast.success("Container allocations saved"); - setAllocations( - containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}), - ); - }, - onError: (error) => { - toast.error( - error instanceof Error ? error.message : "Failed to save allocations", - ); - }, - }); - - const allocatedCount = Object.values(allocations).filter(Boolean).length; - const allAllocated = allocatedCount === containers.length; - - if (vehiclesLoading) { - return ( - - - - ); - } - - return ( - - {vehicles.length === 0 && ( - } color="yellow"> - No active vehicles available. Add vehicles before allocating containers. - - )} - - - - - - Container ID - Type - Qty - Assigned Vehicle - - - - {containers.map((container) => ( - - - - {container.id} - - - {container.type} - {container.qty} - -
-
- - - - {allocatedCount} of {containers.length} containers allocated - - - -
- ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx b/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx new file mode 100644 index 000000000..c4fd7f39e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx @@ -0,0 +1,148 @@ +import type { ReactNode } from "react"; +import { Box, Image, Stack, Text, Title } from "@mantine/core"; +import { ChevronDown, Globe } from "lucide-react"; + +const EDR_IMAGE = "/assets/edr_image.png"; +const EDR_LOGO = "/assets/logo.svg"; + +/** Muted deep-green brand wash for the left panel. */ +const LEFT_PANEL_BG = + "linear-gradient(158deg, #2E6B55 0%, #21503F 46%, #16352A 100%)"; + +/** Radial opacity mask: image fully opaque at its center, fading to nothing at the edges. */ +const IMAGE_FADE_MASK = + "linear-gradient(-90deg, #000 95%, #0009 97%, #0000 100%), linear-gradient(0deg, #000 80%, #0001 100%)"; + +export interface AuthShellProps { + children: ReactNode; + /** Headline shown in the top-left of the green panel. */ + tagline?: string; + taglineBody?: string; +} + +const LeftPanel = ({ + tagline, + taglineBody, +}: Pick) => ( + + {/* Top-left: logo, title, description — stacked, left aligned. */} + + EDR Freight + + + + {tagline ?? "Ethiopian Djibouti Railway"} + + + {taglineBody ?? + "Manage bookings, track cargo, and run day-to-day logistics for the Ethio–Djibouti Railway from a single backoffice."} + + + + + {/* Bottom-right: brand image with a center-to-edge opacity fade, no color tint. */} + + +); + +const RightPanelDecor = () => ( +
+
+
+ + + + + + + + +
+); + +const LanguageSelector = () => ( +
+ + Eng + +
+); + +export default function AuthShell({ + children, + tagline, + taglineBody, +}: AuthShellProps) { + return ( +
+
+ + +
+ + +
+ +
+ +
+
+
+ {children} +
+
+
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index 01a0db69a..13ac9577d 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -65,7 +65,6 @@ export function BookingActionsMenu({ }; const hasMenu = listRowHasActions(row, user); - const primary = actions.find((a) => a.primary) ?? actions[0]; if (!hasMenu && variant === "table") { return ( @@ -117,19 +116,6 @@ export function BookingActionsMenu({ onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > - {variant === "table" && primary && ( - - )} - Promise, filename: string) => { - const blob = await fn(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - }; - if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") { return null; } @@ -101,23 +91,6 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool - - {status === "CONTRACT_READY" && ( - - - - )} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx index 1b2960b8f..7d1fff022 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx @@ -18,6 +18,7 @@ const statusColorMap: Record = { EXPIRED: "red", PAID: "edr-green", IN_TRANSIT: "cyan", + ARRIVED: "teal", COMPLETED: "indigo", REJECTED: "red", CANCELLED: "red", diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainerUnitsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainerUnitsCard.tsx new file mode 100644 index 000000000..cd925c03c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContainerUnitsCard.tsx @@ -0,0 +1,202 @@ +import { useMemo } from "react"; +import { Boxes, Container as ContainerIcon, Snowflake, Flame } from "lucide-react"; +import { Badge, Box, Group, Stack, Table, Text, ThemeIcon } from "@mantine/core"; + +import type { BookingDetail } from "@/types/booking"; +import { SectionCard } from "./SectionCard"; + +export interface BookingContainerUnitsCardProps { + booking: BookingDetail; +} + +interface FlatUnit { + id: string; + containerNumber: string; + sealNumber?: string | null; + vgmTons: number; + isHazardous?: boolean; + isReefer?: boolean; + typeLabel: string; + sizeFt?: number; +} + +/** + * The physical container manifest: one row per container with its number, type, + * seal, and weight (VGM). Per-unit numbers are only captured for contract-drawdown + * bookings — when a line has no units the card falls back to the aggregate + * type/qty/weight so it still renders something for plain bookings. + */ +export function BookingContainerUnitsCard({ booking }: BookingContainerUnitsCardProps) { + const lines = booking.bookingContainers ?? []; + + const units: FlatUnit[] = useMemo( + () => + lines.flatMap((line) => + (line.units ?? []).map((u) => ({ + id: u.id, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber, + vgmTons: Number(u.vgmTons) || 0, + isHazardous: u.isHazardous, + isReefer: u.isReefer, + typeLabel: line.containerType?.label ?? line.containerType?.code ?? "—", + sizeFt: line.containerType?.sizeFt, + })), + ), + [lines], + ); + + // Container bookings only — bulk has no container manifest. + if (booking.freightType === "BULK" || lines.length === 0) return null; + + const totalUnits = units.length; + const totalVgm = units.reduce((sum, u) => sum + u.vgmTons, 0); + + return ( + 0 + ? "Each physical container with its number and weight" + : "Per-container numbers were not captured for this booking" + } + accent="teal" + extra={ + totalUnits > 0 ? ( + + {totalUnits} container{totalUnits === 1 ? "" : "s"} + + ) : ( + + {lines.length} line{lines.length === 1 ? "" : "s"} + + ) + } + > + {totalUnits > 0 ? ( + + + + + + # + Container No. + Type + Seal + Weight (VGM) + + + + {units.map((u, i) => ( + + + + {i + 1} + + + + + + + + + {u.containerNumber} + + {u.isReefer ? ( + + + + ) : null} + {u.isHazardous ? ( + + + + ) : null} + + + + + {u.typeLabel} + {u.sizeFt ? ( + + {u.sizeFt}FT + + ) : null} + + + + + {u.sealNumber || "—"} + + + + + {u.vgmTons.toFixed(3)} t + + + + ))} + +
+
+ + + + Total weight (VGM) + + + {totalVgm.toFixed(3)} t + + +
+ ) : ( + // Fallback: no per-unit numbers — show the aggregate lines. + + + + + Type + Qty + VGM / unit + Total VGM + + + + {lines.map((line) => { + const perUnit = Number(line.vgmPerUnitTons) || 0; + return ( + + + + + {line.containerType?.label ?? line.containerType?.code ?? "—"} + + {line.containerType?.sizeFt ? ( + + {line.containerType.sizeFt}FT + + ) : null} + + + {line.quantity} + {perUnit.toFixed(3)} t + + + {(line.quantity * perUnit).toFixed(3)} t + + + + ); + })} + +
+
+ )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingDocumentsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingDocumentsPanel.tsx new file mode 100644 index 000000000..3c36f757e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingDocumentsPanel.tsx @@ -0,0 +1,146 @@ +import { useMemo } from "react"; +import { Box, Center, Group, Loader, Stack, Text } from "@mantine/core"; +import { FileText, FolderOpen } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import type { Freight } from "@edr/types"; + +import { bookingsService } from "@/services/bookings.service"; +import { downloadBookingFile } from "@/services/files.service"; +import { useFileViewer } from "@/hooks/useFileViewer"; +import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel"; +import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow"; +import { SectionCard } from "./SectionCard"; + +interface LabeledFile { + label: string; + file: { id: string; name: string }; +} + +/** + * Every document tied to a booking, in one tab: the customer/GL clearance + * documents, the customs workflow files (declaration/duty/transit/Djibouti), + * the duty-tax notice, and the final invoice + payment slip. All fetched from + * the booking's clearance view (the only endpoint that surfaces booking files), + * each with inline view + download. + */ +export function BookingDocumentsPanel({ bookingId }: { bookingId: string }) { + const { view, viewer } = useFileViewer(); + + const { data: clearance, isLoading, isError } = useQuery({ + queryKey: ["clearance", bookingId], + queryFn: () => bookingsService.getClearance(bookingId), + }); + + const onDownload = (f: { id: string; name: string }) => + void downloadBookingFile(f.id, f.name); + + // Uploaded customer + GL clearance documents (skip the not-yet-uploaded slots). + const clearanceDocs = useMemo< + Array<{ doc: Freight.ClearanceDocument; file: { id: string; name: string } }> + >( + () => + (clearance?.documents ?? []) + .filter((d) => d.file) + .map((d) => ({ doc: d, file: d.file! })), + [clearance], + ); + + const workflowFiles = useMemo( + () => (clearance?.workflowFiles ?? []).filter((f) => f.file), + [clearance], + ); + + // Duty notice + final invoice + payment slip — loose files that don't ride in + // the documents/workflow arrays. + const otherFiles = useMemo(() => { + const rows: LabeledFile[] = []; + const notice = clearance?.dutyAdvice?.noticeFile; + if (notice) rows.push({ label: "Duty & tax notice", file: notice }); + const inv = clearance?.finalInvoice; + if (inv?.invoiceFile) + rows.push({ label: `Final invoice · ${inv.invoiceNumber}`, file: inv.invoiceFile }); + if (inv?.slipFile) + rows.push({ label: "Final invoice payment slip", file: inv.slipFile }); + return rows; + }, [clearance]); + + if (isLoading) { + return ( +
+ + + Loading documents… + +
+ ); + } + + const hasAny = + clearanceDocs.length > 0 || workflowFiles.length > 0 || otherFiles.length > 0; + + if (isError || !hasAny) { + return ( + +
+ + + No documents yet + + {isError + ? "Couldn’t load this booking’s documents." + : "Documents attached to this booking will appear here as they’re uploaded."} + + +
+
+ ); + } + + return ( + + {clearanceDocs.length > 0 && ( + + + {clearanceDocs.map(({ doc, file }) => ( + + ))} + + + )} + + {workflowFiles.length > 0 && ( + + )} + + {otherFiles.length > 0 && ( + + + {otherFiles.map((row) => ( + + ))} + + + )} + + {viewer} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index 16bd6ed27..78cc5efd6 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -41,6 +41,12 @@ export interface ClearanceReviewSectionProps { onChanged?: () => void; /** Hide the inline progress summary (e.g. when the parent renders its own). */ hideSummary?: boolean; + /** Lock approve actions after document review phase completes. */ + approvalsLocked?: boolean; + /** Block new queries after pre-clearance finalization. */ + queriesLocked?: boolean; + /** Read-only audit view — no approve/query actions. */ + readOnly?: boolean; } const STATUS_META: Record< @@ -64,6 +70,9 @@ export function ClearanceReviewSection({ bookingId, onChanged, hideSummary, + approvalsLocked = false, + queriesLocked = false, + readOnly = false, }: ClearanceReviewSectionProps) { const qc = useQueryClient(); const [queryNotes, setQueryNotes] = useState>({}); @@ -147,6 +156,11 @@ export function ClearanceReviewSection({ return { total, approved, queried, pending, pct }; }, [customerDocs]); + const hasDocsAwaitingApproval = customerDocs.some( + (d) => d.file && d.reviewStatus !== "APPROVED", + ); + const effectiveApprovalsLocked = approvalsLocked && !hasDocsAwaitingApproval; + if (isLoading || !clearance) { return ( @@ -194,6 +208,9 @@ export function ClearanceReviewSection({ @@ -397,6 +414,9 @@ function StatPill({ function DocReviewCard({ doc, + approvalsLocked, + queriesLocked, + readOnly, note, queryOpen, onToggleQuery, @@ -407,6 +427,9 @@ function DocReviewCard({ busy, }: { doc: Freight.ClearanceDocument; + approvalsLocked: boolean; + queriesLocked: boolean; + readOnly: boolean; note: string; queryOpen: boolean; onToggleQuery: (open: boolean) => void; @@ -419,6 +442,7 @@ function DocReviewCard({ const status = doc.reviewStatus ?? "PENDING"; const meta = STATUS_META[status]; const hasFile = !!doc.file; + const isApproved = status === "APPROVED"; return ( )} - {hasFile && ( + {hasFile && !readOnly && ( {!queryOpen ? ( - - + {!queriesLocked && ( + + )} + {!isApproved && !approvalsLocked && ( + + )} ) : ( void; + onDownloadFile?: (file: { id: string; name: string }) => void; +} + +function findMilestone( + milestones: Freight.IClearanceMilestone[] | undefined, + code: string, +): Freight.IClearanceMilestone | undefined { + return milestones?.find((m) => m.milestoneCode === code); +} + +/** + * Document Clearance detail layout: primary clearance workflow plus optional + * uploaded documents, post-booking risk assignment, and incident reporting tabs. + */ +export function ClearanceOpsTabs({ + bookingId, + milestones, + showOpsTabs = true, + clearanceTab, + workflowFiles = [], + showWorkflowFilesTab = false, + tradeDirection = "IMPORT", + onViewFile, + onDownloadFile, +}: ClearanceOpsTabsProps) { + const riskMs = findMilestone(milestones, "RISK_ASSIGNED"); + const hasOps = Boolean(bookingId); + const isExport = tradeDirection === "EXPORT"; + const uploadedDocCount = workflowFiles.filter((f) => { + if (!f.file) return false; + if (isExport) return f.category !== "duty"; + return true; + }).length; + const showDocuments = showWorkflowFilesTab && Boolean(onViewFile); + const hasTabs = (showOpsTabs && hasOps) || showDocuments; + + if (!hasTabs) { + return <>{clearanceTab}; + } + + return ( + + + Clearance + {showDocuments ? ( + } + rightSection={ + uploadedDocCount > 0 ? ( + + {uploadedDocCount} + + ) : undefined + } + > + Uploaded documents + + ) : null} + {showOpsTabs && riskMs ? ( + }> + Risk assignment + + ) : null} + {showOpsTabs && bookingId ? ( + }> + Incidents + + ) : null} + + + {clearanceTab} + + {showDocuments ? ( + + + + ) : null} + + {showOpsTabs && riskMs && bookingId ? ( + + + + + + ) : null} + + {showOpsTabs && bookingId ? ( + + + + + Log container or seal issues discovered during clearance handling. + + + + + + ) : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx new file mode 100644 index 000000000..ba93a790d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearancePhaseStepper.tsx @@ -0,0 +1,112 @@ +import { Check } from "lucide-react"; +import { Box, Group, Stack, Text } from "@mantine/core"; +import type { Freight } from "@edr/types"; + +const BRAND_GREEN = "var(--freight-brand, #0A6F4D)"; + +const IMPORT_PHASES = [ + "CUSTOMER_INTAKE", + "GL_ET_REVIEW", + "GL_ET_OUTPUT", + "CUSTOMER_DUTY", + "GL_ET_POST_CLEARANCE", + "GL_DJ_COLLECTION", +] as const; + +const PHASE_LABELS: Record = { + CUSTOMER_INTAKE: "Customer docs", + GL_ET_REVIEW: "GL ET review", + GL_DJ_COLLECTION: "GL Djibouti DO", + GL_ET_OUTPUT: "Declaration", + CUSTOMER_DUTY: "Duty / customer pays", + GL_ET_POST_CLEARANCE: "Transit & finalize", + GL_DJ_LOADING: "Loading", + POST_TRANSIT: "Transit", +}; + +const EXPORT_PHASES = [ + "CUSTOMER_INTAKE", + "GL_ET_REVIEW", + "GL_DJ_COLLECTION", + "GL_ET_OUTPUT", + "GL_ET_POST_CLEARANCE", +] as const; + +function phaseIndex(phases: readonly string[], current?: string | null): number { + if (!current) return 0; + const idx = phases.indexOf(current); + return idx >= 0 ? idx : 0; +} + +export function ClearancePhaseStepper({ + clearance, + tradeDirection, + compact = false, +}: { + clearance?: Freight.ContractClearanceView | Freight.ClearanceView | null; + tradeDirection?: string; + compact?: boolean; +}) { + const phases = tradeDirection === "EXPORT" ? EXPORT_PHASES : IMPORT_PHASES; + const current = clearance?.phase ?? phases[0]; + const activeIdx = phaseIndex(phases, current); + + return ( + + {phases.map((phase, index) => { + const isComplete = index < activeIdx; + const isActive = index === activeIdx; + const isLast = index === phases.length - 1; + + return ( + + + + + {isComplete ? : null} + + + {PHASE_LABELS[phase] ?? phase} + + + {!isLast && ( + + )} + + + ); + })} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceUploadedDocumentsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceUploadedDocumentsPanel.tsx new file mode 100644 index 000000000..1abffa761 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceUploadedDocumentsPanel.tsx @@ -0,0 +1,205 @@ +import { useMemo } from "react"; +import { Badge, Box, Stack, Tabs, Text, ThemeIcon } from "@mantine/core"; +import { FileText, Receipt, Ship, Truck } from "lucide-react"; +import type { Freight } from "@edr/types"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow"; + +type TabValue = Freight.ClearanceWorkflowFileCategory; + +type TabConfig = { + value: TabValue; + label: string; + icon: typeof FileText; + emptyHint: string; +}; + +function tabConfigForTradeDirection(tradeDirection: string): TabConfig[] { + if (tradeDirection === "EXPORT") { + return [ + { + value: "declaration", + label: "Declaration", + icon: FileText, + emptyHint: "No declaration uploaded yet.", + }, + { + value: "djibouti", + label: "Release order", + icon: Ship, + emptyHint: "No release order uploaded yet.", + }, + { + value: "transit", + label: "Transit Permit", + icon: Truck, + emptyHint: "No transit permit uploaded yet.", + }, + ]; + } + + return [ + { + value: "declaration", + label: "Declaration", + icon: FileText, + emptyHint: "No declaration uploaded yet.", + }, + { + value: "duty", + label: "Duty notice", + icon: Receipt, + emptyHint: "No duty notice or payment slip uploaded yet.", + }, + { + value: "transit", + label: "Transit permit", + icon: Truck, + emptyHint: "No transit permit uploaded yet.", + }, + ]; +} + +function subtitleForTradeDirection(tradeDirection: string): string { + return tradeDirection === "EXPORT" + ? "Declaration, release order, and transit permit files for this clearance." + : "Declaration, duty notice, and transit permit files for this clearance."; +} + +function footerHintForTradeDirection(tradeDirection: string): string { + return tradeDirection === "EXPORT" + ? "Files appear here once GL uploads the declaration and release order, and after booking when the transit permit is uploaded." + : "Files appear here once GL Ethiopia uploads declaration, duty notice, or transit permit documents."; +} + +export interface ClearanceUploadedDocumentsPanelProps { + files: Freight.ClearanceWorkflowFile[]; + tradeDirection?: string; + onView: (file: { name: string; url: string }) => void; + onDownload?: (file: { id: string; name: string }) => void; +} + +export function ClearanceUploadedDocumentsPanel({ + files, + tradeDirection = "IMPORT", + onView, + onDownload, +}: ClearanceUploadedDocumentsPanelProps) { + const tabConfig = useMemo( + () => tabConfigForTradeDirection(tradeDirection), + [tradeDirection], + ); + const isExport = tradeDirection === "EXPORT"; + + const visibleFiles = useMemo( + () => + isExport ? files.filter((f) => f.category !== "duty") : files, + [files, isExport], + ); + + const uploadedCount = visibleFiles.filter((f) => f.file).length; + + const defaultTab = + tabConfig.find((tab) => + visibleFiles.some((f) => f.category === tab.value && f.file), + )?.value ?? tabConfig[0]?.value ?? "declaration"; + + return ( + + + + {tabConfig.map((tab) => { + const count = visibleFiles.filter( + (f) => f.category === tab.value && f.file, + ).length; + const Icon = tab.icon; + return ( + } + rightSection={ + count > 0 ? ( + + {count} + + ) : undefined + } + > + {tab.label} + + ); + })} + + + {tabConfig.map((tab) => { + const items = visibleFiles.filter( + (f) => f.category === tab.value && f.file, + ); + const Icon = tab.icon; + + return ( + + {items.length > 0 ? ( + + {items.map((item) => ( + + ))} + + ) : ( + + )} + + ); + })} + + + {uploadedCount === 0 ? ( + + {footerHintForTradeDirection(tradeDirection)} + + ) : null} + + ); +} + +function EmptyTabState({ + icon: Icon, + hint, +}: { + icon: typeof FileText; + hint: string; +}) { + return ( + + + + + + + {hint} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx new file mode 100644 index 000000000..17e5e4b70 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceWorkflowFilesPanel.tsx @@ -0,0 +1,155 @@ +import { + Badge, + Box, + Button, + Group, + Paper, + Stack, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { Download, Eye, FileText } from "lucide-react"; +import type { Freight } from "@edr/types"; +import { isViewable } from "@edr/ui-common"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { fileViewUrl } from "@/constants/apiConfig"; + +const CATEGORY_LABELS: Record< + Freight.ClearanceWorkflowFileCategory, + string +> = { + declaration: "Declaration", + duty: "Duty & taxes", + transit: "Transit", + djibouti: "Djibouti", +}; + +const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [ + "declaration", + "duty", + "transit", + "djibouti", +]; + +const OWNER_LABELS: Record = { + customer: "Customer", + gl_et: "GL Ethiopia", + gl_dj: "GL Djibouti", +}; + +export interface ClearanceWorkflowFilesPanelProps { + files: Freight.ClearanceWorkflowFile[]; + onView: (file: { name: string; url: string }) => void; + onDownload?: (file: { id: string; name: string }) => void; + title?: string; +} + +export function ClearanceWorkflowFilesPanel({ + files, + onView, + onDownload, + title = "Customs workflow documents", +}: ClearanceWorkflowFilesPanelProps) { + if (files.length === 0) return null; + + const grouped = CATEGORY_ORDER.map((category) => ({ + category, + label: CATEGORY_LABELS[category], + items: files.filter((f) => f.category === category), + })).filter((g) => g.items.length > 0); + + return ( + + + {grouped.map((group) => ( + + + {group.label} + + + {group.items.map((item) => ( + + ))} + + + ))} + + + ); +} + +function WorkflowFileRow({ + item, + onView, + onDownload, +}: { + item: Freight.ClearanceWorkflowFile; + onView: (file: { name: string; url: string }) => void; + onDownload?: (file: { id: string; name: string }) => void; +}) { + const file = item.file; + if (!file) return null; + + const viewUrl = fileViewUrl(file.id); + const canPreview = isViewable({ name: file.name, url: viewUrl }); + + return ( + + + + + + + + + {item.label} + + + + {OWNER_LABELS[item.uploadedBy]} + + + {file.name} + + + + + + {canPreview ? ( + + + + ) : null} + {onDownload ? ( + + + + ) : null} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx index 7a3dd897e..044c729ee 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx @@ -1,6 +1,15 @@ -import { useMemo } from "react"; -import { Check, ShieldCheck } from "lucide-react"; -import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core"; +import { useMemo, useState } from "react"; +import { Check, ShieldCheck, X } from "lucide-react"; +import { + Stack, + Group, + Text, + Badge, + Button, + Box, + Modal, + Textarea, +} from "@mantine/core"; import type { Freight } from "@edr/types"; import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress"; @@ -19,6 +28,14 @@ export function ContractApprovalStepsCard({ contract, mutations, }: ContractApprovalStepsCardProps) { + const [confirmOpen, setConfirmOpen] = useState(false); + const [pendingStep, setPendingStep] = + useState(null); + const [rejectOpen, setRejectOpen] = useState(false); + const [rejectStepRow, setRejectStepRow] = + useState(null); + const [rejectReason, setRejectReason] = useState(""); + const steps = useMemo( () => [...(contract.approvalSteps ?? [])].sort( @@ -30,6 +47,46 @@ export function ContractApprovalStepsCard({ const nextPending = steps.find((s) => s.status === "PENDING"); const summary = formatContractApprovalProgress(contract.status, steps); + const openApprove = (step: Freight.IContractApprovalStep) => { + setPendingStep(step); + setConfirmOpen(true); + }; + + const closeApprove = () => { + setConfirmOpen(false); + setPendingStep(null); + }; + + const runApprove = () => { + if (!pendingStep) return; + mutations.approveStep.mutate( + { stepId: pendingStep.id, requiredRole: pendingStep.requiredRole }, + { onSuccess: () => closeApprove() }, + ); + }; + + const openReject = (step: Freight.IContractApprovalStep) => { + setRejectStepRow(step); + setRejectReason(""); + setRejectOpen(true); + }; + + const closeReject = () => { + setRejectOpen(false); + setRejectStepRow(null); + setRejectReason(""); + }; + + const trimmedReason = rejectReason.trim(); + + const runReject = () => { + if (!rejectStepRow || !trimmedReason) return; + mutations.rejectStep.mutate( + { stepId: rejectStepRow.id, reason: trimmedReason }, + { onSuccess: () => closeReject() }, + ); + }; + const subtitle = summary.detail || (nextPending @@ -39,54 +96,139 @@ export function ContractApprovalStepsCard({ : "Accept submission to begin"); return ( - - {steps.filter((s) => s.status === "APPROVED").length}/{steps.length} - - } - > - - {subtitle} - - - {steps.length === 0 ? ( - - Use Accept for approval in staff actions to - instantiate steps. + <> + + {steps.filter((s) => s.status === "APPROVED").length}/{steps.length} + + } + > + + {subtitle} - ) : ( - - {steps.map((step) => ( - - mutations.approveStep.mutate({ - stepId: step.id, - requiredRole: step.requiredRole, - }) - } - /> - ))} + + {steps.length === 0 ? ( + + Use Accept for approval in staff actions to + instantiate steps. + + ) : ( + + {steps.map((step) => ( + openApprove(step)} + onReject={() => openReject(step)} + /> + ))} + + )} + + + + + + You are about to approve the{" "} + + {pendingStep?.requiredRole} + {" "} + step for contract{" "} + + {contract.reference} + + . This action cannot be undone from this screen. + + + + + - )} - + + + + + + Rejecting the{" "} + + {rejectStepRow?.requiredRole} + {" "} + step rejects contract{" "} + + {contract.reference} + {" "} + outright. The customer must create a new contract — this cannot be + undone. + +