mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -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
|
||||
|
||||
67
apps/edr-freight-api/py/README.md
Normal file
67
apps/edr-freight-api/py/README.md
Normal 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.
|
||||
Binary file not shown.
504
apps/edr-freight-api/py/create_contracts.py
Normal file
504
apps/edr-freight-api/py/create_contracts.py
Normal 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()
|
||||
3
apps/edr-freight-api/py/requirements.txt
Normal file
3
apps/edr-freight-api/py/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
requests>=2.31
|
||||
psycopg[binary]>=3.1
|
||||
python-dotenv>=1.0
|
||||
BIN
apps/edr-freight-api/py/waafi.jpeg
Normal file
BIN
apps/edr-freight-api/py/waafi.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,59 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Multi-truck customer (self-haul) assignment. Replaces the single
|
||||
* booking.customer_truck_* fields with a per-booking list of trucks, each
|
||||
* carrying 1–2 containers and tracking its own arrival. The legacy
|
||||
* booking.customer_truck_* columns are kept as a synced booking-level flag
|
||||
* (any truck assigned / all trucks arrived) so the warehouse exit-gate and
|
||||
* delivery-approval logic keep working.
|
||||
*/
|
||||
export class AddCustomerTruckAssignments1950000000000 implements MigrationInterface {
|
||||
name = 'AddCustomerTruckAssignments1950000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<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;`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
@@ -61,6 +62,11 @@ import {
|
||||
} from './dto/request-changes.dto';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||
import { CustomerTruckService } from './customer-truck.service';
|
||||
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
||||
import { ContainerReceiptService } from './container-receipt.service';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
import {
|
||||
@@ -83,6 +89,8 @@ export class BookingsController {
|
||||
private readonly transitionService: BookingTransitionService,
|
||||
private readonly contractService: BookingContractService,
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
private readonly customerTruckService: CustomerTruckService,
|
||||
private readonly containerReceiptService: ContainerReceiptService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -309,6 +317,94 @@ export class BookingsController {
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/customer-trucks')
|
||||
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
|
||||
async listCustomerTrucks(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.customerTruckService.listTrucks(id);
|
||||
}
|
||||
|
||||
@Post(':id/customer-trucks')
|
||||
@ApiOperation({ summary: 'Add a customer self-haul truck carrying 1–2 of the booking containers' })
|
||||
async addCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AddCustomerTruckDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.customerTruckService.addTruck(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/customer-trucks/:assignmentId')
|
||||
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
|
||||
async removeCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.customerTruckService.removeTruck(id, assignmentId);
|
||||
}
|
||||
|
||||
@Post(':id/customer-trucks/:assignmentId/depart')
|
||||
@ApiOperation({
|
||||
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
|
||||
})
|
||||
async departCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||
@Body() dto: DepartCustomerTruckDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Weighing + registering the load on exit is a warehouse/gate staff action.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
throw new ForbiddenException('Only warehouse staff can register a truck departure');
|
||||
}
|
||||
return this.customerTruckService.departTruck(id, assignmentId, dto);
|
||||
}
|
||||
|
||||
@Get(':id/received-pending-grn')
|
||||
@ApiOperation({ summary: 'Containers received into port but not yet on a GRN' })
|
||||
async receivedPendingGrn(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// GRN is a warehouse-staff action — no customer access.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
throw new ForbiddenException('Only warehouse staff can view or generate GRNs');
|
||||
}
|
||||
return this.containerReceiptService.listReceivedPendingGrn(id);
|
||||
}
|
||||
|
||||
@Post(':id/generate-grn')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch',
|
||||
})
|
||||
async generateGrn(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: GenerateGrnDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// GRN is a warehouse-staff action — no customer access.
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
throw new ForbiddenException('Only warehouse staff can view or generate GRNs');
|
||||
}
|
||||
return this.containerReceiptService.generateGrn(id, dto.containerNumbers);
|
||||
}
|
||||
|
||||
@Get(':id/tracking')
|
||||
@ApiOperation({
|
||||
summary: "Shipment tracking timeline for a booking",
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||
|
||||
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
||||
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||||
|
||||
interface BookingGuardRow {
|
||||
tradeDirection: string | null;
|
||||
firstMile: string | null;
|
||||
lastMile: string | null;
|
||||
paymentStatus: string | null;
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-truck self-haul assignment. A booking with no EDR first/last-mile leg
|
||||
* can have several customer trucks, each carrying 1–2 of its containers and
|
||||
* tracking its own arrival. The legacy booking.customer_truck_* columns are kept
|
||||
* as a booking-level flag (any truck assigned / all arrived) so the warehouse
|
||||
* exit-gate + delivery-approval logic keep working unchanged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomerTruckService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly assignments: CustomerTruckAssignmentsRepository,
|
||||
) {}
|
||||
|
||||
listTrucks(bookingId: string): Promise<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 1–2 known containers. IMPORT: containers are
|
||||
// not pre-specified — they are registered + weighed when the truck leaves.
|
||||
if (isExport) {
|
||||
if (requested.length < 1 || requested.length > 2) {
|
||||
throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers');
|
||||
}
|
||||
} else if (requested.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
}
|
||||
|
||||
if (requested.length) {
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
}
|
||||
}
|
||||
const alreadyAssigned = await this.assignedContainerNumbers(bookingId);
|
||||
for (const n of requested) {
|
||||
if (alreadyAssigned.includes(n)) {
|
||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const assignment = await manager.getRepository(CustomerTruckAssignment).save(
|
||||
manager.getRepository(CustomerTruckAssignment).create({
|
||||
bookingId,
|
||||
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||
driverName: dto.driverName.trim(),
|
||||
truckType: dto.truckType.trim(),
|
||||
}),
|
||||
);
|
||||
await manager.getRepository(CustomerTruckContainer).save(
|
||||
requested.map((containerNumber) =>
|
||||
manager.getRepository(CustomerTruckContainer).create({
|
||||
assignmentId: assignment.id,
|
||||
bookingId,
|
||||
containerNumber,
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Booking-level flag: first truck marks the booking as truck-assigned.
|
||||
await manager.query(
|
||||
`UPDATE freight.bookings
|
||||
SET customer_truck_assigned_at = COALESCE(customer_truck_assigned_at, NOW()),
|
||||
status = CASE WHEN status = 'PAID' THEN 'TRUCK_ASSIGNED' ELSE status END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[bookingId],
|
||||
);
|
||||
});
|
||||
|
||||
return this.listTrucks(bookingId);
|
||||
}
|
||||
|
||||
async removeTruck(bookingId: string, assignmentId: string): Promise<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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
||||
|
||||
/**
|
||||
* Add one external customer truck to a booking.
|
||||
* - EXPORT: the truck delivers 1–2 known containers (required, validated in the
|
||||
* service against the booking's containers).
|
||||
* - IMPORT: the customer does not pre-specify — containers are registered and
|
||||
* weighed when the truck leaves, so `containerNumbers` may be omitted/empty.
|
||||
*/
|
||||
export class AddCustomerTruckDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(32)
|
||||
truckPlateNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
driverName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(CUSTOMER_TRUCK_TYPES)
|
||||
truckType!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
each: true,
|
||||
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
containerNumbers?: string[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { Booking } from './booking.entity';
|
||||
import { CustomerTruckContainer } from './customer-truck-container.entity';
|
||||
|
||||
/**
|
||||
* One external (self-haul) truck a customer assigns to a booking that has no
|
||||
* EDR first/last-mile leg. Each truck carries 1–2 containers and tracks its own
|
||||
* arrival at the terminal/warehouse.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'customer_truck_assignments' })
|
||||
@Index(['bookingId'])
|
||||
export class CustomerTruckAssignment extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'plate_number', type: 'varchar', length: 32 })
|
||||
plateNumber!: string;
|
||||
|
||||
@Column({ name: 'driver_name', type: 'varchar', length: 120 })
|
||||
driverName!: string;
|
||||
|
||||
@Column({ name: 'truck_type', type: 'varchar', length: 60 })
|
||||
truckType!: string;
|
||||
|
||||
@Column({ name: 'assigned_at', type: 'timestamptz', default: () => 'now()' })
|
||||
assignedAt!: Date;
|
||||
|
||||
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||
arrivedAt?: Date | null;
|
||||
|
||||
/** Weighed gross of what the truck actually loaded (import), captured on
|
||||
* leaving. Null until the truck departs. */
|
||||
@Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
||||
grossWeightKg?: number | null;
|
||||
|
||||
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||
departedAt?: Date | null;
|
||||
|
||||
@OneToMany(() => CustomerTruckContainer, (c) => c.assignment, { cascade: true })
|
||||
containers?: CustomerTruckContainer[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BATCH_WINDOW_START_HOURS,
|
||||
listConfigBookingWindows,
|
||||
groupBookingsIntoBoardWindows,
|
||||
computeImportWindowTimes,
|
||||
type BoardWindowConfig,
|
||||
} from './batch-window.util';
|
||||
|
||||
@@ -54,11 +55,145 @@ describe('batch-window.util', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeImportWindowTimes — first-window open respects office hours', () => {
|
||||
// Departs Mon 06 Jul 08:00 EAT (05:00 UTC). Lead 3 days → anchor 03 Jul 08:00
|
||||
// EAT (05:00 UTC). Bounded desk 08:00–17:00, 15h window.
|
||||
const departure = new Date('2026-07-06T05:00:00.000Z');
|
||||
const bounded = {
|
||||
importWindowLeadDays: 3,
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
windowDurationHours: 15,
|
||||
};
|
||||
|
||||
it('opens at the morning anchor when now is before the lead window', () => {
|
||||
// Now = 02 Jul 06:00 EAT (before the 03 Jul anchor).
|
||||
const now = new Date('2026-07-02T03:00:00.000Z');
|
||||
const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now);
|
||||
// Anchor: 03 Jul 08:00 EAT = 05:00 UTC.
|
||||
expect(windowOpensAt.toISOString()).toBe('2026-07-03T05:00:00.000Z');
|
||||
});
|
||||
|
||||
it('opens NOW when inside the lead window and inside office hours (past the anchor)', () => {
|
||||
// Now = 05 Jul 12:00 EAT (09:00 UTC): inside lead days, inside 08:00–17:00,
|
||||
// anchor already passed → open immediately.
|
||||
const now = new Date('2026-07-05T09:00:00.000Z');
|
||||
const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now);
|
||||
expect(windowOpensAt.toISOString()).toBe('2026-07-05T09:00:00.000Z');
|
||||
});
|
||||
|
||||
it('waits for next morning when now is after the desk closes', () => {
|
||||
// Departs 06 Jul 14:00 EAT (11:00 UTC) so next-morning open sits before departure.
|
||||
// Now = 05 Jul 18:00 EAT (15:00 UTC): after 17:00 close → open 06 Jul 08:00 EAT.
|
||||
const lateDeparture = new Date('2026-07-06T11:00:00.000Z');
|
||||
const now = new Date('2026-07-05T15:00:00.000Z');
|
||||
const { windowOpensAt } = computeImportWindowTimes(lateDeparture, bounded, now);
|
||||
// 06 Jul 08:00 EAT = 05:00 UTC.
|
||||
expect(windowOpensAt.toISOString()).toBe('2026-07-06T05:00:00.000Z');
|
||||
});
|
||||
|
||||
it('opens this morning when now is before the desk opens on a lead day', () => {
|
||||
// Now = 05 Jul 06:00 EAT (03:00 UTC): inside lead days but before 08:00 → 08:00 today.
|
||||
const now = new Date('2026-07-05T03:00:00.000Z');
|
||||
const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now);
|
||||
expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z');
|
||||
});
|
||||
|
||||
it('24-hour desk opens NOW at any hour, day or night, once inside the lead window', () => {
|
||||
// Round-the-clock desk (open === close). Now = 05 Jul 03:00 EAT (00:00 UTC),
|
||||
// deep night, past the anchor → open immediately.
|
||||
const roundClock = { ...bounded, windowOpenHour: 8, windowCloseHour: 8 };
|
||||
const now = new Date('2026-07-05T00:00:00.000Z');
|
||||
const { windowOpensAt } = computeImportWindowTimes(departure, roundClock, now);
|
||||
expect(windowOpensAt.toISOString()).toBe('2026-07-05T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('caps the close at departure', () => {
|
||||
// Opens now (05 Jul 12:00 EAT); a 24h duration would close 06 Jul 12:00 EAT,
|
||||
// past the 06 Jul 08:00 departure → clamped to departure.
|
||||
const now = new Date('2026-07-05T09:00:00.000Z');
|
||||
const { windowClosesAt } = computeImportWindowTimes(
|
||||
departure,
|
||||
{ ...bounded, windowDurationHours: 24 },
|
||||
now,
|
||||
);
|
||||
expect(windowClosesAt.toISOString()).toBe(departure.toISOString());
|
||||
});
|
||||
|
||||
describe('overnight desk (open > close, wraps past midnight)', () => {
|
||||
// Desk open 08:00, closes 05:00 next morning — open across midnight.
|
||||
const overnight = { ...bounded, windowOpenHour: 8, windowCloseHour: 5 };
|
||||
|
||||
it('opens NOW at 00:00 (deep night is INSIDE the overnight window)', () => {
|
||||
// Now = 05 Jul 00:00 EAT (04 Jul 21:00 UTC): after midnight, before 05:00 →
|
||||
// inside the overnight desk → open immediately. This is the reported bug.
|
||||
const now = new Date('2026-07-04T21:00:00.000Z');
|
||||
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
|
||||
expect(windowOpensAt.toISOString()).toBe('2026-07-04T21:00:00.000Z');
|
||||
});
|
||||
|
||||
it('opens NOW at 22:00 (evening is INSIDE the overnight window)', () => {
|
||||
// Now = 05 Jul 22:00 EAT (19:00 UTC): after 08:00 open → inside → open now.
|
||||
const now = new Date('2026-07-05T19:00:00.000Z');
|
||||
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
|
||||
expect(windowOpensAt.toISOString()).toBe('2026-07-05T19:00:00.000Z');
|
||||
});
|
||||
|
||||
it('waits to 08:00 when now is in the daytime gap [05:00, 08:00)', () => {
|
||||
// Now = 05 Jul 06:00 EAT (03:00 UTC): desk shut (gap) → open 08:00 today.
|
||||
const now = new Date('2026-07-05T03:00:00.000Z');
|
||||
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
|
||||
// 05 Jul 08:00 EAT = 05:00 UTC.
|
||||
expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeImportWindowTimes — overnight desk (open > close, wraps midnight)', () => {
|
||||
// Overnight desk 08:00 → 07:00 next morning: open across [08:00, 24:00) and
|
||||
// [00:00, 07:00). Only the daytime gap [07:00, 08:00) is shut.
|
||||
const overnight = {
|
||||
importWindowLeadDays: 3,
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 7,
|
||||
windowDurationHours: 6,
|
||||
};
|
||||
|
||||
it('opens NOW in the evening side of the window (after open hour)', () => {
|
||||
// Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 05 Jul 20:00 EAT (17:00 UTC):
|
||||
// ≥ 08:00 → desk open → open immediately.
|
||||
const departure = new Date('2026-07-06T07:00:00.000Z');
|
||||
const now = new Date('2026-07-05T17:00:00.000Z');
|
||||
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
|
||||
expect(windowOpensAt.toISOString()).toBe('2026-07-05T17:00:00.000Z');
|
||||
});
|
||||
|
||||
it('opens NOW after midnight (before close hour)', () => {
|
||||
// Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 06 Jul 02:00 EAT (05 Jul 23:00
|
||||
// UTC): < 07:00 → still inside the overnight window → open immediately.
|
||||
const departure = new Date('2026-07-06T07:00:00.000Z');
|
||||
const now = new Date('2026-07-05T23:00:00.000Z');
|
||||
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
|
||||
expect(windowOpensAt.toISOString()).toBe('2026-07-05T23:00:00.000Z');
|
||||
});
|
||||
|
||||
it('waits until open hour in the daytime gap [close, open)', () => {
|
||||
// Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 05 Jul 07:30 EAT (04:30 UTC):
|
||||
// in the shut daytime gap → opens 05 Jul 08:00 EAT (05:00 UTC).
|
||||
const departure = new Date('2026-07-06T07:00:00.000Z');
|
||||
const now = new Date('2026-07-05T04:30:00.000Z');
|
||||
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
|
||||
expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch-window board windows (config-driven booking cycles)', () => {
|
||||
// Default rules: open 08:00 EAT, 3 days before departure, 3h long, reopen 90m later.
|
||||
// Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure,
|
||||
// 3h long, reopen 90m later.
|
||||
const cfg: BoardWindowConfig = {
|
||||
importWindowLeadDays: 3,
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
windowDurationHours: 3,
|
||||
reopenDelayMinutes: 90,
|
||||
exportBookingLeadHours: 24,
|
||||
@@ -76,14 +211,43 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
|
||||
expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z');
|
||||
});
|
||||
|
||||
it('import: reopens reopenDelayMinutes after close, same booking day', () => {
|
||||
it('import: reopens reopenDelayMinutes after close while inside office hours', () => {
|
||||
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
|
||||
// cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT
|
||||
// cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT, same day
|
||||
expect(windows.length).toBeGreaterThanOrEqual(2);
|
||||
expect(windows[1].start.toISOString()).toBe('2026-06-05T09:30:00.000Z'); // 12:30 EAT
|
||||
// all cycles stay on the same EAT booking day
|
||||
expect(windows.every((w) => w.date === '2026-06-05')).toBe(true);
|
||||
expect(windows[1].date).toBe('2026-06-05');
|
||||
});
|
||||
|
||||
it('import: pauses at close hour and resumes next morning at open hour', () => {
|
||||
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
|
||||
// Day 05 Jun: 08:00, 12:30, 17:00-clamped… the cycle whose reopen lands
|
||||
// at/after 17:00 EAT rolls to 06 Jun 08:00 EAT (05:00 UTC).
|
||||
const day6First = windows.find((w) => w.date === '2026-06-06');
|
||||
expect(day6First).toBeDefined();
|
||||
expect(day6First!.start.toISOString()).toBe('2026-06-06T05:00:00.000Z'); // 08:00 EAT
|
||||
// Cycles span the office days between the window day and departure.
|
||||
const days = new Set(windows.map((w) => w.date));
|
||||
expect(days.has('2026-06-05')).toBe(true);
|
||||
expect(days.has('2026-06-06')).toBe(true);
|
||||
});
|
||||
|
||||
it('import: 24-hour desk (open hour === close hour) never breaks for the day', () => {
|
||||
const roundClock: BoardWindowConfig = { ...cfg, windowOpenHour: 8, windowCloseHour: 8 };
|
||||
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||
const windows = listConfigBookingWindows('IMPORT', departure, roundClock);
|
||||
// Reopen chains straight through midnight: an overnight cycle exists.
|
||||
const crossesNight = windows.some(
|
||||
(w, i) => i > 0 && windows[i - 1].date !== w.date,
|
||||
);
|
||||
expect(crossesNight).toBe(true);
|
||||
// Cycles run continuously from the window day up to departure — the last one
|
||||
// reaches departure, proving the runaway cap did not truncate the projection.
|
||||
expect(windows[windows.length - 1].end.getTime()).toBe(departure.getTime());
|
||||
// Spans the full lead (window day 05 Jun → departure 08 Jun).
|
||||
expect(new Set(windows.map((w) => w.date)).size).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('export: single FCFS window exportBookingLeadHours before departure', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
importWindowLeadDays: 3,
|
||||
exportBookingLeadHours: 24,
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]] : []),
|
||||
];
|
||||
|
||||
|
||||
@@ -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) ──────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 & 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 & close T1
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { isAxiosError } from "axios";
|
||||
import { Clock, Info, Moon, Sun } from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import DurationField from "@/components/trainScheduling/DurationField";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { UpdateScheduleWindowRulePayload } from "@/types/trainScheduling";
|
||||
|
||||
/** Fallbacks matching the API's global-rules defaults (used when a field is null). */
|
||||
const DEFAULTS = {
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
importWindowLeadDays: 3,
|
||||
};
|
||||
|
||||
/** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
|
||||
function hourLabel(hour: number): string {
|
||||
const period = hour < 12 ? "AM" : "PM";
|
||||
const h12 = hour % 12 === 0 ? 12 : hour % 12;
|
||||
return `${h12}:00 ${period}`;
|
||||
}
|
||||
|
||||
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({
|
||||
value: String(h),
|
||||
label: `${hourLabel(h)} · ${String(h).padStart(2, "0")}:00`,
|
||||
}));
|
||||
|
||||
interface FormState {
|
||||
windowOpenHour: number;
|
||||
windowCloseHour: number;
|
||||
windowDurationHours: number | "";
|
||||
docReviewMinutes: number | "";
|
||||
paymentWindowMinutes: number | "";
|
||||
importWindowLeadDays: number | "";
|
||||
}
|
||||
|
||||
function parseError(error: unknown, fallback: string): string {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface BookingWindowSettingsModalProps {
|
||||
scheduleId: string | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after a successful save (e.g. to refetch a list). */
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-schedule booking-window settings editor. Prefills from the schedule's own
|
||||
* rule snapshot, lets staff tune the daily desk hours / durations for just that
|
||||
* train, and saves an override. Only editable before the window opens.
|
||||
*/
|
||||
export default function BookingWindowSettingsModal({
|
||||
scheduleId,
|
||||
opened,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: BookingWindowSettingsModalProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const detailQuery = useQuery({
|
||||
...api.trainScheduling.scheduleDetail.queryOptions({
|
||||
input: { id: scheduleId ?? "" },
|
||||
}),
|
||||
enabled: opened && Boolean(scheduleId),
|
||||
});
|
||||
const schedule = detailQuery.data;
|
||||
|
||||
const save = useMutation(
|
||||
api.trainScheduling.updateScheduleWindowRule.mutationOptions(),
|
||||
);
|
||||
|
||||
const [form, setForm] = useState<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 (1–2)"
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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 A–Z" },
|
||||
{ value: "reference:DESC", label: "Reference Z–A" },
|
||||
] 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>
|
||||
);
|
||||
}
|
||||
@@ -526,23 +526,13 @@ export default function NewContractPage({
|
||||
},
|
||||
];
|
||||
|
||||
// Routes — pure 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 {
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 origin–destination 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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 1–2 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;
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "FraudAlert" ADD COLUMN "acknowledgedAt" TIMESTAMP(3);
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`,
|
||||
``,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user