From b5546b9d6f3de2354ceb7e3c7a74503f7a24cde9 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 22 Jul 2026 08:57:47 +0000 Subject: [PATCH 1/2] feat(freight-me): add position type lookup and enrich user profile with position type --- apps/edr-freight-web/backoffice/src/App.tsx | 2 + .../backoffice/src/lib/permissions.ts | 6 + e2e/freight/cypress.config.ts | 16 + .../e2e/flows/intercity_one_time.cy.ts | 549 ++++++++++++++++++ .../cypress/fixtures/seed-intercity.sql | 116 ++++ e2e/freight/cypress/support/commands.ts | 5 +- 6 files changed, 693 insertions(+), 1 deletion(-) create mode 100644 e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts create mode 100644 e2e/freight/cypress/fixtures/seed-intercity.sql diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index aed710e2e..f81e733e0 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,6 +190,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Support", href: "/dashboard/support", icon: , + 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/intercity_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts new file mode 100644 index 000000000..43eca6b4a --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts @@ -0,0 +1,549 @@ +/** + * 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 passes the intercity-documents + * step; the e2e setting has no required fields) + * 7. backoffice — operations 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("operations finalizes document approval — 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(); + + // The e2e intercity_documents setting has no required fields, so the + // review section is immediately finalizable. + 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-intercity.sql b/e2e/freight/cypress/fixtures/seed-intercity.sql new file mode 100644 index 000000000..4c45f8de9 --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-intercity.sql @@ -0,0 +1,116 @@ +-- 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); + +-- 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. */ From 44cad7a52b41706a8faab864f01442b68ea7f30d Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 22 Jul 2026 10:50:36 +0000 Subject: [PATCH 2/2] changes --- apps/edr-freight-api/src/main.ts | 7 + .../cypress/e2e/flows/export_one_time.cy.ts | 525 ++++++++++++++++++ .../e2e/flows/intercity_one_time.cy.ts | 37 +- e2e/freight/cypress/fixtures/seed-export.sql | 73 +++ .../cypress/fixtures/seed-intercity.sql | 15 + 5 files changed, 651 insertions(+), 6 deletions(-) create mode 100644 e2e/freight/cypress/e2e/flows/export_one_time.cy.ts create mode 100644 e2e/freight/cypress/fixtures/seed-export.sql 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/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 index 43eca6b4a..b8ca2fd1b 100644 --- a/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts +++ b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts @@ -10,9 +10,13 @@ * → CONTRACT_READY * 5. portal — customer OTP-signs → SIGNED_CUSTOMER * 6. backoffice — marketer counter-signs → AWAITING_CLEARANCE_DOCUMENTS - * (ONE_TIME intercity always passes the intercity-documents - * step; the e2e setting has no required fields) - * 7. backoffice — operations finalizes document approval → FULLY_EXECUTED + * (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 @@ -314,15 +318,36 @@ describe("intercity one-time journey: contract → booking → export train", { expectContractStatus("AWAITING_CLEARANCE_DOCUMENTS"); }); - it("operations finalizes document approval — contract fully executed", () => { + 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(); - // The e2e intercity_documents setting has no required fields, so the - // review section is immediately finalizable. + // 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(); 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 index 4c45f8de9..732624ff4 100644 --- a/e2e/freight/cypress/fixtures/seed-intercity.sql +++ b/e2e/freight/cypress/fixtures/seed-intercity.sql @@ -76,6 +76,21 @@ 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).