Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-06 10:58:57 +00:00
189 changed files with 11443 additions and 3232 deletions

View File

@@ -49,6 +49,9 @@ MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
# Preset region so signed URLs are generated locally (no GetBucketLocation
# network call per sign). MinIO's default is us-east-1.
MINIO_REGION=us-east-1
# Redis
REDIS_HOST=localhost

View File

@@ -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 <token>`. 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
`<schema>.otp_verifications` in Postgres, then signs within the 5-minute TTL.

View File

@@ -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()

View File

@@ -0,0 +1,3 @@
requests>=2.31
psycopg[binary]>=3.1
python-dotenv>=1.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Multi-truck customer (self-haul) assignment. Replaces the single
* booking.customer_truck_* fields with a per-booking list of trucks, each
* carrying 12 containers and tracking its own arrival. The legacy
* booking.customer_truck_* columns are kept as a synced booking-level flag
* (any truck assigned / all trucks arrived) so the warehouse exit-gate and
* delivery-approval logic keep working.
*/
export class AddCustomerTruckAssignments1950000000000 implements MigrationInterface {
name = 'AddCustomerTruckAssignments1950000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.customer_truck_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
plate_number varchar(32) NOT NULL,
driver_name varchar(120) NOT NULL,
truck_type varchar(60) NOT NULL,
assigned_at timestamptz NOT NULL DEFAULT now(),
arrived_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_customer_truck_assignments_booking" ON freight.customer_truck_assignments (booking_id);`,
);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.customer_truck_containers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
assignment_id uuid NOT NULL REFERENCES freight.customer_truck_assignments(id) ON DELETE CASCADE,
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
container_number varchar(64) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_customer_truck_containers_assignment" ON freight.customer_truck_containers (assignment_id);`,
);
// One container number can be loaded onto exactly one truck per booking.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_customer_truck_containers_booking_number"
ON freight.customer_truck_containers (booking_id, container_number)
WHERE deleted_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_containers;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_assignments;`);
}
}

View File

@@ -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<void> {
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<void> {
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;
`);
}
}

View File

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-container receive tracking. A booking's containers arrive individually
* (on separate self-haul trucks), so each container unit tracks whether it has
* been received into the port and, once staff confirm it, the GRN it belongs to.
* A single GRN covers the containers received together — so if the whole booking
* arrives at once, all its units share one GRN (per-booking GRN).
*/
export class AddContainerReceiptToBookingContainerUnits1960000000000
implements MigrationInterface
{
name = 'AddContainerReceiptToBookingContainerUnits1960000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_container_units
ADD COLUMN IF NOT EXISTS received_to_port boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS received_at timestamptz,
ADD COLUMN IF NOT EXISTS grn_number varchar(100)
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_booking_container_units_grn" ON freight.booking_container_units (grn_number);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_booking_container_units_grn";`);
await queryRunner.query(`
ALTER TABLE freight.booking_container_units
DROP COLUMN IF EXISTS received_to_port,
DROP COLUMN IF EXISTS received_at,
DROP COLUMN IF EXISTS grn_number
`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Import self-haul trucks are weighed on leaving. The customer does not
* pre-specify what an import truck takes — staff register the containers loaded
* and the weighed gross when the truck departs. These columns capture that.
*/
export class AddCustomerTruckDeparture1970000000000 implements MigrationInterface {
name = 'AddCustomerTruckDeparture1970000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.customer_truck_assignments
ADD COLUMN IF NOT EXISTS gross_weight_kg numeric(14, 2),
ADD COLUMN IF NOT EXISTS departed_at timestamptz
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.customer_truck_assignments
DROP COLUMN IF EXISTS gross_weight_kg,
DROP COLUMN IF EXISTS departed_at
`);
}
}

View File

@@ -2,6 +2,7 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
@@ -61,6 +62,11 @@ import {
} from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.dto';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { CustomerTruckService } from './customer-truck.service';
import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service';
import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import {
@@ -83,6 +89,8 @@ export class BookingsController {
private readonly transitionService: BookingTransitionService,
private readonly contractService: BookingContractService,
private readonly bookingClearanceService: BookingClearanceService,
private readonly customerTruckService: CustomerTruckService,
private readonly containerReceiptService: ContainerReceiptService,
) {}
@Post()
@@ -309,6 +317,94 @@ export class BookingsController {
res.send(buffer);
}
@Get(':id/customer-trucks')
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
async listCustomerTrucks(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.customerTruckService.listTrucks(id);
}
@Post(':id/customer-trucks')
@ApiOperation({ summary: 'Add a customer self-haul truck carrying 12 of the booking containers' })
async addCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AddCustomerTruckDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.customerTruckService.addTruck(id, dto);
}
@Delete(':id/customer-trucks/:assignmentId')
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
async removeCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.customerTruckService.removeTruck(id, assignmentId);
}
@Post(':id/customer-trucks/:assignmentId/depart')
@ApiOperation({
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
})
async departCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@Body() dto: DepartCustomerTruckDto,
@CurrentUser() user: TCurrentUser,
) {
// Weighing + registering the load on exit is a warehouse/gate staff action.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
throw new ForbiddenException('Only warehouse staff can register a truck departure');
}
return this.customerTruckService.departTruck(id, assignmentId, dto);
}
@Get(':id/received-pending-grn')
@ApiOperation({ summary: 'Containers received into port but not yet on a GRN' })
async receivedPendingGrn(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
// GRN is a warehouse-staff action — no customer access.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
throw new ForbiddenException('Only warehouse staff can view or generate GRNs');
}
return this.containerReceiptService.listReceivedPendingGrn(id);
}
@Post(':id/generate-grn')
@ApiOperation({
summary:
'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch',
})
async generateGrn(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: GenerateGrnDto,
@CurrentUser() user: TCurrentUser,
) {
// GRN is a warehouse-staff action — no customer access.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
throw new ForbiddenException('Only warehouse staff can view or generate GRNs');
}
return this.containerReceiptService.generateGrn(id, dto.containerNumbers);
}
@Get(':id/tracking')
@ApiOperation({
summary: "Shipment tracking timeline for a booking",

View File

@@ -33,6 +33,11 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
import { CustomerTruckService } from './customer-truck.service';
import { ContainerReceiptService } from './container-receipt.service';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractsModule } from '../contracts/contracts.module';
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
@@ -55,6 +60,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingReviewNote,
BookingContractSignature,
BookingContainerAllocation,
CustomerTruckAssignment,
CustomerTruckContainer,
]),
BillingModule,
forwardRef(() => FirstMileModule),
@@ -91,12 +98,17 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ContractPricingScheduleBuilder,
ContractRendererService,
ContractPdfService,
CustomerTruckAssignmentsRepository,
CustomerTruckService,
ContainerReceiptService,
],
exports: [
BookingsService,
BookingsRepository,
BookingPricingService,
BookingInvoiceService,
CustomerTruckService,
ContainerReceiptService,
],
})
export class BookingsModule { }

View File

@@ -62,16 +62,23 @@ export class BookingsRepository extends BaseRepository<Booking> {
return this.repository.findOne({ where: { reference } });
}
/** Count bookings created in a specific year. */
async countByYear(year: number): Promise<number> {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year + 1, 0, 1);
return this.repository
/**
* Highest NNNNNN sequence already issued for `BK-<year>-…` references.
* Includes soft-deleted bookings so the next number clears references that
* still occupy the unique index. (A created-at count drifts below the issued
* sequence after any delete and then collides forever.)
*/
async maxReferenceSequence(year: number): Promise<number> {
const row = await this.repository
.createQueryBuilder('booking')
.where('booking.created_at >= :startDate', { startDate })
.andWhere('booking.created_at < :endDate', { endDate })
.getCount();
.withDeleted()
.select(
"COALESCE(MAX(CAST(SUBSTRING(booking.reference FROM '[0-9]+$') AS int)), 0)",
'max',
)
.where('booking.reference LIKE :prefix', { prefix: `BK-${year}-%` })
.getRawOne<{ max: string | number | null }>();
return Number(row?.max ?? 0);
}
/** Find a booking by reference with files and relations. */

View File

@@ -9,6 +9,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { Freight, SchedulingStatus } from '@edr/types';
import { insertWithGeneratedReference } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
@@ -145,7 +146,28 @@ export class BookingsService {
throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated');
}
const html = this.buildCustomerTruckFreightOrderHtml(booking);
const trucks: Array<{
plateNumber: string;
driverName: string;
truckType: string;
arrivedAt: string | null;
containers: string | null;
}> = await this.dataSource.query(
`SELECT a.plate_number AS "plateNumber",
a.driver_name AS "driverName",
a.truck_type AS "truckType",
a.arrived_at AS "arrivedAt",
string_agg(c.container_number, ', ' ORDER BY c.container_number) AS "containers"
FROM freight.customer_truck_assignments a
LEFT JOIN freight.customer_truck_containers c
ON c.assignment_id = a.id AND c.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.arrived_at, a.assigned_at
ORDER BY a.assigned_at`,
[bookingId],
);
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
return {
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
@@ -186,41 +208,89 @@ export class BookingsService {
/** Generate a unique booking reference number. */
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.bookingsRepository.countByYear(year);
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
const seq = await this.bookingsRepository.maxReferenceSequence(year);
return `BK-${year}-${String(seq + 1).padStart(6, '0')}`;
}
private buildCustomerTruckFreightOrderHtml(booking: Booking): string {
private buildCustomerTruckFreightOrderHtml(
booking: Booking,
trucks: Array<{
plateNumber: string;
driverName: string;
truckType: string;
arrivedAt: string | null;
containers: string | null;
}>,
): string {
const assignedAt = booking.customerTruckAssignedAt
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
: '-';
const rows: Array<[string, string | null | undefined]> = [
const bookingRows: Array<[string, string | null | undefined]> = [
['Booking Reference', booking.reference],
['Client Name', booking.company?.name],
['Client ID', booking.companyId],
['Trade Direction', booking.tradeDirection],
['Freight Type', booking.freightType],
['Truck Plate Number', booking.customerTruckPlateNumber],
['Driver Name', booking.customerTruckDriverName],
['Truck Type', booking.customerTruckType],
['Container Number to Load', booking.customerTruckContainerNumber],
['Assigned At', assignedAt],
['Booking Status', booking.status],
];
const rowHtml = rows
const bookingRowHtml = bookingRows
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
.join('');
// Fall back to the legacy single-truck booking columns when there are no
// multi-truck rows (bookings assigned before the multi-truck feature).
const truckList =
trucks.length > 0
? trucks
: booking.customerTruckPlateNumber
? [
{
plateNumber: booking.customerTruckPlateNumber,
driverName: booking.customerTruckDriverName ?? '',
truckType: booking.customerTruckType ?? '',
arrivedAt: booking.customerTruckArrivedAt
? String(booking.customerTruckArrivedAt)
: null,
containers: booking.customerTruckContainerNumber ?? null,
},
]
: [];
const truckBlocks = truckList
.map((t, i) => {
const rows: Array<[string, string | null | undefined]> = [
['Truck Plate Number', t.plateNumber],
['Driver Name', t.driverName],
['Truck Type', t.truckType],
['Containers Loaded', t.containers],
[
'Arrival',
t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival',
],
];
const html = rows
.map(
([label, value]) =>
`<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`,
)
.join('');
return `<div class="truck"><h2>Truck ${i + 1}</h2><table>${html}</table></div>`;
})
.join('');
const copy = (watermark: string) => `
<section class="copy">
<div class="watermark">${this.escapeHtml(watermark)}</div>
<header>
<div>
<h1>Freight Order</h1>
<p>Customer external truck assignment</p>
<p>Customer external truck assignment${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</p>
</div>
<strong>${this.escapeHtml(booking.reference)}</strong>
</header>
<table>${rowHtml}</table>
<table>${bookingRowHtml}</table>
${truckBlocks}
<div class="signatures">
<div>Customer / Carrier Signature</div>
<div>Port Operations Verification</div>
@@ -238,11 +308,13 @@ export class BookingsService {
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
h2 { margin: 18px 0 8px; font-size: 14px; color: #0a6f4d; }
p { margin: 4px 0 0; color: #64748b; }
strong { font-size: 16px; color: #0a9f6a; }
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; margin-bottom: 6px; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
th { width: 34%; background: #f1f5f9; }
.truck { page-break-inside: avoid; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
</style>
@@ -522,7 +594,6 @@ export class BookingsService {
}
}
const reference = dto.reference || (await this.generateReference());
const containers = dto.containers ?? [];
assertFreightShape({
freightType: dto.freightType,
@@ -617,7 +688,10 @@ export class BookingsService {
// the customer clears it themselves and may name their broker.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
const booking = await this.bookingsRepository.create({
// Explicit reference is caller-chosen (a collision is a real conflict);
// auto-generated references retry past a concurrent same-sequence insert.
const insertBooking = (reference: string) =>
this.bookingsRepository.create({
reference,
companyId,
companyProfileId,
@@ -672,7 +746,14 @@ export class BookingsService {
priorityScore: ruleResult.priorityScore,
totalAmount: 0,
paymentStatus: 'PENDING',
});
});
const booking = dto.reference
? await insertBooking(dto.reference)
: await insertWithGeneratedReference(
() => this.generateReference(),
insertBooking,
);
if (dto.freightType === 'CONTAINER') {
await this.bookingsRepository.createContainers(

View File

@@ -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();
});
});

View File

@@ -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). */

View File

@@ -0,0 +1,145 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
export interface ReceivedUnitRow {
id: string;
containerNumber: string;
receivedToPort: boolean;
receivedAt: string | null;
grnNumber: string | null;
}
/**
* Per-container receive + GRN tracking on booking_container_units.
*
* Containers arrive individually (on separate self-haul trucks), so each unit is
* flipped `received_to_port` when its truck arrives (auto). Staff then confirm a
* Goods Received Note over the received-but-un-GRN'd containers: one GRN covers a
* batch, so if the whole booking arrives together every unit shares a single GRN
* (per-booking GRN); if trucks arrive separately each batch gets its own GRN.
*/
@Injectable()
export class ContainerReceiptService {
constructor(private readonly dataSource: DataSource) {}
/**
* Auto-mark the containers loaded on an arrived truck as received into the
* port. Idempotent — only flips units not already received. Runs inside the
* caller's transaction when a manager is supplied.
*/
async markReceivedForAssignment(
bookingId: string,
assignmentId: string,
manager?: EntityManager,
): Promise<void> {
const m = manager ?? this.dataSource.manager;
await m.query(
`UPDATE freight.booking_container_units bcu
SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW()
FROM freight.booking_containers bc,
freight.customer_truck_containers ctc
WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1
AND ctc.assignment_id = $2
AND ctc.deleted_at IS NULL
AND ctc.container_number = bcu.container_number
AND bcu.deleted_at IS NULL
AND bcu.received_to_port = false`,
[bookingId, assignmentId],
);
}
/** Received-into-port containers that have not yet been assigned a GRN. */
async listReceivedPendingGrn(bookingId: string): Promise<ReceivedUnitRow[]> {
return this.dataSource.query(
`SELECT bcu.id,
bcu.container_number AS "containerNumber",
bcu.received_to_port AS "receivedToPort",
bcu.received_at AS "receivedAt",
bcu.grn_number AS "grnNumber"
FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND bcu.deleted_at IS NULL
AND bcu.received_to_port = true
AND bcu.grn_number IS NULL
ORDER BY bcu.received_at`,
[bookingId],
);
}
/**
* Confirm a GRN over the currently received-but-un-GRN'd containers (optionally
* a subset by container number). Assigns one GRN number to the whole batch and
* returns it with the covered containers. If the batch covers every container
* on the booking it is effectively a per-booking GRN.
*/
async generateGrn(
bookingId: string,
containerNumbers?: string[],
): Promise<{ grnNumber: string; containerNumbers: string[]; perBooking: boolean }> {
const [booking] = await this.dataSource.query(
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
return this.dataSource.transaction(async (manager) => {
const wanted = containerNumbers?.map((n) => n.trim().toUpperCase());
const pending: ReceivedUnitRow[] = await manager.query(
`SELECT bcu.id, bcu.container_number AS "containerNumber"
FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND bcu.deleted_at IS NULL
AND bcu.received_to_port = true
AND bcu.grn_number IS NULL
${wanted ? 'AND bcu.container_number = ANY($2::varchar[])' : ''}`,
wanted ? [bookingId, wanted] : [bookingId],
);
if (!pending.length) {
throw new BadRequestException('No received containers are awaiting a GRN');
}
// Batch sequence = number of GRNs already issued for this booking + 1.
const [{ batches }]: Array<{ batches: string }> = await manager.query(
`SELECT COUNT(DISTINCT bcu.grn_number) AS batches
FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`,
[bookingId],
);
const seq = Number(batches) + 1;
const grnNumber = `GRN-${String(booking.reference).replace(/^BK-?/i, '')}-${String(seq).padStart(2, '0')}`;
const ids = pending.map((p) => p.id);
await manager.query(
`UPDATE freight.booking_container_units
SET grn_number = $1, updated_at = NOW()
WHERE id = ANY($2::uuid[])`,
[grnNumber, ids],
);
// Per-booking when no container on the booking is left un-GRN'd.
const [{ remaining }]: Array<{ remaining: string }> = await manager.query(
`SELECT COUNT(*) AS remaining
FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`,
[bookingId],
);
return {
grnNumber,
containerNumbers: pending.map((p) => p.containerNumber),
perBooking: Number(remaining) === 0 && seq === 1,
};
});
}
}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
@Injectable()
export class CustomerTruckAssignmentsRepository extends BaseRepository<CustomerTruckAssignment> {
constructor(
@InjectRepository(CustomerTruckAssignment)
private readonly repo: Repository<CustomerTruckAssignment>,
) {
super(repo);
}
/** All trucks assigned to a booking, oldest first, with their containers. */
findByBookingId(bookingId: string): Promise<CustomerTruckAssignment[]> {
return this.repo.find({
where: { bookingId },
relations: { containers: true },
order: { assignedAt: 'ASC' },
});
}
findByIdWithContainers(id: string): Promise<CustomerTruckAssignment | null> {
return this.repo.findOne({ where: { id }, relations: { containers: true } });
}
}

View File

@@ -0,0 +1,321 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, IsNull } from 'typeorm';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
interface BookingGuardRow {
tradeDirection: string | null;
firstMile: string | null;
lastMile: string | null;
paymentStatus: string | null;
status: string | null;
}
/**
* Multi-truck self-haul assignment. A booking with no EDR first/last-mile leg
* can have several customer trucks, each carrying 12 of its containers and
* tracking its own arrival. The legacy booking.customer_truck_* columns are kept
* as a booking-level flag (any truck assigned / all arrived) so the warehouse
* exit-gate + delivery-approval logic keep working unchanged.
*/
@Injectable()
export class CustomerTruckService {
constructor(
private readonly dataSource: DataSource,
private readonly assignments: CustomerTruckAssignmentsRepository,
) {}
listTrucks(bookingId: string): Promise<CustomerTruckAssignment[]> {
return this.assignments.findByBookingId(bookingId);
}
async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise<CustomerTruckAssignment[]> {
const booking = await this.loadBookingGuard(bookingId);
this.assertSelfHaulPaid(booking);
const isExport = booking.tradeDirection === 'EXPORT';
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
// EXPORT: the truck delivers 12 known containers. IMPORT: containers are
// not pre-specified — they are registered + weighed when the truck leaves.
if (isExport) {
if (requested.length < 1 || requested.length > 2) {
throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers');
}
} else if (requested.length > 2) {
throw new BadRequestException('A truck carries at most 2 containers');
}
if (requested.length) {
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
}
const alreadyAssigned = await this.assignedContainerNumbers(bookingId);
for (const n of requested) {
if (alreadyAssigned.includes(n)) {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
}
await this.dataSource.transaction(async (manager) => {
const assignment = await manager.getRepository(CustomerTruckAssignment).save(
manager.getRepository(CustomerTruckAssignment).create({
bookingId,
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
driverName: dto.driverName.trim(),
truckType: dto.truckType.trim(),
}),
);
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId: assignment.id,
bookingId,
containerNumber,
}),
),
);
// Booking-level flag: first truck marks the booking as truck-assigned.
await manager.query(
`UPDATE freight.bookings
SET customer_truck_assigned_at = COALESCE(customer_truck_assigned_at, NOW()),
status = CASE WHEN status = 'PAID' THEN 'TRUCK_ASSIGNED' ELSE status END,
updated_at = NOW()
WHERE id = $1`,
[bookingId],
);
});
return this.listTrucks(bookingId);
}
async removeTruck(bookingId: string, assignmentId: string): Promise<CustomerTruckAssignment[]> {
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
if (!assignment || assignment.bookingId !== bookingId) {
throw new NotFoundException('Truck assignment not found for this booking');
}
if (assignment.arrivedAt) {
throw new ConflictException('Cannot remove a truck that has already arrived');
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckAssignment).softDelete(assignmentId);
const remaining = await manager
.getRepository(CustomerTruckAssignment)
.count({ where: { bookingId } });
if (remaining === 0) {
// No trucks left — clear the booking-level flag and revert the status.
await manager.query(
`UPDATE freight.bookings
SET customer_truck_assigned_at = NULL,
status = CASE WHEN status = 'TRUCK_ASSIGNED' THEN 'PAID' ELSE status END,
updated_at = NOW()
WHERE id = $1`,
[bookingId],
);
}
});
return this.listTrucks(bookingId);
}
/**
* Register an IMPORT self-haul truck leaving the port: the containers it
* actually loaded (replacing any provisional list) and its weighed gross.
* Export bookings have no truck departure — trucks only deliver (receive).
*/
async departTruck(
bookingId: string,
assignmentId: string,
dto: DepartCustomerTruckDto,
): Promise<CustomerTruckAssignment[]> {
const booking = await this.loadBookingGuard(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException(
'Truck departure/weighing applies to import self-haul only (export trucks only deliver)',
);
}
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
if (!assignment || assignment.bookingId !== bookingId) {
throw new NotFoundException('Truck assignment not found for this booking');
}
// Once filled, the departure record is uneditable.
if (assignment.departedAt) {
throw new ConflictException('This truck has already departed — its exit record is locked');
}
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (requested.length) {
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
}
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
for (const n of requested) {
if (elsewhere.includes(n)) {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
}
await this.dataSource.transaction(async (manager) => {
if (requested.length) {
// Replace the truck's containers with what was actually loaded.
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId,
bookingId,
containerNumber,
}),
),
);
}
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
grossWeightKg: dto.grossWeightKg,
departedAt: dto.gateOutTime ? new Date(dto.gateOutTime) : new Date(),
arrivedAt: assignment.arrivedAt ?? new Date(),
});
});
return this.listTrucks(bookingId);
}
/**
* Mark the truck carrying `containerNumber` as arrived. Called by the warehouse
* receive flow. When every truck on the booking has arrived, the booking-level
* customer_truck_arrived_at flag is stamped (used by the delivery-approval
* gate). No-op when the container is not on any customer truck.
*/
async markArrivedByContainer(
bookingId: string,
containerNumber: string,
manager?: EntityManager,
): Promise<void> {
const m = manager ?? this.dataSource.manager;
const cn = containerNumber.trim().toUpperCase();
const container = await m.getRepository(CustomerTruckContainer).findOne({
where: { bookingId, containerNumber: cn },
});
if (!container) return;
await m
.getRepository(CustomerTruckAssignment)
.update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
await this.syncBookingArrival(bookingId, m);
}
/** Mark every truck on the booking arrived (fallback when no container is known). */
async markAllArrived(bookingId: string, manager?: EntityManager): Promise<void> {
const m = manager ?? this.dataSource.manager;
await m
.getRepository(CustomerTruckAssignment)
.update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
await this.syncBookingArrival(bookingId, m);
}
/**
* Stamp the booking-level arrival flag on the FIRST truck arrival. The import
* handover is signed once, before the first truck leaves, even though trucks
* pick up per-container — so the flag fires on the first arrival (COALESCE
* keeps it), not once all trucks have arrived.
*/
private async syncBookingArrival(bookingId: string, m: EntityManager): Promise<void> {
await m.query(
`UPDATE freight.bookings
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
updated_at = NOW()
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL`,
[bookingId],
);
}
private async loadBookingGuard(bookingId: string): Promise<BookingGuardRow> {
const [row]: BookingGuardRow[] = await this.dataSource.query(
`SELECT trade_direction AS "tradeDirection",
first_mile_pickup_address AS "firstMile",
last_mile_delivery_address AS "lastMile",
payment_status AS "paymentStatus",
status
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!row) throw new NotFoundException(`Booking ${bookingId} not found`);
return row;
}
private assertSelfHaulPaid(booking: BookingGuardRow): void {
const hasFirstMile = Boolean(booking.firstMile?.trim());
const hasLastMile = Boolean(booking.lastMile?.trim());
const usesMileService =
booking.tradeDirection === 'IMPORT'
? hasLastMile
: booking.tradeDirection === 'EXPORT'
? hasFirstMile
: hasFirstMile || hasLastMile;
if (usesMileService) {
throw new BadRequestException(
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
);
}
if (booking.paymentStatus !== 'PAID') {
throw new BadRequestException(
'Booking must be paid before assigning an external customer truck',
);
}
}
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber"
FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
[bookingId],
);
return rows.map((r) => r.containerNumber.trim().toUpperCase());
}
private async assignedContainerNumbers(bookingId: string): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT container_number AS "containerNumber"
FROM freight.customer_truck_containers
WHERE booking_id = $1 AND deleted_at IS NULL`,
[bookingId],
);
return rows.map((r) => r.containerNumber.trim().toUpperCase());
}
private async assignedContainerNumbersExcept(
bookingId: string,
exceptAssignmentId: string,
): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT container_number AS "containerNumber"
FROM freight.customer_truck_containers
WHERE booking_id = $1 AND assignment_id <> $2 AND deleted_at IS NULL`,
[bookingId, exceptAssignmentId],
);
return rows.map((r) => r.containerNumber.trim().toUpperCase());
}
}

View File

@@ -0,0 +1,47 @@
import {
ArrayMaxSize,
ArrayUnique,
IsArray,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
/**
* Add one external customer truck to a booking.
* - EXPORT: the truck delivers 12 known containers (required, validated in the
* service against the booking's containers).
* - IMPORT: the customer does not pre-specify — containers are registered and
* weighed when the truck leaves, so `containerNumbers` may be omitted/empty.
*/
export class AddCustomerTruckDto {
@IsString()
@IsNotEmpty()
@MaxLength(32)
truckPlateNumber!: string;
@IsString()
@IsNotEmpty()
@MaxLength(120)
driverName!: string;
@IsString()
@IsNotEmpty()
@IsIn(CUSTOMER_TRUCK_TYPES)
truckType!: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})
containerNumbers?: string[];
}

View File

@@ -0,0 +1,37 @@
import {
ArrayMaxSize,
ArrayUnique,
IsArray,
IsDateString,
IsNumber,
IsOptional,
Matches,
Min,
} from 'class-validator';
/**
* Register an import self-haul truck leaving the port: the containers it actually
* loaded (staff read them off the truck) and the weighed gross. Container numbers
* are optional here only because they may already have been recorded; the weighed
* gross is required.
*/
export class DepartCustomerTruckDto {
@IsOptional()
@IsArray()
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})
containerNumbers?: string[];
@IsNumber()
@Min(0)
grossWeightKg!: number;
/** Gate-out time. Defaults to now when omitted. */
@IsOptional()
@IsDateString()
gateOutTime?: string;
}

View File

@@ -0,0 +1,17 @@
import { ArrayUnique, IsArray, IsOptional, Matches } from 'class-validator';
/**
* Confirm a Goods Received Note. Omit `containerNumbers` to GRN every
* received-but-un-GRN'd container on the booking (per-booking when that's all of
* them); pass a subset to GRN just those.
*/
export class GenerateGrnDto {
@IsOptional()
@IsArray()
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})
containerNumbers?: string[];
}

View File

@@ -34,4 +34,17 @@ export class BookingContainerUnit extends BaseEntity {
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
sortOrder!: number;
/** Whether this container has been received into the port (auto-set when its
* self-haul truck arrives). */
@Column({ name: 'received_to_port', type: 'boolean', default: false })
receivedToPort!: boolean;
@Column({ name: 'received_at', type: 'timestamptz', nullable: true })
receivedAt?: Date | null;
/** The GRN this container was received under (assigned when staff confirm the
* Goods Received Note for a batch of received containers). */
@Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true })
grnNumber?: string | null;
}

View File

@@ -0,0 +1,47 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Booking } from './booking.entity';
import { CustomerTruckContainer } from './customer-truck-container.entity';
/**
* One external (self-haul) truck a customer assigns to a booking that has no
* EDR first/last-mile leg. Each truck carries 12 containers and tracks its own
* arrival at the terminal/warehouse.
*/
@Entity({ schema: 'freight', name: 'customer_truck_assignments' })
@Index(['bookingId'])
export class CustomerTruckAssignment extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'plate_number', type: 'varchar', length: 32 })
plateNumber!: string;
@Column({ name: 'driver_name', type: 'varchar', length: 120 })
driverName!: string;
@Column({ name: 'truck_type', type: 'varchar', length: 60 })
truckType!: string;
@Column({ name: 'assigned_at', type: 'timestamptz', default: () => 'now()' })
assignedAt!: Date;
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
arrivedAt?: Date | null;
/** Weighed gross of what the truck actually loaded (import), captured on
* leaving. Null until the truck departs. */
@Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true })
grossWeightKg?: number | null;
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
departedAt?: Date | null;
@OneToMany(() => CustomerTruckContainer, (c) => c.assignment, { cascade: true })
containers?: CustomerTruckContainer[];
}

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { CustomerTruckAssignment } from './customer-truck-assignment.entity';
/**
* A container number loaded onto a customer truck. A container may be loaded
* onto exactly one truck per booking (enforced by a partial unique index on
* booking_id + container_number).
*/
@Entity({ schema: 'freight', name: 'customer_truck_containers' })
@Index(['assignmentId'])
export class CustomerTruckContainer extends BaseEntity {
@Column({ name: 'assignment_id', type: 'uuid' })
assignmentId!: string;
@ManyToOne(() => CustomerTruckAssignment, (a) => a.containers, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'assignment_id' })
assignment?: CustomerTruckAssignment;
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@Column({ name: 'container_number', type: 'varchar', length: 64 })
containerNumber!: string;
}

View File

@@ -48,8 +48,22 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
});
}
/** Total rows — used to mint the next sequential reference. */
async count(): Promise<number> {
return this.repository.count();
/**
* Highest NNNNNN sequence already issued for `SR-…` references (all-time —
* these are not year-scoped). Includes soft-deleted rows so a cancel/delete
* can't make the next number reuse an earlier one. A plain row count drifts
* below the issued sequence after any delete and hands out duplicates.
*/
async maxReferenceSequence(): Promise<number> {
const row = await this.repository
.createQueryBuilder('request')
.withDeleted()
.select(
"COALESCE(MAX(CAST(SUBSTRING(request.reference FROM '[0-9]+$') AS int)), 0)",
'max',
)
.where('request.reference LIKE :prefix', { prefix: 'SR-%' })
.getRawOne<{ max: string | number | null }>();
return Number(row?.max ?? 0);
}
}

View File

@@ -192,8 +192,7 @@ export class BookingRequestService {
}
private async generateReference(): Promise<string> {
const count = await this.repo.count();
const seq = String(count + 1).padStart(6, '0');
return `SR-${seq}`;
const seq = await this.repo.maxReferenceSequence();
return `SR-${String(seq + 1).padStart(6, '0')}`;
}
}

View File

@@ -8,6 +8,7 @@ import {
forwardRef,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
@@ -110,7 +111,6 @@ export class ContractBookingService {
const route = await this.resolveRoute(contract, dto.contractRouteId);
const warnings: string[] = [];
const reference = await this.generateReference();
const freightType = contract.freightType;
// GENERAL + customs (Path B) runs per-booking clearance: the booking starts
@@ -146,7 +146,11 @@ export class ContractBookingService {
}
// Denormalize route/direction/freight onto the booking for the scheduling engine.
const booking = await this.bookingsRepository.create({
// Retry past a concurrent insert that grabbed the same BK sequence number.
const booking = await insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
this.bookingsRepository.create({
reference,
companyId: contract.companyId ?? null,
companyProfileId: contract.companyProfileId ?? null,
@@ -180,7 +184,8 @@ export class ContractBookingService {
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
} as never);
} as never),
);
// Persist container lines + per-unit container numbers (container freight only).
if (freightType === 'CONTAINER') {
@@ -825,8 +830,7 @@ export class ContractBookingService {
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.bookingsRepository.countByYear(year);
const seq = String(count + 1).padStart(6, '0');
return `BK-${year}-${seq}`;
const seq = await this.bookingsRepository.maxReferenceSequence(year);
return `BK-${year}-${String(seq + 1).padStart(6, '0')}`;
}
}

View File

@@ -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. */

View File

@@ -5,6 +5,7 @@ import {
Logger,
} from '@nestjs/common';
import { Readable } from 'stream';
import { insertWithGeneratedReference } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
@@ -611,8 +612,11 @@ export class ContractTransitionService {
async renew(contractId: string, userId?: string): Promise<Contract> {
const source = await this.contractsService.findById(contractId);
const reference = await this.generateRenewalReference();
const renewal = await this.contractsRepository.create({
// Retry past a concurrent insert that grabbed the same CTR sequence number.
const renewal = await insertWithGeneratedReference(
() => this.generateRenewalReference(),
(reference) =>
this.contractsRepository.create({
reference,
companyId: source.companyId,
companyProfileId: source.companyProfileId,
@@ -640,7 +644,8 @@ export class ContractTransitionService {
status: 'RENEWAL_DRAFT',
clearanceStatus: 'NOT_APPLICABLE',
clearanceCycleNumber: 0,
} as never);
} as never),
);
void userId;
return this.contractsService.findById(renewal.id);
@@ -648,7 +653,7 @@ export class ContractTransitionService {
private async generateRenewalReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.contractsRepository.countByYear(year);
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
const seq = await this.contractsRepository.maxReferenceSequence(year);
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
}
}

View File

@@ -45,16 +45,23 @@ export class ContractsRepository extends BaseRepository<Contract> {
return this.repository.findOne({ where: { reference } });
}
/** Count contracts created in a specific year. */
async countByYear(year: number): Promise<number> {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year + 1, 0, 1);
return this.repository
/**
* Highest NNNNN sequence already issued for `CTR-<year>-…` references.
* Includes soft-deleted contracts — their references still occupy the unique
* index, so the next number must move past them. (A created-at count drifts
* below the issued sequence after any delete and then collides forever.)
*/
async maxReferenceSequence(year: number): Promise<number> {
const row = await this.repository
.createQueryBuilder('contract')
.where('contract.created_at >= :startDate', { startDate })
.andWhere('contract.created_at < :endDate', { endDate })
.getCount();
.withDeleted()
.select(
"COALESCE(MAX(CAST(SUBSTRING(contract.reference FROM '[0-9]+$') AS int)), 0)",
'max',
)
.where('contract.reference LIKE :prefix', { prefix: `CTR-${year}-%` })
.getRawOne<{ max: string | number | null }>();
return Number(row?.max ?? 0);
}
/** Find a contract by ID with all child collections, service type, company and files. */

View File

@@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';
import { CompaniesService } from '../companies/companies.service';
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
@@ -57,8 +58,8 @@ export class ContractsService {
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.contractsRepository.countByYear(year);
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
const seq = await this.contractsRepository.maxReferenceSequence(year);
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
}
/** Whether a service type bundles customs clearance. */
@@ -144,8 +145,6 @@ export class ContractsService {
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
this.assertRouteShape(dto.contractKind, dto.routes);
const reference = dto.reference || (await this.generateReference());
// Stamp the operational profile (importer/exporter) for portal scoping.
let companyProfileId: string | null = null;
if (!isGovernment && companyId) {
@@ -177,7 +176,61 @@ export class ContractsService {
// Customs clearing is owned by the service type, not the customer.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
const contract = await this.contractsRepository.create({
// An explicit reference is caller-chosen — a collision there is a real
// conflict and should surface. Auto-generated references retry past a
// concurrent insert that grabbed the same sequence number.
const contract = dto.reference
? await this.insertContract(dto.reference, {
companyId,
companyProfileId,
isGovernment,
includesCustoms,
dto,
})
: await insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
this.insertContract(reference, {
companyId,
companyProfileId,
isGovernment,
includesCustoms,
dto,
}),
);
await this.persistRoutes(contract.id, dto.routes);
await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind);
if (files.length > 0) {
try {
await this.filesService.uploadMany(contract.id, 'contracts', files);
} catch {
warnings.push('File upload failed — contract was created without attached files.');
}
}
// Attach the company profile's onboarding / business-license documents to the
// contract by reference. The separate "Documents" intake step was removed —
// the profile documents are simply carried onto every contract automatically.
await this.attachProfileDocuments(contract.id, companyProfileId);
return { contract: await this.findById(contract.id), warnings };
}
/** Insert one DRAFT contract row with the given reference (no children). */
private insertContract(
reference: string,
ctx: {
companyId: string | null | undefined;
companyProfileId: string | null;
isGovernment: boolean;
includesCustoms: boolean;
dto: CreateContractDto;
},
): Promise<Contract> {
const { companyId, companyProfileId, isGovernment, includesCustoms, dto } = ctx;
return this.contractsRepository.create({
reference,
companyId: companyId ?? null,
companyProfileId,
@@ -205,24 +258,6 @@ export class ContractsService {
clearanceStatus: 'NOT_APPLICABLE',
clearanceCycleNumber: 0,
} as never);
await this.persistRoutes(contract.id, dto.routes);
await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind);
if (files.length > 0) {
try {
await this.filesService.uploadMany(contract.id, 'contracts', files);
} catch {
warnings.push('File upload failed — contract was created without attached files.');
}
}
// Attach the company profile's onboarding / business-license documents to the
// contract by reference. The separate "Documents" intake step was removed —
// the profile documents are simply carried onto every contract automatically.
await this.attachProfileDocuments(contract.id, companyProfileId);
return { contract: await this.findById(contract.id), warnings };
}
/**

View File

@@ -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<Freight.ClearanceT1State> {
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.

View File

@@ -7,4 +7,9 @@ export const minioConfig = registerAs("minio", () => ({
accessKey: process.env.MINIO_ACCESS_KEY || "",
secretKey: process.env.MINIO_SECRET_KEY || "",
bucket: process.env.MINIO_BUCKET || "fhc",
// Preset the region so presignedGetObject signs URLs locally. Without it the
// minio client fires a live GetBucketLocation request to the endpoint on every
// sign — which blocks (no timeout) when MinIO is slow/unreachable and hangs
// API responses that reload a booking's files (e.g. staff accept).
region: process.env.MINIO_REGION || "us-east-1",
}));

View File

@@ -29,6 +29,9 @@ export class MinioService {
useSSL: config.useSSL,
accessKey: config.accessKey,
secretKey: config.secretKey,
// Presetting the region keeps presignedGetObject fully local — no live
// GetBucketLocation round-trip to the endpoint on each signed URL.
region: config.region,
});
}
@@ -108,8 +111,11 @@ export class MinioService {
try {
return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds);
} catch (error) {
// Signing a file URL must never break a booking/transition response — the
// caller only needs SOMETHING to link to. Degrade to the public object URL
// and log, rather than throwing (which would 500 an otherwise-good load).
this.logger.error(`Failed to generate signed URL for ${objectName}:`, error);
throw error;
return this.getPublicUrl(objectName);
}
}
}

View File

@@ -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;

View File

@@ -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:0017: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:0017: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:0011:00; reopen +90m → cycle 2 opens 12:30 EAT
// cycle 1: 08:0011: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', () => {

View File

@@ -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

View File

@@ -82,6 +82,7 @@ describe('BookingBatchService — PAID reconcile', () => {
importWindowLeadDays: 3,
exportBookingLeadHours: 24,
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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<boolean> {
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<void> {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 })

View File

@@ -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({

View File

@@ -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({

View File

@@ -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<TrainSchedule> {
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<TrainSchedule> {
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,13 +1535,41 @@ export class TrainSchedulingService {
return this.getImportDjiboutiOperation(schedule.id);
}
/**
* Confirm cargo is loaded on the train from the workspace, for any direction.
* For import-from-Djibouti trains this stamps the ImportDjiboutiOperation's
* loadedOnTrainAt (the flag dispatch checks) — gatepass must already be granted.
* For every other schedule there is no departure loading gate, so this is a
* success no-op and simply returns the current detail.
*/
async confirmScheduleLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
// Import-Djibouti trains gate dispatch on the operation's loadedOnTrainAt.
if (this.isImportDjiboutiSchedule(schedule)) {
await this.confirmImportLoadedOnTrain(scheduleId, dto);
}
// Confirming loading also marks every wagon-assigned booking LOADED, so the
// per-booking loading flag and the dispatch gate agree (otherwise the
// dispatch pre-check keeps reporting these bookings as unloaded).
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
if (wagonAssignedIds.size) {
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
scheduleId,
[...wagonAssignedIds],
LoadingStatus.Loaded,
);
}
return this.getTrainScheduleById(scheduleId);
}
async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
this.assertImportDjiboutiGatepassGranted(operation);
if (!operation.loadedOnTrainAt) {
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
}
// Loading confirmation does not block departure (see assertImportDjiboutiMayDepart).
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
await this.dispatchSchedule(schedule.id);
@@ -1622,9 +1946,9 @@ export class TrainSchedulingService {
where: { trainScheduleId: schedule.id },
});
this.assertImportDjiboutiGatepassGranted(operation);
if (!operation?.loadedOnTrainAt) {
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
}
// Loading confirmation does NOT gate dispatch. Per-booking loading is
// tracking only and the loaded-on-train step is optional — a scheduled train
// dispatches without waiting on loading.
}
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
@@ -3649,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
@@ -3684,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,
@@ -3699,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,
@@ -3792,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),
})) ?? [],
};
}

View File

@@ -920,6 +920,23 @@ export class WarehouseInventoryService {
}),
);
// Receiving the booking flags every container unit as received into the
// port (self-haul export: the delivering truck's goods are now in) so
// staff can raise the per-container GRN over what's received.
await manager.query(
`UPDATE freight.booking_container_units bcu
SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW()
FROM freight.booking_containers bc
WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1
AND bc.deleted_at IS NULL
AND bcu.deleted_at IS NULL
AND bcu.received_to_port = false`,
[bookingId],
);
await this.activityLog.record(
{
activityType: 'INVENTORY_RECEIVED',
@@ -1774,6 +1791,26 @@ export class WarehouseInventoryService {
await this.applyCapacityDelta(manager, dto, weight, volume, containerCount);
// Per-container receive: flag this container's unit as received into the
// port so staff can raise the GRN over what's received.
if (dto.bookingId && dto.containerId) {
await manager.query(
`UPDATE freight.booking_container_units bcu
SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW()
FROM freight.booking_containers bc, freight.containers cont
WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1
AND bc.deleted_at IS NULL
AND cont.id = $2
AND cont.container_number = bcu.container_number
AND bcu.deleted_at IS NULL
AND bcu.received_to_port = false`,
[dto.bookingId, dto.containerId],
);
}
await this.activityLog.record(
{
activityType: 'INVENTORY_RECEIVED',
@@ -2068,6 +2105,29 @@ export class WarehouseInventoryService {
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
});
if (!isTruckLeaving && item.bookingId) {
// Per-truck arrival: mark the customer truck carrying THIS item's
// container as arrived (matched via the physical container number).
if (item.containerId) {
await manager.query(
`UPDATE freight.customer_truck_assignments a
SET arrived_at = COALESCE(a.arrived_at, NOW()), updated_at = NOW()
FROM freight.customer_truck_containers c
JOIN freight.containers cont ON cont.container_number = c.container_number
WHERE c.assignment_id = a.id
AND c.deleted_at IS NULL
AND c.booking_id = $1
AND cont.id = $2
AND a.arrived_at IS NULL
AND a.deleted_at IS NULL`,
[item.bookingId, item.containerId],
);
// NB: import arrival changes nothing on the goods — received_to_port is
// an EXPORT concept (set when a truck delivers into the port). Import
// load + weight are captured on truck departure, not arrival.
}
// Booking-level flag stamped on the FIRST truck arrival. The import
// handover is signed ONCE (before the first truck leaves), even though
// trucks pick up per-container — COALESCE keeps the first timestamp.
await manager.query(
`UPDATE freight.bookings
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
@@ -2147,6 +2207,48 @@ export class WarehouseInventoryService {
}
await this.invoices.assertClearanceAllowed(id);
// Import self-haul: the exit paper names the pickup truck + all containers it
// carries, so gate staff can verify the goods leaving on that truck.
let truck: {
plateNumber: string;
driverName: string;
truckType: string;
containerNumbers: string;
truckWeightTons: string | number | null;
grossWeightKg: string | number | null;
departedAt: string | null;
} | null = null;
if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) {
const [truckRow] = await this.dataSource.query(
`SELECT a.plate_number AS "plateNumber",
a.driver_name AS "driverName",
a.truck_type AS "truckType",
a.gross_weight_kg AS "grossWeightKg",
a.departed_at AS "departedAt",
string_agg(DISTINCT c2.container_number, ', ' ORDER BY c2.container_number) AS "containerNumbers",
COALESCE((
SELECT SUM(bcu.vgm_tons)
FROM freight.customer_truck_containers cc
JOIN freight.booking_container_units bcu
ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL
JOIN freight.booking_containers bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
AND bc.booking_id = c.booking_id
WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL
), 0) AS "truckWeightTons"
FROM freight.customer_truck_containers c
JOIN freight.customer_truck_assignments a
ON a.id = c.assignment_id AND a.deleted_at IS NULL
JOIN freight.customer_truck_containers c2
ON c2.assignment_id = a.id AND c2.deleted_at IS NULL
WHERE c.booking_id = $1 AND c.container_number = $2 AND c.deleted_at IS NULL
GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, c.booking_id
LIMIT 1`,
[row.bookingId, row.containerNumber],
);
truck = truckRow ?? null;
}
const bookingReference = row?.bookingReference || 'N/A';
const reference =
row?.releaseOrderReference ||
@@ -2170,6 +2272,17 @@ export class WarehouseInventoryService {
inventoryStatus: row?.status ?? null,
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
exitInspectionSummary: this.extractExitInspectionNote(row?.notes),
truckPlateNumber: truck?.plateNumber ?? null,
truckDriverName: truck?.driverName ?? null,
truckType: truck?.truckType ?? null,
truckGateOut: truck?.departedAt ?? null,
// Prefer the weighed gross captured on departure; fall back to the summed
// container VGM when the truck hasn't been weighed yet.
truckWeightKg: truck
? Number(truck.grossWeightKg ?? 0) > 0
? Number(truck.grossWeightKg)
: Number(truck.truckWeightTons ?? 0) * 1000
: null,
});
return {
@@ -3099,6 +3212,11 @@ export class WarehouseInventoryService {
inventoryStatus: string | null;
clearanceStatus: string;
exitInspectionSummary?: string | null;
truckPlateNumber?: string | null;
truckDriverName?: string | null;
truckType?: string | null;
truckGateOut?: string | null;
truckWeightKg?: number | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
@@ -3123,12 +3241,37 @@ export class WarehouseInventoryService {
['Container Number', data.containerNumber],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
['Declared Weight', `${data.weight.toLocaleString()} kg`],
[
data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight',
`${(data.truckPlateNumber && data.truckWeightKg
? data.truckWeightKg
: data.weight
).toLocaleString()} kg`,
],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
['Inventory Status', data.inventoryStatus],
['Clearance Status', data.clearanceStatus],
...(data.truckPlateNumber
? ([
['Pickup Truck Plate', data.truckPlateNumber],
['Truck Driver', data.truckDriverName],
['Truck Type', data.truckType],
[
'Gate-Out Time',
data.truckGateOut
? new Date(data.truckGateOut).toLocaleString('en-GB', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
: null,
],
] as [string, string | null][])
: []),
...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []),
];

View File

@@ -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) ──────────────────

View File

@@ -474,7 +474,8 @@ const isClearanceItem = (item: SidebarItem): boolean =>
/**
* Keep only items the user is permitted to see; drop now-empty sections.
*
* Position-scoped visibility (super_admin bypasses all of this):
* Position-scoped visibility (super_admin sees everything):
* - Super Admin → sees all items (all permissions pass, all tabs visible)
* - Ethiopian GL → sees ONLY the ET document-clearance page.
* - Djibouti GL → sees ONLY the DJ clearance page.
* - Everyone else → sees everything they have permission for, EXCEPT the two
@@ -484,9 +485,11 @@ const filterSidebarByPermission = (
sections: SidebarSection[],
user: ReturnType<typeof useAuth>["user"],
): SidebarSection[] => {
const superAdmin = isSuperAdmin(user);
const etGl = !superAdmin && isEthiopianGl(user);
const djGl = !superAdmin && isDjiboutiGl(user);
// Superadmin sees every section and item — no permission filtering.
if (isSuperAdmin(user)) return sections;
const etGl = isEthiopianGl(user);
const djGl = isDjiboutiGl(user);
const permissionAllowed = (item: SidebarItem): boolean => {
if (!item.permission) return true;
@@ -497,8 +500,6 @@ const filterSidebarByPermission = (
};
const itemAllowed = (item: SidebarItem): boolean => {
if (superAdmin) return true;
// GL positions are locked to their single clearance page.
if (etGl) return isEtClearanceItem(item);
if (djGl) return isDjClearanceItem(item);

View File

@@ -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({
</Stack>
</Stepper.Step>
<Stepper.Step
label="Gate pass"
description="Secured on the train schedule after arrival"
icon={clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />}
>
<GatepassStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="Accept T1"
description="GL Djibouti closes the transport document"
description="GL Djibouti closes once the train arrives"
icon={clearance.t1Closed ? <CheckCircle2 size={14} /> : <PackageCheck size={14} />}
>
<AcceptT1Step
@@ -420,6 +413,14 @@ export function ExportClearanceStepper({
/>
</Stepper.Step>
<Stepper.Step
label="Gate pass"
description="Secured on the train schedule after arrival"
icon={clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />}
>
<GatepassStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="Final invoice & payment"
description="GL Djibouti invoices after offload; customer pays"
@@ -554,6 +555,7 @@ function AcceptT1Step({
}) {
const [loading, setLoading] = useState(false);
const files = exportTransitFilesFromWorkflow(workflowFiles);
const arrived = Boolean(clearance.train?.arrivedAt);
return (
<Stack gap="sm">
@@ -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({
<Button
color="edr-green"
loading={loading}
disabled={!transportIssued || !clearance.gatepassGranted}
disabled={!transportIssued || !arrived}
leftSection={<PackageCheck size={16} />}
onClick={async () => {
setLoading(true);

View File

@@ -120,7 +120,7 @@ export function GlClearanceUploadModal({
/>
) : (
<DateInput
label="Vessel departure date (optional)"
label="Vessel arrival date (optional)"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
size="sm"

View File

@@ -98,6 +98,7 @@ function computeImportActiveStep(
clearance: ClearanceViewLike,
bookingCreated: boolean,
bookingMilestones: MilestoneRow[],
t1Uploaded: boolean,
): number {
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
@@ -119,16 +120,24 @@ function computeImportActiveStep(
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
if (!bookingCreated) return 7;
if (!clearance.gatepassGranted) return 8;
if (!clearance.t1?.closed) return 9;
if (!isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")) return 10;
if (!t1Uploaded && !clearance.t1?.closed) return 9;
if (!clearance.t1?.closed) return 10;
// Risk is "assigned" when the booking milestone says so OR the clearance view
// already carries a riskLevel. The ET page derives its bookingMilestones from a
// separately-fetched booking id that can lag or mismatch the booking carrying
// the milestone — `clearance.riskLevel` is server truth and matches the badge.
const riskAssigned =
Boolean(clearance.riskLevel) ||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
if (!riskAssigned) return 11;
// Additional duty round is optional — resolved once skipped or paid.
const secondDutyResolved =
clearance.secondDuty?.skipped ||
clearance.secondDuty?.paid ||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
if (!secondDutyResolved) return 11;
if (!clearance.importReleaseGranted) return 12;
return 13;
if (!secondDutyResolved) return 12;
if (!clearance.importReleaseGranted) return 13;
return 14;
}
function t1FilesFromWorkflow(
@@ -226,12 +235,25 @@ export function PhasedClearanceActionPanel({
// The booking that carries the post-booking steps (gate pass, risk, duty, release).
const actionBookingId =
clearance.t1?.bookingId ?? clearance.linkedBookingId ?? bookingId ?? null;
const t1Uploaded = t1FilesFromWorkflow(workflowFiles).length > 0;
// Risk is assigned when the clearance view carries a riskLevel (server truth,
// drives the badge) OR the fetched booking milestone confirms it. Kept in sync
// with computeImportActiveStep so the stepper never freezes on a page whose
// bookingMilestones lag/mismatch the booking that holds the milestone.
const riskAssigned =
Boolean(clearance.riskLevel) ||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
const activeStep = useMemo(
() =>
isImport
? computeImportActiveStep(clearance, effectiveBookingCreated, bookingMilestones)
? computeImportActiveStep(
clearance,
effectiveBookingCreated,
bookingMilestones,
t1Uploaded,
)
: 0,
[clearance, isImport, effectiveBookingCreated, bookingMilestones],
[clearance, isImport, effectiveBookingCreated, bookingMilestones, t1Uploaded],
);
if (isImport) {
@@ -544,27 +566,50 @@ export function PhasedClearanceActionPanel({
<Stepper.Step
label="T1 transport documents"
description="GL Djibouti uploads after wagon allocation; GL Ethiopia closes on arrival"
description="GL Djibouti uploads after the gate pass is secured"
icon={
clearance.t1?.closed ? <CheckCircle2 size={14} /> : <Truck size={14} />
t1Uploaded || clearance.t1?.closed ? (
<CheckCircle2 size={14} />
) : (
<Truck size={14} />
)
}
>
<ImportT1Section
<ImportT1UploadStep
t1={clearance.t1 ?? null}
gatepassGranted={Boolean(clearance.gatepassGranted)}
workflowFiles={workflowFiles}
canDjAct={showDj && canDj}
canEtAct={showEt && canEt}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
<Stepper.Step
label="Close T1"
description="GL Ethiopia closes once the train arrives"
icon={
clearance.t1?.closed ? (
<CheckCircle2 size={14} />
) : (
<PackageCheck size={14} />
)
}
>
<ImportT1CloseStep
t1={clearance.t1 ?? null}
t1Uploaded={t1Uploaded}
canEtAct={showEt && canEt}
onChanged={onChanged}
/>
</Stepper.Step>
<Stepper.Step
label="Customs risk"
description="GL Ethiopia assigns Green / Yellow / Red"
icon={
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED") ? (
riskAssigned ? (
<CheckCircle2 size={14} />
) : (
<ShieldAlert size={14} />
@@ -575,7 +620,7 @@ export function PhasedClearanceActionPanel({
bookingId={actionBookingId}
clearance={clearance}
canAct={showEt && canEt}
done={isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")}
done={riskAssigned}
onChanged={onChanged}
/>
</Stepper.Step>
@@ -589,7 +634,7 @@ export function PhasedClearanceActionPanel({
bookingId={actionBookingId}
clearance={clearance}
canAct={showEt && canEt}
riskAssigned={isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")}
riskAssigned={riskAssigned}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
@@ -645,26 +690,29 @@ export function PhasedClearanceActionPanel({
);
}
function ImportT1Section({
/**
* GL Djibouti uploads the T1 transport documents (multi-file) once the gate
* pass is secured on the train schedule; locked on departure or T1 close.
*/
function ImportT1UploadStep({
t1,
gatepassGranted,
workflowFiles = [],
canDjAct,
canEtAct,
onChanged,
onViewFile,
onDownloadFile,
}: {
t1: Freight.ClearanceT1State | null;
gatepassGranted: boolean;
workflowFiles?: Freight.ClearanceWorkflowFile[];
canDjAct: boolean;
canEtAct: boolean;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [closing, setClosing] = useState(false);
const uploaded = t1FilesFromWorkflow(workflowFiles);
const replaceMode = uploaded.length > 0;
@@ -680,8 +728,8 @@ function ImportT1Section({
}
const departed = Boolean(t1.trainDepartedAt);
const arrived = Boolean(t1.trainArrivedAt);
const canUpload = canDjAct && t1.wagonAllocated && !departed && !t1.closed;
const canUpload =
canDjAct && t1.wagonAllocated && gatepassGranted && !departed && !t1.closed;
return (
<Stack gap="sm">
@@ -707,7 +755,7 @@ function ImportT1Section({
<StepStatus
done
pendingLabel=""
doneLabel="T1 accepted and closed by GL Ethiopia — documents are final."
doneLabel="T1 documents are final — closed by GL Ethiopia."
/>
) : !t1.wagonAllocated ? (
<StepStatus
@@ -715,6 +763,12 @@ function ImportT1Section({
pendingLabel="Waiting for operations to allocate wagons."
doneLabel=""
/>
) : !gatepassGranted ? (
<StepStatus
done={false}
pendingLabel="Waiting for the gate pass to be secured on the train schedule."
doneLabel=""
/>
) : departed ? (
<Alert color="orange" variant="light" icon={<AlertTriangle size={16} />}>
The train has departed T1 documents are locked and can no longer be changed.
@@ -725,6 +779,8 @@ function ImportT1Section({
pendingLabel="Waiting for GL Djibouti to upload T1 transport documents."
doneLabel=""
/>
) : uploaded.length > 0 && !canUpload ? (
<StepStatus done pendingLabel="" doneLabel="T1 documents uploaded." />
) : null}
{canUpload ? (
@@ -771,40 +827,103 @@ function ImportT1Section({
</Button>
</>
) : null}
</Stack>
);
}
{canEtAct && !t1.closed ? (
arrived ? (
<Stack gap="xs">
<Text size="sm" c="dimmed">
The train has arrived review the T1 documents and close (accept) them.
</Text>
<Button
color="edr-green"
loading={closing}
disabled={uploaded.length === 0}
leftSection={<PackageCheck size={16} />}
onClick={async () => {
setClosing(true);
try {
await contractsService.closeT1(t1.bookingId);
toast.success("T1 closed");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setClosing(false);
}
}}
>
Accept &amp; close T1
</Button>
</Stack>
) : departed ? (
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
Train en route T1 can be closed once it arrives in Ethiopia.
</Alert>
) : 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 (
<StepStatus
done={false}
pendingLabel="Available once the shipment booking is created."
doneLabel=""
/>
);
}
if (t1.closed) {
return (
<StepStatus
done
pendingLabel=""
doneLabel={`T1 accepted and closed by GL Ethiopia${
t1.closedAt ? ` · ${new Date(t1.closedAt).toLocaleString()}` : ""
}`}
/>
);
}
if (!t1Uploaded) {
return (
<StepStatus
done={false}
pendingLabel="Waiting for GL Djibouti to upload T1 transport documents."
doneLabel=""
/>
);
}
if (!t1.trainArrivedAt) {
return (
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
{t1.trainDepartedAt
? "Train en route — T1 can be closed once it arrives in Ethiopia."
: "T1 can be closed once the train arrives in Ethiopia."}
</Alert>
);
}
if (!canEtAct) {
return (
<StepStatus
done={false}
pendingLabel="Train arrived — waiting for GL Ethiopia to close the T1."
doneLabel=""
/>
);
}
return (
<Stack gap="xs">
<Text size="sm" c="dimmed">
The train has arrived review the T1 documents and close (accept) them.
</Text>
<Button
color="edr-green"
loading={closing}
leftSection={<PackageCheck size={16} />}
onClick={async () => {
setClosing(true);
try {
await contractsService.closeT1(t1.bookingId);
toast.success("T1 closed");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setClosing(false);
}
}}
>
Accept &amp; close T1
</Button>
</Stack>
);
}

View File

@@ -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 023, 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<FormState | null>(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 (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size="lg"
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<Clock size={18} />
</ThemeIcon>
<Box>
<Text fw={600} lh={1.2}>
Booking window settings
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{schedule?.route?.name ?? "This schedule only"}
</Text>
</Box>
</Group>
}
>
{detailQuery.isLoading || !form ? (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
) : !canEdit ? (
<Alert
variant="light"
color="yellow"
icon={<Info size={16} />}
title="Window already open"
>
Booking window settings can only be changed before the window opens.
This schedule is currently{" "}
<b>{String(schedule?.windowPhase ?? "not window-managed")}</b>.
</Alert>
) : (
<Stack gap="lg">
{isExport ? (
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Export schedules use a single FCFS lead window the daily desk
hours below don't apply, only the lead time does.
</Alert>
) : null}
{/* ── Daily desk hours ─────────────────────────────────────────── */}
<Box>
<Group justify="space-between" align="center" mb={6}>
<Text size="sm" fw={600}>
Daily desk hours (EAT)
</Text>
{is24h ? (
<Badge
variant="light"
color="grape"
leftSection={<Moon size={12} />}
>
24-hour desk
</Badge>
) : (
<Badge
variant="light"
color="edr-green"
leftSection={<Sun size={12} />}
>
{hourLabel(form.windowOpenHour)} {hourLabel(form.windowCloseHour)}
</Badge>
)}
</Group>
<Group grow align="flex-start">
<Select
label="Opens"
data={HOUR_OPTIONS}
value={String(form.windowOpenHour)}
onChange={(v) =>
v != null &&
setForm((f) => f && { ...f, windowOpenHour: Number(v) })
}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
disabled={isExport}
/>
<Select
label="Closes"
data={HOUR_OPTIONS}
value={String(form.windowCloseHour)}
onChange={(v) =>
v != null &&
setForm((f) => f && { ...f, windowCloseHour: Number(v) })
}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
disabled={isExport}
/>
</Group>
{isOvernight && !is24h ? (
<Text size="xs" c="dimmed" mt={4}>
Overnight desk opens {form.windowOpenHour}:00 and runs past
midnight, closing {form.windowCloseHour}:00 the next morning.
</Text>
) : null}
<Switch
mt="sm"
size="sm"
color="grape"
label="Run 24 hours a day (never pause overnight)"
checked={is24h}
disabled={isExport}
onChange={(e) => {
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 ? (
<Text size="xs" c="dimmed" mt={6}>
A not-yet-full train pauses at the close hour and resumes the next
morning at the open hour, every day until it fills or departs.
</Text>
) : null}
</Box>
<Divider />
{/* ── Cycle timing ─────────────────────────────────────────────── */}
<Box>
<Text size="sm" fw={600} mb={6}>
Cycle timing
</Text>
<Stack gap="sm">
<DurationField
label="Window duration"
description="How long each booking cycle stays open before it closes for review"
value={form.windowDurationHours}
nativeUnit="hours"
onChange={(v) =>
setForm((f) => f && { ...f, windowDurationHours: v })
}
min={0.0166}
disabled={isExport}
/>
<Group grow align="flex-start">
<DurationField
label="Document review"
description="Staff time to accept documents after the window closes"
value={form.docReviewMinutes}
nativeUnit="minutes"
onChange={(v) =>
setForm((f) => f && { ...f, docReviewMinutes: v })
}
min={0}
disabled={isExport}
/>
<DurationField
label="Payment window"
description="Time a selected customer has to pay"
value={form.paymentWindowMinutes}
nativeUnit="minutes"
onChange={(v) =>
setForm((f) => f && { ...f, paymentWindowMinutes: v })
}
min={1}
disabled={isExport}
/>
</Group>
{!isExport ? (
<Text size="xs" c="dimmed">
Reopen gap after each cycle = document review + payment ={" "}
<b>{reopenSummary}</b>.
</Text>
) : null}
</Stack>
</Box>
<Divider />
{/* ── Lead time ────────────────────────────────────────────────── */}
<NumberInput
label={isExport ? "Booking lead (days)" : "Window lead (days)"}
description={
isExport
? "How many days before departure export booking opens"
: "How many days before departure the booking window starts"
}
value={form.importWindowLeadDays}
onChange={(v) =>
setForm(
(f) =>
f && {
...f,
importWindowLeadDays: v === "" ? "" : Number(v),
},
)
}
min={0}
clampBehavior="none"
allowDecimal={false}
/>
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={onClose} disabled={save.isPending}>
Cancel
</Button>
<Button onClick={handleSave} loading={save.isPending}>
Save settings
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -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 (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<CalendarClock size={18} />
</ThemeIcon>
<Box>
<Text fw={600} lh={1.2}>
Edit departure date
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{routeName ?? "This schedule only"}
</Text>
</Box>
</Group>
}
>
<Stack gap="md">
<Alert variant="light" color="blue" icon={<Info size={16} />}>
The date can only be changed before the booking window opens, and must
still leave room for the booking lead window before departure.
</Alert>
<TextInput
label="Departure date"
type="datetime-local"
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={onClose} disabled={save.isPending}>
Cancel
</Button>
<Button onClick={handleSave} loading={save.isPending}>
Save
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -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({
<div>
<Text fw={700}>Allocation workspace</Text>
<Text size="xs" c="dimmed">
Manually add ready-to-pay bookings, remove, or reassign them
Manually add paid, unassigned bookings, remove, or reassign them
</Text>
</div>
</Group>
@@ -347,17 +398,65 @@ export function ScheduleWorkspacePanel({
</Text>
) : null}
{/* Loading confirmation — required before dispatch for import-Djibouti
trains; shown for every direction so staff have one place to confirm. */}
{canManage ? (
<Group
gap={10}
p="sm"
wrap="nowrap"
align="center"
justify="space-between"
style={{
borderRadius: 10,
background: schedule.loadingConfirmed
? "var(--mantine-color-edr-green-0)"
: "var(--mantine-color-yellow-0)",
border: `1px solid ${
schedule.loadingConfirmed
? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-yellow-3)"
}`,
}}
>
<Group gap={8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
{schedule.loadingConfirmed ? (
<CheckCircle2 size={18} color="var(--mantine-color-edr-green-7)" />
) : (
<PackageCheck size={18} color="#B7791F" />
)}
<Text size="sm" fw={600}>
{schedule.loadingConfirmed
? "Loading confirmed — cleared to dispatch"
: "Confirm loading before dispatching this train"}
</Text>
</Group>
{!schedule.loadingConfirmed ? (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
loading={confirmLoading.isPending}
onClick={doConfirmLoading}
>
Confirm loading
</Button>
) : null}
</Group>
) : null}
{/* Two-panel board */}
<Group align="stretch" gap="lg" grow wrap="wrap">
{/* Pool */}
<PanelColumn
title="Ready to pay"
hint="Accepted · this route & day"
title="Paid · unassigned"
hint="Paid · this route & day · not on a train"
count={pool.length}
accent="#F2A516"
loading={poolQuery.isLoading}
emptyIcon={Inbox}
emptyText="No ready-to-pay bookings waiting for this train."
emptyText="No paid, unassigned bookings waiting for this train."
>
{pool.map((b) => (
<BookingCard
@@ -402,9 +501,53 @@ export function ScheduleWorkspacePanel({
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
loadingStatus={b.wagonAssigned ? b.loadingStatus ?? "UNLOADED" : undefined}
right={
canManage ? (
<Group gap={6} wrap="nowrap" justify="flex-end">
{b.wagonAssigned ? (
<Tooltip
label={
(b.loadingStatus ?? "UNLOADED") === "LOADED"
? "Mark cargo unloaded from wagon"
: "Mark cargo loaded onto wagon"
}
withArrow
>
<Button
size="compact-sm"
variant={
(b.loadingStatus ?? "UNLOADED") === "LOADED"
? "light"
: "filled"
}
color="edr-green"
radius="md"
leftSection={
(b.loadingStatus ?? "UNLOADED") === "LOADED" ? (
<PackageX size={13} />
) : (
<PackageCheck size={13} />
)
}
loading={setLoading.isPending}
onClick={() =>
toggleLoaded(
b.id,
b.reference ?? b.id.slice(0, 8),
(b.loadingStatus ?? "UNLOADED") === "LOADED"
? "UNLOADED"
: "LOADED",
)
}
>
{(b.loadingStatus ?? "UNLOADED") === "LOADED"
? "Unload"
: "Load"}
</Button>
</Tooltip>
) : null}
{/* Reassign-to-another-train — hidden for now.
<Tooltip label="Reassign to another train" withArrow>
<Button
size="compact-sm"
@@ -420,6 +563,7 @@ export function ScheduleWorkspacePanel({
Move
</Button>
</Tooltip>
*/}
<Tooltip label="Remove from this train" withArrow>
<Button
size="compact-sm"
@@ -565,12 +709,14 @@ function BookingCard({
customer,
weightTons,
status,
loadingStatus,
right,
}: {
reference: string;
customer?: string | null;
weightTons?: number | null;
status?: string | null;
loadingStatus?: "LOADED" | "UNLOADED";
right?: React.ReactNode;
}) {
return (
@@ -596,6 +742,16 @@ function BookingCard({
{reference}
</Text>
{status ? <BookingStatusBadge status={status} /> : null}
{loadingStatus ? (
<Badge
size="sm"
radius="sm"
variant={loadingStatus === "LOADED" ? "filled" : "light"}
color={loadingStatus === "LOADED" ? "edr-green" : "gray"}
>
{loadingStatus === "LOADED" ? "Loaded" : "Unloaded"}
</Badge>
) : null}
</Group>
<Group gap={10} align="center" wrap="nowrap">
<Text size="xs" c="dimmed" truncate>

View File

@@ -281,6 +281,10 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
BOOKING_WINDOW: (id: string) =>
`/train-scheduling/schedules/${id}/booking-window`,
WINDOW_RULE: (id: string) =>
`/train-scheduling/schedules/${id}/window-rule`,
SCHEDULE_DATE: (id: string) =>
`/train-scheduling/schedules/${id}/schedule-date`,
CONTRACT_BOOKING_WINDOWS: (contractId: string) =>
`/train-scheduling/contracts/${contractId}/booking-windows`,
MARK_BOOKING_PAID: (bookingId: string) =>
@@ -324,6 +328,10 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/import-loading-bookings`,
IMPORT_LOADING_STATUS: (id: string) =>
`/train-scheduling/schedules/${id}/import-loading-status`,
LOADING_STATUS: (id: string) =>
`/train-scheduling/schedules/${id}/loading-status`,
CONFIRM_LOADING: (id: string) =>
`/train-scheduling/schedules/${id}/confirm-loading`,
IMPORT_DJIBOUTI: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti`,
IMPORT_DJIBOUTI_DOCUMENTS: (id: string) =>

View File

@@ -44,7 +44,7 @@ export default function ContractClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const { view, viewer } = useFileViewer();
const { data: contract } = useContractDetail(id);
const { data: contract, refetch: refetchContract } = useContractDetail(id);
const {
data: clearance,
isLoading,
@@ -250,6 +250,7 @@ export default function ContractClearanceDetailPage() {
phasedCustoms={phasedCustoms}
onChanged={() => {
void refetch();
void refetchContract();
void refetchBookingMilestones();
}}
/>

View File

@@ -1,25 +1,28 @@
import {
Alert,
Badge,
Box,
Button,
Checkbox,
Group,
List,
Loader,
Modal,
Paper,
RingProgress,
Stack,
Tabs,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { isAxiosError } from "axios";
import {
AlertTriangle,
ArrowLeft,
CalendarClock,
CheckCircle2,
Clock,
Container as ContainerIcon,
Eye,
FileText,
@@ -45,9 +48,10 @@ import {
} from "@/components/trainScheduling/containerPlacement.util";
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
@@ -95,10 +99,12 @@ export default function TrainScheduleV2DetailPage() {
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
const [gatepassReference, setGatepassReference] = useState("");
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
const [gatepassNotes, setGatepassNotes] = useState("");
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
const autoPreviewedRef = useRef(false);
const detailQuery = useQuery(
@@ -125,6 +131,7 @@ export default function TrainScheduleV2DetailPage() {
queryFn: () => trainSchedulingService.getImportDjiboutiOperation(scheduleId as string),
enabled: Boolean(scheduleId && gatepassApplies),
});
const gatepassSecured = gatepassQuery.data?.gatepassStatus === "SECURED";
const secureGatepass = useMutation({
mutationFn: () =>
trainSchedulingService.grantImportDjiboutiGatepass(scheduleId as string, {
@@ -146,12 +153,14 @@ export default function TrainScheduleV2DetailPage() {
},
});
const importLoadingQuery = useQuery(
api.trainScheduling.importLoadingBookings.queryOptions({
input: { id: scheduleId ?? "" },
enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"),
}),
);
// Superseded by the Workspace tab Load/Unload toggle — see commented
// "Import loading confirmation" card below.
// const importLoadingQuery = useQuery(
// api.trainScheduling.importLoadingBookings.queryOptions({
// input: { id: scheduleId ?? "" },
// enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"),
// }),
// );
const eligibleFilters = useMemo(
() =>
@@ -358,6 +367,22 @@ export default function TrainScheduleV2DetailPage() {
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
const canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0;
const canDispatch = schedule.status === "SCHEDULED";
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
// cargo staff never marked loaded. Both are warnings, not blockers — staff can
// still dispatch after confirming.
const dispatchBookings = schedule.bookings ?? [];
const unassignedCount = dispatchBookings.filter((b) => !b.wagonAssigned).length;
const unloadedCount = dispatchBookings.filter(
(b) => b.wagonAssigned && (b.loadingStatus ?? "UNLOADED") !== "LOADED",
).length;
// Import-Djibouti trains are HARD-blocked from dispatch until loading is
// confirmed in the workspace — surface it as a blocker, not just a warning.
const loadingBlocksDispatch =
schedule.requiresLoadingConfirmation === true &&
schedule.loadingConfirmed !== true;
const hasDispatchWarnings = unassignedCount > 0 || unloadedCount > 0;
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
const canPrintMarshalling =
@@ -396,6 +421,24 @@ export default function TrainScheduleV2DetailPage() {
}
};
const runDispatch = async () => {
setDispatchConfirmOpen(false);
try {
await dispatch.mutateAsync(scheduleId);
await openMarshallingDocument({
title: "Train dispatched",
successDescription: "Marshalling document generated for the dispatched train.",
errorTitle: "Train dispatched, but document could not open",
});
} catch (err) {
toast({
title: "Dispatch failed",
description: parseError(err, "Could not dispatch"),
variant: "destructive",
});
}
};
const handleAssign = async () => {
if (!allSelectedIds.length) return;
@@ -777,22 +820,7 @@ export default function TrainScheduleV2DetailPage() {
radius="md"
leftSection={<Send size={18} />}
loading={dispatch.isPending}
onClick={async () => {
try {
await dispatch.mutateAsync(scheduleId);
await openMarshallingDocument({
title: "Train dispatched",
successDescription: "Marshalling document generated for the dispatched train.",
errorTitle: "Train dispatched, but document could not open",
});
} catch (err) {
toast({
title: "Dispatch failed",
description: parseError(err, "Could not dispatch"),
variant: "destructive",
});
}
}}
onClick={() => setDispatchConfirmOpen(true)}
>
Dispatch train
</Button>
@@ -886,6 +914,18 @@ export default function TrainScheduleV2DetailPage() {
Track train
</Button>
) : null}
{schedule.windowPhase === "PRE_WINDOW" ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<Clock size={16} />}
onClick={() => setWindowSettingsOpen(true)}
>
Window settings
</Button>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Button
variant="default"
@@ -896,6 +936,31 @@ export default function TrainScheduleV2DetailPage() {
Reschedule train
</Button>
) : null}
{gatepassApplies ? (
gatepassSecured ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<CheckCircle2 size={16} />}
disabled
>
Gate pass secured
</Button>
) : (
<Button
color="edr-green"
radius="lg"
size="sm"
leftSection={<FileText size={16} />}
loading={secureGatepass.isPending}
onClick={() => secureGatepass.mutate()}
>
Secure gate pass
</Button>
)
) : null}
</Group>
</Group>
@@ -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" ? (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">
@@ -979,83 +1047,7 @@ export default function TrainScheduleV2DetailPage() {
</Stack>
</Paper>
) : null}
{gatepassApplies ? (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon
size={44}
radius="md"
variant="light"
color={gatepassQuery.data?.gatepassStatus === "SECURED" ? "edr-green" : "orange"}
>
<FileText size={22} />
</ThemeIcon>
<Stack gap={4}>
<Group gap="sm">
<Title order={4} fw={700}>
Djibouti Port gate pass
</Title>
<Badge
color={gatepassQuery.data?.gatepassStatus === "SECURED" ? "green" : "orange"}
variant="light"
>
{gatepassQuery.data?.gatepassStatus ?? "NOT_SECURED"}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{schedule.direction === "IMPORT"
? "Secure before dispatch from Djibouti."
: "Secure after dispatch before Djibouti Port entry / unloading."}
</Text>
</Stack>
</Group>
{gatepassQuery.isLoading ? <Loader size="sm" /> : null}
</Group>
<Group align="flex-end" grow>
<TextInput
label="Secured date"
type="datetime-local"
value={gatepassSecuredAt}
onChange={(event) => setGatepassSecuredAt(event.currentTarget.value)}
/>
<TextInput
label="Document reference"
placeholder="Optional"
value={gatepassReference}
onChange={(event) => setGatepassReference(event.currentTarget.value)}
/>
<TextInput
label="Document URL"
placeholder="Optional upload/link"
value={gatepassFileUrl}
onChange={(event) => setGatepassFileUrl(event.currentTarget.value)}
/>
</Group>
<Textarea
label="Notes"
placeholder="Optional"
autosize
minRows={2}
value={gatepassNotes}
onChange={(event) => setGatepassNotes(event.currentTarget.value)}
/>
<Group justify="flex-end">
<Button
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
loading={secureGatepass.isPending}
onClick={() => secureGatepass.mutate()}
>
Save as Secured
</Button>
</Group>
</Stack>
</Paper>
) : null}
*/}
<Tabs defaultValue="workflow" radius="md" color="edr-green" keepMounted={false}>
<Tabs.List mb="md">
@@ -1126,7 +1118,7 @@ export default function TrainScheduleV2DetailPage() {
</Stack>
</Paper>
<ScheduleBatchPanel schedule={schedule} />
{/* <ScheduleBatchPanel schedule={schedule} /> */}
</Stack>
</Tabs.Panel>
@@ -1150,6 +1142,109 @@ export default function TrainScheduleV2DetailPage() {
onComplete={() => void detailQuery.refetch()}
/>
) : null}
<BookingWindowSettingsModal
scheduleId={scheduleId ?? null}
opened={windowSettingsOpen}
onClose={() => setWindowSettingsOpen(false)}
onSaved={() => void detailQuery.refetch()}
/>
<Modal
opened={dispatchConfirmOpen}
onClose={() => setDispatchConfirmOpen(false)}
centered
radius="lg"
title={
<Group gap={8}>
<Send size={18} />
<Text fw={700}>Dispatch this train?</Text>
</Group>
}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Dispatch locks the composition and begins rail movement. This cannot be
undone.
</Text>
{loadingBlocksDispatch ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertTriangle size={18} />}
title="Loading not confirmed"
>
This import train cannot depart until loading is confirmed. Use{" "}
<Text span fw={700}>
Confirm loading
</Text>{" "}
in the Workspace tab first.
</Alert>
) : null}
{hasDispatchWarnings ? (
<Alert
color="orange"
variant="light"
radius="md"
icon={<AlertTriangle size={18} />}
title="Some bookings are not fully ready"
>
<List size="sm" spacing={4}>
{unassignedCount > 0 ? (
<List.Item>
<Text span fw={700}>
{unassignedCount}
</Text>{" "}
booking{unassignedCount === 1 ? "" : "s"} not assigned to a wagon
</List.Item>
) : null}
{unloadedCount > 0 ? (
<List.Item>
<Text span fw={700}>
{unloadedCount}
</Text>{" "}
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} still marked
unloaded
</List.Item>
) : null}
</List>
<Text size="xs" c="dimmed" mt={6}>
You can still dispatch confirm to proceed.
</Text>
</Alert>
) : (
<Alert
color="edr-green"
variant="light"
radius="md"
icon={<CheckCircle2 size={18} />}
>
All bookings are assigned to a wagon and marked loaded.
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setDispatchConfirmOpen(false)}
>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<Send size={16} />}
loading={dispatch.isPending}
disabled={loadingBlocksDispatch}
onClick={() => void runDispatch()}
>
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}

View File

@@ -20,9 +20,11 @@ import {
ArrowRight,
Ban,
CalendarClock,
Clock,
Eye,
MoreHorizontal,
Navigation,
Pencil,
Send,
Train,
Weight,
@@ -36,6 +38,8 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
import {
locomotiveOption,
showScheduleWarnings,
@@ -86,6 +90,9 @@ export default function TrainScheduleV2ListPage() {
const [statusFilter, setStatusFilter] = useState("ALL");
const [freightFilter, setFreightFilter] = useState("ALL");
const [createOpen, setCreateOpen] = useState(false);
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
const [editDateSchedule, setEditDateSchedule] =
useState<TrainScheduleListItem | null>(null);
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
@@ -315,6 +322,22 @@ export default function TrainScheduleV2ListPage() {
Track
</Menu.Item>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item
leftSection={<Pencil size={15} />}
onClick={() => setEditDateSchedule(schedule)}
>
Edit departure date
</Menu.Item>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item
leftSection={<Clock size={15} />}
onClick={() => setWindowSettingsId(schedule.id)}
>
Booking window settings
</Menu.Item>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item
color="red"
@@ -583,6 +606,22 @@ export default function TrainScheduleV2ListPage() {
</Group>
</Stack>
</Modal>
<BookingWindowSettingsModal
scheduleId={windowSettingsId}
opened={windowSettingsId != null}
onClose={() => setWindowSettingsId(null)}
onSaved={() => void schedulesQuery.refetch()}
/>
<EditScheduleDateModal
scheduleId={editDateSchedule?.id ?? null}
currentDate={editDateSchedule?.scheduleDate ?? null}
routeName={editDateSchedule?.routeName ?? null}
opened={editDateSchedule != null}
onClose={() => setEditDateSchedule(null)}
onSaved={() => void schedulesQuery.refetch()}
/>
</PageContainer>
);
}

View File

@@ -58,6 +58,7 @@ export default function TrainSchedulingGlobalRulesPage() {
"importWindowLeadDays",
"exportBookingLeadHours",
"windowOpenHour",
"windowCloseHour",
"windowDurationHours",
"docReviewMinutes",
"paymentWindowMinutes",
@@ -196,7 +197,7 @@ export default function TrainSchedulingGlobalRulesPage() {
/>
<NumberInput
label="Window open hour (EAT)"
description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)"
description="Local hour the booking desk opens each day (e.g. 8 = 08:00)"
value={form.windowOpenHour ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, windowOpenHour: value }))
@@ -207,6 +208,19 @@ export default function TrainSchedulingGlobalRulesPage() {
max={23}
disabled={loading}
/>
<NumberInput
label="Window close hour (EAT)"
description="Local hour the booking desk shuts each day; a not-yet-full window resumes next morning at the open hour. Set equal to the open hour for a 24-hour desk."
value={form.windowCloseHour ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, windowCloseHour: value }))
}
clampBehavior="none"
allowDecimal
min={0}
max={23}
disabled={loading}
/>
<DurationField
label="Window duration"
description="How long the import booking window stays open"

View File

@@ -58,6 +58,7 @@ import type {
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListItem,
UpdateScheduleWindowRulePayload,
TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse,
TrainTrackResponse,
@@ -438,6 +439,30 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
updateScheduleWindowRule: endpoint<
{ id: string; payload: UpdateScheduleWindowRulePayload },
TrainScheduleDetail
>(
"train-scheduling",
"update-schedule-window-rule",
({ id, payload }) =>
trainSchedulingService.updateScheduleWindowRule(id, payload),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
updateScheduleDate: endpoint<
{ id: string; scheduleDate: string },
TrainScheduleDetail
>(
"train-scheduling",
"update-schedule-date",
({ id, scheduleDate }) =>
trainSchedulingService.updateScheduleDate(id, scheduleDate),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
markBookingPaid: endpoint<string, void>(
"train-scheduling",
"mark-booking-paid",
@@ -521,6 +546,26 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
setLoadingStatus: endpoint<
{ id: string; bookingIds: string[]; loadingStatus: LoadingStatus },
TrainScheduleDetail
>(
"train-scheduling",
"set-loading-status",
({ id, bookingIds, loadingStatus }) =>
trainSchedulingService.setLoadingStatus(id, { bookingIds, loadingStatus }),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
confirmLoading: endpoint<{ id: string }, TrainScheduleDetail>(
"train-scheduling",
"confirm-loading",
({ id }) => trainSchedulingService.confirmLoading(id),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
pinWagons: endpoint<
{ id: string; payload: PinWagonsPayload },
TrainScheduleDetail

View File

@@ -23,6 +23,7 @@ import type {
RecordCheckpointPayload,
StaffBookingWindow,
TrainScheduleDetail,
UpdateScheduleWindowRulePayload,
TrainScheduleFilters,
TrainScheduleListItem,
TrainSchedulePreviewPayload,
@@ -210,6 +211,28 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
updateScheduleWindowRule: async (
scheduleId: string,
payload: UpdateScheduleWindowRulePayload,
): Promise<TrainScheduleDetail> => {
const response = await client.patch<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.WINDOW_RULE(scheduleId),
payload,
);
return unwrap(response.data);
},
updateScheduleDate: async (
scheduleId: string,
scheduleDate: string,
): Promise<TrainScheduleDetail> => {
const response = await client.patch<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULE_DATE(scheduleId),
{ scheduleDate },
);
return unwrap(response.data);
},
markBookingPaid: async (bookingId: string): Promise<void> => {
await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.MARK_BOOKING_PAID(bookingId),
@@ -335,6 +358,27 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
setLoadingStatus: async (
scheduleId: string,
payload: { bookingIds: string[]; loadingStatus: LoadingStatus },
): Promise<TrainScheduleDetail> => {
const response = await client.patch<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.LOADING_STATUS(scheduleId),
payload,
);
return unwrap(response.data);
},
confirmLoading: async (
scheduleId: string,
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.CONFIRM_LOADING(scheduleId),
{},
);
return unwrap(response.data);
},
getImportDjiboutiOperation: async (
scheduleId: string,
): Promise<ImportDjiboutiOperation> => {

View File

@@ -108,6 +108,7 @@ export interface TrainSchedulingGlobalRules {
importWindowLeadDays: number;
exportBookingLeadHours: number;
windowOpenHour: number;
windowCloseHour: number;
windowDurationHours: number;
docReviewMinutes: number;
paymentWindowMinutes: number;
@@ -399,6 +400,28 @@ export interface TrainScheduleWagonAllocation {
} | null;
}
/** Per-schedule booking-window rule snapshot (null fields fall back to global config). */
export interface ScheduleWindowRule {
windowOpenHour: number | null;
windowCloseHour: number | null;
windowDurationHours: number | null;
reopenDelayMinutes: number | null;
importWindowLeadDays: number | null;
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
docReviewMinutes: number;
paymentWindowMinutes: number;
}
/** Editable window-rule override for one schedule; every field optional. */
export interface UpdateScheduleWindowRulePayload {
windowOpenHour?: number;
windowCloseHour?: number;
windowDurationHours?: number;
docReviewMinutes?: number;
paymentWindowMinutes?: number;
importWindowLeadDays?: number;
}
export interface TrainScheduleDetail {
id: string;
status: TrainScheduleStatus | string;
@@ -406,11 +429,17 @@ export interface TrainScheduleDetail {
freightType?: FreightType | null;
trainNumber?: string | null;
direction?: string | null;
/** True when this schedule needs loading confirmed before dispatch (import-Djibouti). */
requiresLoadingConfirmation?: boolean;
/** True when loading is already confirmed (or not required for this direction). */
loadingConfirmed?: boolean;
windowPhase?: BookingWindowPhase | string | null;
windowOpensAt?: string | null;
windowClosesAt?: string | null;
docReviewEndsAt?: string | null;
paymentPhaseEndsAt?: string | null;
/** Booking-window rule snapshot — prefills the per-schedule settings editor. */
windowRule?: ScheduleWindowRule | null;
route?: {
id: string;
name: string;
@@ -479,6 +508,8 @@ export interface TrainScheduleDetail {
status: string | null;
schedulingStatus?: SchedulingStatus | null;
freightType?: FreightType | string | null;
loadingStatus?: "LOADED" | "UNLOADED";
wagonAssigned?: boolean;
}>;
warnings?: string[];
}

View File

@@ -5,6 +5,7 @@ import {
Layers,
Loader2,
MapPin,
Package,
Receipt,
Settings,
} from "lucide-react";
@@ -35,6 +36,7 @@ import InvoiceDetailPage from "./pages/billing/InvoiceDetailPage";
import InvoicesList from "./pages/billing/InvoicesList";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import BookingsListPage from "./pages/bookings/BookingsListPage";
import EditBookingPage from "./pages/bookings/EditBookingPage";
import ContractClearanceFlow from "./pages/contracts/ContractClearanceFlow";
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
@@ -184,6 +186,11 @@ const sidebarItems: SidebarItem[] = [
href: "/contracts",
icon: <Layers size={18} />,
},
{
label: "Bookings",
href: "/bookings",
icon: <Package size={18} />,
},
{
label: "Tracking",
href: "/tracking",
@@ -255,12 +262,9 @@ const App = () => {
}
>
<Route path="/portal" element={<MyPortalPage />} />
{/* Bookings live under contracts now — the standalone list is gone.
Legacy /bookings* entry points redirect into the contract flow. */}
<Route
path="/bookings"
element={<Navigate to="/contracts" replace />}
/>
{/* Bookings are created against a contract, but the full list is
browsable here. New-booking entry still routes via a contract. */}
<Route path="/bookings" element={<BookingsListPage />} />
<Route
path="/bookings/new"
element={<Navigate to="/contracts/new" replace />}

View File

@@ -107,6 +107,9 @@ export const URL_CONSTANTS = {
CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`,
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`,
CUSTOMER_TRUCK: (id: string, assignmentId: string) =>
`/api/bookings/${id}/customer-trucks/${assignmentId}`,
},
CONTRACTS: {

View File

@@ -1,15 +1,30 @@
import { Alert, Button, Group, Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Button,
Divider,
Group,
Loader,
MultiSelect,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Freight } from "@edr/types";
import { Download, Lock, Truck } from "lucide-react";
import { CheckCircle2, Clock, Download, Plus, Trash2, Truck } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { customerTrucksService } from "@/services/customer-trucks.service";
import { CardTitle, SectionCard } from "./layout";
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
const ISO_CONTAINER_PATTERN = /^[A-Z]{4}\d{7}$/;
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
@@ -22,6 +37,13 @@ const downloadBlob = (blob: Blob, filename: string) => {
URL.revokeObjectURL(url);
};
const errorMessage = (error: unknown, fallback: string) => {
const data = (error as { response?: { data?: { message?: string | string[] } } })?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return error instanceof Error ? error.message : fallback;
};
export function CustomerTruckAssignmentCard({
booking,
onAssigned,
@@ -29,48 +51,86 @@ export function CustomerTruckAssignmentCard({
booking: Freight.IBooking;
onAssigned: () => void;
}) {
const assigned = Boolean(booking.customerTruckAssignedAt);
const [truckPlateNumber, setTruckPlateNumber] = useState(booking.customerTruckPlateNumber ?? "");
const [driverName, setDriverName] = useState(booking.customerTruckDriverName ?? "");
const [truckType, setTruckType] = useState(booking.customerTruckType ?? "");
const [containerNumberToLoad, setContainerNumberToLoad] = useState(
booking.customerTruckContainerNumber ?? "",
);
const queryClient = useQueryClient();
const trucksKey = ["customer-trucks", booking.id];
const { data: trucks = [], isLoading } = useQuery({
queryKey: trucksKey,
queryFn: () => customerTrucksService.list(booking.id),
});
const [plateNumber, setPlateNumber] = useState("");
const [driverName, setDriverName] = useState("");
const [truckType, setTruckType] = useState("");
const [containers, setContainers] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
// Physical container numbers on this booking — the customer picks which one to
// load onto the truck instead of typing it. Falls back to free entry when the
// booking has no container numbers recorded.
const containerOptions = booking.containerNumbers ?? [];
// Container numbers on the booking that aren't already loaded onto a truck.
const assignedNumbers = new Set(
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
);
const availableContainers = (booking.containerNumbers ?? []).filter(
(n) => !assignedNumbers.has(n),
);
const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions());
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
// EXPORT trucks deliver known containers (pre-selected). IMPORT trucks don't —
// staff register + weigh what was loaded when the truck leaves.
const isExport = booking.tradeDirection === "EXPORT";
const submit = async () => {
const payload = {
truckPlateNumber: truckPlateNumber.trim().toUpperCase(),
driverName: driverName.trim(),
truckType: truckType.trim(),
containerNumberToLoad: containerNumberToLoad.trim().toUpperCase(),
};
if (!payload.truckPlateNumber || !payload.driverName || !payload.truckType || !payload.containerNumberToLoad) {
setError("All truck assignment fields are required.");
return;
}
if (!ISO_CONTAINER_PATTERN.test(payload.containerNumberToLoad)) {
setError("Container number must match ISO format, e.g. ABCD1234567.");
return;
}
const resetForm = () => {
setPlateNumber("");
setDriverName("");
setTruckType("");
setContainers([]);
setError(null);
await assignMutation.mutateAsync({ id: booking.id, payload });
onAssigned();
};
const addMutation = useMutation({
mutationFn: () =>
customerTrucksService.add(booking.id, {
truckPlateNumber: plateNumber.trim().toUpperCase(),
driverName: driverName.trim(),
truckType: truckType.trim(),
// Import: containers are registered + weighed on departure, not here.
containerNumbers: isExport ? containers : [],
}),
onSuccess: (list) => {
queryClient.setQueryData(trucksKey, list);
resetForm();
onAssigned();
toast.success("Truck added");
},
onError: (e) => setError(errorMessage(e, "Could not add truck")),
});
const removeMutation = useMutation({
mutationFn: (assignmentId: string) => customerTrucksService.remove(booking.id, assignmentId),
onSuccess: (list) => {
queryClient.setQueryData(trucksKey, list);
onAssigned();
},
onError: (e) => toast.error(errorMessage(e, "Could not remove truck")),
});
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
const downloadFreightOrder = async () => {
const blob = await downloadMutation.mutateAsync({ id: booking.id });
downloadBlob(blob, `freight-order-${booking.reference}.pdf`);
};
const submitAdd = () => {
if (!plateNumber.trim() || !driverName.trim() || !truckType.trim()) {
setError("Plate number, driver name and truck type are required.");
return;
}
if (isExport && (containers.length < 1 || containers.length > 2)) {
setError("Select 1 or 2 container numbers for this truck.");
return;
}
setError(null);
addMutation.mutate();
};
return (
<SectionCard>
<Stack gap="md">
@@ -79,78 +139,135 @@ export function CustomerTruckAssignmentCard({
<Truck size={18} color="#0a9f6a" />
<CardTitle>External Truck Assignment</CardTitle>
</Group>
{assigned && (
<Group gap={6} c="#0a9f6a">
<Lock size={14} />
<Text size="sm" fw={700}>
Truck Assigned
</Text>
</Group>
{trucks.length > 0 && (
<Text size="sm" fw={700} c="#0a9f6a">
{trucks.length} truck{trucks.length !== 1 ? "s" : ""}
</Text>
)}
</Group>
{/* Assigned trucks */}
{isLoading ? (
<Group justify="center" py="sm">
<Loader size="sm" />
</Group>
) : (
trucks.map((t) => (
<Group
key={t.id}
justify="space-between"
align="flex-start"
wrap="nowrap"
style={{ border: "1px solid #EEF2F6", borderRadius: 12, padding: "12px 14px" }}
>
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fz="14px" fw={700} c="#10202F">
{t.plateNumber}
</Text>
{t.arrivedAt ? (
<Badge color="green" variant="light" leftSection={<CheckCircle2 size={12} />}>
Arrived
</Badge>
) : (
<Badge color="orange" variant="light" leftSection={<Clock size={12} />}>
Awaiting arrival
</Badge>
)}
</Group>
<Text fz="12.5px" c="#6B7C8E">
{t.driverName} · {t.truckType}
</Text>
<Group gap={6}>
{(t.containers ?? []).map((c) => (
<Badge key={c.id} variant="outline" color="gray">
{c.containerNumber}
</Badge>
))}
</Group>
</Stack>
{!t.arrivedAt && (
<ActionIcon
variant="subtle"
color="red"
aria-label="Remove truck"
onClick={() => removeMutation.mutate(t.id)}
loading={removeMutation.isPending}
>
<Trash2 size={16} />
</ActionIcon>
)}
</Group>
))
)}
{error && (
<Alert color="red" variant="light">
{error}
</Alert>
)}
{assignMutation.isError && (
<Alert color="red" variant="light">
{assignMutation.error instanceof Error
? assignMutation.error.message
: "Truck assignment failed."}
</Alert>
{/* Add-truck form. Export needs unassigned containers; import always allows another truck. */}
{(isExport ? availableContainers.length > 0 : true) ? (
<>
<Divider label="Add a truck" labelPosition="center" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<TextInput
label="Truck Plate Number"
required
value={plateNumber}
onChange={(e) => setPlateNumber(e.currentTarget.value.toUpperCase())}
/>
<TextInput
label="Driver Name"
required
value={driverName}
onChange={(e) => setDriverName(e.currentTarget.value)}
/>
<Select
label="Truck Type"
required
data={TRUCK_TYPES}
value={truckType || null}
onChange={(value) => setTruckType(value ?? "")}
/>
{isExport && (
<MultiSelect
label="Containers to load (12)"
required
placeholder="Select container numbers"
data={availableContainers}
value={containers}
onChange={setContainers}
maxValues={2}
searchable
nothingFoundMessage="No unassigned containers"
/>
)}
</SimpleGrid>
<Group justify="flex-end">
<Button
leftSection={<Plus size={16} />}
color="edr-green"
onClick={submitAdd}
loading={addMutation.isPending}
>
Add truck
</Button>
</Group>
</>
) : (
trucks.length > 0 && (
<Text fz="12.5px" c="#9AA8B5">
All containers on this booking have been assigned to a truck.
</Text>
)
)}
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<TextInput
label="Truck Plate Number"
required
value={truckPlateNumber}
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
readOnly={assigned}
/>
<TextInput
label="Driver Name"
required
value={driverName}
onChange={(e) => setDriverName(e.currentTarget.value)}
readOnly={assigned}
/>
<Select
label="Truck Type"
required
data={TRUCK_TYPES}
value={truckType || null}
onChange={(value) => setTruckType(value ?? "")}
disabled={assigned}
/>
{containerOptions.length > 0 ? (
<Select
label="Container Number to Load"
required
placeholder="Select a container from this booking"
data={containerOptions}
value={containerNumberToLoad || null}
onChange={(value) => setContainerNumberToLoad(value ?? "")}
searchable
disabled={assigned}
nothingFoundMessage="No matching container"
/>
) : (
<TextInput
label="Container Number to Load"
required
value={containerNumberToLoad}
onChange={(e) => setContainerNumberToLoad(e.currentTarget.value.toUpperCase())}
readOnly={assigned}
/>
)}
</SimpleGrid>
<Group justify="flex-end">
{assigned ? (
{trucks.length > 0 && (
<Group justify="flex-end">
<Button
variant="light"
leftSection={<Download size={16} />}
color="edr-green"
onClick={downloadFreightOrder}
@@ -158,12 +275,8 @@ export function CustomerTruckAssignmentCard({
>
Generate Freight Order Copies
</Button>
) : (
<Button color="edr-green" onClick={submit} loading={assignMutation.isPending}>
Verify & Submit Assignment
</Button>
)}
</Group>
</Group>
)}
</Stack>
</SectionCard>
);

View File

@@ -0,0 +1,953 @@
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Box,
Button,
Card,
Group,
Menu,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import {
ArrowRight,
CheckCircle2,
FileEdit,
LayoutList,
MoreVertical,
Package,
Plus,
Search,
Train,
Wallet,
X,
} from "lucide-react";
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
import { BookingActionButton } from "./clearance/BookingActionButton";
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
import {
BookingTypeBadge,
CargoModeCell,
PaymentBadge,
SchedulingCell,
} from "./booking-display";
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
import type { Freight } from "@edr/types";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
} from "@edr/ui-common";
// Bookings that have left (or are leaving) the yard can be tracked live.
const TRACKABLE_STATUSES = new Set([
"PAID",
"IN_TRANSIT",
"COMPLETED",
"DELIVERED",
]);
// ── Status filter options (grouped by lifecycle) ──────────────────────────────
const STATUS_FILTERS = [
{
key: "all",
label: "All bookings",
statuses: undefined as string | undefined,
},
{
key: "active",
label: "In progress",
statuses:
"SUBMITTED,CHANGES_REQUESTED,PENDING_APPROVAL,APPROVED_PENDING_SIGNATURE,APPROVED,CONTRACT_READY,SIGNED_CUSTOMER,FULLY_EXECUTED,PENDING_CONSOLIDATION,CONSOLIDATED",
},
{ key: "draft", label: "Drafts", statuses: "DRAFT" },
{
key: "payment",
label: "Awaiting payment",
statuses:
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
},
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
{
key: "closed",
label: "Cancelled / rejected",
statuses: "CANCELLED,REJECTED",
},
] as const;
type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"];
const SELECT_DATA = STATUS_FILTERS.map((f) => ({
value: f.key,
label: f.label,
}));
// Sort options — server-side ordering on the booking list.
const SORT_OPTIONS = [
{ value: "createdAt:DESC", label: "Newest first" },
{ value: "createdAt:ASC", label: "Oldest first" },
{ value: "scheduledDate:ASC", label: "Ship date ↑" },
{ value: "scheduledDate:DESC", label: "Ship date ↓" },
{ value: "reference:ASC", label: "Reference AZ" },
{ value: "reference:DESC", label: "Reference ZA" },
] as const;
// ── Summary stat cards (clickable lifecycle filters) ──────────────────────────
const STAT_CARDS: Array<{
key: StatusFilterKey;
label: string;
icon: LucideIcon;
iconBg: string;
iconColor: string;
}> = [
{
key: "all",
label: "All bookings",
icon: LayoutList,
iconBg: "#ECF6F1",
iconColor: "#0A8A5F",
},
{
key: "active",
label: "In progress",
icon: Package,
iconBg: "#FDF3E0",
iconColor: "#C77F09",
},
{
key: "payment",
label: "Awaiting payment",
icon: Wallet,
iconBg: "#FEF6E6",
iconColor: "#F2A516",
},
{
key: "draft",
label: "Drafts",
icon: FileEdit,
iconBg: "#F1F4F7",
iconColor: "#475569",
},
{
key: "done",
label: "Completed",
icon: CheckCircle2,
iconBg: "#ECF6F1",
iconColor: "#0A8A5F",
},
];
// ── Status badge (reuses the shared portal status config) ─────────────────────
function StatusBadge({ status }: { status: string }) {
const cfg = STATUS_CONFIG[status];
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
const text = cfg
? `var(--mantine-color-${cfg.badgeText}-7, #475569)`
: "#475569";
const dot = cfg
? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)`
: "#94A3B8";
return (
<Group
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: bg,
padding: "5px 11px",
}}
>
<Box
style={{
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: dot,
flexShrink: 0,
}}
/>
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
{label}
</Text>
</Group>
);
}
// ── Context-sensitive action button ───────────────────────────────────────────
function PrimaryAction({
booking,
onNavigate,
}: {
booking: Freight.IBooking;
onNavigate: (path: string) => void;
}) {
const { status, id } = booking;
const go = () => onNavigate(`/bookings/${id}`);
// A general contract is payable as soon as it's FULLY_EXECUTED (signed); a
// one-time booking only after it's SELECTED_FOR_BATCH.
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
if (status === "DRAFT") {
return (
<Button
size="xs"
radius="md"
fw={700}
fz={13}
rightSection={<ArrowRight size={14} />}
style={{
backgroundColor: "var(--mantine-color-edr-ink-0)",
color: "#fff",
}}
onClick={go}
>
Continue
</Button>
);
}
// CHANGES_REQUESTED + clearance/operation steps are handled in place by a
// modal (update & resubmit, upload clearance docs, schedule & proceed).
if (bookingHasInlineAction(booking)) {
return <BookingActionButton booking={booking} size="xs" />;
}
const payableStatus = isGeneralContract
? "FULLY_EXECUTED"
: "SELECTED_FOR_BATCH";
if (status === payableStatus && booking.paymentStatus !== "PAID") {
return <PayNowButton booking={booking} />;
}
return (
<Button
size="xs"
radius="md"
variant="default"
fw={600}
fz={13}
onClick={go}
>
View
</Button>
);
}
function ColHeader({ label }: { label: string }) {
return (
<Text
fz={11}
fw={700}
c="edr-muted"
style={{
letterSpacing: "0.6px",
textTransform: "uppercase",
whiteSpace: "nowrap",
}}
>
{label}
</Text>
);
}
const hMeta = { headerClassName: "bg-[#F4F7FA]" };
function fmtDate(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime())
? ""
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
// ── Main component ────────────────────────────────────────────────────────────
// Lightweight count query for a single lifecycle filter (reads only `total`).
function useStatusCount(statuses: string | undefined): number | undefined {
const { data } = useQuery(
api.bookings.list.queryOptions({
input: { statuses, page: 1, pageSize: 1 },
staleTime: 30_000,
}),
);
return data?.meta?.total;
}
function StatCard({
card,
active,
count,
onSelect,
}: {
card: (typeof STAT_CARDS)[number];
active: boolean;
count: number | undefined;
onSelect: () => void;
}) {
const Icon = card.icon;
return (
<Paper
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
p="md"
radius="lg"
withBorder
style={{
cursor: "pointer",
transition: "box-shadow 140ms ease, border-color 140ms ease",
borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)",
boxShadow: active ? "0 0 0 1px #F2A516" : "none",
}}
>
<Group gap={12} wrap="nowrap" align="center">
<Box
style={{
width: 42,
height: 42,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: card.iconBg,
color: card.iconColor,
}}
>
<Icon size={20} strokeWidth={2} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz={24} fw={800} lh={1.05} c="edr-text">
{count ?? "—"}
</Text>
<Text fz={12} fw={600} c="edr-muted" truncate>
{card.label}
</Text>
</Box>
</Group>
</Paper>
);
}
export default function BookingsListPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all");
const [query, setQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [freightFilter, setFreightFilter] = useState<string | null>(null);
const [sort, setSort] = useState<string>("createdAt:DESC");
const [createdFrom, setCreatedFrom] = useState<string>("");
const [createdTo, setCreatedTo] = useState<string>("");
const [trackingBooking, setTrackingBooking] =
useState<Freight.IBooking | null>(null);
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"];
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
const selectFilter = (key: StatusFilterKey) => {
setStatusFilter(key);
resetPage();
};
const hasExtraFilters =
!!typeFilter || !!freightFilter || !!createdFrom || !!createdTo;
const clearExtraFilters = () => {
setTypeFilter(null);
setFreightFilter(null);
setCreatedFrom("");
setCreatedTo("");
resetPage();
};
const filter: BookingListFilter = useMemo(
() => ({
statuses,
bookingType: typeFilter ?? undefined,
freightType: freightFilter ?? undefined,
createdFrom: createdFrom || undefined,
// include the whole selected end day
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy,
sortOrder,
}),
[
statuses,
typeFilter,
freightFilter,
createdFrom,
createdTo,
pagination.pageIndex,
pagination.pageSize,
sortBy,
sortOrder,
],
);
const { data, isLoading, isError } = useQuery(
api.bookings.list.queryOptions({ input: filter }),
);
// Per-card lifecycle counts (one cheap query each, total-only).
const allCount = useStatusCount(undefined);
const activeCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
);
const paymentCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
);
const draftCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
);
const doneCount = useStatusCount(
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
);
const cardCounts: Record<StatusFilterKey, number | undefined> = {
all: allCount,
active: activeCount,
payment: paymentCount,
draft: draftCount,
done: doneCount,
transit: undefined,
closed: undefined,
};
const allItems = data?.items ?? [];
const total = data?.meta?.total ?? allItems.length;
// Server handles status + pagination; reference search is applied on the page
// (matches booking reference, contract reference, and route yards).
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return allItems;
return allItems.filter((b) =>
[
b.reference,
b.contractReference,
b.originYard?.label,
b.destinationYard?.label,
]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q)),
);
}, [allItems, query]);
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
const showEmpty = !isLoading && !isError && rows.length === 0;
const columns: ColumnDef<Freight.IBooking>[] = [
{
id: "booking",
size: 244,
meta: hMeta,
header: () => <ColHeader label="Booking" />,
cell: ({ row }) => {
const b = row.original;
const cargoLabel =
b.freightType === "BULK" ? "Bulk cargo" : "Container";
return (
<Group gap={12} wrap="nowrap" align="center">
<Box
style={{
width: 36,
height: 36,
borderRadius: 9,
flexShrink: 0,
backgroundColor: "var(--mantine-color-edr-soft-0)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Package
size={18}
color="var(--mantine-color-edr-green-7)"
strokeWidth={2}
/>
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} c="edr-text" truncate>
{b.reference}
</Text>
<Text fz={12} c="edr-muted">
{cargoLabel}
</Text>
</Box>
</Group>
);
},
},
{
id: "contract",
size: 150,
meta: hMeta,
header: () => <ColHeader label="Contract" />,
cell: ({ row }) => {
const ref = row.original.contractReference;
const cid = row.original.contractId;
if (!ref) {
return (
<Text fz={13} c="edr-muted">
</Text>
);
}
return (
<Text
fz={13}
fw={600}
c={cid ? "edr-green" : "edr-text"}
style={{ whiteSpace: "nowrap", cursor: cid ? "pointer" : "default" }}
onClick={
cid
? (e) => {
e.stopPropagation();
navigate(`/contracts/${cid}`);
}
: undefined
}
>
{ref}
</Text>
);
},
},
{
id: "type",
size: 150,
meta: hMeta,
header: () => <ColHeader label="Type" />,
cell: ({ row }) => <BookingTypeBadge booking={row.original} />,
},
{
id: "cargo",
size: 168,
meta: hMeta,
header: () => <ColHeader label="Cargo" />,
cell: ({ row }) => <CargoModeCell booking={row.original} />,
},
{
id: "route",
size: 196,
meta: hMeta,
header: () => <ColHeader label="Route" />,
cell: ({ row }) => {
const b = row.original;
const origin = b.originYard?.label ?? b.originYard?.code ?? "—";
const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—";
const sub = fmtDate(b.scheduledDate ?? b.createdAt);
return (
<Box>
<Text fz={13} fw={600} c="edr-text">
{origin} {dest}
</Text>
{sub && (
<Text fz={12} c="edr-muted">
{sub}
</Text>
)}
</Box>
);
},
},
{
id: "payment",
size: 130,
meta: hMeta,
header: () => <ColHeader label="Payment" />,
cell: ({ row }) => <PaymentBadge status={row.original.paymentStatus} />,
},
{
id: "scheduling",
size: 140,
meta: hMeta,
header: () => <ColHeader label="Train" />,
cell: ({ row }) => <SchedulingCell booking={row.original} />,
},
{
id: "status",
size: 190,
meta: hMeta,
header: () => <ColHeader label="Status" />,
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "amount",
size: 140,
meta: hMeta,
header: () => <ColHeader label="Amount" />,
cell: ({ row }) => {
const b = row.original as Freight.IBooking & {
totalAmount?: number;
amount?: number;
};
const amount = b.totalAmount ?? b.amount ?? null;
if (!amount) {
return (
<Text fz={14} fw={700} style={{ color: "#94A3B8" }}>
</Text>
);
}
return (
<Text fz={14} fw={700} c="edr-text">
ETB {amount.toLocaleString()}
</Text>
);
},
},
{
id: "actions",
meta: hMeta,
header: () => null,
cell: ({ row }) => {
const booking = row.original;
const trackable = TRACKABLE_STATUSES.has(booking.status);
return (
<Group
justify="flex-end"
gap={8}
wrap="nowrap"
onClick={(e) => e.stopPropagation()}
>
{trackable && (
<Button
size="xs"
radius="md"
variant="light"
color="edr-green"
fw={700}
fz={13}
leftSection={<Train size={14} />}
onClick={() => setTrackingBooking(booking)}
>
Track
</Button>
)}
<PrimaryAction booking={booking} onNavigate={navigate} />
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
<Menu.Target>
<ActionIcon
variant="transparent"
size={30}
radius="md"
aria-label="More options"
>
<MoreVertical size={16} color="#9AA8B5" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
View details
</Menu.Item>
{trackable && (
<Menu.Item
leftSection={<Train size={15} />}
onClick={() => setTrackingBooking(booking)}
>
Track shipment
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Group>
);
},
},
];
return (
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
{/* ── Page header ─────────────────────────────────────────────── */}
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Box>
<Group gap={10} align="center">
<Title
order={1}
fw={800}
fz={26}
style={{ letterSpacing: "-0.01em" }}
>
Bookings
</Title>
</Group>
<Text size="sm" c="edr-muted" mt={4}>
Track every cargo booking from draft to delivery.
</Text>
</Box>
<Button
component={Link}
to="/contracts"
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
>
New booking
</Button>
</Group>
{/* ── Summary stat cards ──────────────────────────────────────── */}
<SimpleGrid cols={{ base: 2, sm: 3, lg: 5 }} spacing="md">
{STAT_CARDS.map((card) => (
<StatCard
key={card.key}
card={card}
active={statusFilter === card.key}
count={cardCounts[card.key]}
onSelect={() => selectFilter(card.key)}
/>
))}
</SimpleGrid>
{/* ── Bookings table card ──────────────────────────────────────── */}
<Card p={0} style={{ overflow: "hidden" }}>
<Group
justify="space-between"
gap={12}
px={20}
py={14}
wrap="wrap"
style={{
borderBottom: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Group gap={10} wrap="wrap" style={{ flex: 1, minWidth: 260 }}>
<TextInput
placeholder="Search booking, contract, or route…"
leftSection={<Search size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
variant="transparent"
color="gray"
onClick={() => setQuery("")}
>
<X size={14} />
</ActionIcon>
) : null
}
radius="md"
style={{ flex: 1, minWidth: 200, maxWidth: 340 }}
/>
<Select
data={SELECT_DATA}
value={statusFilter}
onChange={(value) =>
selectFilter((value as StatusFilterKey) ?? "all")
}
allowDeselect={false}
radius="md"
checkIconPosition="right"
comboboxProps={{ withinPortal: true }}
style={{ width: 190 }}
aria-label="Filter by status"
/>
<Select
placeholder="Any type"
data={[
{ value: "ONE_TIME", label: "One-time" },
{ value: "GENERAL_CONTRACT", label: "General contract" },
]}
value={typeFilter}
onChange={(v) => {
setTypeFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 170 }}
aria-label="Filter by booking type"
/>
<Select
placeholder="Any cargo"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freightFilter}
onChange={(v) => {
setFreightFilter(v);
resetPage();
}}
clearable
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 150 }}
aria-label="Filter by cargo type"
/>
<Select
data={SORT_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
}))}
value={sort}
onChange={(v) => {
setSort(v ?? "createdAt:DESC");
resetPage();
}}
allowDeselect={false}
radius="md"
comboboxProps={{ withinPortal: true }}
style={{ width: 160 }}
aria-label="Sort bookings"
/>
<TextInput
type="date"
value={createdFrom}
onChange={(e) => {
setCreatedFrom(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 150 }}
aria-label="Created from"
placeholder="From"
/>
<TextInput
type="date"
value={createdTo}
onChange={(e) => {
setCreatedTo(e.currentTarget.value);
resetPage();
}}
radius="md"
style={{ width: 150 }}
aria-label="Created to"
placeholder="To"
/>
{hasExtraFilters && (
<Button
variant="subtle"
color="gray"
radius="md"
size="sm"
leftSection={<X size={14} />}
onClick={clearExtraFilters}
>
Clear
</Button>
)}
</Group>
<Text fz={12} c="edr-muted">
{total} booking{total !== 1 ? "s" : ""}
</Text>
</Group>
{showEmpty ? (
<Stack align="center" gap={4} px="lg" py={64} ta="center">
<ThemeIcon
size={56}
radius="lg"
color="edr-green"
variant="light"
mb="xs"
>
<Package size={28} />
</ThemeIcon>
<Text size="sm" fw={600} c="edr-text">
{query
? "No bookings match your search"
: "No bookings here yet"}
</Text>
<Text size="xs" c="edr-muted" maw={320}>
{query
? "Try a different reference or clear the search."
: "Bookings are created against a contract. Open a contract to book a shipment."}
</Text>
{!query && (
<Button
component={Link}
to="/contracts"
size="sm"
mt="md"
variant="light"
color="edr-green"
>
Go to contracts
</Button>
)}
</Stack>
) : (
<DataTable
columns={columns}
data={rows}
status={dataTableStatus}
onRowClick={(row) =>
navigate(`/bookings/${(row as Freight.IBooking).id}`)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none rounded-none"
footer={DataTableFooter}
/>
)}
</Card>
</Stack>
<ShipmentTrackingModal
opened={trackingBooking !== null}
onClose={() => setTrackingBooking(null)}
bookingId={trackingBooking?.id ?? ""}
bookingReference={trackingBooking?.reference ?? ""}
originLabel={
trackingBooking?.originYard?.label ??
trackingBooking?.originYard?.code
}
destinationLabel={
trackingBooking?.destinationYard?.label ??
trackingBooking?.destinationYard?.code
}
/>
</Box>
);
}

View File

@@ -526,23 +526,13 @@ export default function NewContractPage({
},
];
// Routespure origin→destination lanes, no quantity. Route #1 is primary;
// extras only apply to GENERAL contracts.
// Route — a single origin→destination lane, general contracts included.
const routes: Freight.CreateContractRouteInputDto[] = [
{
originYardId: data.originYard,
destinationYardId: data.destinationYard,
sortOrder: 0,
},
...(isGeneral
? (data.extraRoutes ?? [])
.filter((r) => r.originYard && r.destinationYard)
.map((r, i) => ({
originYardId: r.originYard,
destinationYardId: r.destinationYard,
sortOrder: i + 1,
}))
: []),
];
return {

View File

@@ -52,13 +52,9 @@ export function contractToFormValues(
const routes = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
// Contracts carry a single route now; older multi-route GENERAL contracts
// load only their primary route.
const primaryRoute = routes[0];
const extraRoutes = isGeneral
? routes.slice(1).map((r) => ({
originYard: r.originYardId,
destinationYard: r.destinationYardId,
}))
: [];
const scope = contract.cargoScope ?? [];
@@ -131,7 +127,6 @@ export function contractToFormValues(
originYard: primaryRoute?.originYardId ?? "",
destinationYard: primaryRoute?.destinationYardId ?? "",
extraRoutes,
documents: {},
};

View File

@@ -73,7 +73,7 @@ export const CONTRACT_KIND_OPTIONS: Array<{
{
value: "general_contract",
label: "General Contract",
description: "Ship multiple times over the validity window across routes.",
description: "Ship multiple times over the validity window on one route.",
},
];
@@ -107,9 +107,9 @@ export const CONTAINER_SIZES = ["20ft", "40ft"] as const;
export type ContainerSize = (typeof CONTAINER_SIZES)[number];
// A GENERAL-contract quantity cap. The Mantine NumberInput backing these fields
// can briefly emit "" / undefined / NaN (cleared or never-touched field); those
// all mean "uncapped", so coerce them to 0 before the >= 0 check rather than
// letting them fail validation and silently block the Cargo & Route step.
// can briefly emit "" / undefined / NaN (cleared or never-touched field);
// coerce those to 0 so the superRefine below can flag them with a clear
// "greater than 0" message instead of a type error.
const nonNegativeQuantityCap = z.preprocess(
(v) =>
v === "" || v === null || v === undefined || Number.isNaN(v) ? 0 : v,
@@ -167,34 +167,23 @@ export const contractFormSchema = z
// contract_cargo_scope row.
enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]),
// GENERAL only: per-size container quantity cap (total bookable over the
// validity window). Keyed by size; 0/undefined = uncapped. The NumberInput
// can momentarily hold "" / undefined (empty field) — coerce those to 0 so
// an untouched cap never blocks the step.
// validity window). Keyed by size; must be > 0 for every enabled size
// (enforced in the superRefine below).
containerSizeCaps: z
.record(z.string(), nonNegativeQuantityCap)
.default({}),
// Bulk scope: the cargo type path (group → commodity).
cargoTypePath: z.array(z.string()).default([]),
cargoFreeText: z.string().default(""),
// GENERAL only: total bulk tons/items bookable. 0 = uncapped. Same empty-
// field coercion as the container caps above.
// GENERAL only: total bulk tons/items bookable; must be > 0 (superRefine).
bulkQuantityCap: nonNegativeQuantityCap.default(0),
// Contract-level billing flags.
isHazardous: z.boolean().default(false),
isRefrigerated: z.boolean().default(false),
// ── Route ──
// ── Route ── (one route per contract — general contracts included)
originYard: z.string().min(1, "Select an origin yard."),
destinationYard: z.string().min(1, "Select a destination yard."),
// Additional routes for a GENERAL contract (route #1 is the primary above).
extraRoutes: z
.array(
z.object({
originYard: z.string().default(""),
destinationYard: z.string().default(""),
}),
)
.default([]),
documents: z.record(z.string(), z.any()).default({}),
notes: z.string().default(""),
@@ -260,6 +249,29 @@ export const contractFormSchema = z
});
}
}
// GENERAL contracts must carry a real (> 0) quantity cap — an untouched
// NumberInput coerces to 0 (see nonNegativeQuantityCap), which blocks the
// Cargo & Route step until the customer enters a quantity.
if (data.contractKind === "general_contract") {
if (data.cargoType === "container") {
for (const size of data.enabledContainerSizes) {
if (!(data.containerSizeCaps[size] > 0)) {
ctx.addIssue({
code: "custom",
path: ["containerSizeCaps", size],
message: `Enter a ${size} quantity greater than 0.`,
});
}
}
}
if (data.cargoType === "bulk" && !(data.bulkQuantityCap > 0)) {
ctx.addIssue({
code: "custom",
path: ["bulkQuantityCap"],
message: "Enter a total quantity greater than 0.",
});
}
}
});
export type ContractFormValues = z.infer<typeof contractFormSchema>;
@@ -289,7 +301,6 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
originYard: "",
destinationYard: "",
extraRoutes: [],
documents: {},
notes: "",
@@ -325,7 +336,6 @@ export const contractStepFields: Record<
"isRefrigerated",
"originYard",
"destinationYard",
"extraRoutes",
],
// Step 2 — Review & Submit. (The separate Documents step was removed — the
// company profile documents are attached to the contract automatically.)

View File

@@ -132,19 +132,12 @@ export function Step1ContractType({
);
form.setValue("customsClearingAgent", contract.customsClearingAgent ?? "");
// ── Route (primary + extras) ──
// ── Route (single route per contract) ──
const routes = contract.routes ?? [];
if (routes[0]) {
form.setValue("originYard", routes[0].originYardId);
form.setValue("destinationYard", routes[0].destinationYardId);
}
form.setValue(
"extraRoutes",
routes.slice(1).map((r) => ({
originYard: r.originYardId,
destinationYard: r.destinationYardId,
})),
);
// ── Cargo scope ──
form.setValue(

View File

@@ -227,14 +227,14 @@ export function Step3CargoScope({
{/* GENERAL contract quantity cap (draw-down ceiling). */}
{isGeneral && (
<Box>
<StepLabel>Booking quantity cap (optional)</StepLabel>
<StepLabel>Booking quantity cap *</StepLabel>
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
Total quantity bookable across all shipments under this contract.
Customers / GL can book repeatedly until it is reached. Leave 0 for
unlimited.
Customers / GL can book repeatedly until it is reached. Must be
greater than 0.
</Text>
{cargoType === "container" ? (
<Group gap={12} grow>
<Group gap={12} grow align="flex-start">
{enabledSizes.length === 0 ? (
<Text fz={13} c="dimmed">
Select container sizes above to set their caps.
@@ -245,13 +245,14 @@ export function Step3CargoScope({
key={size}
name={`containerSizeCaps.${size}`}
control={form.control}
render={({ field }) => (
render={({ field, fieldState }) => (
<NumberInput
label={`${size} cap (containers)`}
placeholder="0 = unlimited"
label={`${size} cap (containers) *`}
placeholder="e.g. 100"
min={0}
value={Number(field.value ?? 0)}
onChange={(v) => field.onChange(Number(v) || 0)}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
@@ -264,13 +265,14 @@ export function Step3CargoScope({
<Controller
name="bulkQuantityCap"
control={form.control}
render={({ field }) => (
render={({ field, fieldState }) => (
<NumberInput
label="Total cap (tons / items)"
placeholder="0 = unlimited"
label="Total cap (tons / items) *"
placeholder="e.g. 500"
min={0}
value={Number(field.value ?? 0)}
onChange={(v) => field.onChange(Number(v) || 0)}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>

View File

@@ -1,12 +1,8 @@
import type { Freight } from "@edr/types";
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { MapPin, Plus, Trash2 } from "lucide-react";
import { Skeleton, Stack } from "@mantine/core";
import { MapPin } from "lucide-react";
import { useCallback, useEffect, useMemo } from "react";
import {
Controller,
useFieldArray,
type UseFormReturn,
} from "react-hook-form";
import { Controller, type UseFormReturn } from "react-hook-form";
import { ContractFormInputValues, type ContractFormValues } from "./schema";
import { getRouteDirection } from "./helpers";
import { SelectField, StepLabel } from "./shared";
@@ -29,7 +25,6 @@ export function Step4Route({
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const operationType = form.watch("operationType");
const isGeneralContract = form.watch("contractKind") === "general_contract";
const { originCountry, destinationCountry } = useMemo(() => {
switch (operationType) {
@@ -46,14 +41,6 @@ export function Step4Route({
}
}, [operationType]);
const {
fields: extraRoutes,
append: appendRoute,
remove: removeRoute,
} = useFieldArray({ control: form.control, name: "extraRoutes" });
const watchedExtraRoutes = form.watch("extraRoutes") ?? [];
const yardOptions = useMemo(() => {
if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({ value: y.id, label: y.name }));
@@ -96,22 +83,6 @@ export function Step4Route({
}
}, [destinationCountry, dest, form]);
useEffect(() => {
watchedExtraRoutes.forEach((route, i) => {
const ro = referenceData?.yard.find((y) => y.id === route?.originYard);
if (originCountry && ro && ro.country !== originCountry) {
form.setValue(`extraRoutes.${i}.originYard`, "");
}
const rd = referenceData?.yard.find(
(y) => y.id === route?.destinationYard,
);
if (destinationCountry && rd && rd.country !== destinationCountry) {
form.setValue(`extraRoutes.${i}.destinationYard`, "");
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [originCountry, destinationCountry, referenceData, form]);
const directionStyle: Record<string, string> = {
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
@@ -173,94 +144,6 @@ export function Step4Route({
</div>
)}
{isGeneralContract && !isLoading && (
<Box mt={18}>
<Group justify="space-between" align="center" mb={8}>
<StepLabel>Additional contract routes</StepLabel>
<Button
variant="light"
color="edr-green"
size="xs"
radius="md"
leftSection={<Plus size={14} />}
disabled={stationSelectDisabled}
onClick={() =>
appendRoute({ originYard: "", destinationYard: "" })
}
>
Add route
</Button>
</Group>
<Text fz={12} c="#6B7C8E" mb={12}>
A general contract can cover several routes. The route above is your
primary route; add more origindestination routes the contract
should cover.
</Text>
<Stack gap={12}>
{extraRoutes.map((rf, i) => {
const rowOrigin = watchedExtraRoutes[i]?.originYard ?? "";
const rowDestination =
watchedExtraRoutes[i]?.destinationYard ?? "";
const rowOriginData = yardsForSide(originCountry, rowDestination);
const rowDestData = yardsForSide(destinationCountry, rowOrigin);
return (
<Group
key={rf.id}
gap={10}
align="flex-start"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 12 }}
>
<Box style={{ flex: 1 }}>
<Controller
name={`extraRoutes.${i}.originYard`}
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Origin"
placeholder="Origin..."
disabled={stationSelectDisabled}
data={rowOriginData}
/>
)}
/>
</Box>
<Box style={{ flex: 1 }}>
<Controller
name={`extraRoutes.${i}.destinationYard`}
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Destination"
placeholder="Destination..."
disabled={stationSelectDisabled}
data={rowDestData}
/>
)}
/>
</Box>
<Button
variant="subtle"
color="red"
size="xs"
mt={24}
px={6}
onClick={() => removeRoute(i)}
>
<Trash2 size={16} />
</Button>
</Group>
);
})}
</Stack>
</Box>
)}
</Stack>
);
}

View File

@@ -250,10 +250,6 @@ export function Step8Review({
? direction.charAt(0) + direction.slice(1).toLowerCase()
: "—";
const routesCount = 1 + (values.extraRoutes?.filter(
(r) => r.originYard && r.destinationYard,
).length ?? 0);
return (
<Stack gap="lg">
<StepHeader
@@ -335,17 +331,13 @@ export function Step8Review({
/>
<SummaryItem
icon={<Route size={18} />}
label="Primary route"
label="Route"
value={`${originYardName}${destinationYardName}`}
/>
<SummaryItem
icon={<MapPin size={18} />}
label="Trade direction"
value={
isGeneralContract
? `${directionLabel} · ${routesCount} routes`
: directionLabel
}
value={directionLabel}
/>
<SummaryItem
icon={<Package size={18} />}

View File

@@ -0,0 +1,33 @@
import type { Freight } from "@edr/types";
import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "../utils/api";
const B = URL_CONSTANTS.BOOKINGS;
/**
* Multi-truck self-haul assignment for a booking (no EDR first/last mile).
* Each truck carries 12 of the booking's containers and tracks its own arrival.
*/
export const customerTrucksService = {
list: async (bookingId: string): Promise<Freight.ICustomerTruck[]> => {
const { data } = await client.get(B.CUSTOMER_TRUCKS(bookingId));
return data.data ?? data;
},
add: async (
bookingId: string,
payload: Freight.AddCustomerTruckPayload,
): Promise<Freight.ICustomerTruck[]> => {
const { data } = await client.post(B.CUSTOMER_TRUCKS(bookingId), payload);
return data.data ?? data;
},
remove: async (
bookingId: string,
assignmentId: string,
): Promise<Freight.ICustomerTruck[]> => {
const { data } = await client.delete(B.CUSTOMER_TRUCK(bookingId, assignmentId));
return data.data ?? data;
},
};

View File

@@ -38,7 +38,7 @@
"@prisma/client": "^6.19.3",
"@sendgrid/mail": "^8.1.0",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.9.tgz",
"@types/bcrypt": "^6.0.0",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",

View File

@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "packageId" TEXT,
ADD COLUMN "priceTierId" TEXT;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "FraudAlert" ADD COLUMN "acknowledgedAt" TIMESTAMP(3);

View File

@@ -0,0 +1,13 @@
CREATE TABLE "passenger"."AppRelease" (
"id" TEXT NOT NULL,
"os" TEXT NOT NULL,
"version" TEXT NOT NULL,
"forceUpdate" BOOLEAN NOT NULL DEFAULT false,
"storeLink" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AppRelease_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "AppRelease_os_version_key" ON "passenger"."AppRelease"("os", "version");

View File

@@ -0,0 +1,21 @@
-- AlterTable: add package_departure_station_id to Booking
ALTER TABLE "passenger"."Booking"
ADD COLUMN "packageDepartureStationId" TEXT;
-- AlterTable: add package_departure_station_id to PackageBooking
ALTER TABLE "passenger"."PackageBooking"
ADD COLUMN "packageDepartureStationId" TEXT;
-- AddForeignKey: Booking -> Station
ALTER TABLE "passenger"."Booking"
ADD CONSTRAINT "Booking_packageDepartureStationId_fkey"
FOREIGN KEY ("packageDepartureStationId")
REFERENCES "passenger"."Station"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey: PackageBooking -> Station
ALTER TABLE "passenger"."PackageBooking"
ADD CONSTRAINT "PackageBooking_packageDepartureStationId_fkey"
FOREIGN KEY ("packageDepartureStationId")
REFERENCES "passenger"."Station"("id")
ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -330,7 +330,9 @@ model Station {
originSchedules TrainSchedule[] @relation("OriginTrips")
destinationSchedules TrainSchedule[] @relation("DestinationTrips")
stopTimes TripStopTime[]
crowdSignals StationCrowdSignal[]
crowdSignals StationCrowdSignal[]
bookingDepartures Booking[] @relation("BookingPackageDepartureStation")
packageBookingDepartures PackageBooking[] @relation("PackageBookingDepartureStation")
@@index([city, countryCode])
@@index([sequence])
@@schema("passenger")
@@ -506,6 +508,8 @@ model Booking {
bookingRef String @unique
passengerId String
scheduleId String
packageId String?
priceTierId String?
bookingType String @default("ONE_WAY")
status BookingStatus @default(DRAFT)
currency String @default("ETB")
@@ -539,11 +543,15 @@ model Booking {
promoCode String?
paidAt DateTime?
paymentReminderSentAt DateTime?
packageDepartureStationId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
passenger Passenger @relation(fields: [passengerId], references: [id])
schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id])
returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
package TravelPackage? @relation(fields: [packageId], references: [id])
priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id])
departureStation Station? @relation("BookingPackageDepartureStation", fields: [packageDepartureStationId], references: [id])
seats BookingSeat[]
paymentIntent PaymentIntent?
tickets Ticket[]
@@ -1301,6 +1309,7 @@ model FraudAlert {
context Json
severity String @default("MEDIUM")
acknowledged Boolean @default(false)
acknowledgedAt DateTime?
createdAt DateTime @default(now())
@@index([iamUserId, createdAt])
@@index([acknowledged])
@@ -1428,7 +1437,8 @@ model TravelPackage {
outboundSchedule TrainSchedule @relation("PackageOutbound", fields: [outboundScheduleId], references: [id])
returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id])
priceTiers PackagePriceTier[]
bookings PackageBooking[]
bookings Booking[]
packageBookings PackageBooking[]
inquiries PackageInquiry[]
@@index([status, validFrom])
@@ -1446,7 +1456,8 @@ model PackagePriceTier {
bookedSeats Int @default(0)
package TravelPackage @relation(fields: [packageId], references: [id])
bookings PackageBooking[]
bookings Booking[]
packageBookings PackageBooking[]
inquiries PackageInquiry[]
@@unique([packageId, seatType])
@@ -1472,10 +1483,12 @@ model PackageBooking {
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
packageDepartureStationId String?
package TravelPackage @relation(fields: [packageId], references: [id])
priceTier PackagePriceTier @relation(fields: [priceTierId], references: [id])
passenger Passenger? @relation(fields: [passengerId], references: [id])
departureStation Station? @relation("PackageBookingDepartureStation", fields: [packageDepartureStationId], references: [id])
passengers PackageBookingPassenger[]
paymentIntent PackagePaymentIntent?
@@ -1536,3 +1549,17 @@ model PackageInquiry {
@@index([packageId])
@@schema("passenger")
}
model AppRelease {
id String @id @default(uuid())
os String // "android" | "ios"
version String
forceUpdate Boolean @default(false)
storeLink String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([os, version])
@@schema("passenger")
}

View File

@@ -854,26 +854,64 @@ async function runStep(name: string, step: () => Promise<unknown>): Promise<bool
}
}
async function seedPackageBookings() {
const pkg = await prisma.travelPackage.findFirst({ where: { code: 'KULUBBI-2025' }, include: { priceTiers: true } });
if (!pkg || !pkg.priceTiers.length) {
console.log(' ⚠️ Kulubbi package not found, skipping package booking seed');
return;
}
const tier = pkg.priceTiers[0];
const passenger = await prisma.passenger.findFirst();
const existing = await prisma.packageBooking.findFirst({ where: { bookingRef: 'PKG-SEED01' } });
if (existing) { console.log(' Package booking seed already exists'); return; }
await prisma.packageBooking.create({
data: {
bookingRef: 'PKG-SEED01',
packageId: pkg.id,
priceTierId: tier.id,
passengerId: passenger?.id ?? null,
contactEmail: 'kelemu@email.com',
contactPhone: '+251911234567',
passengerCount: 2,
totalMinor: tier.priceMinor * 2,
currency: 'ETB',
displayCurrency: 'ETB',
displayTotalMinor: tier.priceMinor * 2,
status: 'PENDING_PAYMENT',
passengers: {
create: [
{ passengerName: 'Abebe Kebede', idDocumentType: 'NATIONAL_ID' },
{ passengerName: 'Tigist Alemu', idDocumentType: 'NATIONAL_ID' },
],
},
},
});
console.log(' ✅ Sample package booking created (PKG-SEED01)');
}
async function main() {
console.log('🌱 Comprehensive EDR Seed Starting...\n');
const steps: Array<[string, () => Promise<unknown>]> = [
['System Users', seedSystemUsers],
['Stations', seedStations],
['Coach Types & Classes', seedCoachTypesAndClasses],
['Route', seedRoute],
['Coaches', seedCoaches],
['Trips', seedTrips],
['Fare Rules', seedFareRules],
['Currency', seedCurrency],
['Payment Methods', seedPaymentMethods],
['Segment Fares', seedSegmentFares],
['Notification Templates', seedNotificationTemplates],
['Menu & Food', seedMenuAndFood],
['Promotions', seedPromotions],
['FAQ', seedFAQ],
['Fraud Rules', seedFraudRules],
['Kulubbi Package', seedKulubbiPackage],
// ['System Users', seedSystemUsers],
// ['Stations', seedStations],
// ['Coach Types & Classes', seedCoachTypesAndClasses],
// ['Route', seedRoute],
// ['Coaches', seedCoaches],
// ['Trips', seedTrips],
// ['Fare Rules', seedFareRules],
// ['Currency', seedCurrency],
// ['Payment Methods', seedPaymentMethods],
// ['Segment Fares', seedSegmentFares],
// ['Notification Templates', seedNotificationTemplates],
// ['Menu & Food', seedMenuAndFood],
// ['Promotions', seedPromotions],
// ['FAQ', seedFAQ],
// ['Fraud Rules', seedFraudRules],
// ['Kulubbi Package', seedKulubbiPackage],
// ['Package Bookings', seedPackageBookings],
];
let failed = 0;

View File

@@ -8,6 +8,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
import { IamModule as TriaIamModule } from '@tria-plc/iamapi-common/iam.module';
import { DataSeeder } from '@tria-plc/iamapi-common/db/seed/seeder';
import { EOtpType } from '@tria-plc/iamapi-common';
import { SharedAuthModule } from '@tria-plc/api-common/modules/auth/shared-auth.module';
import {
EDR_PASSENGER_APPLICATION,
@@ -61,6 +62,9 @@ import { PackagesModule } from './modules/packages/packages.module';
import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module';
import { HealthModule } from './modules/health/health.module';
import { TasksModule } from './modules/tasks/tasks.module';
import { AppReleasesModule } from './modules/app-releases/app-releases.module';
import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module';
import { SegmentFareSeeder } from './seed/segment-fare.seeder';
@Module({
imports: [
@@ -94,6 +98,16 @@ import { TasksModule } from './modules/tasks/tasks.module';
TriaIamModule.forRoot({
applications: [EDR_PASSENGER_APPLICATION],
permissions: EDR_PASSENGER_PERMISSIONS,
otpMessages: {
[EOtpType.MFA_LOGIN]: ({ otp }) =>
`Your EDR Passenger login code is ${otp}. It will expire in 5 minutes.`,
[EOtpType.VERIFY_PHONE_NUMBER]: ({ otp }) =>
`Your EDR Passenger phone verification code is ${otp}. It will expire in 5 minutes.`,
[EOtpType.RESET_PASSWORD]: ({ route }) =>
`Reset your EDR Passenger password using this link: ${route}`,
[EOtpType.SET_PASSWORD]: ({ route }) =>
`Set your EDR Passenger password using this link: ${route}`,
},
}),
SharedAuthModule,
PrismaModule,
@@ -130,6 +144,8 @@ import { TasksModule } from './modules/tasks/tasks.module';
ExcessBaggageModule,
HealthModule,
TasksModule,
AppReleasesModule,
ConfigurableFareModule,
],
providers: [
{ provide: APP_GUARD, useClass: DynamicThrottlerGuard },
@@ -137,6 +153,7 @@ import { TasksModule } from './modules/tasks/tasks.module';
DynamicThrottlerGuard,
EdrPassengerOrgSeeder,
PassengerStaffUsersSeeder,
SegmentFareSeeder,
],
})
export class AppModule implements OnApplicationBootstrap {
@@ -145,6 +162,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly seeder: DataSeeder,
private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder,
private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder,
private readonly segmentFareSeeder: SegmentFareSeeder,
) {}
async onApplicationBootstrap() {
@@ -163,5 +181,10 @@ export class AppModule implements OnApplicationBootstrap {
} catch (err) {
this.logger.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message);
}
try {
await this.segmentFareSeeder.run();
} catch (err) {
this.logger.error('[SegmentFareSeeder] Seed failed (non-fatal):', (err as Error).message);
}
}
}

View File

@@ -6,6 +6,7 @@ import {
HttpStatus,
Logger,
} from '@nestjs/common';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
@@ -22,15 +23,27 @@ export class HttpExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse();
const request = ctx.getRequest();
let prismaMessage: string | null = null;
if (exception instanceof PrismaClientKnownRequestError) {
if (exception.code === 'P2003') {
const field = (exception.meta?.field_name as string | undefined) ?? 'a related record';
prismaMessage = `Cannot delete this record because it is still referenced by ${field}. Remove the related records first.`;
} else if (exception.code === 'P2025') {
prismaMessage = 'Record not found.';
}
}
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
: prismaMessage
? HttpStatus.BAD_REQUEST
: HttpStatus.INTERNAL_SERVER_ERROR;
const messageRaw =
exception instanceof HttpException
? exception.getResponse()
: 'Internal server error';
: prismaMessage ?? 'Internal server error';
const message =
typeof messageRaw === 'string'

View File

@@ -0,0 +1,50 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, SetMetadata } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiParam } from '@nestjs/swagger';
import { AppReleasesService, AppReleaseDto } from './app-releases.service';
import { PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('App Releases')
@Controller('app-releases')
export class AppReleasesController {
constructor(private service: AppReleasesService) {}
@Get()
@SetMetadata('isPublic', true)
@ApiOperation({ summary: 'List all app releases (public)' })
getAll() {
return this.service.getAll();
}
@Get('latest/:os')
@SetMetadata('isPublic', true)
@ApiOperation({ summary: 'Get latest release for a given OS (public)' })
@ApiParam({ name: 'os', enum: ['android', 'ios'] })
getLatest(@Param('os') os: string) {
return this.service.getLatest(os);
}
@Post()
@PassengerStaff(PASSENGER_PERMS.admin)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create an app release (admin)' })
create(@Body() dto: AppReleaseDto) {
return this.service.create(dto);
}
@Patch(':id')
@PassengerStaff(PASSENGER_PERMS.admin)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update an app release (admin)' })
update(@Param('id') id: string, @Body() dto: Partial<AppReleaseDto>) {
return this.service.update(id, dto);
}
@Delete(':id')
@PassengerStaff(PASSENGER_PERMS.admin)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete an app release (admin)' })
remove(@Param('id') id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AppReleasesController } from './app-releases.controller';
import { AppReleasesService } from './app-releases.service';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [AppReleasesController],
providers: [AppReleasesService],
})
export class AppReleasesModule {}

View File

@@ -0,0 +1,71 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PrismaService } from '../../common/prisma.service';
export class AppReleaseDto {
@ApiProperty({ enum: ['android', 'ios'] })
@IsIn(['android', 'ios'])
os: string;
@ApiProperty({ example: '1.2.3' })
@IsString()
version: string;
@ApiProperty({ default: false })
@IsBoolean()
forceUpdate: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
storeLink?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
@Injectable()
export class AppReleasesService {
constructor(private prisma: PrismaService) {}
private get db() {
return (this.prisma as any);
}
getAll() {
return this.db.appRelease.findMany({ orderBy: [{ os: 'asc' }, { createdAt: 'desc' }] });
}
async getLatest(os: string) {
const release = await this.db.appRelease.findFirst({
where: { os },
orderBy: { createdAt: 'desc' },
});
if (!release) throw new NotFoundException(`No release found for ${os}`);
return release;
}
async create(dto: AppReleaseDto) {
const existing = await this.db.appRelease.findUnique({
where: { os_version: { os: dto.os, version: dto.version } },
});
if (existing) throw new ConflictException(`Release ${dto.os} ${dto.version} already exists`);
return this.db.appRelease.create({ data: dto });
}
async update(id: string, dto: Partial<AppReleaseDto>) {
const release = await this.db.appRelease.findUnique({ where: { id } });
if (!release) throw new NotFoundException('App release not found');
return this.db.appRelease.update({ where: { id }, data: dto });
}
async remove(id: string) {
const release = await this.db.appRelease.findUnique({ where: { id } });
if (!release) throw new NotFoundException('App release not found');
await this.db.appRelease.delete({ where: { id } });
return { deleted: true, id };
}
}

View File

@@ -30,8 +30,8 @@ export class AuditController {
entityType: entityType || undefined,
};
const items = await this.auditService.getLogs(filters);
return { items };
const result = await this.auditService.getLogs(filters);
return { items: result.data, total: result.total, limit: result.limit, offset: result.offset };
}
@Get('logs/:id')

View File

@@ -137,6 +137,12 @@ export class CreateBookingDto {
@IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto)
passengers: PassengerInputDto[];
@ApiPropertyOptional({ description: 'Package ID — when set, fare is taken from the package price tier instead of the fare engine' })
@IsOptional() @IsString() packageId?: string;
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
@IsOptional() @IsString() priceTierId?: string;
@ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' })
@IsOptional() @IsString() promoCode?: string;

View File

@@ -17,6 +17,25 @@ function generateRef(): string {
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
/**
* For package round-trip bookings, totalMinor in the DB may have been stored as a
* single-leg amount before the server fix. Recompute from the tier price when needed.
* tierPriceMinor is the per-leg per-adult price from PackagePackagePriceTier.
*/
function resolvePackageRoundTripTotal(
booking: { totalMinor: number; bookingType: string; packageId?: string | null },
tierPriceMinor: number | null | undefined,
adultCount: number,
childCount: number,
): number {
if (!booking.packageId || booking.bookingType !== 'ROUND_TRIP' || !tierPriceMinor) {
return booking.totalMinor;
}
const adultFareMinor = tierPriceMinor * 2;
const childFareMinor = Math.round(adultFareMinor * 0.1);
return adultCount * adultFareMinor + childCount * childFareMinor;
}
function calculateAge(dateOfBirth: Date): number {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
@@ -82,6 +101,7 @@ export class BookingsService {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true,
seats: { include: { seat: true } },
priceTier: { select: { priceMinor: true } },
},
}),
this.prisma.booking.count({ where }),
@@ -92,7 +112,7 @@ export class BookingsService {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.totalMinor,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
@@ -161,6 +181,7 @@ export class BookingsService {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true,
seats: { include: { seat: true } },
priceTier: { select: { priceMinor: true } },
},
}),
this.prisma.booking.count({ where }),
@@ -171,7 +192,7 @@ export class BookingsService {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.totalMinor,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
@@ -202,9 +223,12 @@ export class BookingsService {
async findAll(filters: BookingFilters = {}) {
const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const onlyPackages = bookingType === 'PACKAGE';
const includePackageBookings = !returnLegStatus && bookingType !== 'ONE_WAY' && bookingType !== 'ROUND_TRIP' && bookingType !== 'TRANSIT' && bookingType !== 'ROUND_TRIP_TRANSIT';
const where: any = {};
if (search) {
const iamRows = await this.dataSource.query<{ id: string }[]>(
`SELECT u.id FROM iam.users u
@@ -229,10 +253,10 @@ export class BookingsService {
{ seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
];
}
if (status) where.status = status;
if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
if (bookingType) where.bookingType = bookingType;
if (bookingType && !onlyPackages) where.bookingType = bookingType;
if (dateFrom || dateTo) {
where.createdAt = {
...(dateFrom ? { gte: new Date(dateFrom) } : {}),
@@ -240,17 +264,127 @@ export class BookingsService {
};
}
if (paymentStatus) {
const statusMap: Record<string, string> = {
PAID: 'SUCCEEDED',
PENDING: 'REQUIRES_ACTION',
FAILED: 'FAILED',
REFUNDED: 'REFUNDED',
};
const statusMap: Record<string, string> = { PAID: 'SUCCEEDED', PENDING: 'REQUIRES_ACTION', FAILED: 'FAILED', REFUNDED: 'REFUNDED' };
const mapped = statusMap[paymentStatus] ?? paymentStatus;
where.paymentIntent = { is: { status: mapped } };
}
const [items, total] = await Promise.all([
const pkgWhere: any = {};
if (search) {
pkgWhere.OR = [
{ bookingRef: { contains: search, mode: 'insensitive' } },
{ contactEmail: { contains: search, mode: 'insensitive' } },
{ contactPhone: { contains: search, mode: 'insensitive' } },
{ passengers: { some: { passengerName: { contains: search, mode: 'insensitive' } } } },
];
}
if (status) pkgWhere.status = status;
if (dateFrom || dateTo) pkgWhere.createdAt = where.createdAt;
if (paymentStatus) pkgWhere.paymentIntent = { is: { status: (where.paymentIntent as any)?.is?.status } };
if (onlyPackages) {
// Package bookings live in two places:
// 1. PackageBooking table (dedicated package bookings)
// 2. Booking table with packageId != null (round-trip bookings linked to a package)
const bookingPkgWhere: any = { packageId: { not: null } };
if (status) bookingPkgWhere.status = status;
if (dateFrom || dateTo) bookingPkgWhere.createdAt = where.createdAt;
if (paymentStatus) bookingPkgWhere.paymentIntent = where.paymentIntent;
if (search) bookingPkgWhere.OR = where.OR;
const [pkgItems, pkgTotal, regPkgItems, regPkgTotal] = await Promise.all([
this.prisma.packageBooking.findMany({
where: pkgWhere,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
package: { select: { id: true, name: true, code: true } },
priceTier: { select: { id: true, label: true, seatType: true } },
passengers: true,
paymentIntent: true,
},
}),
this.prisma.packageBooking.count({ where: pkgWhere }),
this.prisma.booking.findMany({
where: bookingPkgWhere,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
passenger: { select: { id: true, iamUserId: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true,
seats: { include: { seat: true } },
priceTier: { select: { priceMinor: true } },
},
}),
this.prisma.booking.count({ where: bookingPkgWhere }),
]);
const iamUserIds = regPkgItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
[iamUserIds],
)
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
const mappedRegPkg = regPkgItems.map((booking: any) => {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB',
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true,
returnLegStatus: (booking as any).returnLegStatus ?? null,
adultCount: booking.adultCount, childCount: booking.childCount,
createdAt: booking.createdAt,
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers,
schedule: booking.schedule ? {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
} : null,
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
});
const mappedPkg = pkgItems.map((b: any) => ({
id: b.id, bookingRef: b.bookingRef, status: b.status,
totalMinor: b.totalMinor, currency: b.currency || 'ETB',
displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor,
contactEmail: b.contactEmail, contactPhone: b.contactPhone,
bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId,
isPackageBooking: true, packageName: b.package?.name, packageCode: b.package?.code,
tierLabel: b.priceTier?.label ?? null,
returnLegStatus: null, adultCount: b.passengerCount, childCount: 0,
createdAt: b.createdAt, passenger: null,
passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [],
passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [],
schedule: null, paymentIntent: b.paymentIntent, seatCount: b.passengerCount,
}));
const total = pkgTotal + regPkgTotal;
const allItems = [...mappedPkg, ...mappedRegPkg]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, pageSize);
return {
items: allItems,
meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
};
}
const [regularItems, regularTotal, pkgItems, pkgTotal] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
@@ -261,12 +395,27 @@ export class BookingsService {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
paymentIntent: true,
seats: { include: { seat: true } },
package: { select: { id: true, name: true, code: true } },
priceTier: { select: { id: true, label: true, priceMinor: true } },
},
}),
this.prisma.booking.count({ where }),
includePackageBookings
? this.prisma.packageBooking.findMany({
where: pkgWhere,
orderBy: { createdAt: 'desc' },
include: {
package: { select: { id: true, name: true, code: true } },
priceTier: { select: { id: true, label: true, seatType: true } },
passengers: true,
paymentIntent: true,
},
})
: Promise.resolve([] as any[]),
includePackageBookings ? this.prisma.packageBooking.count({ where: pkgWhere }) : Promise.resolve(0),
]);
const iamUserIds = items.map(b => b.passenger?.iamUserId).filter(Boolean) as string[];
const iamUserIds = regularItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
@@ -275,59 +424,86 @@ export class BookingsService {
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
const mappedRegular = regularItems.map((booking: any) => {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
bookingType: booking.bookingType,
packageId: booking.packageId ?? null,
priceTierId: (booking as any).priceTierId ?? null,
packageName: (booking as any).package?.name ?? null,
packageCode: (booking as any).package?.code ?? null,
tierLabel: (booking as any).priceTier?.label ?? null,
isPackageBooking: !!booking.packageId,
returnLegStatus: (booking as any).returnLegStatus ?? null,
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt,
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
});
const mappedPkg = pkgItems.map((b: any) => ({
id: b.id,
bookingRef: b.bookingRef,
status: b.status,
totalMinor: b.totalMinor,
currency: b.currency || 'ETB',
displayCurrency: b.displayCurrency,
displayTotalMinor: b.displayTotalMinor,
contactEmail: b.contactEmail,
contactPhone: b.contactPhone,
bookingType: 'PACKAGE',
packageId: b.packageId,
priceTierId: b.priceTierId,
isPackageBooking: true,
packageName: b.package?.name,
packageCode: b.package?.code,
tierLabel: b.priceTier?.label ?? null,
returnLegStatus: null,
adultCount: b.passengerCount,
childCount: 0,
createdAt: b.createdAt,
passenger: null,
passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [],
passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [],
schedule: null,
paymentIntent: b.paymentIntent,
seatCount: b.passengerCount,
}));
const total = regularTotal + pkgTotal;
const allItems = [...mappedRegular, ...mappedPkg]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, pageSize);
return {
items: items.map(booking => {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
// Build passenger list with categories
const passengerDetails = booking.seats.map((s: any) => ({
name: s.passengerName,
category: s.passengerCategory // 'ADULT' or 'CHILD'
}));
// Get unique names with their categories
const uniquePassengers = Array.from(
new Map(passengerDetails.map(p => [p.name, p])).values()
);
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.totalMinor,
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
adultCount: booking.adultCount,
childCount: booking.childCount,
createdAt: booking.createdAt,
passenger: iam
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
: null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers, // Include category info
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
},
paymentIntent: booking.paymentIntent,
seatCount: booking.seats.length,
};
}),
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
items: allItems,
meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
};
}
async create(dto: CreateBookingDto) {
async create(dto: CreateBookingDto) {
if (dto.bookingType === 'ROUND_TRIP') return this.createRoundTripBooking(dto);
if (dto.bookingType === 'TRANSIT') return this.createTransitBooking(dto);
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createRoundTripTransitBooking(dto);
@@ -363,7 +539,9 @@ export class BookingsService {
const passengersData = await this.processPassengers(dto.passengers as any[]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const fareCalculation = await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
const fareCalculation = dto.packageId && dto.priceTierId
? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount)
: await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = fareCalculation.totalMinor;
@@ -371,20 +549,18 @@ export class BookingsService {
displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
}
// Track which child gets free fare (first child encountered)
// Track per-seat fare. For package bookings children pay 10% of adult fare;
// for regular bookings the first child is free.
let freeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let fareMinor: number;
if (p.category === PassengerCategory.ADULT) {
fareMinor = fareCalculation.baseFareMinor;
} else if (dto.packageId) {
fareMinor = Math.round(fareCalculation.baseFareMinor * 0.1);
} else {
// Child: first child is free, subsequent children pay full fare
if (!freeChildUsed) {
fareMinor = 0;
freeChildUsed = true;
} else {
fareMinor = fareCalculation.baseFareMinor;
}
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
else fareMinor = fareCalculation.baseFareMinor;
}
return { ...p, fareMinor };
});
@@ -401,6 +577,7 @@ export class BookingsService {
childCount,
displayCurrency,
displayTotalMinor,
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
seats: {
create: passengersWithFares.map(p => ({
seat: { connect: { id: p.seatId } },
@@ -421,6 +598,12 @@ export class BookingsService {
});
await this.seatsService.confirmSeats(passengersData.map(p => p.seatId));
if (dto.packageId && dto.priceTierId) {
await this.prisma.packagePriceTier.update({
where: { id: dto.priceTierId },
data: { bookedSeats: { increment: passengersData.length } },
});
}
this.eventEmitter.emit('booking.created', { booking });
return { ...booking, fareBreakdown: fareCalculation };
}
@@ -468,23 +651,40 @@ export class BookingsService {
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const [outboundFare, returnFare] = await Promise.all([
this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount),
this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount)
]);
const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor;
// Package bookings use fixed tier price split equally across both legs
let outboundFare: Awaited<ReturnType<typeof this.calculateFare>>;
let returnFare: Awaited<ReturnType<typeof this.calculateFare>>;
let combinedBaseFareMinor: number;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
let loyaltyMinor = 0;
let totalMinor: number;
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
if (dto.packageId && dto.priceTierId) {
const pkgFare = await this.calculatePackageFare(dto.priceTierId, adultCount, childCount);
// pkgFare covers one leg; round-trip = both legs combined
const roundTripTotal = pkgFare.totalMinor * 2;
// Split evenly across both legs for per-seat fare recording
const halfMinor = Math.round(pkgFare.baseFareMinor / 2);
outboundFare = { ...pkgFare, baseFareMinor: halfMinor, totalBaseFareMinor: Math.round(pkgFare.totalBaseFareMinor / 2) };
returnFare = { ...pkgFare, baseFareMinor: pkgFare.baseFareMinor - halfMinor, totalBaseFareMinor: pkgFare.totalBaseFareMinor - Math.round(pkgFare.totalBaseFareMinor / 2) };
combinedBaseFareMinor = pkgFare.totalBaseFareMinor * 2;
totalMinor = roundTripTotal;
} else {
[outboundFare, returnFare] = await Promise.all([
this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount),
this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount)
]);
combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
}
const taxesMinor = 0;
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
@@ -492,34 +692,27 @@ export class BookingsService {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
}
// Track which child gets free fare for outbound and return legs
// Track per-seat fare. For package bookings children pay 10% of adult fare;
// for regular bookings the first child is free per leg.
let outboundFreeChildUsed = false;
let returnFreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let outboundFareMinor: number;
let returnFareMinor: number;
if (p.category === PassengerCategory.ADULT) {
outboundFareMinor = outboundFare.baseFareMinor;
returnFareMinor = returnFare.baseFareMinor;
} else if (dto.packageId) {
outboundFareMinor = Math.round(outboundFare.baseFareMinor * 0.1);
returnFareMinor = Math.round(returnFare.baseFareMinor * 0.1);
} else {
// Child fare for outbound
if (!outboundFreeChildUsed) {
outboundFareMinor = 0;
outboundFreeChildUsed = true;
} else {
outboundFareMinor = outboundFare.baseFareMinor;
}
// Child fare for return
if (!returnFreeChildUsed) {
returnFareMinor = 0;
returnFreeChildUsed = true;
} else {
returnFareMinor = returnFare.baseFareMinor;
}
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
else outboundFareMinor = outboundFare.baseFareMinor;
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
else returnFareMinor = returnFare.baseFareMinor;
}
return { ...p, outboundFareMinor, returnFareMinor };
});
@@ -541,6 +734,7 @@ export class BookingsService {
returnHoldId: dto.returnHoldId,
returnSeatClassId: dto.returnSeatClassId,
returnLegStatus: 'NEITHER_USED',
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
seats: {
create: [
...passengersWithFares.map(p => ({
@@ -586,6 +780,13 @@ export class BookingsService {
this.seatsService.confirmSeats(returnSeatIds)
]);
if (dto.packageId && dto.priceTierId) {
await this.prisma.packagePriceTier.update({
where: { id: dto.priceTierId },
data: { bookedSeats: { increment: passengersData.length } },
});
}
this.eventEmitter.emit('booking.created', { booking });
return {
@@ -1048,6 +1249,34 @@ export class BookingsService {
return { adultCount, childCount };
}
private async calculatePackageFare(
priceTierId: string,
adultCount: number,
childCount: number,
) {
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } });
// For round-trip packages the caller splits the tier price across legs, so
// priceMinor here is already the per-leg amount. Children pay 10% of adult fare.
const childFareMinor = Math.round(tier.priceMinor * 0.1);
const adultFareMinor = tier.priceMinor * adultCount;
const childTotalMinor = childFareMinor * childCount;
const totalBaseFareMinor = adultFareMinor + childTotalMinor;
return {
baseFareMinor: tier.priceMinor,
adultCount,
adultFareMinor,
childCount,
freeChildrenCount: 0,
paidChildrenCount: childCount,
childFareMinor: childTotalMinor,
totalBaseFareMinor,
discountMinor: 0,
loyaltyRedemptionMinor: 0,
taxesFeesMinor: 0,
totalMinor: totalBaseFareMinor,
};
}
private async calculateFare(
scheduleId: string,
seatClassId: string,
@@ -1180,12 +1409,70 @@ export class BookingsService {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: { take: 1 },
priceTier: { select: { priceMinor: true } },
},
});
if (!booking) throw new NotFoundException('Booking not found');
if (!booking) {
// Fall back to PackageBooking
const pkgBooking = await this.prisma.packageBooking.findUnique({
where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId },
include: {
package: { include: { outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } }, returnSchedule: { include: { originStation: true, destinationStation: true } } } },
priceTier: true,
passengers: true,
paymentIntent: true,
},
});
if (!pkgBooking) throw new NotFoundException('Booking not found');
return {
id: pkgBooking.id,
bookingRef: pkgBooking.bookingRef,
status: pkgBooking.status,
totalMinor: pkgBooking.totalMinor,
currency: pkgBooking.currency || 'ETB',
adultCount: pkgBooking.passengerCount,
childCount: 0,
displayCurrency: pkgBooking.displayCurrency,
displayTotalMinor: pkgBooking.displayTotalMinor ?? undefined,
bookingType: 'PACKAGE',
packageId: pkgBooking.packageId,
priceTierId: pkgBooking.priceTierId,
packageName: (pkgBooking as any).package?.name,
packageCode: (pkgBooking as any).package?.code,
tierLabel: (pkgBooking as any).priceTier?.label,
isPackageBooking: true,
returnLegStatus: null,
contactEmail: pkgBooking.contactEmail,
contactPhone: pkgBooking.contactPhone,
createdAt: pkgBooking.createdAt,
schedule: (pkgBooking as any).package?.outboundSchedule ? {
id: (pkgBooking as any).package.outboundSchedule.id,
trainNumber: (pkgBooking as any).package.outboundSchedule.train?.number,
trainName: (pkgBooking as any).package.outboundSchedule.train?.name,
origin: (pkgBooking as any).package.outboundSchedule.originStation,
destination: (pkgBooking as any).package.outboundSchedule.destinationStation,
departureAt: (pkgBooking as any).package.outboundSchedule.departureAt,
arrivalAt: (pkgBooking as any).package.outboundSchedule.arrivalAt,
} : null,
passengers: (pkgBooking as any).passengers?.map((p: any) => ({
fullName: p.passengerName,
category: 'ADULT',
leg: 1,
fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount),
verifaydaVerified: false,
seat: null,
})),
payment: (pkgBooking as any).paymentIntent
? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status }
: undefined,
ticket: undefined,
};
}
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: booking.totalMinor, currency: 'ETB',
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB',
adultCount: booking.adultCount, childCount: booking.childCount,
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,
bookingType: booking.bookingType,

View File

@@ -144,6 +144,12 @@ export class CreateGuestBookingDto {
@ApiPropertyOptional({ example: 'device-uuid-12345', description: 'Device ID for local storage of passenger details' })
@IsOptional() @IsString() deviceId?: string;
@ApiPropertyOptional({ description: 'Package ID — when set, fare is taken from the package price tier' })
@IsOptional() @IsString() packageId?: string;
@ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' })
@IsOptional() @IsString() priceTierId?: string;
}
export class SavedPassengerProfileDto {

View File

@@ -81,8 +81,10 @@ export class GuestBookingService {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId)
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId)
?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
@@ -142,21 +144,34 @@ export class GuestBookingService {
});
}
// Calculate fare
const primaryNationality = passengersData[0]?.nationality;
const baseFareMinor = await this.getBaseFare(
dto.scheduleId,
dto.seatClassId,
segmentRoute,
fullRoute,
primaryNationality,
dto.originStationId,
dto.destinationStationId,
);
// Calculate fare — package bookings use the fixed tier price, bypassing the fare engine
const isPackageOneway = !!dto.packageId && !!dto.priceTierId;
let baseFareMinor: number;
let paidChildrenCount: number;
let childUnitFare: number;
if (isPackageOneway) {
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } });
baseFareMinor = tier.priceMinor;
paidChildrenCount = childCount;
childUnitFare = Math.round(baseFareMinor * 0.1);
} else {
const primaryNationality = passengersData[0]?.nationality;
baseFareMinor = await this.getBaseFare(
dto.scheduleId,
dto.seatClassId,
segmentRoute,
fullRoute,
primaryNationality,
dto.originStationId,
dto.destinationStationId,
);
paidChildrenCount = Math.max(0, childCount - 1);
childUnitFare = baseFareMinor;
}
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
const childFareMinor = baseFareMinor * paidChildrenCount;
const childFareMinor = childUnitFare * paidChildrenCount;
const totalBaseFareMinor = adultFareMinor + childFareMinor;
let discountMinor = 0;
@@ -215,6 +230,7 @@ export class GuestBookingService {
displayCurrency,
displayTotalMinor,
bookingType: 'ONE_WAY',
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
userAgent: dto.deviceId,
contactEmail: firstPassenger.email || null,
contactPhone: firstPassenger.phone || null,
@@ -229,7 +245,7 @@ export class GuestBookingService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0),
fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : childUnitFare,
displayCurrency,
})),
},
@@ -256,7 +272,7 @@ export class GuestBookingService {
adultCount,
adultFareMinor,
childCount,
freeChildrenCount: Math.min(childCount, 1),
freeChildrenCount: isPackageOneway ? 0 : Math.min(childCount, 1),
paidChildrenCount,
childFareMinor,
totalBaseFareMinor,
@@ -306,10 +322,16 @@ export class GuestBookingService {
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
}
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId);
const synth = (sched: any, stationId: string, seq: number) => {
const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation;
return { stationId, sequence: seq, station };
};
const obStops = outboundSchedule.stopTimes.length > 0 ? outboundSchedule.stopTimes : [synth(outboundSchedule, outboundSchedule.originStationId, 0), synth(outboundSchedule, outboundSchedule.destinationStationId, 1)];
const retStops = returnSchedule.stopTimes.length > 0 ? returnSchedule.stopTimes : [synth(returnSchedule, returnSchedule.originStationId, 0), synth(returnSchedule, returnSchedule.destinationStationId, 1)];
const outboundOriginStop = obStops.find((s: any) => s.stationId === dto.originStationId) ?? obStops[0];
const outboundDestStop = obStops.find((s: any) => s.stationId === dto.destinationStationId) ?? obStops[obStops.length - 1];
const returnOriginStop = retStops.find((s: any) => s.stationId === dto.returnOriginStationId) ?? retStops[0];
const returnDestStop = retStops.find((s: any) => s.stationId === dto.returnDestinationStationId) ?? retStops[retStops.length - 1];
if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule');
if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule');
@@ -358,18 +380,36 @@ export class GuestBookingService {
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality });
}
// Calculate fares for both legs
// Calculate fares for both legs — package bookings use the fixed tier price split across legs
const returnSeatClassId = dto.returnSeatClassId || dto.seatClassId;
const primaryNationality = passengersData[0]?.nationality;
const isPackageRoundTrip = !!dto.packageId && !!dto.priceTierId;
let outboundBaseFare: number;
let returnBaseFare: number;
let paidChildrenCount: number;
let outboundChildUnitFare: number;
let returnChildUnitFare: number;
const [outboundBaseFare, returnBaseFare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
]);
const paidChildrenCount = Math.max(0, childCount - 1);
const outboundTotalBase = outboundBaseFare * adultCount + outboundBaseFare * paidChildrenCount;
const returnTotalBase = returnBaseFare * adultCount + returnBaseFare * paidChildrenCount;
if (isPackageRoundTrip) {
const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: dto.priceTierId! } });
// tier.priceMinor is the full round-trip price per adult; split evenly across legs
const halfMinor = Math.round(tier.priceMinor / 2);
outboundBaseFare = halfMinor;
returnBaseFare = tier.priceMinor - halfMinor;
paidChildrenCount = childCount;
outboundChildUnitFare = Math.round(outboundBaseFare * 0.1);
returnChildUnitFare = Math.round(returnBaseFare * 0.1);
} else {
const primaryNationality = passengersData[0]?.nationality;
[outboundBaseFare, returnBaseFare] = await Promise.all([
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
]);
paidChildrenCount = Math.max(0, childCount - 1);
outboundChildUnitFare = outboundBaseFare;
returnChildUnitFare = returnBaseFare;
}
const outboundTotalBase = outboundBaseFare * adultCount + outboundChildUnitFare * paidChildrenCount;
const returnTotalBase = returnBaseFare * adultCount + returnChildUnitFare * paidChildrenCount;
const combinedBaseFareMinor = outboundTotalBase + returnTotalBase;
let discountMinor = 0;
@@ -415,6 +455,7 @@ export class GuestBookingService {
returnHoldId: dto.returnHoldId,
returnSeatClassId,
returnLegStatus: 'NEITHER_USED',
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
userAgent: dto.deviceId,
contactEmail: passengersData[0]?.email || null,
contactPhone: passengersData[0]?.phone || null,
@@ -432,7 +473,7 @@ export class GuestBookingService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : (paidChildrenCount > 0 ? outboundBaseFare : 0),
fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : outboundChildUnitFare,
displayCurrency,
})),
...passengersData.map((p) => ({
@@ -447,7 +488,7 @@ export class GuestBookingService {
passportCountry: p.passportCountry,
verifaydaVerified: p.verifaydaVerified,
verifaydaData: p.verifaydaData || undefined,
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : (paidChildrenCount > 0 ? returnBaseFare : 0),
fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : returnChildUnitFare,
displayCurrency,
})),
],
@@ -476,7 +517,7 @@ export class GuestBookingService {
returnBaseFareMinor: returnBaseFare,
adultCount,
childCount,
freeChildrenCount: Math.min(childCount, 1),
freeChildrenCount: isPackageRoundTrip ? 0 : Math.min(childCount, 1),
paidChildrenCount,
combinedBaseFareMinor,
discountMinor,

View File

@@ -329,7 +329,7 @@ export class ConfigurableFareService {
);
if (childRule) {
const freeChildren = Math.min(childCount, childRule.max_free_passengers);
const freeChildren = Math.min(childCount, adultCount);
const paidChildren = Math.max(0, childCount - freeChildren);
if (freeChildren > 0) {

View File

@@ -130,8 +130,8 @@ export class FareEngineService {
const adultCount = dto.adultCount ?? 1;
const childCount = dto.childCount ?? 0;
const freeChildrenCount = Math.min(childCount, 1);
const paidChildrenCount = Math.max(0, childCount - 1);
const freeChildrenCount = Math.min(childCount, adultCount);
const paidChildrenCount = Math.max(0, childCount - freeChildrenCount);
// Subtotal includes: (distance-based fare + premium + insurance) × passengers
// First child is free, but pays premium and insurance
@@ -169,7 +169,7 @@ export class FareEngineService {
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
``,
`Adults: ${adultCount} × ${farePerPassengerMinor} = ${adultSubtotal} ETB minor`,
`Children: ${childCount} (${freeChildrenCount} free + ${paidChildrenCount} paid)`,
`Children: ${childCount} (${freeChildrenCount} free [1 per adult] + ${paidChildrenCount} paid)`,
` Free child: ${freeChildrenCount} × ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`,
` Paid child: ${paidChildrenCount} × ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`,
``,

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common';
import { Controller, Get, Post, Patch, Param, Body, Query, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { FraudService, FraudRuleConfig } from './fraud.service';
import { PassengerStaff } from '../../common/passenger-guards';
@@ -48,6 +48,31 @@ export class FraudController {
return { data: rule, message: 'Rule updated successfully' };
}
/**
* Acknowledge a fraud alert
*/
@Patch('alerts/:id/acknowledge')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Acknowledge a fraud alert' })
async acknowledgeAlert(@Param('id') id: string) {
const alert = await this.fraudService.acknowledgeAlert(id);
return { data: alert, message: 'Alert acknowledged' };
}
/**
* Block user via userId
*/
@Post('users/:userId/block')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Block user by userId' })
async blockUserById(
@Param('userId') userId: string,
@Body() body: { reason?: string; durationMinutes?: number },
) {
await this.fraudService.blockUserTemporarily(userId, body.durationMinutes ?? 60);
return { message: `User blocked for ${body.durationMinutes ?? 60} minutes` };
}
/**
* Block user temporarily
*/

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FraudService } from './fraud.service';
import { FraudController } from './fraud.controller';
@Module({
imports: [HttpModule],
imports: [HttpModule, TypeOrmModule],
providers: [FraudService],
controllers: [FraudController],
exports: [FraudService],

View File

@@ -164,6 +164,16 @@ export class FraudService {
this.logger.log(`Passenger (iamUserId=${iamUserId}) unblocked`);
}
/**
* Acknowledge a fraud alert
*/
async acknowledgeAlert(id: string) {
return this.prisma.fraudAlert.update({
where: { id },
data: { acknowledged: true, acknowledgedAt: new Date() },
});
}
/**
* Get all fraud alerts
*/

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, Post, Delete, UseGuards, SetMetadata, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { LoyaltyService } from './loyalty.service';
import { JwtGuard } from '../../common/jwt.guard';
@@ -9,7 +9,9 @@ import { JwtGuard } from '../../common/jwt.guard';
@ApiBearerAuth('JWT-auth')
export class LoyaltyController {
constructor(private service: LoyaltyService) {}
@Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all loyalty accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); }
@Get(':passengerId') @ApiOperation({ summary: 'Get loyalty account with tier progress' }) getAccount(@Param('passengerId') id: string) { return this.service.getAccount(id); }
@Get(':passengerId/rewards') @ApiOperation({ summary: 'Get available rewards' }) getRewards(@Param('passengerId') id: string) { return this.service.getRewards(id); }
@Post(':passengerId/rewards/:rewardId/redeem') @ApiOperation({ summary: 'Redeem a loyalty reward' }) redeemReward(@Param('passengerId') pid: string, @Param('rewardId') rid: string) { return this.service.redeemReward(pid, rid); }
@Delete('accounts/:id') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete loyalty account' }) deleteAccount(@Param('id') id: string) { return this.service.deleteAccount(id); }
}

Some files were not shown because too many files have changed in this diff Show More