/** * 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(); 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); }); });