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/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/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/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/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index e639f0867..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 @@ -244,9 +244,10 @@ 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. + * 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); @@ -269,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( @@ -287,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.'); } @@ -303,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, @@ -337,10 +346,9 @@ export class GlOperationsService { 'The transport document must be uploaded before T1 can be closed.', ); } - const gatepass = await this.gatepassForBooking(bookingId); - if (!gatepass.granted) { + if (!state.trainArrivedAt) { throw new BadRequestException( - 'Secure the Djibouti gate pass on the train schedule before closing T1.', + '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. 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 0b239990b..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 @@ -736,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 bf93cf391..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 @@ -64,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, @@ -124,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' @@ -269,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 @@ -298,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 @@ -329,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; } @@ -359,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), @@ -503,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' ? { @@ -593,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, @@ -693,7 +943,7 @@ export class TrainSchedulingService { savedWagons, wagonPlan, bookings, - dto.containerPlacements ?? [], + containerPlacements ?? [], ); for (const booking of bookings) { @@ -809,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, @@ -957,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) { @@ -1239,6 +1535,36 @@ 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); @@ -3647,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 @@ -3682,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, @@ -3697,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, @@ -3790,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/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-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx index b13e38961..db6e79430 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -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,17 +396,9 @@ export function ExportClearanceStepper({ - : } - > - - - : } > + : } + > + + + @@ -582,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="" /> @@ -592,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 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, +}: { + t1: Freight.ClearanceT1State | null; + t1Uploaded: boolean; + canEtAct: boolean; + onChanged?: () => void; +}) { + 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. + + ); } 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. @@ -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)} - /> - -