mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
changes
This commit is contained in:
@@ -73,11 +73,16 @@ describe("bulk import: dead first cycle — expire all, reopen, book again", { r
|
||||
["BRA", "BRB"].forEach((suffix) => forceReservationExpiry(suffix));
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
endPaymentPhase(s.id);
|
||||
// When the reopen instant falls inside office hours, the 10s tick's
|
||||
// guard-loop chains PRE_WINDOW straight into OPEN within the SAME tick
|
||||
// (booking-window.service.ts advanceSchedule), so PRE_WINDOW is not a
|
||||
// reliably observable resting state — assert the cycle left PAYMENT
|
||||
// without concluding FULL/DONE, whichever phase it lands on.
|
||||
pollDb<ScheduleRow>(
|
||||
"window reopens (PRE_WINDOW, cycle 2 pending)",
|
||||
"window concludes not-full (PRE_WINDOW or fast-forwarded to OPEN)",
|
||||
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
|
||||
[s.id],
|
||||
(row) => row?.window_phase === "PRE_WINDOW",
|
||||
(row) => !!row && row.window_phase !== "PAYMENT" && row.window_phase !== "DONE",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
140
e2e/freight/cypress/e2e/flows/general_drawdown.cy.ts
Normal file
140
e2e/freight/cypress/e2e/flows/general_drawdown.cy.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* GENERAL drawdown ledger (D+25) — one GENERAL contract capped at 60×20ft
|
||||
* draws down across many bookings; the cap is enforced at CREATE, releases
|
||||
* when a booking dies, and the contract stays CONTRACT_ACTIVE throughout:
|
||||
*
|
||||
* B1 30 → B2 20 → B3 asking 20 REJECTED ("only 10 remain") → B3' 10 → cap
|
||||
* exhausted → B4 2 REJECTED → B3' expires → its 10 return → B5 10 accepted.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
bookContainers,
|
||||
createImportSchedule,
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
forceWindowOpen,
|
||||
pollBookingStatus,
|
||||
resetCorridorDay,
|
||||
seedImportContract,
|
||||
withSchedule,
|
||||
} from "./import-utils";
|
||||
|
||||
const DEPARTURE = departureAt(25);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const SUFFIX = "GDCAP";
|
||||
const REF = `CTR-IMP-${stamp}-${SUFFIX}`;
|
||||
|
||||
describe("GENERAL drawdown: a 60×20ft cap across many bookings", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
seedImportContract({ suffix: SUFFIX, reference: REF, kind: "GENERAL", cap20: 60 });
|
||||
});
|
||||
|
||||
it("operations prepares the corridor train with an open window (bookings need an open day)", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-17", "LOCO-IMP-18"] });
|
||||
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
|
||||
});
|
||||
|
||||
it("B1 draws 30 and B2 draws 20 — 50 of 60 held", () => {
|
||||
bookContainers({
|
||||
suffix: SUFFIX,
|
||||
runStamp: stamp,
|
||||
isoSeed: 18_000,
|
||||
twenty: 30,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
bookContainers({
|
||||
suffix: SUFFIX,
|
||||
runStamp: stamp,
|
||||
isoSeed: 18_100,
|
||||
twenty: 20,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference = $1`,
|
||||
[REF],
|
||||
).then(({ rows }) => expect(Number(rows[0].n), "two live drawdowns").to.eq(2));
|
||||
});
|
||||
|
||||
it("B3 asking 20 is REJECTED — only 10 of 60 remain", () => {
|
||||
bookContainers({
|
||||
suffix: SUFFIX,
|
||||
runStamp: stamp,
|
||||
isoSeed: 18_200,
|
||||
twenty: 20,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
expectFailure: "remain on this contract",
|
||||
});
|
||||
});
|
||||
|
||||
it("B3' takes exactly the remaining 10 — the contract COMPLETES and B4 is rejected", () => {
|
||||
bookContainers({
|
||||
suffix: SUFFIX,
|
||||
runStamp: stamp,
|
||||
isoSeed: 18_300,
|
||||
twenty: 10,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
// Booking the last of the cap flips the GENERAL contract to
|
||||
// CONTRACT_CLOSED (fully drawn) — the next booking is rejected as such.
|
||||
db<{ status: string }>(
|
||||
`SELECT status FROM freight.contracts WHERE reference = $1`,
|
||||
[REF],
|
||||
).then(({ rows }) =>
|
||||
expect(rows[0].status, "fully drawn contract completes").to.eq("CONTRACT_CLOSED"),
|
||||
);
|
||||
bookContainers({
|
||||
suffix: SUFFIX,
|
||||
runStamp: stamp,
|
||||
isoSeed: 18_400,
|
||||
twenty: 2,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
expectFailure: "completed",
|
||||
});
|
||||
});
|
||||
|
||||
it("an EXPIRED booking releases its draw — B5 books the freed 10 and the ledger closes again", () => {
|
||||
// Kill the newest live drawdown (B3', 10×20ft) — expiry releases its hold.
|
||||
db(
|
||||
`UPDATE freight.bookings b SET status = 'EXPIRED'
|
||||
FROM freight.contracts ct
|
||||
WHERE ct.id = b.contract_id AND ct.reference = $1
|
||||
AND b.id = (
|
||||
SELECT b2.id FROM freight.bookings b2
|
||||
JOIN freight.contracts c2 ON c2.id = b2.contract_id
|
||||
WHERE c2.reference = $1 AND b2.status NOT IN ('EXPIRED','CANCELLED','REJECTED')
|
||||
ORDER BY b2.created_at DESC LIMIT 1
|
||||
)`,
|
||||
[REF],
|
||||
);
|
||||
bookContainers({
|
||||
suffix: SUFFIX,
|
||||
runStamp: stamp,
|
||||
isoSeed: 18_500,
|
||||
twenty: 10,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
pollBookingStatus(SUFFIX, "AWAITING_DOCUMENTS", 5);
|
||||
|
||||
// Completion tracks the outstanding quantity: the released 10 were
|
||||
// rebooked, so the ledger is full again and the contract stays CLOSED.
|
||||
db<{ status: string }>(
|
||||
`SELECT status FROM freight.contracts WHERE reference = $1`,
|
||||
[REF],
|
||||
).then(({ rows }) =>
|
||||
expect(rows[0].status, "re-drawn contract closed").to.eq("CONTRACT_CLOSED"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
136
e2e/freight/cypress/e2e/flows/general_mixed_reopen.cy.ts
Normal file
136
e2e/freight/cypress/e2e/flows/general_mixed_reopen.cy.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* GENERAL mixed reopen (D+26) — the same two GENERAL contracts (one
|
||||
* container, one bulk) book AGAIN after their first bookings die: cycle 1
|
||||
* reserves a container + a bulk drawdown, nobody pays, both expire, the
|
||||
* window reopens — and the SAME contracts issue fresh drawdowns in cycle 2
|
||||
* that pay and allocate typed. GENERAL contracts survive dead bookings and
|
||||
* dead cycles alike.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
bookBulk,
|
||||
bookContainers,
|
||||
clearGeneralBooking,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createImportSchedule,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
expectWagonType,
|
||||
forceReservationExpiry,
|
||||
forceWindowOpen,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollDb,
|
||||
resetCorridorDay,
|
||||
seedImportContract,
|
||||
withBooking,
|
||||
withSchedule,
|
||||
type ScheduleRow,
|
||||
} from "./import-utils";
|
||||
|
||||
const DEPARTURE = departureAt(26);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
describe("GENERAL mixed reopen: the same contracts book again after a dead cycle", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
seedImportContract({ suffix: "GMC", reference: stampedRef("GMC"), kind: "GENERAL" });
|
||||
seedImportContract({
|
||||
suffix: "GMB",
|
||||
reference: stampedRef("GMB"),
|
||||
kind: "GENERAL",
|
||||
freight: "BULK",
|
||||
});
|
||||
});
|
||||
|
||||
it("operations prepares the corridor train — first window opens (cycle 1)", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-19", "LOCO-IMP-20"] });
|
||||
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
|
||||
withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 1").to.eq(1));
|
||||
});
|
||||
|
||||
it("cycle 1: a container and a bulk GENERAL drawdown clear per booking and are reserved", () => {
|
||||
bookContainers({
|
||||
suffix: "GMC",
|
||||
runStamp: stamp,
|
||||
isoSeed: 19_000,
|
||||
twenty: 12, // 6 wagons
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearGeneralBooking("GMC", BOOKING_DAY);
|
||||
bookBulk({ suffix: "GMB", tons: 700, scheduledDate: BOOKING_DAY }); // 10 wagons
|
||||
clearGeneralBooking("GMB", BOOKING_DAY);
|
||||
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
closeBookingWindow(s.id);
|
||||
completeDocReview(s.id);
|
||||
});
|
||||
["GMC", "GMB"].forEach((suffix) =>
|
||||
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("nobody pays — both drawdowns expire and the window reopens", () => {
|
||||
["GMC", "GMB"].forEach((suffix) => forceReservationExpiry(suffix));
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
endPaymentPhase(s.id);
|
||||
// Same-tick guard-loop can chain PRE_WINDOW straight into OPEN when the
|
||||
// reopen instant falls inside office hours — assert it left PAYMENT
|
||||
// without concluding FULL/DONE, whichever phase it lands on.
|
||||
pollDb<ScheduleRow>(
|
||||
"window concludes not-full (PRE_WINDOW or fast-forwarded to OPEN)",
|
||||
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
|
||||
[s.id],
|
||||
(row) => !!row && row.window_phase !== "PAYMENT" && row.window_phase !== "DONE",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("cycle 2: the SAME contracts issue fresh drawdowns that pay and allocate typed", () => {
|
||||
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
|
||||
withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 2").to.eq(2));
|
||||
|
||||
bookContainers({
|
||||
suffix: "GMC",
|
||||
runStamp: stamp,
|
||||
isoSeed: 19_200,
|
||||
twenty: 12,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearGeneralBooking("GMC", BOOKING_DAY);
|
||||
bookBulk({ suffix: "GMB", tons: 700, scheduledDate: BOOKING_DAY });
|
||||
clearGeneralBooking("GMB", BOOKING_DAY);
|
||||
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
closeBookingWindow(s.id);
|
||||
completeDocReview(s.id);
|
||||
});
|
||||
["GMC", "GMB"].forEach((suffix) =>
|
||||
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
|
||||
);
|
||||
markPaid("GMC");
|
||||
pollAllocations("GMC", 6);
|
||||
expectWagonType("GMC", "NW5", 6);
|
||||
markPaid("GMB");
|
||||
pollAllocations("GMB", 10);
|
||||
expectWagonType("GMB", "CW4", 10);
|
||||
|
||||
// The cycle-1 corpses stay dead; both contracts remain ACTIVE.
|
||||
["GMC", "GMB"].forEach((suffix) =>
|
||||
withBooking(suffix, (b) => expect(b.status, `${suffix} cycle-2 rides`).to.eq("PAID")),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
179
e2e/freight/cypress/e2e/flows/general_rush_hour.cy.ts
Normal file
179
e2e/freight/cypress/e2e/flows/general_rush_hour.cy.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* GENERAL rush hour — 20 "users" (20 GENERAL contracts) book one 54-wagon
|
||||
* import train in the SAME first window (D+23). Every GENERAL booking clears
|
||||
* PER BOOKING (upload → GL approve → finalize → proceed → ops accept) before
|
||||
* it may enter the pool. Then:
|
||||
*
|
||||
* – a 21st booking whose operation request is never accepted is EXPIRED by
|
||||
* the batch at doc-review end (it can no longer make the train)
|
||||
* – the batch reserves the top 9 (9 × 6w = 54/54); 11 wait
|
||||
* – the payment race: 5 pay, 4 miss their deadline → the freed 24 wagons
|
||||
* promote the next 4 waiters live (payment phase extends); they pay →
|
||||
* the train still departs FULL
|
||||
* – the 7 left-over waiters expire in the day-end sweep; every contract
|
||||
* stays CONTRACT_ACTIVE (GENERAL survives its bookings)
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
bookContainers,
|
||||
clearGeneralBooking,
|
||||
closeBookingWindow,
|
||||
completeDocReview,
|
||||
createImportSchedule,
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
expectWagonType,
|
||||
forceReservationExpiry,
|
||||
forceWindowOpen,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollDb,
|
||||
resetCorridorDay,
|
||||
seedImportContract,
|
||||
setPriority,
|
||||
withBooking,
|
||||
withSchedule,
|
||||
type ScheduleRow,
|
||||
} from "./import-utils";
|
||||
|
||||
const DEPARTURE = departureAt(23);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
/** 20 virtual users — one GENERAL contract each, 12×20ft = 6 wagons per booking. */
|
||||
const USERS = Array.from({ length: 20 }, (_, i) =>
|
||||
`GR${String(i + 1).padStart(2, "0")}`,
|
||||
);
|
||||
const SELECTED = USERS.slice(0, 9); // 9 × 6w = 54
|
||||
const PAYERS = SELECTED.slice(0, 5);
|
||||
const DEFAULTERS = SELECTED.slice(5, 9);
|
||||
const PROMOTED = USERS.slice(9, 13); // take the defaulters' 24 wagons
|
||||
const LEFTOVER = USERS.slice(13); // 7 expire with the day
|
||||
|
||||
describe("GENERAL rush hour: 20 users, one train, one window", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
[...USERS, "GRNA"].forEach((suffix) =>
|
||||
seedImportContract({ suffix, reference: stampedRef(suffix), kind: "GENERAL" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("operations prepares the corridor train with an open first window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-11", "LOCO-IMP-12"] });
|
||||
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
|
||||
});
|
||||
|
||||
it("20 users book at once — each GENERAL booking clears PER BOOKING into the pool", () => {
|
||||
USERS.forEach((suffix, i) => {
|
||||
bookContainers({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed: 16_000 + i * 15,
|
||||
twenty: 12,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearGeneralBooking(suffix, BOOKING_DAY);
|
||||
});
|
||||
USERS.forEach((suffix, i) => setPriority(suffix, i + 1));
|
||||
});
|
||||
|
||||
it("a 21st booking never accepted by operations is expired when doc review ends", () => {
|
||||
bookContainers({
|
||||
suffix: "GRNA",
|
||||
runStamp: stamp,
|
||||
isoSeed: 16_500,
|
||||
twenty: 12,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
// Clearance done, operation requested — but ops never accept it.
|
||||
withBooking("GRNA", (b) => {
|
||||
cy.log(`GRNA ${b.reference} stays OPERATION_REQUEST_PENDING`);
|
||||
});
|
||||
db(
|
||||
`UPDATE freight.bookings b SET status = 'OPERATION_REQUEST_PENDING'
|
||||
FROM freight.contracts ct
|
||||
WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%-GRNA'
|
||||
AND b.status = 'AWAITING_DOCUMENTS'`,
|
||||
[],
|
||||
);
|
||||
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
closeBookingWindow(s.id);
|
||||
completeDocReview(s.id);
|
||||
});
|
||||
// Never-accepted bookings are swept BEFORE the batch runs.
|
||||
pollBookingStatus("GRNA", "EXPIRED");
|
||||
});
|
||||
|
||||
it("the batch reserves the top 9 (54/54); 11 wait with no pay window", () => {
|
||||
SELECTED.forEach((suffix) =>
|
||||
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
|
||||
);
|
||||
[...PROMOTED, ...LEFTOVER].forEach((suffix) =>
|
||||
withBooking(suffix, (b) => {
|
||||
expect(b.status, `${suffix} waiting`).to.eq("FULLY_EXECUTED");
|
||||
expect(b.payment_deadline, `${suffix} no pay window yet`).to.be.null;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("payment race: 5 pay, 4 default — the freed 24 wagons promote the next 4 waiters live", () => {
|
||||
PAYERS.forEach((suffix) => {
|
||||
markPaid(suffix);
|
||||
pollAllocations(suffix, 6);
|
||||
});
|
||||
DEFAULTERS.forEach((suffix) => forceReservationExpiry(suffix));
|
||||
PROMOTED.forEach((suffix) =>
|
||||
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("the promoted 4 pay — the train departs FULL at 54/54, typed", () => {
|
||||
PROMOTED.forEach((suffix) => {
|
||||
markPaid(suffix);
|
||||
pollAllocations(suffix, 6);
|
||||
expectWagonType(suffix, "NW5", 6);
|
||||
});
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
endPaymentPhase(s.id);
|
||||
pollDb<ScheduleRow>(
|
||||
"window FULL + DONE",
|
||||
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
|
||||
[s.id],
|
||||
(row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
|
||||
);
|
||||
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 wagons allocated").to.eq(54));
|
||||
});
|
||||
});
|
||||
|
||||
it("the 7 leftover waiters expire in ONE day-end sweep; every contract stays ACTIVE", () => {
|
||||
LEFTOVER.forEach((suffix) => pollBookingStatus(suffix, "EXPIRED"));
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n FROM freight.contracts
|
||||
WHERE reference LIKE 'CTR-IMP-%-GR%' AND status = 'CONTRACT_ACTIVE'
|
||||
AND deleted_at IS NULL AND created_at > now() - interval '1 hour'`,
|
||||
[],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), "GENERAL contracts survive their bookings").to.be.at.least(21),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
269
e2e/freight/cypress/e2e/flows/general_two_trains.cy.ts
Normal file
269
e2e/freight/cypress/e2e/flows/general_two_trains.cy.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* GENERAL two-trains-one-day — 20 GENERAL bookings against TWO 54-wagon
|
||||
* trains sharing one route-day (D+24): ONE window timeline, ONE batch
|
||||
* release, overflow cascading earliest-departure-first, and a full DUAL
|
||||
* lifecycle (both trains dispatch, run the corridor and arrive the same day).
|
||||
*
|
||||
* – both schedules share the group window (identical clocks); one
|
||||
* doc-review-complete releases the WHOLE day
|
||||
* – 20 × 6w = 120w demand vs 108: train 1 takes 9 bookings, the overflow
|
||||
* lands on train 2 (9 more), 2 wait
|
||||
* – all 18 pay → 54/54 on EACH train; the 2 waiters expire only after BOTH
|
||||
* trains conclude (the sweep defers while a sibling is open)
|
||||
* – both trains finalize, gate-pass, dispatch, checkpoint and arrive —
|
||||
* every booking ARRIVED under its own train, movements ledger per train
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
apiPost,
|
||||
bookContainers,
|
||||
clearGeneralBooking,
|
||||
completeDocReview,
|
||||
createImportSchedule,
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
forceReservationExpiry,
|
||||
markPaid,
|
||||
opsStaff,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
pollDb,
|
||||
resetCorridorDay,
|
||||
seedImportContract,
|
||||
setPriority,
|
||||
withBooking,
|
||||
type ScheduleRow,
|
||||
} from "./import-utils";
|
||||
|
||||
const DEPARTURE_1 = departureAt(24);
|
||||
const DEPARTURE_2 = new Date(DEPARTURE_1.getTime() + 2 * 3_600_000); // same EAT day
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE_1);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const USERS = Array.from({ length: 20 }, (_, i) =>
|
||||
`GT${String(i + 1).padStart(2, "0")}`,
|
||||
);
|
||||
const RIDERS = USERS.slice(0, 18); // 9 per train
|
||||
const WAITERS = USERS.slice(18); // 2 expire after both trains conclude
|
||||
|
||||
interface DayScheduleRow {
|
||||
id: string;
|
||||
window_phase: string;
|
||||
booking_window_status: string;
|
||||
status: string;
|
||||
scheduled_departure_date: string;
|
||||
}
|
||||
|
||||
/** Both schedules of the day, earliest departure first. */
|
||||
function dayShedules() {
|
||||
return db<DayScheduleRow>(
|
||||
`SELECT ts.id, ts.window_phase, ts.booking_window_status, ts.status,
|
||||
ts.scheduled_departure_date
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = 'DJIB_PORT'
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = 'KALITY'
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $1::timestamptz))) < 14400
|
||||
ORDER BY ts.scheduled_departure_date ASC`,
|
||||
[DEPARTURE_1.toISOString()],
|
||||
);
|
||||
}
|
||||
|
||||
function withBothSchedules(fn: (first: DayScheduleRow, second: DayScheduleRow) => void) {
|
||||
dayShedules().then(({ rows }) => {
|
||||
expect(rows, "two schedules on the day").to.have.length(2);
|
||||
fn(rows[0], rows[1]);
|
||||
});
|
||||
}
|
||||
|
||||
describe("GENERAL two trains, one day: shared window, overflow, dual lifecycle", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
USERS.forEach((suffix) =>
|
||||
seedImportContract({ suffix, reference: stampedRef(suffix), kind: "GENERAL" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("operations schedules TWO 54-wagon trains on one day — one shared window, forced open", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE_1);
|
||||
createImportSchedule({ departure: DEPARTURE_1, locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"] });
|
||||
createImportSchedule({ departure: DEPARTURE_2, locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"] });
|
||||
|
||||
// One clock for the whole day: force IDENTICAL window timestamps on both
|
||||
// siblings (the group rule's shared timeline, arranged deterministically).
|
||||
withBothSchedules((first, second) => {
|
||||
db(
|
||||
`UPDATE freight.train_schedules
|
||||
SET window_opens_at = now() - interval '1 minute',
|
||||
window_closes_at = now() + interval '60 minutes'
|
||||
WHERE id = ANY($1::uuid[])`,
|
||||
[[first.id, second.id]],
|
||||
);
|
||||
[first.id, second.id].forEach((id) =>
|
||||
pollDb<ScheduleRow>(
|
||||
`schedule ${id} OPEN`,
|
||||
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
|
||||
[id],
|
||||
(row) => row?.window_phase === "OPEN" && row?.booking_window_status === "OPEN",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("20 users book into the day (never a specific train); every booking clears per booking", () => {
|
||||
USERS.forEach((suffix, i) => {
|
||||
bookContainers({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed: 17_000 + i * 15,
|
||||
twenty: 12,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearGeneralBooking(suffix, BOOKING_DAY);
|
||||
});
|
||||
USERS.forEach((suffix, i) => setPriority(suffix, i + 1));
|
||||
});
|
||||
|
||||
it("ONE doc-review-complete releases the whole day — 18 reserved across both trains, 2 wait", () => {
|
||||
withBothSchedules((first, second) => {
|
||||
db(
|
||||
`UPDATE freight.train_schedules
|
||||
SET window_closes_at = now() - interval '1 second'
|
||||
WHERE id = ANY($1::uuid[]) AND window_phase = 'OPEN'`,
|
||||
[[first.id, second.id]],
|
||||
);
|
||||
[first.id, second.id].forEach((id) =>
|
||||
pollDb<ScheduleRow>(
|
||||
`schedule ${id} DOC_REVIEW`,
|
||||
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
|
||||
[id],
|
||||
(row) => row?.window_phase === "DOC_REVIEW",
|
||||
),
|
||||
);
|
||||
// Staff complete doc review on ONE train — the group stamp releases both.
|
||||
completeDocReview(first.id);
|
||||
pollDb<ScheduleRow>(
|
||||
"sibling released by the same action",
|
||||
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
|
||||
[second.id],
|
||||
(row) => row?.window_phase === "PAYMENT" || row?.window_phase === "DONE",
|
||||
);
|
||||
});
|
||||
RIDERS.forEach((suffix) =>
|
||||
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
|
||||
);
|
||||
WAITERS.forEach((suffix) =>
|
||||
withBooking(suffix, (b) => {
|
||||
expect(b.status, `${suffix} waiting`).to.eq("FULLY_EXECUTED");
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("all 18 pay — 54/54 on EACH train, overflow filled earliest-departure-first", () => {
|
||||
RIDERS.forEach((suffix) => {
|
||||
markPaid(suffix);
|
||||
pollAllocations(suffix, 6);
|
||||
});
|
||||
withBothSchedules((first, second) => {
|
||||
[first.id, second.id].forEach((id) => {
|
||||
pollDb<{ n: string }>(
|
||||
`train ${id} carries 9 bookings`,
|
||||
`SELECT count(*) AS n FROM freight.train_schedule_bookings
|
||||
WHERE train_schedule_id = $1 AND deleted_at IS NULL`,
|
||||
[id],
|
||||
(row) => Number(row?.n) === 9,
|
||||
15,
|
||||
);
|
||||
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`,
|
||||
[id],
|
||||
).then(({ rows }) => expect(Number(rows[0].n), "54 wagons").to.eq(54));
|
||||
});
|
||||
// The top-priority bookings ride the EARLIEST departure.
|
||||
withBooking("GT01", (b) =>
|
||||
expect(b.train_schedule_id, "GT01 on the first train").to.eq(first.id),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("both trains conclude FULL — only then do the 2 waiters expire (sweep defers for siblings)", () => {
|
||||
withBothSchedules((first, second) => {
|
||||
db(
|
||||
`UPDATE freight.train_schedules
|
||||
SET payment_phase_ends_at = now() - interval '1 second'
|
||||
WHERE id = ANY($1::uuid[]) AND window_phase = 'PAYMENT'`,
|
||||
[[first.id, second.id]],
|
||||
);
|
||||
[first.id, second.id].forEach((id) =>
|
||||
pollDb<ScheduleRow>(
|
||||
`schedule ${id} FULL + DONE`,
|
||||
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
|
||||
[id],
|
||||
(row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
|
||||
),
|
||||
);
|
||||
});
|
||||
WAITERS.forEach((suffix) => pollBookingStatus(suffix, "EXPIRED"));
|
||||
});
|
||||
|
||||
it("dual lifecycle: both trains gate-pass, dispatch, run the corridor and arrive", () => {
|
||||
withBothSchedules((first, second) => {
|
||||
[first.id, second.id].forEach((id) => {
|
||||
pollDb<ScheduleRow>(
|
||||
`schedule ${id} finalized`,
|
||||
`SELECT status FROM freight.train_schedules WHERE id = $1`,
|
||||
[id],
|
||||
(row) => row?.status === "SCHEDULED",
|
||||
10,
|
||||
);
|
||||
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`)
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`)
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
[1, 2, 3, 4].forEach((seq) => {
|
||||
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/checkpoints`, {
|
||||
sequenceNo: seq,
|
||||
kind: "PASSED",
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/checkpoints`, {
|
||||
sequenceNo: 5,
|
||||
kind: "ARRIVED",
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
pollDb<ScheduleRow>(
|
||||
`schedule ${id} ARRIVED`,
|
||||
`SELECT status FROM freight.train_schedules WHERE id = $1`,
|
||||
[id],
|
||||
(row) => row?.status === "ARRIVED",
|
||||
20,
|
||||
);
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n FROM freight.wagon_movements WHERE train_schedule_id = $1`,
|
||||
[id],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), `train ${id} movements`).to.be.at.least(54),
|
||||
);
|
||||
});
|
||||
});
|
||||
RIDERS.forEach((suffix) => pollBookingStatus(suffix, "ARRIVED", 20));
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
@@ -120,6 +120,10 @@ export interface SeedContractOpts {
|
||||
freight?: "CONTAINER" | "BULK";
|
||||
originCode?: string;
|
||||
destCode?: string;
|
||||
/** GENERAL = multi-booking drawdown contract (status CONTRACT_ACTIVE). */
|
||||
kind?: "ONE_TIME" | "GENERAL";
|
||||
/** GENERAL only: quantity cap on the 20ft scope row (40ft stays uncapped). */
|
||||
cap20?: number;
|
||||
}
|
||||
|
||||
export function seedImportContract(opts: SeedContractOpts) {
|
||||
@@ -127,6 +131,8 @@ export function seedImportContract(opts: SeedContractOpts) {
|
||||
const customs = opts.customs ?? false;
|
||||
const direction = opts.direction ?? "IMPORT";
|
||||
const freight = opts.freight ?? "CONTAINER";
|
||||
const kind = opts.kind ?? "ONE_TIME";
|
||||
const status = kind === "GENERAL" ? "CONTRACT_ACTIVE" : "FULLY_EXECUTED";
|
||||
// Pre-booking boundary milestone for Path B contracts differs by direction.
|
||||
const boundary = direction === "EXPORT" ? "EXPORT_RELEASED" : "DO_COLLECTED";
|
||||
db(
|
||||
@@ -146,11 +152,11 @@ export function seedImportContract(opts: SeedContractOpts) {
|
||||
ELSE 1
|
||||
END
|
||||
LIMIT 1),
|
||||
'ONE_TIME', $2::text, $9::text,
|
||||
$11::text, $2::text, $9::text,
|
||||
(SELECT st.id FROM freight.service_types st ORDER BY st.created_at LIMIT 1),
|
||||
$3, $4,
|
||||
CASE WHEN $4 THEN 'CLEARANCE_READY_FOR_BOOKING' ELSE 'NOT_APPLICABLE' END,
|
||||
'FULLY_EXECUTED', now(), now() - interval '1 day',
|
||||
$12::text, now(), now() - interval '1 day',
|
||||
now() + interval '60 days', 'E2E import-corridor fixture contract'
|
||||
FROM freight.companies comp
|
||||
WHERE comp.tin = $5
|
||||
@@ -175,8 +181,10 @@ export function seedImportContract(opts: SeedContractOpts) {
|
||||
RETURNING id
|
||||
), scope_container AS (
|
||||
INSERT INTO freight.contract_cargo_scope
|
||||
(contract_id, container_size, cargo_free_text)
|
||||
SELECT c.id, v.size, 'E2E import corridor cargo'
|
||||
(contract_id, container_size, quantity_cap, cargo_free_text)
|
||||
SELECT c.id, v.size,
|
||||
CASE WHEN v.size = '20ft' THEN $13::numeric END,
|
||||
'E2E import corridor cargo'
|
||||
FROM c CROSS JOIN (VALUES ('20ft'), ('40ft')) AS v(size)
|
||||
WHERE $9::text = 'CONTAINER'
|
||||
), scope_bulk AS (
|
||||
@@ -203,6 +211,9 @@ export function seedImportContract(opts: SeedContractOpts) {
|
||||
opts.suffix,
|
||||
freight,
|
||||
boundary,
|
||||
kind,
|
||||
status,
|
||||
opts.cap20 ?? null,
|
||||
],
|
||||
);
|
||||
// Backfill the boundary milestone when the insert above was skipped because
|
||||
@@ -414,6 +425,34 @@ export function bookBulk(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a GENERAL booking through its PER-BOOKING clearance chain (Path A):
|
||||
* AWAITING_DOCUMENTS → upload one doc → GL approves it → finalize →
|
||||
* CLEARANCE_READY → customer proceeds with the shipment day →
|
||||
* OPERATION_REQUEST_PENDING → ops accept → FULLY_EXECUTED (pool). The e2e
|
||||
* seed configures no required documents, so one ad-hoc doc satisfies the
|
||||
* 100%-approved gate.
|
||||
*/
|
||||
export function clearGeneralBooking(suffix: string, scheduledDate: string) {
|
||||
withBooking(suffix, (b) => {
|
||||
expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS");
|
||||
glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e");
|
||||
apiPost(superAdmin, `/api/bookings/${b.id}/clearance/review`, {
|
||||
fileKey: "custom_e2e",
|
||||
status: "APPROVED",
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
apiPost(superAdmin, `/api/bookings/${b.id}/clearance/finalize`)
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
apiPost(customer, `/api/bookings/${b.id}/clearance/proceed`, { scheduledDate })
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
acceptOperation(suffix);
|
||||
}
|
||||
|
||||
/** Ops accepts the operation request → FULLY_EXECUTED (enters the day pool). */
|
||||
export function acceptOperation(suffix: string) {
|
||||
withBooking(suffix, (b) => {
|
||||
|
||||
@@ -111,11 +111,14 @@ describe("mixed import: dead mixed cycle, mixed recovery, mixed ride-alongs", {
|
||||
["MRC", "MRB"].forEach((suffix) => forceReservationExpiry(suffix));
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
endPaymentPhase(s.id);
|
||||
// Same-tick guard-loop can chain PRE_WINDOW straight into OPEN when the
|
||||
// reopen instant falls inside office hours — assert it left PAYMENT
|
||||
// without concluding FULL/DONE, whichever phase it lands on.
|
||||
pollDb<ScheduleRow>(
|
||||
"window reopens (PRE_WINDOW)",
|
||||
"window concludes not-full (PRE_WINDOW or fast-forwarded to OPEN)",
|
||||
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
|
||||
[s.id],
|
||||
(row) => row?.window_phase === "PRE_WINDOW",
|
||||
(row) => !!row && row.window_phase !== "PAYMENT" && row.window_phase !== "DONE",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,11 +38,13 @@ WHERE c.tin = '0102030405'
|
||||
);
|
||||
|
||||
-- 2c. Link the demo portal user to the company, onboarding already done.
|
||||
-- (active_profile_type was dropped by migration 2450 — the "active mode"
|
||||
-- column no longer exists; bookings resolve the profile per shipment.)
|
||||
INSERT INTO freight.external_profiles
|
||||
(id, user_id, company_id, first_name, last_name, is_primary_contact,
|
||||
active_profile_type, onboarding_step, onboarding_completed)
|
||||
onboarding_step, onboarding_completed)
|
||||
SELECT gen_random_uuid(), u.id, c.id, 'Demo', 'User', true,
|
||||
'importer', 'done', true
|
||||
'done', true
|
||||
FROM iam.users u
|
||||
JOIN freight.companies c ON c.tin = '0102030405'
|
||||
WHERE u.email = 'user@gmail.com'
|
||||
|
||||
Reference in New Issue
Block a user