Files
edr-platform/integration/src/g1-s4-split-closes-gap.it.ts
2026-08-03 10:51:43 +00:00

173 lines
6.1 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.

/**
* 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<string, string>();
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<string, number> } | 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);
});
});