Merge pull request #935 from Tria-plc/freight_feature/usermanagement

changes
This commit is contained in:
marshal
2026-07-23 14:07:03 +03:00
committed by GitHub
26 changed files with 1717 additions and 115 deletions

View File

@@ -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",
);
});
});

View 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 {};

View 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 {};

View 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 {};

View 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 {};

View File

@@ -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) => {

View File

@@ -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",
);
});
});

View File

@@ -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'