mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 09:42:53 +00:00
173 lines
6.1 KiB
TypeScript
173 lines
6.1 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|