diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts
index 0fa1056dd..5b027448c 100644
--- a/apps/edr-freight-api/src/main.ts
+++ b/apps/edr-freight-api/src/main.ts
@@ -33,6 +33,13 @@ async function bootstrap() {
"delegator-position-id",
"current-project-id",
"current-position-id",
+ // x-prefixed variants sent by the user-management / record-management
+ // frontend modules (same values, different naming convention)
+ "x-organization-unit-id",
+ "x-delegator-id",
+ "x-delegator-position-id",
+ "x-current-project-id",
+ "x-current-position-id",
],
exposedHeaders: ["Content-Disposition"],
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index f8e17fe76..15daad79a 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -146,6 +146,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Overview",
href: "/dashboard/overview",
icon: ,
+ permission: FREIGHT_PERMS.overview.view,
},
{
label: "Customers",
@@ -189,7 +190,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Support",
href: "/dashboard/support",
icon: ,
- permission: FREIGHT_PERMS.bookings.view,
+ permission: FREIGHT_PERMS.support.view,
},
...demoItems,
],
diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
index b11d7bffa..f793f7006 100644
--- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts
+++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
@@ -2,6 +2,12 @@ import type { AuthUser } from "@/auth/types";
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export const FREIGHT_PERMS = {
+ overview: {
+ view: "edr_freight_app:overview:view",
+ },
+ support: {
+ view: "edr_freight_app:support:view",
+ },
bookings: {
view: "edr_freight_app:bookings:view",
create: "edr_freight_app:bookings:create",
diff --git a/e2e/freight/cypress.config.ts b/e2e/freight/cypress.config.ts
index a2acffe24..dda8c1703 100644
--- a/e2e/freight/cypress.config.ts
+++ b/e2e/freight/cypress.config.ts
@@ -59,6 +59,22 @@ export default defineConfig({
* user seeders are disabled in app code, so the fixture replicates
* their output. Idempotent — safe to run before every spec file.
*/
+ /** Apply one idempotent SQL fixture from cypress/fixtures (arrange-data). */
+ async "db:seedFile"(file: string) {
+ const client = new Client({ connectionString: dbUrl });
+ await client.connect();
+ try {
+ const sql = readFileSync(
+ join(process.cwd(), "cypress", "fixtures", file),
+ "utf8",
+ );
+ await client.query(sql);
+ return true;
+ } finally {
+ await client.end();
+ }
+ },
+
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/export_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts
new file mode 100644
index 000000000..a1cc00c57
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts
@@ -0,0 +1,525 @@
+/**
+ * Export ONE_TIME journeys — two contracts ride the same export train
+ * (Mojo Dry Port → Dire Dawa Yard → Djibouti Port Terminal):
+ *
+ * A. CONTAINER (20ft + 40ft):
+ * portal wizard → staff approval chain → OTP sign → counter-sign
+ * → AWAITING_CLEARANCE_DOCUMENTS (export self-clear has no required
+ * docs in e2e) → ops finalize → FULLY_EXECUTED → customer books
+ * 2 × 20ft + 1 × 40ft picking a real Shipment day → ops accepts the
+ * operation request → EXPORT is FCFS, so accept reserves the train slot
+ * immediately: SELECTED_FOR_BATCH with a pay deadline clamped to the
+ * export window close → staff mark-paid → PAID + SCHEDULED + linked.
+ *
+ * B. BULK (E2E Wheat, 60 tons): same journey through the bulk wizard and
+ * bulk booking form, riding CW4 covered wagons on the same train.
+ *
+ * Infrastructure (route, built train, distances, rates, cargo types) comes
+ * from seed-intercity.sql + seed-export.sql; the export route and the
+ * departing-today schedule are created through the UI when missing.
+ *
+ * Sequential steps of one journey — retries off (steps are not idempotent).
+ */
+
+const customer = "user@gmail.com";
+const companyTin = "0102030405"; // seed-company.sql
+const opsStaff = "operation@edr.local";
+
+const ORIGIN_YARD = "Mojo Dry Port";
+const MID_YARD = "Dire Dawa Yard";
+const PORT_YARD = "Djibouti Port Terminal";
+const TRAIN_CODE = "TRN-E2E-1";
+
+// Container numbers must be ISO (4 letters + 7 digits) and unused — stamp per run.
+const stamp = String(Date.now());
+const isoNumber = (prefix: string, offset: number) =>
+ `${prefix}${String(Number(stamp.slice(-7)) + offset).padStart(7, "0")}`;
+
+const apiUrl = () => Cypress.env("apiUrl") as string;
+
+function dbContract(freight: "CONTAINER" | "BULK") {
+ return cy.task<{ rows: Array<{ id: string; reference: string; status: string }> }>(
+ "db:query",
+ {
+ sql: `SELECT ct.id, ct.reference, ct.status
+ FROM freight.contracts ct
+ JOIN freight.companies c ON c.id = ct.company_id
+ WHERE c.tin = $1 AND ct.trade_direction = 'EXPORT' AND ct.freight_type = $2
+ ORDER BY ct.created_at DESC LIMIT 1`,
+ params: [companyTin, freight],
+ },
+ );
+}
+
+function withContract(
+ freight: "CONTAINER" | "BULK",
+ fn: (c: { id: string; reference: string; status: string }) => void,
+) {
+ dbContract(freight).then(({ rows }) => {
+ expect(rows, `latest EXPORT ${freight} contract`).to.have.length(1);
+ fn(rows[0]);
+ });
+}
+
+function expectContractStatus(freight: "CONTAINER" | "BULK", expected: string) {
+ dbContract(freight).then(({ rows }) => {
+ expect(rows[0]?.status, "contract status").to.eq(expected);
+ });
+}
+
+function dbBooking(freight: "CONTAINER" | "BULK") {
+ return cy.task<{
+ rows: Array<{
+ id: string;
+ reference: string;
+ status: string;
+ scheduling_status: string;
+ train_schedule_id: string | null;
+ payment_deadline: string | null;
+ scheduled_date: string | null;
+ }>;
+ }>("db:query", {
+ sql: `SELECT b.id, b.reference, b.status, b.scheduling_status,
+ b.train_schedule_id, b.payment_deadline, b.scheduled_date
+ FROM freight.bookings b
+ JOIN freight.companies c ON c.id = b.company_id
+ WHERE c.tin = $1 AND b.trade_direction = 'EXPORT' AND b.freight_type = $2
+ ORDER BY b.created_at DESC LIMIT 1`,
+ params: [companyTin, freight],
+ });
+}
+
+function withBooking(
+ freight: "CONTAINER" | "BULK",
+ fn: (b: {
+ id: string;
+ reference: string;
+ status: string;
+ scheduling_status: string;
+ train_schedule_id: string | null;
+ payment_deadline: string | null;
+ scheduled_date: string | null;
+ }) => void,
+) {
+ dbBooking(freight).then(({ rows }) => {
+ expect(rows, `EXPORT ${freight} booking`).to.have.length(1);
+ fn(rows[0]);
+ });
+}
+
+/**
+ * The journey's export schedule. Export trains must be scheduled ≥ the booking
+ * lead (24h) ahead, and their window OPENS at departure − lead — so the spec
+ * departs at now + 24h + a couple of minutes: creatable now, window opens
+ * minutes later. (The intercity spec's train leaves in 2 days — outside 25h.)
+ */
+function dbUpcomingSchedule() {
+ return cy.task<{
+ rows: Array<{ id: string; window_closes_at: string; booking_window_status: string }>;
+ }>("db:query", {
+ sql: `SELECT ts.id, ts.window_closes_at, ts.booking_window_status
+ FROM freight.train_schedules ts
+ WHERE ts.direction = 'EXPORT' AND ts.deleted_at IS NULL
+ AND ts.scheduled_departure_date > now()
+ AND ts.scheduled_departure_date < now() + interval '25 hours'
+ ORDER BY ts.created_at DESC LIMIT 1`,
+ });
+}
+
+/** The train departs ~24h out — bookings ride its departure day (tomorrow). */
+const SHIPMENT_DAY = new Date(Date.now() + 24 * 3_600_000 + 150_000);
+
+function fill(label: string | RegExp, value: string) {
+ cy.contains("label", label)
+ .invoke("attr", "for")
+ .then((id) => {
+ cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
+ });
+}
+
+/** Fill the N-th input whose label matches (two container-size editors both say "Quantity *"). */
+function fillNth(label: RegExp, index: number, value: string) {
+ cy.get("label").then(($labels) => {
+ const matches = $labels.filter((_, el) => label.test(el.textContent ?? ""));
+ expect(matches.length, `labels matching ${label}`).to.be.greaterThan(index);
+ const id = matches.eq(index).attr("for");
+ cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
+ });
+}
+
+/** Open the Shipment day picker and choose the train's departure day. */
+function pickShipmentDay() {
+ cy.contains("label", /^Shipment day/)
+ .invoke("attr", "for")
+ .then((id) => {
+ cy.get(`[id="${id}"]`).click({ force: true });
+ });
+ const day = String(SHIPMENT_DAY.getDate());
+ cy.get(".mantine-Popover-dropdown button:not([data-disabled]):not([disabled])", {
+ timeout: 15000,
+ })
+ .contains(new RegExp(`^${day}$`))
+ .click({ force: true });
+}
+
+/** Shared staff steps: accept + LINE_STAFF approve, then director approve. */
+function approveChain(freight: "CONTAINER" | "BULK") {
+ cy.loginBackoffice("marketer@edr.local");
+ withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
+ cy.contains("button", "Accept for approval", { timeout: 20000 }).click();
+ cy.contains("button", "Accept & start approval", { timeout: 20000 })
+ .should("not.be.disabled")
+ .click();
+ cy.contains("Approval chain", { timeout: 20000 }).should("be.visible");
+ cy.contains("button", "Approve", { timeout: 20000 }).click();
+ cy.contains("button", "Confirm approval").click();
+ cy.contains("1/2", { timeout: 20000 }).should("be.visible");
+
+ cy.loginBackoffice("director@edr.local");
+ withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
+ cy.contains("button", "Approve", { timeout: 20000 }).click();
+ cy.contains("button", "Confirm approval").click();
+ cy.contains("button", "View & sign", { timeout: 30000 }).should("exist");
+ expectContractStatus(freight, "CONTRACT_READY");
+}
+
+/** Shared customer OTP-signature step. */
+function customerSigns(freight: "CONTAINER" | "BULK") {
+ cy.loginPortal(customer);
+ withContract(freight, (c) => cy.visitPortal(`/contracts/${c.id}/view`));
+
+ const unlockConsent = (attempt: number) => {
+ cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(($f) => {
+ const win = ($f[0] as HTMLIFrameElement).contentWindow;
+ const el = win?.document?.scrollingElement ?? win?.document?.documentElement;
+ if (win && el) {
+ el.scrollTop = el.scrollHeight;
+ win.dispatchEvent(new Event("scroll"));
+ }
+ });
+ cy.wait(500).then(() => {
+ cy.get("body").then(($b) => {
+ if ($b.text().includes("I have read the entire contract")) return;
+ expect(attempt, "consent bar unlocked").to.be.lessThan(20);
+ unlockConsent(attempt + 1);
+ });
+ });
+ };
+ unlockConsent(0);
+
+ cy.contains("I have read the entire contract", { timeout: 15000 }).click();
+ cy.contains("button", /^Sign contract$|^Approve & sign$/).click();
+ cy.contains("label", "Full name")
+ .invoke("attr", "for")
+ .then((id) => {
+ cy.get(`[id="${id}"]`).clear().type("Demo User");
+ });
+ cy.drawSignature();
+ cy.contains("button", "Continue to verification").click();
+ cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible");
+ cy.getOtp(customer).then((otp) => cy.typeOtp(otp));
+ cy.contains("button", "Verify & sign").click();
+ cy.contains("Your signature has been recorded", { timeout: 30000 }).should("be.visible");
+ expectContractStatus(freight, "SIGNED_CUSTOMER");
+}
+
+/** Shared counter-sign + ops finalize (export self-clear: no required docs in e2e). */
+function counterSignAndFinalize(freight: "CONTAINER" | "BULK") {
+ cy.loginBackoffice("marketer@edr.local");
+ withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}/view`));
+ cy.contains("button", /^Sign as staff$|^Approve & sign$/, { timeout: 30000 }).click();
+ cy.contains("label", "Full name")
+ .invoke("attr", "for")
+ .then((id) => {
+ cy.get(`[id="${id}"]`).clear().type("EDR Marketer");
+ });
+ cy.drawSignature();
+ cy.get(".mantine-Modal-content")
+ .contains("button", /^Confirm signature$|^Approve & sign$/)
+ .click();
+ cy.contains("counter-signed", { timeout: 30000 }).should("be.visible");
+ expectContractStatus(freight, "AWAITING_CLEARANCE_DOCUMENTS");
+
+ cy.loginBackoffice(opsStaff);
+ withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
+ cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click();
+ cy.contains("button", "Finalize document approval", { timeout: 30000 })
+ .should("not.be.disabled")
+ .click();
+ cy.contains("finalized", { timeout: 30000 }).should("be.visible");
+ expectContractStatus(freight, "FULLY_EXECUTED");
+}
+
+/** Ops accept: EXPORT is FCFS — accept reserves the slot and opens the pay window. */
+function acceptAndAssertReserved(freight: "CONTAINER" | "BULK") {
+ cy.loginBackoffice(opsStaff);
+ withBooking(freight, (b) => cy.visit(`/dashboard/booking-requests/${b.id}`));
+ cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click();
+ cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible");
+ cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click();
+ cy.get(".mantine-Modal-content", { timeout: 30000 }).should("not.exist");
+
+ dbUpcomingSchedule().then(({ rows: schedules }) => {
+ expect(schedules, "departing-today export schedule").to.have.length(1);
+ withBooking(freight, (b) => {
+ expect(b.status, "FCFS reservation").to.eq("SELECTED_FOR_BATCH");
+ expect(b.train_schedule_id).to.eq(schedules[0].id);
+ // Export parity: the pay window never outlives the booking window close.
+ expect(b.payment_deadline, "pay deadline set").to.be.a("string");
+ expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
+ new Date(schedules[0].window_closes_at).getTime(),
+ );
+ });
+ });
+}
+
+function markPaidAndAssertAllocated(freight: "CONTAINER" | "BULK") {
+ withBooking(freight, (b) => {
+ cy.apiLogin(opsStaff).then(({ token }) => {
+ cy.request({
+ method: "POST",
+ url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`,
+ headers: { Authorization: `Bearer ${token}` },
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ });
+ withBooking(freight, (b) => {
+ expect(b.status).to.eq("PAID");
+ expect(b.scheduling_status).to.eq("SCHEDULED");
+ cy.task<{ rows: Array<{ n: string }> }>("db:query", {
+ sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings
+ WHERE booking_id = $1 AND deleted_at IS NULL`,
+ params: [b.id],
+ }).then(({ rows }) => {
+ expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1);
+ });
+ });
+}
+
+describe("export one-time journeys: container + bulk on one train", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-intercity.sql");
+ cy.task("db:seedFile", "seed-export.sql");
+ });
+
+ // ── Shared infrastructure ─────────────────────────────────────────────────
+
+ it("operations ensures the export route exists", () => {
+ cy.loginBackoffice(opsStaff);
+ cy.task<{ rows: Array<{ n: string }> }>("db:query", {
+ sql: `SELECT count(*) AS n
+ FROM freight.routes r
+ JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
+ JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT'
+ WHERE r.deleted_at IS NULL`,
+ }).then(({ rows }) => {
+ if (Number(rows[0].n) > 0) return;
+
+ cy.visit("/dashboard/routes");
+ cy.contains("button", "Add route", { timeout: 20000 }).click();
+ cy.contains("Add Route", { timeout: 15000 }).should("be.visible");
+ cy.get(".mantine-Modal-content").contains("button", "Add milestone").click();
+ const pickYard = (index: number, yard: string) => {
+ cy.get('.mantine-Modal-content input[placeholder="Select yard"]')
+ .eq(index)
+ .click({ force: true });
+ cy.get('[role="option"]:visible').contains(yard).click();
+ };
+ pickYard(0, ORIGIN_YARD);
+ pickYard(1, MID_YARD);
+ pickYard(2, PORT_YARD);
+ cy.get(".mantine-Modal-content").contains("button", "Save").click();
+ });
+ });
+
+ it("operations schedules the export train — booking window opens", () => {
+ cy.loginBackoffice(opsStaff);
+
+ dbUpcomingSchedule().then(({ rows }) => {
+ if (rows.length > 0) return;
+
+ cy.visit("/dashboard/operations/train-scheduling-v2");
+ cy.contains("button", "New schedule", { timeout: 20000 }).click();
+ cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible");
+ cy.mantineSelect(/^Route$/, new RegExp(ORIGIN_YARD));
+
+ // Just past the 24h scheduling lead: creatable now, and the export
+ // window (opens departure − lead) flips OPEN a couple of minutes later.
+ const local = new Date(
+ SHIPMENT_DAY.getTime() - SHIPMENT_DAY.getTimezoneOffset() * 60000,
+ )
+ .toISOString()
+ .slice(0, 16);
+ cy.get('.mantine-Modal-content input[type="datetime-local"]')
+ .clear({ force: true })
+ .type(local, { force: true });
+ cy.mantineSelect(/^Train$/, new RegExp(TRAIN_CODE));
+ cy.get(".mantine-Modal-content").contains("button", "Create").click();
+ cy.location("pathname", { timeout: 30000 }).should(
+ "match",
+ /\/dashboard\/operations\/train-scheduling-v2\/.+/,
+ );
+ });
+
+ // The 10s window tick flips PRE_WINDOW → OPEN once the lead moment passes.
+ const waitForOpenWindow = (attempt: number) => {
+ dbUpcomingSchedule().then(({ rows }) => {
+ expect(rows, "upcoming export schedule").to.have.length(1);
+ if (rows[0].booking_window_status === "OPEN") return;
+ expect(attempt, "export booking window OPEN").to.be.lessThan(40);
+ cy.wait(10000).then(() => waitForOpenWindow(attempt + 1));
+ });
+ };
+ waitForOpenWindow(0);
+ });
+
+ // ── Journey A: container 20ft + 40ft ──────────────────────────────────────
+
+ it("customer submits an export container contract (20ft + 40ft)", () => {
+ cy.loginPortal(customer);
+ cy.visitPortal("/contracts/new");
+
+ cy.mantineSelect(/^Operation Type/, /^Export$/);
+ cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
+ cy.mantineSelect(/^New or Renewal/, "New Contract");
+ cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true });
+ cy.mantineSelect(/^Payment Currency/, /^ETB/);
+ cy.contains("button", "Continue").click({ force: true });
+
+ cy.mantineSelect(/^Cargo Scope/, /Containerized/);
+ cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
+ cy.get('[role="checkbox"][aria-label="40ft Container"]').click();
+ cy.get('textarea[placeholder*="Electronics"]').type("E2E export electronics");
+ cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
+ cy.mantineSelect(/^Destination Yard/, PORT_YARD);
+ cy.contains("button", "Continue").click({ force: true });
+
+ cy.contains("button", "Submit").click({ force: true });
+ cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible");
+ cy.contains("button", "Approve & submit").click();
+ cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts");
+
+ expectContractStatus("CONTAINER", "SUBMITTED");
+ });
+
+ it("staff approve the container contract (marketer + director)", () => {
+ approveChain("CONTAINER");
+ });
+
+ it("customer signs the container contract with OTP", () => {
+ customerSigns("CONTAINER");
+ });
+
+ it("staff counter-sign and operations finalize the container contract", () => {
+ counterSignAndFinalize("CONTAINER");
+ });
+
+ it("customer books 2 × 20ft + 1 × 40ft with a shipment day", () => {
+ cy.loginPortal(customer);
+ withContract("CONTAINER", (c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`));
+ cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
+
+ // Two size editors, each with its own "Quantity *" (20ft first, then 40ft).
+ fillNth(/^Quantity/, 0, "2");
+ fillNth(/^Quantity/, 1, "1");
+
+ cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 3);
+ cy.get('input[placeholder*="MSCU"]').eq(0).type(isoNumber("MSCU", 0));
+ cy.get('input[placeholder*="MSCU"]').eq(1).type(isoNumber("TCLU", 1));
+ cy.get('input[placeholder*="MSCU"]').eq(2).type(isoNumber("FSCU", 2));
+
+ cy.get('input[placeholder*="24.5"]').each(($input) => {
+ cy.wrap($input).clear({ force: true }).type("10", { force: true });
+ });
+
+ pickShipmentDay();
+
+ cy.contains("button", "Review price & book").should("not.be.disabled").click();
+ cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
+ cy.contains("button", "Confirm & book").click();
+ cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
+
+ withBooking("CONTAINER", (b) => {
+ expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
+ expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string");
+ });
+ });
+
+ it("operations accepts the container request — FCFS reserves today's train", () => {
+ acceptAndAssertReserved("CONTAINER");
+ });
+
+ it("staff mark the container booking paid — allocated onto the train", () => {
+ markPaidAndAssertAllocated("CONTAINER");
+ });
+
+ // ── Journey B: bulk (E2E Wheat) ───────────────────────────────────────────
+
+ it("customer submits an export bulk contract (wheat)", () => {
+ cy.loginPortal(customer);
+ cy.visitPortal("/contracts/new");
+
+ cy.mantineSelect(/^Operation Type/, /^Export$/);
+ cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
+ cy.mantineSelect(/^New or Renewal/, "New Contract");
+ cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true });
+ cy.mantineSelect(/^Payment Currency/, /^ETB/);
+ cy.contains("button", "Continue").click({ force: true });
+
+ cy.mantineSelect(/^Cargo Scope/, /General \/ Bulk cargo/);
+ cy.mantineSelect(/^Bulk Cargo Type/, "E2E Grains");
+ cy.mantineSelect(/^Commodity/, "E2E Wheat");
+ cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
+ cy.mantineSelect(/^Destination Yard/, PORT_YARD);
+ cy.contains("button", "Continue").click({ force: true });
+
+ cy.contains("button", "Submit").click({ force: true });
+ cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible");
+ cy.contains("button", "Approve & submit").click();
+ cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts");
+
+ expectContractStatus("BULK", "SUBMITTED");
+ });
+
+ it("staff approve the bulk contract (marketer + director)", () => {
+ approveChain("BULK");
+ });
+
+ it("customer signs the bulk contract with OTP", () => {
+ customerSigns("BULK");
+ });
+
+ it("staff counter-sign and operations finalize the bulk contract", () => {
+ counterSignAndFinalize("BULK");
+ });
+
+ it("customer books 60 tons of wheat with a shipment day", () => {
+ cy.loginPortal(customer);
+ withContract("BULK", (c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`));
+ cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
+
+ fill(/^Quantity \(tons\)/, "60");
+ pickShipmentDay();
+
+ cy.contains("button", "Review price & book").should("not.be.disabled").click();
+ cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
+ cy.contains("button", "Confirm & book").click();
+ cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
+
+ withBooking("BULK", (b) => {
+ expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
+ });
+ });
+
+ it("operations accepts the bulk request — FCFS reserves today's train", () => {
+ acceptAndAssertReserved("BULK");
+ });
+
+ it("staff mark the bulk booking paid — allocated onto the train", () => {
+ markPaidAndAssertAllocated("BULK");
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts
new file mode 100644
index 000000000..b8ca2fd1b
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts
@@ -0,0 +1,574 @@
+/**
+ * Intercity ONE_TIME journey — the full life of a domestic ride-along shipment:
+ *
+ * 1. portal — customer creates an INTERCITY / One-Time / Container contract
+ * (Mojo Dry Port → Dire Dawa Yard) and submits it
+ * 2. backoffice — marketer REJECTS it with a reason
+ * 3. portal — customer sees the rejection banner + reason, then submits a
+ * fresh contract
+ * 4. backoffice — marketer accepts + approves LINE_STAFF, director approves
+ * → CONTRACT_READY
+ * 5. portal — customer OTP-signs → SIGNED_CUSTOMER
+ * 6. backoffice — marketer counter-signs → AWAITING_CLEARANCE_DOCUMENTS
+ * (ONE_TIME intercity always routes through the
+ * intercity-documents step; the fixture seeds one REQUIRED
+ * document so the step is real)
+ * 6b. portal — customer uploads the required intercity document
+ * → CLEARANCE_UNDER_REVIEW
+ * 7. backoffice — operations approves the document, then finalizes document
+ * approval → FULLY_EXECUTED
+ * 8. portal — customer books 2 × 20ft under the contract (intercity has
+ * no shipment date) → booking OPERATION_REQUEST_PENDING
+ * 9. backoffice — operations accepts the operation request → booking
+ * FULLY_EXECUTED (intercity waiting pool)
+ * 10. backoffice — operations creates the EXPORT route
+ * Mojo → Dire Dawa → Djibouti Port (distances seeded)
+ * 11. backoffice — operations schedules the export train (built Train-Builder
+ * train seeded by seed-intercity.sql)
+ * 12. backoffice — operations accepts the intercity booking onto the train
+ * (Workspace → Intercity ride-along) → SELECTED_FOR_BATCH with
+ * a pay deadline that never outlives the export window close
+ * 13. staff mark-paid (API — the batch panel has no mounted UI button)
+ * → PAID + SCHEDULED + linked to the schedule
+ *
+ * Sequential steps of one journey — retries off (steps are not idempotent).
+ */
+
+const customer = "user@gmail.com";
+const companyTin = "0102030405"; // seed-company.sql
+const opsStaff = "operation@edr.local";
+
+const ORIGIN_YARD = "Mojo Dry Port";
+const DEST_YARD = "Dire Dawa Yard";
+const PORT_YARD = "Djibouti Port Terminal";
+const TRAIN_CODE = "TRN-E2E-1";
+
+const apiUrl = () => Cypress.env("apiUrl") as string;
+
+/** Latest contract of the seeded company — the journey's contract. */
+function dbContract() {
+ return cy.task<{ rows: Array<{ id: string; reference: string; status: string }> }>(
+ "db:query",
+ {
+ sql: `SELECT ct.id, ct.reference, ct.status
+ FROM freight.contracts ct
+ JOIN freight.companies c ON c.id = ct.company_id
+ WHERE c.tin = $1 AND ct.trade_direction = 'DOMESTIC'
+ ORDER BY ct.created_at DESC LIMIT 1`,
+ params: [companyTin],
+ },
+ );
+}
+
+function withContract(fn: (c: { id: string; reference: string; status: string }) => void) {
+ dbContract().then(({ rows }) => {
+ expect(rows, "latest contract for the seeded company").to.have.length(1);
+ fn(rows[0]);
+ });
+}
+
+function expectContractStatus(expected: string) {
+ dbContract().then(({ rows }) => {
+ expect(rows[0]?.status, "contract status").to.eq(expected);
+ });
+}
+
+/** Latest DOMESTIC booking of the seeded company — the journey's booking. */
+function dbBooking() {
+ return cy.task<{
+ rows: Array<{
+ id: string;
+ reference: string;
+ status: string;
+ scheduling_status: string;
+ train_schedule_id: string | null;
+ payment_deadline: string | null;
+ scheduled_date: string | null;
+ }>;
+ }>("db:query", {
+ sql: `SELECT b.id, b.reference, b.status, b.scheduling_status,
+ b.train_schedule_id, b.payment_deadline, b.scheduled_date
+ FROM freight.bookings b
+ JOIN freight.companies c ON c.id = b.company_id
+ WHERE c.tin = $1 AND b.trade_direction = 'DOMESTIC'
+ ORDER BY b.created_at DESC LIMIT 1`,
+ params: [companyTin],
+ });
+}
+
+function withBooking(
+ fn: (b: {
+ id: string;
+ reference: string;
+ status: string;
+ scheduling_status: string;
+ train_schedule_id: string | null;
+ payment_deadline: string | null;
+ scheduled_date: string | null;
+ }) => void,
+) {
+ dbBooking().then(({ rows }) => {
+ expect(rows, "intercity booking for the seeded company").to.have.length(1);
+ fn(rows[0]);
+ });
+}
+
+/** Latest export schedule created by this journey. */
+function dbSchedule() {
+ return cy.task<{
+ rows: Array<{ id: string; status: string; direction: string; window_closes_at: string }>;
+ }>("db:query", {
+ sql: `SELECT ts.id, ts.status, ts.direction, ts.window_closes_at
+ FROM freight.train_schedules ts
+ ORDER BY ts.created_at DESC LIMIT 1`,
+ });
+}
+
+/** Fill a labelled Mantine input (label[for] → input id). */
+function fill(label: string | RegExp, value: string) {
+ cy.contains("label", label)
+ .invoke("attr", "for")
+ .then((id) => {
+ cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
+ });
+}
+
+/**
+ * Run the portal wizard for an INTERCITY / One-Time / Container contract and
+ * submit it. Reused for the initial (to-be-rejected) and the second contract.
+ */
+function createIntercityContract() {
+ cy.loginPortal(customer);
+ cy.visitPortal("/contracts/new");
+
+ // Step 0 — Setup. Intercity forces ETB and hides the customs section.
+ cy.mantineSelect(/^Operation Type/, /^Intercity$/);
+ cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
+ cy.mantineSelect(/^New or Renewal/, "New Contract");
+ cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click();
+ cy.mantineSelect(/^Payment Currency/, /^ETB/);
+ cy.contains("button", "Continue").click({ force: true });
+
+ // Step 1 — Cargo & Route (Ethiopian yards only for intercity).
+ cy.mantineSelect(/^Cargo Scope/, /Containerized/);
+ cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
+ cy.get('textarea[placeholder*="Electronics"]').type(
+ "E2E intercity electronics between Ethiopian yards",
+ );
+ cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
+ cy.mantineSelect(/^Destination Yard/, DEST_YARD);
+ cy.contains("button", "Continue").click({ force: true });
+
+ // Step 2 — Review & Submit → quotation modal.
+ cy.contains("button", "Submit").click({ force: true });
+ cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible");
+ cy.contains("button", "Approve & submit").click();
+
+ cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts");
+
+ dbContract().then(({ rows }) => {
+ expect(rows, "contract row").to.have.length(1);
+ expect(rows[0].status).to.eq("SUBMITTED");
+ expect(rows[0].reference).to.match(/^CTR-/);
+ });
+}
+
+describe("intercity one-time journey: contract → booking → export train", { retries: 0 }, () => {
+ before(() => {
+ // Container types, locomotives, built train, yard distances — the
+ // infrastructure the UI journey cannot create in-flow.
+ cy.task("db:seedFile", "seed-intercity.sql");
+ });
+
+ // ── Contract: submit → reject → resubmit → approve → sign ────────────────
+
+ it("customer submits an intercity one-time contract", () => {
+ createIntercityContract();
+ });
+
+ it("marketer rejects the submission with a reason", () => {
+ cy.loginBackoffice("marketer@edr.local");
+ withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
+
+ cy.contains("button", "Reject contract", { timeout: 20000 }).click();
+ cy.get(".mantine-Modal-content")
+ .contains("label", "Reason for rejection")
+ .invoke("attr", "for")
+ .then((id) => {
+ cy.get(`[id="${id}"]`).type("E2E rejection — cargo details incomplete");
+ });
+ cy.get(".mantine-Modal-content").contains("button", /^Reject$/).click();
+
+ // Modal closes on success; the status pill can sit inside clipped layout,
+ // so the authoritative check is the DB row.
+ cy.get(".mantine-Modal-content", { timeout: 20000 }).should("not.exist");
+ expectContractStatus("REJECTED");
+ });
+
+ it("customer sees the rejection reason on the contracts list", () => {
+ cy.loginPortal(customer);
+ cy.visitPortal("/contracts");
+
+ // The list is a collapsed table — expand the rejected contract's row to
+ // reveal its step banner with the staff reason.
+ withContract((c) => {
+ cy.contains("tr", c.reference, { timeout: 20000 })
+ .find("button")
+ .first()
+ .click();
+ });
+ cy.contains("This contract was rejected.", { timeout: 20000 }).should("be.visible");
+ cy.contains("Reason: E2E rejection — cargo details incomplete").should("be.visible");
+ });
+
+ it("customer submits a fresh intercity contract", () => {
+ createIntercityContract();
+ });
+
+ it("marketer accepts the submission and approves the LINE_STAFF step", () => {
+ cy.loginBackoffice("marketer@edr.local");
+ withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
+
+ cy.contains("button", "Accept for approval", { timeout: 20000 }).click();
+ cy.contains("button", "Accept & start approval", { timeout: 20000 })
+ .should("not.be.disabled")
+ .click();
+
+ cy.contains("Approval chain", { timeout: 20000 }).should("be.visible");
+ cy.contains("button", "Approve", { timeout: 20000 }).click();
+ cy.contains("button", "Confirm approval").click();
+ cy.contains("1/2", { timeout: 20000 }).should("be.visible");
+
+ expectContractStatus("PENDING_APPROVAL");
+ });
+
+ it("director approves the final step — contract PDF becomes ready", () => {
+ cy.loginBackoffice("director@edr.local");
+ withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
+
+ cy.contains("button", "Approve", { timeout: 20000 }).click();
+ cy.contains("button", "Confirm approval").click();
+
+ cy.contains("button", "View & sign", { timeout: 30000 }).should("exist");
+ expectContractStatus("CONTRACT_READY");
+ });
+
+ it("customer signs the contract with OTP", () => {
+ cy.loginPortal(customer);
+ withContract((c) => cy.visitPortal(`/contracts/${c.id}/view`));
+
+ // Scroll the contract iframe to the bottom so the consent bar unlocks.
+ const unlockConsent = (attempt: number) => {
+ cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(($f) => {
+ const win = ($f[0] as HTMLIFrameElement).contentWindow;
+ const el = win?.document?.scrollingElement ?? win?.document?.documentElement;
+ if (win && el) {
+ el.scrollTop = el.scrollHeight;
+ win.dispatchEvent(new Event("scroll"));
+ }
+ });
+ cy.wait(500).then(() => {
+ cy.get("body").then(($b) => {
+ if ($b.text().includes("I have read the entire contract")) return;
+ expect(attempt, "consent bar unlocked").to.be.lessThan(20);
+ unlockConsent(attempt + 1);
+ });
+ });
+ };
+ unlockConsent(0);
+
+ cy.contains("I have read the entire contract", { timeout: 15000 }).click();
+ cy.contains("button", /^Sign contract$|^Approve & sign$/).click();
+
+ cy.contains("label", "Full name")
+ .invoke("attr", "for")
+ .then((id) => {
+ cy.get(`[id="${id}"]`).clear().type("Demo User");
+ });
+ cy.drawSignature();
+ cy.contains("button", "Continue to verification").click();
+
+ cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible");
+ cy.getOtp(customer).then((otp) => cy.typeOtp(otp));
+ cy.contains("button", "Verify & sign").click();
+
+ cy.contains("Your signature has been recorded", { timeout: 30000 }).should("be.visible");
+ expectContractStatus("SIGNED_CUSTOMER");
+ });
+
+ it("marketer counter-signs — intercity one-time enters the documents step", () => {
+ cy.loginBackoffice("marketer@edr.local");
+ withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}/view`));
+
+ cy.contains("button", /^Sign as staff$|^Approve & sign$/, { timeout: 30000 }).click();
+ cy.contains("label", "Full name")
+ .invoke("attr", "for")
+ .then((id) => {
+ cy.get(`[id="${id}"]`).clear().type("EDR Marketer");
+ });
+ cy.drawSignature();
+ cy.get(".mantine-Modal-content")
+ .contains("button", /^Confirm signature$|^Approve & sign$/)
+ .click();
+
+ cy.contains("counter-signed", { timeout: 30000 }).should("be.visible");
+
+ // DOMESTIC one-time always routes through the intercity-documents step —
+ // unlike GENERAL, it does NOT go straight to CONTRACT_ACTIVE.
+ expectContractStatus("AWAITING_CLEARANCE_DOCUMENTS");
+ });
+
+ it("customer uploads the required intercity document", () => {
+ cy.loginPortal(customer);
+ // Deep link auto-opens the clearance documents modal.
+ withContract((c) => cy.visitPortal(`/contracts/${c.id}?action=clearance`));
+
+ cy.contains("Cargo Manifest", { timeout: 30000 }).should("exist");
+ cy.get('.mantine-Modal-content input[type="file"]')
+ .first()
+ .selectFile("cypress/fixtures/docs/license.pdf", { force: true });
+ cy.contains("button", "Submit documents", { timeout: 15000 })
+ .should("not.be.disabled")
+ .click();
+
+ // Upload hands the contract to Operations review — the card flips to
+ // "Under review" (the host modal may linger while queries refetch).
+ cy.contains("Under review", { timeout: 30000 }).should("exist");
+ expectContractStatus("CLEARANCE_UNDER_REVIEW");
+ });
+
+ it("operations approves the document and finalizes — contract fully executed", () => {
+ cy.loginBackoffice(opsStaff);
+ withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
+
+ // The clearance review section lives behind its own tab on the detail page.
+ cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click();
+
+ // Approve the uploaded Cargo Manifest, then finalize.
+ cy.contains("button", /Approve all/, { timeout: 30000 }).click();
+ cy.contains("1/1 approved", { timeout: 30000 }).should("exist");
+
+ cy.contains("button", "Finalize document approval", { timeout: 30000 })
+ .should("not.be.disabled")
+ .click();
+
+ cy.contains("finalized", { timeout: 30000 }).should("be.visible");
+ expectContractStatus("FULLY_EXECUTED");
+ });
+
+ // ── Booking under the contract ────────────────────────────────────────────
+
+ it("customer books 2 × 20ft under the contract (no shipment date for intercity)", () => {
+ cy.loginPortal(customer);
+ withContract((c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`));
+
+ cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
+
+ // 20ft quantities must be even (pairs share a wagon).
+ fill(/^Quantity/, "2");
+
+ // One ISO container number per unit.
+ cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 2);
+ cy.get('input[placeholder*="MSCU"]').eq(0).type("MSCU1234567");
+ cy.get('input[placeholder*="MSCU"]').eq(1).type("TCLU7654321");
+
+ // VGM per unit — column inputs carry a placeholder, not a linked label.
+ cy.get('input[placeholder*="24.5"]').each(($input) => {
+ cy.wrap($input).clear({ force: true }).type("10", { force: true });
+ });
+
+ // Intercity: no "Shipment day" picker — the ride-along note renders instead.
+ cy.contains("Shipment day").should("not.exist");
+
+ cy.contains("button", "Review price & book").should("not.be.disabled").click();
+ cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
+ cy.contains("button", "Confirm & book").click();
+
+ cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
+
+ withBooking((b) => {
+ expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
+ expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null);
+ });
+ });
+
+ it("operations accepts the operation request — booking joins the intercity pool", () => {
+ cy.loginBackoffice(opsStaff);
+ withBooking((b) => cy.visit(`/dashboard/booking-requests/${b.id}`));
+
+ cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click();
+ cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible");
+ cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click();
+
+ withBooking((b) => {
+ expect(b.status, "accepted intercity booking waits in the pool").to.eq("FULLY_EXECUTED");
+ expect(b.train_schedule_id).to.eq(null);
+ });
+ });
+
+ // ── Route + export schedule ───────────────────────────────────────────────
+
+ it("operations creates the export route Mojo → Dire Dawa → Djibouti Port", () => {
+ cy.loginBackoffice(opsStaff);
+
+ // Skip creation when a previous run already added the route (unique yards
+ // pair) — the journey stays re-runnable against a warm DB.
+ cy.task<{ rows: Array<{ n: string }> }>("db:query", {
+ sql: `SELECT count(*) AS n
+ FROM freight.routes r
+ JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
+ JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT'
+ WHERE r.deleted_at IS NULL`,
+ }).then(({ rows }) => {
+ if (Number(rows[0].n) > 0) return;
+
+ cy.visit("/dashboard/routes");
+ cy.contains("button", "Add route", { timeout: 20000 }).click();
+ cy.contains("Add Route", { timeout: 15000 }).should("be.visible");
+
+ // Third stop row, then fill Origin / Milestone / Destination in order.
+ cy.get(".mantine-Modal-content").contains("button", "Add milestone").click();
+ const pickYard = (index: number, yard: string) => {
+ cy.get('.mantine-Modal-content input[placeholder="Select yard"]')
+ .eq(index)
+ .click({ force: true });
+ // Three yard selects share option texts — only the open dropdown counts.
+ cy.get('[role="option"]:visible').contains(yard).click();
+ };
+ pickYard(0, ORIGIN_YARD);
+ pickYard(1, DEST_YARD);
+ pickYard(2, PORT_YARD);
+
+ // Distances (when the build has them) resolve from the seeded rows.
+ cy.get(".mantine-Modal-content").contains("button", "Save").click();
+ });
+
+ cy.task<{ rows: Array<{ direction: string }> }>("db:query", {
+ sql: `SELECT r.direction
+ FROM freight.routes r
+ JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
+ JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT'
+ WHERE r.deleted_at IS NULL`,
+ }).then(({ rows }) => {
+ expect(rows, "export route").to.have.length.at.least(1);
+ expect(rows[0].direction).to.eq("EXPORT");
+ });
+ });
+
+ it("operations schedules the export train from the built consist", () => {
+ cy.loginBackoffice(opsStaff);
+
+ // One departure per route per day — a warm DB from a previous run already
+ // has this train scheduled, so only create when none is live.
+ cy.task<{ rows: Array<{ n: string }> }>("db:query", {
+ sql: `SELECT count(*) AS n
+ FROM freight.train_schedules ts
+ JOIN freight.routes r ON r.id = ts.route_id
+ JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
+ WHERE ts.status IN ('DRAFT', 'SCHEDULED') AND ts.deleted_at IS NULL`,
+ }).then(({ rows }) => {
+ if (Number(rows[0].n) > 0) return;
+
+ cy.visit("/dashboard/operations/train-scheduling-v2");
+ cy.contains("button", "New schedule", { timeout: 20000 }).click();
+ cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible");
+
+ cy.mantineSelect(/^Route$/, new RegExp(ORIGIN_YARD));
+
+ // Two days out, local datetime-local format.
+ const departure = new Date(Date.now() + 2 * 86400000);
+ const local = new Date(departure.getTime() - departure.getTimezoneOffset() * 60000)
+ .toISOString()
+ .slice(0, 16);
+ cy.get('.mantine-Modal-content input[type="datetime-local"]')
+ .clear({ force: true })
+ .type(local, { force: true });
+
+ cy.mantineSelect(/^Train$/, new RegExp(TRAIN_CODE));
+ cy.get(".mantine-Modal-content").contains("button", "Create").click();
+
+ // Create navigates straight to the new schedule's detail page.
+ cy.location("pathname", { timeout: 30000 }).should(
+ "match",
+ /\/dashboard\/operations\/train-scheduling-v2\/.+/,
+ );
+ });
+
+ dbSchedule().then(({ rows }) => {
+ expect(rows, "created schedule").to.have.length(1);
+ expect(rows[0].direction).to.eq("EXPORT");
+ });
+ });
+
+ // ── Intercity ride-along: accept → pay → allocated ────────────────────────
+
+ it("operations accepts the intercity booking onto the export train", () => {
+ cy.loginBackoffice(opsStaff);
+ dbSchedule().then(({ rows: schedules }) => {
+ cy.visit(`/dashboard/operations/train-scheduling-v2/${schedules[0].id}`);
+ });
+
+ cy.contains('[role="tab"]', "Workspace", { timeout: 30000 }).click();
+ // Presence, not viewport visibility — the panel can sit below the fold /
+ // inside clipped layout once earlier runs' rows stack up.
+ cy.contains("Intercity ride-along", { timeout: 30000 }).should("exist");
+
+ withBooking((b) => {
+ cy.contains("tr", b.reference, { timeout: 30000 })
+ .find('input[type="checkbox"]')
+ .check({ force: true });
+ cy.contains("button", /Accept .*onto this train/).click();
+
+ // Accepted table shows the pay-window state (inside a horizontal
+ // Table.ScrollContainer — assert presence, not viewport visibility).
+ cy.contains("Awaiting payment", { timeout: 30000 }).should("exist");
+ });
+
+ // Export parity: the ride-along's pay deadline never outlives the export
+ // booking window (reserve() clamps it to window_closes_at).
+ dbSchedule().then(({ rows: schedules }) => {
+ withBooking((b) => {
+ expect(b.status).to.eq("SELECTED_FOR_BATCH");
+ expect(b.train_schedule_id).to.eq(schedules[0].id);
+ expect(b.payment_deadline, "pay deadline set").to.be.a("string");
+ expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
+ new Date(schedules[0].window_closes_at).getTime(),
+ );
+ });
+ });
+ });
+
+ it("staff mark the ride-along paid — booking allocates onto the train", () => {
+ // ScheduleBatchPanel (the only "Mark paid" button) is not mounted in the
+ // current UI, so drive the staff override endpoint directly.
+ withBooking((b) => {
+ cy.apiLogin(opsStaff).then(({ token }) => {
+ cy.request({
+ method: "POST",
+ url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`,
+ headers: { Authorization: `Bearer ${token}` },
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ });
+
+ withBooking((b) => {
+ expect(b.status).to.eq("PAID");
+ expect(b.scheduling_status).to.eq("SCHEDULED");
+ expect(b.train_schedule_id, "still pinned to the export train").to.be.a("string");
+
+ // The schedule↔booking link row is what makes the booking visible on the
+ // train board, in yard work, and to the wagon planner.
+ cy.task<{ rows: Array<{ n: string }> }>("db:query", {
+ sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings
+ WHERE booking_id = $1 AND deleted_at IS NULL`,
+ params: [b.id],
+ }).then(({ rows }) => {
+ expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1);
+ });
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/fixtures/seed-export.sql b/e2e/freight/cypress/fixtures/seed-export.sql
new file mode 100644
index 000000000..023125106
--- /dev/null
+++ b/e2e/freight/cypress/fixtures/seed-export.sql
@@ -0,0 +1,73 @@
+-- Arrange-data for flows/export_one_time.cy.ts. Idempotent.
+-- Run AFTER seed-intercity.sql (reuses its container types, locomotives,
+-- built train TRN-E2E-1 and yard distances).
+--
+-- 1. bulk cargo hierarchy: group "E2E Grains" → commodity "E2E Wheat",
+-- carried on CW4 covered wagons
+-- 2. two CW4 wagons coupled onto the train (bulk capacity)
+-- 3. LIVE export rates for Mojo → Djibouti Port: container (per container)
+-- and bulk (per ton) — booking pricing hard-blocks without them
+
+-- 1a. Cargo type group + commodity.
+INSERT INTO freight.cargo_types (id, code, cargo_type_name, is_active)
+SELECT gen_random_uuid(), 'E2E_GRAINS', 'E2E Grains', true
+WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_GRAINS');
+
+INSERT INTO freight.cargo_types (id, code, cargo_type_name, parent_group_id, is_active)
+SELECT gen_random_uuid(), 'E2E_WHEAT', 'E2E Wheat', g.id, true
+FROM freight.cargo_types g
+WHERE g.code = 'E2E_GRAINS'
+ AND NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_WHEAT');
+
+-- 1b. Wheat rides CW4 covered wagons.
+INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
+SELECT ct.id, wt.id
+FROM freight.cargo_types ct
+JOIN freight.wagon_types wt ON wt.code = 'CW4'
+WHERE ct.code IN ('E2E_WHEAT', 'E2E_GRAINS')
+ AND NOT EXISTS (
+ SELECT 1 FROM freight.cargo_type_wagon_types x
+ WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
+ );
+
+-- 2. Couple two free CW4 wagons onto the train, parked at Mojo with it.
+UPDATE freight.wagons w
+SET train_id = t.id,
+ sequence_number = 100 + sub.rn,
+ current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO')
+FROM freight.trains t,
+ LATERAL (
+ SELECT w2.id, row_number() OVER (ORDER BY w2.wagon_number) AS rn
+ FROM freight.wagons w2
+ JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'CW4'
+ WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
+ ORDER BY w2.wagon_number
+ LIMIT 2
+ ) sub
+WHERE t.code = 'TRN-E2E-1'
+ AND w.id = sub.id
+ AND NOT EXISTS (
+ SELECT 1 FROM freight.wagons wx
+ JOIN freight.wagon_types wxt ON wxt.id = wx.wagon_type_id AND wxt.code = 'CW4'
+ WHERE wx.train_id = t.id
+ );
+
+-- 3. LIVE export rates Mojo → Djibouti Port.
+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,
+ v.unit, 'LIVE', a.id, b.id, u.id
+FROM (VALUES
+ ('CONTAINER_EXPORT', 'CONTAINER', 600, 'PER_CONTAINER'),
+ ('BULK_EXPORT', 'BULK', 25, 'PER_TON')
+ ) AS v(rate_type, applies_to, value, unit)
+JOIN freight.yards a ON a.code = 'MOJO'
+JOIN freight.yards b ON b.code = 'DJIB_PORT'
+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
+);
diff --git a/e2e/freight/cypress/fixtures/seed-intercity.sql b/e2e/freight/cypress/fixtures/seed-intercity.sql
new file mode 100644
index 000000000..732624ff4
--- /dev/null
+++ b/e2e/freight/cypress/fixtures/seed-intercity.sql
@@ -0,0 +1,131 @@
+-- Arrange-data for flows/intercity_one_time.cy.ts. Idempotent.
+--
+-- The e2e DB boots with yards + a wagon fleet only: no container types, no
+-- locomotives, no Train-Builder train, no yard distances, no routes. The spec
+-- drives route + schedule creation through the UI; this fixture provides only
+-- the infrastructure the UI journey cannot reasonably create in-flow:
+--
+-- 1. container types (booking form resolves 20ft/40ft by size_ft)
+-- 2. container-type → wagon-type allow-list (wagon planner)
+-- 3. two locomotives (a schedulable train needs >= 2)
+-- 4. a built Train-Builder train at Mojo with four NW5 flat wagons
+-- 5. yard distances for Mojo–Dire Dawa–Djibouti Port (route creation
+-- refuses unconfigured pairs)
+
+-- 1. Container types.
+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);
+
+-- 2. 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. Two locomotives at Mojo (status defaults to AVAILABLE).
+INSERT INTO freight.locomotives
+ (id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id)
+SELECT gen_random_uuid(), v.code, 4000, 760, y.id
+FROM (VALUES ('LOCO-E2E-1'), ('LOCO-E2E-2')) AS v(code)
+JOIN freight.yards y ON y.code = 'MOJO'
+WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
+
+-- 4a. Built train at Mojo (status defaults to AVAILABLE).
+INSERT INTO freight.trains (id, code, train_name, capacity_tons, current_yard_id)
+SELECT gen_random_uuid(), 'TRN-E2E-1', 'E2E Export Carrier', 2000, y.id
+FROM freight.yards y
+WHERE y.code = 'MOJO'
+ AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = 'TRN-E2E-1');
+
+-- 4b. Couple both locomotives (available-trains filter requires >= 2).
+INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no)
+SELECT gen_random_uuid(), t.id, l.id,
+ row_number() OVER (ORDER BY l.code) - 1
+FROM freight.trains t
+JOIN freight.locomotives l ON l.code IN ('LOCO-E2E-1', 'LOCO-E2E-2')
+WHERE t.code = 'TRN-E2E-1'
+ AND NOT EXISTS (
+ SELECT 1 FROM freight.train_locomotives tl
+ WHERE tl.train_id = t.id AND tl.locomotive_id = l.id
+ );
+
+-- 4c. Couple four free NW5 flat wagons onto the train and park them at Mojo
+-- with it (the seeded fleet sits at Doraleh; the planner reads the consist by
+-- train_id, the yard only matters for warnings).
+UPDATE freight.wagons w
+SET train_id = t.id,
+ sequence_number = sub.rn,
+ current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO')
+FROM freight.trains t,
+ LATERAL (
+ SELECT w2.id, row_number() OVER (ORDER BY w2.wagon_number) AS rn
+ 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
+ LIMIT 4
+ ) sub
+WHERE t.code = 'TRN-E2E-1'
+ AND w.id = sub.id
+ AND NOT EXISTS (SELECT 1 FROM freight.wagons wx WHERE wx.train_id = t.id);
+
+-- 4d. One REQUIRED intercity clearance document, so the journey exercises the
+-- real customer-upload → ops-review → finalize step (the seeder leaves the
+-- intercity_documents setting empty).
+INSERT INTO freight.file_upload_fields
+ (id, setting_id, file_key, file_label, is_required, is_multiple, max_files,
+ allowed_extensions, max_size_mb, display_order)
+SELECT gen_random_uuid(), s.id, 'cargo_manifest', 'Cargo Manifest', true, false, 1,
+ '{pdf,jpg,jpeg,png}'::text[], 10, 1
+FROM freight.file_upload_settings s
+WHERE s.code = 'intercity_documents'
+ AND NOT EXISTS (
+ SELECT 1 FROM freight.file_upload_fields f
+ WHERE f.setting_id = s.id AND f.file_key = 'cargo_manifest' AND f.deleted_at IS NULL
+ );
+
+-- 5. LIVE intercity container rate for Mojo → Dire Dawa (booking pricing
+-- hard-blocks any container line without a rate on its exact leg; rates are
+-- configured in USD and converted to the booking currency).
+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(), 'INTERCITY_CONTAINER', 'INTERCITY', 'ALWAYS', 'USD', 500,
+ 'PER_CONTAINER', 'LIVE', a.id, b.id, u.id
+FROM freight.yards a
+JOIN freight.yards b ON b.code = 'DIRE_DAWA'
+JOIN iam.users u ON u.email = 'operation@edr.local'
+WHERE a.code = 'MOJO'
+ AND NOT EXISTS (
+ SELECT 1 FROM freight.rates r
+ WHERE r.rate_type = 'INTERCITY_CONTAINER'
+ AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
+ AND r.deleted_at IS NULL
+ );
+
+-- 6. Segment distances (symmetric — one row covers both directions). Guarded:
+-- an e2e image built from a branch that predates the yard_distances feature
+-- has no table, and its route form doesn't require distances either.
+DO $$
+BEGIN
+ IF to_regclass('freight.yard_distances') IS NOT NULL THEN
+ 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 ('MOJO', 'DIRE_DAWA', 300), ('DIRE_DAWA', 'DJIB_PORT', 450))
+ 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)
+ );
+ END IF;
+END $$;
diff --git a/e2e/freight/cypress/support/commands.ts b/e2e/freight/cypress/support/commands.ts
index 6a958d5be..2d18350a0 100644
--- a/e2e/freight/cypress/support/commands.ts
+++ b/e2e/freight/cypress/support/commands.ts
@@ -110,7 +110,10 @@ Cypress.Commands.add("mantineSelect", (label: string | RegExp, option: string |
.then((id) => {
cy.get(`[id="${id}"]`).click({ force: true });
});
- cy.get('[role="option"]').contains(option).click();
+ // :visible — closed dropdowns can linger in the DOM, and two selects on one
+ // page may list the same option text (e.g. the intercity wizard's origin +
+ // destination both list every Ethiopian yard).
+ cy.get('[role="option"]:visible').contains(option).click();
});
/** Type a 6-digit code into a Mantine PinInput. */