mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 12:28:21 +00:00
- Replaced instances of bookContainers with bookAndClear across multiple test files to streamline booking and acceptance process. - Updated import statements to include bookAndClear where necessary. - Removed redundant acceptOperation calls after booking, as bookAndClear handles this internally. - Adjusted comments and documentation to reflect changes in booking logic. - Modified forceReservationExpiry function to ensure payment deadlines are set correctly, preventing issues with booking promotions.
327 lines
13 KiB
TypeScript
327 lines
13 KiB
TypeScript
/**
|
||
* GROUP 4 · S19–S21 — two trains on one import day.
|
||
*
|
||
* The import batch fills a route-DAY, not a single schedule: `topUpFill`
|
||
* re-runs the whole day pool (booking-batch.service.ts:2277, and see the
|
||
* comment at :2284 explaining why a schedule-scoped query was the bug). So a
|
||
* day with two departures is one pool with two boards, and these scenarios
|
||
* pin down how demand lands across them.
|
||
*
|
||
* S19 an oversized booking is split ACROSS the two trains (import may do
|
||
* what export cannot — see Group 3)
|
||
* S20 the batch prefers whole placements over forcing a split when a second
|
||
* train exists
|
||
* S21 the fill-first-train policy: T1 is closed out with a split before T2
|
||
* is opened up
|
||
*
|
||
* ── S20 vs S21 ARE COMPETING POLICIES ──────────────────────────────────────
|
||
*
|
||
* The original scenario document poses them as alternatives and says "pick
|
||
* one, the test asserts it". They cannot both be true of the same engine:
|
||
* S20 says a 10-wagon booking facing a 3-wagon gap on T1 goes WHOLE to T2;
|
||
* S21 says the same booking is split 3 + 7 to close T1 first.
|
||
*
|
||
* The engine's actual rule is visible in the batch loop: a booking is placed
|
||
* whole when ANY open train can take it whole, and `maybeOfferPartial` is
|
||
* reached only when `!target` — i.e. when NO train fits it
|
||
* (booking-batch.service.ts:2473-2496). That is S20. S21's fill-first policy
|
||
* is therefore NOT implemented, and its test is written `.skip` documenting
|
||
* the difference rather than asserting a behaviour that does not exist.
|
||
*
|
||
* Both trains are 53-wagon built consists (TRN-G1-1, TRN-G1-2) so the two
|
||
* boards are directly comparable.
|
||
*
|
||
* Sequential steps per scenario — retries off.
|
||
*/
|
||
|
||
import {
|
||
acceptOperation,
|
||
bookContainers,
|
||
db,
|
||
departureAt,
|
||
eatDayStr,
|
||
endPaymentPhase,
|
||
ensureCorridorRoute,
|
||
markPaid,
|
||
pollAllocations,
|
||
pollBookingStatus,
|
||
resetCorridorDay,
|
||
seedImportContract,
|
||
setPriority,
|
||
withBooking,
|
||
withSchedule,
|
||
} from "./import-utils";
|
||
import {
|
||
G1_TRAIN_2,
|
||
G1_WAGONS,
|
||
bookAndClear,
|
||
closeWindowAndRunBatch,
|
||
configureAndOpenSchedule,
|
||
expectVerdict,
|
||
wagonsFor,
|
||
} from "./g1-utils";
|
||
|
||
const stamp = String(Date.now());
|
||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||
|
||
/**
|
||
* Wagons allocated to one booking on one specific schedule.
|
||
*
|
||
* The schedule → consist link is `train_schedules.train_set_id` (a schedule
|
||
* points AT its set; `train_sets` has no back-reference), so every query here
|
||
* joins in that direction.
|
||
*/
|
||
function wagonsOnSchedule(suffix: string, scheduleId: string) {
|
||
return db<{ n: string }>(
|
||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||
FROM freight.wagon_booking_allocations wba
|
||
JOIN freight.bookings b ON b.id = wba.booking_id
|
||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
||
AND ts.id = $2
|
||
AND wba.deleted_at IS NULL AND b.deleted_at IS NULL
|
||
AND ts.deleted_at IS NULL`,
|
||
[suffix, scheduleId],
|
||
).then(({ rows }) => Number(rows[0].n));
|
||
}
|
||
|
||
/** Distinct schedules a booking's wagons sit on — >1 means it spans trains. */
|
||
function schedulesFor(suffix: string) {
|
||
return db<{ id: string }>(
|
||
`SELECT DISTINCT ts.id
|
||
FROM freight.wagon_booking_allocations wba
|
||
JOIN freight.bookings b ON b.id = wba.booking_id
|
||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
||
AND wba.deleted_at IS NULL AND b.deleted_at IS NULL
|
||
AND ts.deleted_at IS NULL`,
|
||
[suffix],
|
||
).then(({ rows }) => rows.map((r) => r.id));
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// S19 — an oversized import booking spans both trains
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("G4·S19: an import booking splits across two trains", { retries: 0 }, () => {
|
||
const T1 = departureAt(31);
|
||
const T2 = new Date(T1.getTime() + 3 * 3_600_000);
|
||
const BOOKING_DAY = eatDayStr(T1);
|
||
const SHAPES = {
|
||
MA: { forty: 60, wagons: 60 },
|
||
MB: { forty: 10, wagons: 10 },
|
||
MC: { forty: 5, wagons: 5 },
|
||
} as const;
|
||
const ORDER = ["MA", "MB", "MC"] as const;
|
||
|
||
before(() => {
|
||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||
ORDER.forEach((suffix) =>
|
||
seedImportContract({ suffix, reference: stampedRef(suffix) }),
|
||
);
|
||
});
|
||
|
||
it("MA is bigger than either train but fits the day's combined capacity", () => {
|
||
expect(SHAPES.MA.wagons, "60 > one train").to.be.greaterThan(G1_WAGONS);
|
||
expect(SHAPES.MA.wagons, "60 < two trains").to.be.lessThan(G1_WAGONS * 2);
|
||
expect(eatDayStr(T2), "both trains run the same EAT day").to.eq(BOOKING_DAY);
|
||
});
|
||
|
||
it("the day runs two 53-wagon built trains", () => {
|
||
ensureCorridorRoute();
|
||
resetCorridorDay(T1);
|
||
resetCorridorDay(T2);
|
||
configureAndOpenSchedule({ departure: T1 });
|
||
configureAndOpenSchedule({ departure: T2, trainCode: G1_TRAIN_2 });
|
||
});
|
||
|
||
it("three bookings arrive, MA first in priority", () => {
|
||
let isoSeed = 17_100;
|
||
ORDER.forEach((suffix) => {
|
||
bookAndClear({
|
||
suffix,
|
||
runStamp: stamp,
|
||
isoSeed,
|
||
forty: SHAPES[suffix].forty,
|
||
scheduledDate: BOOKING_DAY,
|
||
});
|
||
isoSeed += SHAPES[suffix].forty;
|
||
});
|
||
ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
|
||
});
|
||
|
||
it("MA is placed across BOTH trains — import may span, unlike export", () => {
|
||
closeWindowAndRunBatch(T1);
|
||
ORDER.forEach((suffix) =>
|
||
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
|
||
);
|
||
ORDER.forEach((suffix) => markPaid(suffix));
|
||
ORDER.forEach((suffix) => pollAllocations(suffix, 1));
|
||
|
||
// The distinguishing assertion: MA's wagons sit on two different schedules.
|
||
schedulesFor("MA").then((ids) => {
|
||
expect(ids.length, "MA spans two trains").to.eq(2);
|
||
});
|
||
withSchedule(T1, (s1) =>
|
||
wagonsOnSchedule("MA", s1.id).then((n) =>
|
||
expect(n, "MA fills T1 completely").to.eq(G1_WAGONS),
|
||
),
|
||
);
|
||
});
|
||
|
||
it("T1 departs FULL with MA alone; T2 carries the remainder plus MB and MC", () => {
|
||
withSchedule(T1, (s) => endPaymentPhase(s.id));
|
||
expectVerdict(T1, { wagons: G1_WAGONS, full: true });
|
||
|
||
// MA's overflow (7) + MB (10) + MC (5) = 22 on the second train.
|
||
const overflow = SHAPES.MA.wagons - G1_WAGONS; // 7
|
||
const onT2 = overflow + SHAPES.MB.wagons + SHAPES.MC.wagons; // 22
|
||
expect(onT2, "22 wagons on T2").to.eq(22);
|
||
expectVerdict(T2, { wagons: onT2, full: false });
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// S20 — whole placements are preferred while a second train has room
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("G4·S20: the batch prefers a whole placement over a forced split", { retries: 0 }, () => {
|
||
const T1 = departureAt(32);
|
||
const T2 = new Date(T1.getTime() + 3 * 3_600_000);
|
||
const BOOKING_DAY = eatDayStr(T1);
|
||
const SHAPES = {
|
||
WA: { forty: 30, wagons: 30 },
|
||
WB: { forty: 30, wagons: 30 },
|
||
WC: { forty: 20, wagons: 20 },
|
||
WD: { forty: 10, wagons: 10 },
|
||
} as const;
|
||
const ORDER = ["WA", "WB", "WC", "WD"] as const;
|
||
|
||
before(() => {
|
||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||
ORDER.forEach((suffix) =>
|
||
seedImportContract({ suffix, reference: stampedRef(suffix) }),
|
||
);
|
||
});
|
||
|
||
it("90 wagons of demand across two 53-wagon trains", () => {
|
||
ORDER.forEach((s) =>
|
||
expect(wagonsFor(0, SHAPES[s].forty), `${s} wagons`).to.eq(SHAPES[s].wagons),
|
||
);
|
||
expect(
|
||
ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
|
||
"90 wagons of demand",
|
||
).to.eq(90);
|
||
});
|
||
|
||
it("both trains run and all four book", () => {
|
||
ensureCorridorRoute();
|
||
resetCorridorDay(T1);
|
||
resetCorridorDay(T2);
|
||
configureAndOpenSchedule({ departure: T1 });
|
||
configureAndOpenSchedule({ departure: T2, trainCode: G1_TRAIN_2 });
|
||
|
||
let isoSeed = 17_600;
|
||
ORDER.forEach((suffix) => {
|
||
bookAndClear({
|
||
suffix,
|
||
runStamp: stamp,
|
||
isoSeed,
|
||
forty: SHAPES[suffix].forty,
|
||
scheduledDate: BOOKING_DAY,
|
||
});
|
||
isoSeed += SHAPES[suffix].forty;
|
||
});
|
||
ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
|
||
closeWindowAndRunBatch(T1);
|
||
});
|
||
|
||
it("EVERY booking is placed WHOLE — none is split to close T1's gap", () => {
|
||
ORDER.forEach((suffix) =>
|
||
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
|
||
);
|
||
ORDER.forEach((suffix) => markPaid(suffix));
|
||
ORDER.forEach((suffix) => pollAllocations(suffix, SHAPES[suffix].wagons));
|
||
|
||
// The policy assertion: with a second train available, `maybeOfferPartial`
|
||
// is never reached, because a whole placement always exists.
|
||
ORDER.forEach((suffix) => {
|
||
withBooking(suffix, (b) =>
|
||
expect(b.is_split, `${suffix} placed whole`).to.eq(false),
|
||
);
|
||
schedulesFor(suffix).then((ids) =>
|
||
expect(ids.length, `${suffix} rides ONE train`).to.eq(1),
|
||
);
|
||
});
|
||
});
|
||
|
||
it("neither train ends FULL — whole placements leave gaps, and that is correct", () => {
|
||
withSchedule(T1, (s) => endPaymentPhase(s.id));
|
||
withSchedule(T2, (s) => endPaymentPhase(s.id));
|
||
|
||
// 90 wagons over two 53-slot trains cannot both be full; the engine
|
||
// trades slot efficiency for keeping bookings intact.
|
||
withSchedule(T1, (s1) =>
|
||
db<{ n: string }>(
|
||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||
FROM freight.wagon_booking_allocations wba
|
||
JOIN freight.train_schedule_bookings tsb
|
||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||
[s1.id],
|
||
).then(({ rows }) => {
|
||
const onT1 = Number(rows[0].n);
|
||
expect(onT1, "T1 carries whole bookings only").to.be.at.most(G1_WAGONS);
|
||
// Whatever landed on T1, the day's total is all 90 wagons.
|
||
withSchedule(T2, (s2) =>
|
||
db<{ n: string }>(
|
||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||
FROM freight.wagon_booking_allocations wba
|
||
JOIN freight.train_schedule_bookings tsb
|
||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||
[s2.id],
|
||
).then(({ rows: r2 }) =>
|
||
expect(onT1 + Number(r2[0].n), "all 90 wagons placed across the day").to.eq(90),
|
||
),
|
||
);
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// S21 — the competing "fill T1 first" policy (NOT implemented)
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("G4·S21: fill-first-train policy", { retries: 0 }, () => {
|
||
it("documents that the engine prefers whole placement, not fill-first", () => {
|
||
// S20 (above) asserts the implemented behaviour. Keeping this as a live,
|
||
// passing note rather than a skipped mystery: the two scenarios are
|
||
// mutually exclusive and S20 is the one that matches the code.
|
||
cy.log(
|
||
"S21 proposes closing T1 with a split before opening T2. The batch " +
|
||
"instead reaches maybeOfferPartial only when NO train fits the " +
|
||
"booking whole (booking-batch.service.ts:2473-2496), so a second " +
|
||
"train with room always wins. S20 is the asserted policy.",
|
||
);
|
||
});
|
||
|
||
// Un-skip only if the fill-first policy is deliberately implemented; it
|
||
// would change S19 and S20's outcomes too, so treat it as a product change
|
||
// rather than a test fix.
|
||
it.skip("closes T1 with a split before opening T2", () => {
|
||
// Would assert: A30 + B20 + C-split(3) fills T1 to 53/53, and C's
|
||
// remaining 7 wagons roll to T2 — i.e. a split is CHOSEN even though a
|
||
// whole placement was available on T2.
|
||
});
|
||
});
|
||
|
||
export {};
|