diff --git a/integration/README.md b/integration/README.md index 65550aa0f..b35d682ce 100644 --- a/integration/README.md +++ b/integration/README.md @@ -75,6 +75,7 @@ gateway-mock-it`) — the code is a read-only mount, not baked into an image. | `src/g1-s4-split-closes-gap.it.ts` | container split closes the last 3-wagon gap, 14-box remainder | | `src/g1-s5-cascading-expiry.it.ts` | one settle promotes twice; expiries terminal, invoices closed | | `src/g1-s6-s8-offers-government-tiers.it.ts` | ignored offer, government preemption, USD/customs/plain tiers | +| `src/g2-weight.it.ts` | weight before slots: base pull, overage tolerance, split sized on base only, light cargo | | `src/flows.ts` | freight business steps, ported from `e2e/freight/cypress/e2e/flows/import-utils.ts` | ## Findings pinned by these tests @@ -101,6 +102,20 @@ what it actually does and says so in a comment, so a fix fails loudly: priority config. - **Freight sends a dev-shortcut amount** (1 minor unit, 10 for CAC) for every non-`CBE_BILL` provider, with no short-payment guard. +- **A BUILT train's batch is blind to the pull limit** (`g2-weight`, last + describe). `remainingBudget` replaces the locomotive limits with + `{wagons: physicalWagons, weightTons: Infinity, lengthMeters: Infinity}` the + moment a schedule has a built train, so the batch reserves — and invoices — + a load the locomotives cannot pull. The only check left is at wagon + allocation, which then fails every tick with "Train set locomotives cannot + pull the gross weight … limit 3500T incl. tolerance". The customer is PAID + with zero wagons. Group 2 therefore runs its real weight scenarios on + locomotive PAIRS, where the limits survive. +- **The allocator weighs the whole consist, the batch weighs the booking.** + Allocation charges the tare of every wagon in the train set (53 × 22.4 T on + the G2 consist), while `needFor` charges only the tare of the wagons the + booking occupies — so the same 45-wagon load reads 3 528 T at reservation and + 3 707.2 T at allocation. Two capacity models, one train. - **Government preemption cannot reach a FULL train** (`g1-s6-s8`). `isFillable` rejects a schedule whose `booking_window_status` is FULL before any budget or victim is considered, and `refreshWindowStatus` re-derives that flag from live @@ -143,6 +158,12 @@ what it actually does and says so in a comment, so a fix fails loudly: - **One tenant per booking.** `seedTenantContracts` mints a company per booking because a company may hold only one unpaid reservation at a time; staff book and pay on their behalf, which is also the real Path B flow. +- **The weight axis is GROSS, but the column is not.** + `wagon_booking_allocations.allocated_weight_tons` holds CARGO only; + `allocatedGrossTons` adds each wagon type's tare, because the pull limit is + spent on both. Two 20ft at 28 T ride one wagon at 78.4 T gross — 35 of those + spend a 3 500 T locomotive pair, and reading the raw column would report + 1 960 T and hide it. - **Group 1 rides a BUILT train, not a loco pair.** `maxWagonsPerTrain` is not a cap: `syncScheduleMaxWagons` recomputes it from locomotive length (54 here) every fill pass. A built train's coupled consist wins outright, so diff --git a/integration/src/flows.ts b/integration/src/flows.ts index 96867b0ec..eaab5a6f4 100644 --- a/integration/src/flows.ts +++ b/integration/src/flows.ts @@ -1492,6 +1492,38 @@ export async function expectContainersPlaced(scheduleId: string, containers: num } } +/** + * GROSS tonnage riding a schedule — cargo PLUS the tare of every wagon it + * occupies, because a locomotive hauls the wagon as well as what is in it. + * + * `allocated_weight_tons` holds the CARGO alone, so the tare of each allocated + * wagon type is added here. Reading the column raw understates a loaded consist + * by 22.4 T a wagon and makes a weight-bound train look half empty. + */ +export async function allocatedGrossTons(scheduleId: string): Promise { + const [row] = await db<{ tons: string | null }>( + `SELECT COALESCE(sum(wba.allocated_weight_tons + wt.tare_weight_tons), 0)::text AS tons + FROM freight.wagon_booking_allocations wba + JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id + JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`, + [scheduleId], + ); + return Number(row.tons ?? 0); +} + +/** Wagons a single booking currently holds. */ +export async function wagonAllocationCount(bookingId: string): Promise { + const [row] = await db<{ n: string }>( + `SELECT count(*)::text AS n FROM freight.wagon_booking_allocations + WHERE booking_id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + return Number(row.n); +} + /** Bookings still linked to a schedule — the seats actually held. */ export async function linkedBookings(scheduleId: string): Promise { const [row] = await db<{ n: string }>( diff --git a/integration/src/g2-weight.it.ts b/integration/src/g2-weight.it.ts new file mode 100644 index 000000000..58ce74786 --- /dev/null +++ b/integration/src/g2-weight.it.ts @@ -0,0 +1,572 @@ +/** + * GROUP 2 · S9–S12 — when WEIGHT binds before slots. + * + * Group 1 kept every non-slot axis slack so wagon arithmetic was the only thing + * under test. This group inverts it: the locomotives pull 3 500 T base (two + * 1 750 T units — pull weight ADDS UP across a set) and the cargo is heavy + * enough that the pull limit runs out before the slots do. + * + * The arithmetic follows from `grossWagonWeightTons` = tare + cargo. The weight + * axis is GROSS: a locomotive hauls the wagon as well as what is in it. NW5 + * tare 22.4 T, two 20ft per wagon: + * + * heavy (28 T VGM): 2 × 28 + 22.4 = 78.4 T per wagon + * light (12 T VGM): 2 × 12 + 22.4 = 46.4 T per wagon + * + * S9 35 wagons × 78.4 = 2 744 T fits; 10 more would be 3 528 T > 3 500 T, + * so the next booking is cut down on WEIGHT and the window closes FULL + * with 19 slots still empty — the verdict names pull, not slots. + * S10 that same 3 528 T is admitted WHOLE by the pair carrying a 90 T + * tolerance (cap 3 590 T). Tolerance buys a whole booking, nothing else. + * S11 with 756 T of base room left, the split offer is sized from BASE room + * only — it may never reach into the tolerance. + * S12 light cargo: every slot fills at ~72% of the pull limit. SLOTS bind. + * + * WHY LOCOMOTIVE PAIRS AND NOT THE BUILT TRAINS + * + * `remainingBudget` replaces the whole limit set with + * `{wagons: physicalWagons, weightTons: Infinity, lengthMeters: Infinity}` the + * moment a schedule has a built train — so on a built consist the batch is + * blind to pull weight and only slots bind. The locomotive-pair path keeps the + * real limits, which is where this group's axis actually lives. The last + * describe pins the built-train hole itself: the batch reserves a load the + * locomotives cannot pull, and only the allocator notices — after payment. + * + * Fixture: seed-g2-weight.sql (LOCO-G2-A/B 1 750 T + 0 tolerance, + * LOCO-G2-C/D 1 750 T + 45 T each, TRN-G2-BASE for the built-train case). + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, gateway, sleep } from "./client"; +import { + allocatedGrossTons, + allocatedWagons, + bookContainersReady, + bookingRow, + closeBookingWindow, + completeDocReview, + createBuiltTrainSchedule, + createSchedule, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + expectNoPartialOffer, + extendPayWindow, + forceWindowOpen, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollCycleConcluded, + pollPartialOffer, + pollWindow, + releaseUnpaidHolds, + resetCorridorDay, + scheduleRow, + seedTenantContracts, + setPriority, + wagonAllocationCount, +} from "./flows"; + +const STAMP = String(Date.now()); + +/** NW5 tare and capacity — the fixture's whole premise. */ +const NW5_TARE = 22.4; +const NW5_CAPACITY = 70; +const HEAVY_VGM = 28; +const LIGHT_VGM = 12; +/** Two 20ft ride one NW5. */ +const grossPerWagon = (vgm: number) => 2 * vgm + NW5_TARE; +/** Base pull of the 1 750 T + 1 750 T pair. */ +const BASE_TONS = 3500; +/** LOCO-G2-C/D: 45 T + 45 T. Weight tolerance adds up too. */ +const TOLERANCE_TONS = 90; +const BASE_PAIR: [string, string] = ["LOCO-G2-A", "LOCO-G2-B"]; +const TOL_PAIR: [string, string] = ["LOCO-G2-C", "LOCO-G2-D"]; +/** floor(760 m / 13.966 m) — the slot count a 760 m pair derives. */ +const SLOTS = 54; + +/** + * How the engine sizes a partial when weight is the binding axis: it walks + * candidate wagon counts and keeps the one carrying the most cargo, measuring + * each wagon at its FULL capacity rather than at the booking's real density. + * With 756 T of room that peaks at 8 wagons (8 × 70 = 560 T of nominal cargo) + * rather than 9 (756 − 9 × 22.4 = 554.4 T), even though this cargo only weighs + * 56 T per wagon. Conservative, and never dependent on the tolerance. + */ +function offerWagonsFor(roomTons: number, bookingWagons: number, freeSlots: number): number { + let best = 0; + let bestCargo = 0; + for (let w = 1; w <= Math.min(freeSlots, bookingWagons - 1); w += 1) { + const cargo = Math.min(w * NW5_CAPACITY, roomTons - w * NW5_TARE); + if (cargo > bestCargo) { + bestCargo = cargo; + best = w; + } + } + return best; +} + +// ─────────────────────────────────────────────────────────────────────────── +// S9 — weight binds before slots +// ─────────────────────────────────────────────────────────────────────────── + +describe("g2 s9: weight cuts a booking down while slots sit empty", () => { + const DEPARTURE = departureAt(58); + const BOOKING_DAY = eatDayStr(DEPARTURE); + const SHAPES = { + WA: { twenty: 40, wagons: 20 }, + WB: { twenty: 30, wagons: 15 }, + WC: { twenty: 20, wagons: 10 }, + } as const; + const ORDER = ["WA", "WB", "WC"] as const; + const BOARDED = SHAPES.WA.wagons + SHAPES.WB.wagons; // 35 + const FREE_SLOTS = SLOTS - BOARDED; // 19 + /** 3 500 − 2 744 = 756 T of pull left, against 19 free slots. */ + const BASE_ROOM = BASE_TONS - BOARDED * grossPerWagon(HEAVY_VGM); // 756 + const EXPECTED_OFFER = offerWagonsFor(BASE_ROOM, SHAPES.WC.wagons, FREE_SLOTS); // 8 + + const booking = new Map(); + 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 createSchedule({ departure: DEPARTURE, locoPair: BASE_PAIR })).id; + await forceWindowOpen(scheduleId, 60); + + let isoSeed = 30_000; + for (const [i, suffix] of ORDER.entries()) { + booking.set( + suffix, + await bookContainersReady({ + contractId: contracts.get(suffix)!, + runStamp: STAMP, + isoSeed, + twenty: SHAPES[suffix].twenty, + scheduledDate: BOOKING_DAY, + vgmTons: HEAVY_VGM, + }), + ); + isoSeed += SHAPES[suffix].twenty; + await setPriority(booking.get(suffix)!, i + 1); + } + }, 1_800_000); + + it("the heavy-wagon arithmetic is what the scenario assumes", async () => { + expect(grossPerWagon(HEAVY_VGM), "2 × 28 T + 22.4 T tare").toBe(78.4); + expect(BOARDED * grossPerWagon(HEAVY_VGM), "35 wagons fit the 3 500 T base").toBe(2744); + expect( + (BOARDED + SHAPES.WC.wagons) * grossPerWagon(HEAVY_VGM), + "WC's 10 more would breach the base", + ).toBeGreaterThan(BASE_TONS); + expect(Number((await scheduleRow(scheduleId)).max_wagons), "54 slots").toBe(SLOTS); + expect(FREE_SLOTS, "19 slots would still be free").toBe(19); + }); + + it("WA and WB board whole; WC is cut down by WEIGHT, not by slots", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + + for (const suffix of ["WA", "WB"] as const) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + await expectNoPartialOffer(booking.get(suffix)!, suffix); + } + + // 19 slots are free and WC needs 10 — on the slot axis it fits easily. What + // it gets instead is an offer sized by the 756 T of pull left. + const offered = Number((await pollPartialOffer(booking.get("WC")!)).offered_wagons); + expect(offered, "sized by remaining pull, not by slots").toBe(EXPECTED_OFFER); + expect(offered, "less than the 10 wagons WC asked for").toBeLessThan(SHAPES.WC.wagons); + expect(offered, "and far less than the free slots").toBeLessThan(FREE_SLOTS); + expect( + offered * grossPerWagon(HEAVY_VGM), + "the offered part fits the base room", + ).toBeLessThanOrEqual(BASE_ROOM); + }); + + it("the verdict names WEIGHT: 35 of 54 slots with the pull limit spent", async () => { + await extendPayWindow(scheduleId, [booking.get("WA")!, booking.get("WB")!]); + for (const suffix of ["WA", "WB"] as const) { + await payViaGateway(booking.get(suffix)!); + await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons); + } + + const tons = await allocatedGrossTons(scheduleId); + expect(tons, "2 744 T of the 3 500 T base used").toBeCloseTo(2744, 0); + expect( + tons + SHAPES.WC.wagons * grossPerWagon(HEAVY_VGM), + "WC whole would not fit on weight", + ).toBeGreaterThan(BASE_TONS); + expect(await allocatedWagons(scheduleId), "35 of 54 slots used").toBe(BOARDED); + // And the verdict itself: the window reads FULL with 19 slots standing + // empty, because `isExhausted` ran out of PULL, not of wagons. On the built + // trains of Group 1 the same board would still be selling space. + expect( + (await scheduleRow(scheduleId)).booking_window_status, + "FULL — declared on weight while 19 slots are free", + ).toBe("FULL"); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// S10 — tolerance admits a WHOLE booking over the base +// ─────────────────────────────────────────────────────────────────────────── + +describe("g2 s10: the overage tolerance admits the last booking whole", () => { + const DEPARTURE = departureAt(59); + const BOOKING_DAY = eatDayStr(DEPARTURE); + const SHAPES = { + TA: { twenty: 40, wagons: 20 }, + TB: { twenty: 30, wagons: 15 }, + TC: { twenty: 20, wagons: 10 }, + } as const; + const ORDER = ["TA", "TB", "TC"] as const; + const ALL_WAGONS = 45; + const ALL_TONS = ALL_WAGONS * grossPerWagon(HEAVY_VGM); // 3528 + + const booking = new Map(); + 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 createSchedule({ departure: DEPARTURE, locoPair: TOL_PAIR })).id; + await forceWindowOpen(scheduleId, 60); + + let isoSeed = 31_000; + for (const [i, suffix] of ORDER.entries()) { + booking.set( + suffix, + await bookContainersReady({ + contractId: contracts.get(suffix)!, + runStamp: STAMP, + isoSeed, + twenty: SHAPES[suffix].twenty, + scheduledDate: BOOKING_DAY, + vgmTons: HEAVY_VGM, + }), + ); + isoSeed += SHAPES[suffix].twenty; + await setPriority(booking.get(suffix)!, i + 1); + } + }, 1_800_000); + + it("3 528 T breaks the 3 500 T base but sits inside the 3 590 T cap", () => { + expect(ALL_TONS, "45 heavy wagons").toBeCloseTo(3528, 6); + expect(ALL_TONS, "over base").toBeGreaterThan(BASE_TONS); + expect(ALL_TONS, "within base + tolerance").toBeLessThanOrEqual(BASE_TONS + TOLERANCE_TONS); + }); + + it("TC — the booking S9 could not fit — is admitted WHOLE, not split", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + for (const suffix of ORDER) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + // The scenario's whole claim: the tolerance is spent admitting a WHOLE + // booking. A split offer here would be the bug. + await expectNoPartialOffer(booking.get(suffix)!, suffix); + } + }); + + it("the train rides over base, inside tolerance, at 45 of 54 slots", async () => { + await extendPayWindow(scheduleId, [...booking.values()]); + for (const suffix of ORDER) { + await payViaGateway(booking.get(suffix)!); + await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons); + } + await endPaymentPhase(scheduleId); + await pollCycleConcluded(scheduleId); + + expect(await allocatedWagons(scheduleId), "45 of 54 slots").toBe(ALL_WAGONS); + const tons = await allocatedGrossTons(scheduleId); + expect(tons, "3 528 T aboard").toBeCloseTo(ALL_TONS, 0); + expect(tons, "over the 3 500 T base").toBeGreaterThan(BASE_TONS); + expect(tons, "inside the 3 590 T cap").toBeLessThanOrEqual(BASE_TONS + TOLERANCE_TONS); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// S11 — a split may never touch the tolerance +// ─────────────────────────────────────────────────────────────────────────── + +describe("g2 s11: a split is sized against base weight only", () => { + const DEPARTURE = departureAt(60); + const BOOKING_DAY = eatDayStr(DEPARTURE); + const SHAPES = { + XA: { twenty: 40, wagons: 20 }, + XB: { twenty: 30, wagons: 15 }, + /** Wants 15 wagons; base room pays for a fraction of them. */ + XD: { twenty: 30, wagons: 15 }, + } as const; + const ORDER = ["XA", "XB", "XD"] as const; + const USED_TONS = 35 * grossPerWagon(HEAVY_VGM); // 2744 + const BASE_ROOM = BASE_TONS - USED_TONS; // 756 + const TOLERANCE_ROOM = BASE_ROOM + TOLERANCE_TONS; // 846 + const FREE_SLOTS = SLOTS - 35; // 19 + const EXPECTED_OFFER = offerWagonsFor(BASE_ROOM, SHAPES.XD.wagons, FREE_SLOTS); // 8 + /** What the tolerance would have bought if the sizer were allowed to spend it. */ + const OFFER_IF_TOLERANCE_SPENT = offerWagonsFor(TOLERANCE_ROOM, SHAPES.XD.wagons, FREE_SLOTS); + + const booking = new Map(); + 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 })), + ); + // Deliberately the TOLERANCE pair: the 90 T is present and must go unspent. + scheduleId = (await createSchedule({ departure: DEPARTURE, locoPair: TOL_PAIR })).id; + await forceWindowOpen(scheduleId, 60); + + let isoSeed = 32_000; + for (const [i, suffix] of ORDER.entries()) { + booking.set( + suffix, + await bookContainersReady({ + contractId: contracts.get(suffix)!, + runStamp: STAMP, + isoSeed, + twenty: SHAPES[suffix].twenty, + scheduledDate: BOOKING_DAY, + vgmTons: HEAVY_VGM, + }), + ); + isoSeed += SHAPES[suffix].twenty; + await setPriority(booking.get(suffix)!, i + 1); + } + }, 1_800_000); + + it("base room and tolerance room would buy different offers", () => { + expect(BASE_ROOM, "756 T of base room after 2 744 T").toBe(756); + expect( + OFFER_IF_TOLERANCE_SPENT, + "spending the 90 T would buy a bigger offer — so this is a real distinction", + ).toBeGreaterThan(EXPECTED_OFFER); + }); + + it("XD's offer is sized from base room — the tolerance stays unspent", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + for (const suffix of ["XA", "XB"] as const) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + } + + const offered = Number((await pollPartialOffer(booking.get("XD")!)).offered_wagons); + expect(offered, "split sized from BASE room only").toBe(EXPECTED_OFFER); + expect( + offered * grossPerWagon(HEAVY_VGM), + "the offered part never reaches past the base room", + ).toBeLessThanOrEqual(BASE_ROOM); + }); + + it("the train closes on weight with 19 slots free and its tolerance unused", async () => { + await extendPayWindow(scheduleId, [booking.get("XA")!, booking.get("XB")!]); + for (const suffix of ["XA", "XB"] as const) { + await payViaGateway(booking.get(suffix)!); + await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons); + } + expect( + await allocatedGrossTons(scheduleId), + "still inside base — no tolerance spent", + ).toBeLessThanOrEqual(BASE_TONS); + expect(await allocatedWagons(scheduleId), "35 of 54 slots").toBe(35); + expect( + (await scheduleRow(scheduleId)).booking_window_status, + "FULL on pull weight, not on slots", + ).toBe("FULL"); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// S12 — light cargo, slots bind +// ─────────────────────────────────────────────────────────────────────────── + +describe("g2 s12: with light cargo the slots bind first", () => { + const DEPARTURE = departureAt(61); + const BOOKING_DAY = eatDayStr(DEPARTURE); + const SHAPES = { + LA: { twenty: 40, wagons: 20 }, + LB: { twenty: 40, wagons: 20 }, + LC: { twenty: 28, wagons: 14 }, + } as const; + const ORDER = ["LA", "LB", "LC"] as const; + const FULL_TONS = SLOTS * grossPerWagon(LIGHT_VGM); // 2505.6 + + const booking = new Map(); + 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 createSchedule({ departure: DEPARTURE, locoPair: BASE_PAIR })).id; + await forceWindowOpen(scheduleId, 60); + + let isoSeed = 33_000; + for (const suffix of ORDER) { + booking.set( + suffix, + await bookContainersReady({ + contractId: contracts.get(suffix)!, + runStamp: STAMP, + isoSeed, + twenty: SHAPES[suffix].twenty, + scheduledDate: BOOKING_DAY, + vgmTons: LIGHT_VGM, + }), + ); + isoSeed += SHAPES[suffix].twenty; + } + }, 1_800_000); + + it("a full consist of light wagons weighs only ~72% of the pull limit", () => { + expect(grossPerWagon(LIGHT_VGM), "2 × 12 T + 22.4 T tare").toBe(46.4); + expect(FULL_TONS, "54 × 46.4 T").toBeCloseTo(2505.6, 1); + expect(FULL_TONS / BASE_TONS, "~72% of base").toBeCloseTo(0.72, 1); + expect( + ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0), + "the three fill every slot", + ).toBe(SLOTS); + }); + + it("all three board and fill the train on SLOTS, with weight to spare", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + for (const suffix of ORDER) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + await expectNoPartialOffer(booking.get(suffix)!, suffix); + } + + await extendPayWindow(scheduleId, [...booking.values()]); + for (const suffix of ORDER) { + await payViaGateway(booking.get(suffix)!); + await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons); + } + }); + + it("the verdict names SLOTS — every slot filled with ~1 000 T of pull unused", async () => { + await endPaymentPhase(scheduleId); + await pollWindow(scheduleId, (s) => s.booking_window_status === "FULL", "FULL"); + expect(await allocatedWagons(scheduleId), "54 of 54 slots").toBe(SLOTS); + for (const suffix of ORDER) { + expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID"); + } + + const tons = await allocatedGrossTons(scheduleId); + expect(tons, "2 505 T aboard").toBeCloseTo(FULL_TONS, 0); + // The opposite of S9 on identical locomotives: the train is full because it + // ran out of WAGONS, not pull. + expect(BASE_TONS - tons, "nearly 1 000 T of pull unused").toBeGreaterThan(900); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// FINDING — on a BUILT train the batch never sees the pull limit +// ─────────────────────────────────────────────────────────────────────────── + +describe("g2 finding: a built train's batch ignores the locomotive pull limit", () => { + const DEPARTURE = departureAt(62); + const BOOKING_DAY = eatDayStr(DEPARTURE); + /** 45 wagons of heavy cargo — the exact load S10's tolerance admitted. */ + const TWENTY = 90; + const WAGONS = 45; + /** What the ALLOCATOR weighs: cargo, plus the tare of the WHOLE 53-wagon consist. */ + const ALLOCATOR_TONS = TWENTY * HEAVY_VGM + 53 * NW5_TARE; // 3707.2 + + let bookingId: string; + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + const contracts = await seedTenantContracts(STAMP, [ + { suffix: "BW", freight: "CONTAINER" as const }, + ]); + scheduleId = ( + await createBuiltTrainSchedule({ + departure: DEPARTURE, + trainCode: "TRN-G2-BASE", + }) + ).id; + await forceWindowOpen(scheduleId, 60); + + bookingId = await bookContainersReady({ + contractId: contracts.get("BW")!, + runStamp: STAMP, + isoSeed: 34_000, + twenty: TWENTY, + scheduledDate: BOOKING_DAY, + vgmTons: HEAVY_VGM, + }); + }, 1_800_000); + + afterAll(closeDb); + + it("the load is beyond what these locomotives can pull, tolerance included", () => { + expect(WAGONS * grossPerWagon(HEAVY_VGM), "3 528 T on the booking's own wagons").toBeCloseTo( + 3528, + 6, + ); + expect(ALLOCATOR_TONS, "3 707.2 T once the whole consist's tare is charged").toBeCloseTo( + 3707.2, + 1, + ); + expect(ALLOCATOR_TONS, "over the 3 500 T base with no tolerance on this pair").toBeGreaterThan( + BASE_TONS, + ); + }); + + it("the batch reserves it anyway — weight is Infinity in a built train's budget", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + // `remainingBudget` swaps the locomotive limits for + // {wagons: physicalWagons, weightTons: Infinity, lengthMeters: Infinity} + // as soon as a schedule has a built train, so nothing weighs this booking + // until the wagons are handed out. + await pollBookingStatus(bookingId, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + await expectNoPartialOffer(bookingId, "BW"); + }); + + it("the customer pays, and only THEN does allocation refuse on weight", async () => { + await extendPayWindow(scheduleId, [bookingId]); + await payViaGateway(bookingId); + + // Allocation retries on every settle tick and keeps failing with + // "Train set locomotives cannot pull the gross weight … limit 3500T incl. + // tolerance". The customer is PAID with no wagons — the state this whole + // scenario group exists to surface. + await sleep(60_000); + expect(await wagonAllocationCount(bookingId), "paid, and not a single wagon").toBe(0); + expect((await bookingRow(bookingId)).status, "money taken").toBe("PAID"); + expect(await allocatedWagons(scheduleId), "the train stays empty").toBe(0); + }, 300_000); +}); diff --git a/integration/src/global-setup.ts b/integration/src/global-setup.ts index 2805a7b4e..2dec8ba49 100644 --- a/integration/src/global-setup.ts +++ b/integration/src/global-setup.ts @@ -7,6 +7,7 @@ * seed-import-corridor.sql (yards, locos, wagons, rates, distances) * seed-bulk-items.sql (PER_ITEM break-bulk cargo types) * seed-g1-train.sql (the 53-wagon BUILT container train) + * seed-g2-weight.sql (the two 3 500 T weight-bound trains) * seed-government.sql (the kind='government' company) * seed-company-b.sql (user2@gmail.com's company — this suite) * seed-customs-service-type.sql (a service type that bundles customs) @@ -30,6 +31,7 @@ const SEEDS: Array<[dir: string, file: string]> = [ [CYPRESS_FIXTURES, "seed-import-corridor.sql"], [CYPRESS_FIXTURES, "seed-bulk-items.sql"], [CYPRESS_FIXTURES, "seed-g1-train.sql"], + [CYPRESS_FIXTURES, "seed-g2-weight.sql"], [CYPRESS_FIXTURES, "seed-government.sql"], [OWN_FIXTURES, "seed-company-b.sql"], [OWN_FIXTURES, "seed-customs-service-type.sql"],