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

196 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* BULK IMPORT — split offer, exact-remainder rebooking, pay-window expiry and
* priority-ordered waiting-list promotion on one 54-wagon CW4 train. Bulk
* splits are FULL-WAGONS-ONLY at the base 70 T cap.
*
* reserved (priority order): BSA 1 400 T = 20 w, BSB 980 T = 14 w,
* BSD 840 T = 12 w → 46 w. BSC 1 680 T = 24 w does NOT fit whole → PARTIAL
* offer of the remaining 8 wagons = 560 T. BSC settles through the payment
* service → the split applies (is_split + pre-split snapshot); the
* outstanding 1 120 T must later be rebooked EXACTLY.
* BSD never pays → EXPIRES; the freed 12 wagons promote BS1 (6 w) and
* BS2 (6 w) in priority order; BS3 (20 w) never fits and expires.
*
* Final consist: 20 + 14 + 8 + 6 + 6 = 54/54.
*
* The split is the reason this file pays through the gateway rather than the
* staff shortcut: applying a pending partial offer hangs off
* `booking.invoice.paid`, which only a real settlement emits.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, gateway } from "./client";
import {
allocatedWagons,
bookBulk,
bookBulkReady,
bookingRow,
closeBookingWindow,
completeDocReview,
createSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
extendPayWindow,
ensureCorridorRoute,
forceReservationExpiry,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollPartialOffer,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
seedTenantContracts,
setPriority,
} from "./flows";
const DEPARTURE = departureAt(23);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const REMAINDER_DEPARTURE = departureAt(24);
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
const STAMP = String(Date.now());
const ORDER = ["BSA", "BSB", "BSD", "BSC", "BS1", "BS2", "BS3"] as const;
const TONS: Record<string, number> = {
BSA: 1400,
BSB: 980,
BSD: 840,
BSC: 1680,
BS1: 420,
BS2: 420,
BS3: 1400,
};
describe("bulk import: split offer, remainder rebooking, expiry + promotion", () => {
const booking = new Map<string, string>();
let contracts: Map<string, string>;
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
await resetCorridorDay(REMAINDER_DEPARTURE);
contracts = await seedTenantContracts(
STAMP,
ORDER.map((suffix) => ({ suffix, freight: "BULK" as const })),
);
scheduleId = (
await createSchedule({
departure: DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-19", "LOCO-IMP-20"],
})
).id;
await forceWindowOpen(scheduleId, 45);
for (const [i, suffix] of ORDER.entries()) {
const id = await bookBulkReady({
contractId: contracts.get(suffix)!,
tons: TONS[suffix],
scheduledDate: BOOKING_DAY,
});
booking.set(suffix, id);
await setPriority(id, i + 1);
}
}, 1_800_000);
afterAll(closeDb);
it("reserves BSA/BSB/BSD whole and offers BSC a PARTIAL for the last 8 wagons", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of ["BSA", "BSB", "BSD", "BSC"]) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
}
const offer = await pollPartialOffer(booking.get("BSC")!);
expect(Number(offer.offered_wagons), "BSC offered the remaining 8 wagons").toBe(8);
for (const suffix of ["BS1", "BS2", "BS3"]) {
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} waiting`).toBe(
"FULLY_EXECUTED",
);
}
});
it("BSA and BSB pay; BSC settles through the gateway — the split applies", async () => {
// Three sequential gateway settlements outlast the ~60s hold; BSD is left
// alone because the next test is about its expiry.
await extendPayWindow(scheduleId, [booking.get("BSA")!, booking.get("BSB")!, booking.get("BSC")!]);
await payViaGateway(booking.get("BSA")!);
await pollAllocations(booking.get("BSA")!, 20);
await payViaGateway(booking.get("BSB")!);
await pollAllocations(booking.get("BSB")!, 14);
await payViaGateway(booking.get("BSC")!);
await pollAllocations(booking.get("BSC")!, 8);
const bsc = await bookingRow(booking.get("BSC")!);
expect(bsc.is_split, "BSC is split").toBe(true);
expect(bsc.pre_split_quantities, "pre-split snapshot kept").toBeTruthy();
});
it("BSD misses its pay window — the freed wagons promote BS1 + BS2 in priority order", async () => {
await forceReservationExpiry(booking.get("BSD")!);
for (const suffix of ["BS1", "BS2"]) {
const row = await pollBookingStatus(booking.get(suffix)!, [
"SELECTED_FOR_BATCH",
"AWAITING_PAYMENT",
]);
expect(row.status, `${suffix} promoted`).not.toBe("FULLY_EXECUTED");
expect(
(await bookingRow(booking.get(suffix)!)).payment_deadline,
`${suffix} got a pay window`,
).toBeTruthy();
}
expect((await bookingRow(booking.get("BS3")!)).status, "BS3 still has no seat").toBe(
"FULLY_EXECUTED",
);
});
it("BS1 and BS2 pay — the train is FULL at 54; BS3 expires with the day", async () => {
await extendPayWindow(scheduleId, [booking.get("BS1")!, booking.get("BS2")!]);
await payViaGateway(booking.get("BS1")!);
await pollAllocations(booking.get("BS1")!, 6);
await payViaGateway(booking.get("BS2")!);
await pollAllocations(booking.get("BS2")!, 6);
await endPaymentPhase(scheduleId);
await pollWindow(
scheduleId,
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
"FULL + DONE",
);
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
await pollBookingStatus(booking.get("BS3")!, "EXPIRED", 40);
});
it("the split customer must rebook EXACTLY the 1 120 T remainder", async () => {
const remainderSchedule = await createSchedule({
departure: REMAINDER_DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-21", "LOCO-IMP-22"],
});
await forceWindowOpen(remainderSchedule.id, 45);
// 1 680 booked 560 shipped by the split = 1 120 T outstanding.
const wrong = await bookBulk({
contractId: contracts.get("BSC")!,
tons: 560,
scheduledDate: REMAINDER_DAY,
});
expect(wrong.status, "partial remainder rejected").toBeGreaterThanOrEqual(400);
expect(JSON.stringify(wrong.body)).toMatch(/must take the whole/i);
const exact = await bookBulk({
contractId: contracts.get("BSC")!,
tons: 1120,
scheduledDate: REMAINDER_DAY,
});
expect(exact.status, JSON.stringify(exact.body)).toBeLessThanOrEqual(201);
});
});