mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
246 lines
8.0 KiB
TypeScript
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 {};
|