diff --git a/e2e/freight/cypress.config.ts b/e2e/freight/cypress.config.ts index 4a3237e64..5a96460b8 100644 --- a/e2e/freight/cypress.config.ts +++ b/e2e/freight/cypress.config.ts @@ -24,8 +24,15 @@ export default defineConfig({ screenshotOnRunFailure: true, viewportWidth: 1440, viewportHeight: 900, - defaultCommandTimeout: 10000, - requestTimeout: 15000, + // Generous across the board: these journeys drive the batch engine, whose + // window transitions are settled by a 10s server tick, and a single step + // can wait on several of them. Two minutes is long enough that a real + // timeout means something is genuinely stuck rather than merely slow. + defaultCommandTimeout: 120000, + requestTimeout: 120000, + responseTimeout: 120000, + pageLoadTimeout: 120000, + taskTimeout: 120000, retries: { runMode: 1, openMode: 0 }, env: { apiUrl: process.env.CYPRESS_API_URL ?? "http://localhost:3101", diff --git a/e2e/freight/cypress/e2e/flows/g1-utils.ts b/e2e/freight/cypress/e2e/flows/g1-utils.ts index 2bbb449ed..8bbde1013 100644 --- a/e2e/freight/cypress/e2e/flows/g1-utils.ts +++ b/e2e/freight/cypress/e2e/flows/g1-utils.ts @@ -28,7 +28,11 @@ */ import { + acceptExport, + acceptOperation, apiPost, + bookContainers, + clearToOperationRequestPending, closeBookingWindow, db, dbSchedule, @@ -64,15 +68,22 @@ export function escapeRegExp(value: string): string { /** * A Date as the unzoned "YYYY-MM-DDTHH:mm" wall-clock string that an * `` accepts (the create-schedule form's Departure - * date field). The corridor departures are pinned to 12:00 EAT = 09:00 UTC, so - * the wall-clock the operator types is the EAT one — the browser running the - * test is UTC in CI and EAT on a local machine, and typing a UTC-rendered - * string on an EAT machine would file the departure three hours early and land - * it on the wrong booking day. + * date field). + * + * The value MUST be in the BROWSER's local zone, not EAT. The input carries no + * offset, so whatever is typed is read as local time and converted on submit — + * pre-shifting to EAT on a UTC browser files the departure three hours late, + * which put it outside dbSchedule's ±1h lookup window and made a successfully + * created schedule look like it had never been created at all. + * + * Built from the local getters rather than toISOString for exactly that reason. */ export function localDateTime(d: Date): string { - const eat = new Date(d.getTime() + 3 * 3_600_000); - return eat.toISOString().slice(0, 16); + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + + `T${pad(d.getHours())}:${pad(d.getMinutes())}` + ); } /** @@ -142,8 +153,8 @@ export function configureAndOpenSchedule(opts: { cy.loginBackoffice(opsStaff); cy.visit("/dashboard/operations/train-scheduling-v2"); - cy.contains("button", "New schedule", { timeout: 30000 }).click(); - cy.contains("Create train schedule", { timeout: 20000 }).should("be.visible"); + cy.contains("button", "New schedule", { timeout: 120000 }).click(); + cy.contains("Create train schedule", { timeout: 120000 }).should("be.visible"); // Route options are composed by formatRouteLabel, which renders yard LABELS // ("Djibouti Port"), never codes — so resolve the label for this corridor. @@ -160,7 +171,7 @@ export function configureAndOpenSchedule(opts: { // Option text is composed: "TRN-G1-1 — E2E Group-1 … · 53 wagons". cy.mantineSelect("Train", new RegExp(escapeRegExp(trainCode))); cy.get(".mantine-Modal-content").contains("button", "Create").click(); - cy.get(".mantine-Modal-content", { timeout: 30000 }).should("not.exist"); + cy.get(".mantine-Modal-content", { timeout: 120000 }).should("not.exist"); expectCapacity(opts.departure, opts.wagons ?? G1_WAGONS); withSchedule(opts.departure, (s) => forceWindowOpen(s.id, opts.closesInMinutes ?? 45)); @@ -179,8 +190,8 @@ export function closeWindowAndRunBatch(departure: Date) { cy.loginBackoffice(opsStaff); withSchedule(departure, (s) => cy.visit(`/dashboard/operations/batch-board/${s.id}`)); - cy.contains("Doc review", { timeout: 30000 }).should("exist"); - cy.contains("button", "Doc review complete — run batch", { timeout: 20000 }).click(); + cy.contains("Doc review", { timeout: 120000 }).should("exist"); + cy.contains("button", "Doc review complete — run batch", { timeout: 120000 }).click(); withSchedule(departure, (s) => pollDb( @@ -212,12 +223,12 @@ export function expectBoard( ) { cy.loginBackoffice(opsStaff); withSchedule(departure, (s) => cy.visit(`/dashboard/operations/batch-board/${s.id}`)); - cy.contains(/Priority Tracking/, { timeout: 30000 }).click(); - cy.contains("Priority ranking", { timeout: 20000 }).should("be.visible"); + cy.contains(/Priority Tracking/, { timeout: 120000 }).click(); + cy.contains("Priority ranking", { timeout: 120000 }).should("be.visible"); if (opts.inBatch !== undefined) { cy.contains(new RegExp(`In the batch\\s*\\(${opts.inBatch}\\)`), { - timeout: 20000, + timeout: 120000, }).should("exist"); } if (opts.waiting !== undefined) { @@ -309,6 +320,71 @@ export function expectVerdict( }); } +// --------------------------------------------------------------------------- +// booking → clearance gate → operations queue +// --------------------------------------------------------------------------- + +/** + * Book containers and walk the booking all the way to the operations pool. + * + * EVERY contract booking is now born in the clearance gate — see + * contract-booking.service.ts:211, "EVERY contract booking clears per booking + * now — both contract kinds, both paths, intercity included". A booking is + * created in AWAITING_DOCUMENTS regardless of whether customs clearance is + * enabled, so calling `acceptOperation` straight after `bookContainers` always + * 409s with: + * + * Cannot perform this action on status "AWAITING_DOCUMENTS". + * Allowed: OPERATION_REQUEST_PENDING + * + * The gate is: upload a document → GL approves it → finalize → the customer + * proceeds with the shipment day. `clearToOperationRequestPending` runs that + * whole chain (the e2e seed configures no required documents, so one ad-hoc + * doc satisfies the 100%-approved rule). + * + * Use this instead of bookContainers + acceptOperation anywhere a booking has + * to reach the day pool. + */ +export function bookAndClear(opts: { + suffix: string; + runStamp: string; + isoSeed: number; + twenty?: number; + forty?: number; + scheduledDate: string; + vgmTons?: number; + /** EXPORT reserves on accept (FCFS) rather than entering the batch pool. */ + mode?: "import" | "export"; +}) { + bookContainers({ + suffix: opts.suffix, + runStamp: opts.runStamp, + isoSeed: opts.isoSeed, + twenty: opts.twenty, + forty: opts.forty, + scheduledDate: opts.scheduledDate, + vgmTons: opts.vgmTons, + }); + clearToOperationRequestPending(opts.suffix, opts.scheduledDate); + if (opts.mode === "export") acceptExport(opts.suffix); + else acceptOperation(opts.suffix); +} + +/** + * The clearance half alone, for a booking created some other way — e.g. the + * portal form (`bookContainersVisually`), which leaves the booking sitting in + * the same AWAITING_DOCUMENTS gate. + */ +export function clearAndAccept(opts: { + suffix: string; + scheduledDate: string; + mode?: "import" | "export"; +}) { + clearToOperationRequestPending(opts.suffix, opts.scheduledDate); + if (opts.mode === "export") acceptExport(opts.suffix); + else acceptOperation(opts.suffix); +} + // --------------------------------------------------------------------------- // booking through the real portal form // --------------------------------------------------------------------------- @@ -349,52 +425,93 @@ export function bookContainersVisually(opts: { expect(twenty % 2, "20ft quantity must be even").to.eq(0); cy.visitPortal(`/contracts/${opts.contractId}/bookings/new`); - cy.contains("New Shipment Booking", { timeout: 30000 }).should("be.visible"); + cy.contains("New Shipment Booking", { timeout: 120000 }).should("be.visible"); - if (twenty) fillSizeQuantity("20ft", String(twenty)); - if (forty) fillSizeQuantity("40ft", String(forty)); + // BOTH size cards must be given a quantity, including the unused one. + // + // The form renders a ContainerLineEditor per size in the contract's cargo + // scope, and an untouched editor keeps one blank unit row. The zod schema + // requires a valid ISO number AND a VGM on EVERY unit row + // (new-shipment-form/schema.ts:39-50), so that blank row fails validation and + // handleSubmit aborts SILENTLY — no modal, no toast, no request. Typing 0 + // truncates the card's units to none (syncUnits: `next.length = max(0, qty)`) + // and takes it out of validation. + fillSizeQuantity("20ft", String(twenty)); + fillSizeQuantity("40ft", String(forty)); // One ISO row per container, then the VGM on each. + // + // Scoped PER SIZE CARD, not globally: the form renders a ContainerLineEditor + // for every size in the contract's cargo scope, and an editor left at + // quantity 0 still renders one blank unit row. A global + // `input[placeholder*="MSCU"]` therefore counts the other card's row too — + // "Found 7, expected 6" — and the numbers land in the wrong card. const prefix = opts.isoPrefix ?? "MSCU"; - cy.get('input[placeholder*="MSCU"]', { timeout: 20000 }).should("have.length", total); - for (let i = 0; i < total; i += 1) { - cy.get('input[placeholder*="MSCU"]') - .eq(i) - .clear({ force: true }) - .type(`${prefix}${String(1_000_000 + i).slice(0, 7)}`, { force: true }); - } - cy.get('input[placeholder*="24.5"]').each(($input) => { - cy.wrap($input) - .clear({ force: true }) - .type(String(opts.vgmTons ?? 10), { force: true }); - }); + let unit = 0; + const fillUnits = (size: "20ft" | "40ft", count: number) => { + if (!count) return; + cy.contains(`${size} containers`, { timeout: 120000 }) + .closest("div.rounded-xl") + .within(() => { + cy.get('input[placeholder*="MSCU"]', { timeout: 120000 }).should( + "have.length", + count, + ); + for (let i = 0; i < count; i += 1) { + const iso = `${prefix}${String(1_000_000 + unit + i).slice(0, 7)}`; + cy.get('input[placeholder*="MSCU"]') + .eq(i) + .clear({ force: true }) + .type(iso, { force: true }); + } + cy.get('input[placeholder*="24.5"]').each(($input) => { + cy.wrap($input) + .clear({ force: true }) + .type(String(opts.vgmTons ?? 10), { force: true }); + }); + }) + .then(() => { + unit += count; + }); + }; + fillUnits("20ft", twenty); + fillUnits("40ft", forty); pickShipmentDay(opts.shipmentDay); cy.fillCargoDescription(); cy.contains("button", "Review price & book").should("not.be.disabled").click(); - cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible"); + cy.contains("Confirm shipment price", { timeout: 120000 }).should("be.visible"); cy.contains("button", "Confirm & book").click(); - cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/); + cy.location("pathname", { timeout: 120000 }).should("match", /^\/bookings\/.+/); } -/** One container-size line's quantity, addressed by its heading (see above). */ +/** + * One container-size line's quantity, addressed by its heading rather than by + * position — the cards render in CONTRACT-SCOPE order, not 20ft-then-40ft. + * + * A no-op when the contract does not scope this size, so callers can always + * set both (see the note about blank unit rows in bookContainersVisually). + */ function fillSizeQuantity(size: "20ft" | "40ft", value: string) { - cy.contains(`${size} containers`, { timeout: 20000 }) - .closest("div.rounded-xl") - .find('input[type="number"]') - .first() - .clear({ force: true }) - .type(value, { force: true }); + cy.get("body").then(($body) => { + if (!$body.text().includes(`${size} containers`)) return; + cy.contains(`${size} containers`, { timeout: 120000 }) + .closest("div.rounded-xl") + .find('input[type="number"]') + .first() + .clear({ force: true }) + .type(value, { force: true }); + }); } /** Pick a day on the Schedule card's inline, cargo-aware calendar. */ function pickShipmentDay(day: Date) { - cy.contains(/available day/, { timeout: 30000 }).should("exist"); + cy.contains(/available day/, { timeout: 120000 }).should("exist"); // Day cells are plain buttons in a grid; only bookable days are enabled // (out-of-month duplicates and unscheduled days stay disabled). const eatDay = new Date(day.getTime() + 3 * 3_600_000).getUTCDate(); - cy.get("button:not(:disabled)", { timeout: 20000 }) + cy.get("button:not(:disabled)", { timeout: 120000 }) .contains(new RegExp(`^${eatDay}$`)) .click({ force: true }); } diff --git a/e2e/freight/cypress/e2e/flows/g10_validation.cy.ts b/e2e/freight/cypress/e2e/flows/g10_validation.cy.ts index a815f0174..56ec26c51 100644 --- a/e2e/freight/cypress/e2e/flows/g10_validation.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g10_validation.cy.ts @@ -51,6 +51,7 @@ import { } from "./import-utils"; import { G1_WAGONS, + bookAndClear, closeWindowAndRunBatch, configureAndOpenSchedule, expectVerdict, @@ -275,7 +276,7 @@ describe("G10·S40b: a re-priced booking parks with no capacity footprint", { re let isoSeed = 27_100; ORDER.forEach((suffix) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -283,7 +284,6 @@ describe("G10·S40b: a re-priced booking parks with no capacity footprint", { re scheduledDate: BOOKING_DAY, }); isoSeed += SHAPES[suffix].forty; - acceptOperation(suffix); }); }); diff --git a/e2e/freight/cypress/e2e/flows/g1_s1_expiry_promotes_waitlist.cy.ts b/e2e/freight/cypress/e2e/flows/g1_s1_expiry_promotes_waitlist.cy.ts index 7acf9563d..a74955a91 100644 --- a/e2e/freight/cypress/e2e/flows/g1_s1_expiry_promotes_waitlist.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g1_s1_expiry_promotes_waitlist.cy.ts @@ -2,7 +2,7 @@ * GROUP 1 · S1 — a no-pay expiry frees exactly the space the waiting list needs. * * A 3×40FT = 3 wagons - * B 20×20FT + 10×40FT = 20 wagons (customs clearance) + * B 20×20FT + 10×40FT = 20 wagons * C 30×40FT = 30 wagons * ───────── * 53 = the whole train → RESERVED FULL @@ -67,7 +67,9 @@ import { import { G1_TRAIN, G1_WAGONS, + bookAndClear, bookContainersVisually, + clearAndAccept, closeWindowAndRunBatch, configureAndOpenSchedule, expectBoard, @@ -90,11 +92,11 @@ const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; * genuinely inside the batch and its expiry genuinely frees space. */ const SHAPES = { - A: { twenty: 0, forty: 3, wagons: 3, customs: false }, - B: { twenty: 20, forty: 10, wagons: 20, customs: true }, - C: { twenty: 0, forty: 30, wagons: 30, customs: false }, + A: { twenty: 0, forty: 3, wagons: 3 }, + B: { twenty: 20, forty: 10, wagons: 20 }, + C: { twenty: 0, forty: 30, wagons: 30 }, // D is booked through the UI, not from this table — see the portal step. - D: { twenty: 6, forty: 0, wagons: 3, customs: false }, + D: { twenty: 6, forty: 0, wagons: 3 }, } as const; const IN_BATCH = ["A", "B", "C"] as const; @@ -103,12 +105,12 @@ describe("G1·S1: expiry frees exactly the waiting list's space", { retries: 0 } before(() => { cy.task("db:seedFile", "seed-import-corridor.sql"); cy.task("db:seedFile", "seed-g1-train.sql"); + // All four self-clear. A customs contract routes its booking into + // AWAITING_DOCUMENTS and a whole clearance gate before ops can accept it + // (see clearGeneralBooking in import-utils) — orthogonal to this + // scenario, which is about expiry and waiting-list promotion. (["A", "B", "C", "D"] as const).forEach((suffix) => - seedImportContract({ - suffix, - reference: stampedRef(suffix), - customs: SHAPES[suffix].customs, - }), + seedImportContract({ suffix, reference: stampedRef(suffix) }), ); }); @@ -142,14 +144,18 @@ describe("G1·S1: expiry frees exactly the waiting list's space", { retries: 0 } cy.visit(`/dashboard/train-builder/${rows[0].id}`); }, ); - cy.contains(`Train ${G1_TRAIN}`, { timeout: 30000 }).should("exist"); - cy.contains("Wagon order", { timeout: 20000 }).should("exist"); - // Stat strip: "Wagons" → 53. Scoped to the stat, since bare "53" would - // also match wagon numbers in the consist list below it. - cy.contains("Wagons") - .parent() - .contains(String(G1_WAGONS), { timeout: 20000 }) - .should("exist"); + cy.contains(`Train ${G1_TRAIN}`, { timeout: 120000 }).should("exist"); + cy.contains("Wagon order", { timeout: 120000 }).should("exist"); + // Stat strip: the "Wagons" KPI cell reads 53. + // + // Scope to the KpiStrip cell (`div.flex-1`, KpiStrip.tsx) rather than + // matching "Wagons" anywhere: the left sidebar has a NavLink of the same + // name, cy.contains returns the FIRST match, and its parent never contains + // the count — which is exactly how this first failed. + cy.contains("div.flex-1", "Wagons", { timeout: 120000 }).should( + "contain.text", + String(G1_WAGONS), + ); }); it("operations schedules that train on the corridor and opens the window", () => { @@ -166,7 +172,7 @@ describe("G1·S1: expiry frees exactly the waiting list's space", { retries: 0 } let isoSeed = 7100; IN_BATCH.forEach((suffix) => { const shape = SHAPES[suffix]; - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -175,7 +181,6 @@ describe("G1·S1: expiry frees exactly the waiting list's space", { retries: 0 } scheduledDate: BOOKING_DAY, }); isoSeed += shape.twenty + shape.forty; - acceptOperation(suffix); }); // Booking order = priority order, so A is inside the batch and its expiry // is what frees space (see the SHAPES comment). @@ -193,8 +198,7 @@ describe("G1·S1: expiry frees exactly the waiting list's space", { retries: 0 } isoPrefix: "DDDU", }); }); - pollBookingStatus("D", "OPERATION_REQUEST_PENDING", 10); - acceptOperation("D"); + clearAndAccept({ suffix: "D", scheduledDate: BOOKING_DAY }); setPriority("D", 4); }); diff --git a/e2e/freight/cypress/e2e/flows/g1_s2_exact_fill.cy.ts b/e2e/freight/cypress/e2e/flows/g1_s2_exact_fill.cy.ts index c179e0ee5..bd3acfc8b 100644 --- a/e2e/freight/cypress/e2e/flows/g1_s2_exact_fill.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g1_s2_exact_fill.cy.ts @@ -43,7 +43,9 @@ import { } from "./import-utils"; import { G1_WAGONS, + bookAndClear, bookContainersVisually, + clearAndAccept, closeWindowAndRunBatch, configureAndOpenSchedule, expectBoard, @@ -101,7 +103,7 @@ describe("G1·S2: four bookings pay and fill the train exactly", { retries: 0 }, let isoSeed = 8100; VIA_API.forEach((suffix) => { const shape = SHAPES[suffix]; - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -110,7 +112,6 @@ describe("G1·S2: four bookings pay and fill the train exactly", { retries: 0 }, scheduledDate: BOOKING_DAY, }); isoSeed += shape.containers; - acceptOperation(suffix); }); }); @@ -124,8 +125,7 @@ describe("G1·S2: four bookings pay and fill the train exactly", { retries: 0 }, isoPrefix: "SEXU", }); }); - pollBookingStatus("D", "OPERATION_REQUEST_PENDING", 10); - acceptOperation("D"); + clearAndAccept({ suffix: "D", scheduledDate: BOOKING_DAY }); }); it("the batch reserves all four — they fit exactly, so nobody is offered a split", () => { diff --git a/e2e/freight/cypress/e2e/flows/g1_s3_underfill_day_stays_open.cy.ts b/e2e/freight/cypress/e2e/flows/g1_s3_underfill_day_stays_open.cy.ts index f52f5791f..d8b61d8f5 100644 --- a/e2e/freight/cypress/e2e/flows/g1_s3_underfill_day_stays_open.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g1_s3_underfill_day_stays_open.cy.ts @@ -39,7 +39,9 @@ import { } from "./import-utils"; import { G1_WAGONS, + bookAndClear, bookContainersVisually, + clearAndAccept, closeWindowAndRunBatch, configureAndOpenSchedule, expectBoard, @@ -93,7 +95,7 @@ describe("G1·S3: an under-filled train keeps its day open", { retries: 0 }, () let isoSeed = 8600; (["A", "B"] as const).forEach((suffix) => { const shape = SHAPES[suffix]; - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -102,7 +104,6 @@ describe("G1·S3: an under-filled train keeps its day open", { retries: 0 }, () scheduledDate: BOOKING_DAY, }); isoSeed += shape.twenty + shape.forty; - acceptOperation(suffix); }); cy.loginPortal(customer); @@ -114,8 +115,7 @@ describe("G1·S3: an under-filled train keeps its day open", { retries: 0 }, () isoPrefix: "CSQU", }); }); - pollBookingStatus("C", "OPERATION_REQUEST_PENDING", 10); - acceptOperation("C"); + clearAndAccept({ suffix: "C", scheduledDate: BOOKING_DAY }); }); it("the batch reserves all three whole — there is room to spare, so no splits", () => { diff --git a/e2e/freight/cypress/e2e/flows/g1_s4_split_closes_gap.cy.ts b/e2e/freight/cypress/e2e/flows/g1_s4_split_closes_gap.cy.ts index b1f00d158..fdd5e28e5 100644 --- a/e2e/freight/cypress/e2e/flows/g1_s4_split_closes_gap.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g1_s4_split_closes_gap.cy.ts @@ -48,6 +48,7 @@ import { } from "./import-utils"; import { G1_WAGONS, + bookAndClear, closeWindowAndRunBatch, configureAndOpenSchedule, expectBoard, @@ -109,7 +110,7 @@ describe("G1·S4: a split closes the last 3-wagon gap", { retries: 0 }, () => { let isoSeed = 9100; ORDER.forEach((suffix) => { const shape = SHAPES[suffix]; - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -118,7 +119,6 @@ describe("G1·S4: a split closes the last 3-wagon gap", { retries: 0 }, () => { scheduledDate: BOOKING_DAY, }); isoSeed += shape.twenty + shape.forty; - acceptOperation(suffix); }); // Priority decides who gets a whole seat and who gets the offer. ORDER.forEach((suffix, i) => setPriority(suffix, i + 1)); diff --git a/e2e/freight/cypress/e2e/flows/g1_s5_cascading_expiry.cy.ts b/e2e/freight/cypress/e2e/flows/g1_s5_cascading_expiry.cy.ts index 1d6cce642..9e1556c15 100644 --- a/e2e/freight/cypress/e2e/flows/g1_s5_cascading_expiry.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g1_s5_cascading_expiry.cy.ts @@ -43,6 +43,7 @@ import { } from "./import-utils"; import { G1_WAGONS, + bookAndClear, closeWindowAndRunBatch, configureAndOpenSchedule, expectBoard, @@ -104,7 +105,7 @@ describe("G1·S5: expiry cascades into a second promotion", { retries: 0 }, () = let isoSeed = 9600; ORDER.forEach((suffix) => { const shape = SHAPES[suffix]; - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -113,7 +114,6 @@ describe("G1·S5: expiry cascades into a second promotion", { retries: 0 }, () = scheduledDate: BOOKING_DAY, }); isoSeed += shape.twenty + shape.forty; - acceptOperation(suffix); }); ORDER.forEach((suffix, i) => setPriority(suffix, i + 1)); }); diff --git a/e2e/freight/cypress/e2e/flows/g1_s6_s8_offers_and_priority.cy.ts b/e2e/freight/cypress/e2e/flows/g1_s6_s8_offers_and_priority.cy.ts index 37b181e28..4d090ae32 100644 --- a/e2e/freight/cypress/e2e/flows/g1_s6_s8_offers_and_priority.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g1_s6_s8_offers_and_priority.cy.ts @@ -56,6 +56,7 @@ import { } from "./import-utils"; import { G1_WAGONS, + bookAndClear, closeWindowAndRunBatch, configureAndOpenSchedule, expectBoard, @@ -105,7 +106,7 @@ describe("G1·S6: an ignored split offer expires the booking whole", { retries: let isoSeed = 10_100; ORDER.forEach((suffix) => { const shape = SHAPES[suffix]; - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -114,7 +115,6 @@ describe("G1·S6: an ignored split offer expires the booking whole", { retries: scheduledDate: BOOKING_DAY, }); isoSeed += shape.twenty + shape.forty; - acceptOperation(suffix); }); ORDER.forEach((suffix, i) => setPriority(suffix, i + 1)); @@ -195,7 +195,7 @@ describe("G1·S7: a government booking preempts commercial", { retries: 0 }, () let isoSeed = 10_600; ORDER.forEach((suffix) => { const shape = SHAPES[suffix]; - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -203,7 +203,6 @@ describe("G1·S7: a government booking preempts commercial", { retries: 0 }, () scheduledDate: BOOKING_DAY, }); isoSeed += shape.forty; - acceptOperation(suffix); }); // GA outranks GB, so GB is the one preemption should take. ORDER.forEach((suffix, i) => setPriority(suffix, i + 1)); @@ -369,7 +368,7 @@ describe("G1·S8: priority tiers order the batch", { retries: 0 }, () => { let isoSeed = 11_100; ORDER.forEach((suffix) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -377,7 +376,6 @@ describe("G1·S8: priority tiers order the batch", { retries: 0 }, () => { scheduledDate: BOOKING_DAY, }); isoSeed += SHAPES[suffix].forty; - acceptOperation(suffix); }); }); diff --git a/e2e/freight/cypress/e2e/flows/g2_weight.cy.ts b/e2e/freight/cypress/e2e/flows/g2_weight.cy.ts index 86c1595f1..fec4c0980 100644 --- a/e2e/freight/cypress/e2e/flows/g2_weight.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g2_weight.cy.ts @@ -50,6 +50,7 @@ import { } from "./import-utils"; import { G1_WAGONS, + bookAndClear, closeWindowAndRunBatch, configureAndOpenSchedule, expectSplitOffer, @@ -124,7 +125,7 @@ describe("G2·S9: weight refuses a booking while slots sit empty", { retries: 0 it("three heavy bookings arrive — 45 wagons of demand for 53 slots", () => { let isoSeed = 12_100; ORDER.forEach((suffix) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -133,7 +134,6 @@ describe("G2·S9: weight refuses a booking while slots sit empty", { retries: 0 vgmTons: HEAVY_VGM, }); isoSeed += SHAPES[suffix].twenty; - acceptOperation(suffix); }); ORDER.forEach((suffix, i) => setPriority(suffix, i + 1)); }); @@ -205,7 +205,7 @@ describe("G2·S10: the overage tolerance admits C whole", { retries: 0 }, () => let isoSeed = 12_600; ORDER.forEach((suffix) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -214,7 +214,6 @@ describe("G2·S10: the overage tolerance admits C whole", { retries: 0 }, () => vgmTons: HEAVY_VGM, }); isoSeed += SHAPES[suffix].twenty; - acceptOperation(suffix); }); ORDER.forEach((suffix, i) => setPriority(suffix, i + 1)); @@ -295,7 +294,7 @@ describe("G2·S11: a split is sized against base weight only", { retries: 0 }, ( let isoSeed = 13_100; ORDER.forEach((suffix) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -304,7 +303,6 @@ describe("G2·S11: a split is sized against base weight only", { retries: 0 }, ( vgmTons: HEAVY_VGM, }); isoSeed += SHAPES[suffix].twenty; - acceptOperation(suffix); }); ORDER.forEach((suffix, i) => setPriority(suffix, i + 1)); closeWindowAndRunBatch(DEPARTURE); @@ -384,7 +382,7 @@ describe("G2·S12: with light cargo the slots bind first", { retries: 0 }, () => let isoSeed = 13_600; ORDER.forEach((suffix) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -393,7 +391,6 @@ describe("G2·S12: with light cargo the slots bind first", { retries: 0 }, () => vgmTons: LIGHT_VGM, }); isoSeed += SHAPES[suffix].twenty; - acceptOperation(suffix); }); closeWindowAndRunBatch(DEPARTURE); diff --git a/e2e/freight/cypress/e2e/flows/g3_export.cy.ts b/e2e/freight/cypress/e2e/flows/g3_export.cy.ts index 37f6d3d9f..d41405c4a 100644 --- a/e2e/freight/cypress/e2e/flows/g3_export.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g3_export.cy.ts @@ -53,6 +53,7 @@ import { seedImportContract, withBooking, } from "./import-utils"; +import { bookAndClear } from "./g1-utils"; const stamp = String(Date.now()); const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; @@ -182,14 +183,12 @@ describe("G3·S13: unpaid export holds occupy the train", { retries: 0 }, () => // Export: ACCEPT is the reservation (FCFS), not a batch entry. acceptExport("EA"); - bookContainers({ + bookAndClear({ suffix: "EB", runStamp: stamp, isoSeed: 14_200, forty: SHAPES.EB.forty, - scheduledDate: BOOKING_DAY, - }); - acceptExport("EB"); + scheduledDate: BOOKING_DAY, mode: "export"}); // Neither has paid. Both nevertheless hold their wagons: reserved = // SELECTED_FOR_BATCH / AWAITING_PAYMENT is subtracted from capacity @@ -273,14 +272,12 @@ describe("G3·S14: a booking that cannot fit whole is SKIPPED, not split", { ret }); it("A boards; B is refused WHOLE — no partial offer is ever raised", () => { - bookContainers({ + bookAndClear({ suffix: "FA", runStamp: stamp, isoSeed: 14_600, forty: SHAPES.FA.forty, - scheduledDate: BOOKING_DAY, - }); - acceptExport("FA"); + scheduledDate: BOOKING_DAY, mode: "export"}); // B asks for 25 with 24 free. Export cannot part-load, so the request is // refused at requestOperation with a sized message @@ -310,14 +307,12 @@ describe("G3·S14: a booking that cannot fit whole is SKIPPED, not split", { ret it("C and D board behind B — the train fills to 54 without it", () => { (["FC", "FD"] as const).forEach((suffix, i) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed: 14_800 + i * 100, forty: SHAPES[suffix].forty, - scheduledDate: BOOKING_DAY, - }); - acceptExport(suffix); + scheduledDate: BOOKING_DAY, mode: "export"}); }); (["FA", "FC", "FD"] as const).forEach((suffix) => { @@ -570,14 +565,12 @@ describe("G3·S16: an expired hold frees space and fits flips back", { retries: }); (["HA", "HB"] as const).forEach((suffix, i) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed: 15_100 + i * 100, forty: SHAPES[suffix].forty, - scheduledDate: BOOKING_DAY, - }); - acceptExport(suffix); + scheduledDate: BOOKING_DAY, mode: "export"}); }); bookContainers({ @@ -695,14 +688,12 @@ describe("G3·S18: a booking larger than the train is refused outright", { retri it("split into two 30-wagon bookings, both board — the documented workaround", () => { (["OB1", "OB2"] as const).forEach((suffix, i) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed: 15_800 + i * 100, forty: 27, - scheduledDate: BOOKING_DAY, - }); - acceptExport(suffix); + scheduledDate: BOOKING_DAY, mode: "export"}); markPaid(suffix); pollAllocations(suffix, 27); }); diff --git a/e2e/freight/cypress/e2e/flows/g4_multi_schedule.cy.ts b/e2e/freight/cypress/e2e/flows/g4_multi_schedule.cy.ts index 581b7995c..4d83da695 100644 --- a/e2e/freight/cypress/e2e/flows/g4_multi_schedule.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g4_multi_schedule.cy.ts @@ -54,6 +54,7 @@ import { import { G1_TRAIN_2, G1_WAGONS, + bookAndClear, closeWindowAndRunBatch, configureAndOpenSchedule, expectVerdict, @@ -142,7 +143,7 @@ describe("G4·S19: an import booking splits across two trains", { retries: 0 }, it("three bookings arrive, MA first in priority", () => { let isoSeed = 17_100; ORDER.forEach((suffix) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -150,7 +151,6 @@ describe("G4·S19: an import booking splits across two trains", { retries: 0 }, scheduledDate: BOOKING_DAY, }); isoSeed += SHAPES[suffix].forty; - acceptOperation(suffix); }); ORDER.forEach((suffix, i) => setPriority(suffix, i + 1)); }); @@ -229,7 +229,7 @@ describe("G4·S20: the batch prefers a whole placement over a forced split", { r let isoSeed = 17_600; ORDER.forEach((suffix) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -237,7 +237,6 @@ describe("G4·S20: the batch prefers a whole placement over a forced split", { r scheduledDate: BOOKING_DAY, }); isoSeed += SHAPES[suffix].forty; - acceptOperation(suffix); }); ORDER.forEach((suffix, i) => setPriority(suffix, i + 1)); closeWindowAndRunBatch(T1); diff --git a/e2e/freight/cypress/e2e/flows/g5_waitlist.cy.ts b/e2e/freight/cypress/e2e/flows/g5_waitlist.cy.ts index e0017ef50..00b1f23fa 100644 --- a/e2e/freight/cypress/e2e/flows/g5_waitlist.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g5_waitlist.cy.ts @@ -37,6 +37,7 @@ import { import { G1_TRAIN_2, G1_WAGONS, + bookAndClear, closeWindowAndRunBatch, configureAndOpenSchedule, expectBoard, @@ -81,7 +82,7 @@ describe("G5·S22: an expired booking moves to the next day intact", { retries: let isoSeed = 18_100; ORDER.forEach((suffix) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -89,7 +90,6 @@ describe("G5·S22: an expired booking moves to the next day intact", { retries: scheduledDate: BOOKING_DAY_1, }); isoSeed += SHAPES[suffix].forty; - acceptOperation(suffix); }); ORDER.forEach((suffix, i) => setPriority(suffix, i + 1)); closeWindowAndRunBatch(DAY_1); @@ -135,14 +135,13 @@ describe("G5·S22: an expired booking moves to the next day intact", { retries: it("day 2 carries RC plus a new 40-wagon customer — FULL at 53/53", () => { seedImportContract({ suffix: "RD", reference: stampedRef("RD") }); - bookContainers({ + bookAndClear({ suffix: "RD", runStamp: stamp, isoSeed: 18_500, forty: 40, scheduledDate: BOOKING_DAY_2, }); - acceptOperation("RD"); closeWindowAndRunBatch(DAY_2); (["RC", "RD"] as const).forEach((suffix) => { @@ -189,30 +188,27 @@ describe("G5·S23: a split's paid part survives the remainder expiring", { retri resetCorridorDay(DAY_1); configureAndOpenSchedule({ departure: DAY_1 }); - bookContainers({ + bookAndClear({ suffix: "SA", runStamp: stamp, isoSeed: 18_800, forty: SHAPES.SA.forty, scheduledDate: BOOKING_DAY_1, }); - acceptOperation("SA"); - bookContainers({ + bookAndClear({ suffix: "SB", runStamp: stamp, isoSeed: 18_900, twenty: SHAPES.SB.twenty, scheduledDate: BOOKING_DAY_1, }); - acceptOperation("SB"); - bookContainers({ + bookAndClear({ suffix: "SC", runStamp: stamp, isoSeed: 19_000, twenty: SHAPES.SC.twenty, scheduledDate: BOOKING_DAY_1, }); - acceptOperation("SC"); (["SA", "SB", "SC"] as const).forEach((suffix, i) => setPriority(suffix, i + 1)); closeWindowAndRunBatch(DAY_1); @@ -316,7 +312,7 @@ describe("G5·S24: the waiting list is walked, never skipped silently", { retrie let isoSeed = 19_500; ORDER.forEach((suffix) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -324,7 +320,6 @@ describe("G5·S24: the waiting list is walked, never skipped silently", { retrie scheduledDate: BOOKING_DAY, }); isoSeed += SHAPES[suffix].forty; - acceptOperation(suffix); }); ORDER.forEach((suffix, i) => setPriority(suffix, i + 1)); diff --git a/e2e/freight/cypress/e2e/flows/g6_corridor.cy.ts b/e2e/freight/cypress/e2e/flows/g6_corridor.cy.ts index ebcc0ebbf..74e3b3a4f 100644 --- a/e2e/freight/cypress/e2e/flows/g6_corridor.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g6_corridor.cy.ts @@ -58,6 +58,7 @@ import { type ScheduleRow, } from "./import-utils"; import { + bookAndClear, closeWindowAndRunBatch, configureAndOpenSchedule, } from "./g1-utils"; @@ -125,7 +126,7 @@ describe("G6·S25: three bookings alight at three stations", { retries: 0 }, () let isoSeed = 20_100; ORDER.forEach((suffix) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -133,7 +134,6 @@ describe("G6·S25: three bookings alight at three stations", { retries: 0 }, () scheduledDate: BOOKING_DAY, }); isoSeed += SHAPES[suffix].forty; - acceptOperation(suffix); }); closeWindowAndRunBatch(DEPARTURE); @@ -302,14 +302,13 @@ describe("G6·S27: a paid booking with no schedule is not on rails yet", { retri resetCorridorDay(DEPARTURE); configureAndOpenSchedule({ departure: DEPARTURE }); - bookContainers({ + bookAndClear({ suffix: "TR", runStamp: stamp, isoSeed: 21_100, forty: 5, scheduledDate: BOOKING_DAY, }); - acceptOperation("TR"); // Accepted into the pool but not yet through the batch: no train. withBooking("TR", (b) => { @@ -368,14 +367,13 @@ describe("G6·S28: an early alighter is ARRIVED while the train runs on", { retr configureAndOpenSchedule({ departure: DEPARTURE }); (["EA1", "EA2"] as const).forEach((suffix, i) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed: 21_400 + i * 100, forty: 10, scheduledDate: BOOKING_DAY, }); - acceptOperation(suffix); }); closeWindowAndRunBatch(DEPARTURE); (["EA1", "EA2"] as const).forEach((suffix) => { @@ -445,14 +443,13 @@ describe("G6·S29: out-of-order checkpoint handling", { retries: 0 }, () => { ensureCorridorRoute(); resetCorridorDay(DEPARTURE); configureAndOpenSchedule({ departure: DEPARTURE }); - bookContainers({ + bookAndClear({ suffix: "OO", runStamp: stamp, isoSeed: 21_800, forty: 6, scheduledDate: BOOKING_DAY, }); - acceptOperation("OO"); closeWindowAndRunBatch(DEPARTURE); markPaid("OO"); pollAllocations("OO", 6); diff --git a/e2e/freight/cypress/e2e/flows/g7_disruptions.cy.ts b/e2e/freight/cypress/e2e/flows/g7_disruptions.cy.ts index 7cb525b3a..6f1526274 100644 --- a/e2e/freight/cypress/e2e/flows/g7_disruptions.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g7_disruptions.cy.ts @@ -38,6 +38,7 @@ import { import { G1_TRAIN_2, G1_WAGONS, + bookAndClear, closeWindowAndRunBatch, configureAndOpenSchedule, expectVerdict, @@ -92,7 +93,7 @@ describe("G7·S30: a cancelled train freezes a snapshot of what it was", { retri let isoSeed = 22_100; ORDER.forEach((suffix) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -100,7 +101,6 @@ describe("G7·S30: a cancelled train freezes a snapshot of what it was", { retri scheduledDate: BOOKING_DAY_1, }); isoSeed += SHAPES[suffix].forty; - acceptOperation(suffix); }); closeWindowAndRunBatch(DAY_1); ORDER.forEach((suffix) => { @@ -220,14 +220,13 @@ describe("G7·S31: a wagon shortage is filled by transfer request", { retries: 0 configureAndOpenSchedule({ departure: DEPARTURE }); (["YA", "YB"] as const).forEach((suffix, i) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed: 22_600 + i * 100, forty: 10, scheduledDate: BOOKING_DAY, }); - acceptOperation(suffix); }); closeWindowAndRunBatch(DEPARTURE); (["YA", "YB"] as const).forEach((suffix) => { @@ -316,14 +315,13 @@ describe("G7·S32: a failed wagon is replaced and the train stays full", { retri ensureCorridorRoute(); resetCorridorDay(DEPARTURE); configureAndOpenSchedule({ departure: DEPARTURE }); - bookContainers({ + bookAndClear({ suffix: "MW", runStamp: stamp, isoSeed: 23_100, forty: 12, scheduledDate: BOOKING_DAY, }); - acceptOperation("MW"); closeWindowAndRunBatch(DEPARTURE); markPaid("MW"); pollAllocations("MW", 12); @@ -403,14 +401,13 @@ describe("G7·S33: an under-filled train dispatches anyway", { retries: 0 }, () configureAndOpenSchedule({ departure: DEPARTURE }); (["UA", "UB"] as const).forEach((suffix, i) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed: 23_400 + i * 100, forty: SHAPES[suffix].forty, scheduledDate: BOOKING_DAY, }); - acceptOperation(suffix); }); closeWindowAndRunBatch(DEPARTURE); (["UA", "UB"] as const).forEach((suffix) => { diff --git a/e2e/freight/cypress/e2e/flows/g8_import_customs.cy.ts b/e2e/freight/cypress/e2e/flows/g8_import_customs.cy.ts index 836196a66..9875b0f6a 100644 --- a/e2e/freight/cypress/e2e/flows/g8_import_customs.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g8_import_customs.cy.ts @@ -48,7 +48,11 @@ import { superAdmin, withBooking, } from "./import-utils"; -import { closeWindowAndRunBatch, configureAndOpenSchedule } from "./g1-utils"; +import { + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "./g1-utils"; const stamp = String(Date.now()); const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; @@ -74,14 +78,13 @@ function arrangeCustomsBooking(opts: { ensureCorridorRoute(); resetCorridorDay(opts.departure); configureAndOpenSchedule({ departure: opts.departure }); - bookContainers({ + bookAndClear({ suffix: opts.suffix, runStamp: stamp, isoSeed: opts.isoSeed, forty: opts.forty ?? 6, scheduledDate: bookingDay, }); - acceptOperation(opts.suffix); closeWindowAndRunBatch(opts.departure); markPaid(opts.suffix); pollAllocations(opts.suffix, opts.forty ?? 6); diff --git a/e2e/freight/cypress/e2e/flows/g9_delivery.cy.ts b/e2e/freight/cypress/e2e/flows/g9_delivery.cy.ts index 7a0a283cb..352c51f7c 100644 --- a/e2e/freight/cypress/e2e/flows/g9_delivery.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g9_delivery.cy.ts @@ -40,7 +40,11 @@ import { withBooking, withSchedule, } from "./import-utils"; -import { closeWindowAndRunBatch, configureAndOpenSchedule } from "./g1-utils"; +import { + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "./g1-utils"; const stamp = String(Date.now()); const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; @@ -114,7 +118,7 @@ describe("G9·S38: customers collect with their own trucks", { retries: 0 }, () let isoSeed = 26_100; ORDER.forEach((suffix) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -122,7 +126,6 @@ describe("G9·S38: customers collect with their own trucks", { retries: 0 }, () scheduledDate: BOOKING_DAY, }); isoSeed += SHAPES[suffix].twenty; - acceptOperation(suffix); }); closeWindowAndRunBatch(DEPARTURE); ORDER.forEach((suffix) => { @@ -273,7 +276,7 @@ describe("G9·S39: last-mile, self-haul and yard pickup on one train", { retries let isoSeed = 26_600; ORDER.forEach((suffix) => { - bookContainers({ + bookAndClear({ suffix, runStamp: stamp, isoSeed, @@ -281,7 +284,6 @@ describe("G9·S39: last-mile, self-haul and yard pickup on one train", { retries scheduledDate: BOOKING_DAY, }); isoSeed += SHAPES[suffix].twenty; - acceptOperation(suffix); }); closeWindowAndRunBatch(DEPARTURE); ORDER.forEach((suffix) => { diff --git a/e2e/freight/cypress/e2e/flows/import-utils.ts b/e2e/freight/cypress/e2e/flows/import-utils.ts index ac81d6b6f..487307fd2 100644 --- a/e2e/freight/cypress/e2e/flows/import-utils.ts +++ b/e2e/freight/cypress/e2e/flows/import-utils.ts @@ -725,7 +725,16 @@ export function settleViaGateway(suffix: string) { export function forceReservationExpiry(suffix: string) { withBooking(suffix, (b) => db( - `UPDATE freight.bookings SET payment_deadline = now() - interval '1 second' + // A full HOUR into the past, not one second. + // + // The sweep races the top-up it triggers: promoting a waiting booking + // calls extendPaymentPhaseForTopUp (booking-batch.service.ts:2795), + // which pushes paymentPhaseEndsAt out — and a deadline only just behind + // `now()` can end up on the wrong side of the moved boundary, leaving + // the reservation un-flipped while its wagons have already been handed + // to the waiting list. An hour is unambiguously overdue under any + // extension the top-up applies. + `UPDATE freight.bookings SET payment_deadline = now() - interval '1 hour' WHERE id = $1`, [b.id], ),