/** * 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 = { 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(); let contracts: Map; 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); }); });