mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
183 lines
5.7 KiB
TypeScript
183 lines
5.7 KiB
TypeScript
/**
|
|
* BULK EXPORT — FCFS capacity truth.
|
|
*
|
|
* Day 1: three bookings (1 400 + 1 400 + 980 T = 54 wagons) accept first and
|
|
* hold the train BEFORE paying; three late 700 T exporters are rejected at
|
|
* submission by the whole-train space gate. The three pay → FULL.
|
|
*
|
|
* Day 2: whole-or-nothing — a 4 060 T giant (58 wagons) is rejected against
|
|
* an empty train (export never splits); rebooked at exactly 3 780 T it takes
|
|
* the whole consist alone; a 70 T afterthought bounces off FULL.
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { closeDb, gateway } from "./client";
|
|
import {
|
|
EXP_DEST,
|
|
EXP_ORIGIN,
|
|
acceptExport,
|
|
allocatedWagons,
|
|
bookBulk,
|
|
bookBulkReady,
|
|
expectDayRefused,
|
|
bookingFor,
|
|
bookingRow,
|
|
createSchedule,
|
|
departureAt,
|
|
eatDayStr,
|
|
ensureExportRoute,
|
|
extendPayWindow,
|
|
forceWindowOpen,
|
|
payViaGateway,
|
|
pollAllocations,
|
|
pollWindow,
|
|
releaseUnpaidHolds,
|
|
resetCorridorDay,
|
|
seedTenantContracts,
|
|
} from "./flows";
|
|
|
|
const DEPARTURE = departureAt(31);
|
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
|
const GIANT_DEPARTURE = departureAt(32);
|
|
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
|
|
const STAMP = String(Date.now());
|
|
|
|
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"];
|
|
|
|
describe("bulk export FCFS: reservations hold capacity, whole-or-nothing gate", () => {
|
|
const booking = new Map<string, string>();
|
|
let contracts: Map<string, string>;
|
|
let scheduleId: string;
|
|
let giantScheduleId: string;
|
|
|
|
beforeAll(async () => {
|
|
await gateway.reset();
|
|
await releaseUnpaidHolds();
|
|
await ensureExportRoute();
|
|
await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST);
|
|
await resetCorridorDay(GIANT_DEPARTURE, EXP_ORIGIN, EXP_DEST);
|
|
|
|
contracts = await seedTenantContracts(
|
|
STAMP,
|
|
[...FIRST.map((b) => b.suffix), ...LATE, "YGBIG", "YG", "YS"].map((suffix) => ({
|
|
suffix,
|
|
freight: "BULK" as const,
|
|
direction: "EXPORT" as const,
|
|
})),
|
|
);
|
|
scheduleId = (
|
|
await createSchedule({
|
|
departure: DEPARTURE,
|
|
kind: "bulk",
|
|
locoPair: ["LOCO-EXP-3", "LOCO-EXP-4"],
|
|
originCode: EXP_ORIGIN,
|
|
destCode: EXP_DEST,
|
|
})
|
|
).id;
|
|
await forceWindowOpen(scheduleId, 60);
|
|
|
|
for (const b of FIRST) {
|
|
booking.set(
|
|
b.suffix,
|
|
await bookBulkReady({
|
|
contractId: contracts.get(b.suffix)!,
|
|
tons: b.tons,
|
|
scheduledDate: BOOKING_DAY,
|
|
mode: "export",
|
|
}),
|
|
);
|
|
}
|
|
}, 1_800_000);
|
|
|
|
afterAll(closeDb);
|
|
|
|
it("holds 54 wagons on accept — before any payment", async () => {
|
|
for (const b of FIRST) {
|
|
const row = await bookingRow(booking.get(b.suffix)!);
|
|
expect(["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], `${b.suffix} reserved`).toContain(
|
|
row.status,
|
|
);
|
|
expect(row.train_schedule_id, `${b.suffix} pinned to the train`).toBe(scheduleId);
|
|
}
|
|
});
|
|
|
|
it("rejects three late exporters at submission — the space gate reports no room", async () => {
|
|
for (const suffix of LATE) {
|
|
const res = await expectDayRefused({
|
|
contractId: contracts.get(suffix)!,
|
|
tons: 700,
|
|
scheduledDate: BOOKING_DAY,
|
|
});
|
|
expect(res.status, `${suffix} rejected`).toBeGreaterThanOrEqual(400);
|
|
expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i);
|
|
}
|
|
});
|
|
|
|
it("the three reserved pay — 54/54 allocated and the window flips FULL", async () => {
|
|
await extendPayWindow(scheduleId, FIRST.map((b) => booking.get(b.suffix)!));
|
|
for (const b of FIRST) {
|
|
await payViaGateway(booking.get(b.suffix)!);
|
|
await pollAllocations(booking.get(b.suffix)!, b.wagons);
|
|
}
|
|
await pollWindow(scheduleId, (s) => s.booking_window_status === "FULL", "export window FULL");
|
|
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
|
|
});
|
|
|
|
it("whole-or-nothing: a 4 060 T giant is rejected against the empty train", async () => {
|
|
giantScheduleId = (
|
|
await createSchedule({
|
|
departure: GIANT_DEPARTURE,
|
|
kind: "bulk",
|
|
locoPair: ["LOCO-EXP-5", "LOCO-EXP-6"],
|
|
originCode: EXP_ORIGIN,
|
|
destCode: EXP_DEST,
|
|
})
|
|
).id;
|
|
await forceWindowOpen(giantScheduleId, 60);
|
|
|
|
// 58 wagons on a 54-wagon consist — export never splits.
|
|
// Its own tenant: a refused probe still leaves a live booking on the
|
|
// one-time contract, which would block the 3 780 T rebooking below.
|
|
const res = await expectDayRefused({
|
|
contractId: contracts.get("YGBIG")!,
|
|
tons: 4060,
|
|
scheduledDate: GIANT_DAY,
|
|
});
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i);
|
|
});
|
|
|
|
it("rebooked at exactly 3 780 T the giant takes the whole train alone", async () => {
|
|
const yg = await bookBulkReady({
|
|
contractId: contracts.get("YG")!,
|
|
tons: 3780,
|
|
scheduledDate: GIANT_DAY,
|
|
mode: "export",
|
|
});
|
|
await payViaGateway(yg);
|
|
await pollAllocations(yg, 54);
|
|
|
|
await pollWindow(
|
|
giantScheduleId,
|
|
(s) => s.booking_window_status === "FULL",
|
|
"giant train FULL from one booking",
|
|
);
|
|
expect((await bookingRow(yg)).train_schedule_id, "giant rides its train").toBe(giantScheduleId);
|
|
expect(await allocatedWagons(giantScheduleId), "54 wagons allocated").toBe(54);
|
|
});
|
|
|
|
it("a 70 T afterthought bounces off the FULL train", async () => {
|
|
const res = await expectDayRefused({
|
|
contractId: contracts.get("YS")!,
|
|
tons: 70,
|
|
scheduledDate: GIANT_DAY,
|
|
});
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i);
|
|
});
|
|
});
|