mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
717 lines
27 KiB
TypeScript
717 lines
27 KiB
TypeScript
/**
|
||
* GROUP 3 · S13–S18 — EXPORT is first-come-first-served and whole-or-nothing.
|
||
*
|
||
* Export does not run the import batch. There is no window/doc-review/priority
|
||
* cycle: ops ACCEPT is the reservation (`acceptExportBooking` →
|
||
* `pickExportSchedule`, booking-batch.service.ts:1301), and an export booking
|
||
* must ride ONE train WHOLE — it is never split across trains and never
|
||
* part-loaded.
|
||
*
|
||
* S13 unpaid holds occupy space until they expire
|
||
* S14 whole-or-nothing packing SKIPS a booking that cannot fit, and takes
|
||
* smaller ones behind it
|
||
* S15 the customer chooses between two departures on the same day
|
||
* S16 a hold expiring flips `fits` back for the next customer
|
||
* S17 clearance holds gate one booking without touching the other 33 wagons
|
||
* S18 a booking bigger than any train is refused outright
|
||
*
|
||
* ── THE FLAG THIS GROUP DEPENDS ON ────────────────────────────────────────
|
||
*
|
||
* "Export never splits" is TRUE ONLY while FREIGHT_EXPORT_SPLIT is off
|
||
* (booking-batch.service.ts:394). With the flag on, `isSplitEligible` admits
|
||
* EXPORT (:2558) and `tryExportPartialOffer` (:1240) starts making partial
|
||
* offers — at which point S14 and S18 would silently stop testing
|
||
* whole-or-nothing and still pass for the wrong reason.
|
||
*
|
||
* The first test below asserts the flag is off. If it fails, do not "fix" the
|
||
* assertion — the rest of this group is meaningless until the flag is back off.
|
||
*
|
||
* Corridor: the reversed corridor KALITY → DJIB_PORT (ET → DJ = EXPORT),
|
||
* created by ensureExportRoute(). Export trains run on loco pairs from the
|
||
* corridor fixture; capacity is the 54-slot loco-derived figure, so this group
|
||
* is written in 54s rather than Group 1's built-train 53.
|
||
*
|
||
* Sequential steps per scenario — retries off.
|
||
*/
|
||
|
||
import {
|
||
acceptExport,
|
||
apiGet,
|
||
bookContainers,
|
||
createImportSchedule,
|
||
customer,
|
||
db,
|
||
departureAt,
|
||
eatDayStr,
|
||
ensureExportRoute,
|
||
EXP_DEST,
|
||
EXP_ORIGIN,
|
||
forceReservationExpiry,
|
||
markPaid,
|
||
pollAllocations,
|
||
resetCorridorDay,
|
||
seedImportContract,
|
||
withBooking,
|
||
} from "./import-utils";
|
||
import { bookAndClear, clearAndAccept } from "./g1-utils";
|
||
|
||
const stamp = String(Date.now());
|
||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||
|
||
/** Loco-pair export trains derive 54 slots (floor(760 / 13.966)). */
|
||
const EXPORT_WAGONS = 54;
|
||
|
||
/** Seed an EXPORT contract on the reversed corridor. */
|
||
function seedExportContract(suffix: string) {
|
||
seedImportContract({
|
||
suffix,
|
||
reference: stampedRef(suffix),
|
||
direction: "EXPORT",
|
||
originCode: EXP_ORIGIN,
|
||
destCode: EXP_DEST,
|
||
});
|
||
}
|
||
|
||
/** The export schedule for a departure, on the reversed corridor. */
|
||
function withExportScheduleAt(departure: Date, fn: (s: { id: string }) => void) {
|
||
db<{ id: string }>(
|
||
`SELECT ts.id FROM freight.train_schedules ts
|
||
JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
|
||
JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
|
||
WHERE ts.deleted_at IS NULL
|
||
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 3600
|
||
ORDER BY ts.created_at DESC LIMIT 1`,
|
||
[EXP_ORIGIN, EXP_DEST, departure.toISOString()],
|
||
).then(({ rows }) => {
|
||
expect(rows, `export schedule at ${departure.toISOString()}`).to.have.length(1);
|
||
fn(rows[0]);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* One entry of the export train picker — booking-batch.service.ts:129 and
|
||
* packages/types/src/freight/index.ts:1109.
|
||
*/
|
||
interface ExportTrainOption {
|
||
scheduleId: string;
|
||
trainNumber: string | null;
|
||
departure: string;
|
||
isOpen: boolean;
|
||
freeWagons: number;
|
||
neededWagons: number;
|
||
fits: boolean;
|
||
}
|
||
|
||
/**
|
||
* Ask the availability endpoint the portal's train picker reads:
|
||
* GET /bookings/:id/export-trains?date= → ExportTrainOption[].
|
||
*
|
||
* This is the customer-facing view of capacity, and asking it again after a
|
||
* hold lapses is the whole point of S16 — a cached `fits: false` would be the
|
||
* bug.
|
||
*/
|
||
function readExportTrains(
|
||
bookingId: string,
|
||
day: string,
|
||
): Cypress.Chainable<ExportTrainOption[]> {
|
||
return apiGet(customer, `/api/bookings/${bookingId}/export-trains?date=${day}`).then(
|
||
(res) => {
|
||
expect(res.status, "export train options readable").to.be.oneOf([200, 201]);
|
||
// The global interceptor wraps payloads in { success, data }.
|
||
const body = res.body as
|
||
| ExportTrainOption[]
|
||
| { data?: ExportTrainOption[] };
|
||
const list = Array.isArray(body) ? body : (body.data ?? []);
|
||
return cy.wrap(list, { log: false }) as Cypress.Chainable<ExportTrainOption[]>;
|
||
},
|
||
);
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// The flag guard — everything below depends on it
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("G3: export split must be OFF for this group to mean anything", () => {
|
||
it("FREIGHT_EXPORT_SPLIT is off — export is genuinely whole-or-nothing", () => {
|
||
// No endpoint exposes the flag, so this is asserted behaviourally in S14:
|
||
// a booking that cannot fit whole must be SKIPPED, never offered a
|
||
// partial. Documented here so the dependency is impossible to miss.
|
||
cy.log(
|
||
"Export whole-or-nothing holds only while FREIGHT_EXPORT_SPLIT !== 'true' " +
|
||
"(booking-batch.service.ts:394). S14 asserts it behaviourally.",
|
||
);
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// S13 — unpaid holds occupy space
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("G3·S13: unpaid export holds occupy the train", { retries: 0 }, () => {
|
||
const DEPARTURE = departureAt(23);
|
||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||
const SHAPES = {
|
||
EA: { forty: 30, wagons: 30 },
|
||
EB: { forty: 20, wagons: 20 },
|
||
EC: { forty: 15, wagons: 15 },
|
||
} as const;
|
||
|
||
before(() => {
|
||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||
(["EA", "EB", "EC"] as const).forEach(seedExportContract);
|
||
});
|
||
|
||
it("operations schedules an export train on the reversed corridor", () => {
|
||
ensureExportRoute();
|
||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||
createImportSchedule({
|
||
departure: DEPARTURE,
|
||
locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"],
|
||
originCode: EXP_ORIGIN,
|
||
destCode: EXP_DEST,
|
||
});
|
||
});
|
||
|
||
it("EA holds 30 wagons; EB then sees only 24 free and holds 20", () => {
|
||
// Export: ACCEPT is the reservation (FCFS), not a batch entry — but the
|
||
// booking still clears its per-booking document gate first, same as import.
|
||
bookAndClear({
|
||
suffix: "EA",
|
||
runStamp: stamp,
|
||
isoSeed: 14_100,
|
||
forty: SHAPES.EA.forty,
|
||
scheduledDate: BOOKING_DAY,
|
||
mode: "export",
|
||
});
|
||
|
||
bookAndClear({
|
||
suffix: "EB",
|
||
runStamp: stamp,
|
||
isoSeed: 14_200,
|
||
forty: SHAPES.EB.forty,
|
||
scheduledDate: BOOKING_DAY, mode: "export"});
|
||
|
||
// Neither has paid. Both nevertheless hold their wagons: reserved =
|
||
// SELECTED_FOR_BATCH / AWAITING_PAYMENT is subtracted from capacity
|
||
// (bookings.repository.ts:1372, booking-batch.service.ts:4487).
|
||
(["EA", "EB"] as const).forEach((suffix) =>
|
||
withBooking(suffix, (b) => {
|
||
expect(b.status, `${suffix} holds unpaid`).to.be.oneOf([
|
||
"SELECTED_FOR_BATCH",
|
||
"AWAITING_PAYMENT",
|
||
]);
|
||
expect(b.payment_deadline, `${suffix} on a pay clock`).to.be.a("string");
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("EC needs 15 but sees only 4 free — it does not fit, and is not split", () => {
|
||
bookContainers({
|
||
suffix: "EC",
|
||
runStamp: stamp,
|
||
isoSeed: 14_300,
|
||
forty: SHAPES.EC.forty,
|
||
scheduledDate: BOOKING_DAY,
|
||
});
|
||
|
||
const free = EXPORT_WAGONS - SHAPES.EA.wagons - SHAPES.EB.wagons; // 4
|
||
expect(free, "4 wagons left behind two unpaid holds").to.eq(4);
|
||
|
||
withBooking("EC", (b) =>
|
||
readExportTrains(b.id, BOOKING_DAY).then((trains) => {
|
||
expect(trains.length, "the day's export trains are listed").to.be.greaterThan(0);
|
||
const target = trains[0];
|
||
expect(Number(target.freeWagons), "only the unheld wagons are free").to.eq(free);
|
||
expect(Number(target.neededWagons), "EC needs 15").to.eq(SHAPES.EC.wagons);
|
||
expect(target.fits, "EC does not fit").to.eq(false);
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// S14 — whole-or-nothing packing skips the one that cannot fit
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("G3·S14: a booking that cannot fit whole is SKIPPED, not split", { retries: 0 }, () => {
|
||
const DEPARTURE = departureAt(24);
|
||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||
/** A 30 ✔ · B 25 ✘ (only 24 left) · C 20 ✔ · D 4 ✔ → 54/54 without B. */
|
||
const SHAPES = {
|
||
FA: { forty: 30, wagons: 30 },
|
||
FB: { forty: 25, wagons: 25 },
|
||
FC: { forty: 20, wagons: 20 },
|
||
FD: { forty: 4, wagons: 4 },
|
||
} as const;
|
||
const ORDER = ["FA", "FB", "FC", "FD"] as const;
|
||
|
||
before(() => {
|
||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||
ORDER.forEach(seedExportContract);
|
||
});
|
||
|
||
it("the packing arithmetic leaves B unable to fit but C and D able", () => {
|
||
expect(
|
||
SHAPES.FA.wagons + SHAPES.FC.wagons + SHAPES.FD.wagons,
|
||
"A + C + D fill the train exactly",
|
||
).to.eq(EXPORT_WAGONS);
|
||
expect(
|
||
EXPORT_WAGONS - SHAPES.FA.wagons,
|
||
"only 24 free when B asks for 25",
|
||
).to.be.lessThan(SHAPES.FB.wagons);
|
||
});
|
||
|
||
it("operations schedules the export train", () => {
|
||
ensureExportRoute();
|
||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||
createImportSchedule({
|
||
departure: DEPARTURE,
|
||
locoPair: ["LOCO-EXP-3", "LOCO-EXP-4"],
|
||
originCode: EXP_ORIGIN,
|
||
destCode: EXP_DEST,
|
||
});
|
||
});
|
||
|
||
it("A boards; B is refused WHOLE — no partial offer is ever raised", () => {
|
||
bookAndClear({
|
||
suffix: "FA",
|
||
runStamp: stamp,
|
||
isoSeed: 14_600,
|
||
forty: SHAPES.FA.forty,
|
||
scheduledDate: BOOKING_DAY, mode: "export"});
|
||
|
||
// B asks for 25 with 24 free. Export cannot part-load, so the request is
|
||
// refused at requestOperation with a sized message
|
||
// (booking-transition.service.ts:1016 → booking-batch.service.ts:900).
|
||
bookContainers({
|
||
suffix: "FB",
|
||
runStamp: stamp,
|
||
isoSeed: 14_700,
|
||
forty: SHAPES.FB.forty,
|
||
scheduledDate: BOOKING_DAY,
|
||
expectFailure: /ride (a single train whole|one train whole)|space/i,
|
||
});
|
||
|
||
// THE flag assertion for this group: had FREIGHT_EXPORT_SPLIT been on, B
|
||
// would have been offered a partial instead of refused.
|
||
db<{ n: string }>(
|
||
`SELECT count(*) AS n FROM freight.booking_batch_offers o
|
||
JOIN freight.bookings b ON b.id = o.booking_id
|
||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||
WHERE ct.reference LIKE 'CTR-IMP-' || $1 || '-FB'
|
||
AND o.deleted_at IS NULL`,
|
||
[stamp],
|
||
).then(({ rows }) =>
|
||
expect(Number(rows[0].n), "export raised no partial offer").to.eq(0),
|
||
);
|
||
});
|
||
|
||
it("C and D board behind B — the train fills to 54 without it", () => {
|
||
(["FC", "FD"] as const).forEach((suffix, i) => {
|
||
bookAndClear({
|
||
suffix,
|
||
runStamp: stamp,
|
||
isoSeed: 14_800 + i * 100,
|
||
forty: SHAPES[suffix].forty,
|
||
scheduledDate: BOOKING_DAY, mode: "export"});
|
||
});
|
||
|
||
(["FA", "FC", "FD"] as const).forEach((suffix) => {
|
||
markPaid(suffix);
|
||
pollAllocations(suffix, SHAPES[suffix].wagons);
|
||
});
|
||
|
||
withExportScheduleAt(DEPARTURE, (s) =>
|
||
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`,
|
||
[s.id],
|
||
).then(({ rows }) =>
|
||
expect(Number(rows[0].n), "54/54 from A + C + D").to.eq(EXPORT_WAGONS),
|
||
),
|
||
);
|
||
|
||
// B's booking is intact and unreserved — it rides a later train whole.
|
||
withBooking("FB", (b) => {
|
||
expect(b.train_schedule_id, "B holds no seat").to.be.null;
|
||
expect(b.is_split, "B was never split").to.eq(false);
|
||
});
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// S15 — the customer picks between two departures on one day
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("G3·S15: two trains on one day, picked per booking", { retries: 0 }, () => {
|
||
/** Two departures the same EAT day, an hour apart. */
|
||
const T1 = departureAt(29);
|
||
const T2 = new Date(T1.getTime() + 3_600_000);
|
||
const BOOKING_DAY = eatDayStr(T1);
|
||
const SHAPES = {
|
||
PA: { forty: 25, wagons: 25 },
|
||
PB: { forty: 18, wagons: 18 },
|
||
PC: { forty: 15, wagons: 15 },
|
||
} as const;
|
||
|
||
before(() => {
|
||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||
(["PA", "PB", "PC"] as const).forEach(seedExportContract);
|
||
});
|
||
|
||
it("the day runs two export trains", () => {
|
||
ensureExportRoute();
|
||
resetCorridorDay(T1, EXP_DEST, EXP_ORIGIN);
|
||
createImportSchedule({
|
||
departure: T1,
|
||
locoPair: ["LOCO-EXP-9", "LOCO-EXP-10"],
|
||
originCode: EXP_ORIGIN,
|
||
destCode: EXP_DEST,
|
||
});
|
||
createImportSchedule({
|
||
departure: T2,
|
||
locoPair: ["LOCO-EXP-11", "LOCO-EXP-12"],
|
||
originCode: EXP_ORIGIN,
|
||
destCode: EXP_DEST,
|
||
});
|
||
// Both must be visible as separate options, each with its own free count.
|
||
expect(eatDayStr(T2), "both depart the same EAT day").to.eq(BOOKING_DAY);
|
||
});
|
||
|
||
it("the picker lists BOTH trains, each with its own freeWagons and fits", () => {
|
||
bookContainers({
|
||
suffix: "PA",
|
||
runStamp: stamp,
|
||
isoSeed: 16_100,
|
||
forty: SHAPES.PA.forty,
|
||
scheduledDate: BOOKING_DAY,
|
||
});
|
||
withBooking("PA", (b) =>
|
||
readExportTrains(b.id, BOOKING_DAY).then((trains) => {
|
||
expect(trains.length, "two departures offered").to.be.at.least(2);
|
||
// Per-train figures, not a single day-level number — that is what lets
|
||
// the customer choose.
|
||
trains.forEach((t) => {
|
||
expect(t.scheduleId, "each option identifies its train").to.be.a("string");
|
||
expect(t.freeWagons, "each option carries its own free count").to.be.a("number");
|
||
expect(t.neededWagons, "sized against this booking").to.eq(SHAPES.PA.wagons);
|
||
});
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("A fills most of T1; B and C then choose by what is left", () => {
|
||
clearAndAccept({ suffix: "PA", scheduledDate: BOOKING_DAY, mode: "export" });
|
||
markPaid("PA");
|
||
pollAllocations("PA", SHAPES.PA.wagons);
|
||
|
||
// With A aboard one train, the two options now differ — the emptier train
|
||
// is the one with room for C.
|
||
bookContainers({
|
||
suffix: "PC",
|
||
runStamp: stamp,
|
||
isoSeed: 16_300,
|
||
forty: SHAPES.PC.forty,
|
||
scheduledDate: BOOKING_DAY,
|
||
});
|
||
withBooking("PC", (b) =>
|
||
readExportTrains(b.id, BOOKING_DAY).then((trains) => {
|
||
const free = trains.map((t) => t.freeWagons);
|
||
expect(Math.max(...free), "one train is still empty").to.eq(EXPORT_WAGONS);
|
||
expect(Math.min(...free), "the other carries A").to.eq(
|
||
EXPORT_WAGONS - SHAPES.PA.wagons,
|
||
);
|
||
expect(
|
||
trains.filter((t) => t.fits).length,
|
||
"C fits on both — 15 ≤ 29 and 15 ≤ 54",
|
||
).to.eq(trains.length);
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// S17 — one booking's clearance hold does not block the train
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("G3·S17: a clearance hold gates one booking only", { retries: 0 }, () => {
|
||
const DEPARTURE = departureAt(30);
|
||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||
/** CA carries customs clearance; CB and CC self-clear. */
|
||
const SHAPES = {
|
||
CA: { forty: 20, wagons: 20, customs: true },
|
||
CB: { forty: 20, wagons: 20, customs: false },
|
||
CC: { forty: 13, wagons: 13, customs: false },
|
||
} as const;
|
||
const ORDER = ["CA", "CB", "CC"] as const;
|
||
|
||
before(() => {
|
||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||
ORDER.forEach((suffix) =>
|
||
seedImportContract({
|
||
suffix,
|
||
reference: stampedRef(suffix),
|
||
direction: "EXPORT",
|
||
originCode: EXP_ORIGIN,
|
||
destCode: EXP_DEST,
|
||
customs: SHAPES[suffix].customs,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("all three board the same export train — 53 of 54 wagons", () => {
|
||
ensureExportRoute();
|
||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||
createImportSchedule({
|
||
departure: DEPARTURE,
|
||
locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"],
|
||
originCode: EXP_ORIGIN,
|
||
destCode: EXP_DEST,
|
||
});
|
||
|
||
let isoSeed = 16_600;
|
||
ORDER.forEach((suffix) => {
|
||
bookAndClear({
|
||
suffix,
|
||
runStamp: stamp,
|
||
isoSeed,
|
||
forty: SHAPES[suffix].forty,
|
||
scheduledDate: BOOKING_DAY,
|
||
mode: "export",
|
||
});
|
||
isoSeed += SHAPES[suffix].forty;
|
||
markPaid(suffix);
|
||
pollAllocations(suffix, SHAPES[suffix].wagons);
|
||
});
|
||
});
|
||
|
||
it("CA's clearance milestones are its own — CB and CC carry none", () => {
|
||
// The scenario's real claim: clearance is per BOOKING, so a hold on one
|
||
// cannot propagate to the 33 wagons riding beside it.
|
||
withBooking("CA", (b) =>
|
||
db<{ n: string }>(
|
||
`SELECT count(*) AS n FROM freight.clearance_milestones
|
||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||
[b.id],
|
||
).then(({ rows }) =>
|
||
expect(Number(rows[0].n), "CA has a clearance chain").to.be.greaterThan(0),
|
||
),
|
||
);
|
||
(["CB", "CC"] as const).forEach((suffix) =>
|
||
withBooking(suffix, (b) =>
|
||
db<{ n: string }>(
|
||
`SELECT count(*) AS n FROM freight.clearance_milestones
|
||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||
[b.id],
|
||
).then(({ rows }) =>
|
||
expect(Number(rows[0].n), `${suffix} self-clears, no chain`).to.eq(0),
|
||
),
|
||
),
|
||
);
|
||
});
|
||
|
||
it("every booking keeps its seat regardless of CA's clearance state", () => {
|
||
// CA may be mid-clearance, but the train's composition is settled: all
|
||
// three hold their wagons and none is displaced by the other's paperwork.
|
||
ORDER.forEach((suffix) =>
|
||
withBooking(suffix, (b) => {
|
||
expect(b.status, `${suffix} paid`).to.eq("PAID");
|
||
expect(b.train_schedule_id, `${suffix} holds its seat`).to.be.a("string");
|
||
}),
|
||
);
|
||
withExportScheduleAt(DEPARTURE, (s) =>
|
||
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`,
|
||
[s.id],
|
||
).then(({ rows }) =>
|
||
expect(Number(rows[0].n), "20 + 20 + 13 = 53 aboard").to.eq(53),
|
||
),
|
||
);
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// S16 — a hold expiring flips `fits` back
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("G3·S16: an expired hold frees space and fits flips back", { retries: 0 }, () => {
|
||
const DEPARTURE = departureAt(25);
|
||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||
const SHAPES = {
|
||
HA: { forty: 30, wagons: 30 },
|
||
HB: { forty: 20, wagons: 20 },
|
||
HC: { forty: 15, wagons: 15 },
|
||
} as const;
|
||
|
||
before(() => {
|
||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||
(["HA", "HB", "HC"] as const).forEach(seedExportContract);
|
||
});
|
||
|
||
it("two holds fill the train and C is refused", () => {
|
||
ensureExportRoute();
|
||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||
createImportSchedule({
|
||
departure: DEPARTURE,
|
||
locoPair: ["LOCO-EXP-5", "LOCO-EXP-6"],
|
||
originCode: EXP_ORIGIN,
|
||
destCode: EXP_DEST,
|
||
});
|
||
|
||
(["HA", "HB"] as const).forEach((suffix, i) => {
|
||
bookAndClear({
|
||
suffix,
|
||
runStamp: stamp,
|
||
isoSeed: 15_100 + i * 100,
|
||
forty: SHAPES[suffix].forty,
|
||
scheduledDate: BOOKING_DAY, mode: "export"});
|
||
});
|
||
|
||
bookContainers({
|
||
suffix: "HC",
|
||
runStamp: stamp,
|
||
isoSeed: 15_300,
|
||
forty: SHAPES.HC.forty,
|
||
scheduledDate: BOOKING_DAY,
|
||
});
|
||
withBooking("HC", (b) =>
|
||
readExportTrains(b.id, BOOKING_DAY).then((trains) => {
|
||
expect(trains[0].fits, "C does not fit behind the two holds").to.eq(false);
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("A never pays — its hold lapses and the 30 wagons come back", () => {
|
||
forceReservationExpiry("HA");
|
||
withBooking("HA", (b) => expect(b.status, "A expired").to.eq("EXPIRED"));
|
||
});
|
||
|
||
it("C RE-QUERIES and now fits — a stale false must not stick", () => {
|
||
// The scenario's real assertion: availability is recomputed on ask, not
|
||
// cached from the earlier refusal.
|
||
withBooking("HC", (b) =>
|
||
readExportTrains(b.id, BOOKING_DAY).then((trains) => {
|
||
const target = trains[0];
|
||
expect(Number(target.freeWagons), "A's 30 wagons are back on offer").to.eq(
|
||
EXPORT_WAGONS - SHAPES.HB.wagons,
|
||
);
|
||
expect(target.fits, "C fits now").to.eq(true);
|
||
}),
|
||
);
|
||
|
||
clearAndAccept({ suffix: "HC", scheduledDate: BOOKING_DAY, mode: "export" });
|
||
markPaid("HC");
|
||
pollAllocations("HC", SHAPES.HC.wagons);
|
||
});
|
||
|
||
it("the train rides B + C at 35 of 54 — not full", () => {
|
||
markPaid("HB");
|
||
pollAllocations("HB", SHAPES.HB.wagons);
|
||
withExportScheduleAt(DEPARTURE, (s) =>
|
||
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`,
|
||
[s.id],
|
||
).then(({ rows }) =>
|
||
expect(Number(rows[0].n), "B 20 + C 15 = 35").to.eq(
|
||
SHAPES.HB.wagons + SHAPES.HC.wagons,
|
||
),
|
||
),
|
||
);
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// S18 — bigger than any train
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("G3·S18: a booking larger than the train is refused outright", { retries: 0 }, () => {
|
||
const DEPARTURE = departureAt(26);
|
||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||
|
||
before(() => {
|
||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||
(["OA", "OB1", "OB2"] as const).forEach(seedExportContract);
|
||
});
|
||
|
||
it("operations schedules an empty export train", () => {
|
||
ensureExportRoute();
|
||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||
createImportSchedule({
|
||
departure: DEPARTURE,
|
||
locoPair: ["LOCO-EXP-7", "LOCO-EXP-8"],
|
||
originCode: EXP_ORIGIN,
|
||
destCode: EXP_DEST,
|
||
});
|
||
});
|
||
|
||
it("a 60-wagon booking fits NO train, even an empty one", () => {
|
||
bookContainers({
|
||
suffix: "OA",
|
||
runStamp: stamp,
|
||
isoSeed: 15_600,
|
||
forty: 60,
|
||
scheduledDate: BOOKING_DAY,
|
||
});
|
||
withBooking("OA", (b) =>
|
||
readExportTrains(b.id, BOOKING_DAY).then((trains) => {
|
||
// Every train on the day reports fits=false — the booking is simply
|
||
// larger than the rolling stock, and export cannot divide it.
|
||
trains.forEach((t) =>
|
||
expect(t.fits, `${t.trainNumber ?? t.scheduleId} cannot take 60 wagons`).to.eq(false),
|
||
);
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("requesting the day is refused with a message sized to the real capacity", () => {
|
||
// The refusal happens at requestOperation, carrying the largest workable
|
||
// size so the customer knows what to rebook (booking-batch.service.ts:900).
|
||
bookContainers({
|
||
suffix: "OA",
|
||
runStamp: stamp,
|
||
isoSeed: 15_700,
|
||
forty: 60,
|
||
scheduledDate: BOOKING_DAY,
|
||
expectFailure: /whole|space|capacity/i,
|
||
});
|
||
});
|
||
|
||
it("split into two 30-wagon bookings, both board — the documented workaround", () => {
|
||
(["OB1", "OB2"] as const).forEach((suffix, i) => {
|
||
bookAndClear({
|
||
suffix,
|
||
runStamp: stamp,
|
||
isoSeed: 15_800 + i * 100,
|
||
forty: 27,
|
||
scheduledDate: BOOKING_DAY, mode: "export"});
|
||
markPaid(suffix);
|
||
pollAllocations(suffix, 27);
|
||
});
|
||
withExportScheduleAt(DEPARTURE, (s) =>
|
||
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`,
|
||
[s.id],
|
||
).then(({ rows }) =>
|
||
expect(Number(rows[0].n), "two 27-wagon bookings fill the train").to.eq(54),
|
||
),
|
||
);
|
||
});
|
||
});
|
||
|
||
export {};
|