mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 15:30:56 +00:00
225 lines
7.9 KiB
TypeScript
225 lines
7.9 KiB
TypeScript
/**
|
|
* BULK IMPORT edge matrix:
|
|
* a) a wheat booking on a day with no open window is rejected at submission
|
|
* b) a sub-corridor booking (NAGAD → MOJO) rides the through-train next to a
|
|
* DJIB_PORT → KALITY booking — the batch is corridor-aware
|
|
* c) bulk intercity ride-along (MOJO → KALITY, DOMESTIC, dateless): staff
|
|
* assign it onto the import train's free leg, the pay window opens, it
|
|
* pays and links
|
|
* d) whole-train giant: 4 000 T (58 wagons' worth) alone on a 54-wagon train
|
|
* → partial offer of the FULL consist (3 780 T); the gateway settlement
|
|
* applies the split and the train is FULL from ONE booking; the 220 T
|
|
* outstanding must be rebooked EXACTLY on a later train
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { closeDb, db, gateway, poll } from "./client";
|
|
import {
|
|
acceptIntercityOnto,
|
|
acceptOperation,
|
|
bookBulk,
|
|
bookBulkReady,
|
|
expectDayRefused,
|
|
bookingFor,
|
|
bookingRow,
|
|
clearIntercityBooking,
|
|
closeBookingWindow,
|
|
completeDocReview,
|
|
createSchedule,
|
|
departureAt,
|
|
eatDayStr,
|
|
endPaymentPhase,
|
|
ensureCorridorRoute,
|
|
extendPayWindow,
|
|
forceWindowOpen,
|
|
payViaGateway,
|
|
pollAllocations,
|
|
pollBookingStatus,
|
|
pollPartialOffer,
|
|
pollWindow,
|
|
releaseUnpaidHolds,
|
|
resetCorridorDay,
|
|
seedTenantContracts,
|
|
} from "./flows";
|
|
|
|
const DEPARTURE = departureAt(25);
|
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
|
const GIANT_DEPARTURE = departureAt(26);
|
|
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
|
|
const REMAINDER_DEPARTURE = departureAt(27);
|
|
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
|
|
const NO_WINDOW_DAY = eatDayStr(departureAt(29)); // no schedule exists there
|
|
const STAMP = String(Date.now());
|
|
|
|
describe("bulk import matrix: gates, sub-corridor, ride-along, whole-train giant", () => {
|
|
let contracts: Map<string, string>;
|
|
let scheduleId: string;
|
|
|
|
beforeAll(async () => {
|
|
await gateway.reset();
|
|
await releaseUnpaidHolds();
|
|
await ensureCorridorRoute();
|
|
await resetCorridorDay(DEPARTURE);
|
|
await resetCorridorDay(GIANT_DEPARTURE);
|
|
await resetCorridorDay(REMAINDER_DEPARTURE);
|
|
|
|
contracts = await seedTenantContracts(STAMP, [
|
|
{ suffix: "BM1", freight: "BULK" },
|
|
{ suffix: "BMNW", freight: "BULK" },
|
|
{ suffix: "BMSUB", freight: "BULK", originCode: "NAGAD", destCode: "MOJO" },
|
|
{ suffix: "BMIC", freight: "BULK", direction: "DOMESTIC", originCode: "MOJO", destCode: "KALITY" },
|
|
{ suffix: "BMG", freight: "BULK" },
|
|
]);
|
|
|
|
scheduleId = (
|
|
await createSchedule({
|
|
departure: DEPARTURE,
|
|
kind: "bulk",
|
|
locoPair: ["LOCO-IMP-25", "LOCO-IMP-26"],
|
|
})
|
|
).id;
|
|
await forceWindowOpen(scheduleId, 60);
|
|
}, 1_800_000);
|
|
|
|
afterAll(closeDb);
|
|
|
|
it("refuses a shipment day with no open window", async () => {
|
|
const res = await expectDayRefused({
|
|
contractId: contracts.get("BMNW")!,
|
|
tons: 140,
|
|
scheduledDate: NO_WINDOW_DAY,
|
|
});
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
// Wording differs by gate: the day probe answers "No departures available
|
|
// on the selected day", the route probe "the import booking window …".
|
|
expect(JSON.stringify(res.body)).toMatch(/booking window|no departures/i);
|
|
});
|
|
|
|
it("a through booking and a NAGAD→MOJO sub-corridor booking share the train", async () => {
|
|
const bm1 = await bookBulkReady({
|
|
contractId: contracts.get("BM1")!,
|
|
tons: 1400,
|
|
scheduledDate: BOOKING_DAY,
|
|
});
|
|
const sub = await bookBulkReady({
|
|
contractId: contracts.get("BMSUB")!,
|
|
tons: 700,
|
|
scheduledDate: BOOKING_DAY,
|
|
});
|
|
|
|
await closeBookingWindow(scheduleId);
|
|
await completeDocReview(scheduleId);
|
|
for (const id of [bm1, sub]) {
|
|
await pollBookingStatus(id, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
|
}
|
|
|
|
await extendPayWindow(scheduleId, [bm1, sub]);
|
|
await payViaGateway(bm1);
|
|
await pollAllocations(bm1, 20);
|
|
await payViaGateway(sub);
|
|
await pollAllocations(sub, 10);
|
|
|
|
expect((await bookingRow(bm1)).train_schedule_id).toBe(scheduleId);
|
|
expect((await bookingRow(sub)).train_schedule_id).toBe(scheduleId);
|
|
});
|
|
|
|
it("a dateless DOMESTIC ride-along boards the import train's free leg", async () => {
|
|
// 140 T = 2 wagons MOJO → KALITY, no shipment day of its own.
|
|
const res = await bookBulk({ contractId: contracts.get("BMIC")!, tons: 140 });
|
|
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
|
|
const ic = (await bookingFor(contracts.get("BMIC")!)).id;
|
|
await clearIntercityBooking(ic);
|
|
|
|
await acceptIntercityOnto(scheduleId, ic);
|
|
await pollBookingStatus(ic, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
|
expect((await bookingRow(ic)).payment_deadline, "ride-along pay window opened").toBeTruthy();
|
|
|
|
await payViaGateway(ic);
|
|
// Settlement UNPINS a paid ride-along back to the ride-along pool — staff
|
|
// then place it on the train they want (acceptIntercity's `paid` branch,
|
|
// booking-batch.service.ts). The Cypress twin never sees this: its staff
|
|
// mark-paid shortcut leaves the reservation pinned.
|
|
expect(
|
|
(await bookingRow(ic)).train_schedule_id,
|
|
"paid ride-along returns to the pool",
|
|
).toBeNull();
|
|
|
|
await acceptIntercityOnto(scheduleId, ic);
|
|
await poll(
|
|
"ride-along linked to the import train",
|
|
`SELECT train_schedule_id FROM freight.bookings WHERE id = $1`,
|
|
[ic],
|
|
(row) => (row as { train_schedule_id?: string })?.train_schedule_id === scheduleId,
|
|
{ attempts: 20 },
|
|
);
|
|
const [link] = await db<{ n: string }>(
|
|
`SELECT count(*)::text AS n FROM freight.train_schedule_bookings
|
|
WHERE booking_id = $1 AND train_schedule_id = $2 AND deleted_at IS NULL`,
|
|
[ic, scheduleId],
|
|
);
|
|
expect(Number(link.n), "link row").toBe(1);
|
|
});
|
|
|
|
it("a 4 000 T giant alone gets a FULL-consist partial offer and fills the train", async () => {
|
|
const giantSchedule = await createSchedule({
|
|
departure: GIANT_DEPARTURE,
|
|
kind: "bulk",
|
|
locoPair: ["LOCO-IMP-27", "LOCO-IMP-28"],
|
|
});
|
|
await forceWindowOpen(giantSchedule.id, 45);
|
|
|
|
const bmg = await bookBulkReady({
|
|
contractId: contracts.get("BMG")!,
|
|
tons: 4000,
|
|
scheduledDate: GIANT_DAY,
|
|
});
|
|
await closeBookingWindow(giantSchedule.id);
|
|
await completeDocReview(giantSchedule.id);
|
|
await pollBookingStatus(bmg, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
|
|
|
const offer = await pollPartialOffer(bmg);
|
|
expect(Number(offer.offered_wagons), "offered the whole consist").toBe(54);
|
|
|
|
await payViaGateway(bmg);
|
|
await pollAllocations(bmg, 54);
|
|
expect((await bookingRow(bmg)).is_split, "BMG is split").toBe(true);
|
|
|
|
await endPaymentPhase(giantSchedule.id);
|
|
await pollWindow(
|
|
giantSchedule.id,
|
|
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
|
"FULL from one booking",
|
|
);
|
|
});
|
|
|
|
it("the giant's 220 T outstanding must be rebooked EXACTLY on a later train", async () => {
|
|
const remainder = await createSchedule({
|
|
departure: REMAINDER_DEPARTURE,
|
|
kind: "bulk",
|
|
locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"],
|
|
});
|
|
await forceWindowOpen(remainder.id, 45);
|
|
|
|
const wrong = await bookBulk({
|
|
contractId: contracts.get("BMG")!,
|
|
tons: 100,
|
|
scheduledDate: REMAINDER_DAY,
|
|
});
|
|
expect(wrong.status).toBeGreaterThanOrEqual(400);
|
|
expect(JSON.stringify(wrong.body)).toMatch(/must take the whole/i);
|
|
|
|
const exact = await bookBulk({
|
|
contractId: contracts.get("BMG")!,
|
|
tons: 220,
|
|
scheduledDate: REMAINDER_DAY,
|
|
});
|
|
expect(exact.status, JSON.stringify(exact.body)).toBeLessThanOrEqual(201);
|
|
await acceptOperationIfPossible(contracts.get("BMG")!);
|
|
});
|
|
});
|
|
|
|
/** The remainder booking only needs to exist; walking its gate is out of scope. */
|
|
async function acceptOperationIfPossible(contractId: string) {
|
|
const booking = await bookingFor(contractId);
|
|
if (booking.status === "OPERATION_REQUEST_PENDING") await acceptOperation(booking.id);
|
|
}
|