add e2e test

This commit is contained in:
Marshal
2026-07-22 23:44:32 +00:00
parent b0f561a935
commit 2481f43f1f
32 changed files with 4702 additions and 125 deletions

View File

@@ -0,0 +1,215 @@
/**
* BULK IMPORT critical-scenario matrix — bulk-specific corridor edge cases:
*
* 1. gate: a wheat booking on a day with no open window is rejected
* 2. sub-corridor bulk (NAGAD → MOJO, 700 T) rides the through-train next
* to a DJIB_PORT → KALITY 1 400 T booking — corridor-aware batch
* 3. bulk intercity ride-along (MOJO → KALITY, DOMESTIC, 140 T): dateless
* booking, staff accept onto the import train's free leg, pay window
* opens, paid + linked
* 4. WHOLE-TRAIN giant: a 4 000 T booking (58 wagons worth) alone on a
* 54-wagon train → partial offer of the FULL consist (3 780 T), gateway
* settle applies the split, the train is FULL from ONE booking, and the
* 220 T outstanding must be rebooked EXACTLY on a later train.
*
* Sequential steps — retries off.
*/
import {
acceptOperation,
apiPost,
bookBulk,
closeBookingWindow,
completeDocReview,
createImportSchedule,
db,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
forceWindowOpen,
markPaid,
opsStaff,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
settleViaGateway,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(15);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const GIANT_DEPARTURE = departureAt(16);
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
const REMAINDER_DEPARTURE = departureAt(17);
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
const NO_WINDOW_DAY = eatDayStr(departureAt(19)); // no schedule exists there
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train giant", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
seedImportContract({ suffix: "BM1", reference: stampedRef("BM1"), freight: "BULK" });
seedImportContract({
suffix: "BMSUB",
reference: stampedRef("BMSUB"),
freight: "BULK",
originCode: "NAGAD",
destCode: "MOJO",
});
seedImportContract({
suffix: "BMIC",
reference: stampedRef("BMIC"),
freight: "BULK",
direction: "DOMESTIC",
originCode: "MOJO",
destCode: "KALITY",
});
seedImportContract({ suffix: "BMG", reference: stampedRef("BMG"), freight: "BULK" });
});
it("operations prepares the corridor and the D+15 bulk train with an open window", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
resetCorridorDay(GIANT_DEPARTURE);
resetCorridorDay(REMAINDER_DEPARTURE);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-IMP-25", "LOCO-IMP-26"],
kind: "bulk",
});
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
});
it("gate: a wheat booking on a day with no open window is rejected", () => {
bookBulk({
suffix: "BM1",
tons: 140,
scheduledDate: NO_WINDOW_DAY,
expectFailure: "booking window",
});
});
it("a through-corridor 1 400 T booking and a NAGAD→MOJO 700 T booking share the train", () => {
bookBulk({ suffix: "BM1", tons: 1400, scheduledDate: BOOKING_DAY });
acceptOperation("BM1");
bookBulk({ suffix: "BMSUB", tons: 700, scheduledDate: BOOKING_DAY });
acceptOperation("BMSUB");
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
["BM1", "BMSUB"].forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
markPaid("BM1");
pollAllocations("BM1", 20);
markPaid("BMSUB");
pollAllocations("BMSUB", 10);
withSchedule(DEPARTURE, (s) => {
withBooking("BM1", (b) => expect(b.train_schedule_id).to.eq(s.id));
withBooking("BMSUB", (b) => expect(b.train_schedule_id).to.eq(s.id));
});
});
it("bulk intercity ride-along: dateless DOMESTIC wheat accepted onto the import train's free leg", () => {
bookBulk({ suffix: "BMIC", tons: 140 }); // 2 wagons MOJO → KALITY, dateless
acceptOperation("BMIC");
withSchedule(DEPARTURE, (s) => {
withBooking("BMIC", (b) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${s.id}/intercity/accept`, {
bookingIds: [b.id],
})
.its("status")
.should("be.oneOf", [200, 201]);
});
});
pollBookingStatus("BMIC", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
withBooking("BMIC", (b) => {
expect(b.payment_deadline, "ride-along pay window opened").to.be.a("string");
});
markPaid("BMIC");
withSchedule(DEPARTURE, (s) => {
withBooking("BMIC", (b) => {
expect(b.train_schedule_id, "linked to the import train").to.eq(s.id);
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.train_schedule_bookings
WHERE booking_id = $1 AND train_schedule_id = $2 AND deleted_at IS NULL`,
[b.id, s.id],
).then(({ rows }) => expect(Number(rows[0].n), "link row").to.eq(1));
});
});
});
it("whole-train giant: 4 000 T alone gets a FULL-consist partial offer (3 780 T) and fills the train", () => {
createImportSchedule({
departure: GIANT_DEPARTURE,
locoPair: ["LOCO-IMP-27", "LOCO-IMP-28"],
kind: "bulk",
});
withSchedule(GIANT_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
bookBulk({ suffix: "BMG", tons: 4000, scheduledDate: GIANT_DAY });
acceptOperation("BMG");
withSchedule(GIANT_DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
pollBookingStatus("BMG", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
withBooking("BMG", (b) => {
pollDb<{ status: string }>(
"BMG open partial offer",
`SELECT status FROM freight.booking_batch_offers
WHERE booking_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[b.id],
(row) => row?.status === "OFFERED",
10,
);
});
settleViaGateway("BMG");
pollAllocations("BMG", 54);
withBooking("BMG", (b) => {
expect(b.is_split, "BMG is split").to.eq(true);
});
withSchedule(GIANT_DEPARTURE, (s) => {
endPaymentPhase(s.id);
pollDb<ScheduleRow>(
"giant train FULL + DONE from one booking",
`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",
);
});
});
it("the giant's 220 T outstanding must be rebooked EXACTLY on a later train", () => {
createImportSchedule({
departure: REMAINDER_DEPARTURE,
locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"],
kind: "bulk",
});
withSchedule(REMAINDER_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
bookBulk({
suffix: "BMG",
tons: 100,
scheduledDate: REMAINDER_DAY,
expectFailure: "must take the whole",
});
bookBulk({ suffix: "BMG", tons: 220, scheduledDate: REMAINDER_DAY });
pollBookingStatus("BMG", "OPERATION_REQUEST_PENDING", 5);
});
});
export {};

View File

@@ -0,0 +1,171 @@
/**
* BULK EXPORT — FCFS capacity truth (mirror of export_fcfs_space):
*
* D+9: three wheat bookings (1 400 + 1 400 + 980 T = 54 wagons) accept
* first and hold the train before any payment; three late 700 T exporters
* are REJECTED AT SUBMISSION by the whole-train space gate. The three pay
* → FULL.
*
* D+10: the whole-or-nothing giant — 4 060 T (58 wagons) rejected against
* the empty train; rebooked at exactly 3 780 T (54 wagons) it reserves the
* whole consist alone, pays, allocates 54/54 → FULL; a 70 T afterthought
* bounces.
*
* Sequential steps — retries off.
*/
import {
acceptExport,
bookBulk,
createImportSchedule,
db,
departureAt,
eatDayStr,
ensureExportRoute,
EXP_DEST,
EXP_ORIGIN,
forceWindowOpen,
markPaid,
pollAllocations,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withExportSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(9);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const GIANT_DEPARTURE = departureAt(10);
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
const FIRST = [
{ suffix: "YA", tons: 1400, wagons: 20 },
{ suffix: "YB", tons: 1400, wagons: 20 },
{ suffix: "YC", tons: 980, wagons: 14 },
];
const LATE = ["YL1", "YL2", "YL3"];
function seedBulkExport(suffix: string) {
seedImportContract({
suffix,
reference: stampedRef(suffix),
freight: "BULK",
direction: "EXPORT",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
}
describe("bulk export FCFS: reservations hold capacity, whole-or-nothing gate", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
[...FIRST.map((b) => b.suffix), ...LATE, "YG", "YS"].forEach(seedBulkExport);
});
it("operations prepares the export corridor and the D+9 CW4 train with an open window", () => {
ensureExportRoute();
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
resetCorridorDay(GIANT_DEPARTURE, EXP_DEST, EXP_ORIGIN);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-EXP-3", "LOCO-EXP-4"],
kind: "bulk",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
});
it("three exporters book wheat and are accepted — 54 wagons reserved BEFORE any payment", () => {
FIRST.forEach((b) => {
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
acceptExport(b.suffix);
});
});
it("three late exporters are rejected at submission — the space gate reports no room", () => {
LATE.forEach((suffix) => {
bookBulk({
suffix,
tons: 700,
scheduledDate: BOOKING_DAY,
expectFailure: /space|window/i,
});
});
});
it("the three reserved pay — 54/54 allocated and the export window flips FULL", () => {
FIRST.forEach((b) => {
markPaid(b.suffix);
pollAllocations(b.suffix, b.wagons);
});
withExportSchedule(DEPARTURE, (s) => {
pollDb<ScheduleRow>(
"export window FULL",
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.booking_window_status === "FULL",
);
});
});
it("whole-or-nothing: a 4 060 T giant is rejected against the empty D+10 train", () => {
createImportSchedule({
departure: GIANT_DEPARTURE,
locoPair: ["LOCO-EXP-5", "LOCO-EXP-6"],
kind: "bulk",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(GIANT_DEPARTURE, (s) => forceWindowOpen(s.id, 60));
bookBulk({
suffix: "YG",
tons: 4060, // 58 wagons > 54 — export never splits
scheduledDate: GIANT_DAY,
expectFailure: /space|window/i,
});
});
it("rebooked at exactly 3 780 T the giant reserves the whole train alone, pays, fills it", () => {
bookBulk({ suffix: "YG", tons: 3780, scheduledDate: GIANT_DAY });
acceptExport("YG");
markPaid("YG");
pollAllocations("YG", 54);
withExportSchedule(GIANT_DEPARTURE, (s) => {
pollDb<ScheduleRow>(
"giant train FULL from one booking",
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.booking_window_status === "FULL",
);
withBooking("YG", (b) => {
expect(b.train_schedule_id, "giant rides its train").to.eq(s.id);
});
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("a 70 T afterthought bounces off the FULL train", () => {
bookBulk({
suffix: "YS",
tons: 70,
scheduledDate: GIANT_DAY,
expectFailure: /space|window/i,
});
});
});
export {};

View File

@@ -0,0 +1,251 @@
/**
* BULK EXPORT journey — six wheat bookings fill the 54-wagon CW4 train on the
* reversed corridor KALITY → MOJO → E2E_AWASH → DIRE_DAWA → NAGAD →
* DJIB_PORT, all inside the ONE FCFS export window, then the full life of the
* train to Djibouti Port and the export customs tail.
*
* The six bookings (70 T per CW4 wagon — Σ = 54 wagons / 3 780 T):
* XBF1 customs + USD 560 T = 8 wagons
* XBF2 customs + ETB 420 T = 6 wagons
* XBF3 self + ETB 420 T = 6 wagons
* XBF4 self + ETB 420 T = 6 wagons
* XBF5 customs + USD 1 540 T = 22 wagons (the ≥22-wagon giant)
* XBF6 self + USD 420 T = 6 wagons
*
* Sequential steps of one journey — retries off.
*/
import {
acceptExport,
apiPost,
bookBulk,
completeBookingMilestone,
createImportSchedule,
db,
dbBooking,
departureAt,
eatDayStr,
ensureExportRoute,
EXP_DEST,
EXP_ORIGIN,
expectMilestoneDone,
forceWindowOpen,
glUpload,
markPaid,
opsStaff,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withExportSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(8);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
const BOOKINGS: Array<{
suffix: string;
customs: boolean;
currency: "ETB" | "USD";
tons: number;
wagons: number;
}> = [
{ suffix: "XBF1", customs: true, currency: "USD", tons: 560, wagons: 8 },
{ suffix: "XBF2", customs: true, currency: "ETB", tons: 420, wagons: 6 },
{ suffix: "XBF3", customs: false, currency: "ETB", tons: 420, wagons: 6 },
{ suffix: "XBF4", customs: false, currency: "ETB", tons: 420, wagons: 6 },
{ suffix: "XBF5", customs: true, currency: "USD", tons: 1540, wagons: 22 },
{ suffix: "XBF6", customs: false, currency: "USD", tons: 420, wagons: 6 },
];
const CUSTOMS = BOOKINGS.filter((b) => b.customs).map((b) => b.suffix);
const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs).map((b) => b.suffix);
function withScheduleId(fn: (id: string, s: ScheduleRow) => void) {
withExportSchedule(DEPARTURE, (s) => fn(s.id, s));
}
describe("bulk export: six wheat bookings fill the 54-wagon CW4 train (FCFS)", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
for (const b of BOOKINGS) {
seedImportContract({
suffix: b.suffix,
reference: stampedRef(b.suffix),
currency: b.currency,
customs: b.customs,
freight: "BULK",
direction: "EXPORT",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
}
});
it("operations prepares the export corridor and a 54-wagon CW4 train — window forced open", () => {
ensureExportRoute();
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"],
kind: "bulk",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withScheduleId((id) => forceWindowOpen(id, 60));
});
it("six exporters book wheat inside the one window", () => {
BOOKINGS.forEach((b) => {
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
});
});
it("each accept reserves FCFS immediately — pay deadlines clamped to the window close", () => {
BOOKINGS.forEach((b) => acceptExport(b.suffix));
withScheduleId((_, s) => {
BOOKINGS.forEach((b) => {
withBooking(b.suffix, (row) => {
expect(row.payment_deadline, `${b.suffix} pay deadline`).to.be.a("string");
expect(
new Date(row.payment_deadline!).getTime(),
`${b.suffix} deadline never outlives the window close`,
).to.be.at.most(new Date(s.window_closes_at!).getTime());
});
});
});
BOOKINGS.forEach((b) => {
withBooking(b.suffix, (row) => {
pollDb<{ currency: string }>(
`${b.suffix} invoice`,
`SELECT currency FROM freight.invoices
WHERE source_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[row.id],
(inv) => inv?.currency === b.currency,
10,
);
});
});
});
it("all six pay — allocated 54/54, the export window flips FULL, staff finalize", () => {
BOOKINGS.forEach((b) => {
markPaid(b.suffix);
pollAllocations(b.suffix, b.wagons);
});
withScheduleId((id) => {
pollDb<ScheduleRow>(
"export window FULL",
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
[id],
(s) => s?.booking_window_status === "FULL",
);
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 allocated").to.eq(54));
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/finalize`)
.its("status")
.should("be.oneOf", [200, 201]);
pollDb<ScheduleRow>(
"schedule SCHEDULED",
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(s) => s?.status === "SCHEDULED",
10,
);
});
});
it("gate pass + T1 uploads, the train dispatches and runs the corridor to Djibouti Port", () => {
withScheduleId((id) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`)
.its("status")
.should("be.oneOf", [200, 201]);
});
CUSTOMS.forEach((suffix) => {
withBooking(suffix, (b) => {
glUpload(`/api/contracts/bookings/${b.id}/transport-document`);
});
});
withScheduleId((id) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`)
.its("status")
.should("be.oneOf", [200, 201]);
pollDb<ScheduleRow>(
"schedule DISPATCHED",
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(s) => s?.status === "DISPATCHED",
10,
);
[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 ARRIVED",
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(s) => s?.status === "ARRIVED",
20,
);
});
BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "ARRIVED", 20));
});
it("GL Djibouti closes the export tail (T1 close → offloaded) on every customs booking", () => {
CUSTOMS.forEach((suffix) => {
withBooking(suffix, (b) => {
apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/t1-close`)
.its("status")
.should("be.oneOf", [200, 201]);
});
completeBookingMilestone(suffix, "OFFLOADED");
expectMilestoneDone(suffix, "T1_CLOSED");
expectMilestoneDone(suffix, "OFFLOADED");
});
});
it("the self-clearance bookings arrived clean — no customs tail required", () => {
SELF_CLEAR.forEach((suffix) => {
withBooking(suffix, (b) => {
expect(b.status, `${suffix} final status`).to.eq("ARRIVED");
});
dbBooking(suffix).then(({ rows }) => {
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.clearance_milestones
WHERE booking_id = $1 AND milestone_code = 'T1_CLOSED'
AND status = 'COMPLETED' AND deleted_at IS NULL`,
[rows[0].id],
).then(({ rows: ms }) =>
expect(Number(ms[0].n), `${suffix} has no T1 tail`).to.eq(0),
);
});
});
});
});
export {};

View File

@@ -0,0 +1,193 @@
/**
* BULK EXPORT critical matrix (reversed corridor, D+13):
*
* 1. gate: a wheat booking on a day with no open window is rejected
* 2. sub-corridor bulk export (DIRE_DAWA → DJIB_PORT, 980 T) boards
* mid-route and shares the train with a KALITY 2 800 T through-booking
* 3. directional FULL: through 40w + sub 14w commit the border edges → the
* export window flips FULL while the home leg still has 14 free wagons
* 4. bulk intercity ride-along on the FULL export train's free home leg
* (KALITY → MOJO, DOMESTIC, 140 T, dateless) — accepted, pay window
* clamped to the EXPORT close, paid + linked; the window stays FULL
* 5. same-day sibling export train keeps its OWN window (no group rule)
*
* Sequential steps — retries off.
*/
import {
acceptExport,
apiPost,
bookBulk,
createImportSchedule,
db,
departureAt,
eatDayStr,
ensureExportRoute,
EXP_DEST,
EXP_ORIGIN,
forceWindowOpen,
markPaid,
opsStaff,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withExportSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(13);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const NO_WINDOW_DAY = eatDayStr(departureAt(20)); // no schedule exists there
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
describe("bulk export matrix: sub-corridor, directional FULL, bulk intercity, own windows", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
seedImportContract({
suffix: "YM1",
reference: stampedRef("YM1"),
freight: "BULK",
direction: "EXPORT",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
seedImportContract({
suffix: "YMSUB",
reference: stampedRef("YMSUB"),
freight: "BULK",
direction: "EXPORT",
originCode: "DIRE_DAWA",
destCode: EXP_DEST,
});
seedImportContract({
suffix: "YMIC",
reference: stampedRef("YMIC"),
freight: "BULK",
direction: "DOMESTIC",
originCode: EXP_ORIGIN,
destCode: "MOJO",
});
});
it("operations prepares the export corridor and the D+13 CW4 train with an open window", () => {
ensureExportRoute();
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-EXP-11", "LOCO-EXP-12"],
kind: "bulk",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 90));
});
it("gate: a wheat booking on a day with no open window is rejected", () => {
bookBulk({
suffix: "YM1",
tons: 140,
scheduledDate: NO_WINDOW_DAY,
expectFailure: "booking window",
});
});
it("a KALITY through-booking (2 800 T) and a DIRE_DAWA sub-corridor booking (980 T) share the train", () => {
bookBulk({ suffix: "YM1", tons: 2800, scheduledDate: BOOKING_DAY });
acceptExport("YM1");
bookBulk({ suffix: "YMSUB", tons: 980, scheduledDate: BOOKING_DAY });
acceptExport("YMSUB");
markPaid("YM1");
pollAllocations("YM1", 40);
markPaid("YMSUB");
pollAllocations("YMSUB", 14);
withExportSchedule(DEPARTURE, (s) => {
withBooking("YM1", (b) => expect(b.train_schedule_id).to.eq(s.id));
withBooking("YMSUB", (b) => expect(b.train_schedule_id).to.eq(s.id));
});
});
it("the border edges are committed — the export window flips FULL (home leg still free)", () => {
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("bulk intercity ride-along boards the FULL train's free home leg — clamped, paid, linked", () => {
bookBulk({ suffix: "YMIC", tons: 140 }); // 2 wagons KALITY → MOJO, dateless
withBooking("YMIC", (b) => {
apiPost(opsStaff, `/api/bookings/${b.id}/operation/review`, { decision: "ACCEPT" })
.its("status")
.should("be.oneOf", [200, 201]);
});
pollBookingStatus("YMIC", "FULLY_EXECUTED", 10);
withExportSchedule(DEPARTURE, (s) => {
withBooking("YMIC", (b) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${s.id}/intercity/accept`, {
bookingIds: [b.id],
})
.its("status")
.should("be.oneOf", [200, 201]);
});
});
pollBookingStatus("YMIC", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
withExportSchedule(DEPARTURE, (s) => {
withBooking("YMIC", (b) => {
expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
new Date(s.window_closes_at!).getTime(),
);
});
});
markPaid("YMIC");
withExportSchedule(DEPARTURE, (s) => {
withBooking("YMIC", (b) => {
expect(b.train_schedule_id, "linked to the export train").to.eq(s.id);
});
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-3", "LOCO-EXP-4"],
kind: "bulk",
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);
expect(rows[0].window_phase, "own fresh window").to.eq("PRE_WINDOW");
expect(
new Date(rows[0].window_closes_at).getTime(),
"own close, anchored to its own departure",
).to.not.eq(new Date(anchor.window_closes_at!).getTime());
});
});
});
});
export {};

View File

@@ -0,0 +1,171 @@
/**
* BULK EXPORT — pay-or-lose (mirror of export_pay_or_lose):
*
* D+11: ZA + ZB reserve 40 wagons of wheat and pay. ZC reserves the last 14
* (980 T) but never pays; late ZD is rejected for space while ZC's hold
* lives; ZC expires → ZD immediately books the freed 980 T, pays, allocates.
*
* D+12: five bookings reserve the whole train (840×4 + 420 T), only three
* pay. The window CLOSE passes → phase DONE, the two unpaid expire, export
* never reopens (cycle counter stays 1).
*
* Sequential steps — retries off.
*/
import {
acceptExport,
bookBulk,
createImportSchedule,
db,
departureAt,
eatDayStr,
ensureExportRoute,
EXP_DEST,
EXP_ORIGIN,
forceReservationExpiry,
forceWindowOpen,
markPaid,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withExportSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(11);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const CLOSE_DEPARTURE = departureAt(12);
const CLOSE_DAY = eatDayStr(CLOSE_DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
function seedBulkExport(suffix: string) {
seedImportContract({
suffix,
reference: stampedRef(suffix),
freight: "BULK",
direction: "EXPORT",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
}
const CLOSERS = [
{ suffix: "ZQA", tons: 840, wagons: 12, pays: true },
{ suffix: "ZQB", tons: 840, wagons: 12, pays: true },
{ suffix: "ZQC", tons: 840, wagons: 12, pays: true },
{ suffix: "ZQD", tons: 840, wagons: 12, pays: false },
{ suffix: "ZQE", tons: 420, wagons: 6, pays: false },
];
describe("bulk export pay-or-lose: expiry frees space; close expires the unpaid", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
["ZA", "ZB", "ZC", "ZD", ...CLOSERS.map((c) => c.suffix)].forEach(seedBulkExport);
});
it("operations prepares the export corridor and the D+11 CW4 train with an open window", () => {
ensureExportRoute();
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
resetCorridorDay(CLOSE_DEPARTURE, EXP_DEST, EXP_ORIGIN);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-EXP-7", "LOCO-EXP-8"],
kind: "bulk",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
});
it("ZA and ZB reserve and pay 40 wagons; ZC reserves the last 14 unpaid (clamped)", () => {
bookBulk({ suffix: "ZA", tons: 1400, scheduledDate: BOOKING_DAY });
acceptExport("ZA");
markPaid("ZA");
pollAllocations("ZA", 20);
bookBulk({ suffix: "ZB", tons: 1400, scheduledDate: BOOKING_DAY });
acceptExport("ZB");
markPaid("ZB");
pollAllocations("ZB", 20);
bookBulk({ suffix: "ZC", tons: 980, scheduledDate: BOOKING_DAY });
acceptExport("ZC");
withExportSchedule(DEPARTURE, (s) => {
withBooking("ZC", (b) => {
expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
new Date(s.window_closes_at!).getTime(),
);
});
});
});
it("a late exporter is rejected while ZC's unpaid reservation holds the space", () => {
bookBulk({
suffix: "ZD",
tons: 980,
scheduledDate: BOOKING_DAY,
expectFailure: /space|window/i,
});
});
it("ZC misses its pay window — EXPIRED — and ZD immediately books the freed wagons", () => {
forceReservationExpiry("ZC");
bookBulk({ suffix: "ZD", tons: 980, scheduledDate: BOOKING_DAY });
acceptExport("ZD");
markPaid("ZD");
pollAllocations("ZD", 14);
withExportSchedule(DEPARTURE, (s) => {
withBooking("ZD", (b) => expect(b.train_schedule_id, "ZD took ZC's seat").to.eq(s.id));
});
});
it("window-close day: five reservations, three payments", () => {
createImportSchedule({
departure: CLOSE_DEPARTURE,
locoPair: ["LOCO-EXP-9", "LOCO-EXP-10"],
kind: "bulk",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(CLOSE_DEPARTURE, (s) => forceWindowOpen(s.id, 60));
CLOSERS.forEach((c) => {
bookBulk({ suffix: c.suffix, tons: c.tons, scheduledDate: CLOSE_DAY });
acceptExport(c.suffix);
});
CLOSERS.filter((c) => c.pays).forEach((c) => {
markPaid(c.suffix);
pollAllocations(c.suffix, c.wagons);
});
});
it("the window CLOSES — phase DONE, the two unpaid expire, and export never reopens", () => {
withExportSchedule(CLOSE_DEPARTURE, (s) => {
db(
`UPDATE freight.train_schedules
SET window_closes_at = now() - interval '1 second'
WHERE id = $1 AND window_phase = 'OPEN'`,
[s.id],
);
pollDb<ScheduleRow>(
"export window DONE (no reopen)",
`SELECT window_phase, booking_cycle_no FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.window_phase === "DONE" && Number(row?.booking_cycle_no) === 1,
);
});
// Forced close ⇒ force the matching deadline clamp on the unpaid pair.
CLOSERS.filter((c) => !c.pays).forEach((c) => forceReservationExpiry(c.suffix));
CLOSERS.filter((c) => !c.pays).forEach((c) => pollBookingStatus(c.suffix, "EXPIRED"));
CLOSERS.filter((c) => c.pays).forEach((c) =>
withBooking(c.suffix, (b) => expect(b.status, `${c.suffix} rides`).to.eq("PAID")),
);
});
});
export {};

View File

@@ -0,0 +1,286 @@
/**
* BULK IMPORT journey 1 — six wheat bookings fill a 54-wagon CW4 train on the
* long corridor DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY,
* all in the FIRST booking window, then the full life of the train: payment,
* allocation, gate pass, T1, dispatch, checkpoint-by-checkpoint movement,
* arrival, and the post-arrival customs tail.
*
* The six bookings (70 T per CW4 wagon — Σ = 54 wagons / 3 780 T):
* BF1 customs + USD 560 T = 8 wagons
* BF2 customs + ETB 420 T = 6 wagons
* BF3 self + ETB 420 T = 6 wagons
* BF4 self + ETB 420 T = 6 wagons
* BF5 customs + USD 1 540 T = 22 wagons (the ≥22-wagon giant)
* BF6 self + USD 420 T = 6 wagons
*
* Sequential steps of one journey — retries off.
*/
import {
acceptOperation,
apiPost,
bookBulk,
closeBookingWindow,
completeBookingMilestone,
completeDocReview,
createImportSchedule,
db,
dbBooking,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
expectMilestoneDone,
forceWindowOpen,
glUpload,
markPaid,
opsStaff,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(10);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
const BOOKINGS: Array<{
suffix: string;
customs: boolean;
currency: "ETB" | "USD";
tons: number;
wagons: number;
}> = [
{ suffix: "BF1", customs: true, currency: "USD", tons: 560, wagons: 8 },
{ suffix: "BF2", customs: true, currency: "ETB", tons: 420, wagons: 6 },
{ suffix: "BF3", customs: false, currency: "ETB", tons: 420, wagons: 6 },
{ suffix: "BF4", customs: false, currency: "ETB", tons: 420, wagons: 6 },
{ suffix: "BF5", customs: true, currency: "USD", tons: 1540, wagons: 22 },
{ suffix: "BF6", customs: false, currency: "USD", tons: 420, wagons: 6 },
];
const CUSTOMS = BOOKINGS.filter((b) => b.customs).map((b) => b.suffix);
const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs).map((b) => b.suffix);
function withScheduleId(fn: (id: string, s: ScheduleRow) => void) {
withSchedule(DEPARTURE, (s) => fn(s.id, s));
}
describe("bulk import: six wheat bookings fill the 54-wagon CW4 train", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
for (const b of BOOKINGS) {
seedImportContract({
suffix: b.suffix,
reference: stampedRef(b.suffix),
currency: b.currency,
customs: b.customs,
freight: "BULK",
});
}
});
it("operations prepares the corridor and a 54-wagon BULK train — first window forced open", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
kind: "bulk",
});
withScheduleId((id) => forceWindowOpen(id, 45));
withScheduleId((_, s) => {
expect(s.booking_cycle_no, "FIRST window cycle").to.eq(1);
});
});
it("customer books all six wheat shipments inside the first window", () => {
BOOKINGS.forEach((b) => {
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
});
});
it("operations accepts all six — the whole pool is FULLY_EXECUTED (in window)", () => {
BOOKINGS.forEach((b) => acceptOperation(b.suffix));
});
it("window closes, doc review completes — the batch reserves ALL six (they fit exactly)", () => {
withScheduleId((id) => {
closeBookingWindow(id);
completeDocReview(id);
});
BOOKINGS.forEach((b) =>
pollBookingStatus(b.suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
BOOKINGS.forEach((b) => {
withBooking(b.suffix, (row) => {
expect(row.payment_deadline, `${b.suffix} pay deadline`).to.be.a("string");
pollDb<{ currency: string }>(
`${b.suffix} invoice`,
`SELECT currency FROM freight.invoices
WHERE source_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[row.id],
(inv) => inv?.currency === b.currency,
10,
);
});
});
});
it("all six pay — allocated onto the train, 54/54 wagons, window FULL and schedule finalized", () => {
BOOKINGS.forEach((b) => {
markPaid(b.suffix);
pollAllocations(b.suffix, b.wagons);
});
withScheduleId((id) => {
endPaymentPhase(id);
pollDb<ScheduleRow>(
"schedule FULL + DONE + finalized",
`SELECT window_phase, booking_window_status, status
FROM freight.train_schedules WHERE id = $1`,
[id],
(s) =>
s?.booking_window_status === "FULL" &&
s?.window_phase === "DONE" &&
s?.status === "SCHEDULED",
);
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 allocated").to.eq(54));
});
});
it("GL Djibouti: gate pass granted, T1 documents uploaded for the customs bookings", () => {
withScheduleId((id) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`)
.its("status")
.should("be.oneOf", [200, 201]);
});
CUSTOMS.forEach((suffix) => {
withBooking(suffix, (b) => {
glUpload(`/api/contracts/bookings/${b.id}/t1-documents`);
});
});
});
it("the train dispatches and runs the corridor checkpoint by checkpoint to the terminal", () => {
withScheduleId((id) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`)
.its("status")
.should("be.oneOf", [200, 201]);
pollDb<ScheduleRow>(
"schedule DISPATCHED",
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(s) => s?.status === "DISPATCHED",
10,
);
});
BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "IN_TRANSIT", 10));
withScheduleId((id) => {
[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 ARRIVED",
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(s) => s?.status === "ARRIVED",
20,
);
});
BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "ARRIVED", 20));
withScheduleId((id) => {
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.wagon_movements
WHERE train_schedule_id = $1`,
[id],
).then(({ rows }) =>
expect(Number(rows[0].n), "wagon movement ledger rows").to.be.at.least(54),
);
});
});
it("GL runs the customs tail on every customs booking (T1 close → risk → second duty → release → final invoice)", () => {
CUSTOMS.forEach((suffix) => {
withBooking(suffix, (b) => {
apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/t1-close`)
.its("status")
.should("be.oneOf", [200, 201]);
apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/risk`, {
riskLevel: "GREEN",
})
.its("status")
.should("be.oneOf", [200, 201]);
glUpload(
`/api/contracts/bookings/${b.id}/second-duty`,
{ dutyRequired: "false" },
"attachment",
);
glUpload(
`/api/contracts/bookings/${b.id}/final-invoice`,
{ amount: "1000", description: "e2e final invoice" },
"file",
);
glUpload(`/api/contracts/bookings/${b.id}/final-invoice-slip`, {}, "file");
apiPost(
"superadmin@tria.com",
`/api/contracts/bookings/${b.id}/final-invoice/confirm`,
)
.its("status")
.should("be.oneOf", [200, 201]);
});
completeBookingMilestone(suffix, "IMPORT_RELEASE_GRANTED");
completeBookingMilestone(suffix, "IMPORT_PROCESS_COMPLETED");
expectMilestoneDone(suffix, "T1_CLOSED");
expectMilestoneDone(suffix, "RISK_ASSIGNED");
expectMilestoneDone(suffix, "IMPORT_RELEASE_GRANTED");
expectMilestoneDone(suffix, "IMPORT_PROCESS_COMPLETED");
});
});
it("the self-clearance bookings arrived clean — no customs tail required", () => {
SELF_CLEAR.forEach((suffix) => {
withBooking(suffix, (b) => {
expect(b.status, `${suffix} final status`).to.eq("ARRIVED");
});
dbBooking(suffix).then(({ rows }) => {
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.clearance_milestones
WHERE booking_id = $1 AND milestone_code = 'T1_CLOSED'
AND status = 'COMPLETED' AND deleted_at IS NULL`,
[rows[0].id],
).then(({ rows: ms }) =>
expect(Number(ms[0].n), `${suffix} has no T1 tail`).to.eq(0),
);
});
});
});
});
export {};

View File

@@ -0,0 +1,199 @@
/**
* BULK IMPORT journey 3 — split offer, exact-remainder rebooking, pay-window
* expiry and priority-ordered waiting-list promotion on one 54-wagon CW4
* train (bulk splits are FULL-WAGONS-ONLY at the base 70 T cap):
*
* reserved (priority order): BSA 1 400 T = 20w, BSB 980 T = 14w,
* BSD 840 T = 12w → 46w. BSC 1 680 T = 24w does NOT fit whole → PARTIAL
* offer of the remaining 8 wagons = 560 T. BSC settles via the real payment
* pipeline → split applies (is_split + snapshot); the outstanding 1 120 T
* must later be rebooked EXACTLY (a wrong tonnage is rejected).
* BSD never pays → EXPIRES; the freed 12 wagons promote BS1 (420 T = 6w)
* and BS2 (420 T = 6w); BS3 (1 400 T = 20w) never fits and expires.
*
* Final consist: 20 + 14 + 8 + 6 + 6 = 54/54.
*
* Sequential steps of one journey — retries off.
*/
import {
acceptOperation,
bookBulk,
closeBookingWindow,
completeDocReview,
createImportSchedule,
db,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
forceReservationExpiry,
forceWindowOpen,
markPaid,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
setPriority,
settleViaGateway,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(12);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const REMAINDER_DEPARTURE = departureAt(14);
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
const ORDER = ["BSA", "BSB", "BSD", "BSC", "BS1", "BS2", "BS3"] as const;
const TONS: Record<string, number> = {
BSA: 1400,
BSB: 980,
BSD: 840,
BSC: 1680,
BS1: 420,
BS2: 420,
BS3: 1400,
};
describe("bulk import: split offer, remainder rebooking, expiry + promotion", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
ORDER.forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix), freight: "BULK" }),
);
});
it("operations prepares the corridor bulk train with an open first window", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
resetCorridorDay(REMAINDER_DEPARTURE);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-IMP-19", "LOCO-IMP-20"],
kind: "bulk",
});
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
});
it("seven customers book wheat in the first window; operations accepts them in priority order", () => {
ORDER.forEach((suffix) => {
bookBulk({ suffix, tons: TONS[suffix], scheduledDate: BOOKING_DAY });
acceptOperation(suffix);
});
ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
});
it("the batch reserves BSA/BSB/BSD whole and offers BSC a PARTIAL for the last 8 wagons (560 T)", () => {
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
["BSA", "BSB", "BSD", "BSC"].forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
withBooking("BSC", (b) => {
pollDb<{ status: string }>(
"BSC open partial offer",
`SELECT status FROM freight.booking_batch_offers
WHERE booking_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[b.id],
(row) => row?.status === "OFFERED",
10,
);
});
["BS1", "BS2", "BS3"].forEach((suffix) =>
withBooking(suffix, (b) => {
expect(b.status, `${suffix} waiting`).to.eq("FULLY_EXECUTED");
}),
);
});
it("BSA and BSB pay; BSC settles via the gateway — the split applies (full wagons only)", () => {
markPaid("BSA");
pollAllocations("BSA", 20);
markPaid("BSB");
pollAllocations("BSB", 14);
settleViaGateway("BSC");
pollAllocations("BSC", 8);
withBooking("BSC", (b) => {
expect(b.is_split, "BSC is split").to.eq(true);
db<{ pre_split_quantities: unknown }>(
`SELECT pre_split_quantities FROM freight.bookings WHERE id = $1`,
[b.id],
).then(({ rows }) => {
expect(rows[0].pre_split_quantities, "pre-split snapshot").to.not.be.null;
});
});
});
it("BSD misses its pay window — EXPIRED, and the freed wagons promote BS1 + BS2 (payment sent)", () => {
forceReservationExpiry("BSD");
["BS1", "BS2"].forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
["BS1", "BS2"].forEach((suffix) =>
withBooking(suffix, (b) => {
expect(b.payment_deadline, `${suffix} got a pay window`).to.be.a("string");
}),
);
withBooking("BS3", (b) => {
expect(b.status, "BS3 still has no seat").to.eq("FULLY_EXECUTED");
});
});
it("BS1 and BS2 pay — the train is FULL at 54; BS3 expires with the day", () => {
markPaid("BS1");
pollAllocations("BS1", 6);
markPaid("BS2");
pollAllocations("BS2", 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));
});
pollBookingStatus("BS3", "EXPIRED");
});
it("the split customer must rebook EXACTLY the whole 1 120 T remainder — wrong tonnage rejected, exact accepted", () => {
createImportSchedule({
departure: REMAINDER_DEPARTURE,
locoPair: ["LOCO-IMP-21", "LOCO-IMP-22"],
kind: "bulk",
});
withSchedule(REMAINDER_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
// 1 680 booked 560 shipped-by-split = 1 120 T outstanding. 560 ≠ 1 120.
bookBulk({
suffix: "BSC",
tons: 560,
scheduledDate: REMAINDER_DAY,
expectFailure: "must take the whole",
});
bookBulk({ suffix: "BSC", tons: 1120, scheduledDate: REMAINDER_DAY });
pollBookingStatus("BSC", "OPERATION_REQUEST_PENDING", 5);
});
});
export {};

View File

@@ -0,0 +1,133 @@
/**
* BULK IMPORT journey 2 — the CW4 train fills from THREE wheat bookings;
* three more sit in the waiting pool of the same (first) window. The three
* selected pay and allocate; when the cycle concludes FULL the three waiting
* bookings expire with the day.
*
* Tonnage (70 T per CW4 wagon, 54-wagon consist):
* selected: BWA 1 400 T = 20w, BWB 1 400 T = 20w, BWC 980 T = 14w → Σ 54
* waiting: BW1/BW2/BW3 700 T = 10w each
*
* Sequential steps of one journey — retries off.
*/
import {
acceptOperation,
bookBulk,
closeBookingWindow,
completeDocReview,
createImportSchedule,
db,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
forceWindowOpen,
markPaid,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
setPriority,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(11);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
const SELECTED = [
{ suffix: "BWA", tons: 1400, wagons: 20 },
{ suffix: "BWB", tons: 1400, wagons: 20 },
{ suffix: "BWC", tons: 980, wagons: 14 },
];
const WAITING = [
{ suffix: "BW1", tons: 700, wagons: 10 },
{ suffix: "BW2", tons: 700, wagons: 10 },
{ suffix: "BW3", tons: 700, wagons: 10 },
];
const ALL = [...SELECTED, ...WAITING];
describe("bulk import: 3 bookings fill the train, 3 wait and expire", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
ALL.forEach((b) =>
seedImportContract({ suffix: b.suffix, reference: stampedRef(b.suffix), freight: "BULK" }),
);
});
it("operations prepares the corridor and a 54-wagon bulk train with an open first window", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-IMP-17", "LOCO-IMP-18"],
kind: "bulk",
});
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
});
it("six customers book wheat in the first window; operations accepts all six", () => {
ALL.forEach((b) => {
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
acceptOperation(b.suffix);
});
ALL.forEach((b, i) => setPriority(b.suffix, i + 1));
});
it("the batch selects exactly the three that fill 54 wagons; the rest keep waiting", () => {
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
SELECTED.forEach((b) =>
pollBookingStatus(b.suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
WAITING.forEach((b) =>
withBooking(b.suffix, (row) => {
expect(row.status, `${b.suffix} still waiting`).to.eq("FULLY_EXECUTED");
expect(row.payment_deadline, `${b.suffix} has no pay deadline`).to.be.null;
}),
);
});
it("the three selected bookings pay and allocate — 54/54", () => {
SELECTED.forEach((b) => {
markPaid(b.suffix);
pollAllocations(b.suffix, b.wagons);
});
withSchedule(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 wagons allocated").to.eq(54));
});
});
it("the cycle concludes FULL — the three waiting bookings expire with the day", () => {
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",
);
});
WAITING.forEach((b) => pollBookingStatus(b.suffix, "EXPIRED"));
SELECTED.forEach((b) =>
withBooking(b.suffix, (row) => expect(row.status, `${b.suffix} stays PAID`).to.eq("PAID")),
);
});
});
export {};

View File

@@ -0,0 +1,110 @@
/**
* BULK IMPORT journey 4 — nobody pays in the first window cycle: both
* reserved wheat bookings expire, the cycle concludes NOT-full and the window
* REOPENS for a second cycle on the same bulk train. A fresh 700 T booking
* arrives in cycle 2, pays, and allocates.
*
* Sequential steps of one journey — retries off.
*/
import {
acceptOperation,
bookBulk,
closeBookingWindow,
completeDocReview,
createImportSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
forceReservationExpiry,
forceWindowOpen,
markPaid,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(13);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
describe("bulk import: dead first cycle — expire all, reopen, book again", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
["BRA", "BRB", "BRC"].forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix), freight: "BULK" }),
);
});
it("operations prepares the corridor bulk train — first window opens (cycle 1)", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-IMP-23", "LOCO-IMP-24"],
kind: "bulk",
});
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 1").to.eq(1));
});
it("two customers book wheat and are reserved in cycle 1", () => {
bookBulk({ suffix: "BRA", tons: 1400, scheduledDate: BOOKING_DAY });
bookBulk({ suffix: "BRB", tons: 1400, scheduledDate: BOOKING_DAY });
["BRA", "BRB"].forEach((suffix) => acceptOperation(suffix));
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
["BRA", "BRB"].forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
});
it("nobody pays — both reservations expire and the cycle concludes not-full", () => {
["BRA", "BRB"].forEach((suffix) => forceReservationExpiry(suffix));
withSchedule(DEPARTURE, (s) => {
endPaymentPhase(s.id);
pollDb<ScheduleRow>(
"window reopens (PRE_WINDOW, cycle 2 pending)",
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.window_phase === "PRE_WINDOW",
);
});
});
it("the second window opens (cycle 2) and a fresh 700 T booking pays and allocates", () => {
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 2").to.eq(2));
bookBulk({ suffix: "BRC", tons: 700, scheduledDate: BOOKING_DAY });
acceptOperation("BRC");
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
pollBookingStatus("BRC", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
markPaid("BRC");
pollAllocations("BRC", 10);
["BRA", "BRB"].forEach((suffix) =>
withBooking(suffix, (b) => expect(b.status, `${suffix} stays expired`).to.eq("EXPIRED")),
);
withSchedule(DEPARTURE, (s) => {
withBooking("BRC", (b) => {
expect(b.train_schedule_id, "BRC rides the reopened train").to.eq(s.id);
});
});
});
});
export {};

View File

@@ -0,0 +1,190 @@
/**
* EXPORT journeys E2 + E5 — FCFS capacity truth on the reversed corridor:
*
* E2 (D+3): three bookings (20+20+14 wagons) accept first and hold ALL 54
* wagons before anyone pays. Three late exporters then try to book the same
* day → each is REJECTED AT SUBMISSION by the whole-train space gate
* ("exports ride whole or not at all" — no waiting list, no split). The
* three reserved pay → FULL.
*
* E5 (D+4): the whole-or-nothing giant. A 58-wagon booking (116×20ft) is
* rejected against the empty 54-wagon train; rebooked at exactly 54 wagons
* it reserves the ENTIRE train alone, pays, allocates 54/54 → FULL — and a
* 1-wagon afterthought bounces off the FULL train.
*
* Sequential steps — retries off.
*/
import {
acceptExport,
bookContainers,
createImportSchedule,
db,
departureAt,
eatDayStr,
ensureExportRoute,
EXP_DEST,
EXP_ORIGIN,
forceWindowOpen,
markPaid,
pollAllocations,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withExportSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(3);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const GIANT_DEPARTURE = departureAt(4);
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
const FIRST = [
{ suffix: "XA", twenty: 40, forty: 0, wagons: 20 },
{ suffix: "XB", twenty: 0, forty: 20, wagons: 20 },
{ suffix: "XC", twenty: 28, forty: 0, wagons: 14 },
];
const LATE = ["XL1", "XL2", "XL3"];
function seedExport(suffix: string) {
seedImportContract({
suffix,
reference: stampedRef(suffix),
direction: "EXPORT",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
}
describe("export FCFS: reservations hold capacity, whole-or-nothing space gate", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
[...FIRST.map((b) => b.suffix), ...LATE, "XG", "XS"].forEach(seedExport);
});
it("operations prepares the export corridor and the D+3 train with an open window", () => {
ensureExportRoute();
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
resetCorridorDay(GIANT_DEPARTURE, EXP_DEST, EXP_ORIGIN);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-EXP-3", "LOCO-EXP-4"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
});
it("three exporters book and are accepted — 54 wagons reserved BEFORE any payment", () => {
let isoSeed = 6500;
FIRST.forEach((b) => {
bookContainers({
suffix: b.suffix,
runStamp: stamp,
isoSeed,
twenty: b.twenty,
forty: b.forty,
scheduledDate: BOOKING_DAY,
});
isoSeed += b.twenty + b.forty;
acceptExport(b.suffix);
});
});
it("three late exporters are rejected at submission — the space gate reports no room", () => {
LATE.forEach((suffix, i) => {
bookContainers({
suffix,
runStamp: stamp,
isoSeed: 6700 + i * 30,
twenty: 20, // 10 wagons — but reservations already hold all 54
scheduledDate: BOOKING_DAY,
expectFailure: /space|window/i,
});
});
});
it("the three reserved pay — 54/54 allocated and the export window flips FULL", () => {
FIRST.forEach((b) => {
markPaid(b.suffix);
pollAllocations(b.suffix, b.wagons);
});
withExportSchedule(DEPARTURE, (s) => {
pollDb<ScheduleRow>(
"export window FULL",
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.booking_window_status === "FULL",
);
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("whole-or-nothing: a 58-wagon giant is rejected against the empty D+4 train", () => {
createImportSchedule({
departure: GIANT_DEPARTURE,
locoPair: ["LOCO-EXP-5", "LOCO-EXP-6"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(GIANT_DEPARTURE, (s) => forceWindowOpen(s.id, 60));
bookContainers({
suffix: "XG",
runStamp: stamp,
isoSeed: 7000,
twenty: 116, // 58 wagons > 54 — export never splits
scheduledDate: GIANT_DAY,
expectFailure: /space|window/i,
});
});
it("rebooked at exactly 54 wagons the giant reserves the whole train alone, pays, fills it", () => {
bookContainers({
suffix: "XG",
runStamp: stamp,
isoSeed: 7200,
twenty: 108, // 54 wagons — the whole consist
scheduledDate: GIANT_DAY,
});
acceptExport("XG");
markPaid("XG");
pollAllocations("XG", 54);
withExportSchedule(GIANT_DEPARTURE, (s) => {
pollDb<ScheduleRow>(
"giant train FULL from one booking",
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.booking_window_status === "FULL",
);
withBooking("XG", (b) => {
expect(b.train_schedule_id, "giant rides its train").to.eq(s.id);
});
});
});
it("a 1-wagon afterthought bounces off the FULL train", () => {
bookContainers({
suffix: "XS",
runStamp: stamp,
isoSeed: 7400,
twenty: 2,
scheduledDate: GIANT_DAY,
expectFailure: /space|window/i,
});
});
});
export {};

View File

@@ -0,0 +1,285 @@
/**
* EXPORT journey E1 — six container bookings fill the 54-wagon train on the
* reversed corridor KALITY → MOJO → E2E_AWASH → DIRE_DAWA → NAGAD →
* DJIB_PORT, all inside the ONE FCFS export window, then the full life of the
* train to Djibouti Port and the export customs tail.
*
* Export mechanics under test (vs import): no cycles, no doc-review/payment
* phases, no batch — the ops ACCEPT itself reserves the wagons FCFS and opens
* a pay window CLAMPED to the window close.
*
* The six bookings (Σ = 54 wagons):
* EF1 customs + USD 16×20ft = 8 wagons
* EF2 customs + ETB 6×40ft = 6 wagons
* EF3 self + ETB 12×20ft = 6 wagons
* EF4 self + ETB 6×40ft = 6 wagons
* EF5 customs + USD 44×20ft = 22 wagons (the ≥22-wagon giant)
* EF6 self + USD 4×40ft + 4×20ft = 6 wagons
*
* Sequential steps of one journey — retries off.
*/
import {
acceptExport,
apiPost,
bookContainers,
createImportSchedule,
db,
dbBooking,
departureAt,
eatDayStr,
ensureExportRoute,
EXP_DEST,
EXP_ORIGIN,
expectMilestoneDone,
completeBookingMilestone,
forceWindowOpen,
glUpload,
markPaid,
opsStaff,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withExportSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(2);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
const BOOKINGS: Array<{
suffix: string;
customs: boolean;
currency: "ETB" | "USD";
twenty: number;
forty: number;
wagons: number;
}> = [
{ suffix: "EF1", customs: true, currency: "USD", twenty: 16, forty: 0, wagons: 8 },
{ suffix: "EF2", customs: true, currency: "ETB", twenty: 0, forty: 6, wagons: 6 },
{ suffix: "EF3", customs: false, currency: "ETB", twenty: 12, forty: 0, wagons: 6 },
{ suffix: "EF4", customs: false, currency: "ETB", twenty: 0, forty: 6, wagons: 6 },
{ suffix: "EF5", customs: true, currency: "USD", twenty: 44, forty: 0, wagons: 22 },
{ suffix: "EF6", customs: false, currency: "USD", twenty: 4, forty: 4, wagons: 6 },
];
const CUSTOMS = BOOKINGS.filter((b) => b.customs).map((b) => b.suffix);
const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs).map((b) => b.suffix);
function withScheduleId(fn: (id: string, s: ScheduleRow) => void) {
withExportSchedule(DEPARTURE, (s) => fn(s.id, s));
}
describe("export: six bookings fill the 54-wagon corridor train (FCFS)", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
for (const b of BOOKINGS) {
seedImportContract({
suffix: b.suffix,
reference: stampedRef(b.suffix),
currency: b.currency,
customs: b.customs,
direction: "EXPORT",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
}
});
it("operations ensures the reversed export corridor exists (direction frozen EXPORT)", () => {
ensureExportRoute();
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
});
it("operations schedules the 54-wagon export train — its single FCFS window forced open", () => {
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withScheduleId((id) => forceWindowOpen(id, 60));
});
it("six exporters book inside the one window", () => {
let isoSeed = 6000;
BOOKINGS.forEach((b) => {
bookContainers({
suffix: b.suffix,
runStamp: stamp,
isoSeed,
twenty: b.twenty,
forty: b.forty,
scheduledDate: BOOKING_DAY,
});
isoSeed += b.twenty + b.forty;
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
});
});
it("each accept reserves FCFS immediately — pay deadlines clamped to the window close", () => {
BOOKINGS.forEach((b) => acceptExport(b.suffix));
withScheduleId((_, s) => {
BOOKINGS.forEach((b) => {
withBooking(b.suffix, (row) => {
expect(row.payment_deadline, `${b.suffix} pay deadline`).to.be.a("string");
expect(
new Date(row.payment_deadline!).getTime(),
`${b.suffix} deadline never outlives the window close`,
).to.be.at.most(new Date(s.window_closes_at!).getTime());
});
});
});
// Reservation invoices carry the CONTRACT currency.
BOOKINGS.forEach((b) => {
withBooking(b.suffix, (row) => {
pollDb<{ currency: string }>(
`${b.suffix} invoice`,
`SELECT currency FROM freight.invoices
WHERE source_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[row.id],
(inv) => inv?.currency === b.currency,
10,
);
});
});
});
it("all six pay — allocated 54/54, the export window flips FULL, staff finalize", () => {
BOOKINGS.forEach((b) => {
markPaid(b.suffix);
pollAllocations(b.suffix, b.wagons);
});
withScheduleId((id) => {
pollDb<ScheduleRow>(
"export window FULL",
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
[id],
(s) => s?.booking_window_status === "FULL",
);
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 allocated").to.eq(54));
// Export has no auto-finalize conclude step — staff finalize explicitly.
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/finalize`)
.its("status")
.should("be.oneOf", [200, 201]);
pollDb<ScheduleRow>(
"schedule SCHEDULED",
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(s) => s?.status === "SCHEDULED",
10,
);
});
});
it("gate pass granted at the Djibouti end; T1 documents uploaded for the customs bookings", () => {
withScheduleId((id) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`)
.its("status")
.should("be.oneOf", [200, 201]);
});
CUSTOMS.forEach((suffix) => {
withBooking(suffix, (b) => {
glUpload(`/api/contracts/bookings/${b.id}/transport-document`);
});
});
});
it("the train dispatches — every booking boards at KALITY (IN_TRANSIT)", () => {
withScheduleId((id) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`)
.its("status")
.should("be.oneOf", [200, 201]);
pollDb<ScheduleRow>(
"schedule DISPATCHED",
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(s) => s?.status === "DISPATCHED",
10,
);
});
BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "IN_TRANSIT", 10));
});
it("the train runs the corridor and arrives at Djibouti Port — every booking ARRIVED", () => {
withScheduleId((id) => {
[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 ARRIVED",
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(s) => s?.status === "ARRIVED",
20,
);
});
BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "ARRIVED", 20));
withScheduleId((id) => {
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.wagon_movements
WHERE train_schedule_id = $1`,
[id],
).then(({ rows }) =>
expect(Number(rows[0].n), "wagon movement ledger rows").to.be.at.least(54),
);
});
});
it("GL Djibouti closes the export tail (T1 close → offloaded) on every customs booking", () => {
CUSTOMS.forEach((suffix) => {
withBooking(suffix, (b) => {
apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/t1-close`)
.its("status")
.should("be.oneOf", [200, 201]);
});
completeBookingMilestone(suffix, "OFFLOADED");
expectMilestoneDone(suffix, "T1_CLOSED");
expectMilestoneDone(suffix, "OFFLOADED");
});
});
it("the self-clearance bookings arrived clean — no customs tail required", () => {
SELF_CLEAR.forEach((suffix) => {
withBooking(suffix, (b) => {
expect(b.status, `${suffix} final status`).to.eq("ARRIVED");
});
dbBooking(suffix).then(({ rows }) => {
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.clearance_milestones
WHERE booking_id = $1 AND milestone_code = 'T1_CLOSED'
AND status = 'COMPLETED' AND deleted_at IS NULL`,
[rows[0].id],
).then(({ rows: ms }) =>
expect(Number(ms[0].n), `${suffix} has no T1 tail`).to.eq(0),
);
});
});
});
});
export {};

View File

@@ -0,0 +1,245 @@
/**
* 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 {};

View File

@@ -0,0 +1,207 @@
/**
* EXPORT journeys E3 + E4 — pay-or-lose on the reversed corridor:
*
* E3 (D+5): PA + PB reserve 40 wagons and pay. PC reserves the last 14 but
* never pays; a late exporter PD is rejected for space while PC's hold
* lives. PC's pay deadline passes → EXPIRED → the freed 14 wagons are
* instantly FCFS-bookable again: PD rebooks, accepts, pays, allocates.
*
* E4 (D+6): five bookings reserve the whole train, only three pay. The
* window CLOSE passes → phase DONE, and the day sweep expires the two
* unpaid reservations. Export days never reopen — no second cycle, the
* cycle counter stays at 1 and the three paid bookings ride.
*
* Sequential steps — retries off.
*/
import {
acceptExport,
bookContainers,
createImportSchedule,
db,
departureAt,
eatDayStr,
ensureExportRoute,
EXP_DEST,
EXP_ORIGIN,
forceReservationExpiry,
forceWindowOpen,
markPaid,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withExportSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(5);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const CLOSE_DEPARTURE = departureAt(6);
const CLOSE_DAY = eatDayStr(CLOSE_DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
function seedExport(suffix: string) {
seedImportContract({
suffix,
reference: stampedRef(suffix),
direction: "EXPORT",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
}
/** QA..QE reserve 12+12+12+12+6 = 54; only QA/QB/QC pay. */
const CLOSERS = [
{ suffix: "QA", forty: 12, wagons: 12, pays: true },
{ suffix: "QB", forty: 12, wagons: 12, pays: true },
{ suffix: "QC", forty: 12, wagons: 12, pays: true },
{ suffix: "QD", forty: 12, wagons: 12, pays: false },
{ suffix: "QE", forty: 6, wagons: 6, pays: false },
];
describe("export pay-or-lose: expiry frees space; window close expires the unpaid", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
["PA", "PB", "PC", "PD", ...CLOSERS.map((c) => c.suffix)].forEach(seedExport);
});
it("operations prepares the export corridor and the D+5 train with an open window", () => {
ensureExportRoute();
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
resetCorridorDay(CLOSE_DEPARTURE, EXP_DEST, EXP_ORIGIN);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-EXP-7", "LOCO-EXP-8"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
});
it("PA and PB reserve and pay 40 wagons; PC reserves the last 14 unpaid", () => {
bookContainers({
suffix: "PA",
runStamp: stamp,
isoSeed: 7600,
twenty: 40,
scheduledDate: BOOKING_DAY,
});
acceptExport("PA");
markPaid("PA");
pollAllocations("PA", 20);
bookContainers({
suffix: "PB",
runStamp: stamp,
isoSeed: 7700,
forty: 20,
scheduledDate: BOOKING_DAY,
});
acceptExport("PB");
markPaid("PB");
pollAllocations("PB", 20);
bookContainers({
suffix: "PC",
runStamp: stamp,
isoSeed: 7800,
twenty: 28,
scheduledDate: BOOKING_DAY,
});
acceptExport("PC");
// Clamp: PC's deadline never outlives the window close.
withExportSchedule(DEPARTURE, (s) => {
withBooking("PC", (b) => {
expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
new Date(s.window_closes_at!).getTime(),
);
});
});
});
it("a late exporter is rejected while PC's unpaid reservation holds the space", () => {
bookContainers({
suffix: "PD",
runStamp: stamp,
isoSeed: 7900,
twenty: 28,
scheduledDate: BOOKING_DAY,
expectFailure: /space|window/i,
});
});
it("PC misses its pay window — EXPIRED — and PD immediately books the freed 14 wagons", () => {
forceReservationExpiry("PC");
bookContainers({
suffix: "PD",
runStamp: stamp,
isoSeed: 8000,
twenty: 28,
scheduledDate: BOOKING_DAY,
});
acceptExport("PD");
markPaid("PD");
pollAllocations("PD", 14);
withExportSchedule(DEPARTURE, (s) => {
withBooking("PD", (b) => expect(b.train_schedule_id, "PD took PC's seat").to.eq(s.id));
});
});
it("window-close day: five reservations, three payments", () => {
createImportSchedule({
departure: CLOSE_DEPARTURE,
locoPair: ["LOCO-EXP-9", "LOCO-EXP-10"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(CLOSE_DEPARTURE, (s) => forceWindowOpen(s.id, 60));
let isoSeed = 8200;
CLOSERS.forEach((c) => {
bookContainers({
suffix: c.suffix,
runStamp: stamp,
isoSeed,
forty: c.forty,
scheduledDate: CLOSE_DAY,
});
isoSeed += c.forty;
acceptExport(c.suffix);
});
CLOSERS.filter((c) => c.pays).forEach((c) => {
markPaid(c.suffix);
pollAllocations(c.suffix, c.wagons);
});
});
it("the window CLOSES — phase DONE, the two unpaid expire, and export never reopens", () => {
withExportSchedule(CLOSE_DEPARTURE, (s) => {
db(
`UPDATE freight.train_schedules
SET window_closes_at = now() - interval '1 second'
WHERE id = $1 AND window_phase = 'OPEN'`,
[s.id],
);
pollDb<ScheduleRow>(
"export window DONE (no reopen)",
`SELECT window_phase, booking_cycle_no FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.window_phase === "DONE" && Number(row?.booking_cycle_no) === 1,
);
});
// The unpaid deadlines were clamped to the ORIGINAL close; forcing the
// close earlier means forcing their deadlines with it (same semantics).
CLOSERS.filter((c) => !c.pays).forEach((c) => forceReservationExpiry(c.suffix));
CLOSERS.filter((c) => !c.pays).forEach((c) => pollBookingStatus(c.suffix, "EXPIRED"));
CLOSERS.filter((c) => c.pays).forEach((c) =>
withBooking(c.suffix, (b) => expect(b.status, `${c.suffix} rides`).to.eq("PAID")),
);
});
});
export {};

View File

@@ -27,6 +27,9 @@ export const superAdmin = "superadmin@tria.com";
export const CORRIDOR = ["DJIB_PORT", "NAGAD", "DIRE_DAWA", "E2E_AWASH", "MOJO", "KALITY"] as const;
export const ORIGIN = "DJIB_PORT";
export const DEST = "KALITY";
/** Export rides the same corridor reversed (ET → DJ). */
export const EXP_ORIGIN = "KALITY";
export const EXP_DEST = "DJIB_PORT";
export const apiUrl = () => Cypress.env("apiUrl") as string;
@@ -113,7 +116,8 @@ export interface SeedContractOpts {
reference: string;
currency?: "ETB" | "USD";
customs?: boolean;
direction?: "IMPORT" | "DOMESTIC";
direction?: "IMPORT" | "EXPORT" | "DOMESTIC";
freight?: "CONTAINER" | "BULK";
originCode?: string;
destCode?: string;
}
@@ -122,6 +126,9 @@ export function seedImportContract(opts: SeedContractOpts) {
const currency = opts.currency ?? "ETB";
const customs = opts.customs ?? false;
const direction = opts.direction ?? "IMPORT";
const freight = opts.freight ?? "CONTAINER";
// Pre-booking boundary milestone for Path B contracts differs by direction.
const boundary = direction === "EXPORT" ? "EXPORT_RELEASED" : "DO_COLLECTED";
db(
`WITH c AS (
INSERT INTO freight.contracts
@@ -133,9 +140,13 @@ export function seedImportContract(opts: SeedContractOpts) {
SELECT $1, comp.id,
(SELECT p.id FROM freight.company_profiles p
WHERE p.company_id = comp.id AND p.deleted_at IS NULL
ORDER BY CASE WHEN p.type = 'importer' THEN 0 ELSE 1 END
ORDER BY CASE
WHEN $2::text = 'EXPORT' AND p.type = 'exporter' THEN 0
WHEN $2::text <> 'EXPORT' AND p.type = 'importer' THEN 0
ELSE 1
END
LIMIT 1),
'ONE_TIME', $2, 'CONTAINER',
'ONE_TIME', $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,
@@ -162,17 +173,24 @@ export function seedImportContract(opts: SeedContractOpts) {
JOIN freight.yards o ON o.code = $6
JOIN freight.yards d ON d.code = $7
RETURNING id
), scope AS (
), 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'
FROM c CROSS JOIN (VALUES ('20ft'), ('40ft')) AS v(size)
WHERE $9::text = 'CONTAINER'
), scope_bulk AS (
INSERT INTO freight.contract_cargo_scope
(contract_id, cargo_type_id, cargo_free_text)
SELECT c.id, ct.id, 'E2E import wheat'
FROM c JOIN freight.cargo_types ct ON ct.code = 'E2E_IMP_WHEAT'
WHERE $9::text = 'BULK'
)
-- Path B gate: ONE_TIME customs bookings require the pre-booking boundary
-- milestone (IMPORT → DO_COLLECTED) COMPLETED at contract level.
-- milestone (IMPORT → DO_COLLECTED, EXPORT → EXPORT_RELEASED) COMPLETED.
INSERT INTO freight.clearance_milestones
(contract_id, milestone_code, milestone_label, status, triggered_at, sort_order)
SELECT c.id, 'DO_COLLECTED', 'Delivery order collected', 'COMPLETED', now(), 0
SELECT c.id, $10, 'Pre-booking clearance boundary', 'COMPLETED', now(), 0
FROM c WHERE $4`,
[
opts.reference,
@@ -183,6 +201,8 @@ export function seedImportContract(opts: SeedContractOpts) {
opts.originCode ?? ORIGIN,
opts.destCode ?? DEST,
opts.suffix,
freight,
boundary,
],
);
// Backfill the boundary milestone when the insert above was skipped because
@@ -191,15 +211,15 @@ export function seedImportContract(opts: SeedContractOpts) {
db(
`INSERT INTO freight.clearance_milestones
(contract_id, milestone_code, milestone_label, status, triggered_at, sort_order)
SELECT ct.id, 'DO_COLLECTED', 'Delivery order collected', 'COMPLETED', now(), 0
SELECT ct.id, $2::text, 'Pre-booking clearance boundary', 'COMPLETED', now(), 0
FROM freight.contracts ct
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND ct.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM freight.clearance_milestones m
WHERE m.contract_id = ct.id AND m.milestone_code = 'DO_COLLECTED'
WHERE m.contract_id = ct.id AND m.milestone_code = $2::text
AND m.deleted_at IS NULL
)`,
[opts.suffix],
[opts.suffix, boundary],
);
}
}
@@ -292,7 +312,7 @@ export function bookContainers(opts: {
forty?: number;
scheduledDate?: string; // omit for DOMESTIC (intercity)
vgmTons?: number;
expectFailure?: string; // substring of the expected 4xx error message
expectFailure?: string | RegExp; // substring/regex of the expected 4xx error
}) {
const vgm = opts.vgmTons ?? 10;
const lines: Array<Record<string, unknown>> = [];
@@ -338,7 +358,55 @@ export function bookContainers(opts: {
).then((res) => {
if (opts.expectFailure) {
expect(res.status, `${opts.suffix} booking rejected`).to.be.within(400, 422);
expect(JSON.stringify(res.body)).to.include(opts.expectFailure);
if (opts.expectFailure instanceof RegExp) {
expect(JSON.stringify(res.body)).to.match(opts.expectFailure);
} else {
expect(JSON.stringify(res.body)).to.include(opts.expectFailure);
}
} else {
expect(res.status, `${opts.suffix} booking created`).to.be.oneOf([200, 201]);
}
});
});
}
/**
* Book bulk tons under a seeded BULK contract via the API. Wagon demand =
* ceil(tons / 70) on CW4 covered gondolas.
*/
export function bookBulk(opts: {
suffix: string;
tons: number;
scheduledDate?: string; // omit for DOMESTIC (intercity)
expectFailure?: string | RegExp;
}) {
db<{ id: string; customs_clearing_enabled: boolean; cargo_type_id: string }>(
`SELECT ct.id, ct.customs_clearing_enabled,
(SELECT t.id FROM freight.cargo_types t WHERE t.code = 'E2E_IMP_WHEAT') AS cargo_type_id
FROM freight.contracts ct
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
ORDER BY ct.created_at DESC LIMIT 1`,
[opts.suffix],
).then(({ rows }) => {
expect(rows, `seeded contract *-${opts.suffix}`).to.have.length(1);
const actor = rows[0].customs_clearing_enabled ? superAdmin : customer;
apiPost(
actor,
`/api/contracts/${rows[0].id}/bookings`,
{
...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}),
bulkLines: [{ cargoTypeId: rows[0].cargo_type_id, cargoWeightTons: opts.tons }],
cargoFreeText: "E2E import wheat",
},
!opts.expectFailure,
).then((res) => {
if (opts.expectFailure) {
expect(res.status, `${opts.suffix} booking rejected`).to.be.within(400, 422);
if (opts.expectFailure instanceof RegExp) {
expect(JSON.stringify(res.body)).to.match(opts.expectFailure);
} else {
expect(JSON.stringify(res.body)).to.include(opts.expectFailure);
}
} else {
expect(res.status, `${opts.suffix} booking created`).to.be.oneOf([200, 201]);
}
@@ -470,6 +538,40 @@ export function forceReservationExpiry(suffix: string) {
pollBookingStatus(suffix, "EXPIRED");
}
/**
* Type-integrity assert for mixed trains: every wagon slot allocated to the
* booking is of ONE expected wagon type (containers → NW5, bulk → CW4), and
* the slot count matches. No wheat on a flat wagon, no box in a gondola.
*/
export function expectWagonType(suffix: string, code: string, wagons: number) {
withBooking(suffix, (b) =>
pollDb<{ code: string; n: string }>(
`${suffix} rides ${wagons}× ${code}`,
`SELECT wt.code, count(*) AS n
FROM freight.wagon_booking_allocations wba
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
WHERE wba.booking_id = $1 AND wba.deleted_at IS NULL
GROUP BY wt.code`,
[b.id],
(row) => row?.code === code && Number(row?.n) === wagons,
20,
),
);
// A grouped second row would mean mixed wagon types under one booking.
withBooking(suffix, (b) =>
db<{ k: string }>(
`SELECT count(DISTINCT tsw.wagon_type_id) AS k
FROM freight.wagon_booking_allocations wba
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
WHERE wba.booking_id = $1 AND wba.deleted_at IS NULL`,
[b.id],
).then(({ rows }) =>
expect(Number(rows[0].k), `${suffix} single wagon type`).to.eq(1),
),
);
}
export function pollAllocations(suffix: string, minWagons = 1) {
withBooking(suffix, (b) =>
pollDb<{ n: string }>(
@@ -533,7 +635,7 @@ export function ensureCorridorRoute() {
* scope CTR-IMP-% only — never touches other suites' data). A wiped schedule
* must never leave bookings pointing at it (ghost refs break assign).
*/
export function resetCorridorDay(departure: Date, destCode = DEST) {
export function resetCorridorDay(departure: Date, destCode = DEST, originCode = ORIGIN) {
db(
`WITH stale AS (
SELECT ts.id FROM freight.train_schedules ts
@@ -556,7 +658,7 @@ export function resetCorridorDay(departure: Date, destCode = DEST) {
)
UPDATE freight.train_schedules SET deleted_at = now()
WHERE id IN (SELECT id FROM stale)`,
[ORIGIN, destCode, departure.toISOString()],
[originCode, destCode, departure.toISOString()],
);
// Prior-run pool leftovers (never reserved, so no schedule ref) would
// contaminate this run's batch — a stale high-priority booking steals the
@@ -572,6 +674,48 @@ export function resetCorridorDay(departure: Date, destCode = DEST) {
);
}
/** Create the reversed 6-stop corridor (ET → DJ = EXPORT) if missing. */
export function ensureExportRoute() {
dbRouteId(EXP_ORIGIN, EXP_DEST).then(({ rows }) => {
if (rows.length > 0) return;
const stops = [...CORRIDOR].reverse();
db<{ id: string; code: string }>(
`SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`,
[stops],
).then(({ rows: yards }) => {
expect(yards, "corridor yards").to.have.length(stops.length);
const byCode = new Map(yards.map((y) => [y.code, y.id]));
apiPost(opsStaff, "/api/routes", {
milestones: stops.map((code) => ({ yardId: byCode.get(code) })),
})
.its("status")
.should("be.oneOf", [200, 201]);
});
db<{ direction: string }>(
`SELECT r.direction FROM freight.routes r
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = $1
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = $2
WHERE r.deleted_at IS NULL ORDER BY r.created_at DESC LIMIT 1`,
[EXP_ORIGIN, EXP_DEST],
).then(({ rows: created }) => {
expect(created[0]?.direction, "export corridor direction").to.eq("EXPORT");
});
});
}
/**
* Ops accepts an EXPORT operation request — FCFS: the accept itself reserves
* the train slot and opens a pay window clamped to the window close.
*/
export function acceptExport(suffix: string) {
withBooking(suffix, (b) => {
apiPost(opsStaff, `/api/bookings/${b.id}/operation/review`, { decision: "ACCEPT" })
.its("status")
.should("be.oneOf", [200, 201]);
});
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 10);
}
export interface ScheduleRow {
id: string;
status: string;
@@ -590,7 +734,7 @@ const SCHEDULE_COLS = `ts.id, ts.status, ts.window_phase, ts.booking_window_stat
ts.payment_phase_ends_at, ts.scheduled_departure_date`;
/** The corridor schedule departing within ±1h of `departure` (12:00 EAT pin). */
export function dbSchedule(departure: Date, destCode = DEST) {
export function dbSchedule(departure: Date, destCode = DEST, originCode = ORIGIN) {
return db<ScheduleRow>(
`SELECT ${SCHEDULE_COLS}
FROM freight.train_schedules ts
@@ -599,7 +743,7 @@ export function dbSchedule(departure: Date, destCode = DEST) {
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`,
[ORIGIN, destCode, departure.toISOString()],
[originCode, destCode, departure.toISOString()],
);
}
@@ -610,6 +754,14 @@ export function withSchedule(departure: Date, fn: (s: ScheduleRow) => void) {
});
}
/** Export-corridor (KALITY → DJIB_PORT) variant of withSchedule. */
export function withExportSchedule(departure: Date, fn: (s: ScheduleRow) => void) {
dbSchedule(departure, EXP_DEST, EXP_ORIGIN).then(({ rows }) => {
expect(rows, `export schedule departing ${departure.toISOString()}`).to.have.length(1);
fn(rows[0]);
});
}
/**
* Ops creates an import schedule on the corridor via the API — loco-pair mode
* (no built train): capacity comes from maxWagonsPerTrain (54, the corridor
@@ -619,17 +771,22 @@ export function createImportSchedule(opts: {
departure: Date;
locoPair: [string, string];
maxWagons?: number;
kind?: "container" | "bulk";
originCode?: string;
destCode?: string;
}) {
dbSchedule(opts.departure).then(({ rows }) => {
const originCode = opts.originCode ?? ORIGIN;
const destCode = opts.destCode ?? DEST;
dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => {
if (rows.length > 0) return;
dbRouteId().then(({ rows: routes }) => {
dbRouteId(originCode, destCode).then(({ rows: routes }) => {
expect(routes, "corridor route").to.have.length(1);
db<{ id: string }>(
`SELECT id FROM freight.locomotives WHERE code = ANY($1::text[]) ORDER BY code`,
[opts.locoPair],
).then(({ rows: locos }) => {
expect(locos, `locomotives ${opts.locoPair.join(",")}`).to.have.length(2);
apiPost(opsStaff, "/api/train-scheduling/container/schedules", {
apiPost(opsStaff, `/api/train-scheduling/${opts.kind ?? "container"}/schedules`, {
routeId: routes[0].id,
scheduleDate: opts.departure.toISOString(),
locomotiveIds: locos.map((l) => l.id),
@@ -640,8 +797,9 @@ export function createImportSchedule(opts: {
});
});
});
withSchedule(opts.departure, (s) => {
expect(s.max_wagons, "54-wagon consist").to.eq(opts.maxWagons ?? 54);
dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => {
expect(rows, "created schedule").to.have.length(1);
expect(rows[0].max_wagons, "54-wagon consist").to.eq(opts.maxWagons ?? 54);
});
}

View File

@@ -0,0 +1,253 @@
/**
* MIXED EXPORT journeys XM4 + XM5 — mixed window close, and a FULL mixed
* train carrying a DOUBLE intercity ride-along:
*
* XM4 (D+17): five mixed reservations (2 container + 3 bulk = 54w), only
* one container + one bulk pay. The window CLOSE passes → phase DONE, the
* three unpaid (both kinds) expire in ONE sweep, export never reopens.
*
* XM5 (D+18): through container 40w + sub-corridor bulk (DIRE_DAWA → port)
* 14w commit the border edges → directional FULL. Then a container AND a
* bulk intercity ride-along (KALITY → MOJO, dateless) are accepted onto the
* FULL train's free home leg in ONE staff action — pay windows clamped to
* the EXPORT close, both pay, both linked; the export window stays FULL.
*
* Sequential steps — retries off.
*/
import {
forceReservationExpiry,
acceptExport,
acceptOperation,
apiPost,
bookBulk,
bookContainers,
createImportSchedule,
db,
departureAt,
eatDayStr,
ensureExportRoute,
EXP_DEST,
EXP_ORIGIN,
expectWagonType,
forceWindowOpen,
markPaid,
opsStaff,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withExportSchedule,
type ScheduleRow,
} from "./import-utils";
const CLOSE_DEPARTURE = departureAt(17);
const CLOSE_DAY = eatDayStr(CLOSE_DEPARTURE);
const FULL_DEPARTURE = departureAt(18);
const FULL_DAY = eatDayStr(FULL_DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
/** 2 container + 3 bulk = 54 wagons; only QMC1 + QMB1 pay. */
const CLOSERS: Array<{
suffix: string;
freight: "CONTAINER" | "BULK";
twenty?: number;
tons?: number;
wagons: number;
pays: boolean;
}> = [
{ suffix: "QMC1", freight: "CONTAINER", twenty: 24, wagons: 12, pays: true },
{ suffix: "QMB1", freight: "BULK", tons: 840, wagons: 12, pays: true },
{ suffix: "QMC2", freight: "CONTAINER", twenty: 24, wagons: 12, pays: false },
{ suffix: "QMB2", freight: "BULK", tons: 840, wagons: 12, pays: false },
{ suffix: "QMB3", freight: "BULK", tons: 420, wagons: 6, pays: false },
];
describe("mixed export: mixed close sweep + FULL train with double intercity", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
CLOSERS.forEach((c) =>
seedImportContract({
suffix: c.suffix,
reference: stampedRef(c.suffix),
freight: c.freight,
direction: "EXPORT",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
}),
);
seedImportContract({
suffix: "FMTH",
reference: stampedRef("FMTH"),
direction: "EXPORT",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
seedImportContract({
suffix: "FMSB",
reference: stampedRef("FMSB"),
freight: "BULK",
direction: "EXPORT",
originCode: "DIRE_DAWA",
destCode: EXP_DEST,
});
seedImportContract({
suffix: "MEIC",
reference: stampedRef("MEIC"),
direction: "DOMESTIC",
originCode: EXP_ORIGIN,
destCode: "MOJO",
});
seedImportContract({
suffix: "MEIB",
reference: stampedRef("MEIB"),
freight: "BULK",
direction: "DOMESTIC",
originCode: EXP_ORIGIN,
destCode: "MOJO",
});
});
it("mixed close day: five mixed reservations, one container + one bulk pay", () => {
ensureExportRoute();
resetCorridorDay(CLOSE_DEPARTURE, EXP_DEST, EXP_ORIGIN);
resetCorridorDay(FULL_DEPARTURE, EXP_DEST, EXP_ORIGIN);
createImportSchedule({
departure: CLOSE_DEPARTURE,
locoPair: ["LOCO-EXP-11", "LOCO-EXP-12"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(CLOSE_DEPARTURE, (s) => forceWindowOpen(s.id, 60));
let isoSeed = 15_000;
CLOSERS.forEach((c) => {
if (c.freight === "CONTAINER") {
bookContainers({
suffix: c.suffix,
runStamp: stamp,
isoSeed,
twenty: c.twenty,
scheduledDate: CLOSE_DAY,
});
isoSeed += c.twenty ?? 0;
} else {
bookBulk({ suffix: c.suffix, tons: c.tons!, scheduledDate: CLOSE_DAY });
}
acceptExport(c.suffix);
});
CLOSERS.filter((c) => c.pays).forEach((c) => {
markPaid(c.suffix);
pollAllocations(c.suffix, c.wagons);
expectWagonType(c.suffix, c.freight === "CONTAINER" ? "NW5" : "CW4", c.wagons);
});
});
it("the window CLOSES — the three unpaid of BOTH kinds expire in one sweep, no reopen", () => {
withExportSchedule(CLOSE_DEPARTURE, (s) => {
db(
`UPDATE freight.train_schedules
SET window_closes_at = now() - interval '1 second'
WHERE id = $1 AND window_phase = 'OPEN'`,
[s.id],
);
pollDb<ScheduleRow>(
"export window DONE (no reopen)",
`SELECT window_phase, booking_cycle_no FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.window_phase === "DONE" && Number(row?.booking_cycle_no) === 1,
);
});
// Forced close ⇒ force the matching deadline clamp on the unpaid trio.
CLOSERS.filter((c) => !c.pays).forEach((c) => forceReservationExpiry(c.suffix));
CLOSERS.filter((c) => !c.pays).forEach((c) => pollBookingStatus(c.suffix, "EXPIRED"));
CLOSERS.filter((c) => c.pays).forEach((c) =>
withBooking(c.suffix, (b) => expect(b.status, `${c.suffix} rides`).to.eq("PAID")),
);
});
it("FULL-train day: through container 40w + sub-corridor bulk 14w flip the window FULL", () => {
createImportSchedule({
departure: FULL_DEPARTURE,
locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(FULL_DEPARTURE, (s) => forceWindowOpen(s.id, 90));
bookContainers({
suffix: "FMTH",
runStamp: stamp,
isoSeed: 15_500,
twenty: 80, // 40 wagons, full corridor
scheduledDate: FULL_DAY,
});
acceptExport("FMTH");
bookBulk({ suffix: "FMSB", tons: 980, scheduledDate: FULL_DAY });
acceptExport("FMSB");
markPaid("FMTH");
pollAllocations("FMTH", 40);
expectWagonType("FMTH", "NW5", 40);
markPaid("FMSB");
pollAllocations("FMSB", 14);
expectWagonType("FMSB", "CW4", 14);
withExportSchedule(FULL_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("a container AND a bulk intercity ride-along join the FULL train in ONE accept — clamped, paid, linked", () => {
bookContainers({ suffix: "MEIC", runStamp: stamp, isoSeed: 15_700, twenty: 4 });
acceptOperation("MEIC");
bookBulk({ suffix: "MEIB", tons: 140 });
acceptOperation("MEIB");
withExportSchedule(FULL_DEPARTURE, (s) => {
withBooking("MEIC", (bc) => {
withBooking("MEIB", (bb) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${s.id}/intercity/accept`, {
bookingIds: [bc.id, bb.id],
})
.its("status")
.should("be.oneOf", [200, 201]);
});
});
});
["MEIC", "MEIB"].forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
withExportSchedule(FULL_DEPARTURE, (s) => {
["MEIC", "MEIB"].forEach((suffix) =>
withBooking(suffix, (b) => {
// Export parity: ride-along pay windows never outlive the close.
expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
new Date(s.window_closes_at!).getTime(),
);
}),
);
});
markPaid("MEIC");
markPaid("MEIB");
withExportSchedule(FULL_DEPARTURE, (s) => {
["MEIC", "MEIB"].forEach((suffix) =>
withBooking(suffix, (b) => {
expect(b.train_schedule_id, `${suffix} linked`).to.eq(s.id);
}),
);
expect(s.booking_window_status, "export window stays FULL").to.eq("FULL");
});
});
});
export {};

View File

@@ -0,0 +1,203 @@
/**
* MIXED EXPORT journeys XM2 + XM3 — one FCFS ledger for two cargo kinds:
*
* XM2 (D+15): container 20w, bulk 14w, container 20w accept in order — 54
* wagons held before any payment. A late bulk (700 T) AND a late container
* (10w) both bounce off the SAME space gate. The three reserved pay →
* typed allocation, FULL. One shared reservation account, no per-type quota.
*
* XM3 (D+16): cross-type pay-or-lose. Container 20w pays, bulk 14w pays,
* a second container 20w never pays → expires at its clamped deadline →
* a BULK 1 400 T booking takes the container's freed slots (slots are
* type-blind, wagons stay typed: the wheat lands on CW4). The train is
* FULL again — a 1-wagon container afterthought bounces.
*
* Sequential steps — retries off.
*/
import {
acceptExport,
bookBulk,
bookContainers,
createImportSchedule,
departureAt,
eatDayStr,
ensureExportRoute,
EXP_DEST,
EXP_ORIGIN,
expectWagonType,
forceReservationExpiry,
forceWindowOpen,
markPaid,
pollAllocations,
pollDb,
resetCorridorDay,
seedImportContract,
withExportSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(15);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const PAYLOSE_DEPARTURE = departureAt(16);
const PAYLOSE_DAY = eatDayStr(PAYLOSE_DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
function seedMixedExport(suffix: string, freight: "CONTAINER" | "BULK") {
seedImportContract({
suffix,
reference: stampedRef(suffix),
freight,
direction: "EXPORT",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
}
describe("mixed export FCFS: one shared ledger, cross-type pay-or-lose", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
seedMixedExport("FMC1", "CONTAINER");
seedMixedExport("FMB1", "BULK");
seedMixedExport("FMC2", "CONTAINER");
seedMixedExport("FML1", "BULK"); // late bulk
seedMixedExport("FML2", "CONTAINER"); // late container
seedMixedExport("PLC1", "CONTAINER");
seedMixedExport("PLB1", "BULK");
seedMixedExport("PLC2", "CONTAINER");
seedMixedExport("PLB2", "BULK"); // takes PLC2's freed slots
seedMixedExport("PLS", "CONTAINER"); // afterthought
});
it("operations prepares the export corridor and the D+15 train with an open window", () => {
ensureExportRoute();
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
resetCorridorDay(PAYLOSE_DEPARTURE, EXP_DEST, EXP_ORIGIN);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-EXP-7", "LOCO-EXP-8"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
});
it("container, bulk, container accept in order — 54 wagons held before any payment", () => {
bookContainers({
suffix: "FMC1",
runStamp: stamp,
isoSeed: 14_000,
twenty: 40,
scheduledDate: BOOKING_DAY,
});
acceptExport("FMC1");
bookBulk({ suffix: "FMB1", tons: 980, scheduledDate: BOOKING_DAY });
acceptExport("FMB1");
bookContainers({
suffix: "FMC2",
runStamp: stamp,
isoSeed: 14_100,
twenty: 40,
scheduledDate: BOOKING_DAY,
});
acceptExport("FMC2");
});
it("a late bulk AND a late container bounce off the same space gate", () => {
bookBulk({
suffix: "FML1",
tons: 700,
scheduledDate: BOOKING_DAY,
expectFailure: /space|window/i,
});
bookContainers({
suffix: "FML2",
runStamp: stamp,
isoSeed: 14_200,
twenty: 20,
scheduledDate: BOOKING_DAY,
expectFailure: /space|window/i,
});
});
it("the three reserved pay — typed allocation 54/54, window FULL", () => {
markPaid("FMC1");
pollAllocations("FMC1", 20);
expectWagonType("FMC1", "NW5", 20);
markPaid("FMB1");
pollAllocations("FMB1", 14);
expectWagonType("FMB1", "CW4", 14);
markPaid("FMC2");
pollAllocations("FMC2", 20);
expectWagonType("FMC2", "NW5", 20);
withExportSchedule(DEPARTURE, (s) => {
pollDb<ScheduleRow>(
"export window FULL",
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.booking_window_status === "FULL",
);
});
});
it("pay-or-lose day: container pays, bulk pays, the second container reserves unpaid", () => {
createImportSchedule({
departure: PAYLOSE_DEPARTURE,
locoPair: ["LOCO-EXP-9", "LOCO-EXP-10"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(PAYLOSE_DEPARTURE, (s) => forceWindowOpen(s.id, 60));
bookContainers({
suffix: "PLC1",
runStamp: stamp,
isoSeed: 14_400,
twenty: 40,
scheduledDate: PAYLOSE_DAY,
});
acceptExport("PLC1");
markPaid("PLC1");
pollAllocations("PLC1", 20);
bookBulk({ suffix: "PLB1", tons: 980, scheduledDate: PAYLOSE_DAY });
acceptExport("PLB1");
markPaid("PLB1");
pollAllocations("PLB1", 14);
bookContainers({
suffix: "PLC2",
runStamp: stamp,
isoSeed: 14_500,
twenty: 40,
scheduledDate: PAYLOSE_DAY,
});
acceptExport("PLC2");
});
it("the unpaid container expires — a BULK booking takes its freed slots (typed wagons)", () => {
forceReservationExpiry("PLC2");
bookBulk({ suffix: "PLB2", tons: 1400, scheduledDate: PAYLOSE_DAY });
acceptExport("PLB2");
markPaid("PLB2");
pollAllocations("PLB2", 20);
// Slots are type-blind; the physical wagons are not — wheat rides CW4.
expectWagonType("PLB2", "CW4", 20);
});
it("the train is FULL again — a 1-wagon container afterthought bounces", () => {
bookContainers({
suffix: "PLS",
runStamp: stamp,
isoSeed: 14_600,
twenty: 2,
scheduledDate: PAYLOSE_DAY,
expectFailure: /space|window/i,
});
});
});
export {};

View File

@@ -0,0 +1,257 @@
/**
* MIXED EXPORT journey XM1 — containers AND wheat share ONE 54-wagon export
* train (KALITY → … → DJIB_PORT) inside ONE FCFS window: every accept
* reserves instantly with a close-clamped pay deadline; allocation is typed
* (NW5 under boxes, CW4 under wheat); then the full journey to Djibouti Port
* and both export customs tails.
*
* The six bookings (Σ = 54 wagons):
* XMC1 container customs + USD 16×20ft = 8 × NW5
* XMB1 bulk customs + USD 560 T = 8 × CW4
* XMC2 container self + ETB 6×40ft = 6 × NW5
* XMB2 bulk self + ETB 980 T = 14 × CW4
* XMC3 container customs + ETB 16×20ft = 8 × NW5
* XMB3 bulk self + USD 700 T = 10 × CW4
*
* Sequential steps of one journey — retries off.
*/
import {
acceptExport,
apiPost,
bookBulk,
bookContainers,
completeBookingMilestone,
createImportSchedule,
db,
departureAt,
eatDayStr,
ensureExportRoute,
EXP_DEST,
EXP_ORIGIN,
expectMilestoneDone,
expectWagonType,
forceWindowOpen,
glUpload,
markPaid,
opsStaff,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withExportSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(14);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
const BOOKINGS: Array<{
suffix: string;
freight: "CONTAINER" | "BULK";
customs: boolean;
currency: "ETB" | "USD";
twenty?: number;
forty?: number;
tons?: number;
wagons: number;
}> = [
{ suffix: "XMC1", freight: "CONTAINER", customs: true, currency: "USD", twenty: 16, wagons: 8 },
{ suffix: "XMB1", freight: "BULK", customs: true, currency: "USD", tons: 560, wagons: 8 },
{ suffix: "XMC2", freight: "CONTAINER", customs: false, currency: "ETB", forty: 6, wagons: 6 },
{ suffix: "XMB2", freight: "BULK", customs: false, currency: "ETB", tons: 980, wagons: 14 },
{ suffix: "XMC3", freight: "CONTAINER", customs: true, currency: "ETB", twenty: 16, wagons: 8 },
{ suffix: "XMB3", freight: "BULK", customs: false, currency: "USD", tons: 700, wagons: 10 },
];
const CUSTOMS = BOOKINGS.filter((b) => b.customs).map((b) => b.suffix);
const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs).map((b) => b.suffix);
function withScheduleId(fn: (id: string, s: ScheduleRow) => void) {
withExportSchedule(DEPARTURE, (s) => fn(s.id, s));
}
describe("mixed export: containers and wheat share one 54-wagon FCFS train", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
for (const b of BOOKINGS) {
seedImportContract({
suffix: b.suffix,
reference: stampedRef(b.suffix),
currency: b.currency,
customs: b.customs,
freight: b.freight,
direction: "EXPORT",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
}
});
it("operations prepares the export corridor and one 54-wagon train — window forced open", () => {
ensureExportRoute();
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-EXP-5", "LOCO-EXP-6"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withScheduleId((id) => forceWindowOpen(id, 60));
});
it("three container and three bulk exporters book inside the one window", () => {
let isoSeed = 13_000;
BOOKINGS.forEach((b) => {
if (b.freight === "CONTAINER") {
bookContainers({
suffix: b.suffix,
runStamp: stamp,
isoSeed,
twenty: b.twenty,
forty: b.forty,
scheduledDate: BOOKING_DAY,
});
isoSeed += (b.twenty ?? 0) + (b.forty ?? 0);
} else {
bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY });
}
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
});
});
it("each accept reserves FCFS instantly — both kinds share one clamped ledger", () => {
BOOKINGS.forEach((b) => acceptExport(b.suffix));
withScheduleId((_, s) => {
BOOKINGS.forEach((b) => {
withBooking(b.suffix, (row) => {
expect(
new Date(row.payment_deadline!).getTime(),
`${b.suffix} deadline never outlives the window close`,
).to.be.at.most(new Date(s.window_closes_at!).getTime());
});
});
});
BOOKINGS.forEach((b) => {
withBooking(b.suffix, (row) => {
pollDb<{ currency: string }>(
`${b.suffix} invoice`,
`SELECT currency FROM freight.invoices
WHERE source_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[row.id],
(inv) => inv?.currency === b.currency,
10,
);
});
});
});
it("all six pay — 54/54 STRICTLY typed (boxes on NW5, wheat on CW4), FULL, finalized", () => {
BOOKINGS.forEach((b) => {
markPaid(b.suffix);
pollAllocations(b.suffix, b.wagons);
});
BOOKINGS.forEach((b) =>
expectWagonType(b.suffix, b.freight === "CONTAINER" ? "NW5" : "CW4", b.wagons),
);
withScheduleId((id) => {
pollDb<ScheduleRow>(
"export window FULL",
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
[id],
(s) => s?.booking_window_status === "FULL",
);
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 allocated").to.eq(54));
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/finalize`)
.its("status")
.should("be.oneOf", [200, 201]);
pollDb<ScheduleRow>(
"schedule SCHEDULED",
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(s) => s?.status === "SCHEDULED",
10,
);
});
});
it("transport documents uploaded for customs bookings of BOTH kinds; the train runs to Djibouti", () => {
withScheduleId((id) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`)
.its("status")
.should("be.oneOf", [200, 201]);
});
CUSTOMS.forEach((suffix) => {
withBooking(suffix, (b) => {
glUpload(`/api/contracts/bookings/${b.id}/transport-document`);
});
});
withScheduleId((id) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`)
.its("status")
.should("be.oneOf", [200, 201]);
pollDb<ScheduleRow>(
"schedule DISPATCHED",
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(s) => s?.status === "DISPATCHED",
10,
);
[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 ARRIVED",
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(s) => s?.status === "ARRIVED",
20,
);
});
BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "ARRIVED", 20));
});
it("container AND bulk export tails close side by side (T1 close → offloaded)", () => {
CUSTOMS.forEach((suffix) => {
withBooking(suffix, (b) => {
apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/t1-close`)
.its("status")
.should("be.oneOf", [200, 201]);
});
completeBookingMilestone(suffix, "OFFLOADED");
expectMilestoneDone(suffix, "T1_CLOSED");
expectMilestoneDone(suffix, "OFFLOADED");
});
SELF_CLEAR.forEach((suffix) => {
withBooking(suffix, (b) => {
expect(b.status, `${suffix} arrived clean`).to.eq("ARRIVED");
});
});
});
});
export {};

View File

@@ -0,0 +1,266 @@
/**
* MIXED IMPORT journey M1 — containers AND bulk wheat share ONE 54-wagon
* import train on the corridor DJIB_PORT → … → KALITY. The route-day pool is
* type-blind: one window, one doc-review, ONE batch reserves all six; only
* wagon allocation cares about the physical type — containers ride NW5 flat
* wagons, wheat rides CW4 covered gondolas, never crossed.
*
* The six bookings (Σ = 54 wagons):
* MC1 container customs + USD 16×20ft = 8 × NW5
* MB1 bulk customs + USD 560 T = 8 × CW4
* MC2 container self + ETB 6×40ft = 6 × NW5
* MB2 bulk self + ETB 980 T = 14 × CW4
* MC3 container customs + ETB 16×20ft = 8 × NW5
* MB3 bulk self + USD 700 T = 10 × CW4
*
* Sequential steps of one journey — retries off.
*/
import {
acceptOperation,
apiPost,
bookBulk,
bookContainers,
closeBookingWindow,
completeBookingMilestone,
completeDocReview,
createImportSchedule,
db,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
expectMilestoneDone,
expectWagonType,
forceWindowOpen,
glUpload,
markPaid,
opsStaff,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(18);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
const BOOKINGS: Array<{
suffix: string;
freight: "CONTAINER" | "BULK";
customs: boolean;
currency: "ETB" | "USD";
twenty?: number;
forty?: number;
tons?: number;
wagons: number;
}> = [
{ suffix: "MC1", freight: "CONTAINER", customs: true, currency: "USD", twenty: 16, wagons: 8 },
{ suffix: "MB1", freight: "BULK", customs: true, currency: "USD", tons: 560, wagons: 8 },
{ suffix: "MC2", freight: "CONTAINER", customs: false, currency: "ETB", forty: 6, wagons: 6 },
{ suffix: "MB2", freight: "BULK", customs: false, currency: "ETB", tons: 980, wagons: 14 },
{ suffix: "MC3", freight: "CONTAINER", customs: true, currency: "ETB", twenty: 16, wagons: 8 },
{ suffix: "MB3", freight: "BULK", customs: false, currency: "USD", tons: 700, wagons: 10 },
];
const CUSTOMS = BOOKINGS.filter((b) => b.customs).map((b) => b.suffix);
const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs).map((b) => b.suffix);
function withScheduleId(fn: (id: string, s: ScheduleRow) => void) {
withSchedule(DEPARTURE, (s) => fn(s.id, s));
}
describe("mixed import: containers and wheat share one 54-wagon train", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
for (const b of BOOKINGS) {
seedImportContract({
suffix: b.suffix,
reference: stampedRef(b.suffix),
currency: b.currency,
customs: b.customs,
freight: b.freight,
});
}
});
it("operations prepares the corridor and one 54-wagon train — first window forced open", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-1", "LOCO-IMP-2"] });
withScheduleId((id) => forceWindowOpen(id, 45));
});
it("three container and three bulk shipments book inside the same first window", () => {
let isoSeed = 9000;
BOOKINGS.forEach((b) => {
if (b.freight === "CONTAINER") {
bookContainers({
suffix: b.suffix,
runStamp: stamp,
isoSeed,
twenty: b.twenty,
forty: b.forty,
scheduledDate: BOOKING_DAY,
});
isoSeed += (b.twenty ?? 0) + (b.forty ?? 0);
} else {
bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY });
}
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
});
});
it("operations accepts all six — one type-blind pool", () => {
BOOKINGS.forEach((b) => acceptOperation(b.suffix));
});
it("ONE doc-review-complete releases ONE batch that reserves both cargo kinds together", () => {
withScheduleId((id) => {
closeBookingWindow(id);
completeDocReview(id);
});
BOOKINGS.forEach((b) =>
pollBookingStatus(b.suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
// Currency mix from one batch: USD and ETB invoices side by side.
BOOKINGS.forEach((b) => {
withBooking(b.suffix, (row) => {
pollDb<{ currency: string }>(
`${b.suffix} invoice`,
`SELECT currency FROM freight.invoices
WHERE source_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[row.id],
(inv) => inv?.currency === b.currency,
10,
);
});
});
});
it("all six pay — 54/54 with STRICT wagon-type integrity (containers on NW5, wheat on CW4)", () => {
BOOKINGS.forEach((b) => {
markPaid(b.suffix);
pollAllocations(b.suffix, b.wagons);
});
BOOKINGS.forEach((b) =>
expectWagonType(b.suffix, b.freight === "CONTAINER" ? "NW5" : "CW4", b.wagons),
);
withScheduleId((id) => {
endPaymentPhase(id);
pollDb<ScheduleRow>(
"schedule FULL + DONE + finalized",
`SELECT window_phase, booking_window_status, status
FROM freight.train_schedules WHERE id = $1`,
[id],
(s) =>
s?.booking_window_status === "FULL" &&
s?.window_phase === "DONE" &&
s?.status === "SCHEDULED",
);
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 allocated").to.eq(54));
});
});
it("gate pass + T1 uploads for the customs bookings of BOTH kinds", () => {
withScheduleId((id) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`)
.its("status")
.should("be.oneOf", [200, 201]);
});
CUSTOMS.forEach((suffix) => {
withBooking(suffix, (b) => {
glUpload(`/api/contracts/bookings/${b.id}/t1-documents`);
});
});
});
it("the mixed train dispatches, runs the corridor and arrives — both kinds ARRIVED", () => {
withScheduleId((id) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`)
.its("status")
.should("be.oneOf", [200, 201]);
pollDb<ScheduleRow>(
"schedule DISPATCHED",
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(s) => s?.status === "DISPATCHED",
10,
);
[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 ARRIVED",
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(s) => s?.status === "ARRIVED",
20,
);
});
BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "ARRIVED", 20));
withScheduleId((id) => {
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.wagon_movements WHERE train_schedule_id = $1`,
[id],
).then(({ rows }) =>
expect(Number(rows[0].n), "wagon movement ledger rows").to.be.at.least(54),
);
});
});
it("the customs tails of container AND bulk bookings complete side by side", () => {
CUSTOMS.forEach((suffix) => {
withBooking(suffix, (b) => {
apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/t1-close`)
.its("status")
.should("be.oneOf", [200, 201]);
apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/risk`, {
riskLevel: "GREEN",
})
.its("status")
.should("be.oneOf", [200, 201]);
glUpload(
`/api/contracts/bookings/${b.id}/second-duty`,
{ dutyRequired: "false" },
"attachment",
);
});
completeBookingMilestone(suffix, "IMPORT_RELEASE_GRANTED");
expectMilestoneDone(suffix, "T1_CLOSED");
expectMilestoneDone(suffix, "IMPORT_RELEASE_GRANTED");
});
SELF_CLEAR.forEach((suffix) => {
withBooking(suffix, (b) => {
expect(b.status, `${suffix} arrived clean`).to.eq("ARRIVED");
});
});
});
});
export {};

View File

@@ -0,0 +1,212 @@
/**
* MIXED IMPORT journey M4 + matrix — dead mixed cycle, mixed recovery, and
* mixed ride-alongs on one train (D+21):
*
* 1. cycle 1: one container (20w) and one bulk (1 400 T) reserved, neither
* pays → both expire, the window REOPENS (mixed cycle bookkeeping)
* 2. cycle 2: a fresh container (10w) AND a fresh bulk (730 T → ceil = 11
* wagons, the rounding case) book, pay, allocate typed on the recovered
* train
* 3. mixed intercity: a container ride-along AND a bulk ride-along
* (KALITY-bound legs are free) join the same import train — accepted,
* paid, linked
* 4. mixed sub-corridor: a bulk NAGAD→MOJO booking rides the through-train
* beside the cycle-2 pair
*
* Sequential steps of one journey — retries off.
*/
import {
acceptOperation,
apiPost,
bookBulk,
bookContainers,
closeBookingWindow,
completeDocReview,
createImportSchedule,
db,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
expectWagonType,
forceReservationExpiry,
forceWindowOpen,
markPaid,
opsStaff,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(21);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
describe("mixed import: dead mixed cycle, mixed recovery, mixed ride-alongs", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
seedImportContract({ suffix: "MRC", reference: stampedRef("MRC") });
seedImportContract({ suffix: "MRB", reference: stampedRef("MRB"), freight: "BULK" });
seedImportContract({ suffix: "MRC2", reference: stampedRef("MRC2") });
seedImportContract({ suffix: "MRB2", reference: stampedRef("MRB2"), freight: "BULK" });
seedImportContract({
suffix: "MRSUB",
reference: stampedRef("MRSUB"),
freight: "BULK",
originCode: "NAGAD",
destCode: "MOJO",
});
seedImportContract({
suffix: "MRIC",
reference: stampedRef("MRIC"),
direction: "DOMESTIC",
originCode: "MOJO",
destCode: "KALITY",
});
seedImportContract({
suffix: "MRIB",
reference: stampedRef("MRIB"),
freight: "BULK",
direction: "DOMESTIC",
originCode: "MOJO",
destCode: "KALITY",
});
});
it("operations prepares the corridor train — first window opens (cycle 1)", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-9", "LOCO-IMP-10"] });
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 1").to.eq(1));
});
it("cycle 1: one container and one bulk reserved — neither pays, both expire, window reopens", () => {
bookContainers({
suffix: "MRC",
runStamp: stamp,
isoSeed: 12_000,
twenty: 40,
scheduledDate: BOOKING_DAY,
});
acceptOperation("MRC");
bookBulk({ suffix: "MRB", tons: 1400, scheduledDate: BOOKING_DAY });
acceptOperation("MRB");
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
["MRC", "MRB"].forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
["MRC", "MRB"].forEach((suffix) => forceReservationExpiry(suffix));
withSchedule(DEPARTURE, (s) => {
endPaymentPhase(s.id);
pollDb<ScheduleRow>(
"window reopens (PRE_WINDOW)",
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.window_phase === "PRE_WINDOW",
);
});
});
it("cycle 2: a fresh container (10w) and a fresh bulk (730 T → 11 wagons) 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: "MRC2",
runStamp: stamp,
isoSeed: 12_200,
twenty: 20,
scheduledDate: BOOKING_DAY,
});
acceptOperation("MRC2");
// ceil(730 / 70) = 11 — the bulk rounding case rides beside containers.
bookBulk({ suffix: "MRB2", tons: 730, scheduledDate: BOOKING_DAY });
acceptOperation("MRB2");
// Mixed sub-corridor: bulk boards NAGAD, alights MOJO, same train.
bookBulk({ suffix: "MRSUB", tons: 700, scheduledDate: BOOKING_DAY });
acceptOperation("MRSUB");
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
["MRC2", "MRB2", "MRSUB"].forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
markPaid("MRC2");
pollAllocations("MRC2", 10);
expectWagonType("MRC2", "NW5", 10);
markPaid("MRB2");
pollAllocations("MRB2", 11);
expectWagonType("MRB2", "CW4", 11);
markPaid("MRSUB");
pollAllocations("MRSUB", 10);
expectWagonType("MRSUB", "CW4", 10);
["MRC", "MRB"].forEach((suffix) =>
withBooking(suffix, (b) => expect(b.status, `${suffix} stays expired`).to.eq("EXPIRED")),
);
withSchedule(DEPARTURE, (s) => {
["MRC2", "MRB2", "MRSUB"].forEach((suffix) =>
withBooking(suffix, (b) =>
expect(b.train_schedule_id, `${suffix} rides the recovered train`).to.eq(s.id),
),
);
});
});
it("mixed intercity: a container AND a bulk ride-along join the same import train", () => {
bookContainers({ suffix: "MRIC", runStamp: stamp, isoSeed: 12_400, twenty: 4 });
acceptOperation("MRIC");
bookBulk({ suffix: "MRIB", tons: 140 });
acceptOperation("MRIB");
withSchedule(DEPARTURE, (s) => {
withBooking("MRIC", (bc) => {
withBooking("MRIB", (bb) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${s.id}/intercity/accept`, {
bookingIds: [bc.id, bb.id],
})
.its("status")
.should("be.oneOf", [200, 201]);
});
});
});
["MRIC", "MRIB"].forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
markPaid("MRIC");
markPaid("MRIB");
withSchedule(DEPARTURE, (s) => {
["MRIC", "MRIB"].forEach((suffix) =>
withBooking(suffix, (b) => {
expect(b.train_schedule_id, `${suffix} linked`).to.eq(s.id);
// The link row is written by the async allocate step — poll it.
pollDb<{ n: string }>(
`${suffix} link row`,
`SELECT count(*) AS n FROM freight.train_schedule_bookings
WHERE booking_id = $1 AND train_schedule_id = $2 AND deleted_at IS NULL`,
[b.id, s.id],
(row) => Number(row?.n ?? 0) === 1,
15,
);
}),
);
});
});
});
export {};

View File

@@ -0,0 +1,216 @@
/**
* MIXED IMPORT journey M3 — a container split and a CROSS-TYPE promotion:
* container-freed wagons hand the train to the waiting bulk queue.
*
* Queue reality: containers order by staff priority, bulk is rule-recomputed
* and orders by acceptance. Fill (54 slots):
* MSC container 40×20ft = 20w (prio 1) — pays
* MSD container 40×20ft = 20w (prio 2) — reserved, never pays
* MSX container 48×20ft = 24w (prio 3) — only 14 left → PARTIAL offer 14w;
* settles via the real payment pipeline → split applies (28 boxes ride,
* 20×20ft outstanding)
* bulk queue (acceptance order): MSB 840 T = 12w, MSW2 560 T = 8w,
* MSW3 700 T = 10w — no room at fill, ALL wait.
* MSD expires → its 20 container wagons promote the BULK queue: MSB (12w)
* and MSW2 (8w) get pay windows; both pay → CW4 gondolas under the wheat →
* 54/54 = 20 NW5 + 14 NW5 + 12 CW4 + 8 CW4. MSW3 expires with the day, and
* the split customer must later rebook EXACTLY the 20×20ft remainder.
*
* Sequential steps of one journey — retries off.
*/
import {
acceptOperation,
bookBulk,
bookContainers,
closeBookingWindow,
completeDocReview,
createImportSchedule,
db,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
expectWagonType,
forceReservationExpiry,
forceWindowOpen,
markPaid,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
setPriority,
settleViaGateway,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(20);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const REMAINDER_DEPARTURE = departureAt(22);
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
const CONTAINERS = [
{ suffix: "MSC", twenty: 40, wagons: 20 },
{ suffix: "MSD", twenty: 40, wagons: 20 },
{ suffix: "MSX", twenty: 48, wagons: 24 },
];
const BULKS = [
{ suffix: "MSB", tons: 840, wagons: 12 },
{ suffix: "MSW2", tons: 560, wagons: 8 },
{ suffix: "MSW3", tons: 700, wagons: 10 },
];
describe("mixed import: container split; container-freed wagons promote the bulk queue", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
CONTAINERS.forEach((b) =>
seedImportContract({ suffix: b.suffix, reference: stampedRef(b.suffix) }),
);
BULKS.forEach((b) =>
seedImportContract({ suffix: b.suffix, reference: stampedRef(b.suffix), freight: "BULK" }),
);
});
it("operations prepares the corridor train with an open first window", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
resetCorridorDay(REMAINDER_DEPARTURE);
createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-5", "LOCO-IMP-6"] });
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
});
it("six mixed shipments book in the first window; accepted in queue order", () => {
let isoSeed = 10_000;
CONTAINERS.forEach((b) => {
bookContainers({
suffix: b.suffix,
runStamp: stamp,
isoSeed,
twenty: b.twenty,
scheduledDate: BOOKING_DAY,
});
isoSeed += b.twenty;
acceptOperation(b.suffix);
});
BULKS.forEach((b) => {
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
acceptOperation(b.suffix);
});
CONTAINERS.forEach((b, i) => setPriority(b.suffix, i + 1));
});
it("the batch reserves the containers (MSX gets a 14w PARTIAL); the bulk queue waits whole", () => {
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
CONTAINERS.forEach((b) =>
pollBookingStatus(b.suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
withBooking("MSX", (b) => {
pollDb<{ status: string }>(
"MSX open partial offer",
`SELECT status FROM freight.booking_batch_offers
WHERE booking_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[b.id],
(row) => row?.status === "OFFERED",
10,
);
});
BULKS.forEach((b) =>
withBooking(b.suffix, (row) => {
expect(row.status, `${b.suffix} waiting`).to.eq("FULLY_EXECUTED");
}),
);
});
it("MSC pays; MSX settles via the gateway — the split applies (14 × NW5, 28 boxes ride)", () => {
markPaid("MSC");
pollAllocations("MSC", 20);
expectWagonType("MSC", "NW5", 20);
settleViaGateway("MSX");
pollAllocations("MSX", 14);
expectWagonType("MSX", "NW5", 14);
withBooking("MSX", (b) => {
expect(b.is_split, "MSX is split").to.eq(true);
db<{ q: string }>(
`SELECT sum(quantity) AS q FROM freight.booking_container
WHERE booking_id = $1 AND deleted_at IS NULL`,
[b.id],
).then(({ rows }) => expect(Number(rows[0].q), "MSX shrank to 28 boxes").to.eq(28));
});
});
it("the unpaid container expires — its wagons promote the BULK queue (MSB + MSW2 get pay windows)", () => {
forceReservationExpiry("MSD");
["MSB", "MSW2"].forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
withBooking("MSW3", (b) => {
expect(b.status, "the 10w bulk still has no seat").to.eq("FULLY_EXECUTED");
});
});
it("both promoted bulks pay — 54/54 typed (NW5 under boxes, CW4 under wheat); MSW3 expires", () => {
markPaid("MSB");
pollAllocations("MSB", 12);
expectWagonType("MSB", "CW4", 12);
markPaid("MSW2");
pollAllocations("MSW2", 8);
expectWagonType("MSW2", "CW4", 8);
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));
});
pollBookingStatus("MSW3", "EXPIRED");
});
it("the split container customer must rebook EXACTLY the 20×20ft remainder", () => {
createImportSchedule({
departure: REMAINDER_DEPARTURE,
locoPair: ["LOCO-IMP-7", "LOCO-IMP-8"],
});
withSchedule(REMAINDER_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
bookContainers({
suffix: "MSX",
runStamp: stamp,
isoSeed: 11_000,
twenty: 8,
scheduledDate: REMAINDER_DAY,
expectFailure: "must take the whole remainder",
});
bookContainers({
suffix: "MSX",
runStamp: stamp,
isoSeed: 11_100,
twenty: 20,
scheduledDate: REMAINDER_DAY,
});
pollBookingStatus("MSX", "OPERATION_REQUEST_PENDING", 5);
});
});
export {};

View File

@@ -0,0 +1,149 @@
/**
* MIXED IMPORT journey M2 — one queue, two cargo kinds. Engine ordering rule
* under test: CONTAINER bookings keep their staff-set priority scores, BULK
* priorities are RECOMPUTED from the rule engine at batch time and then order
* among themselves by acceptance — so the queue is containers-by-priority
* first, bulk-by-acceptance after. The train fills across both kinds
* (20w + 20w containers + the first-accepted 14w bulk = 54); the remaining
* bulk trio waits with no pay window and expires when the day concludes FULL.
*
* Sequential steps of one journey — retries off.
*/
import {
acceptOperation,
bookBulk,
bookContainers,
closeBookingWindow,
completeDocReview,
createImportSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
expectWagonType,
forceWindowOpen,
markPaid,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
setPriority,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(19);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
const SELECTED: Array<{
suffix: string;
freight: "CONTAINER" | "BULK";
twenty?: number;
tons?: number;
wagons: number;
}> = [
{ suffix: "MWC1", freight: "CONTAINER", twenty: 40, wagons: 20 },
{ suffix: "MWC2", freight: "CONTAINER", twenty: 40, wagons: 20 },
{ suffix: "MWB1", freight: "BULK", tons: 980, wagons: 14 },
];
const WAITING = [
{ suffix: "MWB2", tons: 700 },
{ suffix: "MWB3", tons: 700 },
{ suffix: "MWB4", tons: 700 },
];
describe("mixed import: containers + first bulk fill the train, bulk trio waits and expires", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
SELECTED.forEach((b) =>
seedImportContract({ suffix: b.suffix, reference: stampedRef(b.suffix), freight: b.freight }),
);
WAITING.forEach((b) =>
seedImportContract({ suffix: b.suffix, reference: stampedRef(b.suffix), freight: "BULK" }),
);
});
it("operations prepares the corridor train with an open first window", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-3", "LOCO-IMP-4"] });
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
});
it("six mixed shipments book; operations accepts — MWB1 is the FIRST-accepted bulk", () => {
let isoSeed = 9500;
// Containers first (manual priority), then bulk in acceptance order —
// MWB1 accepted before the waiters so the recomputed-bulk tie-break
// (fully_executed_at ASC) puts it at the head of the bulk queue.
SELECTED.forEach((b) => {
if (b.freight === "CONTAINER") {
bookContainers({
suffix: b.suffix,
runStamp: stamp,
isoSeed,
twenty: b.twenty,
scheduledDate: BOOKING_DAY,
});
isoSeed += b.twenty ?? 0;
} else {
bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY });
}
acceptOperation(b.suffix);
});
WAITING.forEach((b) => {
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
acceptOperation(b.suffix);
});
// Manual priority holds for CONTAINER bookings only (bulk is recomputed).
setPriority("MWC1", 1);
setPriority("MWC2", 2);
});
it("the batch fills 54 across both kinds; the bulk trio keeps waiting with no pay window", () => {
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
SELECTED.forEach((b) =>
pollBookingStatus(b.suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
WAITING.forEach((b) =>
withBooking(b.suffix, (row) => {
expect(row.status, `${b.suffix} still waiting`).to.eq("FULLY_EXECUTED");
expect(row.payment_deadline, `${b.suffix} has no pay deadline`).to.be.null;
}),
);
});
it("the three selected pay — typed allocation 54/54", () => {
SELECTED.forEach((b) => {
markPaid(b.suffix);
pollAllocations(b.suffix, b.wagons);
expectWagonType(b.suffix, b.freight === "CONTAINER" ? "NW5" : "CW4", b.wagons);
});
});
it("the day concludes FULL — the waiting bulk trio expires in ONE sweep", () => {
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",
);
});
WAITING.forEach((b) => pollBookingStatus(b.suffix, "EXPIRED"));
SELECTED.forEach((b) =>
withBooking(b.suffix, (row) => expect(row.status, `${b.suffix} stays PAID`).to.eq("PAID")),
);
});
});
export {};

View File

@@ -76,7 +76,10 @@ SELECT gen_random_uuid(), v.code, 9000, 760, y.id
FROM (VALUES ('LOCO-IMP-1'), ('LOCO-IMP-2'), ('LOCO-IMP-3'), ('LOCO-IMP-4'),
('LOCO-IMP-5'), ('LOCO-IMP-6'), ('LOCO-IMP-7'), ('LOCO-IMP-8'),
('LOCO-IMP-9'), ('LOCO-IMP-10'), ('LOCO-IMP-11'), ('LOCO-IMP-12'),
('LOCO-IMP-13'), ('LOCO-IMP-14'))
('LOCO-IMP-13'), ('LOCO-IMP-14'), ('LOCO-IMP-15'), ('LOCO-IMP-16'),
('LOCO-IMP-17'), ('LOCO-IMP-18'), ('LOCO-IMP-19'), ('LOCO-IMP-20'),
('LOCO-IMP-21'), ('LOCO-IMP-22'), ('LOCO-IMP-23'), ('LOCO-IMP-24'),
('LOCO-IMP-25'), ('LOCO-IMP-26'), ('LOCO-IMP-27'), ('LOCO-IMP-28'))
AS v(code)
JOIN freight.yards y ON y.code = 'DJIB_PORT'
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
@@ -109,6 +112,70 @@ FROM (
) pick
WHERE w.id = pick.id;
-- 5b. Export-corridor rolling stock: the reversed corridor (KALITY →
-- DJIB_PORT) loads at KALITY, sub-corridor exports board at DIRE_DAWA, and
-- twelve dedicated locomotives live at KALITY.
UPDATE freight.wagons w
SET current_yard_id = (SELECT id FROM freight.yards WHERE code = 'KALITY')
FROM (
SELECT w2.id
FROM freight.wagons w2
JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'NW5'
JOIN freight.yards y ON y.id = w2.current_yard_id AND y.code = 'DJIB_PORT'
WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
ORDER BY w2.wagon_number ASC
LIMIT 120
) pick
WHERE w.id = pick.id;
UPDATE freight.wagons w
SET current_yard_id = (SELECT id FROM freight.yards WHERE code = 'DIRE_DAWA')
FROM (
SELECT w2.id
FROM freight.wagons w2
JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'NW5'
JOIN freight.yards y ON y.id = w2.current_yard_id AND y.code = 'DJIB_PORT'
WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
ORDER BY w2.wagon_number ASC
LIMIT 20
) pick
WHERE w.id = pick.id;
INSERT INTO freight.locomotives
(id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id)
SELECT gen_random_uuid(), v.code, 9000, 760, y.id
FROM (VALUES ('LOCO-EXP-1'), ('LOCO-EXP-2'), ('LOCO-EXP-3'), ('LOCO-EXP-4'),
('LOCO-EXP-5'), ('LOCO-EXP-6'), ('LOCO-EXP-7'), ('LOCO-EXP-8'),
('LOCO-EXP-9'), ('LOCO-EXP-10'), ('LOCO-EXP-11'), ('LOCO-EXP-12'))
AS v(code)
JOIN freight.yards y ON y.code = 'KALITY'
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
-- 5b2. Export-bulk rolling stock: the boot CW4 fleet (110) is already spoken
-- for by the import-bulk specs at DJIB_PORT — mint dedicated e2e CW4 wagons
-- for the export side: 80 at KALITY (full trains) + 20 at DIRE_DAWA
-- (sub-corridor boarding).
INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id)
SELECT gen_random_uuid(), 'ECW' || lpad(g::text, 4, '0'), wt.id,
(SELECT id FROM freight.yards
WHERE code = CASE WHEN g <= 80 THEN 'KALITY' ELSE 'DIRE_DAWA' END)
FROM generate_series(1, 100) AS g
JOIN freight.wagon_types wt ON wt.code = 'CW4'
WHERE NOT EXISTS (
SELECT 1 FROM freight.wagons w WHERE w.wagon_number = 'ECW' || lpad(g::text, 4, '0')
);
-- 5c. Approved exporter profile — export contracts bill against it
-- (seed-company.sql only creates the importer).
INSERT INTO freight.company_profiles (id, company_id, type, status, reference)
SELECT gen_random_uuid(), c.id, 'exporter', 'active', 'EXP-E2E-0002'
FROM freight.companies c
WHERE c.tin = '0102030405'
AND NOT EXISTS (
SELECT 1 FROM freight.company_profiles p
WHERE p.company_id = c.id AND p.type = 'exporter' AND p.deleted_at IS NULL
);
-- 6. Segment distances for every consecutive corridor pair (symmetric rows).
INSERT INTO freight.yard_distances (id, from_yard_id, to_yard_id, distance_km)
SELECT gen_random_uuid(), a.id, b.id, v.km
@@ -127,20 +194,102 @@ WHERE NOT EXISTS (
OR (d.from_yard_id = b.id AND d.to_yard_id = a.id)
);
-- 6b. BULK import cargo hierarchy: group "E2E Import Grains" → commodity
-- "E2E Import Wheat", carried on CW4 covered gondolas (13.976 m — the only
-- bulk-capable type whose 54-wagon consist still fits the 760 m locos).
INSERT INTO freight.cargo_types (id, code, cargo_type_name, is_active)
SELECT gen_random_uuid(), 'E2E_IMP_GRAINS', 'E2E Import Grains', true
WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_IMP_GRAINS');
INSERT INTO freight.cargo_types (id, code, cargo_type_name, parent_group_id, is_active)
SELECT gen_random_uuid(), 'E2E_IMP_WHEAT', 'E2E Import Wheat', g.id, true
FROM freight.cargo_types g
WHERE g.code = 'E2E_IMP_GRAINS'
AND NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_IMP_WHEAT');
INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
SELECT ct.id, wt.id
FROM freight.cargo_types ct
JOIN freight.wagon_types wt ON wt.code = 'CW4'
WHERE ct.code IN ('E2E_IMP_GRAINS', 'E2E_IMP_WHEAT')
AND NOT EXISTS (
SELECT 1 FROM freight.cargo_type_wagon_types x
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
);
-- 6c. Park the free CW4 fleet for the bulk specs: bulk trains load at
-- Djibouti Port, a NAGAD pocket serves the sub-corridor scenario and a MOJO
-- pocket the bulk intercity ride-along. The e2e-minted ECW% wagons are the
-- EXPORT pocket (KALITY / DIRE_DAWA) — never sweep them to Djibouti.
UPDATE freight.wagons w
SET current_yard_id = (SELECT id FROM freight.yards WHERE code = 'DJIB_PORT')
FROM freight.wagon_types wt
WHERE wt.id = w.wagon_type_id AND wt.code = 'CW4'
AND w.train_id IS NULL AND w.deleted_at IS NULL
AND w.wagon_number NOT LIKE 'ECW%';
-- Re-park the export CW4 pocket every seed (the mint above is insert-guarded).
UPDATE freight.wagons w
SET current_yard_id = (SELECT id FROM freight.yards
WHERE code = CASE WHEN substring(w.wagon_number FROM 4)::int <= 80
THEN 'KALITY' ELSE 'DIRE_DAWA' END)
FROM freight.wagon_types wt
WHERE wt.id = w.wagon_type_id AND wt.code = 'CW4'
AND w.wagon_number LIKE 'ECW%'
AND w.train_id IS NULL AND w.deleted_at IS NULL;
UPDATE freight.wagons w
SET current_yard_id = (SELECT id FROM freight.yards WHERE code = 'NAGAD')
FROM (
SELECT w2.id
FROM freight.wagons w2
JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'CW4'
WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
ORDER BY w2.wagon_number DESC
LIMIT 10
) pick
WHERE w.id = pick.id;
UPDATE freight.wagons w
SET current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO')
FROM (
SELECT w2.id
FROM freight.wagons w2
JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'CW4'
JOIN freight.yards y ON y.id = w2.current_yard_id AND y.code = 'DJIB_PORT'
WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
ORDER BY w2.wagon_number ASC
LIMIT 6
) pick
WHERE w.id = pick.id;
-- 7. LIVE import rates on every leg the specs book, plus the intercity
-- ride-along leg (rates are configured in USD and converted per booking).
INSERT INTO freight.rates
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
origin_yard_id, destination_yard_id, proposed_by_staff_id)
SELECT gen_random_uuid(), v.rate_type, v.applies_to, 'ALWAYS', 'USD', v.value,
'PER_CONTAINER', 'LIVE', a.id, b.id, u.id
v.unit, 'LIVE', a.id, b.id, u.id
FROM (VALUES
('CONTAINER_IMPORT', 'CONTAINER', 'DJIB_PORT', 'KALITY', 800),
('CONTAINER_IMPORT', 'CONTAINER', 'DJIB_PORT', 'MOJO', 700),
('CONTAINER_IMPORT', 'CONTAINER', 'NAGAD', 'KALITY', 650),
('CONTAINER_IMPORT', 'CONTAINER', 'NAGAD', 'MOJO', 600),
('INTERCITY_CONTAINER', 'INTERCITY', 'MOJO', 'KALITY', 200)
) AS v(rate_type, applies_to, from_code, to_code, value)
('CONTAINER_IMPORT', 'CONTAINER', 'DJIB_PORT', 'KALITY', 800, 'PER_CONTAINER'),
('CONTAINER_IMPORT', 'CONTAINER', 'DJIB_PORT', 'MOJO', 700, 'PER_CONTAINER'),
('CONTAINER_IMPORT', 'CONTAINER', 'NAGAD', 'KALITY', 650, 'PER_CONTAINER'),
('CONTAINER_IMPORT', 'CONTAINER', 'NAGAD', 'MOJO', 600, 'PER_CONTAINER'),
('INTERCITY_CONTAINER', 'INTERCITY', 'MOJO', 'KALITY', 200, 'PER_CONTAINER'),
('BULK_IMPORT', 'BULK', 'DJIB_PORT', 'KALITY', 30, 'PER_TON'),
('BULK_IMPORT', 'BULK', 'DJIB_PORT', 'MOJO', 28, 'PER_TON'),
('BULK_IMPORT', 'BULK', 'NAGAD', 'KALITY', 26, 'PER_TON'),
('BULK_IMPORT', 'BULK', 'NAGAD', 'MOJO', 25, 'PER_TON'),
('INTERCITY_BULK', 'INTERCITY', 'MOJO', 'KALITY', 10, 'PER_TON'),
('CONTAINER_EXPORT', 'CONTAINER', 'KALITY', 'DJIB_PORT', 800, 'PER_CONTAINER'),
('CONTAINER_EXPORT', 'CONTAINER', 'MOJO', 'DJIB_PORT', 700, 'PER_CONTAINER'),
('CONTAINER_EXPORT', 'CONTAINER', 'DIRE_DAWA', 'DJIB_PORT', 500, 'PER_CONTAINER'),
('INTERCITY_CONTAINER', 'INTERCITY', 'KALITY', 'MOJO', 200, 'PER_CONTAINER'),
('BULK_EXPORT', 'BULK', 'KALITY', 'DJIB_PORT', 30, 'PER_TON'),
('BULK_EXPORT', 'BULK', 'MOJO', 'DJIB_PORT', 25, 'PER_TON'),
('BULK_EXPORT', 'BULK', 'DIRE_DAWA', 'DJIB_PORT', 20, 'PER_TON'),
('INTERCITY_BULK', 'INTERCITY', 'KALITY', 'MOJO', 10, 'PER_TON')
) AS v(rate_type, applies_to, from_code, to_code, value, unit)
JOIN freight.yards a ON a.code = v.from_code
JOIN freight.yards b ON b.code = v.to_code
JOIN iam.users u ON u.email = 'operation@edr.local'