mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
197 lines
6.1 KiB
TypeScript
197 lines
6.1 KiB
TypeScript
/**
|
|
* BULK EXPORT — pay or lose the seat.
|
|
*
|
|
* Day 1: ZA + ZB reserve and pay 40 wagons. ZC reserves the last 14 (980 T)
|
|
* and never pays; while that hold lives a late booking is rejected for
|
|
* space. ZC expires → the late customer immediately books the freed 980 T
|
|
* and pays.
|
|
*
|
|
* Day 2: five reservations fill the train, only three pay. The window close
|
|
* passes → phase DONE, the two unpaid expire, and export never reopens
|
|
* (the cycle counter stays 1 — unlike import, which reopens).
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { closeDb, db, gateway } from "./client";
|
|
import {
|
|
EXP_DEST,
|
|
EXP_ORIGIN,
|
|
bookBulk,
|
|
bookBulkReady,
|
|
expectDayRefused,
|
|
bookingRow,
|
|
createSchedule,
|
|
departureAt,
|
|
eatDayStr,
|
|
ensureExportRoute,
|
|
extendPayWindow,
|
|
forceReservationExpiry,
|
|
forceWindowOpen,
|
|
payViaGateway,
|
|
pollAllocations,
|
|
pollBookingStatus,
|
|
pollWindow,
|
|
releaseUnpaidHolds,
|
|
resetCorridorDay,
|
|
scheduleRow,
|
|
seedTenantContracts,
|
|
} from "./flows";
|
|
|
|
const DEPARTURE = departureAt(33);
|
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
|
const CLOSE_DEPARTURE = departureAt(34);
|
|
const CLOSE_DAY = eatDayStr(CLOSE_DEPARTURE);
|
|
const STAMP = String(Date.now());
|
|
|
|
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", () => {
|
|
const booking = new Map<string, string>();
|
|
let contracts: Map<string, string>;
|
|
let scheduleId: string;
|
|
let closeScheduleId: string;
|
|
|
|
beforeAll(async () => {
|
|
await gateway.reset();
|
|
await releaseUnpaidHolds();
|
|
await ensureExportRoute();
|
|
await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST);
|
|
await resetCorridorDay(CLOSE_DEPARTURE, EXP_ORIGIN, EXP_DEST);
|
|
|
|
contracts = await seedTenantContracts(
|
|
STAMP,
|
|
["ZA", "ZB", "ZC", "ZD", "ZDLATE", ...CLOSERS.map((c) => c.suffix)].map((suffix) => ({
|
|
suffix,
|
|
freight: "BULK" as const,
|
|
direction: "EXPORT" as const,
|
|
})),
|
|
);
|
|
scheduleId = (
|
|
await createSchedule({
|
|
departure: DEPARTURE,
|
|
kind: "bulk",
|
|
locoPair: ["LOCO-EXP-7", "LOCO-EXP-8"],
|
|
originCode: EXP_ORIGIN,
|
|
destCode: EXP_DEST,
|
|
})
|
|
).id;
|
|
await forceWindowOpen(scheduleId, 60);
|
|
}, 1_800_000);
|
|
|
|
afterAll(closeDb);
|
|
|
|
it("ZA and ZB reserve and pay 40 wagons; ZC holds the last 14 unpaid", async () => {
|
|
for (const [suffix, wagons] of [
|
|
["ZA", 20],
|
|
["ZB", 20],
|
|
] as const) {
|
|
const id = await bookBulkReady({
|
|
contractId: contracts.get(suffix)!,
|
|
tons: 1400,
|
|
scheduledDate: BOOKING_DAY,
|
|
mode: "export",
|
|
});
|
|
booking.set(suffix, id);
|
|
await payViaGateway(id);
|
|
await pollAllocations(id, wagons);
|
|
}
|
|
|
|
const zc = await bookBulkReady({
|
|
contractId: contracts.get("ZC")!,
|
|
tons: 980,
|
|
scheduledDate: BOOKING_DAY,
|
|
mode: "export",
|
|
});
|
|
booking.set("ZC", zc);
|
|
const schedule = await scheduleRow(scheduleId);
|
|
expect(
|
|
new Date((await bookingRow(zc)).payment_deadline!).getTime(),
|
|
"ZC deadline clamped to the window close",
|
|
).toBeLessThanOrEqual(new Date(String(schedule.window_closes_at)).getTime());
|
|
}, 900_000);
|
|
|
|
it("a late exporter is rejected while ZC's unpaid reservation holds the space", async () => {
|
|
const res = await expectDayRefused({
|
|
contractId: contracts.get("ZDLATE")!,
|
|
tons: 980,
|
|
scheduledDate: BOOKING_DAY,
|
|
});
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i);
|
|
});
|
|
|
|
it("ZC misses its window — ZD immediately books the freed 980 T and pays", async () => {
|
|
await forceReservationExpiry(booking.get("ZC")!);
|
|
const zd = await bookBulkReady({
|
|
contractId: contracts.get("ZD")!,
|
|
tons: 980,
|
|
scheduledDate: BOOKING_DAY,
|
|
mode: "export",
|
|
});
|
|
booking.set("ZD", zd);
|
|
await payViaGateway(zd);
|
|
await pollAllocations(zd, 14);
|
|
expect((await bookingRow(zd)).train_schedule_id, "ZD took ZC's seat").toBe(scheduleId);
|
|
}, 900_000);
|
|
|
|
it("window-close day: five reservations, three payments", async () => {
|
|
closeScheduleId = (
|
|
await createSchedule({
|
|
departure: CLOSE_DEPARTURE,
|
|
kind: "bulk",
|
|
locoPair: ["LOCO-EXP-9", "LOCO-EXP-10"],
|
|
originCode: EXP_ORIGIN,
|
|
destCode: EXP_DEST,
|
|
})
|
|
).id;
|
|
await forceWindowOpen(closeScheduleId, 60);
|
|
|
|
for (const c of CLOSERS) {
|
|
booking.set(
|
|
c.suffix,
|
|
await bookBulkReady({
|
|
contractId: contracts.get(c.suffix)!,
|
|
tons: c.tons,
|
|
scheduledDate: CLOSE_DAY,
|
|
mode: "export",
|
|
}),
|
|
);
|
|
}
|
|
await extendPayWindow(
|
|
closeScheduleId,
|
|
CLOSERS.filter((x) => x.pays).map((c) => booking.get(c.suffix)!),
|
|
);
|
|
for (const c of CLOSERS.filter((x) => x.pays)) {
|
|
await payViaGateway(booking.get(c.suffix)!);
|
|
await pollAllocations(booking.get(c.suffix)!, c.wagons);
|
|
}
|
|
}, 1_800_000);
|
|
|
|
it("the window CLOSES — phase DONE, the unpaid expire, and export never reopens", async () => {
|
|
await db(
|
|
`UPDATE freight.train_schedules SET window_closes_at = now() - interval '1 second'
|
|
WHERE id = $1 AND window_phase = 'OPEN'`,
|
|
[closeScheduleId],
|
|
);
|
|
await pollWindow(
|
|
closeScheduleId,
|
|
(s) => s.window_phase === "DONE" && Number(s.booking_cycle_no) === 1,
|
|
"DONE, no reopen",
|
|
);
|
|
|
|
// A forced close needs the matching deadline clamp on the unpaid pair.
|
|
for (const c of CLOSERS.filter((x) => !x.pays)) {
|
|
await forceReservationExpiry(booking.get(c.suffix)!);
|
|
await pollBookingStatus(booking.get(c.suffix)!, "EXPIRED", 40);
|
|
}
|
|
for (const c of CLOSERS.filter((x) => x.pays)) {
|
|
expect((await bookingRow(booking.get(c.suffix)!)).status, `${c.suffix} rides`).toBe("PAID");
|
|
}
|
|
}, 900_000);
|
|
});
|