Files
edr-platform/e2e/freight/cypress/e2e/flows/g3_export.cy.ts

717 lines
27 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 3 · S13S18 — 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 {};