mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add script to seed freight contracts across flow variants
This commit is contained in:
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.
479
apps/edr-freight-api/py/create_contracts.py
Normal file
479
apps/edr-freight-api/py/create_contracts.py
Normal file
@@ -0,0 +1,479 @@
|
||||
#!/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. Independent of the
|
||||
# logged-in user — the sign endpoint keys the OTP purely on this number.
|
||||
OTP_PHONE = os.getenv("OTP_PHONE", "")
|
||||
|
||||
# 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 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
|
||||
) -> 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),
|
||||
("OTP_PHONE", OTP_PHONE),
|
||||
]
|
||||
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")
|
||||
|
||||
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)
|
||||
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
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 {
|
||||
BATCH_WINDOW_START_HOURS,
|
||||
listConfigBookingWindows,
|
||||
groupBookingsIntoBoardWindows,
|
||||
computeImportWindowTimes,
|
||||
type BoardWindowConfig,
|
||||
} from './batch-window.util';
|
||||
|
||||
@@ -54,6 +55,72 @@ 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('batch-window board windows (config-driven booking cycles)', () => {
|
||||
// Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure,
|
||||
// 3h long, reopen 90m later.
|
||||
|
||||
@@ -169,31 +169,44 @@ export function isRoundTheClock(hours: OfficeHours): boolean {
|
||||
* single EAT day and never wraps past midnight (enforced when global rules are
|
||||
* saved). openHour === closeHour is the 24-hour desk, handled first.
|
||||
*/
|
||||
/**
|
||||
* 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 (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 {
|
||||
let opensAt: Date;
|
||||
if (isRoundTheClock(hours)) {
|
||||
opensAt = earliestNextOpen;
|
||||
} else {
|
||||
const { hour, minute } = eatParts(earliestNextOpen);
|
||||
const readyMinutes = hour * 60 + minute;
|
||||
const openMinutes = hours.windowOpenHour * 60;
|
||||
const closeMinutes = hours.windowCloseHour * 60;
|
||||
if (readyMinutes < openMinutes) {
|
||||
// Ready before the desk opens on its own EAT calendar day → open this morning.
|
||||
opensAt = eatDayToUtc(eatDay(earliestNextOpen), hours.windowOpenHour);
|
||||
} else if (readyMinutes < closeMinutes) {
|
||||
// Inside office hours → open as soon as ready.
|
||||
opensAt = earliestNextOpen;
|
||||
} else {
|
||||
// Desk shut for the day → open tomorrow morning.
|
||||
const tomorrow = shiftEatDay(eatDay(earliestNextOpen), 1);
|
||||
opensAt = eatDayToUtc(tomorrow, hours.windowOpenHour);
|
||||
}
|
||||
}
|
||||
const opensAt = officeHoursOpen(earliestNextOpen, hours);
|
||||
return opensAt.getTime() < departure.getTime() ? opensAt : null;
|
||||
}
|
||||
|
||||
@@ -203,27 +216,53 @@ export interface InitialWindowTimes {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ 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";
|
||||
@@ -534,6 +535,20 @@ export class TrainSchedulingController {
|
||||
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({
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
} 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,
|
||||
@@ -413,6 +414,93 @@ export class TrainSchedulingService {
|
||||
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: BookingWindowConfig = {
|
||||
importWindowLeadDays:
|
||||
schedule.ruleImportWindowLeadDays ?? windowCfg.importWindowLeadDays,
|
||||
exportBookingLeadHours:
|
||||
schedule.ruleExportBookingLeadHours ?? windowCfg.exportBookingLeadHours,
|
||||
windowOpenHour: schedule.ruleWindowOpenHour ?? windowCfg.windowOpenHour,
|
||||
windowCloseHour: schedule.ruleWindowCloseHour ?? windowCfg.windowCloseHour,
|
||||
windowDurationHours:
|
||||
schedule.ruleWindowDurationHours != null
|
||||
? Number(schedule.ruleWindowDurationHours)
|
||||
: windowCfg.windowDurationHours,
|
||||
docReviewMinutes: windowCfg.docReviewMinutes,
|
||||
paymentWindowMinutes: windowCfg.paymentWindowMinutes,
|
||||
reopenDelayMinutes: windowCfg.reopenDelayMinutes,
|
||||
};
|
||||
|
||||
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
|
||||
|
||||
@@ -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) ──────────────────
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -283,6 +283,8 @@ export const URL_CONSTANTS = {
|
||||
`/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) =>
|
||||
|
||||
@@ -128,6 +128,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, {
|
||||
@@ -911,6 +912,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>
|
||||
|
||||
@@ -995,83 +1021,6 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</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">
|
||||
<Tabs.Tab value="workflow" leftSection={<WorkflowIcon size={16} />}>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
Navigation,
|
||||
Pencil,
|
||||
Send,
|
||||
Train,
|
||||
Weight,
|
||||
@@ -38,6 +39,7 @@ 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,
|
||||
@@ -89,6 +91,8 @@ export default function TrainScheduleV2ListPage() {
|
||||
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[]>([]);
|
||||
@@ -318,6 +322,14 @@ 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} />}
|
||||
@@ -601,6 +613,15 @@ export default function TrainScheduleV2ListPage() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -451,6 +451,18 @@ export const api = {
|
||||
() => 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",
|
||||
|
||||
@@ -222,6 +222,17 @@ export const trainSchedulingService = {
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user