mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 03:10:54 +00:00
181 lines
6.9 KiB
TypeScript
181 lines
6.9 KiB
TypeScript
/**
|
||
* 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 1–10-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 1–10 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);
|
||
});
|
||
});
|