mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
421 lines
17 KiB
TypeScript
421 lines
17 KiB
TypeScript
/**
|
||
* GROUP 1 · S6–S8 — who gets the last wagons on the 53-wagon built train.
|
||
*
|
||
* S6 a split offer nobody takes: the offer lapses, the booking expires
|
||
* WHOLE, and the wagons it was offered go unsold.
|
||
* S7 a government booking jumps the queue — by PREEMPTION, not by ranking:
|
||
* it displaces the lowest-priority commercial reservation and rides
|
||
* unpaid, carrying the +50 000 bonus.
|
||
* S8 commercial priority tiers decide who is offered the remainder:
|
||
* USD payer > customs service > plain, with no per-booking priority set.
|
||
*
|
||
* TWO NOTES ON HOW THE PRODUCT REALLY WORKS
|
||
*
|
||
* S7 — the +50 000 bonus (GOVERNMENT_PRIORITY_BONUS) keys off
|
||
* `bookings.is_government`, not a government-institution lookup, and
|
||
* government does not merely outrank: `preemptForGovernment` EXPIRES the
|
||
* lowest-priority commercial booking whose leg overlaps and allocates in its
|
||
* place. Government bookings are created with POST /api/bookings against a
|
||
* kind='government' company and promoted with /government-expedite — never
|
||
* through the contract wizard.
|
||
*
|
||
* FINDING — preemption cannot reach a train whose window already reads FULL:
|
||
* `isFillable` rejects FULL outright, before any budget or victim is
|
||
* considered, and `refreshWindowStatus` re-derives FULL from live capacity, so
|
||
* a genuinely full train stays skipped. The scenario therefore books 52 of 53
|
||
* slots: committed, one slot short, which is the closest reachable state to
|
||
* "a full train" and still exercises the displacement.
|
||
*
|
||
* S8 — the retired USD_PAYER / RAIL_AND_FORWARDING priority RULES are gone
|
||
* (ReplacePriorityRulesWithPriorityConfigs). The live model is
|
||
* `priority_configs`, typed WAGON | CURRENCY | CUSTOMS and scored by
|
||
* wagon-count band. The scenario's intent — tiered ordering, lowest tier gets
|
||
* the split — is preserved against that mechanism. The CUSTOMS band only
|
||
* applies when the booking's SERVICE TYPE bundles customs, which is why S8's
|
||
* customs tenant is sold RAIL_CUSTOMS (seed-customs-service-type.sql).
|
||
*/
|
||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||
import { closeDb, gateway } from "./client";
|
||
import {
|
||
G1_WAGONS,
|
||
allocatedWagons,
|
||
bookContainersReady,
|
||
bookingRow,
|
||
closeBookingWindow,
|
||
completeDocReview,
|
||
containerCount,
|
||
containerWagons,
|
||
createBuiltTrainSchedule,
|
||
createGovernmentBooking,
|
||
createPriorityConfig,
|
||
departureAt,
|
||
dropPriorityConfig,
|
||
eatDayStr,
|
||
endPaymentPhase,
|
||
ensureCorridorRoute,
|
||
expectNoPartialOffer,
|
||
extendPayWindow,
|
||
forceOfferLapse,
|
||
forceWindowOpen,
|
||
governmentExpedite,
|
||
linkedBookings,
|
||
livePayableInvoices,
|
||
payViaGateway,
|
||
pinToSchedule,
|
||
pollAllocations,
|
||
pollBookingStatus,
|
||
pollCycleConcluded,
|
||
pollPartialOffer,
|
||
pollWindow,
|
||
releaseGovernmentBookings,
|
||
releaseUnpaidHolds,
|
||
resetCorridorDay,
|
||
scheduleRow,
|
||
seedTenantContracts,
|
||
setPriority,
|
||
triggerBatchRun,
|
||
} from "./flows";
|
||
|
||
const STAMP = String(Date.now());
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// S6 — a split offer nobody takes
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("g1 s6: an ignored split offer expires the booking whole", () => {
|
||
const DEPARTURE = departureAt(55);
|
||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||
const SHAPES = {
|
||
SA: { twenty: 0, forty: 30, wagons: 30 },
|
||
SB: { twenty: 40, forty: 0, wagons: 20 },
|
||
SC: { twenty: 20, forty: 0, wagons: 10 },
|
||
} as const;
|
||
const ORDER = ["SA", "SB", "SC"] as const;
|
||
const GAP = G1_WAGONS - SHAPES.SA.wagons - SHAPES.SB.wagons; // 3
|
||
const RIDING = SHAPES.SA.wagons + SHAPES.SB.wagons; // 50
|
||
|
||
const booking = new Map<string, string>();
|
||
let scheduleId: string;
|
||
|
||
beforeAll(async () => {
|
||
await gateway.reset();
|
||
await releaseUnpaidHolds();
|
||
await ensureCorridorRoute();
|
||
await resetCorridorDay(DEPARTURE);
|
||
|
||
const contracts = await seedTenantContracts(
|
||
STAMP,
|
||
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||
);
|
||
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||
await forceWindowOpen(scheduleId, 60);
|
||
|
||
let isoSeed = 25_000;
|
||
for (const [i, suffix] of ORDER.entries()) {
|
||
const shape = SHAPES[suffix];
|
||
booking.set(
|
||
suffix,
|
||
await bookContainersReady({
|
||
contractId: contracts.get(suffix)!,
|
||
runStamp: STAMP,
|
||
isoSeed,
|
||
twenty: shape.twenty,
|
||
forty: shape.forty,
|
||
scheduledDate: BOOKING_DAY,
|
||
}),
|
||
);
|
||
isoSeed += shape.twenty + shape.forty;
|
||
await setPriority(booking.get(suffix)!, i + 1);
|
||
}
|
||
}, 1_800_000);
|
||
|
||
it("SA and SB take 50 wagons; SC is offered the last 3", async () => {
|
||
expect(containerWagons(SHAPES.SC.twenty, 0), "SC needs 10 wagons").toBe(SHAPES.SC.wagons);
|
||
await closeBookingWindow(scheduleId);
|
||
await completeDocReview(scheduleId);
|
||
|
||
for (const suffix of ["SA", "SB"] as const) {
|
||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||
await expectNoPartialOffer(booking.get(suffix)!, suffix);
|
||
}
|
||
const offer = await pollPartialOffer(booking.get("SC")!);
|
||
expect(Number(offer.offered_wagons), "SC offered the 3-wagon gap").toBe(GAP);
|
||
});
|
||
|
||
it("SC ignores the offer for the whole window — it EXPIRES whole", async () => {
|
||
await extendPayWindow(scheduleId, [booking.get("SA")!, booking.get("SB")!]);
|
||
await payViaGateway(booking.get("SA")!);
|
||
await pollAllocations(booking.get("SA")!, SHAPES.SA.wagons);
|
||
await payViaGateway(booking.get("SB")!);
|
||
await pollAllocations(booking.get("SB")!, SHAPES.SB.wagons);
|
||
|
||
// The offer dies with the booking's pay deadline; the tick settles it.
|
||
await forceOfferLapse(booking.get("SC")!);
|
||
|
||
// "Whole" is the load-bearing word: an ignored PARTIAL must not leave the
|
||
// booking silently reduced to the 3 wagons it was offered — the customer
|
||
// still owns all 20 containers and can rebook them intact.
|
||
const sc = await bookingRow(booking.get("SC")!);
|
||
expect(sc.is_split, "SC was never split").not.toBe(true);
|
||
expect(await containerCount(booking.get("SC")!), "SC's 20 containers intact").toBe(
|
||
SHAPES.SC.twenty,
|
||
);
|
||
});
|
||
|
||
it("the train departs NOT FULL at 50/53 — the 3 offered wagons went unsold", async () => {
|
||
await endPaymentPhase(scheduleId);
|
||
await pollCycleConcluded(scheduleId);
|
||
expect(await allocatedWagons(scheduleId), "50 wagons allocated").toBe(RIDING);
|
||
expect(
|
||
(await scheduleRow(scheduleId)).booking_window_status,
|
||
"window not FULL at 50/53",
|
||
).not.toBe("FULL");
|
||
expect(GAP, "3 wagons wasted").toBe(3);
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// S7 — government preempts the lowest-priority commercial reservation
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("g1 s7: a government booking preempts commercial", () => {
|
||
const DEPARTURE = departureAt(56);
|
||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||
/** GA outranks GB, so GB is the one preemption must take. */
|
||
const SHAPES = {
|
||
GA: { forty: 25, wagons: 25 },
|
||
GB: { forty: 27, wagons: 27 },
|
||
} as const;
|
||
const ORDER = ["GA", "GB"] as const;
|
||
/** More than the single free slot: the government booking cannot fit as-is. */
|
||
const GOV_WAGONS = 15;
|
||
|
||
const booking = new Map<string, string>();
|
||
let scheduleId: string;
|
||
let govBookingId: string;
|
||
|
||
beforeAll(async () => {
|
||
await gateway.reset();
|
||
await releaseUnpaidHolds();
|
||
await releaseGovernmentBookings();
|
||
await ensureCorridorRoute();
|
||
await resetCorridorDay(DEPARTURE);
|
||
|
||
const contracts = await seedTenantContracts(
|
||
STAMP,
|
||
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||
);
|
||
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||
await forceWindowOpen(scheduleId, 60);
|
||
|
||
let isoSeed = 26_000;
|
||
for (const [i, suffix] of ORDER.entries()) {
|
||
booking.set(
|
||
suffix,
|
||
await bookContainersReady({
|
||
contractId: contracts.get(suffix)!,
|
||
runStamp: STAMP,
|
||
isoSeed,
|
||
forty: SHAPES[suffix].forty,
|
||
scheduledDate: BOOKING_DAY,
|
||
}),
|
||
);
|
||
isoSeed += SHAPES[suffix].forty;
|
||
await setPriority(booking.get(suffix)!, i + 1);
|
||
}
|
||
}, 1_800_000);
|
||
|
||
it("GA pays and GB holds a reservation — 52 of 53 slots committed", async () => {
|
||
await closeBookingWindow(scheduleId);
|
||
await completeDocReview(scheduleId);
|
||
for (const suffix of ORDER) {
|
||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||
}
|
||
|
||
await extendPayWindow(scheduleId, [booking.get("GA")!]);
|
||
await payViaGateway(booking.get("GA")!);
|
||
await pollAllocations(booking.get("GA")!, SHAPES.GA.wagons);
|
||
|
||
// GB stays UNPAID on purpose — it is the reservation the government
|
||
// booking must displace — but its hold is widened so it does not lapse on
|
||
// its own while the government booking is being created.
|
||
await extendPayWindow(scheduleId, [booking.get("GB")!]);
|
||
expect((await bookingRow(booking.get("GB")!)).status, "GB still reserved").toMatch(
|
||
/SELECTED_FOR_BATCH|AWAITING_PAYMENT/,
|
||
);
|
||
expect(SHAPES.GA.wagons + SHAPES.GB.wagons, "52 of 53 committed").toBe(G1_WAGONS - 1);
|
||
});
|
||
|
||
it("a government booking is created and expedited — PAID without paying", async () => {
|
||
govBookingId = await createGovernmentBooking({ forty: GOV_WAGONS });
|
||
// Idempotent: create() already expedites, the endpoint is the retry path.
|
||
await governmentExpedite(govBookingId);
|
||
|
||
const gov = await bookingRow(govBookingId);
|
||
expect(gov.status, "PAID after expedite").toBe("PAID");
|
||
expect(gov.is_government, "flagged government").toBe(true);
|
||
});
|
||
|
||
it("it carries the +50 000 bonus, far above any commercial score", async () => {
|
||
const govScore = Number((await bookingRow(govBookingId)).priority_score);
|
||
expect(govScore, "government bonus applied").toBeGreaterThanOrEqual(50_000);
|
||
expect(
|
||
Number((await bookingRow(booking.get("GA")!)).priority_score),
|
||
"top commercial still far below government",
|
||
).toBeLessThan(govScore);
|
||
});
|
||
|
||
it("the fill displaces GB — the lower-priority reservation — and GA is untouched", async () => {
|
||
// Staff pin, then run the batch: fillSchedule's pool is keyed on
|
||
// `booking.train_schedule_id`, and the customer-facing pin is unavailable
|
||
// because the window closed at doc review.
|
||
await pinToSchedule(govBookingId, scheduleId);
|
||
await triggerBatchRun(scheduleId);
|
||
|
||
await pollBookingStatus(booking.get("GB")!, "EXPIRED", 30);
|
||
const gb = await bookingRow(booking.get("GB")!);
|
||
expect(gb.scheduling_status, "GB back to ELIGIBLE").toBe("ELIGIBLE");
|
||
expect(gb.payment_deadline, "GB pay window cleared").toBeNull();
|
||
expect(await livePayableInvoices(booking.get("GB")!), "GB invoice closed out").toBe(0);
|
||
|
||
const ga = await bookingRow(booking.get("GA")!);
|
||
expect(ga.status, "GA survives untouched").toBe("PAID");
|
||
expect(ga.train_schedule_id, "GA still on this train").toBe(scheduleId);
|
||
});
|
||
|
||
it("the government booking rides on the freed wagons — allocated, never invoiced", async () => {
|
||
await pollAllocations(govBookingId, GOV_WAGONS);
|
||
const gov = await bookingRow(govBookingId);
|
||
expect(gov.scheduling_status, "government SCHEDULED").toBe("SCHEDULED");
|
||
expect(await livePayableInvoices(govBookingId), "government rides unpaid").toBe(0);
|
||
expect(await linkedBookings(scheduleId), "GA + government hold the seats").toBe(2);
|
||
expect(await allocatedWagons(scheduleId), "25 commercial + 15 government").toBe(
|
||
SHAPES.GA.wagons + GOV_WAGONS,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// S8 — commercial priority tiers order the batch
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
|
||
describe("g1 s8: priority tiers order the batch", () => {
|
||
const DEPARTURE = departureAt(57);
|
||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||
/** Three equal bookings — only the TIER differs, so ordering is the only
|
||
* thing that can decide who is left with the remainder. */
|
||
const SHAPES = {
|
||
PA: { forty: 20, wagons: 20, tier: "USD payer" },
|
||
PB: { forty: 20, wagons: 20, tier: "customs service" },
|
||
PC: { forty: 20, wagons: 20, tier: "plain" },
|
||
} as const;
|
||
const ORDER = ["PA", "PB", "PC"] as const;
|
||
/** 60 wagons of demand for 53 slots → the third gets a 13-wagon offer. */
|
||
const GAP = G1_WAGONS - SHAPES.PA.wagons - SHAPES.PB.wagons; // 13
|
||
|
||
const booking = new Map<string, string>();
|
||
let scheduleId: string;
|
||
let usdConfigId: string;
|
||
|
||
beforeAll(async () => {
|
||
await gateway.reset();
|
||
await releaseUnpaidHolds();
|
||
await ensureCorridorRoute();
|
||
await resetCorridorDay(DEPARTURE);
|
||
|
||
// The USD tier. The CUSTOMS bands (7 / 15 points) ship with the corridor
|
||
// fixture, so only the currency band has to be added — and it is dropped in
|
||
// afterAll, because an active band re-ranks every later file's pool.
|
||
usdConfigId = await createPriorityConfig({
|
||
type: "CURRENCY",
|
||
label: `IT USD payer ${STAMP.slice(-5)}`,
|
||
currency: "USD",
|
||
minWagonCount: 1,
|
||
maxWagonCount: 53,
|
||
scorePoints: 35,
|
||
});
|
||
|
||
const contracts = await seedTenantContracts(STAMP, [
|
||
{ suffix: "PA", freight: "CONTAINER" as const, currency: "USD" as const },
|
||
{
|
||
suffix: "PB",
|
||
freight: "CONTAINER" as const,
|
||
customs: true,
|
||
serviceTypeCode: "RAIL_CUSTOMS",
|
||
},
|
||
{ suffix: "PC", freight: "CONTAINER" as const },
|
||
]);
|
||
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||
await forceWindowOpen(scheduleId, 60);
|
||
|
||
let isoSeed = 27_000;
|
||
for (const suffix of ORDER) {
|
||
booking.set(
|
||
suffix,
|
||
await bookContainersReady({
|
||
contractId: contracts.get(suffix)!,
|
||
runStamp: STAMP,
|
||
isoSeed,
|
||
forty: SHAPES[suffix].forty,
|
||
scheduledDate: BOOKING_DAY,
|
||
customs: suffix === "PB",
|
||
}),
|
||
);
|
||
isoSeed += SHAPES[suffix].forty;
|
||
}
|
||
}, 1_800_000);
|
||
|
||
afterAll(async () => {
|
||
await dropPriorityConfig(usdConfigId);
|
||
await closeDb();
|
||
});
|
||
|
||
it("the engine scores USD above customs above plain — no manual priority set", async () => {
|
||
// Deliberately no setPriority anywhere in this file: the point is that the
|
||
// rule engine's own bands produce the order. Pinning the scores by hand
|
||
// would test setPriority, not the tiers.
|
||
const scores = new Map<string, number>();
|
||
for (const suffix of ORDER) {
|
||
scores.set(suffix, Number((await bookingRow(booking.get(suffix)!)).priority_score));
|
||
}
|
||
expect(scores.get("PA")!, "USD tier outranks the customs tier").toBeGreaterThan(
|
||
scores.get("PB")!,
|
||
);
|
||
expect(scores.get("PB")!, "customs tier outranks plain").toBeGreaterThan(scores.get("PC")!);
|
||
});
|
||
|
||
it("the two top tiers board whole; the lowest is offered the 13-wagon remainder", async () => {
|
||
expect(GAP, "13-wagon remainder after the top two").toBe(13);
|
||
await closeBookingWindow(scheduleId);
|
||
await completeDocReview(scheduleId);
|
||
|
||
for (const suffix of ["PA", "PB"] as const) {
|
||
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||
await expectNoPartialOffer(booking.get(suffix)!, suffix);
|
||
}
|
||
const offer = await pollPartialOffer(booking.get("PC")!);
|
||
expect(Number(offer.offered_wagons), "PC offered the exact remainder").toBe(GAP);
|
||
});
|
||
|
||
it("all three settle — PC ships 13 of its 20 wagons and the train is FULL", async () => {
|
||
await extendPayWindow(scheduleId, [...booking.values()]);
|
||
await payViaGateway(booking.get("PA")!);
|
||
await pollAllocations(booking.get("PA")!, SHAPES.PA.wagons);
|
||
await payViaGateway(booking.get("PB")!);
|
||
await pollAllocations(booking.get("PB")!, SHAPES.PB.wagons);
|
||
await payViaGateway(booking.get("PC")!);
|
||
await pollAllocations(booking.get("PC")!, GAP);
|
||
|
||
expect((await bookingRow(booking.get("PC")!)).is_split, "PC is the split one").toBe(true);
|
||
expect(await containerCount(booking.get("PC")!), "PC shrank to 13 containers").toBe(GAP);
|
||
|
||
await endPaymentPhase(scheduleId);
|
||
await pollWindow(
|
||
scheduleId,
|
||
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||
"FULL + DONE",
|
||
);
|
||
expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS);
|
||
});
|
||
});
|