Refactor booking process to use bookAndClear utility

- Replaced instances of bookContainers with bookAndClear across multiple test files to streamline booking and acceptance process.
- Updated import statements to include bookAndClear where necessary.
- Removed redundant acceptOperation calls after booking, as bookAndClear handles this internally.
- Adjusted comments and documentation to reflect changes in booking logic.
- Modified forceReservationExpiry function to ensure payment deadlines are set correctly, preventing issues with booking promotions.
This commit is contained in:
Marshal
2026-08-01 18:42:45 +00:00
parent 20bb2f274a
commit 8ccb0e1558
18 changed files with 270 additions and 154 deletions

View File

@@ -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
* `<input type="datetime-local">` 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<ScheduleRow>(
@@ -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 });
}

View File

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

View File

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

View File

@@ -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", () => {

View File

@@ -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", () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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