diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
index 1f82f6e93..d8fedcd6e 100644
--- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
+++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
@@ -302,8 +302,6 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
// L. Administration & settings (split from the coarse admin umbrella)
export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
- perm('b3a00001-0001-4000-8000-000000000001', 'edr_freight_app:config:contract_validity:view', 'View contract validity periods'),
- perm('b3a00001-0001-4000-8000-000000000002', 'edr_freight_app:config:contract_validity:manage', 'Manage contract validity periods'),
perm('b4a00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:file_upload:view', 'View file-upload settings'),
perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'),
perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'),
@@ -594,12 +592,6 @@ export const FREIGHT_PERMS = {
cancel: 'edr_freight_app:warehouse_fee_invoices:cancel',
pay: 'edr_freight_app:warehouse_fee_invoices:pay',
},
- config: {
- contractValidity: {
- view: 'edr_freight_app:config:contract_validity:view',
- manage: 'edr_freight_app:config:contract_validity:manage',
- },
- },
settings: {
fileUpload: {
view: 'edr_freight_app:settings:file_upload:view',
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index d216447f3..77ff95c2d 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -548,11 +548,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: ,
children: [
...getCategorySidebarChildren("configuration"),
- {
- label: "Contract validity",
- href: "/dashboard/configuration/contract-validity-periods",
- permission: FREIGHT_PERMS.config.contractValidity.view,
- },
{
label: "Train scheduling rules",
href: "/dashboard/configuration/train-scheduling-rules",
@@ -1463,14 +1458,14 @@ const App = () => {
}
/>
-
}
- />
+ /> */}
} />
;
@@ -29,6 +31,7 @@ export function ContractApprovalStepsCard({
contract,
mutations,
}: ContractApprovalStepsCardProps) {
+ const { user } = useAuth();
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] =
useState(null);
@@ -166,6 +169,10 @@ export function ContractApprovalStepsCard({
key={step.id}
step={step}
isNext={actionable && nextPending?.id === step.id}
+ // Buttons show only to the step's actual approver (matching
+ // position type): a chief step never offers Approve/Reject to a
+ // marketing officer. Everyone still sees the "next" highlight.
+ canAct={canApproveContractStep(user, step.requiredRole)}
isPending={
mutations.approveStep.isPending ||
mutations.rejectStep.isPending
@@ -306,12 +313,14 @@ export function ContractApprovalStepsCard({
function StepRow({
step,
isNext,
+ canAct,
isPending,
onApprove,
onReject,
}: {
step: Freight.IContractApprovalStep;
isNext: boolean;
+ canAct: boolean;
isPending: boolean;
onApprove: () => void;
onReject: () => void;
@@ -372,8 +381,11 @@ function StepRow({
)}
-
- {isNext && step.status === "PENDING" && (
+ {/* One element type per row: action buttons on the active step (they
+ already imply "pending & actionable"), a status badge otherwise.
+ Mixing compact buttons + a badge here made them read as misaligned. */}
+
+ {isNext && canAct && step.status === "PENDING" ? (
<>
);
diff --git a/apps/edr-freight-web/backoffice/src/lib/canApproveContractStep.test.ts b/apps/edr-freight-web/backoffice/src/lib/canApproveContractStep.test.ts
new file mode 100644
index 000000000..ab0f9c3c5
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/lib/canApproveContractStep.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+
+import type { AuthUser } from "@/auth/types";
+import { canApproveContractStep } from "./permissions";
+
+const withPositionType = (typeKey: string): AuthUser => ({
+ employee: [{ positions: [{ positionType: { key: typeKey } }] }],
+});
+
+const withRole = (roleKey: string): AuthUser => ({ roles: [{ key: roleKey }] });
+
+const withPermission = (permKey: string): AuthUser => ({
+ permissionKeys: [permKey],
+});
+
+describe("canApproveContractStep", () => {
+ it("shows to the matching position type only", () => {
+ const chief = withPositionType("-marketing-chief");
+ expect(canApproveContractStep(chief, "-marketing-chief")).toBe(true);
+ // a marketing officer must NOT see the chief step's buttons
+ expect(canApproveContractStep(chief, "-marketing-director-")).toBe(false);
+ });
+
+ it("lets super/org admins action any step", () => {
+ expect(canApproveContractStep(withRole("super_admin"), "anything")).toBe(
+ true,
+ );
+ expect(
+ canApproveContractStep(withRole("organization_admin"), "-marketing-chief"),
+ ).toBe(true);
+ });
+
+ it("resolves legacy chain roles via their position-type aliases", () => {
+ const director = withPositionType("operation-director");
+ expect(canApproveContractStep(director, "DIRECTOR")).toBe(true);
+ expect(canApproveContractStep(director, "CEO")).toBe(false);
+ });
+
+ it("honours the role's own legacy approve permission", () => {
+ const staff = withPermission(
+ "edr_freight_app:contracts:approve_director",
+ );
+ expect(canApproveContractStep(staff, "DIRECTOR")).toBe(true);
+ });
+
+ it("does NOT show to holders of an unrelated approve permission", () => {
+ // the dropped blanket fallback: a line-staff approver is not a chief
+ const lineStaff = withPermission(
+ "edr_freight_app:contracts:approve_line_staff",
+ );
+ expect(canApproveContractStep(lineStaff, "-marketing-chief")).toBe(false);
+ });
+
+ it("returns false without a user or role", () => {
+ expect(canApproveContractStep(null, "-marketing-chief")).toBe(false);
+ expect(canApproveContractStep(withPositionType("x"), null)).toBe(false);
+ });
+});
diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
index f793f7006..c2b82b765 100644
--- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts
+++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
@@ -240,12 +240,6 @@ export const FREIGHT_PERMS = {
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
pay: "edr_freight_app:warehouse_fee_invoices:pay",
},
- config: {
- contractValidity: {
- view: "edr_freight_app:config:contract_validity:view",
- manage: "edr_freight_app:config:contract_validity:manage",
- },
- },
settings: {
fileUpload: {
view: "edr_freight_app:settings:file_upload:view",
@@ -420,6 +414,53 @@ export function hasPermission(
return getPermissionKeys(user).includes(key);
}
+// Legacy chain roles predate position types; map each to the position types
+// that stand in for it. Mirror of the API's LEGACY_ROLE_POSITION_TYPES so the
+// button visibility matches what the approve/reject endpoint will accept.
+const LEGACY_ROLE_POSITION_TYPES: Record = {
+ LINE_STAFF: ["employee", "teamLeader", "officeHead", "recordOfficer"],
+ DIRECTOR: ["director", "operation-director"],
+ CEO: ["chief", "deputy"],
+};
+
+const CONTRACT_APPROVE_ROLE_PERMISSION: Record = {
+ LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
+ DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
+ CEO: FREIGHT_PERMS.contracts.approveCeo,
+};
+
+/**
+ * Can this user action a contract approval step requiring `requiredRole`?
+ *
+ * `requiredRole` is an `iam.position_types.key` (the role vocabulary approval
+ * chains are configured in), or a legacy LINE_STAFF/DIRECTOR/CEO string. Used
+ * to show Approve/Reject only to the step's actual approver — a chief step
+ * shows only to a chief, a marketing-officer step only to that officer.
+ *
+ * Deliberately STRICTER than the API's `assertCanApproveContractStep`, which
+ * also lets through anyone holding any contract-approve permission (a fallback
+ * for delegates whose token omits the position type). That blanket is what made
+ * every approver see the button, so it is dropped here: the visibility rule is
+ * admin OR the matching position type (direct / legacy alias) OR the role's own
+ * legacy approve permission. The server still guards the mutation.
+ */
+export function canApproveContractStep(
+ user: AuthUser | null | undefined,
+ requiredRole: string | null | undefined,
+): boolean {
+ if (!user || !requiredRole) return false;
+ if (isFreightApprovalAdmin(user)) return true;
+
+ const positionTypes = getPositionTypeKeys(user);
+ if (positionTypes.includes(requiredRole)) return true;
+
+ const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
+ if (aliases.some((alias) => positionTypes.includes(alias))) return true;
+
+ const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
+ return Boolean(legacyPermission && hasPermission(user, legacyPermission));
+}
+
export function canAccessBookings(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.view);
}
diff --git a/e2e/freight/cypress.config.ts b/e2e/freight/cypress.config.ts
index dda8c1703..7e659649d 100644
--- a/e2e/freight/cypress.config.ts
+++ b/e2e/freight/cypress.config.ts
@@ -75,6 +75,52 @@ export default defineConfig({
}
},
+ /**
+ * Node-side multipart POST — cy.request cannot stream FormData files,
+ * and driving every GL upload modal through the UI is out of scope for
+ * the scheduling-engine specs. Uses Node 18+ global fetch/FormData.
+ */
+ async "api:upload"({
+ url,
+ token,
+ fields = {},
+ files = [],
+ }: {
+ url: string;
+ token: string;
+ fields?: Record;
+ files?: Array<{
+ field: string;
+ fixture: string;
+ filename?: string;
+ contentType?: string;
+ }>;
+ }) {
+ const form = new FormData();
+ for (const [key, value] of Object.entries(fields)) form.append(key, value);
+ for (const f of files) {
+ const buf = readFileSync(join(process.cwd(), "cypress", "fixtures", f.fixture));
+ form.append(
+ f.field,
+ new Blob([buf], { type: f.contentType ?? "application/pdf" }),
+ f.filename ?? "document.pdf",
+ );
+ }
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
+ body: form,
+ });
+ const text = await res.text();
+ let body: unknown = text;
+ try {
+ body = JSON.parse(text);
+ } catch {
+ // non-JSON body (rare) — return as text
+ }
+ return { status: res.status, body };
+ },
+
async "db:seedUsers"() {
// cwd = the e2e/freight project root when Cypress runs.
// seed-company.sql depends on rows from seed-users.sql — keep order.
diff --git a/e2e/freight/cypress/e2e/flows/import-utils.ts b/e2e/freight/cypress/e2e/flows/import-utils.ts
new file mode 100644
index 000000000..d164280fd
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/import-utils.ts
@@ -0,0 +1,778 @@
+/**
+ * Shared helpers for the IMPORT corridor flow specs.
+ *
+ * Corridor (A→B→C→D→E→T, DJ→ET = IMPORT):
+ * DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY
+ *
+ * Philosophy (same as segment_weight.cy.ts): these specs test the
+ * scheduling/window/clearance ENGINE, not the contract wizard — contracts are
+ * seeded FULLY_EXECUTED in SQL with stamped references; bookings, staff
+ * reviews, window phases, payment, dispatch and clearance run through the real
+ * API + UI. Window *timestamps* are arranged via db:query (the specs arrange
+ * window state, they don't test the wall clock), and every transition is then
+ * performed by the app's own 10s window tick or its staff endpoints.
+ *
+ * No module-level state besides constants: Cypress re-evaluates the spec
+ * bundle on cross-origin reloads, so helpers look rows up by stamped-reference
+ * SUFFIX + newest row, never by captured ids.
+ */
+
+export const customer = "user@gmail.com";
+export const companyTin = "0102030405"; // seed-company.sql
+export const opsStaff = "operation@edr.local";
+/** isSuperAdmin bypasses assertFreightPermission — used for GL endpoints so a
+ * missing preset permission never masks an engine regression. */
+export const superAdmin = "superadmin@tria.com";
+
+export const CORRIDOR = ["DJIB_PORT", "NAGAD", "DIRE_DAWA", "E2E_AWASH", "MOJO", "KALITY"] as const;
+export const ORIGIN = "DJIB_PORT";
+export const DEST = "KALITY";
+
+export const apiUrl = () => Cypress.env("apiUrl") as string;
+
+// ---------------------------------------------------------------------------
+// small generic plumbing
+// ---------------------------------------------------------------------------
+
+export type Row = Record;
+
+export function db(sql: string, params: unknown[] = []) {
+ return cy.task<{ rowCount: number; rows: T[] }>("db:query", { sql, params }, { log: false });
+}
+
+/** Bearer token for a staff/customer account (portal users use the demo pwd). */
+export function tokenFor(email: string): Cypress.Chainable {
+ const pass =
+ email.endsWith("@gmail.com") ? (Cypress.env("demoPassword") as string) : undefined;
+ return cy.apiLogin(email, pass).then(({ token }) => cy.wrap(token, { log: false }));
+}
+
+export function apiPost(
+ email: string,
+ path: string,
+ body?: unknown,
+ failOnStatusCode = true,
+) {
+ return tokenFor(email).then((token) =>
+ cy.request({
+ method: "POST",
+ url: `${apiUrl()}${path}`,
+ headers: { Authorization: `Bearer ${token}` },
+ body: body ?? {},
+ failOnStatusCode,
+ }),
+ );
+}
+
+/** Poll a 1-row query until `check` passes (10s window tick ⇒ 3s cadence). */
+export function pollDb(
+ label: string,
+ sql: string,
+ params: unknown[],
+ check: (row: T | undefined) => boolean,
+ attempts = 40,
+) {
+ const read = (attempt: number): void => {
+ db(sql, params).then(({ rows }) => {
+ if (check(rows[0])) return;
+ expect(attempt, label).to.be.lessThan(attempts);
+ cy.wait(3000, { log: false }).then(() => read(attempt + 1));
+ });
+ };
+ read(0);
+}
+
+// ---------------------------------------------------------------------------
+// time — departures pinned to 12:00 EAT so the EAT day key is unambiguous
+// ---------------------------------------------------------------------------
+
+export function departureAt(dayOffset: number): Date {
+ const eatNow = new Date(Date.now() + 3 * 3_600_000);
+ return new Date(
+ Date.UTC(
+ eatNow.getUTCFullYear(),
+ eatNow.getUTCMonth(),
+ eatNow.getUTCDate() + dayOffset,
+ 9, // 09:00 UTC = 12:00 EAT
+ 0,
+ 0,
+ ),
+ );
+}
+
+/** The EAT calendar day (`YYYY-MM-DD`) of an instant — the booking day key. */
+export const eatDayStr = (d: Date) =>
+ new Date(d.getTime() + 3 * 3_600_000).toISOString().slice(0, 10);
+
+// ---------------------------------------------------------------------------
+// contracts — seeded FULLY_EXECUTED (see file header)
+// ---------------------------------------------------------------------------
+
+export interface SeedContractOpts {
+ suffix: string;
+ reference: string;
+ currency?: "ETB" | "USD";
+ customs?: boolean;
+ direction?: "IMPORT" | "DOMESTIC";
+ originCode?: string;
+ destCode?: string;
+}
+
+export function seedImportContract(opts: SeedContractOpts) {
+ const currency = opts.currency ?? "ETB";
+ const customs = opts.customs ?? false;
+ const direction = opts.direction ?? "IMPORT";
+ db(
+ `WITH c AS (
+ INSERT INTO freight.contracts
+ (reference, company_id, company_profile_id, contract_kind,
+ trade_direction, freight_type, service_type_id, payment_currency,
+ customs_clearing_enabled, clearance_status, status,
+ fully_executed_at, contract_valid_from, contract_valid_until,
+ contract_summary)
+ SELECT $1, comp.id,
+ (SELECT p.id FROM freight.company_profiles p
+ WHERE p.company_id = comp.id AND p.deleted_at IS NULL
+ ORDER BY CASE WHEN p.type = 'importer' THEN 0 ELSE 1 END
+ LIMIT 1),
+ 'ONE_TIME', $2, 'CONTAINER',
+ (SELECT st.id FROM freight.service_types st ORDER BY st.created_at LIMIT 1),
+ $3, $4,
+ CASE WHEN $4 THEN 'CLEARANCE_READY_FOR_BOOKING' ELSE 'NOT_APPLICABLE' END,
+ 'FULLY_EXECUTED', now(), now() - interval '1 day',
+ now() + interval '60 days', 'E2E import-corridor fixture contract'
+ FROM freight.companies comp
+ WHERE comp.tin = $5
+ -- before() re-runs on cross-origin reloads: keep one stable fresh row
+ -- per suffix (skip when this run already seeded an unbooked one).
+ AND NOT EXISTS (
+ SELECT 1 FROM freight.contracts c2
+ WHERE c2.reference LIKE 'CTR-IMP-%-' || $8
+ AND c2.deleted_at IS NULL
+ AND c2.created_at > now() - interval '30 minutes'
+ AND NOT EXISTS (
+ SELECT 1 FROM freight.bookings b2 WHERE b2.contract_id = c2.id
+ )
+ )
+ RETURNING id
+ ), r AS (
+ INSERT INTO freight.contract_routes
+ (contract_id, origin_yard_id, destination_yard_id, sort_order)
+ SELECT c.id, o.id, d.id, 0 FROM c
+ JOIN freight.yards o ON o.code = $6
+ JOIN freight.yards d ON d.code = $7
+ RETURNING id
+ ), scope AS (
+ INSERT INTO freight.contract_cargo_scope
+ (contract_id, container_size, cargo_free_text)
+ SELECT c.id, v.size, 'E2E import corridor cargo'
+ FROM c CROSS JOIN (VALUES ('20ft'), ('40ft')) AS v(size)
+ )
+ -- Path B gate: ONE_TIME customs bookings require the pre-booking boundary
+ -- milestone (IMPORT → DO_COLLECTED) COMPLETED at contract level.
+ INSERT INTO freight.clearance_milestones
+ (contract_id, milestone_code, milestone_label, status, triggered_at, sort_order)
+ SELECT c.id, 'DO_COLLECTED', 'Delivery order collected', 'COMPLETED', now(), 0
+ FROM c WHERE $4`,
+ [
+ opts.reference,
+ direction,
+ currency,
+ customs,
+ companyTin,
+ opts.originCode ?? ORIGIN,
+ opts.destCode ?? DEST,
+ opts.suffix,
+ ],
+ );
+ // Backfill the boundary milestone when the insert above was skipped because
+ // a prior run's still-unbooked contract row is being reused.
+ if (customs) {
+ db(
+ `INSERT INTO freight.clearance_milestones
+ (contract_id, milestone_code, milestone_label, status, triggered_at, sort_order)
+ SELECT ct.id, 'DO_COLLECTED', 'Delivery order collected', 'COMPLETED', now(), 0
+ FROM freight.contracts ct
+ WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND ct.deleted_at IS NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM freight.clearance_milestones m
+ WHERE m.contract_id = ct.id AND m.milestone_code = 'DO_COLLECTED'
+ AND m.deleted_at IS NULL
+ )`,
+ [opts.suffix],
+ );
+ }
+}
+
+/** Newest seeded contract for a suffix — stamp-agnostic. */
+export function dbContractId(suffix: string) {
+ return db<{ id: string }>(
+ `SELECT id FROM freight.contracts
+ WHERE reference LIKE 'CTR-IMP-%-' || $1
+ ORDER BY created_at DESC LIMIT 1`,
+ [suffix],
+ ).then(({ rows }) => {
+ expect(rows, `seeded contract *-${suffix}`).to.have.length(1);
+ return cy.wrap(rows[0].id, { log: false });
+ });
+}
+
+// ---------------------------------------------------------------------------
+// bookings
+// ---------------------------------------------------------------------------
+
+export interface BookingRow {
+ id: string;
+ reference: string;
+ status: string;
+ scheduling_status: string;
+ train_schedule_id: string | null;
+ payment_deadline: string | null;
+ priority_score: number;
+ is_split: boolean;
+ contract_id: string;
+}
+
+export function dbBooking(suffix: string) {
+ return db(
+ `SELECT b.id, b.reference, b.status, b.scheduling_status,
+ b.train_schedule_id, b.payment_deadline, b.priority_score,
+ b.is_split, b.contract_id
+ FROM freight.bookings b
+ JOIN freight.contracts ct ON ct.id = b.contract_id
+ WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
+ ORDER BY b.created_at DESC LIMIT 1`,
+ [suffix],
+ );
+}
+
+export function withBooking(suffix: string, fn: (b: BookingRow) => void) {
+ dbBooking(suffix).then(({ rows }) => {
+ expect(rows, `booking under *-${suffix}`).to.have.length(1);
+ fn(rows[0]);
+ });
+}
+
+export function expectBookingStatus(suffix: string, status: string | string[]) {
+ const want = Array.isArray(status) ? status : [status];
+ withBooking(suffix, (b) =>
+ expect(b.status, `${suffix} booking status`).to.be.oneOf(want),
+ );
+}
+
+export function pollBookingStatus(suffix: string, status: string | string[], attempts = 40) {
+ const want = Array.isArray(status) ? status : [status];
+ pollDb(
+ `${suffix} → ${want.join("|")}`,
+ `SELECT b.status FROM freight.bookings b
+ JOIN freight.contracts ct ON ct.id = b.contract_id
+ WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
+ ORDER BY b.created_at DESC LIMIT 1`,
+ [suffix],
+ (row) => !!row && want.includes(row.status as string),
+ attempts,
+ );
+}
+
+/** ISO 6346-shaped container number, unique per run+seed (checksum unchecked). */
+export function isoNumber(runStamp: string, seed: number): string {
+ return `MSCU${String((Number(runStamp.slice(-6)) * 100 + seed) % 10_000_000).padStart(7, "0")}`;
+}
+
+/**
+ * Customer books containers under a seeded contract via the API (the portal
+ * booking form is exercised by export_one_time/intercity specs; a 22-wagon
+ * booking means 44 ISO inputs — not a UI journey).
+ */
+export function bookContainers(opts: {
+ suffix: string;
+ runStamp: string;
+ isoSeed: number;
+ twenty?: number;
+ forty?: number;
+ scheduledDate?: string; // omit for DOMESTIC (intercity)
+ vgmTons?: number;
+ expectFailure?: string; // substring of the expected 4xx error message
+}) {
+ const vgm = opts.vgmTons ?? 10;
+ const lines: Array> = [];
+ let unit = 0;
+ if (opts.twenty) {
+ lines.push({
+ containerSize: "20ft",
+ quantity: opts.twenty,
+ units: Array.from({ length: opts.twenty }, () => ({
+ containerNumber: isoNumber(opts.runStamp, opts.isoSeed + unit++),
+ vgmTons: vgm,
+ })),
+ });
+ }
+ if (opts.forty) {
+ lines.push({
+ containerSize: "40ft",
+ quantity: opts.forty,
+ units: Array.from({ length: opts.forty }, () => ({
+ containerNumber: isoNumber(opts.runStamp, opts.isoSeed + unit++),
+ vgmTons: vgm,
+ })),
+ });
+ }
+ db<{ id: string; customs_clearing_enabled: boolean }>(
+ `SELECT id, customs_clearing_enabled FROM freight.contracts
+ WHERE reference LIKE 'CTR-IMP-%-' || $1
+ ORDER BY created_at DESC LIMIT 1`,
+ [opts.suffix],
+ ).then(({ rows }) => {
+ expect(rows, `seeded contract *-${opts.suffix}`).to.have.length(1);
+ // Path B: customs-clearance contracts are booked by Global Logistics on
+ // behalf of the customer — the portal user is rejected with a 403.
+ const actor = rows[0].customs_clearing_enabled ? superAdmin : customer;
+ apiPost(
+ actor,
+ `/api/contracts/${rows[0].id}/bookings`,
+ {
+ ...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}),
+ containers: lines,
+ },
+ !opts.expectFailure,
+ ).then((res) => {
+ if (opts.expectFailure) {
+ expect(res.status, `${opts.suffix} booking rejected`).to.be.within(400, 422);
+ expect(JSON.stringify(res.body)).to.include(opts.expectFailure);
+ } else {
+ expect(res.status, `${opts.suffix} booking created`).to.be.oneOf([200, 201]);
+ }
+ });
+ });
+}
+
+/** Ops accepts the operation request → FULLY_EXECUTED (enters the day pool). */
+export function acceptOperation(suffix: string) {
+ withBooking(suffix, (b) => {
+ apiPost(opsStaff, `/api/bookings/${b.id}/operation/review`, { decision: "ACCEPT" })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ pollBookingStatus(suffix, "FULLY_EXECUTED", 10);
+}
+
+/** Batch fill reserves by priority DESC — order 1 = first pick. */
+export function setPriority(suffix: string, order: number) {
+ withBooking(suffix, (b) =>
+ db(`UPDATE freight.bookings SET priority_score = $2 WHERE id = $1`, [
+ b.id,
+ 1000 - order,
+ ]),
+ );
+}
+
+/** Staff force-pay; polls PAID + SCHEDULED. */
+export function markPaid(suffix: string) {
+ withBooking(suffix, (b) => {
+ apiPost(opsStaff, `/api/train-scheduling/bookings/${b.id}/mark-paid`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ pollDb(
+ `${suffix} PAID+SCHEDULED`,
+ `SELECT b.status, b.scheduling_status FROM freight.bookings b
+ JOIN freight.contracts ct ON ct.id = b.contract_id
+ WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
+ ORDER BY b.created_at DESC LIMIT 1`,
+ [suffix],
+ (row) => row?.status === "PAID" && row?.scheduling_status === "SCHEDULED",
+ 20,
+ );
+}
+
+/**
+ * Settle a reservation through the REAL payment pipeline: seed the gateway
+ * intent projection (the payment microservice is absent in e2e), link it to
+ * the open invoice, then deliver the `payment.succeeded` event to the public
+ * internal endpoint. This drives billing settle → `booking.invoice.paid` →
+ * `advanceBookingOnPayment` → `ensurePaidBookingAllocated`, which is the ONLY
+ * path that applies a pending split offer (staff mark-paid skips it).
+ */
+export function settleViaGateway(suffix: string) {
+ withBooking(suffix, (b) => {
+ db<{ intent_id: string; currency: string; total: string }>(
+ `WITH inv AS (
+ SELECT id, currency, total_amount FROM freight.invoices
+ WHERE source_id = $1 AND deleted_at IS NULL AND paid_at IS NULL
+ ORDER BY created_at DESC LIMIT 1
+ ), intent AS (
+ INSERT INTO freight.payments
+ (id, ref_id, type, reference_type, method, currency, amount,
+ reason, raw_initiation, merchant_order_id, status)
+ SELECT gen_random_uuid(), $1, 'FREIGHT', 'SHIPMENT',
+ 'telebirr'::freight.payments_method_enum,
+ inv.currency::freight.payments_currency_enum, 1,
+ 'e2e gateway settle', '{}'::jsonb, 'E2E_' || $2,
+ 'processing'::freight.payments_status_enum
+ FROM inv
+ RETURNING id
+ ), link AS (
+ UPDATE freight.invoices SET payment_id = intent.id
+ FROM intent WHERE freight.invoices.id = (SELECT id FROM inv)
+ RETURNING payment_id
+ )
+ SELECT intent.id AS intent_id, inv.currency, inv.total_amount AS total
+ FROM intent, inv`,
+ [b.id, `${suffix}-${b.id.slice(0, 8)}`],
+ ).then(({ rows }) => {
+ expect(rows, `${suffix} gateway intent`).to.have.length(1);
+ cy.request({
+ method: "POST",
+ url: `${apiUrl()}/api/internal/payments/mark-paid`,
+ body: {
+ version: 1,
+ eventId: crypto.randomUUID(),
+ eventType: "payment.succeeded",
+ occurredAt: new Date().toISOString(),
+ service: "FREIGHT",
+ intentId: rows[0].intent_id,
+ referenceType: "SHIPMENT",
+ referenceId: b.id,
+ merchantOrderId: `E2E_${suffix}_${b.id.slice(0, 8)}`,
+ provider: "TELEBIRR",
+ amountMinor: 1,
+ currency: rows[0].currency,
+ },
+ }).then((res) => {
+ expect(res.status, `${suffix} payment event accepted`).to.eq(200);
+ // The global response interceptor wraps payloads in { success, data }.
+ const raw = res.body as { processed?: boolean; data?: { processed?: boolean } };
+ expect(raw.processed ?? raw.data?.processed, "event processed").to.eq(true);
+ });
+ });
+ });
+ pollDb(
+ `${suffix} PAID via gateway`,
+ `SELECT b.status FROM freight.bookings b
+ JOIN freight.contracts ct ON ct.id = b.contract_id
+ WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
+ ORDER BY b.created_at DESC LIMIT 1`,
+ [suffix],
+ (row) => row?.status === "PAID",
+ 20,
+ );
+}
+
+/** Push a reservation's pay deadline into the past — the 10s tick expires it. */
+export function forceReservationExpiry(suffix: string) {
+ withBooking(suffix, (b) =>
+ db(
+ `UPDATE freight.bookings SET payment_deadline = now() - interval '1 second'
+ WHERE id = $1`,
+ [b.id],
+ ),
+ );
+ pollBookingStatus(suffix, "EXPIRED");
+}
+
+export function pollAllocations(suffix: string, minWagons = 1) {
+ withBooking(suffix, (b) =>
+ pollDb<{ n: string }>(
+ `${suffix} wagon allocations`,
+ `SELECT count(*) AS n FROM freight.wagon_booking_allocations
+ WHERE booking_id = $1 AND deleted_at IS NULL`,
+ [b.id],
+ (row) => Number(row?.n ?? 0) >= minWagons,
+ 30,
+ ),
+ );
+}
+
+// ---------------------------------------------------------------------------
+// route + schedule
+// ---------------------------------------------------------------------------
+
+export function dbRouteId(originCode = ORIGIN, destCode = DEST) {
+ return db<{ id: string }>(
+ `SELECT r.id FROM freight.routes r
+ JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = $1
+ JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = $2
+ WHERE r.deleted_at IS NULL
+ ORDER BY r.created_at DESC LIMIT 1`,
+ [originCode, destCode],
+ );
+}
+
+/** Create the 6-stop corridor route through the API if it doesn't exist yet. */
+export function ensureCorridorRoute() {
+ dbRouteId().then(({ rows }) => {
+ if (rows.length > 0) return;
+ db<{ id: string; code: string }>(
+ `SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`,
+ [[...CORRIDOR]],
+ ).then(({ rows: yards }) => {
+ expect(yards, "corridor yards").to.have.length(CORRIDOR.length);
+ const byCode = new Map(yards.map((y) => [y.code, y.id]));
+ apiPost(opsStaff, "/api/routes", {
+ milestones: CORRIDOR.map((code) => ({ yardId: byCode.get(code) })),
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ // Direction is frozen from the endpoint countries: DJ → ET = IMPORT.
+ db<{ direction: string }>(
+ `SELECT r.direction FROM freight.routes r
+ JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = $1
+ JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = $2
+ WHERE r.deleted_at IS NULL ORDER BY r.created_at DESC LIMIT 1`,
+ [ORIGIN, DEST],
+ ).then(({ rows: created }) => {
+ expect(created[0]?.direction, "corridor direction").to.eq("IMPORT");
+ });
+ });
+}
+
+/**
+ * Make a corridor departure-day re-runnable: soft-delete any schedule a prior
+ * run left on that day and expire its leftover fixture bookings (reference
+ * scope CTR-IMP-% only — never touches other suites' data). A wiped schedule
+ * must never leave bookings pointing at it (ghost refs break assign).
+ */
+export function resetCorridorDay(departure: Date, destCode = DEST) {
+ db(
+ `WITH stale AS (
+ SELECT ts.id FROM freight.train_schedules ts
+ JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
+ JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
+ WHERE ts.deleted_at IS NULL
+ AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 43200
+ ), unlink AS (
+ UPDATE freight.bookings b
+ SET train_schedule_id = NULL,
+ status = CASE WHEN b.status IN ('FULLY_EXECUTED','SELECTED_FOR_BATCH','AWAITING_PAYMENT')
+ THEN 'EXPIRED' ELSE b.status END,
+ scheduling_status = 'NOT_SCHEDULED'
+ FROM freight.contracts ct
+ WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%'
+ AND b.train_schedule_id IN (SELECT id FROM stale)
+ ), drop_links AS (
+ UPDATE freight.train_schedule_bookings SET deleted_at = now()
+ WHERE train_schedule_id IN (SELECT id FROM stale) AND deleted_at IS NULL
+ )
+ UPDATE freight.train_schedules SET deleted_at = now()
+ WHERE id IN (SELECT id FROM stale)`,
+ [ORIGIN, destCode, departure.toISOString()],
+ );
+ // Prior-run pool leftovers (never reserved, so no schedule ref) would
+ // contaminate this run's batch — a stale high-priority booking steals the
+ // top-up slot from this run's waiting list. Reset runs before this run
+ // books anything, so every unpinned fixture booking is debris: expire all.
+ db(
+ `UPDATE freight.bookings b
+ SET status = 'EXPIRED'
+ FROM freight.contracts ct
+ WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%'
+ AND b.status = 'FULLY_EXECUTED' AND b.train_schedule_id IS NULL`,
+ [],
+ );
+}
+
+export interface ScheduleRow {
+ id: string;
+ status: string;
+ window_phase: string;
+ booking_window_status: string;
+ booking_cycle_no: number;
+ max_wagons: number;
+ window_opens_at: string | null;
+ window_closes_at: string | null;
+ payment_phase_ends_at: string | null;
+ scheduled_departure_date: string;
+}
+
+const SCHEDULE_COLS = `ts.id, ts.status, ts.window_phase, ts.booking_window_status,
+ ts.booking_cycle_no, ts.max_wagons, ts.window_opens_at, ts.window_closes_at,
+ ts.payment_phase_ends_at, ts.scheduled_departure_date`;
+
+/** The corridor schedule departing within ±1h of `departure` (12:00 EAT pin). */
+export function dbSchedule(departure: Date, destCode = DEST) {
+ return db(
+ `SELECT ${SCHEDULE_COLS}
+ FROM freight.train_schedules ts
+ JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
+ JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
+ WHERE ts.deleted_at IS NULL
+ AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 3600
+ ORDER BY ts.created_at DESC LIMIT 1`,
+ [ORIGIN, destCode, departure.toISOString()],
+ );
+}
+
+export function withSchedule(departure: Date, fn: (s: ScheduleRow) => void) {
+ dbSchedule(departure).then(({ rows }) => {
+ expect(rows, `schedule departing ${departure.toISOString()}`).to.have.length(1);
+ fn(rows[0]);
+ });
+}
+
+/**
+ * Ops creates an import schedule on the corridor via the API — loco-pair mode
+ * (no built train): capacity comes from maxWagonsPerTrain (54, the corridor
+ * standard) and wagon stock is drawn from the origin yard at allocation time.
+ */
+export function createImportSchedule(opts: {
+ departure: Date;
+ locoPair: [string, string];
+ maxWagons?: number;
+}) {
+ dbSchedule(opts.departure).then(({ rows }) => {
+ if (rows.length > 0) return;
+ dbRouteId().then(({ rows: routes }) => {
+ expect(routes, "corridor route").to.have.length(1);
+ db<{ id: string }>(
+ `SELECT id FROM freight.locomotives WHERE code = ANY($1::text[]) ORDER BY code`,
+ [opts.locoPair],
+ ).then(({ rows: locos }) => {
+ expect(locos, `locomotives ${opts.locoPair.join(",")}`).to.have.length(2);
+ apiPost(opsStaff, "/api/train-scheduling/container/schedules", {
+ routeId: routes[0].id,
+ scheduleDate: opts.departure.toISOString(),
+ locomotiveIds: locos.map((l) => l.id),
+ maxWagonsPerTrain: opts.maxWagons ?? 54,
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ });
+ });
+ withSchedule(opts.departure, (s) => {
+ expect(s.max_wagons, "54-wagon consist").to.eq(opts.maxWagons ?? 54);
+ });
+}
+
+// ---------------------------------------------------------------------------
+// window choreography — arrange timestamps, let the engine do the transition
+// ---------------------------------------------------------------------------
+
+function pollSchedulePhase(
+ scheduleId: string,
+ want: string[],
+ label: string,
+ attempts = 40,
+) {
+ pollDb(
+ label,
+ `SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
+ [scheduleId],
+ (row) => !!row && want.includes(row.window_phase as unknown as string),
+ attempts,
+ );
+}
+
+/** Pull the window-open moment into the past; the tick flips PRE_WINDOW→OPEN. */
+export function forceWindowOpen(scheduleId: string, closesInMinutes = 45) {
+ db(
+ `UPDATE freight.train_schedules
+ SET window_opens_at = now() - interval '1 minute',
+ window_closes_at = now() + ($2 || ' minutes')::interval
+ WHERE id = $1`,
+ [scheduleId, String(closesInMinutes)],
+ );
+ pollSchedulePhase(scheduleId, ["OPEN"], `schedule ${scheduleId} window OPEN`);
+ pollDb(
+ `schedule ${scheduleId} bookable`,
+ `SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
+ [scheduleId],
+ (row) => row?.booking_window_status === "OPEN",
+ );
+}
+
+/** Pull the close moment into the past; the tick flips OPEN→DOC_REVIEW. */
+export function closeBookingWindow(scheduleId: string) {
+ db(
+ `UPDATE freight.train_schedules
+ SET window_closes_at = now() - interval '1 second'
+ WHERE id = $1 AND window_phase = 'OPEN'`,
+ [scheduleId],
+ );
+ pollSchedulePhase(scheduleId, ["DOC_REVIEW"], `schedule ${scheduleId} DOC_REVIEW`);
+}
+
+/**
+ * Staff end document review early → PAYMENT: expires never-accepted bookings,
+ * runs the priority batch over the route-day pool, reserves + issues invoices.
+ * (Lands on DONE instead when the batch reserved nobody.)
+ */
+export function completeDocReview(scheduleId: string) {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${scheduleId}/doc-review-complete`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ pollSchedulePhase(
+ scheduleId,
+ ["PAYMENT", "DONE", "PRE_WINDOW"],
+ `schedule ${scheduleId} payment phase`,
+ );
+}
+
+/** End the payment phase now — the tick settles (allocate paid / expire unpaid). */
+export function endPaymentPhase(scheduleId: string) {
+ db(
+ `UPDATE freight.train_schedules
+ SET payment_phase_ends_at = now() - interval '1 second'
+ WHERE id = $1 AND window_phase = 'PAYMENT'`,
+ [scheduleId],
+ );
+}
+
+// ---------------------------------------------------------------------------
+// train journey + clearance
+// ---------------------------------------------------------------------------
+
+export function recordCheckpoint(scheduleId: string, sequenceNo: number, kind: string) {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${scheduleId}/checkpoints`, {
+ sequenceNo,
+ kind,
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+}
+
+/** GL multipart upload via the Node-side task (cy.request can't send files). */
+export function glUpload(
+ path: string,
+ fields: Record = {},
+ fileField = "files",
+) {
+ return tokenFor(superAdmin).then((token) =>
+ cy
+ .task<{ status: number; body: unknown }>("api:upload", {
+ url: `${apiUrl()}${path}`,
+ token,
+ fields,
+ files: [{ field: fileField, fixture: "docs/license.pdf", filename: "e2e-doc.pdf" }],
+ })
+ .then((res) => {
+ expect(res.status, `upload ${path}`).to.be.within(200, 201);
+ return cy.wrap(res.body, { log: false });
+ }),
+ );
+}
+
+export function completeBookingMilestone(suffix: string, code: string) {
+ withBooking(suffix, (b) => {
+ apiPost(superAdmin, `/api/contracts/bookings/${b.id}/milestones/${code}/complete`, {
+ note: "e2e",
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+}
+
+export function expectMilestoneDone(suffix: string, code: string) {
+ withBooking(suffix, (b) =>
+ pollDb<{ n: string }>(
+ `${suffix} milestone ${code}`,
+ `SELECT count(*) AS n FROM freight.clearance_milestones
+ WHERE booking_id = $1 AND milestone_code = $2 AND status = 'COMPLETED'
+ AND deleted_at IS NULL`,
+ [b.id, code],
+ (row) => Number(row?.n ?? 0) > 0,
+ 10,
+ ),
+ );
+}
diff --git a/e2e/freight/cypress/e2e/flows/import_critical_matrix.cy.ts b/e2e/freight/cypress/e2e/flows/import_critical_matrix.cy.ts
new file mode 100644
index 000000000..dd2300a87
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/import_critical_matrix.cy.ts
@@ -0,0 +1,299 @@
+/**
+ * IMPORT critical-scenario matrix — the corridor edge cases that don't need a
+ * full journey each. One 54-wagon train departing D+7, plus a same-day sibling:
+ *
+ * 1. hard gates at booking creation:
+ * – no open window on the requested day → rejected
+ * – duplicate ISO container number inside one booking → rejected
+ * 2. route creation refuses a yard pair with no configured distance
+ * 3. sub-corridor import booking (NAGAD → MOJO) rides the through-train:
+ * the batch is corridor-aware, the booking reserves only its own leg
+ * 4. intercity ride-along (MOJO → KALITY, DOMESTIC): dateless booking, staff
+ * accept onto the import train's free leg, pay window opens, paid + linked
+ * 5. same-route same-day sibling schedule JOINS the group window (shared
+ * open/close timeline — no cross-expiry), and staff can move a booking
+ * onto the sibling (move-schedule)
+ *
+ * Sequential steps — retries off.
+ */
+
+import {
+ resetCorridorDay,
+ acceptOperation,
+ apiPost,
+ bookContainers,
+ closeBookingWindow,
+ completeDocReview,
+ createImportSchedule,
+ db,
+ departureAt,
+ dbRouteId,
+ eatDayStr,
+ ensureCorridorRoute,
+ forceWindowOpen,
+ markPaid,
+ opsStaff,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ seedImportContract,
+ withBooking,
+ withSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(9);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+const NO_WINDOW_DAY = eatDayStr(departureAt(11)); // no schedule exists there
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+describe("import critical matrix: gates, sub-corridor, intercity, sibling window", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ seedImportContract({ suffix: "MX1", reference: stampedRef("MX1") }); // through-corridor
+ seedImportContract({ suffix: "MX2", reference: stampedRef("MX2") }); // gate probes
+ seedImportContract({
+ suffix: "MXSUB",
+ reference: stampedRef("MXSUB"),
+ originCode: "NAGAD",
+ destCode: "MOJO",
+ });
+ seedImportContract({
+ suffix: "MXIC",
+ reference: stampedRef("MXIC"),
+ direction: "DOMESTIC",
+ originCode: "MOJO",
+ destCode: "KALITY",
+ });
+ seedImportContract({ suffix: "MXMOVE", reference: stampedRef("MXMOVE") });
+ });
+
+ it("operations prepares the corridor and the D+7 train with an open window", () => {
+ ensureCorridorRoute();
+ resetCorridorDay(DEPARTURE);
+ createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-11", "LOCO-IMP-12"] });
+ withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
+ });
+
+ it("gate: a booking day with no open window is rejected", () => {
+ bookContainers({
+ suffix: "MX2",
+ runStamp: stamp,
+ isoSeed: 5000,
+ twenty: 2,
+ scheduledDate: NO_WINDOW_DAY,
+ expectFailure: "booking window",
+ });
+ });
+
+ it("gate: a duplicate ISO container number inside one booking is rejected", () => {
+ // Two units, same number: build the payload by hand via the same API.
+ apiPostDuplicate();
+
+ function apiPostDuplicate() {
+ const dupe = `MSCU${String(Number(stamp.slice(-6)) + 5100).padStart(7, "0")}`;
+ db<{ id: string }>(
+ `SELECT id FROM freight.contracts
+ WHERE reference LIKE 'CTR-IMP-%-' || $1
+ ORDER BY created_at DESC LIMIT 1`,
+ ["MX2"],
+ ).then(({ rows }) => {
+ apiPost(
+ "user@gmail.com",
+ `/api/contracts/${rows[0].id}/bookings`,
+ {
+ scheduledDate: BOOKING_DAY,
+ containers: [
+ {
+ containerSize: "20ft",
+ quantity: 2,
+ units: [
+ { containerNumber: dupe, vgmTons: 10 },
+ { containerNumber: dupe, vgmTons: 10 },
+ ],
+ },
+ ],
+ },
+ false,
+ ).then((res) => {
+ expect(res.status, "duplicate ISO rejected").to.be.within(400, 422);
+ expect(JSON.stringify(res.body)).to.include("Duplicate container number");
+ });
+ });
+ }
+ });
+
+ it("gate: a route over a yard pair with no configured distance is rejected", () => {
+ db<{ id: string; code: string }>(
+ `SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`,
+ [["E2E_AWASH", "DJIB_PORT"]],
+ ).then(({ rows }) => {
+ const byCode = new Map(rows.map((y) => [y.code, y.id]));
+ // E2E_AWASH ↔ DJIB_PORT has no direct distance row.
+ apiPost(
+ opsStaff,
+ "/api/routes",
+ {
+ milestones: [
+ { yardId: byCode.get("DJIB_PORT") },
+ { yardId: byCode.get("E2E_AWASH") },
+ ],
+ },
+ false,
+ ).then((res) => {
+ expect(res.status, "distance-less route rejected").to.be.within(400, 422);
+ expect(JSON.stringify(res.body)).to.include("No distance configured");
+ });
+ });
+ });
+
+ it("a through-corridor booking and a NAGAD→MOJO sub-corridor booking share the train", () => {
+ // All windowed bookings (incl. MXMOVE for the later move test) go in while
+ // the window is still OPEN — the create gate closes with it.
+ bookContainers({
+ suffix: "MX1",
+ runStamp: stamp,
+ isoSeed: 5200,
+ twenty: 40, // 20 wagons DJIB_PORT → KALITY
+ scheduledDate: BOOKING_DAY,
+ });
+ acceptOperation("MX1");
+ bookContainers({
+ suffix: "MXSUB",
+ runStamp: stamp,
+ isoSeed: 5300,
+ twenty: 20, // 10 wagons, NAGAD → MOJO leg only
+ scheduledDate: BOOKING_DAY,
+ });
+ acceptOperation("MXSUB");
+ bookContainers({
+ suffix: "MXMOVE",
+ runStamp: stamp,
+ isoSeed: 5500,
+ forty: 4, // 4 wagons — later moved onto the sibling train
+ scheduledDate: BOOKING_DAY,
+ });
+ acceptOperation("MXMOVE");
+
+ withSchedule(DEPARTURE, (s) => {
+ closeBookingWindow(s.id);
+ completeDocReview(s.id);
+ });
+ ["MX1", "MXSUB", "MXMOVE"].forEach((suffix) =>
+ pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ markPaid("MX1");
+ pollAllocations("MX1", 20);
+ markPaid("MXSUB");
+ pollAllocations("MXSUB", 10);
+
+ // Both ride the same schedule even though MXSUB's endpoints are interior stops.
+ withSchedule(DEPARTURE, (s) => {
+ withBooking("MX1", (b) => expect(b.train_schedule_id).to.eq(s.id));
+ withBooking("MXSUB", (b) => expect(b.train_schedule_id).to.eq(s.id));
+ });
+ });
+
+ it("intercity ride-along: dateless DOMESTIC booking accepted onto the import train's free leg", () => {
+ bookContainers({
+ suffix: "MXIC",
+ runStamp: stamp,
+ isoSeed: 5400,
+ twenty: 4, // 2 wagons MOJO → KALITY — plenty of leg capacity left
+ // no scheduledDate: intercity bookings are dateless
+ });
+ acceptOperation("MXIC");
+
+ withSchedule(DEPARTURE, (s) => {
+ withBooking("MXIC", (b) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${s.id}/intercity/accept`, {
+ bookingIds: [b.id],
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ });
+ pollBookingStatus("MXIC", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
+ withBooking("MXIC", (b) => {
+ expect(b.payment_deadline, "ride-along pay window opened").to.be.a("string");
+ });
+ markPaid("MXIC");
+ withSchedule(DEPARTURE, (s) => {
+ withBooking("MXIC", (b) => {
+ expect(b.train_schedule_id, "linked to the import train").to.eq(s.id);
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.train_schedule_bookings
+ WHERE booking_id = $1 AND train_schedule_id = $2 AND deleted_at IS NULL`,
+ [b.id, s.id],
+ ).then(({ rows }) => expect(Number(rows[0].n), "link row").to.eq(1));
+ });
+ });
+ });
+
+ it("a same-route same-day sibling schedule joins the shared group window", () => {
+ createImportSchedule({
+ departure: new Date(DEPARTURE.getTime() + 90 * 60_000), // same EAT day, later
+ locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"],
+ });
+ withSchedule(DEPARTURE, (anchor) => {
+ pollDb(
+ "sibling adopts the group timeline",
+ `SELECT ${["ts.id", "ts.window_phase", "ts.window_opens_at"].join(", ")}
+ FROM freight.train_schedules ts
+ JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = 'DJIB_PORT'
+ JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = 'KALITY'
+ WHERE ts.deleted_at IS NULL AND ts.id <> $1
+ AND abs(extract(epoch FROM (ts.scheduled_departure_date - $2::timestamptz))) < 7200
+ ORDER BY ts.created_at DESC LIMIT 1`,
+ [anchor.id, DEPARTURE.toISOString()],
+ // The anchor is already past OPEN (we closed it) — a mid-cycle joiner
+ // mirrors the group's live phase instead of restarting its own clock.
+ (row) => !!row && row.window_phase === anchor.window_phase,
+ );
+ });
+ });
+
+ it("staff move a reserved booking onto the sibling train, then it pays there", () => {
+ withSchedule(DEPARTURE, (anchor) => {
+ db<{ id: string }>(
+ `SELECT ts.id FROM freight.train_schedules ts
+ WHERE ts.deleted_at IS NULL AND ts.id <> $1
+ AND abs(extract(epoch FROM (ts.scheduled_departure_date - $2::timestamptz))) < 7200
+ ORDER BY ts.created_at DESC LIMIT 1`,
+ [anchor.id, DEPARTURE.toISOString()],
+ ).then(({ rows: siblings }) => {
+ expect(siblings, "sibling schedule").to.have.length(1);
+ // move-schedule only accepts an OPEN target — the sibling joined the
+ // group mid-cycle (already past OPEN), so arrange its window state.
+ db(
+ `UPDATE freight.train_schedules SET booking_window_status = 'OPEN'
+ WHERE id = $1`,
+ [siblings[0].id],
+ );
+ withBooking("MXMOVE", (b) => {
+ apiPost(opsStaff, `/api/train-scheduling/bookings/${b.id}/move-schedule`, {
+ trainScheduleId: siblings[0].id,
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ pollDb<{ train_schedule_id: string }>(
+ "MXMOVE pinned to the sibling",
+ `SELECT train_schedule_id FROM freight.bookings WHERE id = $1`,
+ [b.id],
+ (row) => row?.train_schedule_id === siblings[0].id,
+ 10,
+ );
+ });
+ markPaid("MXMOVE");
+ pollAllocations("MXMOVE", 4);
+ withBooking("MXMOVE", (b) => {
+ expect(b.train_schedule_id, "paid on the sibling").to.eq(siblings[0].id);
+ });
+ });
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/import_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/import_full_train.cy.ts
new file mode 100644
index 000000000..4d2f2c084
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/import_full_train.cy.ts
@@ -0,0 +1,332 @@
+/**
+ * IMPORT journey 1 — six container bookings fill a 54-wagon train on the long
+ * corridor DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY, all in
+ * the FIRST booking window, then the full life of the train: payment,
+ * allocation, gate pass, T1, dispatch, checkpoint-by-checkpoint movement,
+ * arrival, and the post-arrival customs tail to IMPORT_PROCESS_COMPLETED.
+ *
+ * The six bookings (exact wagon math — Σ = 54, the full consist):
+ * FT1 customs + USD 16×20ft = 8 wagons
+ * FT2 customs + ETB 6×40ft = 6 wagons
+ * FT3 self + ETB 12×20ft = 6 wagons
+ * FT4 self + ETB 6×40ft = 6 wagons
+ * FT5 customs + USD 44×20ft = 22 wagons (the ≥22-wagon giant)
+ * FT6 self + USD 4×40ft + 4×20ft = 6 wagons
+ *
+ * Sequential steps of one journey — retries off (steps are not idempotent).
+ */
+
+import {
+ resetCorridorDay,
+ acceptOperation,
+ apiPost,
+ bookContainers,
+ closeBookingWindow,
+ completeBookingMilestone,
+ completeDocReview,
+ createImportSchedule,
+ db,
+ dbBooking,
+ departureAt,
+ eatDayStr,
+ endPaymentPhase,
+ ensureCorridorRoute,
+ expectMilestoneDone,
+ forceWindowOpen,
+ glUpload,
+ markPaid,
+ opsStaff,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ seedImportContract,
+ withBooking,
+ withSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(4);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+/** suffix → [customs, currency, twenty, forty, wagons] */
+const BOOKINGS: Array<{
+ suffix: string;
+ customs: boolean;
+ currency: "ETB" | "USD";
+ twenty: number;
+ forty: number;
+ wagons: number;
+}> = [
+ { suffix: "FT1", customs: true, currency: "USD", twenty: 16, forty: 0, wagons: 8 },
+ { suffix: "FT2", customs: true, currency: "ETB", twenty: 0, forty: 6, wagons: 6 },
+ { suffix: "FT3", customs: false, currency: "ETB", twenty: 12, forty: 0, wagons: 6 },
+ { suffix: "FT4", customs: false, currency: "ETB", twenty: 0, forty: 6, wagons: 6 },
+ { suffix: "FT5", customs: true, currency: "USD", twenty: 44, forty: 0, wagons: 22 },
+ { suffix: "FT6", customs: false, currency: "USD", twenty: 4, forty: 4, wagons: 6 },
+];
+const CUSTOMS = BOOKINGS.filter((b) => b.customs).map((b) => b.suffix);
+const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs).map((b) => b.suffix);
+
+function withScheduleId(fn: (id: string, s: ScheduleRow) => void) {
+ withSchedule(DEPARTURE, (s) => fn(s.id, s));
+}
+
+describe("import: six bookings fill the 54-wagon corridor train", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ for (const b of BOOKINGS) {
+ seedImportContract({
+ suffix: b.suffix,
+ reference: stampedRef(b.suffix),
+ currency: b.currency,
+ customs: b.customs,
+ });
+ }
+ });
+
+ it("operations ensures the 6-stop import corridor route exists (direction frozen IMPORT)", () => {
+ ensureCorridorRoute();
+ resetCorridorDay(DEPARTURE);
+ });
+
+ it("operations schedules the 54-wagon import train — first window forced open", () => {
+ createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-1", "LOCO-IMP-2"] });
+ withScheduleId((id) => forceWindowOpen(id, 45));
+ withScheduleId((_, s) => {
+ expect(s.booking_cycle_no, "FIRST window cycle").to.eq(1);
+ });
+ });
+
+ it("customer books all six shipments inside the first window", () => {
+ let isoSeed = 0;
+ BOOKINGS.forEach((b) => {
+ bookContainers({
+ suffix: b.suffix,
+ runStamp: stamp,
+ isoSeed,
+ twenty: b.twenty,
+ forty: b.forty,
+ scheduledDate: BOOKING_DAY,
+ });
+ isoSeed += b.twenty + b.forty;
+ pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
+ });
+ });
+
+ it("operations accepts all six — the whole pool is FULLY_EXECUTED (in window)", () => {
+ BOOKINGS.forEach((b) => acceptOperation(b.suffix));
+ });
+
+ it("window closes, doc review completes — the batch reserves ALL six (they fit exactly)", () => {
+ withScheduleId((id) => {
+ closeBookingWindow(id);
+ completeDocReview(id);
+ });
+ BOOKINGS.forEach((b) =>
+ pollBookingStatus(b.suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ // Reservation = pay deadline + a payable invoice in the CONTRACT currency.
+ BOOKINGS.forEach((b) => {
+ withBooking(b.suffix, (row) => {
+ expect(row.payment_deadline, `${b.suffix} pay deadline`).to.be.a("string");
+ pollDb<{ currency: string }>(
+ `${b.suffix} invoice`,
+ `SELECT currency FROM freight.invoices
+ WHERE source_id = $1 AND deleted_at IS NULL
+ ORDER BY created_at DESC LIMIT 1`,
+ [row.id],
+ (inv) => inv?.currency === b.currency,
+ 10,
+ );
+ });
+ });
+ });
+
+ it("all six pay — allocated onto the train, 54/54 wagons, window FULL and schedule finalized", () => {
+ BOOKINGS.forEach((b) => {
+ markPaid(b.suffix);
+ pollAllocations(b.suffix, b.wagons);
+ });
+ withScheduleId((id) => {
+ endPaymentPhase(id);
+ // Full train → conclude marks FULL + DONE and auto-finalizes (DRAFT→SCHEDULED).
+ pollDb(
+ "schedule FULL + DONE + finalized",
+ `SELECT window_phase, booking_window_status, status
+ FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) =>
+ s?.booking_window_status === "FULL" &&
+ s?.window_phase === "DONE" &&
+ s?.status === "SCHEDULED",
+ );
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.train_schedule_bookings
+ WHERE train_schedule_id = $1 AND deleted_at IS NULL`,
+ [id],
+ ).then(({ rows }) => expect(Number(rows[0].n), "6 bookings linked").to.eq(6));
+ db<{ n: string }>(
+ `SELECT count(DISTINCT wba.train_set_wagon_id) AS n
+ FROM freight.wagon_booking_allocations wba
+ JOIN freight.train_schedule_bookings tsb
+ ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
+ WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
+ [id],
+ ).then(({ rows }) => expect(Number(rows[0].n), "54 wagons allocated").to.eq(54));
+ });
+ });
+
+ it("backoffice sees the full train on the schedule detail", () => {
+ cy.loginBackoffice(opsStaff);
+ withScheduleId((id) => cy.visit(`/dashboard/operations/train-scheduling-v2/${id}`));
+ cy.contains(/54/, { timeout: 30000 }).should("exist");
+ });
+
+ it("GL Djibouti: gate pass granted, T1 documents uploaded for the customs bookings", () => {
+ withScheduleId((id) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ CUSTOMS.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ glUpload(`/api/contracts/bookings/${b.id}/t1-documents`);
+ });
+ });
+ });
+
+ it("the train dispatches — every booking boards at the origin (IN_TRANSIT)", () => {
+ withScheduleId((id) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ pollDb(
+ "schedule DISPATCHED",
+ `SELECT status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.status === "DISPATCHED",
+ 10,
+ );
+ });
+ BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "IN_TRANSIT", 10));
+ BOOKINGS.forEach((b) =>
+ withBooking(b.suffix, (row) => {
+ db<{ loaded_at: string | null }>(
+ `SELECT loaded_at FROM freight.bookings WHERE id = $1`,
+ [row.id],
+ ).then(({ rows }) => expect(rows[0].loaded_at, `${b.suffix} loaded`).to.be.a("string"));
+ }),
+ );
+ });
+
+ it("the train runs the corridor checkpoint by checkpoint and arrives at the terminal", () => {
+ withScheduleId((id) => {
+ recordAll(id);
+ pollDb(
+ "schedule ARRIVED",
+ `SELECT status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.status === "ARRIVED",
+ 20,
+ );
+ });
+ // Final-yard auto-arrive settles every booking + its wagons at KALITY.
+ BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "ARRIVED", 20));
+ withScheduleId((id) => {
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.wagon_movements
+ WHERE train_schedule_id = $1`,
+ [id],
+ ).then(({ rows }) =>
+ expect(Number(rows[0].n), "wagon movement ledger rows").to.be.at.least(54),
+ );
+ });
+
+ function recordAll(id: string) {
+ // seq 0 = origin DEPARTED is stamped by dispatch; walk the rest.
+ [1, 2, 3, 4].forEach((seq) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/checkpoints`, {
+ sequenceNo: seq,
+ kind: "PASSED",
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/checkpoints`, {
+ sequenceNo: 5,
+ kind: "ARRIVED",
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ }
+ });
+
+ it("GL Ethiopia runs the customs tail on every customs booking (T1 close → risk → second duty → release → final invoice)", () => {
+ CUSTOMS.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/t1-close`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/risk`, {
+ riskLevel: "GREEN",
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ glUpload(
+ `/api/contracts/bookings/${b.id}/second-duty`,
+ { dutyRequired: "false" },
+ "attachment",
+ );
+ });
+ completeBookingMilestone(suffix, "IMPORT_RELEASE_GRANTED");
+ expectMilestoneDone(suffix, "T1_CLOSED");
+ expectMilestoneDone(suffix, "RISK_ASSIGNED");
+ expectMilestoneDone(suffix, "IMPORT_RELEASE_GRANTED");
+ });
+ });
+
+ it("GL Djibouti raises the final invoice; GL confirms the slip — import process completed", () => {
+ CUSTOMS.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ glUpload(
+ `/api/contracts/bookings/${b.id}/final-invoice`,
+ { amount: "1000", description: "e2e final invoice" },
+ "file",
+ );
+ // The customer attaches the payment slip; only then can GL confirm.
+ glUpload(`/api/contracts/bookings/${b.id}/final-invoice-slip`, {}, "file");
+ apiPost(
+ "superadmin@tria.com",
+ `/api/contracts/bookings/${b.id}/final-invoice/confirm`,
+ )
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ completeBookingMilestone(suffix, "IMPORT_PROCESS_COMPLETED");
+ expectMilestoneDone(suffix, "IMPORT_PROCESS_COMPLETED");
+ });
+ });
+
+ it("the self-clearance bookings arrived clean — no customs tail required", () => {
+ SELF_CLEAR.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ expect(b.status, `${suffix} final status`).to.eq("ARRIVED");
+ });
+ dbBooking(suffix).then(({ rows }) => {
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.clearance_milestones
+ WHERE booking_id = $1 AND milestone_code = 'T1_CLOSED'
+ AND status = 'COMPLETED' AND deleted_at IS NULL`,
+ [rows[0].id],
+ ).then(({ rows: ms }) =>
+ expect(Number(ms[0].n), `${suffix} has no T1 tail`).to.eq(0),
+ );
+ });
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/import_split_promote.cy.ts b/e2e/freight/cypress/e2e/flows/import_split_promote.cy.ts
new file mode 100644
index 000000000..a312063ba
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/import_split_promote.cy.ts
@@ -0,0 +1,224 @@
+/**
+ * IMPORT journey 3 — split offer, remainder rebooking, pay-window expiry, and
+ * priority-ordered waiting-list promotion, all on one 54-wagon corridor train:
+ *
+ * reserved by the batch (priority order):
+ * SA 40×20ft = 20w, SB 14×40ft = 14w, SD 24×20ft = 12w → 46w
+ * SC 48×20ft = 24w does NOT fit whole → the batch offers a PARTIAL of the
+ * remaining 8 wagons (16×20ft). SC pays → the split applies (is_split +
+ * pre_split_quantities), and the customer must later rebook EXACTLY the
+ * whole remainder (32×20ft) — a wrong quantity is rejected.
+ * SD never pays — its pay deadline passes and it EXPIRES; the freed 12
+ * wagons promote the waiting list in priority order: SW1 (12×20ft = 6w)
+ * and SW2 (6×40ft = 6w) get pay windows ("payment sent"); SW3 (40×20ft =
+ * 20w) never fits and expires with the day.
+ *
+ * Final consist: SA 20 + SB 14 + SC(split) 8 + SW1 6 + SW2 6 = 54/54.
+ *
+ * Sequential steps of one journey — retries off.
+ */
+
+import {
+ resetCorridorDay,
+ acceptOperation,
+ bookContainers,
+ closeBookingWindow,
+ completeDocReview,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ endPaymentPhase,
+ ensureCorridorRoute,
+ forceReservationExpiry,
+ forceWindowOpen,
+ markPaid,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ seedImportContract,
+ settleViaGateway,
+ setPriority,
+ withBooking,
+ withSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(6);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+/** The split remainder is rebooked onto a LATER train on the same corridor. */
+const REMAINDER_DEPARTURE = departureAt(8);
+const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+const ORDER = ["SA", "SB", "SD", "SC", "SW1", "SW2", "SW3"] as const;
+
+describe("import: split offer, remainder rebooking, expiry + promotion", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ ORDER.forEach((suffix) => seedImportContract({ suffix, reference: stampedRef(suffix) }));
+ });
+
+ it("operations prepares the corridor train with an open first window", () => {
+ ensureCorridorRoute();
+ resetCorridorDay(DEPARTURE);
+ resetCorridorDay(REMAINDER_DEPARTURE);
+ createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-5", "LOCO-IMP-6"] });
+ withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
+ });
+
+ it("seven customers book in the first window; operations accepts them in priority order", () => {
+ const shapes: Record = {
+ SA: { twenty: 40, forty: 0 },
+ SB: { twenty: 0, forty: 14 },
+ SD: { twenty: 24, forty: 0 },
+ SC: { twenty: 48, forty: 0 },
+ SW1: { twenty: 12, forty: 0 },
+ SW2: { twenty: 0, forty: 6 },
+ SW3: { twenty: 40, forty: 0 },
+ };
+ let isoSeed = 1500;
+ ORDER.forEach((suffix) => {
+ const s = shapes[suffix];
+ bookContainers({
+ suffix,
+ runStamp: stamp,
+ isoSeed,
+ twenty: s.twenty,
+ forty: s.forty,
+ scheduledDate: BOOKING_DAY,
+ });
+ isoSeed += s.twenty + s.forty;
+ acceptOperation(suffix);
+ });
+ ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
+ });
+
+ it("the batch reserves SA/SB/SD whole and offers SC a PARTIAL for the last 8 wagons", () => {
+ withSchedule(DEPARTURE, (s) => {
+ closeBookingWindow(s.id);
+ completeDocReview(s.id);
+ });
+ ["SA", "SB", "SD", "SC"].forEach((suffix) =>
+ pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ // SC's reservation is a partial OFFER (16×20ft of its 48).
+ withBooking("SC", (b) => {
+ pollDb<{ status: string }>(
+ "SC open partial offer",
+ `SELECT status FROM freight.booking_batch_offers
+ WHERE booking_id = $1 AND deleted_at IS NULL
+ ORDER BY created_at DESC LIMIT 1`,
+ [b.id],
+ (row) => row?.status === "OFFERED",
+ 10,
+ );
+ });
+ ["SW1", "SW2", "SW3"].forEach((suffix) =>
+ withBooking(suffix, (b) => {
+ expect(b.status, `${suffix} waiting`).to.eq("FULLY_EXECUTED");
+ }),
+ );
+ });
+
+ it("SA and SB pay; SC pays its partial — the split applies and the remainder is snapshotted", () => {
+ markPaid("SA");
+ pollAllocations("SA", 20);
+ markPaid("SB");
+ pollAllocations("SB", 14);
+
+ // SC must settle through the real payment pipeline — only the settle path
+ // applies the pending split offer (staff mark-paid allocates whole).
+ settleViaGateway("SC");
+ pollAllocations("SC", 8);
+ withBooking("SC", (b) => {
+ expect(b.is_split, "SC is split").to.eq(true);
+ db<{ pre_split_quantities: { bySize?: Record } | null; n: string }>(
+ `SELECT pre_split_quantities FROM freight.bookings WHERE id = $1`,
+ [b.id],
+ ).then(({ rows }) => {
+ expect(rows[0].pre_split_quantities, "pre-split snapshot").to.not.be.null;
+ });
+ // The booking itself shrank to the offered 16×20ft.
+ db<{ q: string }>(
+ `SELECT sum(quantity) AS q FROM freight.booking_container
+ WHERE booking_id = $1 AND deleted_at IS NULL`,
+ [b.id],
+ ).then(({ rows }) => expect(Number(rows[0].q), "SC shrank to 16 boxes").to.eq(16));
+ });
+ });
+
+ it("SD misses its pay window — EXPIRED, and the freed wagons promote SW1 + SW2 (payment sent)", () => {
+ forceReservationExpiry("SD");
+ // The settle promotes the waiting list in priority order into the freed 12
+ // wagons: SW1 (6w) and SW2 (6w) fit; SW3 (20w) does not.
+ ["SW1", "SW2"].forEach((suffix) =>
+ pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ ["SW1", "SW2"].forEach((suffix) =>
+ withBooking(suffix, (b) => {
+ expect(b.payment_deadline, `${suffix} got a pay window`).to.be.a("string");
+ }),
+ );
+ withBooking("SW3", (b) => {
+ expect(b.status, "SW3 still has no seat").to.eq("FULLY_EXECUTED");
+ });
+ });
+
+ it("SW1 and SW2 pay — the train is FULL at 54; SW3 expires with the day", () => {
+ markPaid("SW1");
+ pollAllocations("SW1", 6);
+ markPaid("SW2");
+ pollAllocations("SW2", 6);
+
+ withSchedule(DEPARTURE, (s) => {
+ endPaymentPhase(s.id);
+ pollDb(
+ "window FULL + DONE",
+ `SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ (row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
+ );
+ db<{ n: string }>(
+ `SELECT count(DISTINCT wba.train_set_wagon_id) AS n
+ FROM freight.wagon_booking_allocations wba
+ JOIN freight.train_schedule_bookings tsb
+ ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
+ WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
+ [s.id],
+ ).then(({ rows }) => expect(Number(rows[0].n), "54 wagons allocated").to.eq(54));
+ });
+ pollBookingStatus("SW3", "EXPIRED");
+ });
+
+ it("the split customer must rebook EXACTLY the whole remainder — wrong quantity rejected, exact accepted", () => {
+ createImportSchedule({
+ departure: REMAINDER_DEPARTURE,
+ locoPair: ["LOCO-IMP-7", "LOCO-IMP-8"],
+ });
+ withSchedule(REMAINDER_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
+
+ // 48 booked − 16 shipped-by-split = 32×20ft outstanding. 8 ≠ 32 → rejected.
+ bookContainers({
+ suffix: "SC",
+ runStamp: stamp,
+ isoSeed: 3000,
+ twenty: 8,
+ scheduledDate: REMAINDER_DAY,
+ expectFailure: "must take the whole remainder",
+ });
+
+ bookContainers({
+ suffix: "SC",
+ runStamp: stamp,
+ isoSeed: 3100,
+ twenty: 32,
+ scheduledDate: REMAINDER_DAY,
+ });
+ pollBookingStatus("SC", "OPERATION_REQUEST_PENDING", 5);
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/import_waiting_expiry.cy.ts b/e2e/freight/cypress/e2e/flows/import_waiting_expiry.cy.ts
new file mode 100644
index 000000000..981ba742b
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/import_waiting_expiry.cy.ts
@@ -0,0 +1,143 @@
+/**
+ * IMPORT journey 2 — the train fills from THREE bookings; three more sit in
+ * the waiting pool of the same (first) window. The three selected bookings
+ * pay and allocate; when the cycle concludes with the train FULL, the three
+ * waiting bookings have nowhere left to go on the day and expire.
+ *
+ * Wagon math (54-wagon consist):
+ * selected: WA 40×20ft = 20w, WB 20×40ft = 20w, WC 28×20ft = 14w → Σ 54
+ * waiting: WW1 20×20ft = 10w, WW2 10×40ft = 10w, WW3 20×20ft = 10w
+ *
+ * Priority order (score DESC drives the batch): WA > WB > WC > WW1 > WW2 > WW3.
+ *
+ * Sequential steps of one journey — retries off.
+ */
+
+import {
+ resetCorridorDay,
+ acceptOperation,
+ bookContainers,
+ closeBookingWindow,
+ completeDocReview,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ endPaymentPhase,
+ ensureCorridorRoute,
+ forceWindowOpen,
+ markPaid,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ seedImportContract,
+ setPriority,
+ withBooking,
+ withSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(5);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+const SELECTED = [
+ { suffix: "WA", twenty: 40, forty: 0, wagons: 20 },
+ { suffix: "WB", twenty: 0, forty: 20, wagons: 20 },
+ { suffix: "WC", twenty: 28, forty: 0, wagons: 14 },
+];
+const WAITING = [
+ { suffix: "WW1", twenty: 20, forty: 0, wagons: 10 },
+ { suffix: "WW2", twenty: 0, forty: 10, wagons: 10 },
+ { suffix: "WW3", twenty: 20, forty: 0, wagons: 10 },
+];
+const ALL = [...SELECTED, ...WAITING];
+
+describe("import: 3 bookings fill the train, 3 wait and expire", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ ALL.forEach((b) =>
+ seedImportContract({ suffix: b.suffix, reference: stampedRef(b.suffix) }),
+ );
+ });
+
+ it("operations prepares the corridor and a 54-wagon train with an open first window", () => {
+ ensureCorridorRoute();
+ resetCorridorDay(DEPARTURE);
+ createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-3", "LOCO-IMP-4"] });
+ withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
+ });
+
+ it("six customers book in the first window; operations accepts all six", () => {
+ let isoSeed = 500;
+ ALL.forEach((b) => {
+ bookContainers({
+ suffix: b.suffix,
+ runStamp: stamp,
+ isoSeed,
+ twenty: b.twenty,
+ forty: b.forty,
+ scheduledDate: BOOKING_DAY,
+ });
+ isoSeed += b.twenty + b.forty;
+ acceptOperation(b.suffix);
+ });
+ ALL.forEach((b, i) => setPriority(b.suffix, i + 1));
+ });
+
+ it("the batch selects exactly the three that fill 54 wagons; the rest keep waiting", () => {
+ withSchedule(DEPARTURE, (s) => {
+ closeBookingWindow(s.id);
+ completeDocReview(s.id);
+ });
+ SELECTED.forEach((b) =>
+ pollBookingStatus(b.suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ // Waiting bookings stay in the pool: FULLY_EXECUTED, no pay window opened.
+ WAITING.forEach((b) =>
+ withBooking(b.suffix, (row) => {
+ expect(row.status, `${b.suffix} still waiting`).to.eq("FULLY_EXECUTED");
+ expect(row.payment_deadline, `${b.suffix} has no pay deadline`).to.be.null;
+ }),
+ );
+ });
+
+ it("the three selected bookings pay and allocate — 54/54", () => {
+ SELECTED.forEach((b) => {
+ markPaid(b.suffix);
+ pollAllocations(b.suffix, b.wagons);
+ });
+ withSchedule(DEPARTURE, (s) => {
+ db<{ n: string }>(
+ `SELECT count(DISTINCT wba.train_set_wagon_id) AS n
+ FROM freight.wagon_booking_allocations wba
+ JOIN freight.train_schedule_bookings tsb
+ ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
+ WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
+ [s.id],
+ ).then(({ rows }) => expect(Number(rows[0].n), "54 wagons allocated").to.eq(54));
+ });
+ });
+
+ it("the cycle concludes FULL — the three waiting bookings expire with the day", () => {
+ withSchedule(DEPARTURE, (s) => {
+ endPaymentPhase(s.id);
+ pollDb(
+ "window FULL + DONE",
+ `SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ (row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
+ );
+ });
+ // No sibling train on the route-day can take them → the leftover day pool
+ // expires (the paid three are untouched).
+ WAITING.forEach((b) => pollBookingStatus(b.suffix, "EXPIRED"));
+ SELECTED.forEach((b) =>
+ withBooking(b.suffix, (row) => expect(row.status, `${b.suffix} stays PAID`).to.eq("PAID")),
+ );
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/import_window_reopen.cy.ts b/e2e/freight/cypress/e2e/flows/import_window_reopen.cy.ts
new file mode 100644
index 000000000..43c919bdd
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/import_window_reopen.cy.ts
@@ -0,0 +1,126 @@
+/**
+ * IMPORT journey 4 — nobody pays in the first window cycle: every reserved
+ * booking expires, the cycle concludes NOT-full and the window REOPENS for a
+ * second cycle on the same train. A fresh booking arrives in cycle 2, pays,
+ * and allocates — the train recovers from a dead first window.
+ *
+ * Sequential steps of one journey — retries off.
+ */
+
+import {
+ resetCorridorDay,
+ acceptOperation,
+ bookContainers,
+ closeBookingWindow,
+ completeDocReview,
+ createImportSchedule,
+ departureAt,
+ eatDayStr,
+ endPaymentPhase,
+ ensureCorridorRoute,
+ forceReservationExpiry,
+ forceWindowOpen,
+ markPaid,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ seedImportContract,
+ withBooking,
+ withSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(7);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+describe("import: dead first cycle — expire all, reopen, book again", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ ["RA", "RB", "RC"].forEach((suffix) =>
+ seedImportContract({ suffix, reference: stampedRef(suffix) }),
+ );
+ });
+
+ it("operations prepares the corridor train — first window opens (cycle 1)", () => {
+ ensureCorridorRoute();
+ resetCorridorDay(DEPARTURE);
+ createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-9", "LOCO-IMP-10"] });
+ withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
+ withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 1").to.eq(1));
+ });
+
+ it("two customers book and are reserved in cycle 1", () => {
+ bookContainers({
+ suffix: "RA",
+ runStamp: stamp,
+ isoSeed: 4000,
+ twenty: 40,
+ scheduledDate: BOOKING_DAY,
+ });
+ bookContainers({
+ suffix: "RB",
+ runStamp: stamp,
+ isoSeed: 4100,
+ forty: 20,
+ scheduledDate: BOOKING_DAY,
+ });
+ ["RA", "RB"].forEach((suffix) => acceptOperation(suffix));
+ withSchedule(DEPARTURE, (s) => {
+ closeBookingWindow(s.id);
+ completeDocReview(s.id);
+ });
+ ["RA", "RB"].forEach((suffix) =>
+ pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ });
+
+ it("nobody pays — both reservations expire and the cycle concludes not-full", () => {
+ ["RA", "RB"].forEach((suffix) => forceReservationExpiry(suffix));
+ withSchedule(DEPARTURE, (s) => {
+ endPaymentPhase(s.id);
+ // Not full + departure days away → the engine schedules a fresh cycle.
+ pollDb(
+ "window reopens (PRE_WINDOW, cycle 2 pending)",
+ `SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ (row) => row?.window_phase === "PRE_WINDOW",
+ );
+ });
+ });
+
+ it("the second window opens (cycle 2) and a fresh booking pays and allocates", () => {
+ withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
+ withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 2").to.eq(2));
+
+ bookContainers({
+ suffix: "RC",
+ runStamp: stamp,
+ isoSeed: 4200,
+ twenty: 20,
+ scheduledDate: BOOKING_DAY,
+ });
+ acceptOperation("RC");
+ withSchedule(DEPARTURE, (s) => {
+ closeBookingWindow(s.id);
+ completeDocReview(s.id);
+ });
+ pollBookingStatus("RC", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
+ markPaid("RC");
+ pollAllocations("RC", 10);
+
+ // The dead cycle's corpses stay dead; the recovery booking is on the train.
+ ["RA", "RB"].forEach((suffix) =>
+ withBooking(suffix, (b) => expect(b.status, `${suffix} stays expired`).to.eq("EXPIRED")),
+ );
+ withSchedule(DEPARTURE, (s) => {
+ withBooking("RC", (b) => {
+ expect(b.train_schedule_id, "RC rides the reopened train").to.eq(s.id);
+ });
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/fixtures/seed-import-corridor.sql b/e2e/freight/cypress/fixtures/seed-import-corridor.sql
new file mode 100644
index 000000000..07e7f5f78
--- /dev/null
+++ b/e2e/freight/cypress/fixtures/seed-import-corridor.sql
@@ -0,0 +1,152 @@
+-- Arrange-data for the IMPORT corridor flow specs
+-- (flows/import_full_train, import_waiting_expiry, import_split_promote,
+-- import_window_reopen, import_critical_matrix). Idempotent.
+--
+-- Long import corridor (A→B→C→D→E→T, 6 stops, DJ→ET = IMPORT):
+-- DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY
+--
+-- The specs create the route + schedules through the API; this fixture provides
+-- what the journeys cannot reasonably create in-flow:
+-- 1. the extra mid-corridor yard (E2E_AWASH) + container facility rows
+-- 2. container types 20FT/40FT + NW5 allow-list (shared with seed-intercity)
+-- 3. NW5 rated for 54 wagons per train (the corridor trains run 54)
+-- 4. eight locomotives at Djibouti Port (each import schedule needs >= 2)
+-- 5. free NW5 wagon stock parked at DJIB_PORT (+ a NAGAD pocket for the
+-- sub-corridor scenario) so wagon allocation has physical stock
+-- 6. yard distances for every consecutive pair (route creation refuses
+-- unconfigured pairs)
+-- 7. LIVE CONTAINER_IMPORT rates on the legs the specs book (pricing
+-- hard-blocks a container line without a rate on its exact leg) + an
+-- INTERCITY_CONTAINER rate for the ride-along scenario
+
+-- 0. The split-remainder chain (assertExactRemainder in contract-booking)
+-- deliberately creates a SECOND live booking under a split ONE_TIME contract,
+-- but no migration ever relaxed the 1822 one-live-booking unique index for it
+-- (the dev DB was hand-patched). Drop it here the same way — and note it as a
+-- missing production migration.
+DROP INDEX IF EXISTS freight.uq_one_active_booking_per_one_time_contract;
+
+-- 1a. Extra Ethiopian mid-corridor yard.
+INSERT INTO freight.yards (id, code, label, country, is_active, display_order)
+SELECT gen_random_uuid(), 'E2E_AWASH', 'E2E Awash Yard', 'Ethiopia', true, 50
+WHERE NOT EXISTS (SELECT 1 FROM freight.yards WHERE code = 'E2E_AWASH');
+
+-- 1b. Container-capable facility rows for every corridor yard the specs load
+-- or unload at (booking-journey's yard gate reads freight.yard_facilities).
+INSERT INTO freight.yard_facilities
+ (id, yard_id, has_warehouse, handles_container, handles_bulk, is_active)
+SELECT gen_random_uuid(), y.id, false, true, true, true
+FROM freight.yards y
+WHERE y.code IN ('DJIB_PORT', 'NAGAD', 'DIRE_DAWA', 'E2E_AWASH', 'MOJO', 'KALITY')
+ AND NOT EXISTS (
+ SELECT 1 FROM freight.yard_facilities f
+ WHERE f.yard_id = y.id AND f.deleted_at IS NULL
+ );
+
+-- 2a. Container types (booking form + API resolve 20ft/40ft by size_ft).
+INSERT INTO freight.container_types (id, code, label, size_ft, is_active)
+SELECT gen_random_uuid(), v.code, v.label, v.size_ft, true
+FROM (VALUES ('20FT', '20FT', 20), ('40FT', '40FT', 40)) AS v(code, label, size_ft)
+WHERE NOT EXISTS (SELECT 1 FROM freight.container_types t WHERE t.code = v.code);
+
+-- 2b. 20ft/40ft containers ride NW5 flat wagons.
+INSERT INTO freight.container_type_wagon_types (container_type_id, wagon_type_id)
+SELECT ct.id, wt.id
+FROM freight.container_types ct
+JOIN freight.wagon_types wt ON wt.code = 'NW5'
+WHERE ct.code IN ('20FT', '40FT')
+ AND NOT EXISTS (
+ SELECT 1 FROM freight.container_type_wagon_types x
+ WHERE x.container_type_id = ct.id AND x.wagon_type_id = wt.id
+ );
+
+-- 3. Pin the derived slot count at 54: the batch engine recomputes
+-- schedule.max_wagons as floor(locoLength / SHORTEST active wagon length)
+-- (syncScheduleMaxWagons). GW2 (12.228 m) is not part of these flows but is
+-- the shortest active type — deactivate it so NW5 (13.966 m) governs, and run
+-- 760 m locos: floor(760 / 13.966) = 54 slots, and 54 NW5 = 754.2 m still
+-- fits the per-edge length budget.
+UPDATE freight.wagon_types SET is_active = false WHERE code = 'GW2' AND is_active;
+
+-- 4. Fourteen locomotives at Djibouti Port. 9000T pull comfortably clears a
+-- 54-wagon container consist; each spec's schedule picks its own pair.
+INSERT INTO freight.locomotives
+ (id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id)
+SELECT gen_random_uuid(), v.code, 9000, 760, y.id
+FROM (VALUES ('LOCO-IMP-1'), ('LOCO-IMP-2'), ('LOCO-IMP-3'), ('LOCO-IMP-4'),
+ ('LOCO-IMP-5'), ('LOCO-IMP-6'), ('LOCO-IMP-7'), ('LOCO-IMP-8'),
+ ('LOCO-IMP-9'), ('LOCO-IMP-10'), ('LOCO-IMP-11'), ('LOCO-IMP-12'),
+ ('LOCO-IMP-13'), ('LOCO-IMP-14'))
+ AS v(code)
+JOIN freight.yards y ON y.code = 'DJIB_PORT'
+WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
+
+-- Prior seeds may have created the fleet at other dimensions — enforce.
+UPDATE freight.locomotives
+SET max_pull_weight_tons = 9000, max_train_length_meters = 760
+WHERE code LIKE 'LOCO-IMP-%'
+ AND (max_pull_weight_tons IS DISTINCT FROM 9000
+ OR max_train_length_meters IS DISTINCT FROM 760);
+
+-- 5. Wagon stock: park every free NW5 flat at Djibouti Port, then move 20 of
+-- them to NAGAD for the sub-corridor boarding scenario. Coupled wagons
+-- (train_id set — e.g. TRN-E2E-1's four) are untouched.
+UPDATE freight.wagons w
+SET current_yard_id = (SELECT id FROM freight.yards WHERE code = 'DJIB_PORT')
+FROM freight.wagon_types wt
+WHERE wt.id = w.wagon_type_id AND wt.code = 'NW5'
+ AND w.train_id IS NULL AND w.deleted_at IS NULL;
+
+UPDATE freight.wagons w
+SET current_yard_id = (SELECT id FROM freight.yards WHERE code = 'NAGAD')
+FROM (
+ SELECT w2.id
+ FROM freight.wagons w2
+ JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'NW5'
+ WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
+ ORDER BY w2.wagon_number DESC
+ LIMIT 20
+) pick
+WHERE w.id = pick.id;
+
+-- 6. Segment distances for every consecutive corridor pair (symmetric rows).
+INSERT INTO freight.yard_distances (id, from_yard_id, to_yard_id, distance_km)
+SELECT gen_random_uuid(), a.id, b.id, v.km
+FROM (VALUES
+ ('DJIB_PORT', 'NAGAD', 20),
+ ('NAGAD', 'DIRE_DAWA', 310),
+ ('DIRE_DAWA', 'E2E_AWASH', 200),
+ ('E2E_AWASH', 'MOJO', 250),
+ ('MOJO', 'KALITY', 70)
+ ) AS v(from_code, to_code, km)
+JOIN freight.yards a ON a.code = v.from_code
+JOIN freight.yards b ON b.code = v.to_code
+WHERE NOT EXISTS (
+ SELECT 1 FROM freight.yard_distances d
+ WHERE (d.from_yard_id = a.id AND d.to_yard_id = b.id)
+ OR (d.from_yard_id = b.id AND d.to_yard_id = a.id)
+);
+
+-- 7. LIVE import rates on every leg the specs book, plus the intercity
+-- ride-along leg (rates are configured in USD and converted per booking).
+INSERT INTO freight.rates
+ (id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
+ origin_yard_id, destination_yard_id, proposed_by_staff_id)
+SELECT gen_random_uuid(), v.rate_type, v.applies_to, 'ALWAYS', 'USD', v.value,
+ 'PER_CONTAINER', 'LIVE', a.id, b.id, u.id
+FROM (VALUES
+ ('CONTAINER_IMPORT', 'CONTAINER', 'DJIB_PORT', 'KALITY', 800),
+ ('CONTAINER_IMPORT', 'CONTAINER', 'DJIB_PORT', 'MOJO', 700),
+ ('CONTAINER_IMPORT', 'CONTAINER', 'NAGAD', 'KALITY', 650),
+ ('CONTAINER_IMPORT', 'CONTAINER', 'NAGAD', 'MOJO', 600),
+ ('INTERCITY_CONTAINER', 'INTERCITY', 'MOJO', 'KALITY', 200)
+ ) AS v(rate_type, applies_to, from_code, to_code, value)
+JOIN freight.yards a ON a.code = v.from_code
+JOIN freight.yards b ON b.code = v.to_code
+JOIN iam.users u ON u.email = 'operation@edr.local'
+WHERE NOT EXISTS (
+ SELECT 1 FROM freight.rates r
+ WHERE r.rate_type = v.rate_type
+ AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
+ AND r.deleted_at IS NULL
+);