add script to seed freight contracts across flow variants

This commit is contained in:
Marshal
2026-07-05 18:06:24 +00:00
parent 57a504867b
commit 3e2299960a
19 changed files with 1057 additions and 115 deletions

View File

@@ -0,0 +1,67 @@
# Contract seed driver
Creates freight contracts across every flow variant by driving the freight API
over HTTP end-to-end — from DRAFT through **both signatures** (customer sign +
staff counter-sign). It stops right after the staff counter-sign; no clearance
or booking steps are run.
## What it builds
20 real flows (movement × kind × customs × freight), each created twice → **40
contracts** on `all`.
| Movement | Kind | Customs | Freight | Count |
| --- | --- | --- | --- | --- |
| intercity (DOMESTIC) | one-time / general | without only¹ | bulk / container | 4 |
| import (IMPORT) | one-time / general | with / without | bulk / container | 8 |
| export (EXPORT) | one-time / general | with / without | bulk / container | 8 |
¹ intercity + customs is not a real combo — DOMESTIC has no clearance gate, so
the customs flag is ignored. Those four are skipped, leaving 20 (16 working + 4
`with-customs + bulk`).
The four `with-customs + bulk` flows are still built here: the known break is
downstream in clearance (customs output docs are container-only), which this
script does not reach, so all 20 reach a signed state.
## Terminal status after both signatures (by dimension)
- DOMESTIC one-time → `FULLY_EXECUTED`
- any GENERAL, and DOMESTIC general → `CONTRACT_ACTIVE`
- IMPORT/EXPORT one-time (customs or self-clearance) → `AWAITING_CLEARANCE_DOCUMENTS`
(fully signed; clearance not driven)
## Setup
```bash
cd apps/edr-freight-api/py
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then fill it in
```
Fill `.env`: customer + admin IAM credentials (admin should be a **super_admin**),
`OTP_PHONE`, and the Postgres connection (used only to read the sign-OTP).
## Run
```bash
python create_contracts.py # all 20 flows
python create_contracts.py intercity # only DOMESTIC flows (4)
python create_contracts.py import # only IMPORT flows (8)
python create_contracts.py import export # IMPORT + EXPORT (16)
```
Filters are by movement: `intercity`, `import`, `export` (pass one or many);
no arg or `all` runs everything.
## How auth + OTP work
- **Login**: `POST /api/auth/login` with `{ email, password }` returns a JWT
(`token`), sent as `Authorization: Bearer <token>`. MFA accounts are not
supported — the script errors out clearly if MFA is required.
- **Actors**: the customer token does create/submit/customer-sign; the admin
token does staff-accept/approve/generate/counter-sign.
- **Sign OTP**: customer sign needs a fresh 6-digit OTP. The script calls
`POST /api/otp/send { phone }`, reads the plaintext code from
`<schema>.otp_verifications` in Postgres, then signs within the 5-minute TTL.

View File

@@ -0,0 +1,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()

View File

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

View File

@@ -42,8 +42,13 @@ describe('clearance.util — clearanceOutputSettingCode', () => {
expect(clearanceOutputSettingCode('IMPORT', 'CONTAINER', false)).toBeNull();
});
it('returns null for bulk (no container output set) and domestic', () => {
expect(clearanceOutputSettingCode('IMPORT', 'BULK', true)).toBeNull();
it('resolves bulk output sets (mirrors container) and returns null for domestic', () => {
expect(clearanceOutputSettingCode('IMPORT', 'BULK', true)).toBe(
'clearance_output_import_bulk',
);
expect(clearanceOutputSettingCode('EXPORT', 'BULK', true)).toBe(
'clearance_output_export_bulk',
);
expect(clearanceOutputSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull();
});
});

View File

@@ -33,7 +33,7 @@ export function clearanceSettingCode(
return `clearance_${op}_${freight}_${customs}`;
}
/** The GL-output (customs output) setting code; only container customs sets exist. */
/** The GL-output (customs output) setting code, keyed on op + freight. */
export function clearanceOutputSettingCode(
tradeDirection: string,
freightType: string,
@@ -42,9 +42,8 @@ export function clearanceOutputSettingCode(
if (!includesCustoms) return null;
const op = operationFor(tradeDirection);
if (!op) return null;
// Only container customs output sets are seeded for this phase.
if (freightFor(freightType) !== 'container') return null;
return `clearance_output_${op}_container`;
const freight = freightFor(freightType);
return `clearance_output_${op}_${freight}`;
}
/** Convenience: resolve both codes for a loaded booking (with its serviceType). */

View File

@@ -45,7 +45,7 @@ export function contractClearanceSettingCode(
return `contract_clearance_${op}_${freight}`;
}
/** The GL-output (customs output) setting code; only container customs sets exist. */
/** The GL-output (customs output) setting code, keyed on op + freight. */
export function contractClearanceOutputSettingCode(
tradeDirection: string,
freightType: string,
@@ -54,8 +54,8 @@ export function contractClearanceOutputSettingCode(
if (!includesCustoms) return null;
const op = operationFor(tradeDirection);
if (!op) return null;
if (freightFor(freightType) !== 'container') return null;
return `contract_clearance_output_${op}_container`;
const freight = freightFor(freightType);
return `contract_clearance_output_${op}_${freight}`;
}
/** Convenience: resolve both codes for a loaded contract. */

View File

@@ -5,6 +5,7 @@ import {
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:0017:00, 15h window.
const departure = new Date('2026-07-06T05:00:00.000Z');
const bounded = {
importWindowLeadDays: 3,
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 15,
};
it('opens at the morning anchor when now is before the lead window', () => {
// Now = 02 Jul 06:00 EAT (before the 03 Jul anchor).
const now = new Date('2026-07-02T03:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now);
// Anchor: 03 Jul 08:00 EAT = 05:00 UTC.
expect(windowOpensAt.toISOString()).toBe('2026-07-03T05:00:00.000Z');
});
it('opens NOW when inside the lead window and inside office hours (past the anchor)', () => {
// Now = 05 Jul 12:00 EAT (09:00 UTC): inside lead days, inside 08:0017:00,
// anchor already passed → open immediately.
const now = new Date('2026-07-05T09:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-05T09:00:00.000Z');
});
it('waits for next morning when now is after the desk closes', () => {
// Departs 06 Jul 14:00 EAT (11:00 UTC) so next-morning open sits before departure.
// Now = 05 Jul 18:00 EAT (15:00 UTC): after 17:00 close → open 06 Jul 08:00 EAT.
const lateDeparture = new Date('2026-07-06T11:00:00.000Z');
const now = new Date('2026-07-05T15:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(lateDeparture, bounded, now);
// 06 Jul 08:00 EAT = 05:00 UTC.
expect(windowOpensAt.toISOString()).toBe('2026-07-06T05:00:00.000Z');
});
it('opens this morning when now is before the desk opens on a lead day', () => {
// Now = 05 Jul 06:00 EAT (03:00 UTC): inside lead days but before 08:00 → 08:00 today.
const now = new Date('2026-07-05T03:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z');
});
it('24-hour desk opens NOW at any hour, day or night, once inside the lead window', () => {
// Round-the-clock desk (open === close). Now = 05 Jul 03:00 EAT (00:00 UTC),
// deep night, past the anchor → open immediately.
const roundClock = { ...bounded, windowOpenHour: 8, windowCloseHour: 8 };
const now = new Date('2026-07-05T00:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, roundClock, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-05T00:00:00.000Z');
});
it('caps the close at departure', () => {
// Opens now (05 Jul 12:00 EAT); a 24h duration would close 06 Jul 12:00 EAT,
// past the 06 Jul 08:00 departure → clamped to departure.
const now = new Date('2026-07-05T09:00:00.000Z');
const { windowClosesAt } = computeImportWindowTimes(
departure,
{ ...bounded, windowDurationHours: 24 },
now,
);
expect(windowClosesAt.toISOString()).toBe(departure.toISOString());
});
});
describe('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.

View File

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

View File

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsISO8601 } from 'class-validator';
/**
* Reschedule a train's departure date (staff action on the ops board). Only
* allowed before the booking window opens; the new date must still leave room
* for the booking lead window before departure.
*/
export class UpdateScheduleDateDto {
@ApiProperty({
example: '2026-07-20T05:00:00.000Z',
description: 'New scheduled departure date/time (ISO 8601)',
})
@IsISO8601()
scheduleDate!: string;
}

View File

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

View File

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

View File

@@ -350,6 +350,20 @@ const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
entity: CLEARANCE_ENTITY,
fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
},
// Bulk output sets mirror the container output docs so customs+bulk bookings
// can finalize (previously bulk had no output set and got stuck at finalize).
{
code: "clearance_output_import_bulk",
label: "Customs output documents (import bulk)",
entity: CLEARANCE_ENTITY,
fields: IMPORT_CONTAINER_OUTPUT_FIELDS,
},
{
code: "clearance_output_export_bulk",
label: "Customs output documents (export bulk)",
entity: CLEARANCE_ENTITY,
fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
},
];
// ── Contract pre-booking clearance settings (Path B) ────────────────────────
@@ -398,6 +412,20 @@ const CONTRACT_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
entity: CONTRACT_CLEARANCE_ENTITY,
fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
},
// Bulk output sets mirror the container output docs so customs+bulk contracts
// can finalize (previously bulk had no output set and got stuck at finalize).
{
code: "contract_clearance_output_import_bulk",
label: "Contract customs output documents (import bulk)",
entity: CONTRACT_CLEARANCE_ENTITY,
fields: IMPORT_CONTAINER_OUTPUT_FIELDS,
},
{
code: "contract_clearance_output_export_bulk",
label: "Contract customs output documents (export bulk)",
entity: CONTRACT_CLEARANCE_ENTITY,
fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
},
];
// ── Path A self-clearance settings (no EDR customs service) ──────────────────

View File

@@ -0,0 +1,141 @@
import { useEffect, useState } from "react";
import {
Alert,
Box,
Button,
Group,
Modal,
Stack,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { isAxiosError } from "axios";
import { CalendarClock, Info } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
function parseError(error: unknown, fallback: string): string {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
}
/** ISO → the `YYYY-MM-DDTHH:mm` value a datetime-local input expects (local time). */
function toLocalInputValue(iso: string | null | undefined): string {
if (!iso) return "";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}`
);
}
export interface EditScheduleDateModalProps {
scheduleId: string | null;
currentDate: string | null;
routeName?: string | null;
opened: boolean;
onClose: () => void;
/** Called after a successful save (e.g. to refetch a list). */
onSaved?: () => void;
}
/**
* Reschedule a train's departure date. Only shown for schedules whose booking
* window has not opened yet; the API rejects a date inside the booking lead
* window (import/intercity lead in days, export in hours).
*/
export default function EditScheduleDateModal({
scheduleId,
currentDate,
routeName,
opened,
onClose,
onSaved,
}: EditScheduleDateModalProps) {
const { toast } = useToast();
const save = useMutation(
api.trainScheduling.updateScheduleDate.mutationOptions(),
);
const [value, setValue] = useState("");
useEffect(() => {
if (opened) setValue(toLocalInputValue(currentDate));
}, [opened, currentDate]);
const handleSave = async () => {
if (!scheduleId || !value) {
toast({ title: "Pick a departure date", variant: "destructive" });
return;
}
try {
await save.mutateAsync({
id: scheduleId,
scheduleDate: new Date(value).toISOString(),
});
toast({ title: "Departure date updated" });
onSaved?.();
onClose();
} catch (err) {
toast({
title: "Update failed",
description: parseError(err, "Could not update departure date"),
variant: "destructive",
});
}
};
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<CalendarClock size={18} />
</ThemeIcon>
<Box>
<Text fw={600} lh={1.2}>
Edit departure date
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{routeName ?? "This schedule only"}
</Text>
</Box>
</Group>
}
>
<Stack gap="md">
<Alert variant="light" color="blue" icon={<Info size={16} />}>
The date can only be changed before the booking window opens, and must
still leave room for the booking lead window before departure.
</Alert>
<TextInput
label="Departure date"
type="datetime-local"
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={onClose} disabled={save.isPending}>
Cancel
</Button>
<Button onClick={handleSave} loading={save.isPending}>
Save
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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