mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
196 lines
6.7 KiB
TypeScript
196 lines
6.7 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|