Files
edr-platform/integration/src/bulk-import-matrix.it.ts
2026-08-03 10:51:43 +00:00

225 lines
7.9 KiB
TypeScript

/**
* BULK IMPORT edge matrix:
* a) a wheat booking on a day with no open window is rejected at submission
* b) a sub-corridor booking (NAGAD → MOJO) rides the through-train next to a
* DJIB_PORT → KALITY booking — the batch is corridor-aware
* c) bulk intercity ride-along (MOJO → KALITY, DOMESTIC, dateless): staff
* assign it onto the import train's free leg, the pay window opens, it
* pays and links
* d) whole-train giant: 4 000 T (58 wagons' worth) alone on a 54-wagon train
* → partial offer of the FULL consist (3 780 T); the gateway settlement
* applies the split and the train is FULL from ONE booking; the 220 T
* outstanding must be rebooked EXACTLY on a later train
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, db, gateway, poll } from "./client";
import {
acceptIntercityOnto,
acceptOperation,
bookBulk,
bookBulkReady,
expectDayRefused,
bookingFor,
bookingRow,
clearIntercityBooking,
closeBookingWindow,
completeDocReview,
createSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
extendPayWindow,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollPartialOffer,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
seedTenantContracts,
} from "./flows";
const DEPARTURE = departureAt(25);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const GIANT_DEPARTURE = departureAt(26);
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
const REMAINDER_DEPARTURE = departureAt(27);
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
const NO_WINDOW_DAY = eatDayStr(departureAt(29)); // no schedule exists there
const STAMP = String(Date.now());
describe("bulk import matrix: gates, sub-corridor, ride-along, whole-train giant", () => {
let contracts: Map<string, string>;
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
await resetCorridorDay(GIANT_DEPARTURE);
await resetCorridorDay(REMAINDER_DEPARTURE);
contracts = await seedTenantContracts(STAMP, [
{ suffix: "BM1", freight: "BULK" },
{ suffix: "BMNW", freight: "BULK" },
{ suffix: "BMSUB", freight: "BULK", originCode: "NAGAD", destCode: "MOJO" },
{ suffix: "BMIC", freight: "BULK", direction: "DOMESTIC", originCode: "MOJO", destCode: "KALITY" },
{ suffix: "BMG", freight: "BULK" },
]);
scheduleId = (
await createSchedule({
departure: DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-25", "LOCO-IMP-26"],
})
).id;
await forceWindowOpen(scheduleId, 60);
}, 1_800_000);
afterAll(closeDb);
it("refuses a shipment day with no open window", async () => {
const res = await expectDayRefused({
contractId: contracts.get("BMNW")!,
tons: 140,
scheduledDate: NO_WINDOW_DAY,
});
expect(res.status).toBeGreaterThanOrEqual(400);
// Wording differs by gate: the day probe answers "No departures available
// on the selected day", the route probe "the import booking window …".
expect(JSON.stringify(res.body)).toMatch(/booking window|no departures/i);
});
it("a through booking and a NAGAD→MOJO sub-corridor booking share the train", async () => {
const bm1 = await bookBulkReady({
contractId: contracts.get("BM1")!,
tons: 1400,
scheduledDate: BOOKING_DAY,
});
const sub = await bookBulkReady({
contractId: contracts.get("BMSUB")!,
tons: 700,
scheduledDate: BOOKING_DAY,
});
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const id of [bm1, sub]) {
await pollBookingStatus(id, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
}
await extendPayWindow(scheduleId, [bm1, sub]);
await payViaGateway(bm1);
await pollAllocations(bm1, 20);
await payViaGateway(sub);
await pollAllocations(sub, 10);
expect((await bookingRow(bm1)).train_schedule_id).toBe(scheduleId);
expect((await bookingRow(sub)).train_schedule_id).toBe(scheduleId);
});
it("a dateless DOMESTIC ride-along boards the import train's free leg", async () => {
// 140 T = 2 wagons MOJO → KALITY, no shipment day of its own.
const res = await bookBulk({ contractId: contracts.get("BMIC")!, tons: 140 });
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
const ic = (await bookingFor(contracts.get("BMIC")!)).id;
await clearIntercityBooking(ic);
await acceptIntercityOnto(scheduleId, ic);
await pollBookingStatus(ic, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
expect((await bookingRow(ic)).payment_deadline, "ride-along pay window opened").toBeTruthy();
await payViaGateway(ic);
// Settlement UNPINS a paid ride-along back to the ride-along pool — staff
// then place it on the train they want (acceptIntercity's `paid` branch,
// booking-batch.service.ts). The Cypress twin never sees this: its staff
// mark-paid shortcut leaves the reservation pinned.
expect(
(await bookingRow(ic)).train_schedule_id,
"paid ride-along returns to the pool",
).toBeNull();
await acceptIntercityOnto(scheduleId, ic);
await poll(
"ride-along linked to the import train",
`SELECT train_schedule_id FROM freight.bookings WHERE id = $1`,
[ic],
(row) => (row as { train_schedule_id?: string })?.train_schedule_id === scheduleId,
{ attempts: 20 },
);
const [link] = await db<{ n: string }>(
`SELECT count(*)::text AS n FROM freight.train_schedule_bookings
WHERE booking_id = $1 AND train_schedule_id = $2 AND deleted_at IS NULL`,
[ic, scheduleId],
);
expect(Number(link.n), "link row").toBe(1);
});
it("a 4 000 T giant alone gets a FULL-consist partial offer and fills the train", async () => {
const giantSchedule = await createSchedule({
departure: GIANT_DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-27", "LOCO-IMP-28"],
});
await forceWindowOpen(giantSchedule.id, 45);
const bmg = await bookBulkReady({
contractId: contracts.get("BMG")!,
tons: 4000,
scheduledDate: GIANT_DAY,
});
await closeBookingWindow(giantSchedule.id);
await completeDocReview(giantSchedule.id);
await pollBookingStatus(bmg, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
const offer = await pollPartialOffer(bmg);
expect(Number(offer.offered_wagons), "offered the whole consist").toBe(54);
await payViaGateway(bmg);
await pollAllocations(bmg, 54);
expect((await bookingRow(bmg)).is_split, "BMG is split").toBe(true);
await endPaymentPhase(giantSchedule.id);
await pollWindow(
giantSchedule.id,
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
"FULL from one booking",
);
});
it("the giant's 220 T outstanding must be rebooked EXACTLY on a later train", async () => {
const remainder = await createSchedule({
departure: REMAINDER_DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"],
});
await forceWindowOpen(remainder.id, 45);
const wrong = await bookBulk({
contractId: contracts.get("BMG")!,
tons: 100,
scheduledDate: REMAINDER_DAY,
});
expect(wrong.status).toBeGreaterThanOrEqual(400);
expect(JSON.stringify(wrong.body)).toMatch(/must take the whole/i);
const exact = await bookBulk({
contractId: contracts.get("BMG")!,
tons: 220,
scheduledDate: REMAINDER_DAY,
});
expect(exact.status, JSON.stringify(exact.body)).toBeLessThanOrEqual(201);
await acceptOperationIfPossible(contracts.get("BMG")!);
});
});
/** The remainder booking only needs to exist; walking its gate is out of scope. */
async function acceptOperationIfPossible(contractId: string) {
const booking = await bookingFor(contractId);
if (booking.status === "OPERATION_REQUEST_PENDING") await acceptOperation(booking.id);
}