diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 72ad6de66..62530611c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -182,24 +182,6 @@ jobs: set -euo pipefail docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate - - name: Verify deployment health - if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) - run: | - set -euo pipefail - PORT=$(grep '^PORT=' "${SERVICE_ENV_FILE}" | cut -d= -f2) - echo "Waiting for service to become healthy on port ${PORT}..." - for i in $(seq 1 12); do - if wget -qO- "http://localhost:${PORT}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; then - echo "Service is healthy." - exit 0 - fi - echo "Attempt ${i}/12 — not ready yet, waiting 10s..." - sleep 10 - done - echo "Service failed health check after 120s — rolling back" - docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate || true - exit 1 - - name: Remove npm credentials from workspace if: always() run: rm -f .npmrc .npmrc_temp diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 5bce9de95..2b3b855cb 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -49,6 +49,9 @@ 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 diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index c148dd226..457786cbe 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -22,6 +22,7 @@ "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", @@ -49,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", @@ -70,6 +73,7 @@ "puppeteer": "^24.2.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.3", "typeorm": "^0.3.30" }, "devDependencies": { 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/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 1c99acbb5..efd3287d8 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -35,6 +35,7 @@ import { CompaniesModule } from "./modules/companies/companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; +import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { OtpModule } from "./modules/otp/otp.module"; @@ -64,6 +65,7 @@ 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 { VerifaydaModule } from './modules/verifayda/verifayda.module'; @@ -128,6 +130,7 @@ import { LoggerMiddleware } from "./logger.middleware"; TrackingModule, BillingModule, NotificationsModule, + NotificationInboxModule, FileUploadSettingsModule, DropdownSettingsModule, OtpModule, @@ -176,6 +179,7 @@ import { LoggerMiddleware } from "./logger.middleware"; ExportDjiboutiInterchangeDemoSeeder, MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, + PaidImportExportMileDemoSeeder, ], }) export class AppModule implements OnApplicationBootstrap { 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-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/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/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/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index fdd230d33..a334b5e28 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -957,12 +957,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, 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 35fc7fd54..06edbf04e 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 @@ -1086,6 +1086,9 @@ export class BookingTransitionService { 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 @@ -1141,12 +1144,20 @@ export class BookingTransitionService { `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, }; } } 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 c5574377a..106b7ed5b 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, @@ -61,6 +62,11 @@ import { } 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 { 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 { @@ -83,6 +89,8 @@ export class BookingsController { private readonly transitionService: BookingTransitionService, private readonly contractService: BookingContractService, private readonly bookingClearanceService: BookingClearanceService, + private readonly customerTruckService: CustomerTruckService, + private readonly containerReceiptService: ContainerReceiptService, ) {} @Post() @@ -309,6 +317,94 @@ export class BookingsController { 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); + } + + @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); + } + + @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", 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 92790c273..2cb10ce8e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -33,6 +33,11 @@ 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 { 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 { ContractsModule } from '../contracts/contracts.module'; import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; @@ -55,6 +60,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingReviewNote, BookingContractSignature, BookingContainerAllocation, + CustomerTruckAssignment, + CustomerTruckContainer, ]), BillingModule, forwardRef(() => FirstMileModule), @@ -91,12 +98,17 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ContractPricingScheduleBuilder, ContractRendererService, ContractPdfService, + CustomerTruckAssignmentsRepository, + CustomerTruckService, + ContainerReceiptService, ], exports: [ BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService, + CustomerTruckService, + ContainerReceiptService, ], }) 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 4803f988c..ccdab3379 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'; @@ -61,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. */ @@ -588,8 +596,10 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') // Contract reference for the list column + search (no entity relation on - // Booking → contract, so join by id and select just the reference). - .leftJoin('freight.contracts', 'contract', 'contract.id = booking.contract_id') + // 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'); @@ -1079,8 +1089,10 @@ export class BookingsRepository extends BaseRepository { destinationYard: true, // units carry the real per-container numbers entered at booking time — // the wagon plan shows those instead of generated placeholders. - bookingContainers: { containerType: true, units: true }, - cargoType: true, + // 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 e3d882baa..7a6169b7b 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'; @@ -145,7 +146,28 @@ export class BookingsService { throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated'); } - const html = this.buildCustomerTruckFreightOrderHtml(booking); + 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`, @@ -186,41 +208,89 @@ 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): string { + 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 rows: Array<[string, string | null | undefined]> = [ + 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], - ['Truck Plate Number', booking.customerTruckPlateNumber], - ['Driver Name', booking.customerTruckDriverName], - ['Truck Type', booking.customerTruckType], - ['Container Number to Load', booking.customerTruckContainerNumber], ['Assigned At', assignedAt], ['Booking Status', booking.status], ]; - const rowHtml = rows + const bookingRowHtml = bookingRows .map(([label, value]) => `${this.escapeHtml(label)}${this.escapeHtml(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]) => + `${this.escapeHtml(label)}${this.escapeHtml(value || '-')}`, + ) + .join(''); + return `

Truck ${i + 1}

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

Freight Order

-

Customer external truck assignment

+

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

${this.escapeHtml(booking.reference)}
- ${rowHtml}
+ ${bookingRowHtml}
+ ${truckBlocks}
Customer / Carrier Signature
Port Operations Verification
@@ -238,11 +308,13 @@ export class BookingsService { .watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; } header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; } h1 { margin: 0; font-size: 28px; letter-spacing: 0; } + h2 { margin: 18px 0 8px; font-size: 14px; color: #0a6f4d; } p { margin: 4px 0 0; color: #64748b; } strong { font-size: 16px; color: #0a9f6a; } - table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; } + table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; margin-bottom: 6px; } th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; } th { width: 34%; background: #f1f5f9; } + .truck { page-break-inside: avoid; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; } .signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; } @@ -522,7 +594,6 @@ export class BookingsService { } } - const reference = dto.reference || (await this.generateReference()); const containers = dto.containers ?? []; assertFreightShape({ freightType: dto.freightType, @@ -617,7 +688,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, @@ -672,7 +746,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( 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-receipt.service.ts b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts new file mode 100644 index 000000000..fde3ab797 --- /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_containers 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_containers 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_containers 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_containers 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_containers 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/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..5d0650219 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -0,0 +1,321 @@ +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 isExport = booking.tradeDirection === 'EXPORT'; + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + + // EXPORT: the truck delivers 1–2 known containers. IMPORT: containers are + // not pre-specified — they are registered + weighed when the truck leaves. + if (isExport) { + if (requested.length < 1 || requested.length > 2) { + throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers'); + } + } else if (requested.length > 2) { + throw new BadRequestException('A truck carries at most 2 containers'); + } + + 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 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`); + } + } + } + + 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); + } + + /** + * 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); + } + + /** + * 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_containers 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()); + } +} 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/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/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/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/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/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/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 3f169c49c..61ac93925 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -205,7 +205,7 @@ export class BookingClearanceService { const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId); const bookingMilestone = (code: string) => milestones.find((m) => m.milestoneCode === code); - const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const gatepass = await this.glOperationsService.gatepassForBooking(bookingId); const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); const riskMilestone = bookingMilestone('RISK_ASSIGNED'); const secondDuty = this.glOperationsService.secondDutyState(milestones, files); @@ -242,14 +242,8 @@ export class BookingClearanceService { workflowFiles, t1, train, - gatepassGranted: gatepassMilestone?.status === 'COMPLETED', - gatepassAt: - gatepassMilestone?.status === 'COMPLETED' - ? (gatepassMilestone.metadata?.gatepassAt ?? - (gatepassMilestone.triggeredAt - ? gatepassMilestone.triggeredAt.toISOString() - : null)) - : null, + gatepassGranted: gatepass.granted, + gatepassAt: gatepass.grantedAt, t1Closed: t1ClosedMilestone?.status === 'COMPLETED', t1ClosedAt: t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt 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 7a3695768..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 @@ -48,8 +48,22 @@ export class BookingRequestRepository extends BaseRepository { }); } - /** 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..88a4ec725 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 @@ -192,8 +192,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/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index c1eb4b4f8..873daaf15 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 @@ -8,6 +8,7 @@ import { forwardRef, } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { insertWithGeneratedReference } from '@edr/api-common'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; @@ -110,7 +111,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 @@ -138,10 +138,19 @@ export class ContractBookingService { // 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, @@ -175,7 +184,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') { @@ -761,6 +771,35 @@ export class ContractBookingService { } } + /** + * 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) @@ -791,8 +830,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 532d26359..2c79ba42f 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 @@ -278,7 +278,9 @@ export class ContractClearanceService { } const bookingMilestone = (code: string) => bookingMilestones.find((m) => m.milestoneCode === code); - const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + 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( @@ -344,14 +346,8 @@ export class ContractClearanceService { workflowFiles, t1, train, - gatepassGranted: gatepassMilestone?.status === 'COMPLETED', - gatepassAt: - gatepassMilestone?.status === 'COMPLETED' - ? (gatepassMilestone.metadata?.gatepassAt ?? - (gatepassMilestone.triggeredAt - ? gatepassMilestone.triggeredAt.toISOString() - : null)) - : null, + gatepassGranted: gatepass.granted, + gatepassAt: gatepass.grantedAt, t1Closed: t1ClosedMilestone?.status === 'COMPLETED', t1ClosedAt: t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt 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-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 9bb4b2de6..e31392666 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'; @@ -611,8 +612,11 @@ export class ContractTransitionService { 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, @@ -640,7 +644,8 @@ export class ContractTransitionService { status: 'RENEWAL_DRAFT', clearanceStatus: 'NOT_APPLICABLE', clearanceCycleNumber: 0, - } as never); + } as never), + ); void userId; return this.contractsService.findById(renewal.id); @@ -648,7 +653,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 06ac31d68..a22c7cad4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -77,7 +77,6 @@ import { } from './dto/gl-operations.dto'; import { AdviseContractDutyDto, - GatepassDto, RoAmendmentDto, } from './dto/phased-clearance.dto'; @@ -688,30 +687,6 @@ export class ContractsController { return this.clearanceService.djQueue(filter); } - @Get('clearance/dj-schedules') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' }) - djClearanceSchedules() { - return this.glOperationsService.djSchedules(); - } - - @Post('clearance/schedules/:scheduleId/gatepass') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ - summary: 'GL DJ grants the gate pass for every customs booking on a train schedule', - }) - grantScheduleGatepass( - @Param('scheduleId', ParseUUIDPipe) scheduleId: string, - @Body() dto: GatepassDto, - @CurrentUser() user: AuthUserPayload, - ) { - return this.glOperationsService.grantScheduleGatepass( - scheduleId, - dto?.gatepassAt, - resolveAuthUserId(user), - ); - } - // ── Path A self-clearance — Operations reviews the customer's own docs ─────── @Get('clearance/ops-queue') @@ -947,21 +922,6 @@ export class ContractsController { return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user)); } - @Post('bookings/:bookingId/gatepass') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' }) - grantGatepass( - @Param('bookingId', ParseUUIDPipe) bookingId: string, - @Body() dto: GatepassDto, - @CurrentUser() user: AuthUserPayload, - ) { - return this.glOperationsService.grantGatepass( - bookingId, - dto?.gatepassAt, - resolveAuthUserId(user), - ); - } - @Post('bookings/:bookingId/final-invoice') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(FileInterceptor('file')) 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 34a958fd2..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. */ 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 8d864aac7..73360f7f5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { insertWithGeneratedReference } from '@edr/api-common'; import { CompaniesService } from '../companies/companies.service'; import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity'; @@ -57,8 +58,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. */ @@ -144,8 +145,6 @@ export class ContractsService { this.assertCargoScopeShape(dto.freightType, dto.cargoScope); this.assertRouteShape(dto.contractKind, dto.routes); - const reference = dto.reference || (await this.generateReference()); - // Stamp the operational profile (importer/exporter) for portal scoping. let companyProfileId: string | null = null; if (!isGovernment && companyId) { @@ -177,7 +176,61 @@ export class ContractsService { // Customs clearing is owned by the service type, not the customer. const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId); - 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, @@ -205,24 +258,6 @@ export class ContractsService { 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 }; } /** 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 index 6b784073b..34a903427 100644 --- 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 @@ -36,11 +36,3 @@ export class RoAmendmentDto { note?: string; } -export class GatepassDto { - @ApiPropertyOptional({ - description: 'When the gate pass was granted (ISO datetime; defaults to now)', - }) - @IsOptional() - @IsString() - gatepassAt?: string; -} 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 8fed3a8ff..72e2d14d9 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 @@ -4,7 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { DataSource, In, IsNull } from 'typeorm'; +import { DataSource, IsNull } from 'typeorm'; import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types'; import { BillingService } from '../billing/billing.service'; @@ -17,7 +17,6 @@ import { ClearanceIncident, IncidentType, } from './entities/clearance-incident.entity'; -import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { @@ -198,6 +197,7 @@ export class GlOperationsService { } return { + scheduleId: schedule?.id ?? null, wagonAllocated, departedAt: schedule?.actualDepartureAt ? new Date(schedule.actualDepartureAt).toISOString() @@ -209,9 +209,45 @@ export class GlOperationsService { } /** - * T1 transit-document lifecycle state for an import shipment booking. Wagon - * allocation opens the upload window; train departure locks it; train arrival - * lets GL Ethiopia close (accept) the T1 set. + * 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); @@ -234,7 +270,8 @@ export class GlOperationsService { } /** - * GL Djibouti uploads T1 transport documents (multi-file) after wagon allocation. + * 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 once the train departs or T1 is closed. */ async uploadT1Documents( @@ -252,6 +289,12 @@ export class GlOperationsService { '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.'); } @@ -268,7 +311,8 @@ export class GlOperationsService { /** * Close (accept) the T1/transport document set. * Import: GL Ethiopia closes once the train has arrived (T1 files required). - * Export: GL Djibouti closes after the gate pass (transport document required). + * Export: GL Djibouti closes once the train arrives at Djibouti (transport + * document required). */ async closeT1( bookingId: string, @@ -302,8 +346,10 @@ export class GlOperationsService { 'The transport document must be uploaded before T1 can be closed.', ); } - if (!done('GATEPASS_GRANTED')) { - throw new BadRequestException('Grant the gate pass before closing T1.'); + 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); @@ -322,182 +368,6 @@ export class GlOperationsService { 'ARRIVED_AT_DJIBOUTI', ]; - /** - * GL Djibouti grants the gate pass for a customs booking, capturing the time. - * Export: requires the train to have arrived at Djibouti; back-fills the - * arrival-chain milestones. Import: requires wagon allocation (pre-loading). - */ - async grantGatepass( - bookingId: string, - gatepassAt?: string, - userId?: string, - ): Promise<{ bookingId: string; gatepassAt: string }> { - const booking = await this.getBooking(bookingId); - if (!booking.customsClearingEnabled) { - throw new BadRequestException('Gate pass applies to customs bookings only.'); - } - const tradeDirection = booking.tradeDirection ?? 'IMPORT'; - const milestones = await this.milestoneService.listForBooking(bookingId); - const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); - - const existing = byCode.get('GATEPASS_GRANTED'); - if (existing?.status === 'COMPLETED') { - return { - bookingId, - gatepassAt: - existing.metadata?.gatepassAt ?? - (existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''), - }; - } - - const train = await this.trainState(bookingId); - if (tradeDirection === 'EXPORT') { - if (!train.arrivedAt) { - throw new BadRequestException( - 'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.', - ); - } - for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { - if (byCode.get(code)?.status === 'PENDING') { - await this.milestoneService.completeForBooking(bookingId, code, userId); - } - } - } else if (!train.wagonAllocated) { - throw new BadRequestException( - 'Wagons must be allocated before the gate pass can be granted.', - ); - } - - const at = gatepassAt?.trim() || new Date().toISOString(); - await this.milestoneService.completeWithMetadataForBooking( - bookingId, - 'GATEPASS_GRANTED', - { gatepassAt: at }, - userId, - ); - return { bookingId, gatepassAt: at }; - } - - /** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */ - async djSchedules(): Promise { - const schedules = await this.dataSource.getRepository(TrainSchedule).find({ - relations: { - scheduleBookings: { booking: true }, - originStation: true, - destinationStation: true, - }, - order: { scheduledDepartureDate: 'DESC' }, - }); - - const withCustoms = schedules - .filter((s) => s.status !== 'CANCELLED') - .map((s) => ({ - schedule: s, - customs: (s.scheduleBookings ?? []) - .map((sb) => sb.booking) - .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)), - })) - .filter((s) => s.customs.length > 0); - - const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id)); - const gatepassRows = bookingIds.length - ? await this.dataSource.getRepository(ClearanceMilestone).find({ - where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' }, - }) - : []; - const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m])); - - return withCustoms.map(({ schedule, customs }) => { - const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))]; - return { - id: schedule.id, - trainNumber: schedule.trainNumber ?? null, - routeName: null, - origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, - destination: - schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, - status: schedule.status, - scheduledDepartureDate: schedule.scheduledDepartureDate - ? new Date(schedule.scheduledDepartureDate).toISOString() - : null, - actualDepartureAt: schedule.actualDepartureAt - ? new Date(schedule.actualDepartureAt).toISOString() - : null, - actualArrivalAt: schedule.actualArrivalAt - ? new Date(schedule.actualArrivalAt).toISOString() - : null, - freightType: - freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null, - customsBookings: customs.map((b) => { - const m = gatepassByBooking.get(b.id); - const granted = m?.status === 'COMPLETED'; - return { - bookingId: b.id, - reference: b.reference ?? b.id, - tradeDirection: b.tradeDirection ?? 'IMPORT', - contractId: b.contractId ?? null, - gatepassGranted: granted, - gatepassAt: granted - ? (m?.metadata?.gatepassAt ?? - (m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null)) - : null, - }; - }), - }; - }); - } - - /** - * One-click gate pass for every customs booking on a train schedule. Per-booking - * guard failures are collected, not fatal. Import schedules also get the - * schedule-level ImportDjiboutiOperation gate pass so loading unblocks. - */ - async grantScheduleGatepass( - scheduleId: string, - gatepassAt?: string, - userId?: string, - ): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> { - const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ - where: { id: scheduleId }, - relations: { scheduleBookings: { booking: true } }, - }); - if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); - - const customs = (schedule.scheduleBookings ?? []) - .map((sb) => sb.booking) - .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)); - if (customs.length === 0) { - throw new BadRequestException('No customs bookings ride this schedule.'); - } - - let granted = 0; - const skipped: Array<{ bookingId: string; error: string }> = []; - for (const booking of customs) { - try { - await this.grantGatepass(booking.id, gatepassAt, userId); - granted += 1; - } catch (e) { - skipped.push({ - bookingId: booking.id, - error: e instanceof Error ? e.message : 'Failed', - }); - } - } - - if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) { - const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation); - let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } }); - if (!operation) { - operation = opRepo.create({ trainScheduleId: scheduleId }); - } - if (!operation.gatepassGrantedAt) { - operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date(); - await opRepo.save(operation); - } - } - - return { granted, skipped }; - } /** * GL Djibouti raises the post-offload final invoice (export): manual amount + 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..265e19303 --- /dev/null +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts @@ -0,0 +1,75 @@ +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 + }`, + ); + } + } + + 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/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index d773ebe1f..5b8a3ddca 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -479,7 +479,7 @@ export class PaymentService { alreadyFinalized?: boolean; reason?: string; }> { - console.log(`Received payment event: ${JSON.stringify(event)}`); + this.logger.log(`Received payment event: ${JSON.stringify(event)}`); if (event.eventType === "payment.succeeded") { const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, @@ -490,13 +490,12 @@ export class PaymentService { 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( + this.logger.log( `Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`, ); 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 f30ebd501..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,6 +21,14 @@ export class CreateCargoTypeDto { @IsUUID() parentGroupId?: string; + @ApiPropertyOptional({ + description: + 'Wagon type used to carry this (bulk) cargo. Drives train scheduling wagon-type resolution; required for bulk commodities that are scheduled.', + }) + @IsOptional() + @IsUUID('4') + wagonTypeId?: string | null; + @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() 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/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index c8ac35f25..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: '' }) @@ -25,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/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 a7260f7f1..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 @@ -74,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 87d2febba..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 @@ -65,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/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index f80ada585..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 @@ -82,6 +82,7 @@ export class CargoTypesService { 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/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 0cc07dacc..f82d9696d 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 @@ -120,9 +120,17 @@ export class TrainSchedule extends BaseEntity { @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; 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 764589b73..4e1770dfb 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 @@ -5,6 +5,7 @@ import { BATCH_WINDOW_START_HOURS, listConfigBookingWindows, groupBookingsIntoBoardWindows, + computeImportWindowTimes, type BoardWindowConfig, } from './batch-window.util'; @@ -54,11 +55,145 @@ describe('batch-window.util', () => { }); }); +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('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('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('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, 3 days before departure, 3h long, reopen 90m later. + // 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, @@ -76,14 +211,43 @@ describe('batch-window board windows (config-driven booking cycles)', () => { expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z'); }); - it('import: reopens reopenDelayMinutes after close, same booking day', () => { + 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 + // 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 - // all cycles stay on the same EAT booking day - expect(windows.every((w) => w.date === '2026-06-05')).toBe(true); + expect(windows[1].date).toBe('2026-06-05'); + }); + + 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', () => { 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 b30b3f454..dfbcfcb6d 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 @@ -137,33 +137,145 @@ export function shiftEatDay(day: string, deltaDays: number): string { ).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: opens at `windowOpenHour` EAT on departure-day minus - * `importWindowLeadDays`, for `windowDurationHours`. A schedule created after its - * computed window has fully passed gets a same-day window starting now instead, - * capped at departure. + * 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); - let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour); - let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000); - if (closesAt.getTime() <= now.getTime()) { - opensAt = now; - closesAt = new Date(now.getTime() + cfg.windowDurationHours * 3_600_000); + 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; } @@ -269,7 +381,10 @@ export interface BoardWindow extends BatchWindow { 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; } @@ -328,25 +443,34 @@ export function listConfigBookingWindows( const windows: BoardWindow[] = []; 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); - let opensAt = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour); - // Reopen stays on the same EAT booking day and before departure; cap at 12 cycles. - for (let cycle = 0; cycle < 12; cycle += 1) { + 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 nextOpensAt = new Date(closesAt.getTime() + reopenMs); - if ( - nextOpensAt.getTime() >= departure.getTime() || - eatDay(nextOpensAt) !== eatDay(opensAt) - ) { - break; - } - opensAt = nextOpensAt; + const earliestNextOpen = new Date(closesAt.getTime() + reopenMs); + opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, departure); + if (opensAt == null) break; } // Degenerate config (no window before departure) — surface a single window 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 f112d16d7..4282e045a 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 @@ -82,6 +82,7 @@ describe('BookingBatchService — PAID reconcile', () => { importWindowLeadDays: 3, exportBookingLeadHours: 24, windowOpenHour: 8, + windowCloseHour: 17, windowDurationHours: 3, docReviewMinutes: 30, paymentWindowMinutes: 60, 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 46175c7ef..e6af6c4a0 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 @@ -310,7 +310,10 @@ export class BookingBatchService implements OnModuleInit { private async openRouteDayGroups(): Promise { const open = ( await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: "OPEN" }, + where: [ + { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Draft }, + { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Scheduled }, + ], }) ).filter((s) => s.windowPhase == null); const groups = new Map(); @@ -733,6 +736,7 @@ export class BookingBatchService implements OnModuleInit { }; const windowCfg = { windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour), + windowCloseHour: num(s.ruleWindowCloseHour, liveCfg.windowCloseHour), windowDurationHours: num( s.ruleWindowDurationHours, liveCfg.windowDurationHours, 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 index c694eed11..25d569171 100644 --- 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 @@ -7,8 +7,14 @@ export interface BookingWindowConfig { importWindowLeadDays: number; /** Hours before departure an export booking becomes acceptable (FCFS). */ exportBookingLeadHours: number; - /** Local (Africa/Addis_Ababa) hour at which the import window opens. */ + /** 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; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index da235c562..449c67219 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -9,9 +9,9 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { NotificationsService } from '../notifications/notifications.service'; import { BookingBatchService } from './booking-batch.service'; -import { TrainSchedulingService } from './train-scheduling.service'; +import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; import { BATCH_TIMEZONE } from './booking-batch.constants'; -import { eatDay } from './batch-window.util'; +import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util'; import { type BookingWindowConfig } from './booking-window.config'; /** @@ -54,7 +54,7 @@ export class BookingWindowService implements OnModuleInit { this.ticking = true; try { const now = new Date(); - const cfg = await this.trainSchedulingService.getWindowConfig(); + const liveCfg = await this.trainSchedulingService.getWindowConfig(); const active = ( await this.trainSchedulesRepository.findAll({ @@ -64,12 +64,22 @@ export class BookingWindowService implements OnModuleInit { ], }) ).filter( - (s) => s.windowPhase != null && s.windowPhase !== 'DONE' && s.windowPhase !== 'CLOSED_FOR_DAY', + // 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 { - await this.advanceSchedule(schedule, cfg, now); + // 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.logger.error( `Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`, @@ -100,7 +110,7 @@ export class BookingWindowService implements OnModuleInit { return schedule; } const now = new Date(); - const cfg = await this.trainSchedulingService.getWindowConfig(); + 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 = ( @@ -121,7 +131,7 @@ export class BookingWindowService implements OnModuleInit { .getRepository(TrainSchedule) .update(s.id, { docReviewCompletedAt: now }); s.docReviewCompletedAt = now; - await this.advanceSchedule(s, cfg, now); + await this.advanceSchedule(s, effectiveWindowConfig(s, liveCfg), now); } const fresh = await this.trainSchedulesRepository.findById(scheduleId); return fresh ?? schedule; @@ -185,6 +195,14 @@ export class BookingWindowService implements OnModuleInit { ): 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', @@ -268,34 +286,46 @@ export class BookingWindowService implements OnModuleInit { return; } - const closesAt = schedule.windowClosesAt ?? now; - const reopenAt = new Date(closesAt.getTime() + cfg.reopenDelayMinutes * 60_000); - const nextOpensAt = reopenAt > now ? reopenAt : now; - let nextClosesAt = new Date(nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000); + // 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( + `Schedule ${schedule.id} 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; } - - const sameBookingDay = eatDay(nextOpensAt) === eatDay(closesAt); - const beforeDeparture = nextOpensAt < schedule.scheduledDepartureDate; - if (sameBookingDay && beforeDeparture) { - await this.setPhase(schedule, { - windowPhase: 'PRE_WINDOW', - windowOpensAt: nextOpensAt, - windowClosesAt: nextClosesAt, - docReviewCompletedAt: null, - docReviewEndsAt: null, - paymentPhaseEndsAt: null, - }); - this.logger.log( - `Schedule ${schedule.id} not full — window reopens at ${nextOpensAt.toISOString()}`, - ); - } else { - await this.setPhase(schedule, { windowPhase: 'CLOSED_FOR_DAY' }); - this.logger.log( - `Booking day over for schedule ${schedule.id} — remaining capacity is staff-managed`, - ); - } + // 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( + `Schedule ${schedule.id} not full — window reopens ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`, + ); } private async tryAutoFinalize(scheduleId: string): Promise { 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..9232de29e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts @@ -0,0 +1,62 @@ +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; +} 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 2e82feb6a..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 @@ -52,7 +52,7 @@ export class UpdateTrainSchedulingGlobalRulesDto { @Min(1) exportBookingLeadHours?: number; - @ApiPropertyOptional({ example: 8, description: 'Local EAT hour the import window opens' }) + @ApiPropertyOptional({ example: 8, description: 'Local EAT hour the import window opens each day' }) @IsOptional() @Type(() => Number) @IsInt() @@ -60,6 +60,18 @@ export class UpdateTrainSchedulingGlobalRulesDto { @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 }) 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 1a67bb791..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 @@ -54,6 +54,14 @@ export class TrainSchedulingGlobalRules extends BaseEntity { @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({ 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 773e4738a..fb5ab66d5 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 @@ -42,6 +42,8 @@ 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 { BookingWindowService } from "./booking-window.service"; @@ -370,6 +372,19 @@ export class TrainSchedulingController { 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" }) @@ -438,6 +453,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" }) @@ -519,6 +547,34 @@ 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({ 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 a10847c17..22dec2a37 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 @@ -15,11 +15,12 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In, Not } from 'typeorm'; +import { DataSource, EntityManager, In, IsNull, Not } 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 { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; @@ -38,6 +39,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'; @@ -61,6 +64,8 @@ import { 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 { buildCappedWagonPlan, @@ -87,10 +92,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 { @@ -125,6 +126,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' @@ -270,18 +331,27 @@ export class TrainSchedulingService { 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 @@ -299,6 +369,156 @@ export class TrainSchedulingService { 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: + 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); + + 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()}`, + ); + + 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()})`, + ); + + 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 @@ -330,11 +550,7 @@ export class TrainSchedulingService { await repo.update(s.id, { windowOpensAt: times.windowOpensAt, windowClosesAt: times.windowClosesAt, - ruleWindowOpenHour: cfg.windowOpenHour, - ruleWindowDurationHours: cfg.windowDurationHours, - ruleReopenDelayMinutes: cfg.reopenDelayMinutes, - ruleImportWindowLeadDays: cfg.importWindowLeadDays, - ruleExportBookingLeadHours: cfg.exportBookingLeadHours, + ...windowRuleSnapshot(cfg), }); restamped += 1; } @@ -360,6 +576,7 @@ export class TrainSchedulingService { 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), @@ -504,13 +721,7 @@ export class TrainSchedulingService { // 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 = { - ruleWindowOpenHour: windowCfg.windowOpenHour, - ruleWindowDurationHours: windowCfg.windowDurationHours, - ruleReopenDelayMinutes: windowCfg.reopenDelayMinutes, - ruleImportWindowLeadDays: windowCfg.importWindowLeadDays, - ruleExportBookingLeadHours: windowCfg.exportBookingLeadHours, - }; + const ruleSnapshot = windowRuleSnapshot(windowCfg); const windowFields = direction === 'EXPORT' ? { @@ -594,11 +805,49 @@ 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, @@ -694,7 +943,7 @@ export class TrainSchedulingService { savedWagons, wagonPlan, bookings, - dto.containerPlacements ?? [], + containerPlacements ?? [], ); for (const booking of bookings) { @@ -810,32 +1059,34 @@ 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') { return { direction, @@ -958,6 +1209,50 @@ export class TrainSchedulingService { 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) { @@ -1170,12 +1465,47 @@ export class TrainSchedulingService { notes: dto.notes ?? operation.notes ?? null, }); + await this.completeGatepassMilestoneForSchedule(scheduleId, securedAt); + console.log( `[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); @@ -1205,13 +1535,41 @@ 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, + ); + } + 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); @@ -1267,7 +1625,9 @@ export class TrainSchedulingService { performedBy: 'DOCUMENT_GENERATION', }); const html = this.buildImportLoadListHtml(loadList); - const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + // Generic render — NOT the release-order fallback (would mislabel this as a + // gate-clearance / release order when Chromium is unavailable). + const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list'); const reference = loadList.trainNumber ?? loadList.trainScheduleId; return { filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`, @@ -1285,7 +1645,8 @@ export class TrainSchedulingService { } const html = this.buildExportLoadListHtml(schedule); - const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + // Generic render — NOT the release-order fallback (see importLoadListDocument). + const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list'); const reference = schedule.trainNumber ?? schedule.id; return { filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`, @@ -1585,9 +1946,9 @@ 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 { @@ -2051,7 +2412,9 @@ 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) { @@ -2725,28 +3088,98 @@ 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 picked; + 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; } private async persistTrainSetWagons( @@ -3419,15 +3852,12 @@ 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. @@ -3543,9 +3973,25 @@ export class TrainSchedulingService { 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 @@ -3578,6 +4024,8 @@ export class TrainSchedulingService { freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, direction: schedule.direction ?? 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, @@ -3593,6 +4041,22 @@ export class TrainSchedulingService { 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, + docReviewMinutes: windowCfg.docReviewMinutes, + paymentWindowMinutes: windowCfg.paymentWindowMinutes, + }, route: schedule.route ? { id: schedule.route.id, name: formatRouteLabel(schedule.route) } : null, @@ -3686,6 +4150,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), })) ?? [], }; } 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/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index ac6b71c89..c9844051b 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 @@ -920,6 +920,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_containers 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', @@ -1774,6 +1791,26 @@ 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_containers 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', @@ -2068,6 +2105,29 @@ export class WarehouseInventoryService { 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()), @@ -2147,6 +2207,48 @@ export class WarehouseInventoryService { } 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_containers 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 = row?.releaseOrderReference || @@ -2170,6 +2272,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 { @@ -3099,6 +3212,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 ?? '-') @@ -3123,12 +3241,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()} kg`, + ], ['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]] : []), ]; 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 f8c0dd355..68e630e0b 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 @@ -20,6 +20,18 @@ export class WarehouseReleaseDocumentService { }); } + /** + * 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 htmlToBasicPdfBuffer(html: string): Buffer { const doc = this.extractReleaseDocument(html); const body: string[] = [ 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/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 3466e6653..e5e3bb139 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) ────────────────── 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..708afb86b --- /dev/null +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -0,0 +1,299 @@ +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', displayOrder: 1 }, + { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', 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, + priorityBonusPoints: 0, + 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-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 72c782e11..4efc5c2d4 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -33,6 +33,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", 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 611a06691..421e913ec 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -23,6 +23,7 @@ import { Users, Wallet, } from "lucide-react"; +import { useEffect } from "react"; import { Navigate, Outlet, @@ -139,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, @@ -178,7 +179,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ title: "Operations", items: [ { - label: "Document Clearance", + label: "Clearance", href: "/dashboard/contracts/clearance", icon: , permission: [ @@ -340,7 +341,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ title: "Port & Terminal", items: [ { - label: "Import Operations", + label: "Imports", href: "/dashboard/import-warehouse", icon: , children: [ @@ -372,7 +373,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ ], }, { - label: "Export Operations", + label: "Exports", href: "/dashboard/export-warehouse", icon: , children: [ @@ -501,7 +502,8 @@ const isClearanceItem = (item: SidebarItem): boolean => /** * Keep only items the user is permitted to see; drop now-empty sections. * - * Position-scoped visibility (super_admin bypasses all of this): + * 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 @@ -511,9 +513,11 @@ const filterSidebarByPermission = ( sections: SidebarSection[], user: ReturnType["user"], ): SidebarSection[] => { - const superAdmin = isSuperAdmin(user); - const etGl = !superAdmin && isEthiopianGl(user); - const djGl = !superAdmin && isDjiboutiGl(user); + // 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; @@ -524,8 +528,6 @@ const filterSidebarByPermission = ( }; const itemAllowed = (item: SidebarItem): boolean => { - if (superAdmin) return true; - // GL positions are locked to their single clearance page. if (etGl) return isEtClearanceItem(item); if (djGl) return isDjClearanceItem(item); @@ -544,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(); @@ -569,6 +603,14 @@ const DashboardShell = () => { : 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 ; } 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/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/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx index d7cd9207b..db6e79430 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -14,7 +14,7 @@ import { Text, Textarea, } from "@mantine/core"; -import { DateInput, DateTimePicker } from "@mantine/dates"; +import { DateInput } from "@mantine/dates"; import { AlertTriangle, CheckCircle2, @@ -53,8 +53,9 @@ import { bookingsService } from "@/services/bookings.service"; /** * Export customs flow, ordered per the stakeholder process: * customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET) - * → payment + wagons → transport document (ET) → train to Djibouti → gate pass (DJ) - * → accept T1 (DJ) → final invoice (DJ) + customer slip + GL confirm. + * → payment + wagons → transport document / T1 (ET) → train to Djibouti + * → accept T1 (DJ, one button after arrival) → gate pass (DJ) + * → final invoice (DJ) + customer slip + GL confirm. */ export function computeExportActiveStep( clearance: ClearanceViewLike, @@ -77,8 +78,8 @@ export function computeExportActiveStep( } if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5; if (!clearance.train?.arrivedAt) return 6; - if (!clearance.gatepassGranted) return 7; - if (!clearance.t1Closed) return 8; + if (!clearance.t1Closed) return 7; + if (!clearance.gatepassGranted) return 8; if (clearance.finalInvoice?.status !== "PAID") return 9; return 10; } @@ -395,22 +396,9 @@ export function ExportClearanceStepper({ - : } - > - - - : } > + : } + > + + + void; -}) { - const [opened, setOpened] = useState(false); - const [at, setAt] = useState(new Date()); - const [loading, setLoading] = useState(false); +/** + * Gate pass status, read-only. Secured on the train schedule's "Save as + * Secured" action (train-scheduling-v2) — clearance no longer grants it directly. + */ +function GatepassStep({ clearance }: { clearance: ClearanceViewLike }) { + const scheduleId = clearance.train?.scheduleId ?? null; if (clearance.gatepassGranted) { return ( @@ -526,68 +514,21 @@ function GatepassStep({ done={false} pendingLabel={ arrived - ? "Train arrived — GL Djibouti can grant the gate pass." + ? "Train arrived — secure the gate pass on the train schedule." : "Available once the train arrives at Djibouti." } doneLabel="" /> - {canAct && bookingId ? ( - <> - - setOpened(false)} - title={Grant gate pass} - radius="md" - size="sm" - > - - setAt(v ? new Date(v) : null)} - required - /> - - - - - - - + {scheduleId ? ( + ) : null} ); @@ -614,6 +555,7 @@ function AcceptT1Step({ }) { const [loading, setLoading] = useState(false); const files = exportTransitFilesFromWorkflow(workflowFiles); + const arrived = Boolean(clearance.train?.arrivedAt); return ( @@ -642,9 +584,9 @@ function AcceptT1Step({ pendingLabel={ !transportIssued ? "Waiting for the transport document." - : clearance.gatepassGranted - ? "Gate pass granted — GL Djibouti accepts (closes) the T1." - : "Available after the gate pass is granted." + : arrived + ? "Train arrived — GL Djibouti accepts (closes) the T1." + : "Available once the train arrives at Djibouti." } doneLabel="" /> @@ -652,7 +594,7 @@ function AcceptT1Step({ ) : null} - - {canEtAct && !t1.closed ? ( - arrived ? ( - - - The train has arrived — review the T1 documents and close (accept) them. - - - - ) : departed ? ( - }> - Train en route — T1 can be closed once it arrives in Ethiopia. - - ) : null - ) : null} ); } -/** GL DJ grants the import gate pass once wagons are allocated (captures time). */ -function ImportGatepassStep({ - bookingId, - clearance, - canAct, +/** + * GL Ethiopia closes (accepts) the T1 set with one click once the train has + * arrived. Separate step from the GL Djibouti upload. + */ +function ImportT1CloseStep({ + t1, + t1Uploaded, + canEtAct, onChanged, }: { - bookingId: string | null; - clearance: ClearanceViewLike; - canAct: boolean; + t1: Freight.ClearanceT1State | null; + t1Uploaded: boolean; + canEtAct: boolean; onChanged?: () => void; }) { - const [opened, setOpened] = useState(false); - const [at, setAt] = useState(new Date()); - const [loading, setLoading] = useState(false); + const [closing, setClosing] = useState(false); + + if (!t1) { + return ( + + ); + } + + if (t1.closed) { + return ( + + ); + } + + if (!t1Uploaded) { + return ( + + ); + } + + if (!t1.trainArrivedAt) { + return ( + }> + {t1.trainDepartedAt + ? "Train en route — T1 can be closed once it arrives in Ethiopia." + : "T1 can be closed once the train arrives in Ethiopia."} + + ); + } + + if (!canEtAct) { + return ( + + ); + } + + return ( + + + The train has arrived — review the T1 documents and close (accept) them. + + + + ); +} + +/** + * Gate pass status, read-only. Secured on the train schedule's "Save as + * Secured" action (train-scheduling-v2) — clearance no longer grants it directly. + */ +function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) { + const scheduleId = clearance.train?.scheduleId ?? null; if (clearance.gatepassGranted) { return ( @@ -852,68 +955,21 @@ function ImportGatepassStep({ done={false} pendingLabel={ wagonAllocated - ? "Wagons allocated — GL Djibouti can grant the gate pass." + ? "Wagons allocated — secure the gate pass on the train schedule." : "Available once wagons are allocated." } doneLabel="" /> - {canAct && bookingId ? ( - <> - - setOpened(false)} - title={Grant gate pass} - radius="md" - size="sm" - > - - setAt(v ? new Date(v) : null)} - required - /> - - - - - - - + {scheduleId ? ( + ) : null} ); diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index 8a45c7cf0..8748f589e 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -5,14 +5,12 @@ import { Burger, Divider, Group, - Indicator, Menu, Text, Tooltip, UnstyledButton, } from "@mantine/core"; import { - Bell, ChevronDown, FileSignature, Languages, @@ -25,6 +23,8 @@ import { import { type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; +import NotificationBellContainer from "@/features/notifications/NotificationBellContainer"; + import type { PageMeta } from "./types"; export interface FreightDashboardHeaderProps { @@ -117,19 +117,7 @@ const FreightDashboardHeader = ({ - - - - - - - + {enableThemeToggle && ( active ? { - root: "rounded-md transition-all duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]", - label: "text-edr-primary-dark! font-medium! text-sm!", - section: "text-edr-primary-dark!", - } + root: "rounded-md transition-all py-1.5! duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]", + label: "text-edr-primary-dark! font-medium! text-sm!", + section: "text-edr-primary-dark!", + } : { - root: "rounded-md transition-all duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4", - label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!", - section: "text-edr-text!", - }; + root: "rounded-md transition-all py-1.5! duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4", + label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!", + section: "text-edr-text!", + }; const itemKey = (parentKey: string, item: SidebarItem, index: number) => `${parentKey}/${item.href ?? item.label}/${index}`; @@ -65,7 +66,9 @@ const FreightSidebar = ({ const isHrefActive = useCallback( (href: string) => { const normalized = href.toLowerCase(); - return activePath === normalized || activePath.startsWith(`${normalized}/`); + return ( + activePath === normalized || activePath.startsWith(`${normalized}/`) + ); }, [activePath], ); @@ -109,9 +112,7 @@ const FreightSidebar = ({ if (hasChildren) { const isLink = !!item.href; - const active = - (isLink ? isHrefActive(item.href!) : false) || - branchActive(item.children!); + const active = isLink ? isHrefActive(item.href!) : false; const isOpen = openMap[key] ?? false; return ( @@ -124,7 +125,7 @@ const FreightSidebar = ({ active={active} opened={isOpen} classNames={navClassNames(active)} - onClick={ () => toggle(key)} + onClick={() => toggle(key)} rightSection={ } @@ -161,8 +164,9 @@ const FreightSidebar = ({ label={item.label} leftSection={item.icon} active={active} + component={Link} classNames={navClassNames(active)} - onClick={() => onNavigate?.(item.href!)} + to={item.href!} /> ); }, @@ -178,7 +182,7 @@ const FreightSidebar = ({ tt="uppercase" px="sm" mb={6} - className={ "text-edr-muted!" } + className={"text-edr-muted!"} style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }} > {section.title} @@ -232,14 +236,24 @@ const FreightSidebar = ({ {onClose && ( - + )} {/* Nav */} - + {renderedSections} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx new file mode 100644 index 000000000..2e32dcf45 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx @@ -0,0 +1,404 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Badge, + Box, + Button, + Divider, + Group, + Loader, + Modal, + NumberInput, + Select, + Stack, + Switch, + Text, + ThemeIcon, +} from "@mantine/core"; +import { isAxiosError } from "axios"; +import { Clock, Info, Moon, Sun } from "lucide-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; + +import DurationField from "@/components/trainScheduling/DurationField"; +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; +import type { UpdateScheduleWindowRulePayload } from "@/types/trainScheduling"; + +/** Fallbacks matching the API's global-rules defaults (used when a field is null). */ +const DEFAULTS = { + windowOpenHour: 8, + windowCloseHour: 17, + windowDurationHours: 3, + docReviewMinutes: 30, + paymentWindowMinutes: 60, + importWindowLeadDays: 3, +}; + +/** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */ +function hourLabel(hour: number): string { + const period = hour < 12 ? "AM" : "PM"; + const h12 = hour % 12 === 0 ? 12 : hour % 12; + return `${h12}:00 ${period}`; +} + +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ + value: String(h), + label: `${hourLabel(h)} · ${String(h).padStart(2, "0")}:00`, +})); + +interface FormState { + windowOpenHour: number; + windowCloseHour: number; + windowDurationHours: number | ""; + docReviewMinutes: number | ""; + paymentWindowMinutes: number | ""; + importWindowLeadDays: number | ""; +} + +function parseError(error: unknown, fallback: string): string { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + } + return fallback; +} + +export interface BookingWindowSettingsModalProps { + scheduleId: string | null; + opened: boolean; + onClose: () => void; + /** Called after a successful save (e.g. to refetch a list). */ + onSaved?: () => void; +} + +/** + * Per-schedule booking-window settings editor. Prefills from the schedule's own + * rule snapshot, lets staff tune the daily desk hours / durations for just that + * train, and saves an override. Only editable before the window opens. + */ +export default function BookingWindowSettingsModal({ + scheduleId, + opened, + onClose, + onSaved, +}: BookingWindowSettingsModalProps) { + const { toast } = useToast(); + + const detailQuery = useQuery({ + ...api.trainScheduling.scheduleDetail.queryOptions({ + input: { id: scheduleId ?? "" }, + }), + enabled: opened && Boolean(scheduleId), + }); + const schedule = detailQuery.data; + + const save = useMutation( + api.trainScheduling.updateScheduleWindowRule.mutationOptions(), + ); + + const [form, setForm] = useState(null); + + // Seed the form from the schedule's snapshot once it loads (or when reopened). + useEffect(() => { + if (!opened || !schedule) return; + const r = schedule.windowRule; + setForm({ + windowOpenHour: r?.windowOpenHour ?? DEFAULTS.windowOpenHour, + windowCloseHour: r?.windowCloseHour ?? DEFAULTS.windowCloseHour, + windowDurationHours: r?.windowDurationHours ?? DEFAULTS.windowDurationHours, + docReviewMinutes: r?.docReviewMinutes ?? DEFAULTS.docReviewMinutes, + paymentWindowMinutes: + r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes, + importWindowLeadDays: + r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays, + }); + }, [opened, schedule]); + + const isExport = schedule?.direction === "EXPORT"; + const canEdit = schedule?.windowPhase === "PRE_WINDOW"; + const is24h = + form != null && form.windowOpenHour === form.windowCloseHour; + // Close < open is a valid OVERNIGHT desk (e.g. 08:00 → 07:00 next morning), + // not an error — the engine wraps it across midnight. + const isOvernight = + form != null && form.windowCloseHour < form.windowOpenHour; + + const reopenSummary = useMemo(() => { + if (!form) return ""; + const doc = Number(form.docReviewMinutes) || 0; + const pay = Number(form.paymentWindowMinutes) || 0; + const total = doc + pay; + const h = Math.floor(total / 60); + const m = total % 60; + const parts = [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean); + return parts.length ? parts.join(" ") : "0m"; + }, [form]); + + const handleSave = async () => { + if (!scheduleId || !form) return; + // Numeric fields must hold real values. + const duration = Number(form.windowDurationHours); + const doc = Number(form.docReviewMinutes); + const pay = Number(form.paymentWindowMinutes); + const lead = Number(form.importWindowLeadDays); + if ( + form.windowDurationHours === "" || + form.docReviewMinutes === "" || + form.paymentWindowMinutes === "" || + form.importWindowLeadDays === "" || + !Number.isFinite(duration) || + !Number.isFinite(doc) || + !Number.isFinite(pay) || + !Number.isFinite(lead) + ) { + toast({ + title: "Fill every field before saving", + variant: "destructive", + }); + return; + } + const payload: UpdateScheduleWindowRulePayload = { + windowOpenHour: form.windowOpenHour, + windowCloseHour: form.windowCloseHour, + windowDurationHours: duration, + docReviewMinutes: doc, + paymentWindowMinutes: pay, + importWindowLeadDays: lead, + }; + + try { + await save.mutateAsync({ id: scheduleId, payload }); + toast({ title: "Booking window settings updated" }); + onSaved?.(); + onClose(); + } catch (err) { + toast({ + title: "Update failed", + description: parseError(err, "Could not update booking window"), + variant: "destructive", + }); + } + }; + + return ( + + + + + + + Booking window settings + + + {schedule?.route?.name ?? "This schedule only"} + + + + } + > + {detailQuery.isLoading || !form ? ( + + + + ) : !canEdit ? ( + } + title="Window already open" + > + Booking window settings can only be changed before the window opens. + This schedule is currently{" "} + {String(schedule?.windowPhase ?? "not window-managed")}. + + ) : ( + + {isExport ? ( + }> + Export schedules use a single FCFS lead window — the daily desk + hours below don't apply, only the lead time does. + + ) : null} + + {/* ── Daily desk hours ─────────────────────────────────────────── */} + + + + Daily desk hours (EAT) + + {is24h ? ( + } + > + 24-hour desk + + ) : ( + } + > + {hourLabel(form.windowOpenHour)} – {hourLabel(form.windowCloseHour)} + + )} + + + + v != null && + setForm((f) => f && { ...f, windowCloseHour: Number(v) }) + } + allowDeselect={false} + comboboxProps={{ withinPortal: true }} + disabled={isExport} + /> + + {isOvernight && !is24h ? ( + + Overnight desk — opens {form.windowOpenHour}:00 and runs past + midnight, closing {form.windowCloseHour}:00 the next morning. + + ) : null} + { + const checked = e.currentTarget.checked; + setForm((f) => { + if (!f) return f; + // On → close == open (24h desk). Off → restore a normal ~9h + // day, always kept ≥ open hour so it never lands invalid. + const close = checked + ? f.windowOpenHour + : Math.min(23, f.windowOpenHour + 9); + return { ...f, windowCloseHour: close }; + }); + }} + /> + {!isExport ? ( + + A not-yet-full train pauses at the close hour and resumes the next + morning at the open hour, every day until it fills or departs. + + ) : null} + + + + + {/* ── Cycle timing ─────────────────────────────────────────────── */} + + + Cycle timing + + + + setForm((f) => f && { ...f, windowDurationHours: v }) + } + min={0.0166} + disabled={isExport} + /> + + + setForm((f) => f && { ...f, docReviewMinutes: v }) + } + min={0} + disabled={isExport} + /> + + setForm((f) => f && { ...f, paymentWindowMinutes: v }) + } + min={1} + disabled={isExport} + /> + + {!isExport ? ( + + Reopen gap after each cycle = document review + payment ={" "} + {reopenSummary}. + + ) : null} + + + + + + {/* ── Lead time ────────────────────────────────────────────────── */} + + setForm( + (f) => + f && { + ...f, + importWindowLeadDays: v === "" ? "" : Number(v), + }, + ) + } + min={0} + clampBehavior="none" + allowDecimal={false} + /> + + + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx new file mode 100644 index 000000000..6ea7dfc5b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from "react"; +import { + Alert, + Box, + Button, + Group, + Modal, + Stack, + Text, + TextInput, + ThemeIcon, +} from "@mantine/core"; +import { isAxiosError } from "axios"; +import { CalendarClock, Info } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; + +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; + +function parseError(error: unknown, fallback: string): string { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + } + return fallback; +} + +/** ISO → the `YYYY-MM-DDTHH:mm` value a datetime-local input expects (local time). */ +function toLocalInputValue(iso: string | null | undefined): string { + if (!iso) return ""; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + + `T${pad(date.getHours())}:${pad(date.getMinutes())}` + ); +} + +export interface EditScheduleDateModalProps { + scheduleId: string | null; + currentDate: string | null; + routeName?: string | null; + opened: boolean; + onClose: () => void; + /** Called after a successful save (e.g. to refetch a list). */ + onSaved?: () => void; +} + +/** + * Reschedule a train's departure date. Only shown for schedules whose booking + * window has not opened yet; the API rejects a date inside the booking lead + * window (import/intercity lead in days, export in hours). + */ +export default function EditScheduleDateModal({ + scheduleId, + currentDate, + routeName, + opened, + onClose, + onSaved, +}: EditScheduleDateModalProps) { + const { toast } = useToast(); + const save = useMutation( + api.trainScheduling.updateScheduleDate.mutationOptions(), + ); + + const [value, setValue] = useState(""); + + useEffect(() => { + if (opened) setValue(toLocalInputValue(currentDate)); + }, [opened, currentDate]); + + const handleSave = async () => { + if (!scheduleId || !value) { + toast({ title: "Pick a departure date", variant: "destructive" }); + return; + } + try { + await save.mutateAsync({ + id: scheduleId, + scheduleDate: new Date(value).toISOString(), + }); + toast({ title: "Departure date updated" }); + onSaved?.(); + onClose(); + } catch (err) { + toast({ + title: "Update failed", + description: parseError(err, "Could not update departure date"), + variant: "destructive", + }); + } + }; + + return ( + + + + + + + Edit departure date + + + {routeName ?? "This schedule only"} + + + + } + > + + }> + The date can only be changed before the booking window opens, and must + still leave room for the booking lead window before departure. + + setValue(e.currentTarget.value)} + /> + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index 2a7689582..6a6ba09d9 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -23,7 +23,8 @@ import { CheckCircle2, Inbox, PackageCheck, - Repeat, + PackageX, + // Repeat, // used by the hidden Move (reassign) button Train, Weight, X, @@ -152,6 +153,12 @@ export function ScheduleWorkspacePanel({ // ── Mutations (reuse the existing endpoints) ─────────────────────────────── const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions()); const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions()); + const setLoading = useMutation( + api.trainScheduling.setLoadingStatus.mutationOptions(), + ); + const confirmLoading = useMutation( + api.trainScheduling.confirmLoading.mutationOptions(), + ); const moveSchedule = useMutation( api.trainScheduling.moveBookingSchedule.mutationOptions(), ); @@ -237,6 +244,50 @@ export function ScheduleWorkspacePanel({ ); }; + const toggleLoaded = ( + bookingId: string, + ref: string, + next: "LOADED" | "UNLOADED", + ) => { + setLoading + .mutateAsync({ id: schedule.id, bookingIds: [bookingId], loadingStatus: next }) + .then(() => { + toast({ + title: + next === "LOADED" + ? `${ref} marked loaded` + : `${ref} marked unloaded`, + }); + onChanged(); + }) + .catch((error) => + toast({ + title: "Could not update loading status", + description: apiErrorMessage(error, "Please try again."), + variant: "destructive", + }), + ); + }; + + const doConfirmLoading = () => { + confirmLoading + .mutateAsync({ id: schedule.id }) + .then(() => { + toast({ title: "Loading confirmed", description: "The train is cleared to dispatch." }); + onChanged(); + }) + .catch((error) => + toast({ + title: "Could not confirm loading", + description: apiErrorMessage( + error, + "Grant the Djibouti gatepass first, then confirm loading.", + ), + variant: "destructive", + }), + ); + }; + const doMove = () => { if (!moveBookingId || !moveTarget) return; moveSchedule @@ -268,7 +319,7 @@ export function ScheduleWorkspacePanel({
Allocation workspace - Manually add ready-to-pay bookings, remove, or reassign them + Manually add paid, unassigned bookings, remove, or reassign them
@@ -347,17 +398,65 @@ export function ScheduleWorkspacePanel({ ) : null} + {/* Loading confirmation — required before dispatch for import-Djibouti + trains; shown for every direction so staff have one place to confirm. */} + {canManage ? ( + + + {schedule.loadingConfirmed ? ( + + ) : ( + + )} + + {schedule.loadingConfirmed + ? "Loading confirmed — cleared to dispatch" + : "Confirm loading before dispatching this train"} + + + {!schedule.loadingConfirmed ? ( + + ) : null} + + ) : null} + {/* Two-panel board */} {/* Pool */} {pool.map((b) => ( + {b.wagonAssigned ? ( + + + + ) : null} + {/* Reassign-to-another-train — hidden for now. -
-
+ setPassword(event.target.value)} + /> {error ? ( -
+ }> {error} -
+ ) : null} - - -

- Need an account?{" "} - - Contact your admin - -

-
- + + + ); const mfaForm = ( -
-
- EDR Freight -
+ +
+ EDR Freight +
-
-

+ + Multi-factor verification - </h1> - <p className="text-sm leading-relaxed text-gray-500"> + + We sent a verification code to{" "} - + {normalizedIdentifier} - + . Enter it below to complete sign in. -

-

+ + -
-
- - + + + Verification code + + setOtp(event.target.value)} - placeholder="Enter the code" - className={fieldClass} + placeholder="0" + disabled={submitting} + styles={{ input: { textAlign: "center" } }} + onChange={setOtp} /> -
+ {error ? ( -
+ }> {error} -
+ ) : null} -
- - -
-
- + Verify + + + +
); - return ( - <> - - - - -
-
- - -
- - -
- -
- -
-
-
- {!needsMfa ? loginForm : mfaForm} -
-
-
- - -
-
-
- - ); + return {!needsMfa ? loginForm : mfaForm}; }; export default LoginPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 66abbdd72..2c3e6fa9d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -44,7 +44,7 @@ export default function ContractClearanceDetailPage() { const { id } = useParams<{ id: string }>(); const { view, viewer } = useFileViewer(); - const { data: contract } = useContractDetail(id); + const { data: contract, refetch: refetchContract } = useContractDetail(id); const { data: clearance, isLoading, @@ -250,6 +250,7 @@ export default function ContractClearanceDetailPage() { phasedCustoms={phasedCustoms} onChanged={() => { void refetch(); + void refetchContract(); void refetchBookingMilestones(); }} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx index 6a451ba56..c81f5b919 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx @@ -596,21 +596,58 @@ export default function ContractRequestDetailPage() { No cargo scope lines. ) : ( - - {(contract.cargoScope ?? []).map((s) => ( - - - - {s.containerSize ?? - s.cargoFreeText ?? - s.cargoTypeId ?? - "Cargo"} - - - ))} + + {(contract.cargoScope ?? []).map((s) => { + const isContainer = Boolean(s.containerSize); + // Bulk lines carry their commodity detail (name + unit); + // container lines carry the size (20ft / 40ft). + const title = isContainer + ? `${s.containerSize} container` + : (s.cargoType?.cargoTypeName ?? + s.cargoFreeText ?? + s.cargoType?.code ?? + "Bulk cargo"); + // quantityCap unit: containers for a size line, else the + // cargo type's unit of measure (tons / items / …), default tons. + const capUnit = isContainer + ? "containers" + : (s.cargoType?.unitOfMeasure?.toLowerCase() ?? "tons"); + return ( + + +
+ + {title} + + + + {isContainer ? "Container" : "Bulk"} + + {s.cargoType?.code ? ( + + Code: {s.cargoType.code} + + ) : null} + + {s.quantityCap != null + ? `Cap: ${s.quantityCap} ${capUnit}` + : "Cap: uncapped"} + + +
+
+ ); + })}
)} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx index 3957fb7a5..0b7456851 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx @@ -1,326 +1,65 @@ -import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { - Badge, - Button, - Card, - Group, - Loader, - Modal, - Stack, - Tabs, - Text, -} from "@mantine/core"; -import { DateTimePicker } from "@mantine/dates"; -import { ChevronRight, Ship, Train, Truck } from "lucide-react"; -import { DataTable, type ColumnDef } from "@edr/ui-common"; -import type { Freight } from "@edr/types"; -import toast from "react-hot-toast"; +import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core"; +import { ChevronRight, Ship } from "lucide-react"; import { PageContainer } from "@/components/page/PageContainer"; import { PageHeader } from "@/components/page/PageHeader"; -import { - useDjClearanceQueue, - useDjClearanceSchedules, -} from "@/hooks/contracts/useContracts"; -import { contractsService } from "@/services/contracts.service"; +import { useDjClearanceQueue } from "@/hooks/contracts/useContracts"; export default function GlDjiboutiClearanceListPage() { const navigate = useNavigate(); const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue(); - const schedulesQuery = useDjClearanceSchedules(); const contractItems = contractQueue?.items ?? []; - const scheduleItems = schedulesQuery.data ?? []; - - const [gatepassTarget, setGatepassTarget] = - useState(null); - const [gatepassAt, setGatepassAt] = useState(new Date()); - const [granting, setGranting] = useState(false); - - const columns = useMemo[]>( - () => [ - { - header: "Train", - accessorKey: "trainNumber", - cell: ({ row }) => ( - - {row.original.trainNumber ?? "—"} - - ), - }, - { - header: "Route", - id: "route", - cell: ({ row }) => ( - - {row.original.origin ?? "—"} → {row.original.destination ?? "—"} - - ), - }, - { - header: "Scheduled departure", - id: "scheduled", - cell: ({ row }) => ( - - {row.original.scheduledDepartureDate - ? new Date(row.original.scheduledDepartureDate).toLocaleDateString() - : "—"} - - ), - }, - { - header: "Departed", - id: "departed", - cell: ({ row }) => ( - - {row.original.actualDepartureAt - ? new Date(row.original.actualDepartureAt).toLocaleString() - : "—"} - - ), - }, - { - header: "Arrived", - id: "arrived", - cell: ({ row }) => ( - - {row.original.actualArrivalAt - ? new Date(row.original.actualArrivalAt).toLocaleString() - : "—"} - - ), - }, - { - header: "Status", - accessorKey: "status", - cell: ({ row }) => ( - - {row.original.status} - - ), - }, - { - header: "Customs bookings", - id: "customs", - cell: ({ row }) => { - const bookings = row.original.customsBookings; - const directions = [...new Set(bookings.map((b) => b.tradeDirection))]; - return ( - - - {bookings.length} - - {directions.map((d) => ( - - {d} - - ))} - - ); - }, - }, - { - header: "Gate pass", - id: "gatepass", - cell: ({ row }) => { - const bookings = row.original.customsBookings; - const allGranted = - bookings.length > 0 && bookings.every((b) => b.gatepassGranted); - const grantedAt = bookings.find((b) => b.gatepassAt)?.gatepassAt ?? null; - if (allGranted) { - return ( - - Granted{grantedAt ? ` · ${new Date(grantedAt).toLocaleString()}` : ""} - - ); - } - return ( - - ); - }, - }, - ], - [], - ); return ( - - - Contracts ({contractItems.length}) - }> - Schedules ({scheduleItems.length}) - - - - - {contractsLoading ? ( - - - - ) : ( - - {contractItems.length === 0 ? ( - - No Djibouti customs contracts yet. - - ) : ( - contractItems.map((c) => ( - navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)} - > - - - -
- {c.reference} - - {c.tradeDirection} · {c.status} - -
-
- - - Contract - - - -
-
- )) - )} -
- )} -
- - - void schedulesQuery.refetch(), - } - : undefined - } - emptyMessage="No train schedules carry customs bookings yet." - /> - -
- - setGatepassTarget(null)} - title={ - - - - Gate pass — train {gatepassTarget?.trainNumber ?? ""} + {contractsLoading ? ( + + + + ) : ( + + {contractItems.length === 0 ? ( + + No Djibouti customs contracts yet. - - } - radius="md" - size="sm" - > - - - Grants the gate pass for all{" "} - {gatepassTarget?.customsBookings.length ?? 0} customs booking - {(gatepassTarget?.customsBookings.length ?? 0) === 1 ? "" : "s"} on this - train. - - setGatepassAt(v ? new Date(v) : null)} - required - /> - - - - + ) : ( + contractItems.map((c) => ( + navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)} + > + + + +
+ {c.reference} + + {c.tradeDirection} · {c.status} + +
+
+ + + Contract + + + +
+
+ )) + )}
-
+ )}
); } - -function statusColor(status: string): string { - switch (status) { - case "SCHEDULED": - return "blue"; - case "DISPATCHED": - return "yellow"; - case "ARRIVED": - return "edr-green"; - default: - return "gray"; - } -} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx index d44d0cffb..421885310 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -40,6 +40,7 @@ import { import { useRuleEngineList, useRuleEngineMutations, + useWagonTypeOptions, } from "@/hooks/rule-engine/useRuleEngine"; import type { RuleEngineRecord } from "@/types/rule-engine"; @@ -53,6 +54,8 @@ interface CargoNode extends RuleEngineRecord { requiresDirectorApproval?: boolean; /** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */ unitOfMeasure?: string | null; + /** Wagon type FK used to carry this bulk cargo during scheduling; null if unset. */ + wagonTypeId?: string | null; isActive?: boolean; displayOrder?: number; } @@ -78,6 +81,18 @@ const FORM_FIELDS: FormFieldDef[] = [ { label: "Per item (break-bulk)", value: "PER_ITEM" }, ], }, + { + // Wagon type that carries this (bulk) commodity — drives train-scheduling + // wagon resolution. Optional: leave "None" for grouping categories and + // container/legacy cargo; set it on scheduled bulk commodities. + // Options injected at render from useWagonTypeOptions. + name: "wagonTypeId", + label: "Wagon type", + type: "select", + optional: true, + placeholder: "Select wagon type (bulk cargo)", + options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }], + }, { name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" }, ]; @@ -104,6 +119,24 @@ const CargoTypesPage = () => { const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG); + // Wagon-type options for the "Wagon type" picker (bulk cargo → wagon FK). + const { data: wagonTypeOptions } = useWagonTypeOptions(canManage); + const formFields = useMemo( + () => + FORM_FIELDS.map((field) => + field.name === "wagonTypeId" + ? { + ...field, + options: [ + { label: "None", value: RULE_ENGINE_SELECT_NONE }, + ...(wagonTypeOptions ?? []), + ], + } + : field, + ), + [wagonTypeOptions], + ); + const [search, setSearch] = useState(""); const [formMode, setFormMode] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -349,7 +382,7 @@ const CargoTypesPage = () => { ? "Create a top-level cargo category." : "Create a cargo type inside this category. It's attached here automatically." } - fields={FORM_FIELDS} + fields={formFields} initialRecord={formMode?.kind === "edit" ? formMode.record : null} isSubmitting={create.isPending || update.isPending} onSubmit={handleSubmit} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 297d67050..253cdffcc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -33,6 +33,7 @@ import { useCargoTypeParentOptions, useContainerTypeOptions, useLiveRateOptions, + useWagonTypeOptions, useRateWorkflow, useRuleEngineList, useRuleEngineMutations, @@ -151,6 +152,9 @@ const RuleEngineResourcePage = () => { const usesLiveRateField = Boolean( config?.formFields.some((f) => f.name === "rateId"), ); + const usesWagonTypeField = Boolean( + config?.formFields.some((f) => f.name === "wagonTypeId"), + ); const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } = useCargoTypeParentOptions(editingId, config?.slug === "cargo-types"); @@ -160,6 +164,8 @@ const RuleEngineResourcePage = () => { useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField); const { data: liveRateOptions, isLoading: liveRateOptionsLoading } = useLiveRateOptions(usesLiveRateField); + const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } = + useWagonTypeOptions(usesWagonTypeField); const formFields = useMemo(() => { if (!config) return []; @@ -193,9 +199,16 @@ const RuleEngineResourcePage = () => { options: liveRateOptions ?? [], }; } + if (field.name === "wagonTypeId") { + return { + ...field, + type: "select" as const, + options: wagonTypeOptions ?? [], + }; + } return field; }); - }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions]); + }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]); const rows = data?.data ?? []; const meta = data?.meta; @@ -502,7 +515,8 @@ const RuleEngineResourcePage = () => { (config.slug === "cargo-types" && cargoParentOptionsLoading) || (usesContainerTypeField && containerTypeOptionsLoading) || (usesCargoTypeField && cargoLeafOptionsLoading) || - (usesLiveRateField && liveRateOptionsLoading) + (usesLiveRateField && liveRateOptionsLoading) || + (usesWagonTypeField && wagonTypeOptionsLoading) } positionOptions={!editing ? createPositionOptions : undefined} positionLoading={createPositionLoading} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index faa26036b..2014f9c65 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -248,6 +248,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ formFields: [ { name: "label", label: "Label", type: "text", required: true }, { name: "sizeFt", label: "Size (ft)", type: "number", required: true }, + // Options injected at render from useWagonTypeOptions (RuleEngineResourcePage). + { + name: "wagonTypeId", + label: "Wagon type", + type: "select", + required: true, + description: "Wagon type used to carry this container during train scheduling.", + }, { name: "isOpenTop", label: "Open top", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" }, ], diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 958049a63..249130e61 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -1,25 +1,28 @@ import { + Alert, Badge, Box, Button, Checkbox, Group, + List, Loader, + Modal, Paper, RingProgress, Stack, Tabs, Text, - Textarea, - TextInput, ThemeIcon, Title, } from "@mantine/core"; import { isAxiosError } from "axios"; import { + AlertTriangle, ArrowLeft, CalendarClock, CheckCircle2, + Clock, Container as ContainerIcon, Eye, FileText, @@ -45,9 +48,10 @@ import { } from "@/components/trainScheduling/containerPlacement.util"; import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; -import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; +// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; -import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel"; +import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; +// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel"; import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep"; import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel"; import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; @@ -95,10 +99,12 @@ export default function TrainScheduleV2DetailPage() { const [previewResult, setPreviewResult] = useState(null); const [containerPlacements, setContainerPlacements] = useState([]); const [maintenanceOpen, setMaintenanceOpen] = useState(false); + const [windowSettingsOpen, setWindowSettingsOpen] = useState(false); const [gatepassSecuredAt, setGatepassSecuredAt] = useState(""); const [gatepassReference, setGatepassReference] = useState(""); const [gatepassFileUrl, setGatepassFileUrl] = useState(""); const [gatepassNotes, setGatepassNotes] = useState(""); + const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false); const autoPreviewedRef = useRef(false); const detailQuery = useQuery( @@ -125,6 +131,7 @@ export default function TrainScheduleV2DetailPage() { queryFn: () => trainSchedulingService.getImportDjiboutiOperation(scheduleId as string), enabled: Boolean(scheduleId && gatepassApplies), }); + const gatepassSecured = gatepassQuery.data?.gatepassStatus === "SECURED"; const secureGatepass = useMutation({ mutationFn: () => trainSchedulingService.grantImportDjiboutiGatepass(scheduleId as string, { @@ -146,12 +153,14 @@ export default function TrainScheduleV2DetailPage() { }, }); - const importLoadingQuery = useQuery( - api.trainScheduling.importLoadingBookings.queryOptions({ - input: { id: scheduleId ?? "" }, - enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"), - }), - ); + // Superseded by the Workspace tab Load/Unload toggle — see commented + // "Import loading confirmation" card below. + // const importLoadingQuery = useQuery( + // api.trainScheduling.importLoadingBookings.queryOptions({ + // input: { id: scheduleId ?? "" }, + // enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"), + // }), + // ); const eligibleFilters = useMemo( () => @@ -358,6 +367,22 @@ export default function TrainScheduleV2DetailPage() { const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status); const canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0; const canDispatch = schedule.status === "SCHEDULED"; + + // Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose + // cargo staff never marked loaded. Both are warnings, not blockers — staff can + // still dispatch after confirming. + const dispatchBookings = schedule.bookings ?? []; + const unassignedCount = dispatchBookings.filter((b) => !b.wagonAssigned).length; + const unloadedCount = dispatchBookings.filter( + (b) => b.wagonAssigned && (b.loadingStatus ?? "UNLOADED") !== "LOADED", + ).length; + // Import-Djibouti trains are HARD-blocked from dispatch until loading is + // confirmed in the workspace — surface it as a blocker, not just a warning. + const loadingBlocksDispatch = + schedule.requiresLoadingConfirmation === true && + schedule.loadingConfirmed !== true; + const hasDispatchWarnings = unassignedCount > 0 || unloadedCount > 0; + const finalizeStep = hasContainerStep ? 3 : 2; const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status); const canPrintMarshalling = @@ -396,6 +421,24 @@ export default function TrainScheduleV2DetailPage() { } }; + const runDispatch = async () => { + setDispatchConfirmOpen(false); + try { + await dispatch.mutateAsync(scheduleId); + await openMarshallingDocument({ + title: "Train dispatched", + successDescription: "Marshalling document generated for the dispatched train.", + errorTitle: "Train dispatched, but document could not open", + }); + } catch (err) { + toast({ + title: "Dispatch failed", + description: parseError(err, "Could not dispatch"), + variant: "destructive", + }); + } + }; + const handleAssign = async () => { if (!allSelectedIds.length) return; @@ -777,22 +820,7 @@ export default function TrainScheduleV2DetailPage() { radius="md" leftSection={} loading={dispatch.isPending} - onClick={async () => { - try { - await dispatch.mutateAsync(scheduleId); - await openMarshallingDocument({ - title: "Train dispatched", - successDescription: "Marshalling document generated for the dispatched train.", - errorTitle: "Train dispatched, but document could not open", - }); - } catch (err) { - toast({ - title: "Dispatch failed", - description: parseError(err, "Could not dispatch"), - variant: "destructive", - }); - } - }} + onClick={() => setDispatchConfirmOpen(true)} > Dispatch train @@ -886,6 +914,18 @@ export default function TrainScheduleV2DetailPage() { Track train ) : null} + {schedule.windowPhase === "PRE_WINDOW" ? ( + + ) : null} {["DRAFT", "SCHEDULED"].includes(schedule.status) ? ( + ) : ( + + ) + ) : null} @@ -963,6 +1028,9 @@ export default function TrainScheduleV2DetailPage() { ]} /> + {/* Import loading confirmation — superseded by the per-booking Load/Unload + toggle in the Workspace tab (works for all directions). Kept commented + in case the import-only confirmation flow is needed again. {schedule?.direction === "IMPORT" ? ( @@ -979,83 +1047,7 @@ export default function TrainScheduleV2DetailPage() { ) : null} - - {gatepassApplies ? ( - - - - - - - - - - - Djibouti Port gate pass - - - {gatepassQuery.data?.gatepassStatus ?? "NOT_SECURED"} - - - - {schedule.direction === "IMPORT" - ? "Secure before dispatch from Djibouti." - : "Secure after dispatch before Djibouti Port entry / unloading."} - - - - {gatepassQuery.isLoading ? : null} - - - - setGatepassSecuredAt(event.currentTarget.value)} - /> - setGatepassReference(event.currentTarget.value)} - /> - setGatepassFileUrl(event.currentTarget.value)} - /> - -