Files
edr-platform/integration/src/bulk-b1-priority-expiry-refill.it.ts
2026-08-03 10:51:43 +00:00

181 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* BULK B1 — staff priority decides who rides; expiry refill promotes the
* offered booking WHOLE.
*
* Three wheat bookings that cannot all fit a 54-wagon CW4 train:
* BP1 1 960 T = 28 w (commercial giant)
* BP2 1 400 T = 20 w (commercial)
* BP3 700 T = 10 w (relief cargo — staff rank it FIRST)
*
* 58 wagons chase 54. With BP3 on top the batch reserves BP3 + BP1 whole
* (38 w) and leaves BP2 a whole-wagon offer for the remaining 16. BP1 then
* misses its pay window: its 28 wagons come back and the refill round must
* promote BP2 WHOLE — superseding the 16-wagon offer.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { api, apiOk, closeDb, db, gateway, superAdmin } from "./client";
import {
bookBulkReady,
allocatedWagons,
bookingRow,
closeBookingWindow,
completeDocReview,
createSchedule,
departureAt,
eatDayStr,
ensureCorridorRoute,
extendPayWindow,
expectWagonType,
forceReservationExpiry,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollPartialOffer,
releaseUnpaidHolds,
resetCorridorDay,
seedTenantContracts,
} from "./flows";
const DEPARTURE = departureAt(40);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const STAMP = String(Date.now());
/**
* Booked in arrival order; the RANKING comes from a real priority rule.
*
* NOTE — the Cypress twin ranks these by writing `priority_score` directly.
* That lever is dead for BULK: `recomputeBulkPriorities`
* (booking-batch.service.ts) re-derives every bulk booking's score from the
* rule engine when doc review closes, overwriting anything hand-written. So
* this file configures the product's own lever instead — a WAGON priority band
* that scores 110-wagon bookings above everything else, which is exactly how
* staff would push relief cargo to the front.
*/
const BOOKINGS = [
{ suffix: "BP1", tons: 1960, wagons: 28 }, // commercial giant
{ suffix: "BP2", tons: 1400, wagons: 20 }, // does not fit whole → offered 16
{ suffix: "BP3", tons: 700, wagons: 10 }, // relief cargo — ranked first by the rule
];
describe("bulk b1: priority ordering and expiry refill", () => {
const booking = new Map<string, string>();
let scheduleId: string;
let priorityConfigId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const contracts = await seedTenantContracts(
STAMP,
BOOKINGS.map((b) => ({ suffix: b.suffix, freight: "BULK" as const })),
);
scheduleId = (
await createSchedule({
departure: DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
})
).id;
await forceWindowOpen(scheduleId, 60);
// Staff rank relief-sized cargo first: a WAGON band worth the maximum 50
// points for 110 wagons. Ranges must be contiguous from 1, and this is
// the first WAGON rule in the stack. Removed again in afterAll so the
// other bulk files keep the default (unranked) engine.
const cfg = await apiOk(superAdmin, "post", "/api/priority-configs", {
type: "WAGON",
label: "IT relief cargo 1-10 wagons",
minWagonCount: 1,
maxWagonCount: 10,
scorePoints: 50,
isActive: true,
});
priorityConfigId = (cfg.body?.data?.id ?? cfg.body?.id) as string;
expect(priorityConfigId, "priority config created").toBeTruthy();
for (const b of BOOKINGS) {
booking.set(
b.suffix,
await bookBulkReady({
contractId: contracts.get(b.suffix)!,
tons: b.tons,
scheduledDate: BOOKING_DAY,
}),
);
}
}, 1_800_000);
afterAll(async () => {
if (priorityConfigId) {
await api(superAdmin, "delete", `/api/priority-configs/${priorityConfigId}`);
}
await closeDb();
});
it("ranks the relief cargo first — it and the giant reserve whole, the third is offered 16", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
// The batch re-scored the pool from the rule: relief 50, the other two 0.
const scores = await db<{ id: string; priority_score: string }>(
`SELECT id, priority_score FROM freight.bookings WHERE id = ANY($1::uuid[])`,
[[...booking.values()]],
);
const scoreOf = (suffix: string) =>
Number(scores.find((r) => r.id === booking.get(suffix))?.priority_score ?? 0);
expect(scoreOf("BP3"), "relief cargo outranks the commercial pair").toBeGreaterThan(
Math.max(scoreOf("BP1"), scoreOf("BP2")),
);
for (const suffix of ["BP3", "BP1"]) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
}
const offer = await pollPartialOffer(booking.get("BP2")!);
expect(Number(offer.offered_wagons), "BP2 offered the 16-wagon leftover").toBe(16);
});
it("the relief cargo pays and rides CW4; the giant misses its pay window", async () => {
await extendPayWindow(scheduleId, [booking.get("BP3")!]);
await payViaGateway(booking.get("BP3")!);
await pollAllocations(booking.get("BP3")!, 10);
await expectWagonType(booking.get("BP3")!, "CW4", 10);
await forceReservationExpiry(booking.get("BP1")!);
await pollBookingStatus(booking.get("BP1")!, "EXPIRED", 40);
});
it("the refill round re-selects the offered booking — but its 16-wagon offer still stands", async () => {
await pollBookingStatus(booking.get("BP2")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40);
await extendPayWindow(scheduleId, [booking.get("BP2")!]);
await payViaGateway(booking.get("BP2")!);
await pollAllocations(booking.get("BP2")!, 16);
const bp2 = await bookingRow(booking.get("BP2")!);
// FINDING — the scenario expects the refill to promote BP2 WHOLE (20 w)
// into the 28 wagons the expired giant just freed, superseding its
// 16-wagon offer. It does not: the refill flips the booking back to
// reserved but never issues a replacement offer, and `applySplit` then
// applies the ONLY open offer — the stale 16-wagon one
// (booking-split.service.ts: an offer is superseded only when a NEW offer
// is created). The customer ships 16 of 20 wagons with room to spare.
// This test pins today's behaviour so the fix flips it loudly.
expect(Number(bp2.wagons_required), "rides the stale offer, not the freed 20").toBe(16);
expect(bp2.is_split, "split against the stale offer").toBe(true);
const [offers] = await db<{ n: string }>(
`SELECT count(*)::text AS n FROM freight.booking_batch_offers
WHERE booking_id = $1 AND deleted_at IS NULL`,
[booking.get("BP2")!],
);
expect(Number(offers.n), "no replacement offer was issued after the refill").toBe(1);
// …and the train really did have the room: 10 (relief) + 16 = 26 of 54.
expect(await allocatedWagons(scheduleId), "28 wagons left unused").toBe(26);
});
});