Files
edr-platform/e2e/freight/cypress/e2e/flows/export_matrix.cy.ts
2026-07-22 23:44:32 +00:00

246 lines
8.0 KiB
TypeScript

/**
* EXPORT critical-scenario matrix (reversed corridor, D+7):
*
* 1. sub-corridor export: a DIRE_DAWA → DJIB_PORT booking boards mid-route
* and shares the train with a KALITY through-booking
* 2. directional FULL: through 40w + sub-corridor 14w commit every wagon on
* the border edges → the export window flips FULL while the KALITY→MOJO
* home leg still has 14 free wagons
* 3. intercity ride-along on the FULL export train's free home leg
* (KALITY → MOJO, DOMESTIC, dateless) — accepted, pay deadline clamped
* to the EXPORT window close, paid + linked; the window stays FULL
* 4. same-day sibling export train keeps its OWN window — export is
* excluded from the import route-day group rule
* 5. duplicate ISO container number across two bookings of the same
* route-day is rejected
*
* Sequential steps — retries off.
*/
import {
acceptExport,
apiPost,
bookContainers,
createImportSchedule,
db,
departureAt,
eatDayStr,
ensureExportRoute,
EXP_DEST,
EXP_ORIGIN,
forceWindowOpen,
isoNumber,
markPaid,
opsStaff,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withExportSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(7);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
describe("export matrix: sub-corridor, directional FULL, intercity on FULL train, own windows", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
seedImportContract({
suffix: "EM1",
reference: stampedRef("EM1"),
direction: "EXPORT",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
seedImportContract({
suffix: "EMSUB",
reference: stampedRef("EMSUB"),
direction: "EXPORT",
originCode: "DIRE_DAWA",
destCode: EXP_DEST,
});
seedImportContract({
suffix: "EMIC",
reference: stampedRef("EMIC"),
direction: "DOMESTIC",
originCode: EXP_ORIGIN,
destCode: "MOJO",
});
seedImportContract({
suffix: "EMDUP",
reference: stampedRef("EMDUP"),
direction: "EXPORT",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
});
it("operations prepares the export corridor and the D+7 train with an open window", () => {
ensureExportRoute();
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-EXP-11", "LOCO-EXP-12"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 90));
});
it("a KALITY through-booking (40w) and a DIRE_DAWA sub-corridor booking (14w) share the train", () => {
bookContainers({
suffix: "EM1",
runStamp: stamp,
isoSeed: 8600,
twenty: 80, // 40 wagons, full corridor
scheduledDate: BOOKING_DAY,
});
acceptExport("EM1");
// While the window is still OPEN (14 wagons free): a booking reusing one
// of EM1's ISO container numbers on the same route-day is rejected.
db<{ id: string }>(
`SELECT id FROM freight.contracts
WHERE reference LIKE 'CTR-IMP-%-' || $1
ORDER BY created_at DESC LIMIT 1`,
["EMDUP"],
).then(({ rows }) => {
apiPost(
"user@gmail.com",
`/api/contracts/${rows[0].id}/bookings`,
{
scheduledDate: BOOKING_DAY,
containers: [
{
containerSize: "20ft",
quantity: 2,
units: [
{ containerNumber: isoNumber(stamp, 8600), vgmTons: 10 },
{ containerNumber: isoNumber(stamp, 9990), vgmTons: 10 },
],
},
],
},
false,
).then((res) => {
expect(res.status, "cross-booking ISO clash rejected").to.be.within(400, 422);
expect(JSON.stringify(res.body).toLowerCase()).to.include("container");
});
});
bookContainers({
suffix: "EMSUB",
runStamp: stamp,
isoSeed: 8700,
twenty: 28, // 14 wagons, boards mid-route at DIRE_DAWA
scheduledDate: BOOKING_DAY,
});
acceptExport("EMSUB");
markPaid("EM1");
pollAllocations("EM1", 40);
markPaid("EMSUB");
pollAllocations("EMSUB", 14);
withExportSchedule(DEPARTURE, (s) => {
withBooking("EM1", (b) => expect(b.train_schedule_id).to.eq(s.id));
withBooking("EMSUB", (b) => expect(b.train_schedule_id).to.eq(s.id));
});
});
it("the border edges are committed — the export window flips FULL (home leg still empty)", () => {
withExportSchedule(DEPARTURE, (s) => {
pollDb<ScheduleRow>(
"directional FULL",
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.booking_window_status === "FULL",
);
});
});
it("intercity ride-along boards the FULL train's free home leg — deadline clamped to the export close", () => {
bookContainers({
suffix: "EMIC",
runStamp: stamp,
isoSeed: 8800,
twenty: 4, // 2 wagons KALITY → MOJO, dateless
});
withBooking("EMIC", (b) => {
apiPost(opsStaff, `/api/bookings/${b.id}/operation/review`, { decision: "ACCEPT" })
.its("status")
.should("be.oneOf", [200, 201]);
});
pollBookingStatus("EMIC", "FULLY_EXECUTED", 10);
withExportSchedule(DEPARTURE, (s) => {
withBooking("EMIC", (b) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${s.id}/intercity/accept`, {
bookingIds: [b.id],
})
.its("status")
.should("be.oneOf", [200, 201]);
});
});
pollBookingStatus("EMIC", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
withExportSchedule(DEPARTURE, (s) => {
withBooking("EMIC", (b) => {
// Export parity: the ride-along's pay window never outlives the close.
expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
new Date(s.window_closes_at!).getTime(),
);
});
});
markPaid("EMIC");
withExportSchedule(DEPARTURE, (s) => {
withBooking("EMIC", (b) => {
expect(b.train_schedule_id, "linked to the export train").to.eq(s.id);
});
// The ride-along never reopens the export window.
expect(s.booking_window_status, "window stays FULL").to.eq("FULL");
});
});
it("a same-day sibling export train keeps its OWN window — no route-day group for export", () => {
const sibling = new Date(DEPARTURE.getTime() + 90 * 60_000);
createImportSchedule({
departure: sibling,
locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(DEPARTURE, (anchor) => {
db<{ id: string; window_phase: string; window_closes_at: string }>(
`SELECT ts.id, ts.window_phase, ts.window_closes_at
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 ts.id <> $3
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $4::timestamptz))) < 7200
ORDER BY ts.created_at DESC LIMIT 1`,
[EXP_ORIGIN, EXP_DEST, anchor.id, DEPARTURE.toISOString()],
).then(({ rows }) => {
expect(rows, "sibling export schedule").to.have.length(1);
// The anchor's window was forced/consumed (FULL); a joiner under the
// import group rule would copy its live state. Export siblings don't:
// this one starts its own PRE_WINDOW timeline anchored to its OWN
// departure.
expect(rows[0].window_phase, "own fresh window").to.eq("PRE_WINDOW");
expect(
new Date(rows[0].window_closes_at).getTime(),
"own close, clamped to its own departure",
).to.not.eq(new Date(anchor.window_closes_at!).getTime());
});
});
});
});
export {};