/** * GROUP 1 · S4 — an over-subscribed day closes its last gap with a split. * * A 30×40ft = 30 wagons * B 40×20ft = 20 wagons * C 20×20ft = 10 wagons * ───────── * 60 wagons of demand for 53 slots * * The batch takes A and B whole — 50 used, 3 left. C needs 10 and cannot fit, * so rather than being skipped it is OFFERED the 3 remaining wagons (6×20ft). * C pays the offer THROUGH THE REAL PAYMENT PATH and the split applies: only a * settled `booking.invoice.paid` applies a pending offer, so the staff * mark-paid shortcut would allocate C whole and quietly defeat the scenario. * * What the split leaves behind is the other half of the case: * - `is_split` set and `pre_split_quantities` snapshotting the ORIGINAL 20; * - the booking itself reduced to the offered 6 containers; * - a 14×20ft remainder the customer rolls to a later window. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { closeDb, gateway } from "./client"; import { G1_WAGONS, allocatedWagons, bookContainersReady, bookingRow, closeBookingWindow, completeDocReview, containerCount, containerWagons, createBuiltTrainSchedule, departureAt, eatDayStr, endPaymentPhase, ensureCorridorRoute, expectNoPartialOffer, extendPayWindow, forceWindowOpen, payViaGateway, pollAllocations, pollBookingStatus, pollPartialOffer, pollWindow, releaseUnpaidHolds, resetCorridorDay, seedTenantContracts, setPriority, } from "./flows"; const DEPARTURE = departureAt(53); const BOOKING_DAY = eatDayStr(DEPARTURE); const STAMP = String(Date.now()); const SHAPES = { A: { twenty: 0, forty: 30, wagons: 30 }, B: { twenty: 40, forty: 0, wagons: 20 }, C: { twenty: 20, forty: 0, wagons: 10 }, } as const; const ORDER = ["A", "B", "C"] as const; /** A + B take 50 of 53; the gap is what C is offered. */ const GAP_WAGONS = G1_WAGONS - SHAPES.A.wagons - SHAPES.B.wagons; // 3 const OFFERED_CONTAINERS = GAP_WAGONS * 2; // 6 × 20ft const REMAINDER_CONTAINERS = SHAPES.C.twenty - OFFERED_CONTAINERS; // 14 describe("g1 s4: a split closes the last 3-wagon gap", () => { const booking = new Map(); let scheduleId: string; beforeAll(async () => { await gateway.reset(); await releaseUnpaidHolds(); await ensureCorridorRoute(); await resetCorridorDay(DEPARTURE); const contracts = await seedTenantContracts( STAMP, ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })), ); scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id; await forceWindowOpen(scheduleId, 60); let isoSeed = 23_000; for (const [i, suffix] of ORDER.entries()) { const shape = SHAPES[suffix]; booking.set( suffix, await bookContainersReady({ contractId: contracts.get(suffix)!, runStamp: STAMP, isoSeed, twenty: shape.twenty, forty: shape.forty, scheduledDate: BOOKING_DAY, }), ); isoSeed += shape.twenty + shape.forty; // Priority decides who gets a whole seat and who gets the offer. await setPriority(booking.get(suffix)!, i + 1); } }, 1_800_000); afterAll(closeDb); it("demand exceeds the train by 7 wagons, leaving a 3-wagon gap after A and B", () => { for (const suffix of ORDER) { expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe( SHAPES[suffix].wagons, ); } expect( ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0), "60 wagons of demand", ).toBe(60); expect(GAP_WAGONS, "3-wagon gap after A+B").toBe(3); expect(SHAPES.C.wagons, "C cannot fit whole").toBeGreaterThan(GAP_WAGONS); }); it("the batch takes A and B whole and offers C exactly the 3 remaining wagons", async () => { await closeBookingWindow(scheduleId); await completeDocReview(scheduleId); for (const suffix of ["A", "B"] as const) { await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); await expectNoPartialOffer(booking.get(suffix)!, suffix); } const offer = await pollPartialOffer(booking.get("C")!); expect(Number(offer.offered_wagons), "offer sized to the gap").toBe(GAP_WAGONS); }); it("A and B pay whole; C settles its partial and the split applies", async () => { await extendPayWindow(scheduleId, [...booking.values()]); await payViaGateway(booking.get("A")!); await pollAllocations(booking.get("A")!, SHAPES.A.wagons); await payViaGateway(booking.get("B")!); await pollAllocations(booking.get("B")!, SHAPES.B.wagons); await payViaGateway(booking.get("C")!); await pollAllocations(booking.get("C")!, GAP_WAGONS); }); it("C is flagged split, snapshotted at 20×20ft, and reduced to the offered 6", async () => { const c = await bookingRow(booking.get("C")!); expect(c.is_split, "C is split").toBe(true); const snapshot = c.pre_split_quantities as { bySize?: Record } | null; expect(snapshot, "pre-split snapshot kept").toBeTruthy(); // The remainder is later measured against this snapshot, so the ORIGINAL // quantity has to survive in it — not the reduced one. expect( Number(snapshot?.bySize?.["20FT"] ?? snapshot?.bySize?.["20ft"]), "snapshot holds the original 20 × 20ft", ).toBe(SHAPES.C.twenty); expect(await containerCount(booking.get("C")!), `C shrank to ${OFFERED_CONTAINERS} boxes`).toBe( OFFERED_CONTAINERS, ); }); it("the train is FULL at 53/53 and C's 14-container remainder is outstanding", async () => { await endPaymentPhase(scheduleId); await pollWindow( scheduleId, (s) => s.booking_window_status === "FULL" && s.window_phase === "DONE", "FULL + DONE", ); expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS); // 20 booked − 6 shipped = 14 still owed; the engine holds the customer to // rebooking exactly that (asserted for bulk in bulk-import-split-promote). expect(REMAINDER_CONTAINERS, "14 × 20ft outstanding").toBe(14); }); });