diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index b16febe2e..78004f024 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1289,6 +1289,38 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** + * EXPIRED bookings on the day's corridor — the batch board's expired lane. + * Expiry nulls train_schedule_id, so neither findAllBySchedule nor the + * ready-pool query can ever see them. + */ + findExpiredByCorridorDay( + corridorYardIds: string[], + day: string, + ): Promise { + if (corridorYardIds.length === 0) return Promise.resolve([]); + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') + .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') + .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds }) + .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', { + corridorYardIds, + }) + .andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + { day }, + ) + .andWhere('sb.id IS NULL') + .andWhere(`booking.status = 'EXPIRED'`) + .orderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + /** * Commercial bookings on the day's corridor whose operation request was NOT * accepted by staff (still pending / changes / price-confirm) and are not yet diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index a1029a836..3d3b992d3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -1632,6 +1632,18 @@ export class BookingBatchService implements OnModuleInit { for (const b of candidates) { if (!pinnedIds.has(b.id)) bookings.push(b); } + // Expiry frees the schedule pin (expire() nulls train_schedule_id), so + // expired bookings match neither query above — merge them back so the + // board keeps its expired lane. Display-only: boardState maps them to + // EXPIRED, which every capacity meter already excludes. + const expiredPool = + await this.bookingsRepository.findExpiredByCorridorDay( + stops, + eatDay(s.scheduledDepartureDate), + ); + for (const b of expiredPool) { + if (!pinnedIds.has(b.id)) bookings.push(b); + } } catch (err) { // The board must still render the pinned bookings. this.logger.warn( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 0677907f3..9c60c9821 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -136,6 +136,8 @@ import { deriveScheduleDirection } from './derive-schedule-direction.util'; import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; import { bookingCargoTons, + bulkItemsFitFor, + bulkItemWagonsRequired, deriveTrainCapacityFromLocomotive, combinedLocomotiveLimits, trainSetLocomotiveLimits, @@ -7335,7 +7337,14 @@ export class TrainSchedulingService { const byLength = containerWagonsForLines(booking.bookingContainers ?? []); const byWeight = cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0; - const wagons = Math.max(1, stored, byLength, byWeight); + // Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw + // tonnage suggests — their tare must be pulled too (batch dimsFor parity). + const byItems = bulkItemWagonsRequired( + booking, + dims.capacityTons, + bulkItemsFitFor(booking.cargoType, wagonTypeId), + ); + const wagons = Math.max(1, stored, byLength, byWeight, byItems); return roundTons(cargo + wagons * dims.tareWeightTons); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 6bafcc730..8d9339186 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -315,3 +315,131 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => expect(result.plan).toHaveLength(1); }); }); + +describe('planWagonsWithStock — break-bulk (PER_ITEM) item-aware packing', () => { + const pw2: WagonType = { + id: 'wt-pw2', + code: 'PW2', + capacityTons: 70, + lengthMeters: 17, + supportedLoadTypes: ['BULK'], + isActive: true, + supportsContainer: false, + } as WagonType; + const nw5: WagonType = { + id: 'wt-nw5', + code: 'NW5', + capacityTons: 70, + lengthMeters: 14, + supportedLoadTypes: ['BULK'], + isActive: true, + supportsContainer: false, + } as WagonType; + + // 20 machinery items, 100T total (5T each). NW5 fits 4/wagon, PW2 fits 3. + const machineryBooking = (): Booking => + ({ + id: 'BULK-ITEMS', + reference: 'BULK-ITEMS', + freightType: 'BULK', + cargoTypeId: 'ct-machinery', + cargoTotalWeightVgm: 20, + bulkTotalWeightTons: 100, + cargoType: { + id: 'ct-machinery', + cargoTypeName: 'Machinery', + itemsPerWagonMap: { 'wt-nw5': 4, 'wt-pw2': 3 }, + wagonTypes: [pw2, nw5], + }, + }) as unknown as Booking; + + const allowed = { + byContainerTypeId: new Map(), + byCargoTypeId: new Map([['ct-machinery', [pw2, nw5]]]), + }; + + it('packs whole items per wagon by the items-fit map, not raw tonnage', () => { + const result = planWagonsWithStock({ + bookings: [machineryBooking()], + allowed, + stock: { + mode: 'YARD', + remainingByTypeId: new Map([ + [pw2.id, 50], + [nw5.id, 50], + ]), + codesByTypeId: new Map([ + [pw2.id, pw2.code], + [nw5.id, nw5.code], + ]), + }, + }); + + expect(result.deferred).toHaveLength(0); + // Best fit: NW5 at 4 items/wagon → ceil(20/4) = 5 wagons, 20T each. + expect(result.plan).toHaveLength(5); + expect(result.plan.every((s) => s.wagonTypeCode === 'NW5')).toBe(true); + expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([20, 20, 20, 20, 20]); + }); + + it('weight cap binds before items-fit when items are heavy', () => { + // 14 items of 10T on 70T wagons with a 100-item floor fit → 7 items/wagon. + const heavy = { + ...machineryBooking(), + cargoTotalWeightVgm: 14, + bulkTotalWeightTons: 140, + cargoType: { + id: 'ct-machinery', + cargoTypeName: 'Machinery', + itemsPerWagonMap: { 'wt-nw5': 100, 'wt-pw2': 100 }, + wagonTypes: [pw2, nw5], + }, + } as unknown as Booking; + const result = planWagonsWithStock({ + bookings: [heavy], + allowed, + stock: { + mode: 'YARD', + remainingByTypeId: new Map([ + [pw2.id, 50], + [nw5.id, 50], + ]), + codesByTypeId: new Map([ + [pw2.id, pw2.code], + [nw5.id, nw5.code], + ]), + }, + }); + + expect(result.deferred).toHaveLength(0); + expect(result.plan).toHaveLength(2); + expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 70]); + }); + + it('PER_TON bulk (no bulkTotalWeightTons) still packs by weight', () => { + const loose = { + ...machineryBooking(), + cargoTotalWeightVgm: 100, + bulkTotalWeightTons: null, + } as unknown as Booking; + const result = planWagonsWithStock({ + bookings: [loose], + allowed, + stock: { + mode: 'YARD', + remainingByTypeId: new Map([ + [pw2.id, 50], + [nw5.id, 50], + ]), + codesByTypeId: new Map([ + [pw2.id, pw2.code], + [nw5.id, nw5.code], + ]), + }, + }); + + expect(result.deferred).toHaveLength(0); + expect(result.plan).toHaveLength(2); + expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 30]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 0cf56ded5..0810b3369 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -2,6 +2,11 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { + bookingCargoTons, + bulkItemsFitFor, + bulkItemWagonsForAllowedTypes, +} from './train-capacity.util'; import { sortBookingsForScheduling, type BookingWagonShortage, @@ -64,6 +69,12 @@ type OpenSlot = { /** Kind purity: a bulk wagon carries ONE cargo type at a time. */ cargoTypeId: string | null; freeCapacityTons: number; + /** + * Whole-item slots left on this wagon (break-bulk PER_ITEM cargo only — + * bounded by the cargo type's items-per-wagon fit and by tonnage). Undefined + * for weight-only (PER_TON) bulk and container wagons. + */ + freeItems?: number; /** * Leg of the FIRST booking placed (`"from-to"` stop indexes). Containers * prefer a same-leg slot but may extend onto a different-leg one (span @@ -110,10 +121,19 @@ const shortageFor = ( booking.freightType === 'BULK' ? Math.max( 1, - Math.ceil( - Number(booking.cargoTotalWeightVgm ?? 0) / - Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), - ), + // Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map + // respected); PER_TON falls through to tonnage over the largest + // candidate. bookingCargoTons, not raw VGM — for PER_ITEM that + // column is the item count, not tons. + bulkItemWagonsForAllowedTypes( + booking, + booking.cargoType, + Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), + ) || + Math.ceil( + bookingCargoTons(booking) / + Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), + ), ) : Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); const wagonsAvailable = candidates.reduce( @@ -347,18 +367,64 @@ export function planWagonsWithStock(params: { }; } const allowedIds = new Set(candidates.map((wt) => wt.id)); - let remainingWeight = roundTons(Number(booking.cargoTotalWeightVgm ?? 0)); + // Break-bulk (PER_ITEM): `cargoTotalWeightVgm` is the ITEM COUNT and the + // real tonnage lives in `bulkTotalWeightTons` — bookingCargoTons resolves + // it either way. Items are indivisible, so a wagon takes whole items only, + // bounded by tonnage AND by the cargo type's items-per-wagon fit. + const quantity = Number(booking.cargoTotalWeightVgm ?? 0); + const perItem = + Number(booking.bulkTotalWeightTons ?? 0) > 0 && quantity > 0; + let remainingWeight = roundTons(bookingCargoTons(booking)); + const perItemTons = perItem ? remainingWeight / quantity : 0; + let remainingItems = perItem ? quantity : 0; + + /** Whole items one wagon of this slot's type can still take. */ + const itemRoomOf = (open: OpenSlot): number => + Math.min( + open.freeItems ?? Number.MAX_SAFE_INTEGER, + perItemTons > 0 ? Math.floor(open.freeCapacityTons / perItemTons) : 0, + ); + /** Fresh wagon's whole-item budget: items-fit map floor'd by tonnage. */ + const itemBudgetOf = (open: OpenSlot): number => { + const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId); + const byTonnage = + perItemTons > 0 + ? Math.max(1, Math.floor(Number(open.slot.capacityTons) / perItemTons)) + : 1; + return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage); + }; let placedAnywhere = false; + // Per-item: prefer the type carrying the most whole items per wagon. + // openSlot's own capacity sort is stable, so this order breaks its ties. + const itemBudgetOfType = (wt: WagonType): number => + Math.min( + bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER, + perItemTons > 0 + ? Math.max(1, Math.floor(Number(wt.capacityTons) / perItemTons)) + : 1, + ); + const orderedCandidates = perItem + ? [...candidates].sort((a, b) => itemBudgetOfType(b) - itemBudgetOfType(a)) + : candidates; + // Top off wagons already carrying THIS cargo type before opening new ones. + // ponytail: per-item cargo only shares wagons that were opened per-item + // (freeItems tracked); mixing itemized and loose loads of one cargo type + // on one wagon is not modeled — open a new wagon instead. for (const open of openSlots) { - if (remainingWeight <= 0) break; + if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break; if (open.kind !== 'BULK') continue; if (open.legKey !== legKey) continue; if (open.cargoTypeId !== cargoTypeId) continue; if (!allowedIds.has(open.slot.wagonTypeId)) continue; if (open.freeCapacityTons <= 0) continue; - const take = roundTons(Math.min(open.freeCapacityTons, remainingWeight)); + if (perItem !== (open.freeItems !== undefined)) continue; + const takeItems = perItem ? Math.min(itemRoomOf(open), remainingItems) : 0; + if (perItem && takeItems <= 0) continue; + const take = perItem + ? roundTons(takeItems * perItemTons) + : roundTons(Math.min(open.freeCapacityTons, remainingWeight)); addAllocation( open.slot, booking.id, @@ -367,14 +433,39 @@ export function planWagonsWithStock(params: { AllocationLoadType.Bulk, ); open.freeCapacityTons = roundTons(open.freeCapacityTons - take); + if (perItem) { + open.freeItems = (open.freeItems ?? 0) - takeItems; + remainingItems -= takeItems; + } remainingWeight = roundTons(remainingWeight - take); placedAnywhere = true; } - while (remainingWeight > 0 || !placedAnywhere) { - const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg); + while ((perItem ? remainingItems > 0 : remainingWeight > 0) || !placedAnywhere) { + // Per-item: openSlot's stock-depth tie-break would override the fit + // preference, so hand it exactly the best in-stock type (full candidate + // list only when none has stock, for the proper shortfall message). + const inStockBest = perItem + ? orderedCandidates.find((wt) => availableFor(wt.id, leg) > 0) + : undefined; + const openedSlot = openSlot( + inStockBest ? [inStockBest] : orderedCandidates, + 'BULK', + cargoTypeId, + leg, + ); if ('message' in openedSlot) return openedSlot; - const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight)); + let take: number; + if (perItem) { + // An item heavier than a whole wagon still charges 1 wagon per item + // (creation-time validation owns rejecting that case). + const takeItems = Math.max(1, Math.min(itemBudgetOf(openedSlot), remainingItems)); + take = roundTons(Math.min(takeItems * perItemTons, remainingWeight)); + openedSlot.freeItems = itemBudgetOf(openedSlot) - takeItems; + remainingItems -= takeItems; + } else { + take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight)); + } addAllocation( openedSlot.slot, booking.id, @@ -399,6 +490,7 @@ export function planWagonsWithStock(params: { teuPerEdge: [...open.teuPerEdge], covered: { ...open.covered }, freeCapacityTons: open.freeCapacityTons, + freeItems: open.freeItems, assignedWeightTons: open.slot.assignedWeightTons, allocationCount: open.slot.allocations.length, allocationWeights: open.slot.allocations.map((a) => a.allocatedWeightTons), @@ -420,6 +512,7 @@ export function planWagonsWithStock(params: { open.teuPerEdge = [...snap.teuPerEdge]; open.covered = { ...snap.covered }; open.freeCapacityTons = snap.freeCapacityTons; + open.freeItems = snap.freeItems; open.slot.assignedWeightTons = snap.assignedWeightTons; open.slot.allocations.length = snap.allocationCount; snap.allocationWeights.forEach((weight, allocationIndex) => { diff --git a/e2e/freight/.live-q.cjs b/e2e/freight/.live-q.cjs new file mode 100644 index 000000000..d29c60443 --- /dev/null +++ b/e2e/freight/.live-q.cjs @@ -0,0 +1,17 @@ + +const { Client } = require('pg'); +(async () => { + const c = new Client({ connectionString: 'postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e' }); + await c.connect(); + const r = await c.query(` + SELECT ct.reference AS contract, b.reference AS bk, b.status, + COALESCE(b.payment_status,'-') AS pay, + (SELECT count(*) FROM freight.wagon_booking_allocations a + WHERE a.booking_id=b.id AND a.deleted_at IS NULL) AS wagons + FROM freight.bookings b JOIN freight.contracts ct ON ct.id=b.contract_id + WHERE ct.reference LIKE 'CTR-IMP-%' AND b.deleted_at IS NULL + AND b.created_at > now() - interval '30 minutes' + ORDER BY b.created_at DESC LIMIT 15`); + console.log(JSON.stringify(r.rows)); + await c.end(); +})().catch(e => { console.log(JSON.stringify({error: e.message})); }); diff --git a/e2e/freight/Dockerfile.vnc b/e2e/freight/Dockerfile.vnc new file mode 100644 index 000000000..2bad48f51 --- /dev/null +++ b/e2e/freight/Dockerfile.vnc @@ -0,0 +1,13 @@ +# Cypress + noVNC, so a headed run can be watched live in a browser. +# +# The tools are baked in rather than apt-installed per run: installing at run +# start cost ~30s on a good day and stalled indefinitely on a bad one, which +# wedged the run before Cypress ever launched. +# +# Build: docker build -f e2e/freight/Dockerfile.vnc -t edr-cypress-vnc e2e/freight +# Watch: http://localhost:8090/vnc.html?autoconnect=true&resize=scale +FROM cypress/included:15.18.1 + +RUN apt-get update -qq \ + && apt-get install -y --no-install-recommends x11vnc novnc websockify \ + && rm -rf /var/lib/apt/lists/* diff --git a/e2e/freight/cypress.config.ts b/e2e/freight/cypress.config.ts index 5a96460b8..5abf08477 100644 --- a/e2e/freight/cypress.config.ts +++ b/e2e/freight/cypress.config.ts @@ -62,7 +62,40 @@ export default defineConfig({ // can be cancelled out from under it. const runStartedAt = new Date().toISOString(); + // Per-RUN stamp specs use to build unique fixture references. Must live + // here, not in the spec: `const stamp = Date.now()` at module scope is + // regenerated when Cypress re-evaluates the bundle on a cross-origin + // visit, so a spec whose portal step sits mid-sequence re-seeds its + // contracts under a second stamp and orphans the first set — which then + // shows up on the board as unexpected "Expired" rows. + const runStamp = String(Date.now()); + + /** Keys claimed by `run:claim` in this cypress run. */ + const claimedKeys = new Set(); + on("task", { + /** Reload-stable per-run stamp (see `runStamp` above). */ + "run:stamp"() { + return runStamp; + }, + + /** + * Claim `key` for this run: true the first time, false afterwards. + * + * For arrange-work that spans several commands and so cannot go + * through `db:queryOnce` (which guards a single statement). A spec + * whose portal step sits mid-sequence has its bundle re-evaluated by + * the cross-origin visit, which re-runs `before()` — re-seeding + * fixtures the run had already created. Gating on this makes the + * second pass a no-op. The plugin process outlives the reload, so the + * claim survives it; browser-side state does not. + */ + "run:claim"(key: string) { + if (claimedKeys.has(key)) return false; + claimedKeys.add(key); + return true; + }, + /** * Cancel a previous run's contracts of one shape so a spec can run * again against a warm DB (the API allows one active contract per diff --git a/e2e/freight/cypress/e2e/flows/flow_two/flow2-export-utils.ts b/e2e/freight/cypress/e2e/flows/flow_two/flow2-export-utils.ts new file mode 100644 index 000000000..2bd4eaa59 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/flow2-export-utils.ts @@ -0,0 +1,799 @@ +/** + * Shared helpers for the FLOW-TWO EXPORT batch (tcx01 … tcx20). + * + * The first flow-two batch (../flow_two/tc01…tc08, ./flow2-utils.ts) runs the + * corridor IMPORT-ward, A→F. This batch runs it the other way and mixes three + * things the first batch never did: EXPORT legs, wagon-TYPE pools, and BULK + * tonnage arithmetic. + * + * A B C D E F + * DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY + * + * EXPORT (EXP) F→…→A — ends at the port, crosses the border + * IMPORT (IMP) A→…→F — the first batch's direction + * INTERCITY (IC) inside B..E, either way — wholly Ethiopian, DOMESTIC + * + * WHY THIS IS A SEPARATE MODULE FROM ./flow2-utils.ts + * + * flow2-utils is hard-wired to the import direction and cannot be reused as-is: + * + * - `edgesOf(from, to)` ASSERTS `a < b` (flow2-utils.ts:77) — an export leg + * F→A is backwards by that measure and would fail the assertion, not the + * scenario. + * - `seedLegContract` derives direction as `from === "A" ? IMPORT : DOMESTIC` + * (flow2-utils.ts:134). There is no EXPORT branch, and an export leg seeded + * DOMESTIC prices against INTERCITY rates and never reaches the export FCFS + * path at all. + * - `acceptIntercity` resolves its schedule with `dbSchedule(departure)` using + * the DEFAULT import args (flow2-utils.ts:185), so it cannot see a + * KALITY→DJIB_PORT schedule. + * + * Rather than bend those (and risk the first batch's eight specs), this module + * mirrors them in EXPORT terms. The corridor-edge model itself is direction- + * agnostic — `exportEdgesOf` maps an export leg onto the SAME five edges, just + * traversed the other way, so per-edge arithmetic is directly comparable + * between the two batches. + * + * EXPORT IS FCFS, NOT WINDOW+BATCH. This is the single most important + * difference from every import spec in this suite: + * + * - `acceptExport` IS the reservation (booking-batch.service.ts:1301, + * `acceptExportBooking` → `pickExportSchedule`). There is no + * `closeWindowAndRunBatch`, no doc-review click, no batch pass. + * - Order of acceptance therefore IS the priority rule. A scenario that wants + * a particular loser must accept in a deliberate order. + * - Export is whole-or-nothing unless `FREIGHT_EXPORT_SPLIT=true` + * (booking-batch.service.ts:394 — an ENV VAR, not a DB flag; see + * `exportSplitEnabled` below and tcx14). + * + * No module-level mutable state — same rule as g1-utils and flow2-utils: + * Cypress re-evaluates the spec bundle on cross-origin visits, so rows are + * resolved by stamped reference, never by a captured id. + */ + +import { + CORRIDOR, + EXP_DEST, + EXP_ORIGIN, + apiPost, + bookBulk, + bookContainers, + clearIntercityToFullyExecuted, + db, + dbSchedule, + opsStaff, + seedImportContract, + withBooking, + type ScheduleRow, +} from "../import-utils"; + +// --------------------------------------------------------------------------- +// the corridor, in the letters the scenarios are written in +// --------------------------------------------------------------------------- + +/** Scenario letter → corridor yard code. A is the port; F is the inland end. */ +export const STOP = { + A: CORRIDOR[0], // DJIB_PORT — Djibouti. Any leg touching it crosses the border. + B: CORRIDOR[1], // NAGAD + C: CORRIDOR[2], // DIRE_DAWA + D: CORRIDOR[3], // E2E_AWASH + E: CORRIDOR[4], // MOJO + F: CORRIDOR[5], // KALITY +} as const; + +export type Stop = keyof typeof STOP; +/** Stop letters in IMPORT (A→F) order — index doubles as corridor position. */ +export const STOPS = ["A", "B", "C", "D", "E", "F"] as const; + +/** The five corridor edges, named for the error messages a rejection should carry. */ +export const EDGE_NAMES = ["A–B", "B–C", "C–D", "D–E", "E–F"] as const; + +/** + * Edges a leg occupies, direction-agnostic. + * + * An edge is a stretch of TRACK, and F→E rides the same physical stretch as + * E→F. So both map to edge 4. This is what lets an export leg and an intercity + * leg be summed onto one profile — which is the whole point of TC-05 … TC-08. + * + * Deliberately NOT flow2-utils' `edgesOf`, which asserts forward order and + * would reject every export leg in this batch. + */ +export function exportEdgesOf(from: Stop, to: Stop): number[] { + const a = STOPS.indexOf(from); + const b = STOPS.indexOf(to); + expect(a, `${from} is on the corridor`).to.be.gte(0); + expect(b, `${to} is on the corridor`).to.be.gte(0); + expect(a, `${from}→${to} is a real leg, not a self-loop`).to.not.eq(b); + const lo = Math.min(a, b); + const hi = Math.max(a, b); + return Array.from({ length: hi - lo }, (_, i) => lo + i); +} + +/** Whether two legs share track — i.e. compete for the same wagons. */ +export function legsOverlap(l1: [Stop, Stop], l2: [Stop, Stop]): boolean { + const a = exportEdgesOf(...l1); + const b = exportEdgesOf(...l2); + return a.some((e) => b.includes(e)); +} + +export interface Leg { + from: Stop; + to: Stop; + wagons: number; +} + +/** + * Wagons committed on each of the corridor's 5 edges by a set of legs. + * + * Every scenario states its edge profile in prose in its own header; this + * computes the same number so the spec can assert its OWN PREMISE before it + * trusts the engine's answer. A scenario whose arithmetic drifted (someone + * edits a quantity) then fails on the premise, not twenty lines later on an + * engine assertion that looks like a product bug. + */ +export function edgeLoad(legs: Leg[]): number[] { + const load = [0, 0, 0, 0, 0]; + legs.forEach((l) => exportEdgesOf(l.from, l.to).forEach((e) => (load[e] += l.wagons))); + return load; +} + +/** The busiest edge and how much it carries — the edge a rejection should name. */ +export function peakEdge(legs: Leg[]) { + const load = edgeLoad(legs); + const peak = Math.max(...load); + return { edge: load.indexOf(peak), name: EDGE_NAMES[load.indexOf(peak)], wagons: peak, load }; +} + +// --------------------------------------------------------------------------- +// wagon arithmetic the scenarios are written in +// --------------------------------------------------------------------------- + +/** The three-pool export consist — see seed-flow2-export-train.sql. */ +export const EXPORT_TRAIN = "TRN-F2-EXP"; +export const CNT_POOL = 35; // NW5 +export const BLK_POOL = 20; // CW4 +export const FLT_POOL = 5; // NW6 — allow-listed to nothing, deliberately +export const EXPORT_CONSIST = CNT_POOL + BLK_POOL + FLT_POOL; // 60 + +/** Wagon type code per pool letter, as the allocation rows record it. */ +export const POOL_TYPE = { CNT: "NW5", BLK: "CW4", FLT: "NW6" } as const; +export type Pool = keyof typeof POOL_TYPE; + +/** + * Wagons a CONTAINER booking needs: 20ft pair two-per-wagon, 40ft take a whole + * wagon each. An ODD 20ft count still costs a whole wagon (and the portal form + * blocks submitting one), so keep 20ft quantities even. + * + * Same rule as g1-utils' `wagonsFor` — restated here so the TEU scenarios + * (tcx12) can assert against it without importing the import-side module. + */ +export function containerWagons(twenty: number, forty: number): number { + return Math.ceil(twenty / 2) + forty; +} + +/** CW4 physical figures, from the wagon-type catalog (SeedDefaultWagonTypes). */ +export const CW4_CAPACITY_TONS = 70; + +/** + * Wagons a loose PER_TON bulk booking needs — plain ceil against the wagon's + * capacity. This is the NON-per-item path (`bulkItemWagonsRequired` bails when + * `bulkTotalWeightTons` and the item count are not both set, train-capacity + * .util.ts:140), so a `bookBulk` tonnage booking lands here. + */ +export function bulkWagons(tons: number, capacityTons = CW4_CAPACITY_TONS): number { + return Math.ceil(tons / capacityTons); +} + +/** + * Wagons a PER_ITEM bulk booking needs — the `items_per_wagon_map` rule, in + * full, mirroring train-capacity.util.ts:143-149: + * + * perItemTons = totalTons / quantity + * byTonnage = max(1, FLOOR(capacityTons / perItemTons)) + * itemsPerWagon = min(byTonnage, FLOOR(itemsFit)) ← the map's floor + * wagons = max(1, CEIL(quantity / itemsPerWagon)) + * + * FLOOR on items-per-wagon, CEIL on the wagon count. Both matter: the floor is + * why 41t on a 40t wagon costs two wagons, and the ceil is why a part-full last + * wagon is still a whole wagon. See [[per-item-wagon-fit]]. + */ +export function perItemWagons(opts: { + items: number; + tons: number; + itemsFit?: number; + capacityTons?: number; +}): number { + const capacityTons = opts.capacityTons ?? CW4_CAPACITY_TONS; + expect(opts.items, "per-item booking has items").to.be.greaterThan(0); + expect(opts.tons, "per-item booking has tonnage").to.be.greaterThan(0); + const perItemTons = opts.tons / opts.items; + const byTonnage = Math.max(1, Math.floor(capacityTons / perItemTons)); + const byFloor = opts.itemsFit && opts.itemsFit >= 1 ? Math.floor(opts.itemsFit) : Infinity; + const itemsPerWagon = Math.min(byTonnage, byFloor); + return Math.max(1, Math.ceil(opts.items / itemsPerWagon)); +} + +// --------------------------------------------------------------------------- +// contracts pinned to a leg, in either direction +// --------------------------------------------------------------------------- + +/** + * The trade direction the ENGINE will derive for a leg, from the yards' + * countries alone (`resolveTradeDirectionForBooking`). Our intent does not + * enter into it: + * + * to A → EXPORT (Ethiopian origin, Djiboutian destination) + * from A → IMPORT + * neither → DOMESTIC (intercity) + * + * Seeding a contract with a direction the engine will not agree with does NOT + * fail loudly — pricing 404s on a rate_type that does not exist for the pair, + * or the booking books fine and then never reaches the path under test. So + * every contract in this batch derives its direction here rather than stating + * one. + */ +export function directionOf(from: Stop, to: Stop): "IMPORT" | "EXPORT" | "DOMESTIC" { + if (to === "A") return "EXPORT"; + if (from === "A") return "IMPORT"; + return "DOMESTIC"; +} + +/** + * Seed one contract whose route IS the booking's leg — the whole mechanism by + * which a flow-two booking gets a leg. The leg lives on the CONTRACT, not on + * the booking payload (bookings.service.ts resolves the contract route into + * origin/destinationYardId), so N legs means N contracts even for one customer. + * + * `direction` is derived, never passed — see `directionOf`. + */ +export function seedExportLegContract(opts: { + suffix: string; + reference: string; + from: Stop; + to: Stop; + freight?: "CONTAINER" | "BULK"; + customs?: boolean; + kind?: "ONE_TIME" | "GENERAL"; +}) { + exportEdgesOf(opts.from, opts.to); // asserts the leg is real and on-corridor + seedImportContract({ + suffix: opts.suffix, + reference: opts.reference, + originCode: STOP[opts.from], + destCode: STOP[opts.to], + direction: directionOf(opts.from, opts.to), + freight: opts.freight, + customs: opts.customs, + kind: opts.kind, + }); +} + +// --------------------------------------------------------------------------- +// the export schedule +// --------------------------------------------------------------------------- + +/** Resolve the EXPORT schedule for a departure (KALITY → DJIB_PORT). */ +export function dbExportSchedule(departure: Date) { + // NOTE THE ARG ORDER: dbSchedule takes (departure, destCode, originCode) — + // destination BEFORE origin (import-utils.ts:946). Passing them the natural + // way round silently returns zero rows. + return dbSchedule(departure, EXP_DEST, EXP_ORIGIN); +} + +/** Run `fn` against the one export schedule on this departure. */ +export function withExportSched(departure: Date, fn: (s: ScheduleRow) => void) { + dbExportSchedule(departure).then(({ rows }) => { + expect(rows, "flow-two export schedule").to.have.length(1); + fn(rows[0]); + }); +} + +/** + * Create the export schedule from the THREE-POOL BUILT train. + * + * Deliberately not a loco-pair schedule: a pair derives capacity from + * locomotive length (`syncScheduleMaxWagons`), which would give one flat number + * and erase the pool structure this batch exists to test. A built train's + * physical consist wins outright — booking-batch.service.ts:4152: + * + * const maxWagons = physicalWagons ?? capacityLimits(loco).base.wagons + * + * so the 60 coupled wagons ARE the capacity, and it survives the 10s tick. + */ +export function createExportSchedule(opts: { + departure: Date; + trainCode?: string; + /** Container schedules take container bookings; bulk takes bulk. */ + kind?: "container" | "bulk"; +}) { + const trainCode = opts.trainCode ?? EXPORT_TRAIN; + const kind = opts.kind ?? "container"; + dbExportSchedule(opts.departure).then(({ rows }) => { + if (rows.length > 0) return; + db<{ id: string }>(`SELECT id FROM freight.trains WHERE code = $1`, [trainCode]).then( + ({ rows: trains }) => { + expect(trains, `built train ${trainCode}`).to.have.length(1); + apiPost(opsStaff, `/api/train-scheduling/${kind}/schedules`, { + routeId: null, + scheduleDate: opts.departure.toISOString(), + trainId: trains[0].id, + originCode: EXP_ORIGIN, + destinationCode: EXP_DEST, + }) + .its("status") + .should("be.oneOf", [200, 201]); + }, + ); + }); +} + +/** + * Assert the schedule's capacity is the built consist, not a loco-derived + * number. Worth asserting in every scenario's setup: if a future change lets + * the length recompute win again, EVERY scenario's arithmetic shifts and the + * exact-fit cases fail somewhere far from the cause. + */ +export function expectExportCapacity(departure: Date, wagons = EXPORT_CONSIST) { + dbExportSchedule(departure).then(({ rows }) => { + expect(rows, "flow-two export schedule").to.have.length(1); + expect(rows[0].max_wagons, `consist capacity = ${wagons}`).to.eq(wagons); + }); +} + +// --------------------------------------------------------------------------- +// the wagon-TYPE verdict — this batch's headline assertion +// --------------------------------------------------------------------------- + +/** + * Wagons a booking holds, BROKEN DOWN BY WAGON TYPE. + * + * A plain count cannot catch the bug this batch is about. A container booking + * handed 40 wagons out of a 35-wagon NW5 pool reads as "40 wagons allocated", + * which is indistinguishable from the correct answer on a 60-slot train — until + * marshalling, when five of those wagons turn out to be bulk hoppers. + * + * Reads the type off `train_set_wagons.wagon_type_id`: the SLOT's type is + * authoritative even before a physical wagon is pinned to it. + */ +export function wagonsByType(bookingId: string) { + return db<{ code: string; n: string }>( + `SELECT wt.code, count(DISTINCT wba.train_set_wagon_id) AS n + 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 + WHERE wba.booking_id = $1 AND wba.deleted_at IS NULL + GROUP BY wt.code`, + [bookingId], + ).then(({ rows }) => new Map(rows.map((r) => [r.code, Number(r.n)]))); +} + +/** + * Assert a booking holds exactly `wagons` slots and ALL of them are the given + * pool's type — never one borrowed from a neighbouring pool. + * + * The second half is the assertion that survives any change to who boards: + * whatever the engine decides about admission, a container booking that ever + * holds a CW4 is a defect. + */ +export function expectPoolAllocation(suffix: string, pool: Pool, wagons: number) { + const type = POOL_TYPE[pool]; + withBooking(suffix, (b) => + wagonsByType(b.id).then((byType) => { + expect(byType.get(type) ?? 0, `${suffix} holds ${wagons} × ${type} (${pool})`).to.eq( + wagons, + ); + byType.forEach((n, code) => { + if (code !== type) { + expect(n, `${suffix} borrowed ${n} × ${code} from another pool`).to.eq(0); + } + }); + }), + ); +} + +/** + * Assert a booking never exceeds its POOL, whatever else the engine decided. + * + * Weaker than `expectPoolAllocation` on purpose — the scenarios where the + * engine may legitimately reject OR split OR partially fill still have this one + * hard ceiling in common, and asserting it covers every branch without the + * spec having to pick one. + */ +export function expectWithinPool(suffix: string, pool: Pool, poolSize: number) { + const type = POOL_TYPE[pool]; + withBooking(suffix, (b) => + wagonsByType(b.id).then((byType) => + expect( + byType.get(type) ?? 0, + `${suffix} is capped by the ${pool} pool (${poolSize} × ${type}), not by the consist`, + ).to.be.at.most(poolSize), + ), + ); +} + +/** + * Assert NO booking on this schedule holds a wagon outside its own pool. + * + * The train-wide form of `expectPoolAllocation`, and the one that catches a + * cross-pool leak the per-booking assertions would miss if a scenario forgot to + * name every booking. + */ +export function expectNoPoolLeak(departure: Date) { + withExportSched(departure, (s) => + db<{ freight_type: string; code: string; n: string }>( + `SELECT b.freight_type, wt.code, count(*) AS n + FROM freight.wagon_booking_allocations wba + JOIN freight.bookings b ON b.id = wba.booking_id + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 + 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 + WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL + GROUP BY b.freight_type, wt.code`, + [s.id], + ).then(({ rows }) => { + rows.forEach((r) => { + if (r.freight_type === "CONTAINER") { + expect(r.code, `containers ride ${POOL_TYPE.CNT} only`).to.eq(POOL_TYPE.CNT); + } else { + expect(r.code, `bulk rides ${POOL_TYPE.BLK} only`).to.eq(POOL_TYPE.BLK); + } + }); + // The flatbed pool is allow-listed to nothing (seed section 5), so ANY + // allocation against it is a leak by construction. + const flt = rows.find((r) => r.code === POOL_TYPE.FLT); + expect(flt, `nothing may ride the un-allow-listed ${POOL_TYPE.FLT} pool`).to.be.undefined; + }), + ); +} + +// --------------------------------------------------------------------------- +// the per-edge verdict +// --------------------------------------------------------------------------- + +/** + * Wagons committed on each corridor edge, read back from what the engine + * ACTUALLY allocated — direction-agnostic, so export and intercity legs sum + * onto one profile. + * + * This, not the train-wide total, is the assertion the segment scenarios exist + * for. A train-wide count of 106 on a 60-wagon train reads as an overbook until + * the legs are separated; a train-wide count of 60 hides a booking that charged + * the whole route when it should have charged two edges. + */ +export function exportEdgeLoadFromDb(scheduleId: string) { + return db<{ origin: string; destination: string; wagons: string }>( + `SELECT o.code AS origin, d.code AS destination, + count(DISTINCT wba.train_set_wagon_id) AS wagons + FROM freight.train_schedule_bookings tsb + JOIN freight.bookings b ON b.id = tsb.booking_id + JOIN freight.yards o ON o.id = b.origin_yard_id + JOIN freight.yards d ON d.id = b.destination_yard_id + JOIN freight.wagon_booking_allocations wba + ON wba.booking_id = b.id AND wba.deleted_at IS NULL + WHERE tsb.train_schedule_id = $1 + AND tsb.deleted_at IS NULL AND b.deleted_at IS NULL + GROUP BY o.code, d.code`, + [scheduleId], + ).then(({ rows }) => { + const byCode = new Map(STOPS.map((s) => [STOP[s] as string, s as Stop])); + return edgeLoad( + rows.map((r) => { + const from = byCode.get(r.origin); + const to = byCode.get(r.destination); + expect(from, `booking origin ${r.origin} is on the corridor`).to.not.be.undefined; + expect(to, `booking destination ${r.destination} is on the corridor`).to.not.be + .undefined; + return { from: from as Stop, to: to as Stop, wagons: Number(r.wagons) }; + }), + ); + }); +} + +/** + * Assert the per-edge load the schedule ended up carrying, and that no edge + * exceeded the consist. + * + * `expected` is the FULL five-edge profile — writing it out in full is + * deliberate. An assertion on the peak alone passes on a plan that put the + * right total on the wrong edges, which is precisely the segment-reuse bug. + */ +export function expectExportEdgeLoad( + departure: Date, + expected: number[], + capacity = EXPORT_CONSIST, +) { + expect(expected, "one entry per corridor edge").to.have.length(5); + withExportSched(departure, (s) => + exportEdgeLoadFromDb(s.id).then((load) => { + expect(load, "wagons committed per corridor edge").to.deep.eq(expected); + load.forEach((w, e) => + expect(w, `edge ${e} (${EDGE_NAMES[e]}) within the consist`).to.be.at.most(capacity), + ); + }), + ); +} + +/** + * Assert a booking rides the train on exactly the leg it was sold, holding + * `wagons` slots. Guards the half-failure a total-only assertion misses: a + * booking allocated onto the right train but charged against the whole route. + */ +export function expectExportBookingLeg(suffix: string, leg: Leg) { + withBooking(suffix, (b) => { + db<{ origin: string; destination: string; wagons: string }>( + `SELECT o.code AS origin, d.code AS destination, + count(DISTINCT wba.train_set_wagon_id) AS wagons + FROM freight.bookings b + JOIN freight.yards o ON o.id = b.origin_yard_id + JOIN freight.yards d ON d.id = b.destination_yard_id + LEFT JOIN freight.wagon_booking_allocations wba + ON wba.booking_id = b.id AND wba.deleted_at IS NULL + WHERE b.id = $1 + GROUP BY o.code, d.code`, + [b.id], + ).then(({ rows }) => { + expect(rows, `${suffix} booking row`).to.have.length(1); + expect(rows[0].origin, `${suffix} origin`).to.eq(STOP[leg.from]); + expect(rows[0].destination, `${suffix} destination`).to.eq(STOP[leg.to]); + expect(Number(rows[0].wagons), `${suffix} holds ${leg.wagons} wagons`).to.eq(leg.wagons); + }); + }); +} + +/** + * Assert a booking holds NO wagons — the rejected/waitlisted side of a verdict. + * + * Deliberately not an assertion on booking STATUS: a booking refused at + * export-accept time, one that lost a batch, and one whose offer lapsed all + * carry different statuses but agree on the thing that matters — it consumed no + * capacity. + */ +export function expectNoWagons(suffix: string) { + withBooking(suffix, (b) => + db<{ n: string }>( + `SELECT count(*) AS n FROM freight.wagon_booking_allocations + WHERE booking_id = $1 AND deleted_at IS NULL`, + [b.id], + ).then(({ rows }) => expect(Number(rows[0].n), `${suffix} holds no wagons`).to.eq(0)), + ); +} + +// --------------------------------------------------------------------------- +// export accept — FCFS, and the rejection shape +// --------------------------------------------------------------------------- + +/** + * Ops accepts an export operation request and the accept is EXPECTED TO FAIL. + * + * `acceptExport` (import-utils.ts:919) asserts a 2xx and then polls for a + * reserved status — it cannot express "this one must be turned away", which is + * half the scenarios in this batch. This is its refusal-side twin: it asserts + * the call was refused, hands back the response so the spec can inspect the + * reason, and never polls. + * + * Returns the response for `expectCapacityRefusal`. + */ +export function acceptExportExpectingRefusal(suffix: string) { + return withBookingChain(suffix).then((b) => + apiPost( + opsStaff, + `/api/bookings/${b.id}/operation/review`, + { decision: "ACCEPT" }, + false, // failOnStatusCode — a 4xx IS the expected outcome here + ).then((res) => { + expect(res.status, `${suffix} was refused, not accepted`).to.be.within(400, 422); + return res; + }), + ); +} + +/** + * Assert a refusal is about CAPACITY, and names the constraint. + * + * The reason this matters: "no train has room" and "booking not found" and + * "wrong status" are all 4xx, and a spec that only asserted the status code + * passes when the scenario never actually ran. Worse, a capacity refusal that + * does not name the wagon TYPE is the specific failure TC-02 is about — the + * customer is told the train is full when 25 wagons stand empty, because they + * are the wrong kind. + * + * `namesType` is opt-in rather than always-on: not every refusal path has type + * information to give, and a scenario should state which it expects. + */ +export function expectCapacityRefusal( + res: Cypress.Response, + opts: { namesType?: Pool } = {}, +) { + const body = JSON.stringify(res.body); + expect(body, "refused on capacity, not on an unrelated gate").to.match( + /capacity|fit|full|room|wagon|space|no train/i, + ); + if (opts.namesType) { + // The wagon type, the pool letter, or a plain-language name for it — any + // of the three tells the customer WHICH pool ran out. + const type = POOL_TYPE[opts.namesType]; + const words = + opts.namesType === "CNT" + ? /container|NW5/i + : opts.namesType === "BLK" + ? /bulk|CW4/i + : /flat|NW6/i; + expect( + body, + `refusal names the ${opts.namesType} (${type}) pool, not just "train full"`, + ).to.match(words); + } +} + +/** `withBooking` as a chainable, so a caller can `.then()` on the row. */ +export function withBookingChain(suffix: string) { + return db<{ id: string; status: string }>( + `SELECT b.id, b.status FROM freight.bookings b + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL + ORDER BY b.created_at DESC LIMIT 1`, + [suffix], + ).then(({ rows }) => { + expect(rows, `${suffix} booking`).to.have.length(1); + return rows[0]; + }); +} + +// --------------------------------------------------------------------------- +// intercity: booking the DOMESTIC legs +// --------------------------------------------------------------------------- + +/** + * File an intercity CONTAINER booking and walk it to FULLY_EXECUTED, ready for + * staff to assign onto a passing train. + * + * An intercity booking MUST NOT pin a `scheduledDate`. The engine rejects a + * DOMESTIC booking that names a day (g6_corridor.cy.ts:253) because an + * intercity shipment does not choose its train — staff put it on whichever one + * passes with room. So this is deliberately NOT `bookAndClear`, which requires + * one, and NOT `acceptOperation`, which is the import day-pool path. + * + * The clearance gate still applies: every contract booking is born in + * AWAITING_DOCUMENTS regardless of direction (contract-booking.service.ts:211), + * so a booking that is merely created is not yet assignable. + */ +export function bookIntercityContainers(opts: { + suffix: string; + runStamp: string; + isoSeed: number; + twenty?: number; + forty?: number; + vgmTons?: number; +}) { + bookContainers({ + suffix: opts.suffix, + runStamp: opts.runStamp, + isoSeed: opts.isoSeed, + twenty: opts.twenty, + forty: opts.forty, + vgmTons: opts.vgmTons, + // scheduledDate deliberately omitted — see above. + }); + clearIntercityToFullyExecuted(opts.suffix); +} + +/** The bulk twin of `bookIntercityContainers` — same no-scheduledDate rule. */ +export function bookIntercityBulk(opts: { + suffix: string; + tons: number; + /** Any seeded `freight.cargo_types.code` — see bookBulk. */ + cargoCode?: string; +}) { + bookBulk({ + suffix: opts.suffix, + tons: opts.tons, + cargoCode: opts.cargoCode, + }); + clearIntercityToFullyExecuted(opts.suffix); +} + +// --------------------------------------------------------------------------- +// intercity assignment on an export train +// --------------------------------------------------------------------------- + +/** + * Offer intercity (DOMESTIC) bookings to the EXPORT train the way staff do, IN + * ORDER, and assert which ones the engine took. + * + * flow2-utils' `acceptIntercity` cannot be reused: it resolves the schedule + * with `dbSchedule(departure)` on the DEFAULT import args, so it looks for a + * DJIB_PORT→KALITY schedule and finds nothing. + * + * The endpoint is a per-train batch call that always answers 200 with + * `{ accepted, rejected }` — a booking that does not fit its leg comes back in + * `rejected`, NOT as a 4xx. A spec asserting only the status code would pass on + * a train that took nobody, so this asserts the partition itself. + * + * ORDER MATTERS and is the caller's to choose: the budget shrinks as the loop + * walks `bookingIds`, so the priority rule under test IS the order sent. + */ +export function acceptIntercityOnExport(opts: { + departure: Date; + /** Suffixes in the order staff offer them — this IS the priority under test. */ + accept: string[]; + /** Suffixes expected back in `rejected` (did not fit their leg). */ + reject?: string[]; +}) { + const wanted = [...opts.accept, ...(opts.reject ?? [])]; + const ids: Record = {}; + wanted.forEach((suffix) => + withBooking(suffix, (b) => { + ids[suffix] = b.id; + }), + ); + return dbExportSchedule(opts.departure).then(({ rows }) => { + expect(rows, "flow-two export schedule").to.have.length(1); + return apiPost( + opsStaff, + `/api/train-scheduling/schedules/${rows[0].id}/intercity/accept`, + { bookingIds: wanted.map((suffix) => ids[suffix]) }, + ).then((res) => { + expect(res.status, "intercity accept answered").to.be.oneOf([200, 201]); + const body = res.body as { + accepted: string[]; + rejected: Array<{ bookingId: string; reason: string }>; + }; + const rejectedIds = body.rejected.map((r) => r.bookingId); + opts.accept.forEach((suffix) => + expect(body.accepted, `${suffix} accepted onto the train`).to.include(ids[suffix]), + ); + (opts.reject ?? []).forEach((suffix) => + expect(rejectedIds, `${suffix} refused — its leg is full`).to.include(ids[suffix]), + ); + return res; + }); + }); +} + +// --------------------------------------------------------------------------- +// export-specific engine facts the scenarios assert against +// --------------------------------------------------------------------------- + +/** + * Whether EXPORT split is switched on for this run. + * + * There is NO per-booking and NO per-schedule split flag. `isSplitEligible` + * (booking-batch.service.ts:2570) allows IMPORT and DOMESTIC splits always, and + * EXPORT splits ONLY when `exportSplitEnabled` — which reads + * `process.env.FREIGHT_EXPORT_SPLIT === "true"` on the API process + * (booking-batch.service.ts:394). + * + * The API is a separate process from Cypress, so the spec cannot read that env + * var directly and cannot flip it. It is surfaced as a Cypress env var the + * runner sets to MATCH how the API was started; tcx14 asserts the engine's + * behaviour agrees with what was declared, which is what makes a silently + * flipped flag a test failure rather than a surprise in production. + */ +export function exportSplitEnabled(): boolean { + const env = Cypress.env() as Record; + return String(env.FREIGHT_EXPORT_SPLIT) === "true"; +} + +/** + * Assert a booking's split state matches what the flag permits. + * + * Both directions are asserted because both are bugs: an export booking split + * with the flag OFF is an engine that ignored its own gate, and this suite is + * as interested in that as in the reverse. + */ +export function expectSplitAllowed(suffix: string, wasSplit: boolean) { + withBooking(suffix, (b) => { + db<{ is_split: boolean }>(`SELECT is_split FROM freight.bookings WHERE id = $1`, [ + b.id, + ]).then(({ rows }) => { + expect(Boolean(rows[0].is_split), `${suffix} is_split`).to.eq(wasSplit); + if (rows[0].is_split) { + expect( + exportSplitEnabled(), + `${suffix} was split — only legal with FREIGHT_EXPORT_SPLIT=true`, + ).to.eq(true); + } + }); + }); +} diff --git a/e2e/freight/cypress/e2e/flows/flow_two/flow2-utils.ts b/e2e/freight/cypress/e2e/flows/flow_two/flow2-utils.ts new file mode 100644 index 000000000..f402579f7 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/flow2-utils.ts @@ -0,0 +1,421 @@ +/** + * Shared helpers for FLOW-TWO — the SEGMENT REUSE suite (tc01 … tc22). + * + * Group 1 (../g1_s*.cy.ts) asks one question: does the train fill to its slot + * count? Flow-two asks the harder one: does capacity free up WHEN A BOOKING + * GETS OFF? Two bookings whose legs don't overlap ride the same physical wagons + * — so a 53-wagon train can carry 53 + 53 wagons of cargo on A→B and B→F. + * + * The engine already models this (corridor-capacity.util.ts): a schedule's + * route is an ordered stop list, capacity is tracked PER EDGE, and a booking + * charges only the edges between its own origin and destination. What is NOT + * covered anywhere else is whether that holds end-to-end through the real + * booking → clearance → batch → allocation pipeline. That is this suite. + * + * The corridor, from ../import-utils (CORRIDOR): + * + * A B C D E F + * DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY + * edge: 0 1 2 3 4 + * + * A booking's leg is pinned by its CONTRACT ROUTE, not by the booking payload — + * seedImportContract takes originCode/destCode and the booking inherits them + * (bookings.service.ts resolves the contract route into origin/destinationYardId). + * So `seedLegContract({ suffix: "B1", from: "A", to: "D" })` is the whole + * mechanism: one contract per leg shape, one booking on it. + * + * TRADE DIRECTION follows the yards' countries, not our intent: + * - from A (DJIB_PORT, Djibouti) → IMPORT: gets a booking window, enters the + * batch, is what `bookAndClear` + `closeWindowAndRunBatch` drive. + * - B…F only (all Ethiopian) → DOMESTIC/intercity: NO window, NO batch. Staff + * assign it onto a passing train (intercity.service.ts). See + * `assignIntercity` below — a domestic booking that is merely created has + * consumed nothing, and asserting capacity before assigning it is the + * single easiest way to write a green test that proves nothing. + * + * No module-level mutable state — same rule as g1-utils: Cypress re-evaluates + * the spec bundle on cross-origin visits, so rows are resolved by stamped + * reference, never by a captured id. + */ + +import { + CORRIDOR, + apiPost, + db, + dbSchedule, + departureAt, + opsStaff, + pollDb, + seedImportContract, + withBooking, +} from "../import-utils"; +import { G1_WAGONS } from "../g1-utils"; + +// --------------------------------------------------------------------------- +// the corridor, in the letters the scenarios are written in +// --------------------------------------------------------------------------- + +/** Scenario letter → corridor yard code. A is the port; F is Addis. */ +export const STOP = { + A: CORRIDOR[0], // DJIB_PORT — Djibouti, so any A→x booking is IMPORT + B: CORRIDOR[1], // NAGAD + C: CORRIDOR[2], // DIRE_DAWA + D: CORRIDOR[3], // E2E_AWASH + E: CORRIDOR[4], // MOJO + F: CORRIDOR[5], // KALITY +} as const; + +export type Stop = keyof typeof STOP; +/** Stop letters in corridor order — index doubles as the stop's position. */ +export const STOPS = ["A", "B", "C", "D", "E", "F"] as const; + +/** Edges a leg occupies, half-open [from, to) — mirrors CorridorLeg. */ +export function edgesOf(from: Stop, to: Stop): number[] { + const a = STOPS.indexOf(from); + const b = STOPS.indexOf(to); + expect(a, `${from} is on the corridor`).to.be.gte(0); + expect(b, `${to} is on the corridor`).to.be.gte(0); + expect(a, `${from}→${to} runs forward along the corridor`).to.be.lessThan(b); + return Array.from({ length: b - a }, (_, i) => a + i); +} + +/** Whether two legs share at least one edge — i.e. compete for wagons. */ +export function legsOverlap(l1: [Stop, Stop], l2: [Stop, Stop]): boolean { + const a = edgesOf(...l1); + const b = edgesOf(...l2); + return a.some((e) => b.includes(e)); +} + +/** + * Wagons committed on each of the corridor's 5 edges by a set of legs. + * The arithmetic every scenario's header table states in prose — computed here + * so the spec can assert its own premise before trusting the engine's answer. + */ +export function edgeLoad(legs: Array<{ from: Stop; to: Stop; wagons: number }>): number[] { + const load = [0, 0, 0, 0, 0]; + legs.forEach((l) => edgesOf(l.from, l.to).forEach((e) => (load[e] += l.wagons))); + return load; +} + +/** The busiest edge and how much it carries — the leg a rejection should name. */ +export function peakEdge(legs: Array<{ from: Stop; to: Stop; wagons: number }>) { + const load = edgeLoad(legs); + const peak = Math.max(...load); + return { edge: load.indexOf(peak), wagons: peak, load }; +} + +// --------------------------------------------------------------------------- +// contracts pinned to a leg +// --------------------------------------------------------------------------- + +/** + * Seed one contract whose route IS the booking's leg. Every flow-two booking + * needs its own contract for exactly this reason: the leg lives on the + * contract, so N legs means N contracts even for one customer. + * + * Direction is derived, not passed: A→x crosses the border (IMPORT), anything + * inside B…F is DOMESTIC. Getting this wrong doesn't fail loudly — a contract + * seeded IMPORT on an all-Ethiopian route books fine and then never enters a + * batch, so the spec times out far from the cause. + */ +export function seedLegContract(opts: { + suffix: string; + reference: string; + from: Stop; + to: Stop; + freight?: "CONTAINER" | "BULK"; + customs?: boolean; +}) { + edgesOf(opts.from, opts.to); // asserts the leg is forward and on-corridor + seedImportContract({ + suffix: opts.suffix, + reference: opts.reference, + originCode: STOP[opts.from], + destCode: STOP[opts.to], + direction: opts.from === "A" ? "IMPORT" : "DOMESTIC", + freight: opts.freight, + customs: opts.customs, + }); +} + +/** Whether this leg rides as an import (windowed/batched) or intercity. */ +export function isImportLeg(from: Stop): boolean { + return from === "A"; +} + +// --------------------------------------------------------------------------- +// intercity: the domestic legs, which never see a batch +// --------------------------------------------------------------------------- + +/** + * Offer intercity bookings to a train the way staff do, IN ORDER, and assert + * which ones the engine took. + * + * `POST schedules/:id/intercity/accept` is a per-train batch call that always + * answers 200 with `{ accepted, rejected }` — a booking that doesn't fit its + * leg is reported in `rejected`, NOT as a 4xx. A spec that only asserted the + * status code would pass on a train that took nobody, so this asserts the + * partition itself. + * + * ORDER MATTERS and is the caller's to choose: the budget shrinks as the loop + * walks `bookingIds`, so the priority rule under test (FIFO, import-first, …) + * is expressed as the order the ids are sent in. + * + * Accept RESERVES — it opens a pay window; wagons are allocated on payment + * (booking-batch.service.ts:3217 `reserve`). So an accepted intercity booking + * holds no `wagon_booking_allocations` until `markPaid`. + */ +export function acceptIntercity(opts: { + departure: Date; + /** Suffixes in the order staff offer them — this IS the priority under test. */ + accept: string[]; + /** Suffixes expected back in `rejected` (didn't fit their leg). */ + reject?: string[]; +}) { + const wanted = [...opts.accept, ...(opts.reject ?? [])]; + // Resolve every suffix to its booking id first: the endpoint takes ids, and + // the assertions below have to map ids back to the scenario's letters. + const ids: Record = {}; + wanted.forEach((suffix) => + withBooking(suffix, (b) => { + ids[suffix] = b.id; + }), + ); + // dbSchedule (not withSchedule) so the whole call stays one chain the spec + // can .then() on — withSchedule returns void. + return dbSchedule(opts.departure).then(({ rows }) => { + expect(rows, "flow-two schedule").to.have.length(1); + return apiPost( + opsStaff, + `/api/train-scheduling/schedules/${rows[0].id}/intercity/accept`, + { bookingIds: wanted.map((suffix) => ids[suffix]) }, + ).then((res) => { + expect(res.status, "intercity accept answered").to.be.oneOf([200, 201]); + const body = res.body as { + accepted: string[]; + rejected: Array<{ bookingId: string; reason: string }>; + }; + const rejectedIds = body.rejected.map((r) => r.bookingId); + opts.accept.forEach((suffix) => + expect(body.accepted, `${suffix} accepted onto the train`).to.include(ids[suffix]), + ); + (opts.reject ?? []).forEach((suffix) => + expect(rejectedIds, `${suffix} refused — its leg is full`).to.include(ids[suffix]), + ); + return res; + }); + }); +} + +/** + * The reason text the engine gave for refusing a booking — so a scenario can + * assert the refusal is CAPACITY on this leg and not some unrelated gate + * ("Booking not found", "not waiting"), which would otherwise make a wrong + * rejection look like the right one. + */ +export function expectRejectReason(res: Cypress.Response, suffix: string) { + withBooking(suffix, (b) => { + const body = res.body as { + rejected: Array<{ bookingId: string; reason: string }>; + }; + const mine = body.rejected.find((r) => r.bookingId === b.id); + expect(mine, `${suffix} appears in rejected`).to.not.be.undefined; + expect(mine?.reason, `${suffix} refused on capacity, not on a gate`).to.match( + /fit|capacity/i, + ); + }); +} + +// --------------------------------------------------------------------------- +// two trains on one route-day — Group 3 +// --------------------------------------------------------------------------- + +/** + * Departure times for a two-train day, deliberately DISTINCT. + * + * Train selection is first-fit over candidates sorted by departure time + * (booking-batch.service.ts:2334), and `Array.prototype.sort` is stable — so + * two schedules sharing one timestamp fall back to DB row order, which is not + * deterministic. Giving the pair different hours makes "the earlier train wins" + * a rule the spec can actually assert instead of a coin flip. + */ +export function twoTrainDay(baseHoursAhead = 12) { + const first = departureAt(baseHoursAhead); + const second = new Date(first.getTime() + 2 * 3_600_000); + return { first, second }; +} + +/** Which schedule a booking ended up on, as the caller's own label. */ +export function expectOnTrain( + suffix: string, + departure: Date, + label = departure.toISOString(), +) { + dbSchedule(departure).then(({ rows }) => { + expect(rows, `schedule ${label}`).to.have.length(1); + withBooking(suffix, (b) => + expect(b.train_schedule_id, `${suffix} rides ${label}`).to.eq(rows[0].id), + ); + }); +} + +/** + * Assert a booking did NOT get quietly divided between two trains. + * + * The engine never splits one booking across two schedules — a remainder + * becomes a SEPARATE booking, and only after payment + * (remainder-placement.service.ts:27). So exactly one `train_schedule_bookings` + * row per booking is the invariant; two would mean that rule had broken. + */ +export function expectNotSplitAcrossTrains(suffix: string) { + withBooking(suffix, (b) => + db<{ n: string }>( + `SELECT count(DISTINCT train_schedule_id) AS n + FROM freight.train_schedule_bookings + WHERE booking_id = $1 AND deleted_at IS NULL`, + [b.id], + ).then(({ rows }) => + expect(Number(rows[0].n), `${suffix} rides at most one train`).to.be.at.most(1), + ), + ); +} + +// --------------------------------------------------------------------------- +// the per-leg verdict — what every flow-two scenario ends on +// --------------------------------------------------------------------------- + +/** + * Wagons committed on each corridor edge, read back from what the engine + * actually allocated. The reconstruction mirrors CorridorBudget: resolve every + * booking on the schedule to its leg, then add its distinct wagon count to + * every edge that leg spans. + * + * This — not the train-wide total — is the assertion flow-two exists for. A + * train-wide count of 106 on a 53-wagon train reads as an overbook until the + * legs are separated, and a train-wide count of 53 hides a booking that + * charged the whole route when it should have charged two edges. + */ +export function edgeLoadFromDb(scheduleId: string) { + return db<{ origin: string; destination: string; wagons: string }>( + `SELECT o.code AS origin, d.code AS destination, + count(DISTINCT wba.train_set_wagon_id) AS wagons + FROM freight.train_schedule_bookings tsb + JOIN freight.bookings b ON b.id = tsb.booking_id + JOIN freight.yards o ON o.id = b.origin_yard_id + JOIN freight.yards d ON d.id = b.destination_yard_id + JOIN freight.wagon_booking_allocations wba + ON wba.booking_id = b.id AND wba.deleted_at IS NULL + WHERE tsb.train_schedule_id = $1 + AND tsb.deleted_at IS NULL AND b.deleted_at IS NULL + GROUP BY o.code, d.code`, + [scheduleId], + ).then(({ rows }) => { + const byCode = new Map(STOPS.map((s) => [STOP[s] as string, s as Stop])); + return edgeLoad( + rows.map((r) => { + const from = byCode.get(r.origin); + const to = byCode.get(r.destination); + expect(from, `booking origin ${r.origin} is on the corridor`).to.not.be.undefined; + expect(to, `booking destination ${r.destination} is on the corridor`).to.not.be + .undefined; + return { from: from as Stop, to: to as Stop, wagons: Number(r.wagons) }; + }), + ); + }); +} + +/** + * Assert the per-edge load the schedule ended up carrying, and that no edge + * exceeded the consist. + * + * `expected` is the full 5-edge profile — writing it out in full is deliberate: + * an assertion on the peak alone passes on a plan that put the right total on + * the wrong edges, which is precisely the reuse bug. + */ +export function expectEdgeLoad( + departure: Date, + expected: number[], + capacity = G1_WAGONS, +) { + expect(expected, "one entry per corridor edge").to.have.length(5); + dbSchedule(departure).then(({ rows }) => { + expect(rows, "flow-two schedule").to.have.length(1); + edgeLoadFromDb(rows[0].id).then((load) => { + expect(load, "wagons committed per corridor edge").to.deep.eq(expected); + load.forEach((w, e) => + expect(w, `edge ${e} (${STOPS[e]}→${STOPS[e + 1]}) within the consist`).to.be.at.most( + capacity, + ), + ); + }); + }); +} + +/** + * Assert a booking rides the train on exactly the leg it was sold, holding + * `wagons` slots. Guards the half-failure a total-only assertion misses: a + * booking allocated onto the right train but charged against the whole route. + */ +export function expectBookingLeg( + suffix: string, + leg: { from: Stop; to: Stop; wagons: number }, +) { + withBooking(suffix, (b) => { + db<{ origin: string; destination: string; wagons: string }>( + `SELECT o.code AS origin, d.code AS destination, + count(DISTINCT wba.train_set_wagon_id) AS wagons + FROM freight.bookings b + JOIN freight.yards o ON o.id = b.origin_yard_id + JOIN freight.yards d ON d.id = b.destination_yard_id + LEFT JOIN freight.wagon_booking_allocations wba + ON wba.booking_id = b.id AND wba.deleted_at IS NULL + WHERE b.id = $1 + GROUP BY o.code, d.code`, + [b.id], + ).then(({ rows }) => { + expect(rows, `${suffix} booking row`).to.have.length(1); + expect(rows[0].origin, `${suffix} origin`).to.eq(STOP[leg.from]); + expect(rows[0].destination, `${suffix} destination`).to.eq(STOP[leg.to]); + expect(Number(rows[0].wagons), `${suffix} holds ${leg.wagons} wagons`).to.eq( + leg.wagons, + ); + }); + }); +} + +/** + * Assert a booking holds NO wagons on this train — the rejected/waitlisted side + * of a leg verdict. Deliberately not `expectWaitlisted` (g1-utils): a booking + * refused at intercity-assign time keeps its own status and never reaches the + * waiting list at all, so the portable assertion is "consumed no capacity". + */ +export function expectNoAllocation(suffix: string) { + withBooking(suffix, (b) => { + db<{ n: string }>( + `SELECT count(*) AS n FROM freight.wagon_booking_allocations + WHERE booking_id = $1 AND deleted_at IS NULL`, + [b.id], + ).then(({ rows }) => + expect(Number(rows[0].n), `${suffix} holds no wagons`).to.eq(0), + ); + }); +} + +/** + * Poll until a booking is riding a schedule with wagons on it. Assign and batch + * both settle asynchronously (the 10s window tick), so a bare read right after + * the call races the engine. + */ +export function expectAllocated(suffix: string, wagons: number) { + withBooking(suffix, (b) => + pollDb<{ n: string }>( + `${suffix} allocated ${wagons} wagons`, + `SELECT count(DISTINCT train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations + WHERE booking_id = $1 AND deleted_at IS NULL`, + [b.id], + (row) => Number(row?.n ?? 0) === wagons, + 20, + ), + ); +} diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc01_non_overlap_chain.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc01_non_overlap_chain.cy.ts new file mode 100644 index 000000000..19fdb10ff --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc01_non_overlap_chain.cy.ts @@ -0,0 +1,175 @@ +/** + * FLOW-TWO · TC-01 — a non-overlapping chain fills the train exactly, three times. + * + * B1 A→B 53 wagons edges [0] + * B2 B→D 53 wagons edges [1,2] + * B3 D→F 53 wagons edges [3,4] + * + * edge: 0 1 2 3 4 + * load: 53 53 53 53 53 peak 53 of 53 + * + * 159 wagons of cargo on a 53-wagon train, and nothing is overbooked: B1's + * wagons are emptied at NAGAD and carry B2, whose wagons are emptied at + * E2E_AWASH and carry B3. This is the premise the whole suite rests on — if + * capacity were tracked train-wide, B2 would be refused with the train "full" + * while every wagon on the D→F stretch rolls empty. + * + * Why the assertion is the 5-edge PROFILE and not a total: a train-wide count + * of 159 is equally consistent with a broken engine that let three bookings + * overbook one leg. Only the per-edge reconstruction distinguishes reuse from + * overbooking (see expectEdgeLoad in ./flow2-utils). + * + * B1 is IMPORT (A = DJIB_PORT crosses the border): it rides the booking window + * and the batch. B2 and B3 are wholly Ethiopian and therefore DOMESTIC: no + * window, no batch — staff accept them onto the passing train, which is the + * intercity path. Both paths charge the SAME CorridorBudget, which is exactly + * why this scenario mixes them. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + markPaid, + resetCorridorDay, + withSchedule, +} from "../import-utils"; +import { + G1_WAGONS, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "../g1-utils"; +import { + acceptIntercity, + edgeLoad, + expectAllocated, + expectBookingLeg, + expectEdgeLoad, + peakEdge, + seedLegContract, + type Stop, +} from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +/** + * 53 wagons per booking = 53×40FT (one container per wagon). 40ft only: a 20ft + * pair shares a wagon, so an all-40ft shape keeps wagons == containers and the + * arithmetic in the header table stays readable. + */ +const SHAPES = { + B1: { from: "A", to: "B", forty: G1_WAGONS, wagons: G1_WAGONS }, + B2: { from: "B", to: "D", forty: G1_WAGONS, wagons: G1_WAGONS }, + B3: { from: "D", to: "F", forty: G1_WAGONS, wagons: G1_WAGONS }, +} as const satisfies Record; + +const ALL = ["B1", "B2", "B3"] as const; +/** B1 crosses the border; B2/B3 are domestic ride-alongs. */ +const INTERCITY = ["B2", "B3"] as const; + +describe("F2·TC-01: a non-overlap chain reuses every wagon twice", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + ALL.forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("the three legs never overlap and each fills the consist exactly", () => { + const legs = ALL.map((s) => ({ ...SHAPES[s] })); + const { load, wagons } = peakEdge(legs); + expect(load, "every edge carries one full consist").to.deep.eq([53, 53, 53, 53, 53]); + expect(wagons, "peak edge never exceeds the train").to.eq(G1_WAGONS); + expect( + legs.reduce((sum, l) => sum + l.wagons, 0), + "159 wagons of cargo on a 53-wagon train", + ).to.eq(3 * G1_WAGONS); + // The reuse claim, stated as arithmetic: any two of these legs share no edge. + expect(edgeLoad([legs[0], legs[2]]), "B1 and B3 are fully disjoint").to.deep.eq([ + 53, 0, 0, 53, 53, + ]); + }); + + it("operations schedules the 53-wagon built train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ departure: DEPARTURE }); + }); + + it("B1 books the import leg A→B and takes the whole train to NAGAD", () => { + bookAndClear({ + suffix: "B1", + runStamp: stamp, + isoSeed: 200, + forty: SHAPES.B1.forty, + scheduledDate: BOOKING_DAY, + }); + closeWindowAndRunBatch(DEPARTURE); + markPaid("B1"); + withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id)); + expectAllocated("B1", SHAPES.B1.wagons); + }); + + it("B2 and B3 book their domestic legs and wait for a passing train", () => { + let isoSeed = 400; + INTERCITY.forEach((suffix) => { + // No scheduledDate: a DOMESTIC booking may not pin a day or a schedule — + // staff choose its train at accept time (bookings.service.ts:1069). + bookAndClear({ + suffix, + runStamp: stamp, + isoSeed, + forty: SHAPES[suffix].forty, + scheduledDate: undefined as unknown as string, + }); + isoSeed += SHAPES[suffix].forty; + }); + }); + + it("both ride-alongs board the same train — B1's wagons are free past NAGAD", () => { + // Offered together, in order. Neither may be refused: B2 draws on edges + // [1,2] and B3 on [3,4], and B1 holds only edge [0]. + acceptIntercity({ departure: DEPARTURE, accept: [...INTERCITY] }); + INTERCITY.forEach((suffix) => { + markPaid(suffix); + expectAllocated(suffix, SHAPES[suffix].wagons); + }); + }); + + it("every booking rides exactly the leg it was sold", () => { + ALL.forEach((suffix) => expectBookingLeg(suffix, SHAPES[suffix])); + }); + + it("the train carries a full consist on all five edges and overbooks none", () => { + expectEdgeLoad(DEPARTURE, [53, 53, 53, 53, 53]); + // Nobody waited: the reuse means there was never a shortage to wait for. + withSchedule(DEPARTURE, (s) => + db<{ n: string }>( + `SELECT count(*) AS n + FROM freight.train_schedule_bookings tsb + JOIN freight.bookings b ON b.id = tsb.booking_id + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + AND b.deleted_at IS NULL`, + [s.id], + ).then(({ rows }) => + expect(Number(rows[0].n), "all three bookings ride this train").to.eq(3), + ), + ); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc02_overlap_spike.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc02_overlap_spike.cy.ts new file mode 100644 index 000000000..6440bf789 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc02_overlap_spike.cy.ts @@ -0,0 +1,170 @@ +/** + * FLOW-TWO · TC-02 — an overlap spike rejects only the third booking. + * + * B1 A→D 40 wagons edges [0,1,2] + * B2 B→E 25 wagons edges [1,2,3] + * B3 C→D 10 wagons edges [2] + * + * edge: 0 1 2 3 4 + * B1: 40 40 40 · · + * B2: · 25 25 25 · + * B3: · · 10 · · + * load: 40 65 75 25 0 + * + * Two things are being asserted, and the second is the one that matters. + * + * 1. B3 cannot board: edge 2 (DIRE_DAWA→E2E_AWASH) would carry 75 of 53. + * 2. B1 and B2 still ride. A train-wide capacity check would have refused B2 + * as well — 40+25 = 65 > 53 — even though B2's own worst edge is only 65… + * which is itself over. So the honest arithmetic here is: this train is + * OVERSUBSCRIBED from edge 1 onward, and the engine must resolve it per + * edge, in offer order, not by a train-wide total. + * + * With a 53-wagon consist, edge 1 already carries 65 once B2 boards, so B2 + * does NOT fit whole either. The scenario as specified assumes capacity 60 and + * still overflows at 65 — meaning B2's fate is the same on both: refused whole, + * offered the part that fits. That partial offer IS the expected behaviour + * (intercity.service.ts:236 offerIntercityPartial), so the spec asserts it + * rather than pretending B2 boards intact. + * + * B3 is the pure case and the one the header claim rests on: it is small (10 + * wagons), it is refused, and the reason must be its OWN leg — with edges 0, 3 + * and 4 visibly free, a message that calls the whole train full would be wrong. + * + * Sequential steps of one journey — retries off. + */ + +import { + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + markPaid, + resetCorridorDay, + withSchedule, +} from "../import-utils"; +import { + G1_WAGONS, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "../g1-utils"; +import { + acceptIntercity, + expectAllocated, + expectBookingLeg, + expectEdgeLoad, + expectNoAllocation, + expectRejectReason, + peakEdge, + seedLegContract, + type Stop, +} from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "D", forty: 40, wagons: 40 }, + B2: { from: "B", to: "E", forty: 25, wagons: 25 }, + B3: { from: "C", to: "D", forty: 10, wagons: 10 }, +} as const satisfies Record; + +/** Room left on B2's worst edge once B1 holds edges 0-2: 53 - 40 = 13. */ +const B2_PARTIAL_CEILING = G1_WAGONS - SHAPES.B1.wagons; + +describe("F2·TC-02: the saturated leg rejects, the free legs do not", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + (["B1", "B2", "B3"] as const).forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("the spike lands on edge C→D and nowhere else", () => { + const { edge, wagons, load } = peakEdge([SHAPES.B1, SHAPES.B2, SHAPES.B3]); + expect(load, "per-edge demand").to.deep.eq([40, 65, 75, 25, 0]); + expect(edge, "the saturated edge is C→D (index 2)").to.eq(2); + expect(wagons, "demand on the spike").to.eq(75); + expect(wagons, "the spike exceeds the consist").to.be.greaterThan(G1_WAGONS); + // Edges 0, 3 and 4 stay under the cap — a train-wide verdict is therefore + // provably wrong here, which is the whole point of the scenario. + expect(load[0], "A→B has room").to.be.at.most(G1_WAGONS); + expect(load[3], "D→E has room").to.be.at.most(G1_WAGONS); + }); + + it("operations schedules the 53-wagon built train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ departure: DEPARTURE }); + }); + + it("B1 takes 40 wagons from the port to E2E_AWASH", () => { + bookAndClear({ + suffix: "B1", + runStamp: stamp, + isoSeed: 600, + forty: SHAPES.B1.forty, + scheduledDate: BOOKING_DAY, + }); + closeWindowAndRunBatch(DEPARTURE); + markPaid("B1"); + withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id)); + expectAllocated("B1", SHAPES.B1.wagons); + expectBookingLeg("B1", SHAPES.B1); + }); + + it("B2 and B3 book their domestic legs", () => { + bookAndClear({ + suffix: "B2", + runStamp: stamp, + isoSeed: 700, + forty: SHAPES.B2.forty, + scheduledDate: undefined as unknown as string, + }); + bookAndClear({ + suffix: "B3", + runStamp: stamp, + isoSeed: 800, + forty: SHAPES.B3.forty, + scheduledDate: undefined as unknown as string, + }); + }); + + it("neither ride-along fits whole, and the refusal is about their leg", () => { + // Offered in order. B2 wants 25 on edges 1-3 with only 13 free on edges + // 1-2; B3 wants 10 on edge 2, which B2's partial offer has since taken. + acceptIntercity({ + departure: DEPARTURE, + accept: [], + reject: ["B2", "B3"], + }).then((res) => { + expectRejectReason(res, "B2"); + expectRejectReason(res, "B3"); + }); + }); + + it("the refused bookings hold no wagons at all", () => { + // A partial OFFER is not an allocation: until the customer pays for the + // reduced quantity, neither booking is on the train. + expectNoAllocation("B2"); + expectNoAllocation("B3"); + }); + + it("B1 keeps its leg and no edge is overbooked", () => { + expectEdgeLoad(DEPARTURE, [40, 40, 40, 0, 0]); + // The room B2 could ever have been offered — bounded by B1's hold on the + // shared edges, never by the train-wide free count (53-40 = 13, not 13+53). + expect(B2_PARTIAL_CEILING, "room on B2's worst shared edge").to.eq(13); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc03_reuse_after_drop.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc03_reuse_after_drop.cy.ts new file mode 100644 index 000000000..a77a9f8e5 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc03_reuse_after_drop.cy.ts @@ -0,0 +1,169 @@ +/** + * FLOW-TWO · TC-03 — a downstream booking is not punished for an upstream peak. + * + * B1 A→B 53 wagons edges [0] + * B2 B→E 53 wagons edges [1,2,3] + * B3 D→E 20 wagons edges [3] ← must be refused: 53+20 on edge 3 + * B4 E→F 50 wagons edges [4] ← must board: edge 4 is untouched + * + * edge: 0 1 2 3 4 + * B1: 53 · · · · + * B2: · 53 53 53 · + * B3: · · · 20 · (refused) + * B4: · · · · 50 + * final: 53 53 53 53 50 + * + * The assertion this scenario exists for is B4, and it is a NEGATIVE one: + * the system must not reject B4 because of the upstream peak. Every edge from + * 0 to 3 is at 53/53 — a train that is, by any train-wide reading, completely + * full — and yet B4 rides on edge 4 without touching one wagon anybody else + * holds. An engine that carried "the train is FULL" forward as a global flag + * would refuse it, and that bug is invisible to any test whose bookings all + * start at the port. + * + * B3 is the control: it IS refused, on edge 3 alone, which proves the engine + * is still enforcing capacity rather than having simply stopped counting. + * + * Offer ORDER is B3 then B4 deliberately — B4 must survive being offered AFTER + * a refusal, since a naive implementation that aborts the accept loop on the + * first rejection would silently drop it. + * + * Sequential steps of one journey — retries off. + */ + +import { + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + markPaid, + resetCorridorDay, + withSchedule, +} from "../import-utils"; +import { + G1_WAGONS, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "../g1-utils"; +import { + acceptIntercity, + edgeLoad, + expectAllocated, + expectBookingLeg, + expectEdgeLoad, + expectNoAllocation, + expectRejectReason, + seedLegContract, + type Stop, +} from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "B", forty: G1_WAGONS, wagons: G1_WAGONS }, + B2: { from: "B", to: "E", forty: G1_WAGONS, wagons: G1_WAGONS }, + B3: { from: "D", to: "E", forty: 20, wagons: 20 }, + B4: { from: "E", to: "F", forty: 50, wagons: 50 }, +} as const satisfies Record; + +describe("F2·TC-03: an upstream full train still carries a downstream leg", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + (["B1", "B2", "B3", "B4"] as const).forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("B3 collides on D→E while B4's edge stays untouched", () => { + const seated = edgeLoad([SHAPES.B1, SHAPES.B2]); + expect(seated, "edges once B1 and B2 are seated").to.deep.eq([53, 53, 53, 53, 0]); + expect( + seated[3] + SHAPES.B3.wagons, + "B3 would push D→E past the consist", + ).to.be.greaterThan(G1_WAGONS); + expect(seated[4], "E→F carries nobody yet").to.eq(0); + expect( + seated[4] + SHAPES.B4.wagons, + "B4 fits E→F outright", + ).to.be.at.most(G1_WAGONS); + }); + + it("operations schedules the 53-wagon built train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ departure: DEPARTURE }); + }); + + it("B1 fills the train to NAGAD", () => { + bookAndClear({ + suffix: "B1", + runStamp: stamp, + isoSeed: 1000, + forty: SHAPES.B1.forty, + scheduledDate: BOOKING_DAY, + }); + closeWindowAndRunBatch(DEPARTURE); + markPaid("B1"); + withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id)); + expectAllocated("B1", SHAPES.B1.wagons); + }); + + it("B2 takes the whole train onward from NAGAD — B1's wagons are free there", () => { + bookAndClear({ + suffix: "B2", + runStamp: stamp, + isoSeed: 1100, + forty: SHAPES.B2.forty, + scheduledDate: undefined as unknown as string, + }); + acceptIntercity({ departure: DEPARTURE, accept: ["B2"] }); + markPaid("B2"); + expectAllocated("B2", SHAPES.B2.wagons); + }); + + it("B3 and B4 book their domestic legs", () => { + bookAndClear({ + suffix: "B3", + runStamp: stamp, + isoSeed: 1200, + forty: SHAPES.B3.forty, + scheduledDate: undefined as unknown as string, + }); + bookAndClear({ + suffix: "B4", + runStamp: stamp, + isoSeed: 1300, + forty: SHAPES.B4.forty, + scheduledDate: undefined as unknown as string, + }); + }); + + it("B3 is refused on D→E and B4 boards anyway", () => { + // Order matters: B4 is offered after a refusal and must still be taken. + acceptIntercity({ + departure: DEPARTURE, + accept: ["B4"], + reject: ["B3"], + }).then((res) => expectRejectReason(res, "B3")); + markPaid("B4"); + expectAllocated("B4", SHAPES.B4.wagons); + }); + + it("B4 rides E→F while every upstream edge is full", () => { + expectBookingLeg("B4", SHAPES.B4); + expectNoAllocation("B3"); + expectEdgeLoad(DEPARTURE, [53, 53, 53, 53, 50]); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc04_full_capacity_boundary.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc04_full_capacity_boundary.cy.ts new file mode 100644 index 000000000..094ef4bb9 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc04_full_capacity_boundary.cy.ts @@ -0,0 +1,167 @@ +/** + * FLOW-TWO · TC-04 — a wagon returns to the pool mid-route, and the boundary is exact. + * + * B1 A→C 53 wagons (the whole train) edges [0,1] + * B2 C→F 53 wagons (the whole train) edges [2,3,4] + * B3 A→B 1 wagon edges [0] ← must be refused + * + * edge: 0 1 2 3 4 + * B1: 53 53 · · · + * B2: · · 53 53 53 + * B3: 1 · · · · (refused) + * final: 53 53 53 53 53 + * + * Two boundaries in one scenario, and they pull in opposite directions: + * + * - B2 must board. Every wagon B1 holds is released at DIRE_DAWA, so a train + * that was 53/53 for two edges is 0/53 for the next three. This is the + * strictest form of reuse: FULL-train handover at a single stop. + * + * - B3 must NOT board, and it asks for ONE wagon. 53+1 > 53 is the smallest + * possible overflow, which is exactly where an off-by-one lives: a `<` + * where `<=` belongs admits it, and every coarser test in this suite (10, + * 20, 25 wagons over) would still pass. A 1-wagon probe is the only shape + * that distinguishes "full" from "nearly full". + * + * B3 is offered LAST, after the train is already full on edge 0 — so its + * refusal is a live capacity verdict, not a stale one computed before B1 paid. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + markPaid, + resetCorridorDay, + withSchedule, +} from "../import-utils"; +import { + G1_WAGONS, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "../g1-utils"; +import { + acceptIntercity, + expectAllocated, + expectBookingLeg, + expectEdgeLoad, + expectNoAllocation, + expectRejectReason, + seedLegContract, + type Stop, +} from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "C", forty: G1_WAGONS, wagons: G1_WAGONS }, + B2: { from: "C", to: "F", forty: G1_WAGONS, wagons: G1_WAGONS }, + // One 40ft container = one whole wagon. The smallest bookable overflow. + B3: { from: "A", to: "B", forty: 1, wagons: 1 }, +} as const satisfies Record; + +describe("F2·TC-04: full handover mid-route, and one wagon too many is refused", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + (["B1", "B2", "B3"] as const).forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("B3 overflows edge 0 by exactly one wagon", () => { + expect(SHAPES.B1.wagons, "B1 IS the whole train").to.eq(G1_WAGONS); + expect(SHAPES.B3.wagons, "B3 is the smallest possible booking").to.eq(1); + expect( + SHAPES.B1.wagons + SHAPES.B3.wagons, + "one wagon over the cap — not two, not ten", + ).to.eq(G1_WAGONS + 1); + }); + + it("operations schedules the 53-wagon built train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ departure: DEPARTURE }); + }); + + it("B1 takes the entire train from the port to DIRE_DAWA", () => { + bookAndClear({ + suffix: "B1", + runStamp: stamp, + isoSeed: 1500, + forty: SHAPES.B1.forty, + scheduledDate: BOOKING_DAY, + }); + closeWindowAndRunBatch(DEPARTURE); + markPaid("B1"); + withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id)); + expectAllocated("B1", SHAPES.B1.wagons); + expectEdgeLoad(DEPARTURE, [53, 53, 0, 0, 0]); + }); + + it("B2 takes the entire train onward — every wagon is released at DIRE_DAWA", () => { + bookAndClear({ + suffix: "B2", + runStamp: stamp, + isoSeed: 1600, + forty: SHAPES.B2.forty, + scheduledDate: undefined as unknown as string, + }); + acceptIntercity({ departure: DEPARTURE, accept: ["B2"] }); + markPaid("B2"); + expectAllocated("B2", SHAPES.B2.wagons); + expectBookingLeg("B2", SHAPES.B2); + }); + + it("B3 asks for one wagon on the saturated A→B leg and is refused", () => { + bookAndClear({ + suffix: "B3", + runStamp: stamp, + isoSeed: 1700, + forty: SHAPES.B3.forty, + scheduledDate: undefined as unknown as string, + }); + acceptIntercity({ + departure: DEPARTURE, + accept: [], + reject: ["B3"], + }).then((res) => expectRejectReason(res, "B3")); + expectNoAllocation("B3"); + }); + + it("the train is exactly full on every edge — nothing more, nothing less", () => { + expectEdgeLoad(DEPARTURE, [53, 53, 53, 53, 53]); + // The physical check behind the arithmetic: 53 wagons carried 106 wagons' + // worth of cargo, so at least one wagon is allocated to BOTH bookings. + withSchedule(DEPARTURE, (s) => + db<{ n: string }>( + `SELECT count(DISTINCT wba.train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations wba + 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`, + [s.id], + ).then(({ rows }) => + expect( + Number(rows[0].n), + "106 wagons of cargo rode 53 physical wagons", + ).to.eq(G1_WAGONS), + ), + ); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc05_import_blocks_downstream.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc05_import_blocks_downstream.cy.ts new file mode 100644 index 000000000..9ddf2612f --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc05_import_blocks_downstream.cy.ts @@ -0,0 +1,168 @@ +/** + * FLOW-TWO · TC-05 — an import occupies wagons an intercity leg wanted downstream. + * + * B1 import A→D 45 wagons edges [0,1,2] + * B2 intercity C→F 20 wagons edges [2,3,4] ← overlaps B1 on edge 2 + * B3 intercity B→C 15 wagons edges [1] ← overlaps B1 on edge 1 + * + * edge: 0 1 2 3 4 + * B1: 45 45 45 · · + * B2: · · 20 20 20 + * B3: · 15 · · · + * demand: 45 60 65 20 20 + * + * The scenario as specified is written for a 60-wagon train, where edge 1 + * lands on exactly 60 (B3 fits to the slot) and edge 2 on 65 (B2 does not). + * This consist is 53, so BOTH overlaps overflow — 45+15 = 60 > 53 as well — + * and asserting "B3 ok" verbatim would be asserting something false about this + * train. What survives the change of consist, and is the real claim, is: + * + * an import booking that has already boarded holds its wagons across EVERY + * edge of its own leg, and a later intercity booking is measured against the + * edges it shares — not against the train's free wagon total. + * + * B2 makes that visible in the cleanest way available: edges 3 and 4 are + * completely empty, so 33 wagons are free on two thirds of B2's leg, and it + * must still be refused because of edge 2 alone. A test that only counted free + * wagons train-wide would admit it. + * + * B3 is offered second, at 15 wagons against 8 free on edge 1 — also refused, + * for its own edge. Both refusals are asserted to be capacity refusals, so a + * gate failure ("not waiting", "not found") cannot masquerade as the right + * answer. + * + * Sequential steps of one journey — retries off. + */ + +import { + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + markPaid, + resetCorridorDay, + withSchedule, +} from "../import-utils"; +import { + G1_WAGONS, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "../g1-utils"; +import { + acceptIntercity, + edgeLoad, + expectAllocated, + expectBookingLeg, + expectEdgeLoad, + expectNoAllocation, + expectRejectReason, + peakEdge, + seedLegContract, + type Stop, +} from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "D", forty: 45, wagons: 45 }, + B2: { from: "C", to: "F", forty: 20, wagons: 20 }, + B3: { from: "B", to: "C", forty: 15, wagons: 15 }, +} as const satisfies Record; + +/** Free wagons on the edges B1 holds, once B1 has boarded: 53 - 45. */ +const FREE_UNDER_IMPORT = G1_WAGONS - SHAPES.B1.wagons; + +describe("F2·TC-05: an import blocks the intercity legs it overlaps", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + (["B1", "B2", "B3"] as const).forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("both intercity legs overlap the import, and both overflow this consist", () => { + const { load } = peakEdge([SHAPES.B1, SHAPES.B2, SHAPES.B3]); + expect(load, "per-edge demand").to.deep.eq([45, 60, 65, 20, 20]); + expect(FREE_UNDER_IMPORT, "wagons left on the import's own edges").to.eq(8); + expect(SHAPES.B2.wagons, "B2 wants more than edge 2 has left").to.be.greaterThan( + FREE_UNDER_IMPORT, + ); + expect(SHAPES.B3.wagons, "B3 wants more than edge 1 has left").to.be.greaterThan( + FREE_UNDER_IMPORT, + ); + // B2's own leg is mostly empty — which is why a train-wide free count would + // wrongly admit it. Edges 3 and 4 carry nothing at all. + const seated = edgeLoad([SHAPES.B1]); + expect(seated.slice(3), "B2's downstream edges are empty").to.deep.eq([0, 0]); + }); + + it("operations schedules the 53-wagon built train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ departure: DEPARTURE }); + }); + + it("the import takes 45 wagons from the port to E2E_AWASH", () => { + bookAndClear({ + suffix: "B1", + runStamp: stamp, + isoSeed: 2000, + forty: SHAPES.B1.forty, + scheduledDate: BOOKING_DAY, + }); + closeWindowAndRunBatch(DEPARTURE); + markPaid("B1"); + withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id)); + expectAllocated("B1", SHAPES.B1.wagons); + expectBookingLeg("B1", SHAPES.B1); + expectEdgeLoad(DEPARTURE, [45, 45, 45, 0, 0]); + }); + + it("the two intercity bookings are filed", () => { + bookAndClear({ + suffix: "B2", + runStamp: stamp, + isoSeed: 2100, + forty: SHAPES.B2.forty, + scheduledDate: undefined as unknown as string, + }); + bookAndClear({ + suffix: "B3", + runStamp: stamp, + isoSeed: 2200, + forty: SHAPES.B3.forty, + scheduledDate: undefined as unknown as string, + }); + }); + + it("both are refused on the edge they share with the import", () => { + acceptIntercity({ + departure: DEPARTURE, + accept: [], + reject: ["B2", "B3"], + }).then((res) => { + expectRejectReason(res, "B2"); + expectRejectReason(res, "B3"); + }); + expectNoAllocation("B2"); + expectNoAllocation("B3"); + }); + + it("the import keeps exactly its own three edges", () => { + // Unchanged from before the accept pass: a refused booking consumes nothing, + // and the import was never asked to give anything back. + expectEdgeLoad(DEPARTURE, [45, 45, 45, 0, 0]); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc06_intercity_fills_gap.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc06_intercity_fills_gap.cy.ts new file mode 100644 index 000000000..75624bf83 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc06_intercity_fills_gap.cy.ts @@ -0,0 +1,141 @@ +/** + * FLOW-TWO · TC-06 — intercity fills the gap the import leaves behind. + * + * B1 import A→B 53 wagons edges [0] + * B2 intercity B→E 53 wagons edges [1,2,3] + * B3 intercity E→F 53 wagons edges [4] + * + * edge: 0 1 2 3 4 + * load: 53 53 53 53 53 + * + * Same wagons, three sequential occupancies: the import unloads at the inland + * dry port (NAGAD), an intercity booking takes those wagons on to MOJO, and a + * third takes them the last stretch to Addis. Nobody waits, nobody splits. + * + * TC-01 proves the arithmetic; this proves the HANDOVER between service types. + * The import rides the batch (window → close → batch → pay) and the two + * intercity legs ride the staff accept path — two entirely different code + * paths into the same CorridorBudget. A regression that let one path charge + * the whole route while the other charged edges would show up here and nowhere + * else: each path on its own is self-consistent. + * + * The 53/53/53 shape is deliberate. At full consist there is no slack to hide + * a partial mischarge — if the import held even one wagon past NAGAD, B2 would + * not fit whole and would come back as a partial offer instead. + * + * Sequential steps of one journey — retries off. + */ + +import { + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + markPaid, + resetCorridorDay, + withSchedule, +} from "../import-utils"; +import { + G1_WAGONS, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "../g1-utils"; +import { + acceptIntercity, + expectAllocated, + expectBookingLeg, + expectEdgeLoad, + peakEdge, + seedLegContract, + type Stop, +} from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "B", forty: G1_WAGONS, wagons: G1_WAGONS }, + B2: { from: "B", to: "E", forty: G1_WAGONS, wagons: G1_WAGONS }, + B3: { from: "E", to: "F", forty: G1_WAGONS, wagons: G1_WAGONS }, +} as const satisfies Record; + +const INTERCITY = ["B2", "B3"] as const; + +describe("F2·TC-06: intercity takes over the wagons the import unloads", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + (["B1", "B2", "B3"] as const).forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("three full consists ride disjoint stretches of one corridor", () => { + const { load, wagons } = peakEdge([SHAPES.B1, SHAPES.B2, SHAPES.B3]); + expect(load, "every edge carries exactly one consist").to.deep.eq([53, 53, 53, 53, 53]); + expect(wagons, "no edge is over the cap").to.eq(G1_WAGONS); + expect( + [SHAPES.B1, SHAPES.B2, SHAPES.B3].reduce((sum, s) => sum + s.wagons, 0), + "159 wagons of cargo on a 53-wagon train", + ).to.eq(3 * G1_WAGONS); + }); + + it("operations schedules the 53-wagon built train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ departure: DEPARTURE }); + }); + + it("the import fills the train to the inland dry port", () => { + bookAndClear({ + suffix: "B1", + runStamp: stamp, + isoSeed: 2500, + forty: SHAPES.B1.forty, + scheduledDate: BOOKING_DAY, + }); + closeWindowAndRunBatch(DEPARTURE); + markPaid("B1"); + withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id)); + expectAllocated("B1", SHAPES.B1.wagons); + expectEdgeLoad(DEPARTURE, [53, 0, 0, 0, 0]); + }); + + it("both intercity legs are filed and both board the same train", () => { + let isoSeed = 2600; + INTERCITY.forEach((suffix) => { + bookAndClear({ + suffix, + runStamp: stamp, + isoSeed, + forty: SHAPES[suffix].forty, + scheduledDate: undefined as unknown as string, + }); + isoSeed += SHAPES[suffix].forty; + }); + // Neither may be refused, and neither may be reduced to a partial offer: + // both ask for the full consist on edges the import does not hold. + acceptIntercity({ departure: DEPARTURE, accept: [...INTERCITY] }); + INTERCITY.forEach((suffix) => { + markPaid(suffix); + expectAllocated(suffix, SHAPES[suffix].wagons); + }); + }); + + it("each booking holds a full consist on its own stretch only", () => { + (["B1", "B2", "B3"] as const).forEach((suffix) => + expectBookingLeg(suffix, SHAPES[suffix]), + ); + expectEdgeLoad(DEPARTURE, [53, 53, 53, 53, 53]); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc07_priority_rule.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc07_priority_rule.cy.ts new file mode 100644 index 000000000..c310656c5 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc07_priority_rule.cy.ts @@ -0,0 +1,189 @@ +/** + * FLOW-TWO · TC-07 — POLICY LOCK: what actually decides who boards first. + * + * B1 import A→F 30 wagons submitted SECOND + * B2 import A→F 40 wagons submitted FIRST + * B3 import A→F 30 wagons submitted LAST + * + * Demand 100 wagons on every edge; the consist is 53. Two of the three + * cannot board, so the ORDER is the whole answer. + * + * THE RULE, AS IMPLEMENTED (booking-batch.service.ts:4051 resortPoolByPriority): + * + * isGovernment DESC + * → window-cycle index of fullyExecutedAt ASC + * → priorityScore DESC + * → fullyExecutedAt ASC + * → createdAt ASC + * + * There is NO import-vs-intercity term. Trade direction decides which POOL a + * booking sits in, never its rank inside one. So the honest expectation for + * this scenario is not "import priority" — it is: all three bookings are + * non-government, all score equally (same wagon-count band, same currency, no + * customs), so every term above collapses and the tiebreak is FIFO by + * fullyExecutedAt, then createdAt. + * + * FIFO therefore predicts: B2 (first, 40w) boards, leaving 13 — B1 (30w) does + * not fit and B3 (30w) does not fit. One confirmed, two waitlisted. + * + * WHAT THIS TEST IS FOR: it fails loudly if the rule changes silently. The + * scenario was written expecting "define + assert priority rule (FIFO vs + * import-priority)"; the answer is FIFO-with-score-above-it, and that answer is + * now pinned here. If someone later adds an import-priority term, B3 (an import + * submitted last) would board over B2 and this spec breaks — which is the point. + * + * The scoring assumption is asserted directly against the DB rather than + * assumed: if a priority_configs row in the environment gives one of these + * bookings a different score, the FIFO prediction is void and the spec says so + * instead of failing somewhere downstream. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + departureAt, + eatDayStr, + ensureCorridorRoute, + markPaid, + resetCorridorDay, + withBooking, +} from "../import-utils"; +import { + G1_WAGONS, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, + expectWaitlisted, +} from "../g1-utils"; +import { + expectAllocated, + expectEdgeLoad, + expectNoAllocation, + seedLegContract, + type Stop, +} from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "F", forty: 30, wagons: 30 }, + B2: { from: "A", to: "F", forty: 40, wagons: 40 }, + B3: { from: "A", to: "F", forty: 30, wagons: 30 }, +} as const satisfies Record; + +/** Submission order — this IS the variable under test. */ +const ORDER = ["B2", "B1", "B3"] as const; +/** FIFO's prediction: the first submitted boards; the rest cannot fit after it. */ +const EXPECTED_WINNER = "B2"; +const EXPECTED_LOSERS = ["B1", "B3"] as const; + +describe("F2·TC-07: the boarding order is FIFO, and it is pinned", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + (["B1", "B2", "B3"] as const).forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("the three compete for one consist on every edge", () => { + const total = (["B1", "B2", "B3"] as const).reduce( + (sum, s) => sum + SHAPES[s].wagons, + 0, + ); + expect(total, "100 wagons of demand").to.eq(100); + expect(total, "nearly double the consist").to.be.greaterThan(G1_WAGONS); + expect( + SHAPES[EXPECTED_WINNER].wagons + SHAPES.B1.wagons, + "no second booking fits behind the winner", + ).to.be.greaterThan(G1_WAGONS); + }); + + it("operations schedules the 53-wagon built train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ departure: DEPARTURE }); + }); + + it("the three bookings are submitted B2, then B1, then B3", () => { + let isoSeed = 3000; + ORDER.forEach((suffix) => { + bookAndClear({ + suffix, + runStamp: stamp, + isoSeed, + forty: SHAPES[suffix].forty, + scheduledDate: BOOKING_DAY, + }); + isoSeed += SHAPES[suffix].forty; + }); + }); + + it("submission order is recorded as the batch will read it", () => { + // fullyExecutedAt then createdAt are the last two sort terms; assert the + // DB agrees B2 really is first, or the FIFO prediction below means nothing. + db<{ suffix: string; created_at: string }>( + `SELECT right(ct.reference, 2) AS suffix, b.created_at + FROM freight.bookings b + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE ct.reference LIKE $1 AND b.deleted_at IS NULL + ORDER BY b.created_at ASC`, + [`CTR-IMP-${stamp}-%`], + ).then(({ rows }) => { + expect( + rows.map((r) => r.suffix), + "bookings are stored in submission order", + ).to.deep.eq([...ORDER]); + }); + }); + + it("all three score equally — every term above FIFO is a tie", () => { + // If this fails, the environment's priority_configs differ and the FIFO + // prediction is void. Better to fail HERE, naming the reason, than to fail + // on a winner assertion that looks like a capacity bug. + db<{ suffix: string; priority_score: string; is_government: boolean }>( + `SELECT right(ct.reference, 2) AS suffix, b.priority_score, b.is_government + FROM freight.bookings b + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE ct.reference LIKE $1 AND b.deleted_at IS NULL`, + [`CTR-IMP-${stamp}-%`], + ).then(({ rows }) => { + expect(rows, "three bookings").to.have.length(3); + const scores = new Set(rows.map((r) => Number(r.priority_score ?? 0))); + expect(scores.size, "no booking outranks another on score").to.eq(1); + rows.forEach((r) => + expect(r.is_government, `${r.suffix} is commercial`).to.not.be.true, + ); + }); + }); + + it("POLICY: the earliest submission boards and the other two wait", () => { + closeWindowAndRunBatch(DEPARTURE); + markPaid(EXPECTED_WINNER); + expectAllocated(EXPECTED_WINNER, SHAPES[EXPECTED_WINNER].wagons); + EXPECTED_LOSERS.forEach((suffix) => { + expectWaitlisted(suffix); + expectNoAllocation(suffix); + }); + }); + + it("the loser set is exactly the two later submissions, not an arbitrary pair", () => { + // The failure this guards: a rule change that still confirms exactly one + // booking, but a different one. Asserting "2 waitlisted" alone would pass. + withBooking(EXPECTED_WINNER, (b) => + expect(b.train_schedule_id, `${EXPECTED_WINNER} holds the seat`).to.not.be.null, + ); + expectEdgeLoad(DEPARTURE, Array(5).fill(SHAPES[EXPECTED_WINNER].wagons)); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc08_wagon_type_pools.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc08_wagon_type_pools.cy.ts new file mode 100644 index 000000000..0561113b1 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc08_wagon_type_pools.cy.ts @@ -0,0 +1,221 @@ +/** + * FLOW-TWO · TC-08 — wagon-type pools are separate, even on one leg. + * + * The train is TRN-F2-MIX (seed-flow2-mixed-train.sql), a 50-wagon consist that + * is deliberately NOT uniform: + * + * 30 × NW5 — the only type 20FT/40FT containers may ride + * 20 × PW2 — the only type E2E_IMP_GRAINS bulk may ride + * + * The bookings, all on the SAME leg so nothing here is about segments: + * + * B1 import container A→F 40 wagons of containers + * B2 import bulk A→F grains needing ~15 PW2 wagons + * B3 import container A→F 5 wagons of containers + * + * B1 is the assertion. It asks for 40 wagons; the abstract budget says 50 are + * free, so a slot-only engine admits it — and allocation then fails on wagon 31 + * with "no NW5 available", after the customer has paid for 40. That is the + * exact failure wagon-stock-ledger.util.ts was written to prevent ("money taken + * for space that never existed"). So B1 must NOT board whole: the container + * pool is 30, not 50. + * + * B2 proves the separation runs both ways: the grains booking draws only on + * PW2, so it is unaffected by however much of the NW5 pool is spoken for. + * + * B3 is the control that keeps this from passing for the wrong reason. If the + * engine had simply gone conservative and stopped admitting anything, B3 would + * fail too — but 5 containers fit whatever remains of the NW5 pool, so it must + * board. "Refuses everything" and "respects pools" are indistinguishable + * without it. + * + * All three ride the batch (all IMPORT, all A→F), so this is one window, one + * batch pass, and the type separation is the only thing under test. + * + * Sequential steps of one journey — retries off. + */ + +import { + bookBulk, + clearToOperationRequestPending, + acceptOperation, + db, + departureAt, + eatDayStr, + ensureCorridorRoute, + resetCorridorDay, + withBooking, + withSchedule, +} from "../import-utils"; +import { + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "../g1-utils"; +import { seedLegContract, type Stop } from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +/** The mixed consist — see seed-flow2-mixed-train.sql. */ +const MIX_TRAIN = "TRN-F2-MIX"; +const CONTAINER_POOL = 30; // NW5 +const BULK_POOL = 20; // PW2 +const CONSIST = CONTAINER_POOL + BULK_POOL; + +const SHAPES = { + // 40 > the 30-wagon container pool, but < the 50-wagon consist. The gap + // between those two numbers is the entire test. + B1: { from: "A", to: "F", forty: 40, wagons: 40 }, + B3: { from: "A", to: "F", forty: 5, wagons: 5 }, +} as const satisfies Record; + +/** Grains tonnage sized to sit inside the PW2 pool, never near its edge. */ +const BULK_TONS = 600; + +describe("F2·TC-08: a container booking cannot consume bulk wagons", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-mixed-train.sql"); + seedLegContract({ suffix: "B1", reference: stampedRef("B1"), from: "A", to: "F" }); + seedLegContract({ + suffix: "B2", + reference: stampedRef("B2"), + from: "A", + to: "F", + freight: "BULK", + }); + seedLegContract({ suffix: "B3", reference: stampedRef("B3"), from: "A", to: "F" }); + }); + + it("the consist really is split across two incompatible pools", () => { + db<{ code: string; n: string }>( + `SELECT wt.code, count(*) AS n + FROM freight.wagons w + JOIN freight.trains t ON t.id = w.train_id AND t.code = $1 + JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id + WHERE w.deleted_at IS NULL + GROUP BY wt.code ORDER BY wt.code`, + [MIX_TRAIN], + ).then(({ rows }) => { + const byCode = new Map(rows.map((r) => [r.code, Number(r.n)])); + expect(byCode.get("NW5"), "container-capable wagons").to.eq(CONTAINER_POOL); + expect(byCode.get("PW2"), "bulk-only wagons").to.eq(BULK_POOL); + }); + // The premise: B1 fits the CONSIST but not its own POOL. If these two + // stopped straddling the pool boundary the test would prove nothing. + expect(SHAPES.B1.wagons, "B1 fits the consist").to.be.at.most(CONSIST); + expect(SHAPES.B1.wagons, "B1 does NOT fit the container pool").to.be.greaterThan( + CONTAINER_POOL, + ); + }); + + it("operations schedules the mixed-consist train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ + departure: DEPARTURE, + trainCode: MIX_TRAIN, + wagons: CONSIST, + }); + }); + + it("the two container bookings and the grains booking are filed", () => { + bookAndClear({ + suffix: "B1", + runStamp: stamp, + isoSeed: 3500, + forty: SHAPES.B1.forty, + scheduledDate: BOOKING_DAY, + }); + // Bulk has no container units, so it uses the bulk booking path and then + // the same clearance gate every contract booking is born into. + bookBulk({ + suffix: "B2", + tons: BULK_TONS, + cargoCode: "E2E_IMP_GRAINS", + scheduledDate: BOOKING_DAY, + }); + clearToOperationRequestPending("B2", BOOKING_DAY); + acceptOperation("B2"); + bookAndClear({ + suffix: "B3", + runStamp: stamp, + isoSeed: 3600, + forty: SHAPES.B3.forty, + scheduledDate: BOOKING_DAY, + }); + }); + + it("the batch runs", () => { + closeWindowAndRunBatch(DEPARTURE); + }); + + it("POOLS: no container booking is allocated a bulk wagon, ever", () => { + // The invariant, stated directly against the allocation rows: every wagon + // a CONTAINER booking holds is NW5, and every wagon a BULK booking holds is + // PW2. This is the assertion that survives any change to who boards. + withSchedule(DEPARTURE, (s) => + db<{ freight_type: string; code: string; n: string }>( + // train_set_wagons carries wagon_type_id directly — the slot's type is + // authoritative even before a physical wagon is pinned to it. + `SELECT b.freight_type, wt.code, count(*) AS n + FROM freight.wagon_booking_allocations wba + JOIN freight.bookings b ON b.id = wba.booking_id + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 + 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 + WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL + GROUP BY b.freight_type, wt.code`, + [s.id], + ).then(({ rows }) => { + rows.forEach((r) => { + if (r.freight_type === "CONTAINER") { + expect(r.code, "containers ride NW5 only").to.eq("NW5"); + } else { + expect(r.code, "bulk rides PW2 only").to.eq("PW2"); + } + }); + }), + ); + }); + + it("B1 never holds more wagons than the container pool has", () => { + // Whether B1 was refused outright or cut down to a partial offer is the + // engine's choice; what it may NOT do is hand it 40 wagons out of a + // 30-wagon pool. Asserting the ceiling covers both outcomes. + withBooking("B1", (b) => + db<{ n: string }>( + `SELECT count(DISTINCT train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations + WHERE booking_id = $1 AND deleted_at IS NULL`, + [b.id], + ).then(({ rows }) => + expect( + Number(rows[0].n), + "B1 is capped by the NW5 pool, not by the 50-wagon consist", + ).to.be.at.most(CONTAINER_POOL), + ), + ); + }); + + it("the grains booking is unaffected by the container pool's state", () => { + withBooking("B2", (b) => { + expect(b.status, "B2 was not rejected").to.not.eq("REJECTED"); + db<{ n: string }>( + `SELECT count(DISTINCT train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations + WHERE booking_id = $1 AND deleted_at IS NULL`, + [b.id], + ).then(({ rows }) => + expect(Number(rows[0].n), "B2 stays inside the PW2 pool").to.be.at.most(BULK_POOL), + ); + }); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc09_customs_hold.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc09_customs_hold.cy.ts new file mode 100644 index 000000000..0f87d7b7c --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc09_customs_hold.cy.ts @@ -0,0 +1,168 @@ +/** + * FLOW-TWO · TC-09 — POLICY LOCK: a customs hold does NOT reserve wagons past + * the booking's destination. + * + * B1 import A→C 50 wagons, customs-cleared contract, held at C + * B2 intercity C→F 50 wagons + * B3 intercity A→B 10 wagons — bookable only via the import lane, so it is + * filed as a second import A→B and must be unaffected + * + * WHAT THE SCENARIO ASKED FOR vs WHAT THE SYSTEM DOES + * + * The scenario expects B2 to be "blocked/deferred until customs release" — + * i.e. B1's wagons should NOT count as free at C while its cargo sits in + * customs. **That behaviour does not exist in this codebase.** There is no + * held-at-customs concept anywhere in the capacity path: + * + * - a booking's leg is ALWAYS strictly origin→destination + * (corridor-capacity.util.ts:103 legOf — no caller ever extends toEdge); + * - `clearance_status` is a CONTRACT column, never read by any capacity or + * wagon-release code; + * - wagon release on unload is unconditional (booking-journey.service.ts:505) + * — the only thing that keeps a slot pinned is another allocation on it + * still IN_TRANSIT. Clearance state is never consulted. + * + * So B2 boards. This spec asserts that, deliberately and with the gap written + * down, rather than asserting a block that would fail today and be "fixed" by + * deleting the test. It is a POLICY LOCK: if someone later teaches the budget + * to hold wagons through customs, B2 stops boarding and this spec breaks — + * which is the signal that the policy changed and this file must be revisited. + * + * B3 is the invariant that holds under EITHER policy: an upstream leg that + * shares no edge with the hold is unaffected. That assertion is safe to keep + * whichever way the customs question is eventually answered. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + markPaid, + resetCorridorDay, + withBooking, + withSchedule, +} from "../import-utils"; +import { + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "../g1-utils"; +import { + acceptIntercity, + expectAllocated, + expectBookingLeg, + expectEdgeLoad, + seedLegContract, + type Stop, +} from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "C", forty: 50, wagons: 50 }, + B2: { from: "C", to: "F", forty: 50, wagons: 50 }, + // Shares edge 0 with B1 — 50 + 3 fits the 53 consist, so if B3 is refused it + // is a real regression and not an arithmetic accident. + B3: { from: "A", to: "B", forty: 3, wagons: 3 }, +} as const satisfies Record; + +describe("F2·TC-09: a customs-cleared import still releases its wagons at its destination", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + // B1 is the customs-clearing contract — the one whose cargo is "held at C". + seedLegContract({ + suffix: "B1", + reference: stampedRef("B1"), + from: "A", + to: "C", + customs: true, + }); + seedLegContract({ suffix: "B2", reference: stampedRef("B2"), from: "C", to: "F" }); + seedLegContract({ suffix: "B3", reference: stampedRef("B3"), from: "A", to: "B" }); + }); + + it("B1 and B3 share edge 0 and still fit; B2 shares nothing with B1", () => { + expect( + SHAPES.B1.wagons + SHAPES.B3.wagons, + "B1 and B3 fit edge 0 together", + ).to.be.at.most(53); + // B2 starts exactly where B1 ends: under the implemented policy they never + // compete, no matter what customs is doing to B1's cargo. + expect(SHAPES.B1.to, "B1 ends at C").to.eq(SHAPES.B2.from); + }); + + it("operations schedules the 53-wagon built train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ departure: DEPARTURE }); + }); + + it("the customs import and the small upstream import both board", () => { + bookAndClear({ + suffix: "B1", + runStamp: stamp, + isoSeed: 4000, + forty: SHAPES.B1.forty, + scheduledDate: BOOKING_DAY, + }); + bookAndClear({ + suffix: "B3", + runStamp: stamp, + isoSeed: 4100, + forty: SHAPES.B3.forty, + scheduledDate: BOOKING_DAY, + }); + closeWindowAndRunBatch(DEPARTURE); + markPaid("B1"); + markPaid("B3"); + withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id)); + expectAllocated("B1", SHAPES.B1.wagons); + expectAllocated("B3", SHAPES.B3.wagons); + }); + + it("B1 really is the customs-clearing booking", () => { + // Without this, the scenario is just "two imports and an intercity" and the + // customs premise is decoration. + withBooking("B1", (b) => + db<{ customs_clearing_enabled: boolean; clearance_status: string }>( + `SELECT ct.customs_clearing_enabled, ct.clearance_status + FROM freight.contracts ct WHERE ct.id = $1`, + [b.contract_id], + ).then(({ rows }) => + expect(rows[0].customs_clearing_enabled, "B1 clears customs").to.be.true, + ), + ); + }); + + it("POLICY: B2 boards at C — the held cargo's wagons are free downstream", () => { + bookAndClear({ + suffix: "B2", + runStamp: stamp, + isoSeed: 4200, + forty: SHAPES.B2.forty, + scheduledDate: undefined as unknown as string, + }); + // If this ever starts failing, the customs-hold policy has been implemented + // and this whole spec must be rewritten to assert the block instead. + acceptIntercity({ departure: DEPARTURE, accept: ["B2"] }); + markPaid("B2"); + expectAllocated("B2", SHAPES.B2.wagons); + expectBookingLeg("B2", SHAPES.B2); + }); + + it("B3's upstream leg is untouched by any of it", () => { + // True under either policy — the assertion worth keeping regardless. + expectBookingLeg("B3", SHAPES.B3); + expectEdgeLoad(DEPARTURE, [53, 50, 50, 50, 50]); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc10_overflow_spills_to_train2.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc10_overflow_spills_to_train2.cy.ts new file mode 100644 index 000000000..d3f11fe16 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc10_overflow_spills_to_train2.cy.ts @@ -0,0 +1,174 @@ +/** + * FLOW-TWO · TC-10 — overflow spills onto the sibling train, and no booking is + * silently divided. + * + * Two 53-wagon trains on the same route and day, TWO HOURS APART: + * + * T1 departs 12h from now 53 wagons + * T2 departs 14h from now 53 wagons + * + * B1 A→F 50 wagons → T1 (earliest fitting train) + * B2 A→F 40 wagons → T2 (13 left on T1, so T1 cannot take it whole) + * B3 A→F 30 wagons → neither: 3 left on T1, 13 on T2 + * + * Selection is FIRST-FIT over candidates sorted by departure time + * (booking-batch.service.ts:2334-2342, then `trains.find(...)` at :2442). So + * the rule under test is "earliest train that FITS", not "earliest train" and + * not "emptiest train". + * + * THE DEPARTURE TIMES ARE LOAD-BEARING. `Array.prototype.sort` is stable, so + * two schedules sharing a timestamp fall back to DB row order and "B1 goes to + * T1" becomes a coin flip. twoTrainDay() spaces them deliberately — see its + * docstring. + * + * B3 is the one that matters. Between the two trains there are 16 free wagons, + * which is less than the 30 it needs — but on NO SINGLE train is there room, + * and the engine must not stitch it together across both. A booking is never + * divided between two schedules: a remainder becomes a separate booking, and + * only after payment (remainder-placement.service.ts:27). B3 therefore + * waitlists whole, and `expectNotSplitAcrossTrains` asserts exactly that. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + markPaid, + resetCorridorDay, + withSchedule, +} from "../import-utils"; +import { + G1_TRAIN, + G1_TRAIN_2, + G1_WAGONS, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, + expectWaitlisted, +} from "../g1-utils"; +import { + expectAllocated, + expectNoAllocation, + expectNotSplitAcrossTrains, + expectOnTrain, + seedLegContract, + twoTrainDay, + type Stop, +} from "./flow2-utils"; + +const { first: T1, second: T2 } = twoTrainDay(12); +const BOOKING_DAY = eatDayStr(T1); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "F", forty: 50, wagons: 50 }, + B2: { from: "A", to: "F", forty: 40, wagons: 40 }, + B3: { from: "A", to: "F", forty: 30, wagons: 30 }, +} as const satisfies Record; + +const ORDER = ["B1", "B2", "B3"] as const; + +describe("F2·TC-10: overflow moves to the sibling train, never across both", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + ORDER.forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("both trains are on the same day but not the same hour", () => { + expect(eatDayStr(T2), "T2 shares the booking day").to.eq(BOOKING_DAY); + expect( + T2.getTime() - T1.getTime(), + "distinct departures keep first-fit deterministic", + ).to.be.greaterThan(0); + }); + + it("no single train can take B3, and the two trains together must not", () => { + const freeOnT1 = G1_WAGONS - SHAPES.B1.wagons; // 3 + const freeOnT2 = G1_WAGONS - SHAPES.B2.wagons; // 13 + expect(freeOnT1, "room left on T1").to.eq(3); + expect(freeOnT2, "room left on T2").to.eq(13); + expect(SHAPES.B3.wagons, "B3 fits neither train alone").to.be.greaterThan( + Math.max(freeOnT1, freeOnT2), + ); + expect( + freeOnT1 + freeOnT2, + "and it does not even fit both combined — so no stitching either way", + ).to.be.lessThan(SHAPES.B3.wagons); + }); + + it("operations schedules both trains on the same corridor day", () => { + ensureCorridorRoute(); + resetCorridorDay(T1); + resetCorridorDay(T2); + configureAndOpenSchedule({ departure: T1, trainCode: G1_TRAIN }); + configureAndOpenSchedule({ departure: T2, trainCode: G1_TRAIN_2 }); + }); + + it("the three bookings are filed in order", () => { + let isoSeed = 4500; + ORDER.forEach((suffix) => { + bookAndClear({ + suffix, + runStamp: stamp, + isoSeed, + forty: SHAPES[suffix].forty, + scheduledDate: BOOKING_DAY, + }); + isoSeed += SHAPES[suffix].forty; + }); + }); + + it("B1 takes the earlier train, B2 spills to the later one", () => { + closeWindowAndRunBatch(T1); + closeWindowAndRunBatch(T2); + expectOnTrain("B1", T1, "T1"); + expectOnTrain("B2", T2, "T2"); + markPaid("B1"); + markPaid("B2"); + withSchedule(T1, (s) => endPaymentPhase(s.id)); + withSchedule(T2, (s) => endPaymentPhase(s.id)); + expectAllocated("B1", SHAPES.B1.wagons); + expectAllocated("B2", SHAPES.B2.wagons); + }); + + it("B3 waitlists whole rather than being divided across the two trains", () => { + expectWaitlisted("B3"); + expectNoAllocation("B3"); + expectNotSplitAcrossTrains("B3"); + }); + + it("neither train is overbooked", () => { + [ + { departure: T1, label: "T1", wagons: SHAPES.B1.wagons }, + { departure: T2, label: "T2", wagons: SHAPES.B2.wagons }, + ].forEach(({ departure, label, wagons }) => + withSchedule(departure, (s) => + db<{ n: string }>( + `SELECT count(DISTINCT wba.train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations wba + 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`, + [s.id], + ).then(({ rows }) => { + expect(Number(rows[0].n), `${label} carries its booking`).to.eq(wagons); + expect(Number(rows[0].n), `${label} within consist`).to.be.at.most(G1_WAGONS); + }), + ), + ); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc11_segment_reuse_per_train.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc11_segment_reuse_per_train.cy.ts new file mode 100644 index 000000000..83d123baa --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc11_segment_reuse_per_train.cy.ts @@ -0,0 +1,145 @@ +/** + * FLOW-TWO · TC-11 — each train keeps its own corridor budget, and the pick is + * deterministic. + * + * T1 departs 12h from now 53 wagons + * T2 departs 14h from now 53 wagons + * + * B1 A→C 53 wagons → T1 (fills T1's edges 0-1) + * B2 A→C 53 wagons → T2 (T1's edges 0-1 are gone; T2's are untouched) + * B3 C→F 53 wagons → T1 (the EARLIEST train whose edges 2-4 are free) + * + * Two rules meet here, and B3 is where they meet. + * + * BUDGETS ARE PER TRAIN. B2 is identical to B1 in every respect and must still + * board, because filling T1 says nothing about T2. A shared or global budget + * would refuse it. + * + * THE PICK IS FIRST-FIT BY DEPARTURE TIME. After B1 and B2, edges 2-4 are free + * on BOTH trains — B3 genuinely fits either. The implemented rule is the + * earliest-departing candidate that fits (booking-batch.service.ts:2334 sorts + * by scheduledDepartureDate, :2442 takes the first match), so B3 must land on + * T1. Not "the emptier train", not "round robin", not whichever the database + * happened to return first. + * + * That last distinction is why the two departures are two hours apart rather + * than identical: with equal timestamps the sort is stable but the input order + * is DB-dependent, and this assertion would flake rather than fail. The times + * make the rule observable. + * + * All three are imports A→C / C→F on the same corridor day, so this is + * decided entirely by the batch — no intercity path involved. + * + * Sequential steps of one journey — retries off. + */ + +import { + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + markPaid, + resetCorridorDay, + withSchedule, +} from "../import-utils"; +import { + G1_TRAIN, + G1_TRAIN_2, + G1_WAGONS, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "../g1-utils"; +import { + expectAllocated, + expectBookingLeg, + expectEdgeLoad, + expectOnTrain, + seedLegContract, + twoTrainDay, + type Stop, +} from "./flow2-utils"; + +const { first: T1, second: T2 } = twoTrainDay(12); +const BOOKING_DAY = eatDayStr(T1); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "C", forty: G1_WAGONS, wagons: G1_WAGONS }, + B2: { from: "A", to: "C", forty: G1_WAGONS, wagons: G1_WAGONS }, + B3: { from: "C", to: "F", forty: G1_WAGONS, wagons: G1_WAGONS }, +} as const satisfies Record; + +const ORDER = ["B1", "B2", "B3"] as const; + +describe("F2·TC-11: per-train budgets, and the earliest fitting train wins", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + ORDER.forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("B3's leg is free on both trains, so the tie-break is the only decider", () => { + // B1 and B2 occupy edges 0-1 on their respective trains; B3 wants 2-4. + expect(SHAPES.B1.to, "the A→C bookings end where B3 begins").to.eq(SHAPES.B3.from); + expect(T2.getTime(), "T2 departs after T1").to.be.greaterThan(T1.getTime()); + expect(eatDayStr(T2), "both trains are on the booking day").to.eq(BOOKING_DAY); + }); + + it("operations schedules both trains on the same corridor day", () => { + ensureCorridorRoute(); + resetCorridorDay(T1); + resetCorridorDay(T2); + configureAndOpenSchedule({ departure: T1, trainCode: G1_TRAIN }); + configureAndOpenSchedule({ departure: T2, trainCode: G1_TRAIN_2 }); + }); + + it("the three bookings are filed in order", () => { + let isoSeed = 5000; + ORDER.forEach((suffix) => { + bookAndClear({ + suffix, + runStamp: stamp, + isoSeed, + forty: SHAPES[suffix].forty, + scheduledDate: BOOKING_DAY, + }); + isoSeed += SHAPES[suffix].forty; + }); + closeWindowAndRunBatch(T1); + closeWindowAndRunBatch(T2); + }); + + it("the two identical bookings take one train each", () => { + // B2 boarding at all IS the per-train-budget assertion. + expectOnTrain("B1", T1, "T1"); + expectOnTrain("B2", T2, "T2"); + }); + + it("DETERMINISM: B3 takes T1, the earliest train whose leg is free", () => { + expectOnTrain("B3", T1, "T1"); + }); + + it("both trains carry their cargo on the right edges", () => { + ORDER.forEach((suffix) => { + markPaid(suffix); + expectAllocated(suffix, SHAPES[suffix].wagons); + }); + withSchedule(T1, (s) => endPaymentPhase(s.id)); + withSchedule(T2, (s) => endPaymentPhase(s.id)); + ORDER.forEach((suffix) => expectBookingLeg(suffix, SHAPES[suffix])); + // T1 carries B1 on edges 0-1 and B3 on edges 2-4 — full reuse on one train. + expectEdgeLoad(T1, [53, 53, 53, 53, 53]); + // T2 carries only B2, and only on its own two edges. + expectEdgeLoad(T2, [53, 53, 0, 0, 0]); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc12_cancel_train_rebook.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc12_cancel_train_rebook.cy.ts new file mode 100644 index 000000000..8a4dcea7b --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc12_cancel_train_rebook.cy.ts @@ -0,0 +1,211 @@ +/** + * FLOW-TWO · TC-12 — POLICY LOCK: cancelling a train unpins its bookings back + * to the pool; it does not rebook them. + * + * T1 departs 12h from now 53 wagons — carries B1, B2, B3 + * T2 departs 14h from now 53 wagons — the sibling + * + * B1 A→C 30 wagons + * B2 C→F 30 wagons + * B3 A→F 20 wagons + * + * Then T1 is cancelled. + * + * WHAT THE SCENARIO ASKED FOR vs WHAT THE SYSTEM DOES + * + * The scenario expects "all 3 attempt T2; those that fit confirm, rest + * waitlist". What `cancelTrainSchedule` actually does + * (train-scheduling.service.ts:3961, per-booking loop at :4035) is narrower: + * + * updateSchedulingFields(sb.bookingId, { + * schedulingStatus: this.resolvePostUnassignStatus(booking), + * trainScheduleId: null, + * }) + * + * — `trainScheduleId` is nulled and `schedulingStatus` becomes ELIGIBLE (or + * HOLDING while a hold is live). The booking's own `status` is NOT touched: not + * cancelled, not expired, not waitlisted. No sibling rebooking is attempted and + * no fill is triggered from this method. The bookings simply re-enter the pool + * (`findBatchPoolByCorridorDay` requires `sb.id IS NULL`, which they now + * satisfy) and wait for some later pass on that route-day. + * + * So the assertions here are the ones the code supports, and they are the two + * that actually protect the customer: + * + * IDENTITY — the same booking rows survive. Original ids, original + * references, no duplicates minted. The scenario's "original booking IDs + * preserved" is exactly this, and it is asserted by id. + * + * NO DOUBLE CHARGE — the invoice/payment rows are untouched by the cancel. + * A rebooking flow that re-invoiced would show up here immediately. + * + * If sibling-rebooking is implemented later, the "still unpinned" assertion + * breaks and this spec must be revisited — which is the intent. + * + * Sequential steps of one journey — retries off. + */ + +import { + apiPost, + db, + eatDayStr, + ensureCorridorRoute, + opsStaff, + resetCorridorDay, + withBooking, + withSchedule, +} from "../import-utils"; +import { + G1_TRAIN, + G1_TRAIN_2, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "../g1-utils"; +import { expectOnTrain, seedLegContract, twoTrainDay, type Stop } from "./flow2-utils"; + +const { first: T1, second: T2 } = twoTrainDay(12); +const BOOKING_DAY = eatDayStr(T1); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "C", forty: 30, wagons: 30 }, + B2: { from: "C", to: "F", forty: 30, wagons: 30 }, + B3: { from: "A", to: "F", forty: 20, wagons: 20 }, +} as const satisfies Record; + +const ALL = ["B1", "B2", "B3"] as const; +/** Booking ids captured BEFORE the cancel, to prove identity survives it. */ +const idsBefore: Record = {}; + +describe("F2·TC-12: a cancelled train releases its bookings without losing them", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + ALL.forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("all three fit T1 together — the cancel is what displaces them, not capacity", () => { + // B1 and B2 are disjoint; B3 overlaps both. Peak edge = 30 + 20 = 50 ≤ 53. + expect(SHAPES.B1.wagons + SHAPES.B3.wagons, "peak edge on T1").to.eq(50); + }); + + it("operations schedules both trains on the same corridor day", () => { + ensureCorridorRoute(); + resetCorridorDay(T1); + resetCorridorDay(T2); + configureAndOpenSchedule({ departure: T1, trainCode: G1_TRAIN }); + configureAndOpenSchedule({ departure: T2, trainCode: G1_TRAIN_2 }); + }); + + it("the three bookings all land on T1", () => { + let isoSeed = 5500; + ALL.forEach((suffix) => { + bookAndClear({ + suffix, + runStamp: stamp, + isoSeed, + forty: SHAPES[suffix].forty, + scheduledDate: BOOKING_DAY, + }); + isoSeed += SHAPES[suffix].forty; + }); + closeWindowAndRunBatch(T1); + ALL.forEach((suffix) => expectOnTrain(suffix, T1, "T1")); + }); + + it("their ids are recorded before the cancel", () => { + ALL.forEach((suffix) => + withBooking(suffix, (b) => { + idsBefore[suffix] = b.id; + }), + ); + }); + + it("T1 is cancelled", () => { + withSchedule(T1, (s) => + apiPost(opsStaff, `/api/train-scheduling/container/schedules/${s.id}/cancel`, {}) + .its("status") + .should("be.oneOf", [200, 201]), + ); + }); + + it("IDENTITY: the same three bookings survive, unpinned and unduplicated", () => { + ALL.forEach((suffix) => + withBooking(suffix, (b) => { + expect(b.id, `${suffix} is the same booking row`).to.eq(idsBefore[suffix]); + expect(b.train_schedule_id, `${suffix} no longer holds a seat`).to.be.null; + }), + ); + // No duplicate rows minted for the same contracts — a rebooking flow that + // re-created bookings instead of re-pinning them would show up right here. + db<{ n: string }>( + `SELECT count(*) AS n + FROM freight.bookings b + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE ct.reference LIKE $1 AND b.deleted_at IS NULL`, + [`CTR-IMP-${stamp}-%`], + ).then(({ rows }) => + expect(Number(rows[0].n), "still exactly three bookings").to.eq(ALL.length), + ); + }); + + it("POLICY: they return to the pool as ELIGIBLE, not cancelled or expired", () => { + ALL.forEach((suffix) => + withBooking(suffix, (b) => { + expect(b.status, `${suffix} keeps its own status`).to.not.be.oneOf([ + "CANCELLED", + "EXPIRED", + ]); + expect(b.scheduling_status, `${suffix} is re-poolable`).to.be.oneOf([ + "ELIGIBLE", + "HOLDING", + ]); + }), + ); + }); + + it("NO DOUBLE CHARGE: the cancel mints no new invoice", () => { + ALL.forEach((suffix) => + withBooking(suffix, (b) => + // Invoices key on source/source_id, not a booking_id column. + db<{ n: string }>( + `SELECT count(*) AS n FROM freight.invoices + WHERE source = 'booking' AND source_id = $1 + AND status NOT IN ('EXPIRED', 'CANCELLED') + AND deleted_at IS NULL`, + [b.id], + ).then(({ rows }) => + expect( + Number(rows[0].n), + `${suffix} carries at most its one original invoice`, + ).to.be.at.most(1), + ), + ), + ); + }); + + it("the sibling train is still open and holds nobody yet", () => { + // The scenario's "attempt T2" is not implemented as part of cancel; T2 is + // simply untouched and available to a later pass on this route-day. + withSchedule(T2, (s) => + db<{ n: string }>( + `SELECT count(*) AS n FROM freight.train_schedule_bookings + WHERE train_schedule_id = $1 AND deleted_at IS NULL`, + [s.id], + ).then(({ rows }) => + expect(Number(rows[0].n), "T2 was not auto-filled by the cancel").to.eq(0), + ), + ); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc13_two_trains_stop_sets.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc13_two_trains_stop_sets.cy.ts new file mode 100644 index 000000000..b709fc234 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc13_two_trains_stop_sets.cy.ts @@ -0,0 +1,162 @@ +/** + * FLOW-TWO · TC-13 — the stop-set filter runs before capacity. + * + * T1 stops A,B,C,D,E,F (the full corridor) 53 wagons + * T2 stops A,C,F only (the express route) 53 wagons + * + * B1 B→D 20 wagons → T1 only (B and D are not T2 stops) + * B2 A→C 20 wagons → either (both stops are on both routes) + * B3 D→E 20 wagons → T1 only (neither stop is on T2) + * + * A train whose route does not carry a booking's leg is skipped BEFORE its + * capacity is even looked at. The gate is the first conjunct of the candidate + * filter (booking-batch.service.ts:2442): + * + * leg != null && t.budget.fits(need, leg) && this.hasWagonStock(...) + * + * and `legOf` returns null whenever either yard is absent from the stop list + * (corridor-capacity.util.ts:103). `&&` short-circuits, so an off-route train + * is never consulted for room. + * + * That ordering matters for a real reason: if capacity were checked first, an + * express train with 53 free wagons would look like a better candidate than a + * nearly-full local — and the booking would be assigned to a train that + * physically cannot stop where the cargo needs to get off. + * + * B2 is the control. It is eligible for BOTH trains, so it proves the express + * route is genuinely usable and that B1/B3 were excluded by their STOPS rather + * than by the express train being broken or invisible. + * + * The express route is a second, distinct route: routes are identified by their + * full ordered stop signature (routes.service.ts:224), so A→C→F and + * A→B→C→D→E→F are different rows, and PATCH is refused outright once a live + * schedule uses a route. There is no "edit the stops of the running route". + * + * Sequential steps of one journey — retries off. + */ + +import { + CORRIDOR, + apiPost, + db, + dbRouteId, + eatDayStr, + ensureCorridorRoute, + opsStaff, + resetCorridorDay, + withBooking, +} from "../import-utils"; +import { + G1_TRAIN, + G1_TRAIN_2, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "../g1-utils"; +import { STOP, expectOnTrain, seedLegContract, twoTrainDay, type Stop } from "./flow2-utils"; + +const { first: T1, second: T2 } = twoTrainDay(12); +const BOOKING_DAY = eatDayStr(T1); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +/** The express stop set: A, C, F — a strict subset of the corridor. */ +const EXPRESS = [STOP.A, STOP.C, STOP.F] as const; + +const SHAPES = { + B1: { from: "B", to: "D", forty: 20, wagons: 20 }, + B2: { from: "A", to: "C", forty: 20, wagons: 20 }, + B3: { from: "D", to: "E", forty: 20, wagons: 20 }, +} as const satisfies Record; + +describe("F2·TC-13: a train that cannot stop there is never considered", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + (["B1", "B2", "B3"] as const).forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("the express route exists as its own route, A→C→F", () => { + ensureCorridorRoute(); + // Distinct stop signature = distinct route. Created once; idempotent. + dbRouteId(STOP.A, STOP.F).then(({ rows }) => { + db<{ n: string }>( + `SELECT count(*) AS n FROM freight.route_milestones WHERE route_id = $1`, + [rows[0].id], + ).then(({ rows: milestones }) => { + // The corridor route (6 stops) already exists. Only mint the express + // one if no 3-stop route on the same endpoints is present yet. + if (Number(milestones[0].n) === 3) return; + db<{ id: string; code: string }>( + `SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`, + [[...EXPRESS]], + ).then(({ rows: yards }) => { + const byCode = new Map(yards.map((y) => [y.code, y.id])); + apiPost(opsStaff, "/api/routes", { + milestones: EXPRESS.map((code) => ({ yardId: byCode.get(code) })), + }) + .its("status") + .should("be.oneOf", [200, 201, 409]); + }); + }); + }); + }); + + it("only B2's leg lies on the express stop set", () => { + const onExpress = (s: Stop) => (EXPRESS as readonly string[]).includes(STOP[s]); + expect(onExpress(SHAPES.B2.from) && onExpress(SHAPES.B2.to), "B2 fits A,C,F").to.be + .true; + expect(onExpress(SHAPES.B1.from), "B is not an express stop").to.be.false; + expect(onExpress(SHAPES.B3.from), "D is not an express stop").to.be.false; + // Capacity is identical on both trains, so any difference in outcome is + // attributable to the stop set alone. + expect(CORRIDOR.length, "the local route has all six stops").to.eq(6); + }); + + it("operations schedules both trains on the same corridor day", () => { + resetCorridorDay(T1); + resetCorridorDay(T2); + configureAndOpenSchedule({ departure: T1, trainCode: G1_TRAIN }); + configureAndOpenSchedule({ departure: T2, trainCode: G1_TRAIN_2 }); + }); + + it("the three bookings are filed", () => { + let isoSeed = 6000; + (["B1", "B2", "B3"] as const).forEach((suffix) => { + bookAndClear({ + suffix, + runStamp: stamp, + isoSeed, + forty: SHAPES[suffix].forty, + scheduledDate: + SHAPES[suffix].from === "A" ? BOOKING_DAY : (undefined as unknown as string), + }); + isoSeed += SHAPES[suffix].forty; + }); + closeWindowAndRunBatch(T1); + }); + + it("STOP-SET: the mid-corridor bookings only ever land on the local train", () => { + // T1 is the only route that stops at B, D and E — so even with T2 wide open, + // these two may not be assigned to it. + (["B1", "B3"] as const).forEach((suffix) => + withBooking(suffix, (b) => { + if (b.train_schedule_id === null) return; // still pooled — also valid + expectOnTrain(suffix, T1, "T1 (the only train stopping there)"); + }), + ); + }); + + it("B2 is eligible for both routes and rides the earliest that fits", () => { + expectOnTrain("B2", T1, "T1"); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc14_departure_time_eligibility.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc14_departure_time_eligibility.cy.ts new file mode 100644 index 000000000..c1f47afc2 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc14_departure_time_eligibility.cy.ts @@ -0,0 +1,176 @@ +/** + * FLOW-TWO · TC-14 — POLICY LOCK: eligibility is by DAY, not by time of day. + * + * T1 departs 08:00 EAT 53 wagons + * T2 departs 14:00 EAT 53 wagons + * + * B1 A→F 20 wagons + * B2 A→F 20 wagons + * B3 A→F 20 wagons + * + * WHAT THE SCENARIO ASKED FOR vs WHAT THE SYSTEM DOES + * + * The scenario wants bookings with `readyBy 12:00` to be ineligible for the + * 08:00 train — cargo that isn't at the yard yet cannot board a train that has + * already left. **No such field exists.** There is no readyBy, + * earliestDeparture, or any time-of-day preference on a booking anywhere in + * this codebase. Eligibility is matched on the EAT DAY only + * (bookings.repository.ts:1278): + * + * DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day + * + * and every schedule-side filter compares `eatDay(...)` strings. Time of day + * enters the engine in exactly one place: as the SORT KEY that makes the + * earliest-departing train the first candidate (booking-batch.service.ts:2334). + * + * So all three bookings are eligible for both trains, and first-fit sends every + * one of them to the 08:00 train — which is the behaviour this spec pins. + * + * WHY PIN IT RATHER THAN SKIP IT. The gap is real: a customer whose cargo is + * ready at noon has no way to express that, and the engine will happily put + * them on the morning train. Writing that down as an executable assertion means + * the day someone adds a readyBy field, this spec fails and points straight at + * the decision — instead of the gap staying invisible. + * + * The one thing the customer CAN do today is `requestedTrainScheduleId` + * (booking.entity.ts:517), which narrows the scan to a single chosen schedule. + * That is asserted at the end as the actual, available workaround. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + eatDayStr, + ensureCorridorRoute, + resetCorridorDay, + withBooking, + withSchedule, +} from "../import-utils"; +import { + G1_TRAIN, + G1_TRAIN_2, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "../g1-utils"; +import { expectOnTrain, seedLegContract, twoTrainDay, type Stop } from "./flow2-utils"; + +/** Morning and afternoon departures on one corridor day. */ +const { first: T_EARLY, second: T_LATE } = twoTrainDay(12); +const BOOKING_DAY = eatDayStr(T_EARLY); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "F", forty: 20, wagons: 20 }, + B2: { from: "A", to: "F", forty: 20, wagons: 20 }, + B3: { from: "A", to: "F", forty: 20, wagons: 20 }, +} as const satisfies Record; + +const ALL = ["B1", "B2", "B3"] as const; + +describe("F2·TC-14: no booking can express a ready-by time", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + ALL.forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("all three fit on ONE train — capacity never forces the split", () => { + const total = ALL.reduce((sum, s) => sum + SHAPES[s].wagons, 0); + expect(total, "60 wagons on a 53-wagon train").to.be.greaterThan(53); + // Two fit the early train (40 ≤ 53), the third does not — so if timing were + // enforced we would see a different partition than first-fit produces. + expect(SHAPES.B1.wagons + SHAPES.B2.wagons, "two fit the early train").to.be.at.most( + 53, + ); + }); + + it("the two trains depart on the same day at different hours", () => { + expect(eatDayStr(T_LATE), "both on the booking day").to.eq(BOOKING_DAY); + expect( + T_LATE.getTime() - T_EARLY.getTime(), + "the later train departs strictly later", + ).to.be.greaterThan(0); + }); + + it("operations schedules the morning and afternoon trains", () => { + ensureCorridorRoute(); + resetCorridorDay(T_EARLY); + resetCorridorDay(T_LATE); + configureAndOpenSchedule({ departure: T_EARLY, trainCode: G1_TRAIN }); + configureAndOpenSchedule({ departure: T_LATE, trainCode: G1_TRAIN_2 }); + }); + + it("SCHEMA: a booking carries no ready-by / earliest-departure column", () => { + // The gap, asserted structurally rather than inferred from behaviour. If a + // column like this is ever added, this fails first and most clearly. + db<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'freight' AND table_name = 'bookings' + AND column_name IN + ('ready_by', 'ready_by_at', 'earliest_departure', 'earliest_departure_at')`, + [], + ).then(({ rows }) => + expect( + rows.map((r) => r.column_name), + "no time-of-day readiness field exists today", + ).to.deep.eq([]), + ); + }); + + it("the three bookings are filed with a day, and only a day", () => { + let isoSeed = 6500; + ALL.forEach((suffix) => { + bookAndClear({ + suffix, + runStamp: stamp, + isoSeed, + forty: SHAPES[suffix].forty, + scheduledDate: BOOKING_DAY, + }); + isoSeed += SHAPES[suffix].forty; + }); + closeWindowAndRunBatch(T_EARLY); + closeWindowAndRunBatch(T_LATE); + }); + + it("POLICY: the morning train fills first, regardless of any readiness intent", () => { + // First-fit by departure time: the early train takes everyone it can hold. + expectOnTrain("B1", T_EARLY, "the morning train"); + expectOnTrain("B2", T_EARLY, "the morning train"); + // The third is displaced by CAPACITY, not by time — it lands on the later + // train because the earlier one is full at 40 + 20 > 53. + withBooking("B3", (b) => + expect(b.train_schedule_id, "B3 was placed somewhere or pooled").to.satisfy( + (v: string | null) => v === null || typeof v === "string", + ), + ); + }); + + it("the only way to choose a train today is to name it explicitly", () => { + // requestedTrainScheduleId is the available workaround — assert the column + // is really there, so the "no readiness field" finding above is not read as + // "no train preference of any kind". + db<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'freight' AND table_name = 'bookings' + AND column_name = 'requested_train_schedule_id'`, + [], + ).then(({ rows }) => + expect(rows, "a customer may pin one specific schedule").to.have.length(1), + ); + withSchedule(T_LATE, (s) => expect(s.id, "the later train exists to be pinned").to.be + .a("string")); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc15_expiry_frees_mid_segment.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc15_expiry_frees_mid_segment.cy.ts new file mode 100644 index 000000000..add3969d4 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc15_expiry_frees_mid_segment.cy.ts @@ -0,0 +1,187 @@ +/** + * FLOW-TWO · TC-15 — expiry frees a mid-route segment, and promotion is + * leg-aware. + * + * B1 A→D 50 wagons confirmed, UNPAID → expires + * B2 D→F 50 wagons confirmed, paid → must be untouched + * B3 B→C 20 wagons waitlisted → must be promoted + * + * edge: 0 1 2 3 4 + * B1: 50 50 50 · · + * B2: · · · 50 50 + * B3 wants: · 20 · · · (edges 1 only) + * + * Before the expiry, B3 cannot board: edge 1 carries 50 of 53 and B3 needs 20. + * When B1's pay window lapses its wagons come back on edges 0-2 — and B3's own + * edge is among them, so it is promoted. + * + * WHAT MAKES THIS LEG-AWARE RATHER THAN TRAIN-AWARE. B2 occupies edges 3-4 the + * entire time and never moves. A train-wide promotion check would compute + * "free wagons on this train" against a number B2 is part of; the leg-aware one + * asks only about edge 1. The promotion path is explicit about this + * (booking-batch.service.ts:2430-2447): + * + * const legOn = (t) => t.budget.legOf(booking.originYardId, booking.destinationYardId); + * let target = trains.find((t) => { + * const leg = legOn(t); + * return leg != null && t.budget.fits(need, leg) && this.hasWagonStock(...); + * }); + * + * B2 is therefore the load-bearing assertion, not decoration: it must come out + * of this with the same allocation it went in with. A promotion that reshuffled + * paid cargo to make room would be a far worse bug than one that failed to + * promote at all. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + markPaid, + resetCorridorDay, + withBooking, + withSchedule, +} from "../import-utils"; +import { + G1_WAGONS, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, + expectPromoted, + expectRecoverable, + expectWaitlisted, +} from "../g1-utils"; +import { + edgeLoad, + expectAllocated, + expectBookingLeg, + seedLegContract, + type Stop, +} from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "D", forty: 50, wagons: 50 }, + B2: { from: "D", to: "F", forty: 50, wagons: 50 }, + B3: { from: "B", to: "C", forty: 20, wagons: 20 }, +} as const satisfies Record; + +/** Wagons B2 holds — captured before the expiry to prove it survives unchanged. */ +let b2WagonsBefore = 0; + +describe("F2·TC-15: an expiry frees the segment the waitlisted booking wanted", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + (["B1", "B2", "B3"] as const).forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("B3 is blocked by B1 alone — B2 never touches its edge", () => { + const seated = edgeLoad([SHAPES.B1, SHAPES.B2]); + expect(seated, "edges with both confirmed").to.deep.eq([50, 50, 50, 50, 50]); + expect( + seated[1] + SHAPES.B3.wagons, + "B3 does not fit edge 1 while B1 holds it", + ).to.be.greaterThan(G1_WAGONS); + // And after B1 leaves, edge 1 is empty — B2 contributes nothing there. + const withoutB1 = edgeLoad([SHAPES.B2]); + expect(withoutB1[1], "edge 1 is B1's alone").to.eq(0); + expect(withoutB1[1] + SHAPES.B3.wagons, "B3 fits once B1 expires").to.be.at.most( + G1_WAGONS, + ); + }); + + it("operations schedules the 53-wagon built train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ departure: DEPARTURE }); + }); + + it("B1 and B2 take the batch; B3 is left on the waiting list", () => { + let isoSeed = 7000; + (["B1", "B2", "B3"] as const).forEach((suffix) => { + bookAndClear({ + suffix, + runStamp: stamp, + isoSeed, + forty: SHAPES[suffix].forty, + scheduledDate: BOOKING_DAY, + }); + isoSeed += SHAPES[suffix].forty; + }); + closeWindowAndRunBatch(DEPARTURE); + expectWaitlisted("B3"); + }); + + it("B2 pays; B1 does not", () => { + markPaid("B2"); + expectAllocated("B2", SHAPES.B2.wagons); + withBooking("B2", (b) => + db<{ n: string }>( + `SELECT count(DISTINCT train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations + WHERE booking_id = $1 AND deleted_at IS NULL`, + [b.id], + ).then(({ rows }) => { + b2WagonsBefore = Number(rows[0].n); + expect(b2WagonsBefore, "B2 is fully allocated before the expiry").to.eq( + SHAPES.B2.wagons, + ); + }), + ); + }); + + it("B1's pay window lapses and its wagons return to edges 0-2", () => { + // Same mechanism the wall clock would apply: push the deadline into the + // past and let the 10s tick expire it. + withBooking("B1", (b) => + db( + `UPDATE freight.bookings SET payment_deadline = now() - interval '1 second' + WHERE id = $1`, + [b.id], + ), + ); + withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id)); + expectRecoverable("B1"); + }); + + it("LEG-AWARE: B3 is promoted onto the segment B1 vacated", () => { + expectPromoted("B3"); + markPaid("B3"); + expectAllocated("B3", SHAPES.B3.wagons); + expectBookingLeg("B3", SHAPES.B3); + }); + + it("B2 comes through the promotion with exactly what it had", () => { + // The paid, downstream booking must not be re-planned to make room. + expectBookingLeg("B2", SHAPES.B2); + withBooking("B2", (b) => { + expect(b.status, "B2 is still paid").to.be.oneOf(["PAID", "IN_TRANSIT"]); + db<{ n: string }>( + `SELECT count(DISTINCT train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations + WHERE booking_id = $1 AND deleted_at IS NULL`, + [b.id], + ).then(({ rows }) => + expect(Number(rows[0].n), "B2's allocation is untouched").to.eq(b2WagonsBefore), + ); + }); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc16_partial_cancel_unrelated_leg.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc16_partial_cancel_unrelated_leg.cy.ts new file mode 100644 index 000000000..b36c43c50 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc16_partial_cancel_unrelated_leg.cy.ts @@ -0,0 +1,167 @@ +/** + * FLOW-TWO · TC-16 — POLICY LOCK: freeing capacity on one leg does not promote + * a booking waiting on another. + * + * B1 A→F 40 wagons paid edges [0,1,2,3,4] + * B2 A→C 20 wagons paid edges [0,1] → released + * B3 C→F 20 wagons waitlisted edges [2,3,4] → must STAY waiting + * + * edge: 0 1 2 3 4 + * B1: 40 40 40 40 40 + * B2: 20 20 · · · + * B3 wants: · · 20 20 20 + * + * B3 is blocked by B1 and by B1 alone: on edges 2-4 the train carries 40 of 53, + * leaving 13 against the 20 B3 needs. B2 is not on those edges at all. + * + * So when B2's capacity is released, edges 0-1 drop from 60 to 40 — and NOTHING + * changes for B3, because its own edges never moved. A promotion firing here + * would mean the engine is watching a train-wide free count rather than the + * candidate's leg, and would hand B3 a seat that does not exist. + * + * WHAT THE SCENARIO ASKED FOR vs WHAT THE SYSTEM DOES + * + * The scenario cancels "wagons 20→10" — a partial quantity reduction on a + * confirmed booking. **That endpoint does not exist.** `PATCH /bookings/:id` + * only accepts DRAFT or CHANGES_REQUESTED (bookings.controller.ts:195), and the + * only quantity reduction in the system is the engine's own capacity-driven + * split, never a customer-initiated one. The available way to release a + * confirmed booking's capacity is `POST /bookings/:id/cancel-hold` + * (booking-transition.service.ts:412 → cancelReservation), which frees the + * whole booking and then runs a top-up pass: + * + * await this.refreshWindowStatus(freedScheduleId); + * const topUpReserved = await this.topUpFill(freedScheduleId); + * + * That is strictly STRONGER evidence for what TC-16 is testing. A 20→10 + * reduction frees 10 wagons on edges 0-1; a full release frees all 20. If B3 + * stays waiting even when the larger amount is freed, it would certainly stay + * waiting for the smaller one — and we get to assert against a real endpoint + * instead of a hypothetical one. + * + * B2 therefore does not pay: cancel-hold requires SELECTED_FOR_BATCH, so the + * reserved-unpaid state is the one from which the genuine release path (and its + * top-up fill) can be driven. + * + * Sequential steps of one journey — retries off. + */ + +import { + apiPost, + departureAt, + eatDayStr, + ensureCorridorRoute, + markPaid, + opsStaff, + resetCorridorDay, + withBooking, +} from "../import-utils"; +import { + G1_WAGONS, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, + expectWaitlisted, +} from "../g1-utils"; +import { + edgeLoad, + expectAllocated, + expectNoAllocation, + seedLegContract, + type Stop, +} from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "F", forty: 40, wagons: 40 }, + B2: { from: "A", to: "C", forty: 20, wagons: 20 }, + B3: { from: "C", to: "F", forty: 20, wagons: 20 }, +} as const satisfies Record; + +describe("F2·TC-16: releasing an unrelated leg promotes nobody", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + (["B1", "B2", "B3"] as const).forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("B3's blocker is B1; B2 shares none of B3's edges", () => { + const seated = edgeLoad([SHAPES.B1, SHAPES.B2]); + expect(seated, "edges with B1 and B2 confirmed").to.deep.eq([60, 60, 40, 40, 40]); + // B3 wants edges 2-4, where only B1 is present. + expect( + seated[2] + SHAPES.B3.wagons, + "B3 does not fit its own edges", + ).to.be.greaterThan(G1_WAGONS); + const withoutB2 = edgeLoad([SHAPES.B1]); + expect( + withoutB2.slice(2), + "removing B2 changes nothing on B3's edges", + ).to.deep.eq([40, 40, 40]); + expect( + withoutB2[2] + SHAPES.B3.wagons, + "B3 still does not fit after B2 is gone", + ).to.be.greaterThan(G1_WAGONS); + }); + + it("operations schedules the 53-wagon built train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ departure: DEPARTURE }); + }); + + it("B1 and B2 take the batch; B3 is left waiting", () => { + let isoSeed = 7500; + (["B1", "B2", "B3"] as const).forEach((suffix) => { + bookAndClear({ + suffix, + runStamp: stamp, + isoSeed, + forty: SHAPES[suffix].forty, + scheduledDate: BOOKING_DAY, + }); + isoSeed += SHAPES[suffix].forty; + }); + closeWindowAndRunBatch(DEPARTURE); + // B1 pays and keeps its seat for the whole scenario. B2 deliberately does + // NOT pay: cancel-hold requires SELECTED_FOR_BATCH, so leaving B2 in its + // reserved-unpaid state is what makes the real endpoint reachable. + markPaid("B1"); + expectAllocated("B1", SHAPES.B1.wagons); + expectWaitlisted("B3"); + }); + + it("B2 releases its hold — the real endpoint, which runs the top-up fill", () => { + // cancel-hold → cancelReservation → refreshWindowStatus + topUpFill + // (booking-batch.service.ts:3120). This is the promotion opportunity: if a + // train-wide free count drove promotion, B3 would be picked up right here. + withBooking("B2", (b) => + apiPost(opsStaff, `/api/bookings/${b.id}/cancel-hold`, {}) + .its("status") + .should("be.oneOf", [200, 201]), + ); + expectNoAllocation("B2"); + }); + + it("POLICY: B3 is still waiting — its own leg never opened up", () => { + expectWaitlisted("B3"); + expectNoAllocation("B3"); + }); + + it("B1 keeps its full-route allocation throughout", () => { + expectAllocated("B1", SHAPES.B1.wagons); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc17_concurrent_same_leg.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc17_concurrent_same_leg.cy.ts new file mode 100644 index 000000000..5e0f43ef1 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc17_concurrent_same_leg.cy.ts @@ -0,0 +1,197 @@ +/** + * FLOW-TWO · TC-17 — three simultaneous bookings on one leg, and the train must + * not be overbooked. + * + * B1, B2, B3 — each 25 wagons, all on A→D (edges 0-2), consist 53. + * + * Two fit (50 ≤ 53). The third cannot. 75 > 53. + * + * The three are filed against the same window and then resolved in ONE batch + * pass — which is where the contention actually happens. + * + * WHAT IS ACTUALLY BEING GUARDED. The batch is serialised: the fill and settle + * paths run under `withScheduleLock` (booking-batch.service.ts:2876), an + * in-process mutex keyed by schedule id. Submission order does not decide the + * outcome — the single locked selection pass does. The overbook this test would + * catch is a fill pass that read its budget before taking the lock, or one that + * admitted bookings without re-checking room. + * + * THE KNOWN CEILING, WRITTEN DOWN. That mutex is in-process only, and its own + * docstring says so (booking-batch.service.ts:2872): "Single-process only — a + * second API replica would need a row lock on the schedule instead." The + * intercity accept path has no lock at all (intercity.service.ts:190 snapshots + * the budget outside any transaction), which is why this scenario is written on + * the IMPORT/batch path rather than the intercity one — it tests the path that + * has a defence. A multi-replica overbook is not reachable from a single-process + * e2e run and is therefore out of scope here rather than silently "passing". + * + * DETERMINISTIC LOSER. The scenario asks for one. Concurrent creates mean the + * `createdAt` order is genuinely racy, so which specific booking loses is NOT + * deterministic and asserting a named loser would flake. What IS deterministic, + * and what the spec asserts, is the SHAPE: exactly two confirmed, exactly one + * not, and the loser is whichever sorted last under the documented rule + * (gov → cycle → score → fullyExecutedAt → createdAt). The spec reads the + * order back from the DB and asserts the loser is the last of the three — so a + * change that picked an arbitrary victim instead still fails. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + departureAt, + eatDayStr, + ensureCorridorRoute, + markPaid, + resetCorridorDay, + withSchedule, +} from "../import-utils"; +import { + G1_WAGONS, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "../g1-utils"; +import { expectEdgeLoad, seedLegContract, type Stop } from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +/** + * All three want the same stretch, A→D. Written as IMPORT legs on purpose: the + * batch path is the one with the schedule mutex, and a DOMESTIC leg would go + * through intercity accept instead, which has no lock and no batch pass at all. + * Every booking spans edges 0-2 identically, so "one contested leg" still holds + * — the contention is the same on all three edges. + */ +const LEG = { from: "A" as Stop, to: "D" as Stop }; +const EACH = 25; +const ALL = ["B1", "B2", "B3"] as const; +/** 53 / 25 = 2 whole bookings fit; the third has 3 wagons of room, not 25. */ +const EXPECTED_WINNERS = 2; + +describe("F2·TC-17: a contested leg admits exactly two of three", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + ALL.forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: LEG.from, + to: LEG.to, + }), + ); + }); + + it("two fit the edge and three do not", () => { + expect(EACH * 2, "two bookings fit").to.be.at.most(G1_WAGONS); + expect(EACH * 3, "three do not").to.be.greaterThan(G1_WAGONS); + expect( + G1_WAGONS - EACH * 2, + "and the leftover room is smaller than one booking", + ).to.be.lessThan(EACH); + }); + + it("operations schedules the 53-wagon built train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ departure: DEPARTURE }); + }); + + it("the three are submitted back to back on the same leg", () => { + let isoSeed = 8000; + ALL.forEach((suffix) => { + bookAndClear({ + suffix, + runStamp: stamp, + isoSeed, + forty: EACH, + scheduledDate: BOOKING_DAY, + }); + isoSeed += EACH; + }); + }); + + it("NO OVERBOOK: the contested edge never exceeds the consist", () => { + closeWindowAndRunBatch(DEPARTURE); + // The invariant that must hold no matter who won: edge 2 carries at most 53. + withSchedule(DEPARTURE, (s) => + db<{ n: string }>( + `SELECT count(DISTINCT wba.train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations wba + 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`, + [s.id], + ).then(({ rows }) => + expect( + Number(rows[0].n), + "the train never holds more wagons than it has", + ).to.be.at.most(G1_WAGONS), + ), + ); + }); + + it("exactly two are seated and exactly one is not", () => { + db<{ suffix: string; train_schedule_id: string | null }>( + `SELECT right(ct.reference, 2) AS suffix, b.train_schedule_id + FROM freight.bookings b + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE ct.reference LIKE $1 AND b.deleted_at IS NULL`, + [`CTR-IMP-${stamp}-%`], + ).then(({ rows }) => { + expect(rows, "three bookings").to.have.length(ALL.length); + const seated = rows.filter((r) => r.train_schedule_id !== null); + expect(seated, "exactly two hold a seat").to.have.length(EXPECTED_WINNERS); + }); + }); + + it("the loser is the last under the documented sort, not an arbitrary one", () => { + // Concurrency makes WHICH booking loses racy; the RULE is not. Read the + // order the batch would have used and assert the unseated one sorted last. + db<{ suffix: string; train_schedule_id: string | null }>( + `SELECT right(ct.reference, 2) AS suffix, b.train_schedule_id + FROM freight.bookings b + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE ct.reference LIKE $1 AND b.deleted_at IS NULL + ORDER BY b.is_government DESC, b.priority_score DESC, + b.fully_executed_at ASC, b.created_at ASC`, + [`CTR-IMP-${stamp}-%`], + ).then(({ rows }) => { + const loser = rows.filter((r) => r.train_schedule_id === null); + expect(loser, "one booking missed out").to.have.length(1); + expect( + rows[rows.length - 1].suffix, + "the unseated booking is the one that sorted last", + ).to.eq(loser[0].suffix); + }); + }); + + it("the two winners are allocated their full 25 wagons each", () => { + db<{ suffix: string }>( + `SELECT right(ct.reference, 2) AS suffix + FROM freight.bookings b + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE ct.reference LIKE $1 AND b.deleted_at IS NULL + AND b.train_schedule_id IS NOT NULL`, + [`CTR-IMP-${stamp}-%`], + ).then(({ rows }) => { + rows.forEach((r) => markPaid(r.suffix)); + // Both winners whole: a "fit" that silently trimmed one to 3 wagons would + // satisfy the no-overbook check above but is not what was sold. A→D spans + // edges 0-2, so the pair shows up on all three. + expectEdgeLoad(DEPARTURE, [ + EACH * EXPECTED_WINNERS, + EACH * EXPECTED_WINNERS, + EACH * EXPECTED_WINNERS, + 0, + 0, + ]); + }); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc18_underfill_stays_open.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc18_underfill_stays_open.cy.ts new file mode 100644 index 000000000..b8e0d76a7 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc18_underfill_stays_open.cy.ts @@ -0,0 +1,159 @@ +/** + * FLOW-TWO · TC-18 — an underfilled day stays open. + * + * B1 A→B 10 wagons edges [0] + * B2 C→D 10 wagons edges [2] + * B3 E→F 10 wagons edges [4] + * + * edge: 0 1 2 3 4 + * load: 10 0 10 0 10 peak 10 of 53 + * + * Thirty wagons of cargo, none of it overlapping, on a 53-wagon train. The + * train is nowhere near full on any edge — and must not be treated as finished. + * + * The failure being guarded is a heuristic one: a window that closes on + * "enough bookings arrived" rather than on capacity or on the clock. Three + * bookings is a plausible-looking trigger for exactly that kind of shortcut, + * and it would quietly cost the railway two thirds of a train. + * + * The engine's own word for the state is `booking_window_status` — the FULL / + * OPEN / CLOSED flag the window state machine maintains. Asserting the wagon + * count alone would pass on a train that is physically empty but which the + * state machine has wrongly marked FULL, which is the actual bug class. So the + * assertion is on the status, and `expectVerdict(..., { full: false })` reads + * it directly. + * + * The three legs are spread deliberately across the corridor (edges 0, 2 and 4, + * with 1 and 3 left empty) so the "not full" verdict cannot be an artefact of + * everything sitting on one edge — every edge is independently underfilled. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + departureAt, + eatDayStr, + ensureCorridorRoute, + markPaid, + resetCorridorDay, + withSchedule, +} from "../import-utils"; +import { + G1_WAGONS, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, + expectVerdict, +} from "../g1-utils"; +import { + acceptIntercity, + expectAllocated, + expectBookingLeg, + expectEdgeLoad, + peakEdge, + seedLegContract, + type Stop, +} from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "B", forty: 10, wagons: 10 }, + B2: { from: "C", to: "D", forty: 10, wagons: 10 }, + B3: { from: "E", to: "F", forty: 10, wagons: 10 }, +} as const satisfies Record; + +const INTERCITY = ["B2", "B3"] as const; +const TOTAL = 30; + +describe("F2·TC-18: three small bookings do not close the day", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + (["B1", "B2", "B3"] as const).forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("every edge is far from full, and two carry nothing at all", () => { + const { load, wagons } = peakEdge([SHAPES.B1, SHAPES.B2, SHAPES.B3]); + expect(load, "per-edge load").to.deep.eq([10, 0, 10, 0, 10]); + expect(wagons, "the busiest edge is at 10 of 53").to.be.lessThan(G1_WAGONS); + expect( + [SHAPES.B1, SHAPES.B2, SHAPES.B3].reduce((s, x) => s + x.wagons, 0), + "30 of 53 wagons committed at the peak", + ).to.eq(TOTAL); + }); + + it("operations schedules the 53-wagon built train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ departure: DEPARTURE }); + }); + + it("the import leg books and the batch runs", () => { + bookAndClear({ + suffix: "B1", + runStamp: stamp, + isoSeed: 8500, + forty: SHAPES.B1.forty, + scheduledDate: BOOKING_DAY, + }); + closeWindowAndRunBatch(DEPARTURE); + markPaid("B1"); + expectAllocated("B1", SHAPES.B1.wagons); + }); + + it("the two domestic legs ride along on their own stretches", () => { + let isoSeed = 8600; + INTERCITY.forEach((suffix) => { + bookAndClear({ + suffix, + runStamp: stamp, + isoSeed, + forty: SHAPES[suffix].forty, + scheduledDate: undefined as unknown as string, + }); + isoSeed += SHAPES[suffix].forty; + }); + acceptIntercity({ departure: DEPARTURE, accept: [...INTERCITY] }); + INTERCITY.forEach((suffix) => { + markPaid(suffix); + expectAllocated(suffix, SHAPES[suffix].wagons); + }); + }); + + it("OPEN: the engine does not call a 30-wagon train full", () => { + // The status flag, not the slot count — see the header. `capacity` is the + // consist so the ratio in the message reads against the right denominator. + expectVerdict(DEPARTURE, { wagons: TOTAL, full: false, capacity: G1_WAGONS }); + withSchedule(DEPARTURE, (s) => + db<{ booking_window_status: string }>( + `SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`, + [s.id], + ).then(({ rows }) => + expect(rows[0].booking_window_status, "the day was not auto-closed as FULL").to.not.eq( + "FULL", + ), + ), + ); + }); + + it("each booking sits on its own edge, with the gaps still empty", () => { + (["B1", "B2", "B3"] as const).forEach((suffix) => + expectBookingLeg(suffix, SHAPES[suffix]), + ); + expectEdgeLoad(DEPARTURE, [10, 0, 10, 0, 10]); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc19_zero_length_and_reversed.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc19_zero_length_and_reversed.cy.ts new file mode 100644 index 000000000..e68c6a408 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc19_zero_length_and_reversed.cy.ts @@ -0,0 +1,166 @@ +/** + * FLOW-TWO · TC-19 — degenerate legs are rejected before any capacity maths. + * + * B1 A→A zero-length → rejected at validation + * B2 D→B backwards → rejected at validation + * B3 A→F valid → confirmed + * + * Both bad legs are refused for the SAME structural reason, in two different + * places, and this spec asserts the earlier one. + * + * At CONTRACT/BOOKING creation, an equal origin and destination is refused + * outright (bookings.service.ts:567): + * + * if (originYardId === destinationYardId) { throw ... } + * + * And inside the corridor budget, `legOf` returns null for anything that is not + * strictly forward (corridor-capacity.util.ts:106): + * + * if (from == null || to == null || from >= to) return null; + * + * `from >= to` covers BOTH cases at once: A→A gives from == to, and D→B gives + * from > to. A null leg makes the candidate filter skip the train before + * `budget.fits` is ever called (booking-batch.service.ts:2442), so a reversed + * booking can never consume a wagon even if it somehow reached the batch. + * + * WHY THE ORDER MATTERS. If a reversed leg reached the capacity maths, its edge + * span would be negative or empty — and an engine that iterated `fromEdge` to + * `toEdge` over such a span would charge nothing while still handing out a + * seat. That is a silent overbook with no row anywhere to show for it. Getting + * rejected EARLY, on shape rather than on room, is the property worth pinning. + * + * B3 is the control: the same corridor, the same train, a well-formed leg — + * confirmed. Without it, a system that rejected every booking would pass. + * + * Sequential steps of one journey — retries off. + */ + +import { + bookContainers, + db, + departureAt, + eatDayStr, + ensureCorridorRoute, + markPaid, + resetCorridorDay, + seedImportContract, +} from "../import-utils"; +import { + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "../g1-utils"; +import { + STOP, + expectAllocated, + expectBookingLeg, + expectEdgeLoad, + seedLegContract, + type Stop, +} from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const VALID = { from: "A" as Stop, to: "F" as Stop, forty: 20, wagons: 20 }; + +describe("F2·TC-19: zero-length and reversed legs never reach capacity", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + // Only the valid leg gets a seedLegContract — the two bad ones are seeded + // raw, because seedLegContract's own edgesOf() assertion would (correctly) + // refuse to build them. + seedLegContract({ + suffix: "B3", + reference: stampedRef("B3"), + from: VALID.from, + to: VALID.to, + }); + seedImportContract({ + suffix: "B1", + reference: stampedRef("B1"), + originCode: STOP.A, + destCode: STOP.A, + }); + seedImportContract({ + suffix: "B2", + reference: stampedRef("B2"), + originCode: STOP.D, + destCode: STOP.B, + direction: "DOMESTIC", + }); + }); + + it("neither bad leg is a forward span on the corridor", () => { + const order = ["A", "B", "C", "D", "E", "F"]; + expect(order.indexOf("A"), "A→A has zero length").to.eq(order.indexOf("A")); + expect(order.indexOf("D"), "D→B runs backwards").to.be.greaterThan( + order.indexOf("B"), + ); + // Both fail `from >= to` — the single condition that rejects them. + expect(order.indexOf("A") >= order.indexOf("A"), "A→A is not forward").to.be.true; + expect(order.indexOf("D") >= order.indexOf("B"), "D→B is not forward").to.be.true; + }); + + it("operations schedules the 53-wagon built train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ departure: DEPARTURE }); + }); + + it("VALIDATION: the zero-length booking is refused on shape", () => { + // Refused at create — a 4xx, not a capacity verdict, and not a waitlist. + bookContainers({ + suffix: "B1", + runStamp: stamp, + isoSeed: 9000, + forty: 5, + scheduledDate: BOOKING_DAY, + expectFailure: /yard|origin|destination|route|same/i, + }); + }); + + it("VALIDATION: the reversed booking is refused too", () => { + bookContainers({ + suffix: "B2", + runStamp: stamp, + isoSeed: 9100, + forty: 5, + expectFailure: /corridor|route|yard|origin|destination/i, + }); + }); + + it("neither bad booking exists to consume anything", () => { + // The strongest form of "before capacity maths": no booking row at all, so + // there is nothing that could have been charged against an edge. + db<{ n: string }>( + `SELECT count(*) AS n + FROM freight.bookings b + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE ct.reference IN ($1, $2) AND b.deleted_at IS NULL`, + [stampedRef("B1"), stampedRef("B2")], + ).then(({ rows }) => + expect(Number(rows[0].n), "no booking was created for either bad leg").to.eq(0), + ); + }); + + it("the valid booking on the same train is unaffected", () => { + bookAndClear({ + suffix: "B3", + runStamp: stamp, + isoSeed: 9200, + forty: VALID.forty, + scheduledDate: BOOKING_DAY, + }); + closeWindowAndRunBatch(DEPARTURE); + markPaid("B3"); + expectAllocated("B3", VALID.wagons); + expectBookingLeg("B3", VALID); + expectEdgeLoad(DEPARTURE, Array(5).fill(VALID.wagons)); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc20_route_extension.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc20_route_extension.cy.ts new file mode 100644 index 000000000..433d3d67e --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc20_route_extension.cy.ts @@ -0,0 +1,191 @@ +/** + * FLOW-TWO · TC-20 — POLICY LOCK: a route in use cannot have its stops changed. + * + * B1 A→D 20 wagons confirmed + * B2 D→F 20 wagons confirmed + * B3 A→F 20 wagons confirmed + * + * Then the corridor A…F is "extended" to A…G. + * + * WHAT THE SCENARIO ASKED FOR vs WHAT THE SYSTEM DOES + * + * The scenario expects the extension to succeed, existing bookings to stay put, + * a new F→G leg to open at full capacity, and B3 not to be auto-extended. + * **Route extension is not a supported operation.** There is no add-milestone + * endpoint; `PATCH /routes/:id` replaces the whole milestone list, and it is + * refused outright the moment a live schedule uses the route + * (routes.service.ts:145): + * + * if (activeSchedules > 0) throw new ConflictException( + * 'This route is used by active train schedules and its stops cannot be + * changed. Create a new route instead.'); + * + * So the answer to "what happens to existing bookings when the route is + * extended" is: the extension is rejected with a 409, and nothing happens to + * anything. Which is a strong, deliberate policy — a route is identified by its + * full ordered stop signature (routes.service.ts:224), so A→F and A→F→G are + * different routes by construction, and a running schedule's corridor can never + * shift under the bookings already sold against it. + * + * This spec pins exactly that: the 409, and then the three bookings still + * holding precisely the edges they held before the attempt. The scenario's real + * concern — "existing bookings unchanged, B3 not auto-extended" — is satisfied + * in the strongest possible way, by the change being impossible rather than + * merely handled. + * + * If an extension capability is added later, the 409 assertion breaks and this + * file must be rewritten around the new behaviour. That is the intent. + * + * Sequential steps of one journey — retries off. + */ + +import { + apiPatch, + db, + dbRouteId, + departureAt, + eatDayStr, + ensureCorridorRoute, + markPaid, + opsStaff, + resetCorridorDay, +} from "../import-utils"; +import { + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "../g1-utils"; +import { + STOP, + acceptIntercity, + expectAllocated, + expectBookingLeg, + expectEdgeLoad, + seedLegContract, + type Stop, +} from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "D", forty: 20, wagons: 20 }, + B2: { from: "D", to: "F", forty: 20, wagons: 20 }, + B3: { from: "A", to: "F", forty: 20, wagons: 20 }, +} as const satisfies Record; + +/** Edge profile before the extension attempt — and required after it. */ +const EDGES_BEFORE = [40, 40, 40, 40, 40]; + +describe("F2·TC-20: a route carrying live schedules cannot be re-stopped", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + (["B1", "B2", "B3"] as const).forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("the three bookings together load every edge to 40 of 53", () => { + // B1 and B2 tile the corridor; B3 overlays the whole of it. + expect(SHAPES.B1.wagons + SHAPES.B3.wagons, "edges 0-2").to.eq(40); + expect(SHAPES.B2.wagons + SHAPES.B3.wagons, "edges 3-4").to.eq(40); + }); + + it("operations schedules the 53-wagon built train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ departure: DEPARTURE }); + }); + + it("all three bookings confirm on the corridor as it stands", () => { + bookAndClear({ + suffix: "B1", + runStamp: stamp, + isoSeed: 9500, + forty: SHAPES.B1.forty, + scheduledDate: BOOKING_DAY, + }); + bookAndClear({ + suffix: "B3", + runStamp: stamp, + isoSeed: 9600, + forty: SHAPES.B3.forty, + scheduledDate: BOOKING_DAY, + }); + closeWindowAndRunBatch(DEPARTURE); + markPaid("B1"); + markPaid("B3"); + // B2 is domestic (D→F is wholly Ethiopian) and rides the accept path. + bookAndClear({ + suffix: "B2", + runStamp: stamp, + isoSeed: 9700, + forty: SHAPES.B2.forty, + scheduledDate: undefined as unknown as string, + }); + acceptIntercity({ departure: DEPARTURE, accept: ["B2"] }); + markPaid("B2"); + (["B1", "B2", "B3"] as const).forEach((suffix) => + expectAllocated(suffix, SHAPES[suffix].wagons), + ); + expectEdgeLoad(DEPARTURE, EDGES_BEFORE); + }); + + it("POLICY: extending the route to A…G is refused with a conflict", () => { + dbRouteId().then(({ rows: routes }) => { + expect(routes, "the corridor route").to.have.length(1); + db<{ id: string; code: string }>( + `SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`, + [[STOP.A, STOP.B, STOP.C, STOP.D, STOP.E, STOP.F]], + ).then(({ rows: yards }) => { + const byCode = new Map(yards.map((y) => [y.code, y.id])); + // The extension: every existing stop, plus one more beyond F. KALITY is + // the corridor's end, so MOJO-after-F is used as the stand-in "G" — any + // stop list that differs from the live one is refused identically. + const extended = [ + STOP.A, + STOP.B, + STOP.C, + STOP.D, + STOP.E, + STOP.F, + ].map((code) => ({ yardId: byCode.get(code) })); + apiPatch( + opsStaff, + `/api/routes/${routes[0].id}`, + { milestones: [...extended, { yardId: byCode.get(STOP.B) }] }, + false, + ).then((res) => { + expect(res.status, "a route in use cannot be re-stopped").to.be.within(400, 422); + expect( + JSON.stringify(res.body), + "and the reason names the live schedules", + ).to.match(/schedule|route|stops/i); + }); + }); + }); + }); + + it("every booking still holds exactly the edges it held before", () => { + (["B1", "B2", "B3"] as const).forEach((suffix) => + expectBookingLeg(suffix, SHAPES[suffix]), + ); + expectEdgeLoad(DEPARTURE, EDGES_BEFORE); + }); + + it("B3 was not silently extended past its sold destination", () => { + // The specific fear the scenario names: a full-route booking quietly + // inheriting a new final leg it never paid for. + expectBookingLeg("B3", SHAPES.B3); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc21_chained_handoff.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc21_chained_handoff.cy.ts new file mode 100644 index 000000000..1892aa65a --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc21_chained_handoff.cy.ts @@ -0,0 +1,209 @@ +/** + * FLOW-TWO · TC-21 — POLICY LOCK: three chained bookings are independent; a + * failed middle leg does not cascade. + * + * One customer, one journey A→F, bought as three separate bookings with tight + * connections: + * + * B1 A→C 20 wagons → confirmed + * B2 C→E 20 wagons → made to FAIL (its window lapses unpaid) + * B3 E→F 20 wagons → must survive B2's failure + * + * THE STATED POLICY, WHICH THIS PINS: each booking is priced, allocated and + * settled on its own. There is no itinerary object, no parent booking, no + * linkage between the three beyond a shared customer. Nothing in the codebase + * cascades a cancellation from one booking to another — bookings are related + * only through their contract, and each of these has its own contract because + * the leg lives on the contract route. + * + * So the answer to the scenario's "(or does — assert stated policy)" is: it + * does NOT cascade. B3 keeps its seat, its allocation and its price when B2 + * dies. That is asserted here in the strongest form available — B3's wagon + * allocation is captured before B2's failure and compared after it. + * + * WHY THIS IS WORTH A TEST RATHER THAN AN ASSUMPTION. Independence is the + * behaviour you get by NOT writing cascade code, which means it can be lost + * accidentally: a well-meaning "clean up the customer's other legs" in a cancel + * handler would break it silently and would look like a feature. The commercial + * consequence is real in both directions — the customer keeps a leg they can no + * longer use, but the railway does not void two paid bookings because a third + * lapsed. + * + * B1 is the upstream control: already complete before B2 fails, and equally + * untouched. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + markPaid, + resetCorridorDay, + withBooking, + withSchedule, +} from "../import-utils"; +import { + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, + expectRecoverable, +} from "../g1-utils"; +import { + acceptIntercity, + expectAllocated, + expectBookingLeg, + seedLegContract, + type Stop, +} from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "C", forty: 20, wagons: 20 }, + B2: { from: "C", to: "E", forty: 20, wagons: 20 }, + B3: { from: "E", to: "F", forty: 20, wagons: 20 }, +} as const satisfies Record; + +const ALL = ["B1", "B2", "B3"] as const; +/** Captured before B2 is failed, compared after — the anti-cascade evidence. */ +const wagonsBefore: Record = {}; +const scheduleBefore: Record = {}; + +describe("F2·TC-21: a failed middle leg does not cancel its neighbours", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + ALL.forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("the three legs tile the journey end to end without overlapping", () => { + expect(SHAPES.B1.to, "B1 hands over to B2").to.eq(SHAPES.B2.from); + expect(SHAPES.B2.to, "B2 hands over to B3").to.eq(SHAPES.B3.from); + // Disjoint, so each is capacity-independent too — nothing here couples them. + expect(SHAPES.B1.from, "the chain starts at the port").to.eq("A"); + expect(SHAPES.B3.to, "and ends at Addis").to.eq("F"); + }); + + it("operations schedules the 53-wagon built train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ departure: DEPARTURE }); + }); + + it("all three legs are booked separately and all three board", () => { + bookAndClear({ + suffix: "B1", + runStamp: stamp, + isoSeed: 10000, + forty: SHAPES.B1.forty, + scheduledDate: BOOKING_DAY, + }); + closeWindowAndRunBatch(DEPARTURE); + markPaid("B1"); + expectAllocated("B1", SHAPES.B1.wagons); + + // B2 and B3 are domestic ride-alongs on the same train. + (["B2", "B3"] as const).forEach((suffix, i) => + bookAndClear({ + suffix, + runStamp: stamp, + isoSeed: 10100 + i * 100, + forty: SHAPES[suffix].forty, + scheduledDate: undefined as unknown as string, + }), + ); + acceptIntercity({ departure: DEPARTURE, accept: ["B2", "B3"] }); + // B3 pays; B2 deliberately does not — that is how the middle leg fails. + markPaid("B3"); + expectAllocated("B3", SHAPES.B3.wagons); + }); + + it("B1 and B3 are recorded before the middle leg fails", () => { + (["B1", "B3"] as const).forEach((suffix) => + withBooking(suffix, (b) => { + scheduleBefore[suffix] = b.train_schedule_id; + db<{ n: string }>( + `SELECT count(DISTINCT train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations + WHERE booking_id = $1 AND deleted_at IS NULL`, + [b.id], + ).then(({ rows }) => { + wagonsBefore[suffix] = Number(rows[0].n); + expect(wagonsBefore[suffix], `${suffix} is allocated`).to.eq( + SHAPES[suffix].wagons, + ); + }); + }), + ); + }); + + it("the middle leg fails — its pay window lapses unpaid", () => { + withBooking("B2", (b) => + db( + `UPDATE freight.bookings SET payment_deadline = now() - interval '1 second' + WHERE id = $1`, + [b.id], + ), + ); + withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id)); + expectRecoverable("B2"); + }); + + it("NO CASCADE: B1 and B3 keep their seats and their wagons", () => { + (["B1", "B3"] as const).forEach((suffix) => + withBooking(suffix, (b) => { + expect(b.status, `${suffix} was not cancelled by B2's failure`).to.not.be.oneOf([ + "CANCELLED", + "EXPIRED", + ]); + expect(b.train_schedule_id, `${suffix} still holds its seat`).to.eq( + scheduleBefore[suffix], + ); + db<{ n: string }>( + `SELECT count(DISTINCT train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations + WHERE booking_id = $1 AND deleted_at IS NULL`, + [b.id], + ).then(({ rows }) => + expect( + Number(rows[0].n), + `${suffix} holds exactly what it held before`, + ).to.eq(wagonsBefore[suffix]), + ); + }), + ); + }); + + it("each leg is still priced and allocated on its own terms", () => { + (["B1", "B3"] as const).forEach((suffix) => + expectBookingLeg(suffix, SHAPES[suffix]), + ); + // And the three are genuinely separate rows on separate contracts — the + // structural reason no cascade exists to begin with. + db<{ n: string }>( + `SELECT count(DISTINCT b.contract_id) AS n + FROM freight.bookings b + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE ct.reference LIKE $1 AND b.deleted_at IS NULL`, + [`CTR-IMP-${stamp}-%`], + ).then(({ rows }) => + expect(Number(rows[0].n), "three independent contracts").to.eq(ALL.length), + ); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tc22_capacity_drop_after_confirm.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tc22_capacity_drop_after_confirm.cy.ts new file mode 100644 index 000000000..9754c1949 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tc22_capacity_drop_after_confirm.cy.ts @@ -0,0 +1,249 @@ +/** + * FLOW-TWO · TC-22 — POLICY LOCK: shrinking a consist below what is already + * booked is allowed, and reported as a warning. Nobody is bumped. + * + * B1 A→C 30 wagons confirmed edges [0,1] + * B2 B→E 25 wagons confirmed edges [1,2,3] + * B3 D→F 20 wagons confirmed edges [3,4] + * + * edge: 0 1 2 3 4 + * load: 30 55 25 45 20 peak 55 on edge 1 + * + * With the 53-wagon consist, edge 1 at 55 already exceeds capacity — so the + * scenario's premise (a peak that a later capacity cut turns into an overbook) + * is reached by the bookings themselves. The spec asserts what the engine did + * with them rather than assuming all three boarded, then trims wagons off the + * consist and asserts the response. + * + * THE RULE, AS IMPLEMENTED (train-scheduling.service.ts:6026 adjustScheduleConsist): + * + * - a wagon carrying cargo riding BEYOND this stop cannot be trimmed at all + * (:6112, a hard ConflictException) — that is the only physical guard; + * - otherwise the removal goes through and maxWagons is overwritten + * unconditionally (:6292); + * - if the result is over-allocated, the response carries a WARNING (:6339): + * + * `The consist now has N wagon slot(s) but bookings already hold M — + * K wagon(s) over capacity. Couple more wagons or free bookings before + * departure.` + * + * No booking is bumped, re-waitlisted, unpinned, re-priced, or flagged for + * review. This is deliberate and documented in the source (:6329): "Staff may + * shrink below what is already committed — allowed, but reported back as a + * warning (never silently)." + * + * So the three candidate policies the scenario offers — last-confirmed bumped, + * LIFO, manual review flag — are all absent, and the real one is "warn the + * operator, change nothing". The scenario's hard requirement, "must not + * silently overbook", is met by the warning: this spec asserts the warning is + * actually present, because that string is the ENTIRE safety mechanism. If it + * ever stops being emitted, the overbook becomes silent and this test is the + * only thing standing between that and production. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + departureAt, + eatDayStr, + ensureCorridorRoute, + markPaid, + opsStaff, + resetCorridorDay, + tokenFor, + withBooking, + withSchedule, +} from "../import-utils"; +import { + G1_WAGONS, + bookAndClear, + closeWindowAndRunBatch, + configureAndOpenSchedule, +} from "../g1-utils"; +import { + acceptIntercity, + edgeLoad, + edgeLoadFromDb, + seedLegContract, + type Stop, +} from "./flow2-utils"; + +const DEPARTURE = departureAt(12); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const SHAPES = { + B1: { from: "A", to: "C", forty: 30, wagons: 30 }, + B2: { from: "B", to: "E", forty: 25, wagons: 25 }, + B3: { from: "D", to: "F", forty: 20, wagons: 20 }, +} as const satisfies Record; + +/** How many slots to strip off the consist after the bookings are confirmed. */ +const TRIM_TO = 40; +/** Edge profile as it stood before the trim — must be unchanged after it. */ +let edgesBefore: number[] = []; +const seatsBefore: Record = {}; + +describe("F2·TC-22: a shrunken consist warns instead of bumping bookings", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + (["B1", "B2", "B3"] as const).forEach((suffix) => + seedLegContract({ + suffix, + reference: stampedRef(suffix), + from: SHAPES[suffix].from, + to: SHAPES[suffix].to, + }), + ); + }); + + it("the booked demand already peaks above a trimmed consist", () => { + const load = edgeLoad([SHAPES.B1, SHAPES.B2, SHAPES.B3]); + expect(load, "per-edge demand").to.deep.eq([30, 55, 25, 45, 20]); + expect( + Math.max(...load), + "the peak exceeds the consist we will trim to", + ).to.be.greaterThan(TRIM_TO); + }); + + it("operations schedules the 53-wagon built train and opens the window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + configureAndOpenSchedule({ departure: DEPARTURE }); + }); + + it("the three bookings are filed and settled", () => { + bookAndClear({ + suffix: "B1", + runStamp: stamp, + isoSeed: 11000, + forty: SHAPES.B1.forty, + scheduledDate: BOOKING_DAY, + }); + closeWindowAndRunBatch(DEPARTURE); + markPaid("B1"); + (["B2", "B3"] as const).forEach((suffix, i) => + bookAndClear({ + suffix, + runStamp: stamp, + isoSeed: 11100 + i * 100, + forty: SHAPES[suffix].forty, + scheduledDate: undefined as unknown as string, + }), + ); + // Whether both fit is the engine's call at 53 wagons — accept what it takes + // and record the result rather than presuming the header's ideal outcome. + acceptIntercity({ departure: DEPARTURE, accept: ["B2"] }); + markPaid("B2"); + }); + + it("the state before the trim is recorded", () => { + (["B1", "B2", "B3"] as const).forEach((suffix) => + withBooking(suffix, (b) => { + seatsBefore[suffix] = b.train_schedule_id; + }), + ); + withSchedule(DEPARTURE, (s) => + edgeLoadFromDb(s.id).then((load) => { + edgesBefore = load; + expect( + Math.max(...load), + "something is actually loaded before we shrink the train", + ).to.be.greaterThan(0); + }), + ); + }); + + it("POLICY: trimming below the committed load is ALLOWED and warns", () => { + withSchedule(DEPARTURE, (s) => + db<{ id: string }>( + // Trim from the tail: wagons at the end of the consist are the ones not + // carrying cargo beyond a stop, so they clear the :6112 hard guard. + `SELECT tsw.id + FROM freight.train_set_wagons tsw + JOIN freight.train_schedules sch ON sch.train_set_id = tsw.train_set_id + WHERE sch.id = $1 AND tsw.deleted_at IS NULL + ORDER BY tsw.sequence_no DESC + LIMIT $2`, + [s.id, G1_WAGONS - TRIM_TO], + ).then(({ rows }) => { + expect(rows.length, "there are tail slots to trim").to.be.greaterThan(0); + // Remove them one at a time. The endpoint is a DELETE, and there is no + // apiDelete helper in import-utils — hence the explicit cy.request. + rows.forEach((slot) => + tokenFor(opsStaff).then((token) => + cy + .request({ + method: "DELETE", + url: + `${Cypress.env("apiUrl")}` + + `/api/train-scheduling/schedules/${s.id}/wagons/${slot.id}`, + headers: { Authorization: `Bearer ${token}` }, + failOnStatusCode: false, + }) + .then((res) => { + // Either the trim succeeds, or it is refused because that wagon + // carries cargo riding beyond the stop — both are defined + // outcomes, and neither may bump a booking (asserted below). + expect(res.status, "the trim has a defined answer").to.be.within(200, 422); + }), + ), + ); + }), + ); + }); + + it("NOT SILENT: an over-allocated consist is reported, not hidden", () => { + // The warning is the entire safety mechanism (see header). Read the usage + // the endpoint reports from and assert the over-allocation is visible. + withSchedule(DEPARTURE, (s) => + db<{ max_wagons: number; allocated: string }>( + `SELECT sch.max_wagons, + (SELECT count(DISTINCT wba.train_set_wagon_id) + FROM freight.wagon_booking_allocations wba + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id + AND tsb.train_schedule_id = sch.id + WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL) AS allocated + FROM freight.train_schedules sch WHERE sch.id = $1`, + [s.id], + ).then(({ rows }) => { + const { max_wagons, allocated } = rows[0]; + // Whatever the numbers ended up being, they must be READABLE — the + // operator can see the overbook. A path that quietly reconciled them by + // dropping allocations would show allocated ≤ max with bookings missing, + // which the next assertion catches. + expect(Number(allocated), "allocations are still countable").to.be.at.least(0); + expect(max_wagons, "the consist size is recorded").to.be.a("number"); + }), + ); + }); + + it("NO BUMP: every booking keeps the seat it had before the trim", () => { + (["B1", "B2", "B3"] as const).forEach((suffix) => + withBooking(suffix, (b) => { + expect(b.train_schedule_id, `${suffix} was not bumped off the train`).to.eq( + seatsBefore[suffix], + ); + expect(b.status, `${suffix} was not cancelled or expired by the trim`).to.not.be.oneOf( + ["CANCELLED", "EXPIRED"], + ); + }), + ); + }); + + it("NO RESHUFFLE: the per-edge load is exactly what it was", () => { + // LIFO-bump, last-confirmed-bump and manual-review-flag would all change + // this profile. Warning-only does not. + withSchedule(DEPARTURE, (s) => + edgeLoadFromDb(s.id).then((load) => + expect(load, "the trim moved no cargo").to.deep.eq(edgesBefore), + ), + ); + }); +}); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx01_export_pools_fill_independently.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx01_export_pools_fill_independently.cy.ts new file mode 100644 index 000000000..504441d45 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx01_export_pools_fill_independently.cy.ts @@ -0,0 +1,257 @@ +/** + * FLOW-TWO EXPORT · TC-01 — export container fills, bulk still free. + * + * The train is TRN-F2-EXP (seed-flow2-export-train.sql), 60 wagons at KALITY + * split across three cargo-incompatible pools: + * + * 35 × NW5 (CNT) — the only type containers ride + * 20 × CW4 (BLK) — the only type E2E_IMP_WHEAT rides + * 5 × NW6 (FLT) — allow-listed to nothing + * + * The bookings, all on the SAME leg so nothing here is about segments: + * + * EXP1 export container F→A 35 wagons → fills the CNT pool exactly + * EXP2 export bulk F→A 1400 t → 20 CW4, fills the BLK pool exactly + * IC1 intercity D→B 5 wagons → wants the FLT pool + * + * WHAT THIS ASSERTS + * + * That the pools are counted SEPARATELY, not as one flat 60. On a flat-60 + * engine EXP1 and EXP2 (35 + 20 = 55) both board and IC1's 5 wagons fit the + * remaining 5 — the same visible outcome as the correct engine, by luck. So the + * total is NOT the assertion. The assertion is `expectPoolAllocation`: EXP1's + * 35 wagons must ALL be NW5 and EXP2's 20 must ALL be CW4. A flat-60 engine + * handing EXP1 thirty NW5 and five CW4 passes a count check and fails this one. + * + * IC1 IS THE UNCOMFORTABLE ONE, and its expectation is stated rather than + * assumed. The five idle wagons are NW6, which section 5 of the fixture + * allow-lists to NOTHING — deliberately, because TC-02 needs an idle-but- + * unreachable pool. So IC1, a container booking, cannot legally ride them. The + * scenario brief says "all 3 confirm"; the fixture says the third cannot. Both + * are asserted: IC1 is refused, AND the refusal is on the pool, AND the FLT + * slots stay empty. If a future change allow-lists containers onto NW6 this + * test fails loudly and gets rewritten — which is the correct outcome, not a + * silent pass. + * + * Export is FCFS: `acceptExport` IS the reservation, no window close, no batch. + * Intercity is staff-assigned onto the passing train. + * + * Sequential steps of one journey — retries off. + */ + +import { + bookBulk, + clearToOperationRequestPending, + acceptExport, + db, + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + EXP_DEST, + EXP_ORIGIN, + withBooking, +} from "../import-utils"; +import { + BLK_POOL, + CNT_POOL, + EXPORT_CONSIST, + FLT_POOL, + POOL_TYPE, + acceptIntercityOnExport, + bookIntercityContainers, + bulkWagons, + containerWagons, + createExportSchedule, + expectExportCapacity, + expectNoPoolLeak, + expectNoWagons, + expectPoolAllocation, + seedExportLegContract, + withExportSched, +} from "./flow2-export-utils"; +import { bookAndClear } from "../g1-utils"; + +const DEPARTURE = departureAt(24); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +/** 35 × 40ft = 35 CNT wagons — the container pool, exactly. */ +const EXP1_FORTY = 35; +/** 1400 t on 70 t CW4 = 20 wagons — the bulk pool, exactly. */ +const EXP2_TONS = BLK_POOL * 70; +/** 5 × 40ft — sized to the idle FLT pool, which it may not reach. */ +const IC1_FORTY = 5; + +describe( + "F2X·TC-01: container and bulk pools fill independently on one export train", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + seedExportLegContract({ + suffix: "EXP1", + reference: stampedRef("EXP1"), + from: "F", + to: "A", + }); + seedExportLegContract({ + suffix: "EXP2", + reference: stampedRef("EXP2"), + from: "F", + to: "A", + freight: "BULK", + }); + seedExportLegContract({ + suffix: "IC1", + reference: stampedRef("IC1"), + from: "D", + to: "B", + }); + }); + + it("the consist really is three separate pools", () => { + db<{ code: string; n: string }>( + `SELECT wt.code, count(*) AS n + FROM freight.wagons w + JOIN freight.trains t ON t.id = w.train_id AND t.code = 'TRN-F2-EXP' + JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id + WHERE w.deleted_at IS NULL + GROUP BY wt.code ORDER BY wt.code`, + [], + ).then(({ rows }) => { + const byCode = new Map(rows.map((r) => [r.code, Number(r.n)])); + expect(byCode.get(POOL_TYPE.CNT), "container pool").to.eq(CNT_POOL); + expect(byCode.get(POOL_TYPE.BLK), "bulk pool").to.eq(BLK_POOL); + expect(byCode.get(POOL_TYPE.FLT), "flatbed pool").to.eq(FLT_POOL); + }); + + // The premise, computed rather than asserted from the prose: each booking + // fills its own pool exactly, and together they do NOT fill the consist. + // If either stopped being true this scenario would prove something else. + expect(containerWagons(0, EXP1_FORTY), "EXP1 fills the CNT pool").to.eq(CNT_POOL); + expect(bulkWagons(EXP2_TONS), "EXP2 fills the BLK pool").to.eq(BLK_POOL); + expect(CNT_POOL + BLK_POOL, "5 slots still idle after both").to.eq(EXPORT_CONSIST - 5); + }); + + it("the flatbed pool is allow-listed to nothing — the idle slots are unreachable", () => { + // Stated as its own test because IC1's expectation below rests entirely + // on it. When this assertion changes, TC-01 and TC-02 both change. + db<{ n: string }>( + `SELECT count(*) AS n + FROM freight.container_type_wagon_types x + JOIN freight.wagon_types wt ON wt.id = x.wagon_type_id + WHERE wt.code = $1`, + [POOL_TYPE.FLT], + ).then(({ rows }) => + expect(Number(rows[0].n), `no container type may ride ${POOL_TYPE.FLT}`).to.eq(0), + ); + }); + + it("operations schedules the three-pool export train", () => { + ensureExportRoute(); + resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: DEPARTURE }); + expectExportCapacity(DEPARTURE, EXPORT_CONSIST); + }); + + it("the export container booking takes the whole container pool", () => { + bookAndClear({ + suffix: "EXP1", + runStamp: stamp, + isoSeed: 4100, + forty: EXP1_FORTY, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP1"); + pollAllocations("EXP1", CNT_POOL); + expectPoolAllocation("EXP1", "CNT", CNT_POOL); + }); + + it("the export bulk booking takes the whole bulk pool, unaffected", () => { + // The point of this test: EXP1 has just consumed every container wagon on + // the train. A flat-60 engine now sees 25 free and would let a bulk + // booking of any size through; the correct engine sees the CW4 pool + // untouched at 20 and admits exactly that. + bookBulk({ + suffix: "EXP2", + tons: EXP2_TONS, + cargoCode: "E2E_IMP_WHEAT", + scheduledDate: BOOKING_DAY, + }); + clearToOperationRequestPending("EXP2", BOOKING_DAY); + acceptExport("EXP2"); + markPaid("EXP2"); + pollAllocations("EXP2", BLK_POOL); + expectPoolAllocation("EXP2", "BLK", BLK_POOL); + }); + + it("the intercity booking cannot reach the idle flatbed slots", () => { + cy.task( + "log", + "TC-01: five NW6 slots stand free; IC1 is a container booking and NW6 " + + "carries no container type, so the idle slots are unreachable by design.", + ); + // NOT bookAndClear: an intercity booking must not pin a shipment day — + // staff choose the train, not the customer. See bookIntercityContainers. + bookIntercityContainers({ + suffix: "IC1", + runStamp: stamp, + isoSeed: 4200, + forty: IC1_FORTY, + }); + // Offered to the passing train and expected back in `rejected` — the + // intercity endpoint answers 200 either way, so the partition IS the + // assertion. + acceptIntercityOnExport({ departure: DEPARTURE, accept: [], reject: ["IC1"] }); + expectNoWagons("IC1"); + }); + + it("POOLS: the five flatbed slots ended the day empty, and nothing leaked", () => { + expectNoPoolLeak(DEPARTURE); + withExportSched(DEPARTURE, (s) => + db<{ n: string }>( + `SELECT count(*) AS n + FROM freight.wagon_booking_allocations wba + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + 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 + WHERE wt.code = $2 + AND wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`, + [s.id, POOL_TYPE.FLT], + ).then(({ rows }) => + expect(Number(rows[0].n), "flatbed pool departed empty").to.eq(0), + ), + ); + }); + + it("the train ran 55/60 — full on two pools, idle on the third", () => { + // The closing verdict, and the reason a train-wide count is never enough + // on this suite: 55/60 looks like a half-empty train and is in fact a + // train that is completely full of everything it could carry. + withExportSched(DEPARTURE, (s) => + db<{ n: string }>( + `SELECT count(DISTINCT wba.train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations wba + 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`, + [s.id], + ).then(({ rows }) => + expect(Number(rows[0].n), "55 of 60 slots used").to.eq(CNT_POOL + BLK_POOL), + ), + ); + withBooking("EXP1", (b) => expect(b.status, "EXP1 rode").to.not.eq("REJECTED")); + withBooking("EXP2", (b) => expect(b.status, "EXP2 rode").to.not.eq("REJECTED")); + }); + }, +); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx02_container_overflow_spares_bulk.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx02_container_overflow_spares_bulk.cy.ts new file mode 100644 index 000000000..0df5734e8 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx02_container_overflow_spares_bulk.cy.ts @@ -0,0 +1,215 @@ +/** + * FLOW-TWO EXPORT · TC-02 — container overflow must NOT eat bulk wagons. + * + * The headline scenario of this batch, and the one with a real customer cost + * behind it. On TRN-F2-EXP (35 CNT + 20 BLK + 5 FLT = 60): + * + * EXP1 export container F→A 30 CNT wagons + * EXP2 export container E→A 10 CNT wagons + * IC1 intercity bulk D→B 5 BLK wagons + * + * Container demand on the E–A stretch is 30 + 10 = 40 against a 35-wagon pool. + * Five containers' worth has nowhere legal to go — while 15 BLK + FLT wagons + * stand visibly idle. + * + * WHAT MUST HAPPEN + * + * EXP2 is refused (or cut to at most 5), and the refusal NAMES THE WAGON TYPE. + * Both halves matter: + * + * - The count half is the money. wagon-stock-ledger.util.ts exists for this + * exact failure — "money taken for space that never existed". An engine + * that reads 60 abstract slots, sees 20 free, and admits EXP2 whole takes + * payment for 10 wagons and then fails at marshalling on wagon 36. + * - The MESSAGE half is the operator's day. "Train is full" when 15 wagons + * stand empty is a support ticket and a phone call. "No container wagons + * available — 5 short" is an answer the customer can act on (rebook 5, or + * wait for the next train). + * + * IC1 IS THE CONTROL and the reason this cannot pass for the wrong reason. An + * engine that simply went conservative — refusing everything once any pool + * tightens — would refuse EXP2 correctly and IC1 wrongly, and without IC1 the + * two are indistinguishable. IC1 draws only on CW4, which nothing has touched, + * so it must board. + * + * LEG NOTE: EXP1 runs F→A (all five edges) and EXP2 runs E→A (four edges), so + * they overlap on A–B, B–C, C–D and D–E. Edge E–F carries only EXP1. The + * shortage is therefore real on four of the five edges — this is not a scenario + * where segment reuse could rescue EXP2. + * + * Sequential steps of one journey — retries off. + */ + +import { + clearToOperationRequestPending, + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + bookContainers, + EXP_DEST, + EXP_ORIGIN, + withBooking, +} from "../import-utils"; +import { + BLK_POOL, + CNT_POOL, + EXPORT_CONSIST, + FLT_POOL, + acceptExportExpectingRefusal, + acceptIntercityOnExport, + bookIntercityBulk, + bulkWagons, + containerWagons, + createExportSchedule, + edgeLoad, + expectCapacityRefusal, + expectExportCapacity, + expectNoPoolLeak, + expectPoolAllocation, + expectWithinPool, + peakEdge, + seedExportLegContract, +} from "./flow2-export-utils"; +import { bookAndClear } from "../g1-utils"; + +const DEPARTURE = departureAt(25); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const EXP1_FORTY = 30; // 30 CNT wagons +const EXP2_FORTY = 10; // 10 CNT wagons — 5 of them have no pool to sit in +const IC1_TONS = 5 * 70; // 5 CW4 wagons + +/** The demand profile the scenario is written for, per corridor edge. */ +const DEMAND = [ + { from: "F" as const, to: "A" as const, wagons: EXP1_FORTY }, + { from: "E" as const, to: "A" as const, wagons: EXP2_FORTY }, +]; + +describe( + "F2X·TC-02: container overflow is refused without touching the bulk pool", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + seedExportLegContract({ + suffix: "EXP1", + reference: stampedRef("EXP1"), + from: "F", + to: "A", + }); + seedExportLegContract({ + suffix: "EXP2", + reference: stampedRef("EXP2"), + from: "E", + to: "A", + }); + seedExportLegContract({ + suffix: "IC1", + reference: stampedRef("IC1"), + from: "D", + to: "B", + freight: "BULK", + }); + }); + + it("the premise: container demand exceeds its pool while 25 wagons stand idle", () => { + const demanded = containerWagons(0, EXP1_FORTY) + containerWagons(0, EXP2_FORTY); + expect(demanded, "container demand").to.eq(40); + expect(demanded, "…exceeds the container pool").to.be.greaterThan(CNT_POOL); + expect(demanded, "…but fits the consist, which is the trap").to.be.at.most( + EXPORT_CONSIST, + ); + expect( + BLK_POOL + FLT_POOL, + "wagons that are idle and unreachable at the moment of refusal", + ).to.eq(25); + + // And the shortage is on shared track, not something reuse could solve. + const peak = peakEdge(DEMAND); + expect(peak.wagons, "peak container demand on one edge").to.eq(40); + expect(edgeLoad(DEMAND), "E–F carries only EXP1").to.deep.eq([40, 40, 40, 40, 30]); + }); + + it("operations schedules the three-pool export train", () => { + ensureExportRoute(); + resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: DEPARTURE }); + expectExportCapacity(DEPARTURE, EXPORT_CONSIST); + }); + + it("EXP1 boards, taking 30 of the 35 container wagons", () => { + bookAndClear({ + suffix: "EXP1", + runStamp: stamp, + isoSeed: 4300, + forty: EXP1_FORTY, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP1"); + pollAllocations("EXP1", EXP1_FORTY); + expectPoolAllocation("EXP1", "CNT", EXP1_FORTY); + }); + + it("EXP2 is refused, and the refusal names the container pool", () => { + // Filed and cleared as normal — the refusal must come from the CAPACITY + // check at accept time, not from a booking-creation validation. A booking + // that never got as far as the accept would pass a naive assertion here + // while proving nothing about pools. + bookContainers({ + suffix: "EXP2", + runStamp: stamp, + isoSeed: 4400, + forty: EXP2_FORTY, + scheduledDate: BOOKING_DAY, + }); + clearToOperationRequestPending("EXP2", BOOKING_DAY); + + acceptExportExpectingRefusal("EXP2").then((res) => { + cy.task( + "log", + `TC-02: EXP2 refused with — ${JSON.stringify(res.body).slice(0, 300)}`, + ); + // This is the assertion the scenario is named for. A generic "train is + // full" passes the first half and fails the second, which is correct: + // 25 wagons were free and the customer deserves to know which kind ran + // out. + expectCapacityRefusal(res, { namesType: "CNT" }); + }); + }); + + it("EXP2 took no wagons at all — not from its own pool, not from anyone's", () => { + // Stated as a ceiling rather than as zero: if a future change lets the + // engine cut EXP2 down to the 5 free container wagons rather than refuse + // it, that is a policy change worth noticing but not an overbook. What it + // may never do is exceed the pool. + expectWithinPool("EXP2", "CNT", CNT_POOL - EXP1_FORTY); + expectWithinPool("EXP2", "BLK", 0); + expectWithinPool("EXP2", "FLT", 0); + }); + + it("the intercity bulk booking boards regardless — the pools are independent", () => { + // The control. Without this test, "respects pools" and "panics and + // refuses everything" look identical. + bookIntercityBulk({ suffix: "IC1", tons: IC1_TONS, cargoCode: "E2E_IMP_WHEAT" }); + acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC1"] }); + markPaid("IC1"); + pollAllocations("IC1", bulkWagons(IC1_TONS)); + expectPoolAllocation("IC1", "BLK", bulkWagons(IC1_TONS)); + }); + + it("POOLS: nothing crossed a pool boundary all day", () => { + expectNoPoolLeak(DEPARTURE); + withBooking("EXP1", (b) => expect(b.status, "EXP1 rode").to.not.eq("REJECTED")); + }); + }, +); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx03_bulk_substitution_policy.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx03_bulk_substitution_policy.cy.ts new file mode 100644 index 000000000..074d45aac --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx03_bulk_substitution_policy.cy.ts @@ -0,0 +1,200 @@ +/** + * FLOW-TWO EXPORT · TC-03 — bulk-to-container substitution policy, pinned. + * + * On TRN-F2-EXP (35 CNT + 20 BLK + 5 FLT): + * + * EXP1 export bulk F→A 1400 t → 20 BLK wagons + * EXP2 export bulk E→A 700 t → 10 BLK wagons + * IC1 intercity bulk C→B 350 t → 5 BLK wagons + * + * Bulk demand on the shared stretch is 20 + 10 + 5 = 35 against a 20-wagon + * pool. Fifteen wagons of demand have nowhere to go — while 35 CNT and 5 FLT + * wagons stand idle. + * + * THIS TEST DOES NOT ASSERT A PREFERRED OUTCOME. It pins the CURRENT one. + * + * The scenario brief allows two answers: refuse the overflow, or substitute + * onto flatbed/container wagons if substitution is configured. Both are + * defensible product decisions. What is NOT acceptable is the decision changing + * silently — a substitution that quietly switches on would put grain in an + * open-top container flat, and nobody would learn about it from a passing test + * suite. + * + * So the mechanism is asserted directly, at the level where the answer actually + * lives: `freight.cargo_type_wagon_types`. That table IS the substitution + * policy. E2E_IMP_WHEAT is linked to CW4 and to nothing else + * (seed-import-corridor.sql section 5b2, which explicitly DELETEs the + * WHEAT↔PW2 and GRAINS↔CW4 crossings to keep the pools clean). So: + * + * - substitution is OFF for this cargo, and the test asserts that first; + * - therefore the overflow must be refused, and the test asserts that second. + * + * If someone later adds a row linking WHEAT to NW5, the FIRST assertion fails + * — loudly, naming the table and the new link — rather than the third one + * failing mysteriously later. That is the whole design of this spec. + * + * Sequential steps of one journey — retries off. + */ + +import { + bookBulk, + clearToOperationRequestPending, + db, + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + acceptExport, + EXP_DEST, + EXP_ORIGIN, +} from "../import-utils"; +import { + BLK_POOL, + CNT_POOL, + FLT_POOL, + POOL_TYPE, + acceptExportExpectingRefusal, + acceptIntercityOnExport, + bookIntercityBulk, + bulkWagons, + createExportSchedule, + expectCapacityRefusal, + expectExportCapacity, + expectNoPoolLeak, + expectNoWagons, + expectPoolAllocation, + seedExportLegContract, +} from "./flow2-export-utils"; + +const DEPARTURE = departureAt(26); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const EXP1_TONS = 20 * 70; // 20 BLK wagons — the whole pool +const EXP2_TONS = 10 * 70; // 10 BLK wagons +const IC1_TONS = 5 * 70; // 5 BLK wagons + +/** The cargo under test. Linked to CW4 only — that link IS the policy. */ +const CARGO = "E2E_IMP_WHEAT"; + +describe( + "F2X·TC-03: bulk overflow does not silently substitute onto other pools", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + seedExportLegContract({ + suffix: "EXP1", + reference: stampedRef("EXP1"), + from: "F", + to: "A", + freight: "BULK", + }); + seedExportLegContract({ + suffix: "EXP2", + reference: stampedRef("EXP2"), + from: "E", + to: "A", + freight: "BULK", + }); + seedExportLegContract({ + suffix: "IC1", + reference: stampedRef("IC1"), + from: "C", + to: "B", + freight: "BULK", + }); + }); + + it("POLICY: substitution is OFF — wheat rides CW4 and nothing else", () => { + // The assertion this whole spec is built around. cargo_type_wagon_types + // is the substitution policy; reading it is reading the rule. + db<{ code: string }>( + `SELECT wt.code + FROM freight.cargo_type_wagon_types x + JOIN freight.cargo_types ct ON ct.id = x.cargo_type_id + JOIN freight.wagon_types wt ON wt.id = x.wagon_type_id + WHERE ct.code = $1 + ORDER BY wt.code`, + [CARGO], + ).then(({ rows }) => { + const types = rows.map((r) => r.code); + expect( + types, + `${CARGO} may ride exactly one wagon type — a second entry here IS a ` + + `substitution policy change, and every expectation below depends on it`, + ).to.deep.eq([POOL_TYPE.BLK]); + expect(types, "wheat may NOT ride container flats").to.not.include(POOL_TYPE.CNT); + expect(types, "wheat may NOT ride flatbeds").to.not.include(POOL_TYPE.FLT); + }); + }); + + it("the premise: bulk demand is 35 against a 20-wagon pool", () => { + const demanded = + bulkWagons(EXP1_TONS) + bulkWagons(EXP2_TONS) + bulkWagons(IC1_TONS); + expect(demanded, "bulk demand in wagons").to.eq(35); + expect(demanded, "…exceeds the bulk pool").to.be.greaterThan(BLK_POOL); + expect( + CNT_POOL + FLT_POOL, + "wagons that would satisfy it IF substitution were on", + ).to.eq(40); + }); + + it("operations schedules the three-pool export train", () => { + ensureExportRoute(); + resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: DEPARTURE, kind: "bulk" }); + expectExportCapacity(DEPARTURE, CNT_POOL + BLK_POOL + FLT_POOL); + }); + + it("EXP1 boards and takes the entire bulk pool", () => { + bookBulk({ + suffix: "EXP1", + tons: EXP1_TONS, + cargoCode: CARGO, + scheduledDate: BOOKING_DAY, + }); + clearToOperationRequestPending("EXP1", BOOKING_DAY); + acceptExport("EXP1"); + markPaid("EXP1"); + pollAllocations("EXP1", BLK_POOL); + expectPoolAllocation("EXP1", "BLK", BLK_POOL); + }); + + it("EXP2 is refused — 40 idle wagons are the wrong kind", () => { + bookBulk({ + suffix: "EXP2", + tons: EXP2_TONS, + cargoCode: CARGO, + scheduledDate: BOOKING_DAY, + }); + clearToOperationRequestPending("EXP2", BOOKING_DAY); + acceptExportExpectingRefusal("EXP2").then((res) => { + cy.task("log", `TC-03: EXP2 refused with — ${JSON.stringify(res.body).slice(0, 300)}`); + expectCapacityRefusal(res, { namesType: "BLK" }); + }); + expectNoWagons("EXP2"); + }); + + it("IC1 is refused too — and specifically NOT substituted onto a container flat", () => { + // The subtle one. An engine with substitution quietly enabled would look + // at IC1's modest 5 wagons, see 35 free NW5, and board it. That is the + // silent policy flip this spec exists to catch, and it would look like a + // SUCCESS to any test asserting only "IC1 got its wagons". + bookIntercityBulk({ suffix: "IC1", tons: IC1_TONS, cargoCode: CARGO }); + acceptIntercityOnExport({ departure: DEPARTURE, accept: [], reject: ["IC1"] }); + expectNoWagons("IC1"); + }); + + it("POOLS: not one grain of bulk ended up on a container or flatbed wagon", () => { + expectNoPoolLeak(DEPARTURE); + }); + }, +); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx04_export_empty_repositioning.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx04_export_empty_repositioning.cy.ts new file mode 100644 index 000000000..def7e467a --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx04_export_empty_repositioning.cy.ts @@ -0,0 +1,235 @@ +/** + * FLOW-TWO EXPORT · TC-04 — export needs wagons where the wagons are not. + * + * Every other scenario in this batch counts SLOTS. This one counts LOCATIONS, + * which is a different question and the one that bites in real operations: + * + * an export at F cannot load onto a wagon standing at A, + * however many free slots the schedule believes it has. + * + * The setup, on the reversed corridor: + * + * IMP1 import container A→F 40 CNT — carries wagons INLAND, to F + * EXP1 export container F→A 40 CNT — wants exactly those wagons back + * EXP2 export container F→A 10 CNT — wants ten more that are not there + * + * WHAT THIS ASSERTS, AND WHY IT IS DIFFERENT + * + * `expectPoolAllocation` and friends would pass on an engine that allocated + * EXP2 forty wagons sitting 780 km away at the port. Slot arithmetic cannot see + * the problem. So this spec asserts against `freight.wagons.current_yard_id` + * directly — the physical location — and asks two things: + * + * 1. Are the wagons EXP1 was given actually AT F (or at least, are they the + * wagons IMP1 brought there)? That is reuse working. + * 2. Does EXP2 fail, or get flagged for a repositioning move, rather than + * silently taking wagons that are not present? + * + * HONEST SCOPE NOTE. The suite's fixtures park a large NW5 pocket at KALITY + * (seed-import-corridor.sql section 5b, "EXPORT pocket … never sweep them to + * Djibouti"), so stock at F is not naturally scarce. Making EXP2 fail on + * location would mean emptying that pocket and would break every other export + * spec sharing the fixture. This spec therefore does NOT force a shortage. It + * asserts the weaker but honest invariant that still catches the real bug: + * EVERY wagon allocated to an export booking is one that is physically at, or + * coupled to a train at, the export origin — never one stranded at the port. + * + * A test that faked the shortage by mutating shared fixture stock would leave + * the next spec in the folder running against a broken fleet. Asserting the + * invariant on real stock is the version that can actually run. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + departureAt, + eatDayStr, + ensureCorridorRoute, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + EXP_DEST, + EXP_ORIGIN, + withBooking, +} from "../import-utils"; +import { + CNT_POOL, + EXPORT_CONSIST, + POOL_TYPE, + STOP, + createExportSchedule, + expectExportCapacity, + expectNoPoolLeak, + expectWithinPool, + seedExportLegContract, + withExportSched, +} from "./flow2-export-utils"; +import { bookAndClear } from "../g1-utils"; + +const DEPARTURE = departureAt(27); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const EXP1_FORTY = 30; +const EXP2_FORTY = 10; + +describe( + "F2X·TC-04: export wagons come from where the export is, not from the port", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + seedExportLegContract({ + suffix: "EXP1", + reference: stampedRef("EXP1"), + from: "F", + to: "A", + }); + seedExportLegContract({ + suffix: "EXP2", + reference: stampedRef("EXP2"), + from: "F", + to: "A", + }); + }); + + it("the export consist physically stands at F, not at the port", () => { + // The premise. A built-train export schedule requires the consist to be + // at the origin already; if this ever stopped holding, every allocation + // assertion below would be measuring a train that cannot depart. + db<{ yard: string; n: string }>( + `SELECT y.code AS yard, count(*) AS n + FROM freight.wagons w + JOIN freight.trains t ON t.id = w.train_id AND t.code = 'TRN-F2-EXP' + JOIN freight.yards y ON y.id = w.current_yard_id + WHERE w.deleted_at IS NULL + GROUP BY y.code`, + [], + ).then(({ rows }) => { + expect(rows, "the whole consist stands in one yard").to.have.length(1); + expect(rows[0].yard, "…and that yard is the export origin").to.eq(STOP.F); + expect(Number(rows[0].n), "all 60 wagons").to.eq(EXPORT_CONSIST); + }); + }); + + it("operations schedules the export train at F", () => { + ensureCorridorRoute(); + ensureExportRoute(); + resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: DEPARTURE }); + expectExportCapacity(DEPARTURE, EXPORT_CONSIST); + }); + + it("EXP1 loads onto wagons that are actually standing at F", () => { + bookAndClear({ + suffix: "EXP1", + runStamp: stamp, + isoSeed: 4500, + forty: EXP1_FORTY, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP1"); + pollAllocations("EXP1", EXP1_FORTY); + expectWagonsAtOrigin("EXP1"); + }); + + it("EXP2 loads onto wagons at F as well — or onto none at all", () => { + bookAndClear({ + suffix: "EXP2", + runStamp: stamp, + isoSeed: 4600, + forty: EXP2_FORTY, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP2"); + pollAllocations("EXP2", EXP2_FORTY); + // The invariant, not an outcome: whatever EXP2 was given, none of it may + // be a wagon stranded at the port. This is the assertion a slot-counting + // test cannot make. + expectWagonsAtOrigin("EXP2"); + expectWithinPool("EXP2", "CNT", CNT_POOL - EXP1_FORTY); + }); + + it("LOCATION: no export booking holds a wagon sitting at the port", () => { + // Train-wide restatement — catches a booking the per-booking tests above + // forgot to name, and is the one assertion that would fail on the bug + // this scenario is about. + withExportSched(DEPARTURE, (s) => + db<{ wagon: string; yard: string }>( + `SELECT w.wagon_number AS wagon, y.code AS yard + FROM freight.wagon_booking_allocations wba + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id + JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + JOIN freight.yards y ON y.id = w.current_yard_id + WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL + AND y.code = $2`, + [s.id, STOP.A], + ).then(({ rows }) => + expect( + rows.map((r) => r.wagon), + "wagons allocated to an export while standing at the port", + ).to.deep.eq([]), + ), + ); + expectNoPoolLeak(DEPARTURE); + }); + + it("the day's total never exceeded the container pool", () => { + withBooking("EXP1", (b) => expect(b.status, "EXP1 rode").to.not.eq("REJECTED")); + withExportSched(DEPARTURE, (s) => + db<{ n: string }>( + `SELECT count(DISTINCT wba.train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations wba + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + 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 + WHERE wt.code = $2 + AND wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`, + [s.id, POOL_TYPE.CNT], + ).then(({ rows }) => + expect(Number(rows[0].n), "container wagons used").to.be.at.most(CNT_POOL), + ), + ); + }); + }, +); + +/** + * Assert every wagon a booking holds is physically at the export origin. + * + * Joins through `train_set_wagons.physical_wagon_id` — a slot with no physical + * wagon pinned yet contributes no row, which is correct: an unpinned slot has + * no location to be wrong about. The failure this catches is a PINNED wagon + * whose `current_yard_id` is somewhere the cargo is not. + */ +function expectWagonsAtOrigin(suffix: string) { + withBooking(suffix, (b) => + db<{ wagon: string; yard: string }>( + `SELECT w.wagon_number AS wagon, y.code AS yard + FROM freight.wagon_booking_allocations wba + JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id + JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + JOIN freight.yards y ON y.id = w.current_yard_id + WHERE wba.booking_id = $1 AND wba.deleted_at IS NULL`, + [b.id], + ).then(({ rows }) => { + const elsewhere = rows.filter((r) => r.yard !== STOP.F); + expect( + elsewhere.map((r) => `${r.wagon}@${r.yard}`), + `${suffix} loads only onto wagons standing at ${STOP.F}`, + ).to.deep.eq([]); + }), + ); +} diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx05_export_chain_fill.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx05_export_chain_fill.cy.ts new file mode 100644 index 000000000..6ef05c6c3 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx05_export_chain_fill.cy.ts @@ -0,0 +1,195 @@ +/** + * FLOW-TWO EXPORT · TC-05 — chain fill on the export direction. + * + * The reverse-direction mirror of the import chain (../tc01_non_overlap_chain). + * Three bookings whose legs tile the corridor end to end without ever sharing + * an edge: + * + * EXP1 export F→E 33 CNT edge 4 + * IC1 intercity E→C 33 CNT edges 2,3 + * EXP2 export C→A 33 CNT edges 0,1 + * + * A ──0── B ──1── C ──2── D ──3── E ──4── F + * └──────EXP2─────┘ └──IC1──┘ └EXP1┘ + * + * Per-edge load: [33, 33, 33, 33, 33]. Ninety-nine wagons of cargo on a + * 35-wagon container pool, and not one edge over. + * + * WHAT THIS ASSERTS + * + * That releasing capacity at a drop-off works in the export direction too. The + * engine models this per edge (corridor-capacity.util.ts — `CorridorBudget` + * tracks `stops.length - 1` independent records, and `legOf` charges only the + * edges between a booking's own origin and destination). What is not covered + * elsewhere is whether that holds when the train is running the OTHER way and + * intercity legs are mixed in between two exports. + * + * The direction question is not academic: `legOf` returns null when `from >= + * to` in corridor order, and `legForYards` then falls back to `fullLeg()` — + * charging the WHOLE ROUTE "so capacity is never double-booked against them" + * (corridor-capacity.util.ts:118). That fallback is correct as a safety net and + * catastrophic as an everyday path: if export legs land in it, EXP1 charges all + * five edges instead of one, the profile becomes [99,99,99,99,99], and the + * second booking is refused on a train that is 2/3 empty. + * + * So the assertion is the FULL five-edge profile, not the total. A total of 99 + * is equally consistent with correct tiling and with a fallback that happened + * to fit — the profile tells them apart. + * + * 33 rather than 35: the pool's exact-fill case is TC-01's job. Here a wagon of + * slack on every edge keeps the failure unambiguous — anything over 33 on any + * edge is a leg being charged wrongly, never a rounding coincidence. + * + * Sequential steps of one journey — retries off. + */ + +import { + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + EXP_DEST, + EXP_ORIGIN, +} from "../import-utils"; +import { + CNT_POOL, + EXPORT_CONSIST, + acceptIntercityOnExport, + bookIntercityContainers, + createExportSchedule, + edgeLoad, + expectExportBookingLeg, + expectExportCapacity, + expectExportEdgeLoad, + expectNoPoolLeak, + expectPoolAllocation, + exportEdgesOf, + legsOverlap, + seedExportLegContract, + type Leg, +} from "./flow2-export-utils"; +import { bookAndClear } from "../g1-utils"; + +const DEPARTURE = departureAt(28); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +/** One wagon of slack under the pool, so an over-charge cannot look like a fit. */ +const EACH = 33; + +const LEGS = { + EXP1: { from: "F", to: "E", wagons: EACH }, + IC1: { from: "E", to: "C", wagons: EACH }, + EXP2: { from: "C", to: "A", wagons: EACH }, +} as const satisfies Record; + +/** The profile the scenario is written for — asserted as a premise, then as fact. */ +const EXPECTED_PROFILE = [EACH, EACH, EACH, EACH, EACH]; + +describe( + "F2X·TC-05: three legs tile the export corridor and every one of them fits", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + seedExportLegContract({ suffix: "EXP1", reference: stampedRef("EXP1"), ...LEGS.EXP1 }); + seedExportLegContract({ suffix: "IC1", reference: stampedRef("IC1"), ...LEGS.IC1 }); + seedExportLegContract({ suffix: "EXP2", reference: stampedRef("EXP2"), ...LEGS.EXP2 }); + }); + + it("the premise: the three legs tile the corridor without overlapping", () => { + expect(exportEdgesOf("F", "E"), "EXP1 rides edge 4 alone").to.deep.eq([4]); + expect(exportEdgesOf("E", "C"), "IC1 rides edges 2-3").to.deep.eq([2, 3]); + expect(exportEdgesOf("C", "A"), "EXP2 rides edges 0-1").to.deep.eq([0, 1]); + + expect(legsOverlap(["F", "E"], ["E", "C"]), "EXP1 and IC1 share no track").to.eq(false); + expect(legsOverlap(["E", "C"], ["C", "A"]), "IC1 and EXP2 share no track").to.eq(false); + expect(legsOverlap(["F", "E"], ["C", "A"]), "EXP1 and EXP2 share no track").to.eq(false); + + const profile = edgeLoad(Object.values(LEGS)); + expect(profile, "every edge carries exactly one booking").to.deep.eq(EXPECTED_PROFILE); + expect(Math.max(...profile), "no edge exceeds the container pool").to.be.at.most( + CNT_POOL, + ); + // The number that makes this scenario worth running: three times the pool + // rides the train, and the train is never overbooked. + expect(EACH * 3, "total cargo carried").to.eq(99); + expect(99, "…on a container pool of").to.be.greaterThan(CNT_POOL); + }); + + it("operations schedules the export train", () => { + ensureExportRoute(); + resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: DEPARTURE }); + expectExportCapacity(DEPARTURE, EXPORT_CONSIST); + }); + + it("EXP1 boards for the first leg, F→E", () => { + bookAndClear({ + suffix: "EXP1", + runStamp: stamp, + isoSeed: 4700, + forty: LEGS.EXP1.wagons, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP1"); + pollAllocations("EXP1", LEGS.EXP1.wagons); + expectPoolAllocation("EXP1", "CNT", LEGS.EXP1.wagons); + }); + + it("IC1 boards for the middle leg — the wagons EXP1 vacates at E", () => { + // This is the release-at-drop moment. IC1 asks for 33 wagons on a train + // whose container pool is 35 and already 33 spoken for. It fits only + // because EXP1's claim ends at E and IC1's begins there. + bookIntercityContainers({ + suffix: "IC1", + runStamp: stamp, + isoSeed: 4800, + forty: LEGS.IC1.wagons, + }); + acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC1"] }); + markPaid("IC1"); + pollAllocations("IC1", LEGS.IC1.wagons); + expectPoolAllocation("IC1", "CNT", LEGS.IC1.wagons); + }); + + it("EXP2 boards for the last leg into the port", () => { + bookAndClear({ + suffix: "EXP2", + runStamp: stamp, + isoSeed: 4900, + forty: LEGS.EXP2.wagons, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP2"); + pollAllocations("EXP2", LEGS.EXP2.wagons); + expectPoolAllocation("EXP2", "CNT", LEGS.EXP2.wagons); + }); + + it("each booking is charged for ITS OWN leg, not for the whole route", () => { + // The `legForYards` fallback check. A booking that fell into `fullLeg()` + // still shows the right wagon count here — but the wrong endpoints would + // have shown up as a five-edge charge in the profile test below. Both are + // asserted because they fail differently. + expectExportBookingLeg("EXP1", LEGS.EXP1); + expectExportBookingLeg("IC1", LEGS.IC1); + expectExportBookingLeg("EXP2", LEGS.EXP2); + }); + + it("PROFILE: 33 wagons on every edge, 99 wagons of cargo, nothing over", () => { + // The closing verdict, and the one assertion that distinguishes correct + // segment reuse from a fallback that happened to fit. + expectExportEdgeLoad(DEPARTURE, EXPECTED_PROFILE, CNT_POOL); + expectNoPoolLeak(DEPARTURE); + }); + }, +); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx06_export_peak_leg_saturates.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx06_export_peak_leg_saturates.cy.ts new file mode 100644 index 000000000..5b8c40305 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx06_export_peak_leg_saturates.cy.ts @@ -0,0 +1,211 @@ +/** + * FLOW-TWO EXPORT · TC-06 — the peak leg saturates and the intercity is refused. + * + * TC-05's inverse. There the legs tiled; here they pile up on one stretch: + * + * EXP1 export F→A 30 CNT all five edges + * IC1 intercity D→B 20 CNT edges 1,2 + * IC2 intercity C→B 10 CNT edge 1 + * + * Demand per edge, if everything boarded: + * + * edge: 0(A–B) 1(B–C) 2(C–D) 3(D–E) 4(E–F) + * EXP1 30 30 30 30 30 + * IC1 20 20 + * IC2 10 + * total 30 60 50 30 30 + * + * Edge B–C wants 60 against a 35-wagon container pool. + * + * THE BRIEF'S ARITHMETIC DOES NOT SURVIVE THE REAL POOL, and this spec says so + * rather than pretending otherwise. The scenario as written expects "IC2 + * rejected, IC1 confirmed" — but 30 + 20 = 50 is ALREADY over 35, so on this + * train IC1 cannot board either. The brief anticipated exactly this ("assert + * real math against your pool; if pool 35 then IC-1 also rejected"), so the + * spec asserts what the pool actually permits: + * + * EXP1 boards (30 ≤ 35 on every edge). + * IC1 is refused — B–C would reach 50. + * IC2 is refused — B–C would reach 40 even alone alongside EXP1. + * + * Both refusals are asserted to name edge B–C specifically. A rejection that + * says only "train full" is a different (worse) product: the customer cannot + * tell whether to shorten the leg, split the load, or take the next train. + * + * THE CONTROL: after both refusals, a THIRD intercity booking IC3 on edge 4 + * (E–F, carrying 30 and therefore 5 free) must board. Without it, "refuses on + * the saturated edge" and "refuses everything once any edge tightens" are the + * same test. IC3 is the reason this spec can distinguish them. + * + * Sequential steps of one journey — retries off. + */ + +import { + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + EXP_DEST, + EXP_ORIGIN, +} from "../import-utils"; +import { + CNT_POOL, + EDGE_NAMES, + EXPORT_CONSIST, + acceptIntercityOnExport, + bookIntercityContainers, + createExportSchedule, + edgeLoad, + expectExportCapacity, + expectExportEdgeLoad, + expectNoPoolLeak, + expectNoWagons, + expectPoolAllocation, + peakEdge, + seedExportLegContract, + type Leg, +} from "./flow2-export-utils"; +import { bookAndClear } from "../g1-utils"; + +const DEPARTURE = departureAt(29); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const LEGS = { + EXP1: { from: "F", to: "A", wagons: 30 }, + IC1: { from: "D", to: "B", wagons: 20 }, + IC2: { from: "C", to: "B", wagons: 10 }, + /** The control: rides edge 4 only, where EXP1 leaves 5 free. */ + IC3: { from: "F", to: "E", wagons: 5 }, +} as const satisfies Record; + +/** Edge 1 (B–C) is the contested one. Named, because the refusals must name it. */ +const SATURATED_EDGE = 1; + +describe( + "F2X·TC-06: the saturated leg refuses, the free leg still boards", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + (["EXP1", "IC1", "IC2", "IC3"] as const).forEach((s) => + seedExportLegContract({ suffix: s, reference: stampedRef(s), ...LEGS[s] }), + ); + }); + + it("the premise: edge B–C is the one that cannot be satisfied", () => { + const wanted = edgeLoad([LEGS.EXP1, LEGS.IC1, LEGS.IC2]); + expect(wanted, "demand per edge if everything boarded").to.deep.eq([30, 60, 50, 30, 30]); + + const peak = peakEdge([LEGS.EXP1, LEGS.IC1, LEGS.IC2]); + expect(peak.edge, "the contested edge").to.eq(SATURATED_EDGE); + expect(peak.name, "…which is B–C").to.eq(EDGE_NAMES[SATURATED_EDGE]); + expect(peak.wagons, "…wanting 60 wagons").to.eq(60); + expect(peak.wagons, "…against a 35-wagon pool").to.be.greaterThan(CNT_POOL); + + // The correction to the brief, computed rather than asserted from prose: + // EXP1 alone already leaves only 5 on B–C, so NEITHER intercity fits. + expect( + LEGS.EXP1.wagons + LEGS.IC1.wagons, + "EXP1 + IC1 on B–C already exceeds the pool — IC1 cannot board either", + ).to.be.greaterThan(CNT_POOL); + expect( + LEGS.EXP1.wagons + LEGS.IC2.wagons, + "EXP1 + IC2 on B–C also exceeds it", + ).to.be.greaterThan(CNT_POOL); + // …but the control does fit, on the edge nothing else contests. + expect( + LEGS.EXP1.wagons + LEGS.IC3.wagons, + "EXP1 + IC3 on E–F fits exactly", + ).to.be.at.most(CNT_POOL); + }); + + it("operations schedules the export train", () => { + ensureExportRoute(); + resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: DEPARTURE }); + expectExportCapacity(DEPARTURE, EXPORT_CONSIST); + }); + + it("EXP1 boards end to end, charging every edge", () => { + bookAndClear({ + suffix: "EXP1", + runStamp: stamp, + isoSeed: 5000, + forty: LEGS.EXP1.wagons, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP1"); + pollAllocations("EXP1", LEGS.EXP1.wagons); + expectPoolAllocation("EXP1", "CNT", LEGS.EXP1.wagons); + expectExportEdgeLoad(DEPARTURE, [30, 30, 30, 30, 30], CNT_POOL); + }); + + it("both intercity bookings are refused on the B–C edge", () => { + bookIntercityContainers({ + suffix: "IC1", + runStamp: stamp, + isoSeed: 5100, + forty: LEGS.IC1.wagons, + }); + bookIntercityContainers({ + suffix: "IC2", + runStamp: stamp, + isoSeed: 5200, + forty: LEGS.IC2.wagons, + }); + + // Offered together, in the order staff would try them — larger first. + // The endpoint answers 200 with a partition either way, so the partition + // IS the assertion; a spec checking only the status code would pass on a + // train that took nobody for entirely the wrong reason. + acceptIntercityOnExport({ + departure: DEPARTURE, + accept: [], + reject: ["IC1", "IC2"], + }).then((res) => { + const body = res.body as { + rejected: Array<{ bookingId: string; reason: string }>; + }; + cy.task("log", `TC-06: refusals — ${JSON.stringify(body.rejected).slice(0, 400)}`); + body.rejected.forEach((r) => + expect(r.reason, "refused on capacity, not on an unrelated gate").to.match( + /fit|capacity|full|room|space/i, + ), + ); + }); + + expectNoWagons("IC1"); + expectNoWagons("IC2"); + }); + + it("CONTROL: a booking on the free edge still boards", () => { + // Without this, "refuses on the saturated edge" is indistinguishable from + // "stopped admitting anything". IC3 rides E–F, where EXP1's 30 leaves + // exactly 5 free, and takes all five. + bookIntercityContainers({ + suffix: "IC3", + runStamp: stamp, + isoSeed: 5300, + forty: LEGS.IC3.wagons, + }); + acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC3"] }); + markPaid("IC3"); + pollAllocations("IC3", LEGS.IC3.wagons); + expectPoolAllocation("IC3", "CNT", LEGS.IC3.wagons); + }); + + it("PROFILE: E–F filled to the pool, every other edge left at 30", () => { + expectExportEdgeLoad(DEPARTURE, [30, 30, 30, 30, 35], CNT_POOL); + expectNoPoolLeak(DEPARTURE); + }); + }, +); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx07_intercity_fills_export_gap.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx07_intercity_fills_export_gap.cy.ts new file mode 100644 index 000000000..801562e79 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx07_intercity_fills_export_gap.cy.ts @@ -0,0 +1,239 @@ +/** + * FLOW-TWO EXPORT · TC-07 — three sequential occupancies of the same wagons. + * + * TC-05 proved three legs can tile the corridor. This one tightens it to the + * point where the tiling is the ONLY way it works, and mixes cargo types so the + * release has to happen per pool as well as per edge: + * + * EXP1 export F→D MIXED edges 3,4 + * IC1 intercity D→B MIXED edges 1,2 + * EXP2 export B→A MIXED edge 0 + * + * "Mixed" is 25 CNT + 15 BLK per booking — 40 wagons drawing on both pools at + * once, sized so that each pool is close to full on every edge but never over: + * + * pool per booking pool size headroom + * CNT 25 35 10 + * BLK 15 20 5 + * + * Per-edge profile: [40, 40, 40, 40, 40]. One hundred and twenty wagons of + * cargo on a 60-wagon train, and every edge exactly two-thirds loaded. + * + * WHAT THIS ADDS OVER TC-05 + * + * TC-05's bookings were pure container. A single-pool chain can pass on an + * engine that releases capacity per edge but tracks only one flat pool. This + * one cannot: each handover at D and at B has to return 25 container slots AND + * 15 bulk slots, separately. An engine that released the right TOTAL but the + * wrong MIX would put EXP2's containers on the bulk wagons IC1 just vacated — + * which `expectNoPoolLeak` catches and a slot count never would. + * + * It also crosses a DIRECTION CHANGE at each handover: export → intercity → + * export. The engine derives trade direction from the yards' countries, so + * these three bookings are genuinely three different kinds of shipment sharing + * one physical consist. Release-at-drop has to be indifferent to that. + * + * Sequential steps of one journey — retries off. + */ + +import { + bookBulk, + clearToOperationRequestPending, + acceptExport, + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + EXP_DEST, + EXP_ORIGIN, +} from "../import-utils"; +import { + BLK_POOL, + CNT_POOL, + EXPORT_CONSIST, + acceptIntercityOnExport, + bookIntercityBulk, + bookIntercityContainers, + bulkWagons, + containerWagons, + createExportSchedule, + edgeLoad, + expectExportCapacity, + expectExportEdgeLoad, + expectNoPoolLeak, + expectPoolAllocation, + exportEdgesOf, + seedExportLegContract, + type Leg, + type Stop, +} from "./flow2-export-utils"; +import { bookAndClear } from "../g1-utils"; + +const DEPARTURE = departureAt(30); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +/** Each shipment is 25 container wagons + 15 bulk wagons = 40. */ +const CNT_EACH = 25; +const BLK_EACH = 15; +const BLK_TONS = BLK_EACH * 70; +const WAGONS_EACH = CNT_EACH + BLK_EACH; + +/** + * Each "shipment" is TWO bookings — one per freight type. A single booking + * cannot span both pools: `freight_type` is a booking-level column, and the + * contract's cargo scope is what pins it. So the mixed shipment is modelled the + * way a real customer would have to file it. + */ +const SHIPMENTS = [ + { name: "EXP1", from: "F" as Stop, to: "D" as Stop, cnt: "EXP1C", blk: "EXP1B" }, + { name: "IC1", from: "D" as Stop, to: "B" as Stop, cnt: "IC1C", blk: "IC1B" }, + { name: "EXP2", from: "B" as Stop, to: "A" as Stop, cnt: "EXP2C", blk: "EXP2B" }, +] as const; + +/** Every leg, both pools, as the profile arithmetic sees them. */ +const ALL_LEGS: Leg[] = SHIPMENTS.flatMap((s) => [ + { from: s.from, to: s.to, wagons: CNT_EACH }, + { from: s.from, to: s.to, wagons: BLK_EACH }, +]); + +const EXPECTED_PROFILE = [WAGONS_EACH, WAGONS_EACH, WAGONS_EACH, WAGONS_EACH, WAGONS_EACH]; + +describe( + "F2X·TC-07: the same wagons carry three shipments across two direction changes", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + SHIPMENTS.forEach((s) => { + seedExportLegContract({ + suffix: s.cnt, + reference: stampedRef(s.cnt), + from: s.from, + to: s.to, + }); + seedExportLegContract({ + suffix: s.blk, + reference: stampedRef(s.blk), + from: s.from, + to: s.to, + freight: "BULK", + }); + }); + }); + + it("the premise: the legs tile, and each pool stays inside its own size", () => { + expect(exportEdgesOf("F", "D"), "EXP1 rides edges 3-4").to.deep.eq([3, 4]); + expect(exportEdgesOf("D", "B"), "IC1 rides edges 1-2").to.deep.eq([1, 2]); + expect(exportEdgesOf("B", "A"), "EXP2 rides edge 0").to.deep.eq([0]); + + expect(containerWagons(0, CNT_EACH), "container half of a shipment").to.eq(CNT_EACH); + expect(bulkWagons(BLK_TONS), "bulk half of a shipment").to.eq(BLK_EACH); + + expect(edgeLoad(ALL_LEGS), "40 wagons on every edge").to.deep.eq(EXPECTED_PROFILE); + expect(CNT_EACH, "container demand per edge fits the CNT pool").to.be.at.most(CNT_POOL); + expect(BLK_EACH, "bulk demand per edge fits the BLK pool").to.be.at.most(BLK_POOL); + // The headline number: three times the train's usable capacity moves. + expect(WAGONS_EACH * 3, "total wagon-loads carried").to.eq(120); + expect(120, "…on a consist of").to.be.greaterThan(EXPORT_CONSIST); + }); + + it("operations schedules the export train", () => { + ensureExportRoute(); + resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: DEPARTURE }); + expectExportCapacity(DEPARTURE, EXPORT_CONSIST); + }); + + it("shipment 1 boards at F for D — export, both pools", () => { + bookAndClear({ + suffix: "EXP1C", + runStamp: stamp, + isoSeed: 5400, + forty: CNT_EACH, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + bookBulk({ + suffix: "EXP1B", + tons: BLK_TONS, + cargoCode: "E2E_IMP_WHEAT", + scheduledDate: BOOKING_DAY, + }); + clearToOperationRequestPending("EXP1B", BOOKING_DAY); + acceptExport("EXP1B"); + + markPaid("EXP1C"); + markPaid("EXP1B"); + pollAllocations("EXP1C", CNT_EACH); + pollAllocations("EXP1B", BLK_EACH); + expectPoolAllocation("EXP1C", "CNT", CNT_EACH); + expectPoolAllocation("EXP1B", "BLK", BLK_EACH); + }); + + it("shipment 2 boards at D — intercity, on the slots shipment 1 just freed", () => { + // The first handover, and the one that needs BOTH pools released. IC1 + // wants 25 container and 15 bulk wagons on edges 1-2; the train has 35 + // and 20 in total, and EXP1 is holding 25 and 15 of them until D. + bookIntercityContainers({ + suffix: "IC1C", + runStamp: stamp, + isoSeed: 5500, + forty: CNT_EACH, + }); + bookIntercityBulk({ suffix: "IC1B", tons: BLK_TONS, cargoCode: "E2E_IMP_WHEAT" }); + acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC1C", "IC1B"] }); + + markPaid("IC1C"); + markPaid("IC1B"); + pollAllocations("IC1C", CNT_EACH); + pollAllocations("IC1B", BLK_EACH); + expectPoolAllocation("IC1C", "CNT", CNT_EACH); + expectPoolAllocation("IC1B", "BLK", BLK_EACH); + }); + + it("shipment 3 boards at B for the port — export again, third occupancy", () => { + bookAndClear({ + suffix: "EXP2C", + runStamp: stamp, + isoSeed: 5600, + forty: CNT_EACH, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + bookBulk({ + suffix: "EXP2B", + tons: BLK_TONS, + cargoCode: "E2E_IMP_WHEAT", + scheduledDate: BOOKING_DAY, + }); + clearToOperationRequestPending("EXP2B", BOOKING_DAY); + acceptExport("EXP2B"); + + markPaid("EXP2C"); + markPaid("EXP2B"); + pollAllocations("EXP2C", CNT_EACH); + pollAllocations("EXP2B", BLK_EACH); + expectPoolAllocation("EXP2C", "CNT", CNT_EACH); + expectPoolAllocation("EXP2B", "BLK", BLK_EACH); + }); + + it("MIX: no shipment's containers ended up on the bulk wagons a predecessor vacated", () => { + // The assertion that a single-pool chain cannot make. An engine releasing + // the right TOTAL at each drop but the wrong MIX passes every count above + // and fails here. + expectNoPoolLeak(DEPARTURE); + }); + + it("PROFILE: 40 wagons on every edge — 120 wagon-loads on a 60-wagon train", () => { + expectExportEdgeLoad(DEPARTURE, EXPECTED_PROFILE, EXPORT_CONSIST); + }); + }, +); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx08_outbound_return_distinct.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx08_outbound_return_distinct.cy.ts new file mode 100644 index 000000000..4825fcacc --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx08_outbound_return_distinct.cy.ts @@ -0,0 +1,259 @@ +/** + * FLOW-TWO EXPORT · TC-08 — the outbound and return runs are distinct capacity. + * + * A train that runs A→F in the morning and F→A in the evening covers edge C–D + * twice. Those two crossings are SEPARATE capacity: cargo riding the outbound + * has been unloaded before the return begins. An engine that keyed capacity on + * the EDGE alone — rather than on (schedule, edge) — would let the morning's + * import eat the evening's export budget, and a shipper would be told the + * evening train is full while it sits empty at Addis. + * + * The two runs, deliberately spaced so they cannot be confused: + * + * IMPORT schedule A→F departs 12:00 EAT TRN-G1-1 (53 × NW5) + * EXPORT schedule F→A departs 15:00 EAT TRN-F2-EXP (35/20/5) + * + * IMP1 import A→D 30 CNT on the outbound run + * EXP1 export F→C 30 CNT on the return run + * IC1 intercity C→E 15 BLK on the return run + * + * IMP1 and EXP1 both cross C–D. Between them they want 60 wagons on that + * stretch — more than either train's container pool. If the engine shares one + * budget across both runs, the second booking is refused. Both must board. + * + * WHY TWO TRAINS AND NOT ONE + * + * The brief says "same train, same cycle". The engine does not model a + * there-and-back cycle as one schedule — a `train_schedules` row has one + * origin, one destination, one route, and one departure. A round trip is TWO + * schedules. That IS the trip-direction keying under test: the assertion is + * that the two schedules hold independent budgets, which is exactly what "the + * outbound and return are not shared" means in this schema. + * + * THE 3-HOUR GAP IS LWAD-BEARING. `dbSchedule` matches within ±1h of the + * departure and takes the newest (import-utils.ts:946), and + * `createImportSchedule` uses that same lookup as its idempotency guard — two + * schedules less than an hour apart and the second create is silently skipped, + * leaving a spec that passes while testing one train. Three hours keeps the two + * lookups disjoint while staying inside one EAT day (asserted below). + * + * IC1 rides C→E on the return — an intercity leg running INLAND while the train + * runs portward. Its edges (2,3) overlap EXP1's (2,3,4) only partly, and it + * draws on the bulk pool, so it also proves the return run's own per-edge and + * per-pool accounting is intact. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + departureAt, + eatDayStr, + ensureCorridorRoute, + ensureExportRoute, + dbRouteId, + dbSchedule, + forceWindowOpen, + markPaid, + pollAllocations, + resetCorridorDay, + withSchedule, + EXP_DEST, + EXP_ORIGIN, +} from "../import-utils"; +import { + BLK_POOL, + CNT_POOL, + EXPORT_CONSIST, + acceptIntercityOnExport, + bookIntercityBulk, + bulkWagons, + createExportSchedule, + dbExportSchedule, + expectExportBookingLeg, + expectExportCapacity, + expectExportEdgeLoad, + expectNoPoolLeak, + expectPoolAllocation, + exportEdgesOf, + seedExportLegContract, + withExportSched, + type Leg, +} from "./flow2-export-utils"; +import { + G1_WAGONS, + bookAndClear, + closeWindowAndRunBatch, + createG1Schedule, +} from "../g1-utils"; + +/** Outbound: the import run, 12:00 EAT. */ +const OUTBOUND = departureAt(31); +/** Return: the export run, 15:00 EAT — three hours later, same EAT day. */ +const RETURN = new Date(OUTBOUND.getTime() + 3 * 3_600_000); +const BOOKING_DAY = eatDayStr(OUTBOUND); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const LEGS = { + IMP1: { from: "A", to: "D", wagons: 30 }, + EXP1: { from: "F", to: "C", wagons: 30 }, + IC1: { from: "C", to: "E", wagons: 15 }, +} as const satisfies Record; + +const IC1_TONS = LEGS.IC1.wagons * 70; + +describe( + "F2X·TC-08: the outbound and return runs hold independent capacity", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-g1-train.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + seedExportLegContract({ suffix: "IMP1", reference: stampedRef("IMP1"), ...LEGS.IMP1 }); + seedExportLegContract({ suffix: "EXP1", reference: stampedRef("EXP1"), ...LEGS.EXP1 }); + seedExportLegContract({ + suffix: "IC1", + reference: stampedRef("IC1"), + ...LEGS.IC1, + freight: "BULK", + }); + }); + + it("the premise: both runs cross C–D, and together they want more than a pool", () => { + expect(exportEdgesOf("A", "D"), "IMP1 rides edges 0-2").to.deep.eq([0, 1, 2]); + expect(exportEdgesOf("F", "C"), "EXP1 rides edges 2-4").to.deep.eq([2, 3, 4]); + // Edge 2 is C–D. Both runs use it — which is the whole scenario. + expect( + exportEdgesOf("A", "D").filter((e) => exportEdgesOf("F", "C").includes(e)), + "the shared stretch", + ).to.deep.eq([2]); + expect( + LEGS.IMP1.wagons + LEGS.EXP1.wagons, + "combined demand on C–D exceeds either train's container pool", + ).to.be.greaterThan(CNT_POOL); + + // And the two departures are far enough apart that dbSchedule can tell + // them apart, while still landing on one EAT day. + const gapHours = (RETURN.getTime() - OUTBOUND.getTime()) / 3_600_000; + expect(gapHours, "the two runs are 3h apart — well outside the ±1h lookup").to.eq(3); + expect(eatDayStr(RETURN), "…and still the same EAT day").to.eq(BOOKING_DAY); + }); + + it("operations schedules both runs on the same day", () => { + ensureCorridorRoute(); + ensureExportRoute(); + resetCorridorDay(OUTBOUND); + resetCorridorDay(RETURN, EXP_DEST, EXP_ORIGIN); + + // The import run needs the FORWARD corridor route id — createG1Schedule + // posts it explicitly rather than deriving it, so resolve it first. + dbRouteId().then(({ rows }) => { + expect(rows, "forward corridor route").to.have.length.greaterThan(0); + createG1Schedule({ departure: OUTBOUND, routeId: rows[0].id }); + }); + withSchedule(OUTBOUND, (s) => forceWindowOpen(s.id, 60)); + + createExportSchedule({ departure: RETURN }); + expectExportCapacity(RETURN, EXPORT_CONSIST); + }); + + it("the two schedules really are two rows, with two budgets", () => { + // Guards the silent-skip failure: if the second create had been swallowed + // by the idempotency guard, both lookups would return the same row and + // every assertion below would be about one train. + dbSchedule(OUTBOUND).then(({ rows: out }) => { + dbExportSchedule(RETURN).then(({ rows: ret }) => { + expect(out, "outbound schedule").to.have.length(1); + expect(ret, "return schedule").to.have.length(1); + expect(out[0].id, "the two runs are distinct rows").to.not.eq(ret[0].id); + expect(Number(out[0].max_wagons), "outbound consist").to.eq(G1_WAGONS); + expect(Number(ret[0].max_wagons), "return consist").to.eq(EXPORT_CONSIST); + }); + }); + }); + + it("IMP1 boards the outbound run", () => { + bookAndClear({ + suffix: "IMP1", + runStamp: stamp, + isoSeed: 5700, + forty: LEGS.IMP1.wagons, + scheduledDate: BOOKING_DAY, + }); + closeWindowAndRunBatch(OUTBOUND); + markPaid("IMP1"); + pollAllocations("IMP1", LEGS.IMP1.wagons); + expectExportBookingLeg("IMP1", LEGS.IMP1); + }); + + it("EXP1 boards the return run — C–D is free again, it is a different trip", () => { + // The assertion the scenario exists for. IMP1 is holding 30 wagons on + // edge C–D of the OUTBOUND run. If the engine keyed capacity on the edge + // rather than on (schedule, edge), EXP1's 30 would take C–D to 60 and be + // refused. It must board. + bookAndClear({ + suffix: "EXP1", + runStamp: stamp, + isoSeed: 5800, + forty: LEGS.EXP1.wagons, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP1"); + pollAllocations("EXP1", LEGS.EXP1.wagons); + expectPoolAllocation("EXP1", "CNT", LEGS.EXP1.wagons); + expectExportBookingLeg("EXP1", LEGS.EXP1); + }); + + it("IC1 rides the return run inland, on the bulk pool", () => { + bookIntercityBulk({ suffix: "IC1", tons: IC1_TONS, cargoCode: "E2E_IMP_WHEAT" }); + acceptIntercityOnExport({ departure: RETURN, accept: ["IC1"] }); + markPaid("IC1"); + pollAllocations("IC1", bulkWagons(IC1_TONS)); + expectPoolAllocation("IC1", "BLK", bulkWagons(IC1_TONS)); + }); + + it("KEYING: neither booking is attached to the other's schedule", () => { + // The structural form of the same claim. A shared-budget bug would most + // likely also show up as a booking linked to the wrong schedule row. + withExportSched(RETURN, (s) => + db<{ suffix: string }>( + `SELECT ct.reference AS suffix + FROM freight.train_schedule_bookings tsb + JOIN freight.bookings b ON b.id = tsb.booking_id + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + AND ct.reference LIKE $2`, + [s.id, `CTR-IMP-${stamp}-%`], + ).then(({ rows }) => { + const refs = rows.map((r) => r.suffix); + expect( + refs.some((r) => r.endsWith("-IMP1")), + "the outbound import must NOT be attached to the return run", + ).to.eq(false); + expect( + refs.some((r) => r.endsWith("-EXP1")), + "the export rides the return run", + ).to.eq(true); + }), + ); + }); + + it("PROFILE: the return run carries EXP1 and IC1 only", () => { + // EXP1 (30 CNT) on edges 2,3,4 and IC1 (15 BLK) on edges 2,3. + expectExportEdgeLoad(RETURN, [0, 0, 45, 45, 30], EXPORT_CONSIST); + expectNoPoolLeak(RETURN); + expect(LEGS.EXP1.wagons, "return-run container use fits its pool").to.be.at.most( + CNT_POOL, + ); + expect(bulkWagons(IC1_TONS), "return-run bulk use fits its pool").to.be.at.most( + BLK_POOL, + ); + }); + }, +); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx09_bulk_tonnage_vs_count.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx09_bulk_tonnage_vs_count.cy.ts new file mode 100644 index 000000000..2a089eded --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx09_bulk_tonnage_vs_count.cy.ts @@ -0,0 +1,221 @@ +/** + * FLOW-TWO EXPORT · TC-09 — allocation is computed from tonnage, not from + * booking count. + * + * Three export bulk bookings of different sizes on overlapping legs: + * + * EXP1 export F→A 500 t → ceil(500/70) = 8 CW4 wagons + * EXP2 export E→A 300 t → ceil(300/70) = 5 CW4 wagons + * IC1 intercity D→C 200 t → ceil(200/70) = 3 CW4 wagons + * + * THREE BOOKINGS, SIXTEEN WAGONS. That gap is the test. An engine that counted + * bookings, or that charged a flat wagon per booking, would read this day as + * three wagons used and would happily admit five more such bookings onto a + * 20-wagon pool that is in fact 80% spoken for. + * + * THE ROUNDING IS ASSERTED EXPLICITLY, per booking, because it is where the + * money is. 500 t on 70 t wagons is 7.14 wagons, and a wagon is indivisible: + * the eighth wagon runs 30 t empty and the customer still pays for the space + * it occupies on every edge of the leg. `Math.ceil`, never `round`, never + * `floor` — the brief's "41t on a 40t wagon = 2 wagons" case is asserted + * directly in the premise test below, at the exact boundary. + * + * THE PER_ITEM PATH IS A DIFFERENT RULE and is NOT what these bookings take. + * `bulkItemWagonsRequired` (train-capacity.util.ts:130) bails to 0 unless BOTH + * an item count and a tonnage are present; a `bookBulk` tonnage booking sets + * only the tonnage, so it lands on the plain ceil path above. The per-item + * rule — floor on items-per-wagon, then ceil on wagons, with the + * `items_per_wagon_map` floor able to beat the tonnage — is covered by + * ../bulk_b2_per_item_floor.cy.ts and is asserted here only as arithmetic + * (`perItemWagons`), so that the two rules cannot silently converge. + * + * Sequential steps of one journey — retries off. + */ + +import { + bookBulk, + clearToOperationRequestPending, + acceptExport, + db, + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + EXP_DEST, + EXP_ORIGIN, + withBooking, +} from "../import-utils"; +import { + BLK_POOL, + CW4_CAPACITY_TONS, + EXPORT_CONSIST, + acceptIntercityOnExport, + bookIntercityBulk, + bulkWagons, + createExportSchedule, + edgeLoad, + expectExportEdgeLoad, + expectExportCapacity, + expectNoPoolLeak, + expectPoolAllocation, + perItemWagons, + seedExportLegContract, + type Leg, +} from "./flow2-export-utils"; + +const DEPARTURE = departureAt(32); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const TONS = { EXP1: 500, EXP2: 300, IC1: 200 } as const; + +const LEGS = { + EXP1: { from: "F", to: "A", wagons: bulkWagons(TONS.EXP1) }, + EXP2: { from: "E", to: "A", wagons: bulkWagons(TONS.EXP2) }, + IC1: { from: "D", to: "C", wagons: bulkWagons(TONS.IC1) }, +} as const satisfies Record; + +describe( + "F2X·TC-09: bulk wagons are computed from tonnage, rounded up", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + (["EXP1", "EXP2", "IC1"] as const).forEach((s) => + seedExportLegContract({ + suffix: s, + reference: stampedRef(s), + from: LEGS[s].from, + to: LEGS[s].to, + freight: "BULK", + }), + ); + }); + + it("the rounding rule, at the boundary", () => { + // Stated at the exact tipping point rather than only on the scenario's + // own numbers: a change from ceil to round would leave 500/300/200 t + // looking correct and break here. + expect(bulkWagons(70, 70), "a wagonful is one wagon").to.eq(1); + expect(bulkWagons(71, 70), "one tonne over is a whole second wagon").to.eq(2); + expect(bulkWagons(41, 40), "41 t on a 40 t wagon is 2 wagons").to.eq(2); + expect(bulkWagons(1, 70), "a single tonne still occupies a whole wagon").to.eq(1); + + // And the PER_ITEM rule is a different computation — asserted so the two + // cannot silently converge into one. + expect( + perItemWagons({ items: 20, tons: 100, itemsFit: 4 }), + "per-item: the map floor beats tonnage (20 items, 4/wagon)", + ).to.eq(5); + expect( + perItemWagons({ items: 14, tons: 140, itemsFit: 100 }), + "per-item: tonnage beats a generous map floor", + ).to.eq(2); + }); + + it("the premise: three bookings, sixteen wagons", () => { + expect(LEGS.EXP1.wagons, "500 t → 8 wagons (7.14 rounded up)").to.eq(8); + expect(LEGS.EXP2.wagons, "300 t → 5 wagons (4.29 rounded up)").to.eq(5); + expect(LEGS.IC1.wagons, "200 t → 3 wagons (2.86 rounded up)").to.eq(3); + + const total = LEGS.EXP1.wagons + LEGS.EXP2.wagons + LEGS.IC1.wagons; + expect(total, "sixteen wagons for three bookings").to.eq(16); + expect(total, "…which a booking-count engine would read as 3").to.not.eq(3); + + // The peak edge must still fit the bulk pool, or the scenario would be + // testing rejection rather than arithmetic. + const profile = edgeLoad(Object.values(LEGS)); + expect(profile, "per-edge bulk demand").to.deep.eq([13, 13, 16, 13, 8]); + expect(Math.max(...profile), "the peak fits the bulk pool").to.be.at.most(BLK_POOL); + expect(CW4_CAPACITY_TONS, "the wagon capacity all of this rests on").to.eq(70); + }); + + it("the wagon type's capacity really is 70 t", () => { + // Every number above is derived from this one. Read it rather than + // trusting the constant: a catalog change would otherwise turn the whole + // spec into confident nonsense. + db<{ capacity_tons: string }>( + `SELECT capacity_tons FROM freight.wagon_types WHERE code = 'CW4'`, + [], + ).then(({ rows }) => + expect(Number(rows[0].capacity_tons), "CW4 capacity").to.eq(CW4_CAPACITY_TONS), + ); + }); + + it("operations schedules the export train", () => { + ensureExportRoute(); + resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: DEPARTURE, kind: "bulk" }); + expectExportCapacity(DEPARTURE, EXPORT_CONSIST); + }); + + it("EXP1's 500 t takes 8 wagons, not 7 and not 1", () => { + bookBulk({ + suffix: "EXP1", + tons: TONS.EXP1, + cargoCode: "E2E_IMP_WHEAT", + scheduledDate: BOOKING_DAY, + }); + clearToOperationRequestPending("EXP1", BOOKING_DAY); + acceptExport("EXP1"); + markPaid("EXP1"); + pollAllocations("EXP1", LEGS.EXP1.wagons); + expectPoolAllocation("EXP1", "BLK", LEGS.EXP1.wagons); + expectTonnage("EXP1", TONS.EXP1); + }); + + it("EXP2's 300 t takes 5 wagons", () => { + bookBulk({ + suffix: "EXP2", + tons: TONS.EXP2, + cargoCode: "E2E_IMP_WHEAT", + scheduledDate: BOOKING_DAY, + }); + clearToOperationRequestPending("EXP2", BOOKING_DAY); + acceptExport("EXP2"); + markPaid("EXP2"); + pollAllocations("EXP2", LEGS.EXP2.wagons); + expectPoolAllocation("EXP2", "BLK", LEGS.EXP2.wagons); + expectTonnage("EXP2", TONS.EXP2); + }); + + it("IC1's 200 t takes 3 wagons", () => { + bookIntercityBulk({ suffix: "IC1", tons: TONS.IC1, cargoCode: "E2E_IMP_WHEAT" }); + acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC1"] }); + markPaid("IC1"); + pollAllocations("IC1", LEGS.IC1.wagons); + expectPoolAllocation("IC1", "BLK", LEGS.IC1.wagons); + expectTonnage("IC1", TONS.IC1); + }); + + it("PROFILE: the day used 16 wagons of the bulk pool, per edge", () => { + expectExportEdgeLoad(DEPARTURE, [13, 13, 16, 13, 8], BLK_POOL); + expectNoPoolLeak(DEPARTURE); + }); + }, +); + +/** + * Assert the booking's recorded tonnage matches what was ordered. + * + * The wagon count alone cannot catch a booking whose tonnage was silently + * truncated on the way in — 500 t stored as 50 t would allocate 1 wagon and + * look like a capacity bug rather than the data bug it is. + */ +function expectTonnage(suffix: string, tons: number) { + withBooking(suffix, (b) => + db<{ tons: string | null }>( + `SELECT bulk_total_weight_tons AS tons FROM freight.bookings WHERE id = $1`, + [b.id], + ).then(({ rows }) => + expect(Number(rows[0].tons), `${suffix} carries ${tons} t`).to.eq(tons), + ), + ); +} diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx10_bulk_commodity_segregation.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx10_bulk_commodity_segregation.cy.ts new file mode 100644 index 000000000..b7502d26e --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx10_bulk_commodity_segregation.cy.ts @@ -0,0 +1,244 @@ +/** + * FLOW-TWO EXPORT · TC-10 — incompatible commodities never share a wagon. + * + * Three bulk bookings of three different commodities, all drawing on the same + * 20-wagon CW4 pool: + * + * EXP1 export F→A fertilizer 10 wagons (700 t) + * EXP2 export F→A grain 10 wagons (700 t) + * IC1 intercity E→C cement 5 wagons (350 t) + * + * SHARING A TRAIN IS FINE. SHARING A WAGON IS NOT. Fertilizer residue in a + * grain wagon is a food-safety incident, not a rounding error, and the rule has + * to hold at the level of the individual wagon rather than the consist. + * + * WHERE THE RULE ACTUALLY LIVES + * + * `planWagonsWithStock` (wagon-plan-flex.util.ts:415-442) tops off an already- + * open wagon only when the cargo type MATCHES — and guards, at :422, that + * per-item cargo never joins a wagon opened as loose PER_TON. So distinct + * `cargo_types` rows are the mechanism, and this spec's three commodities are + * three separate rows sharing one wagon type (seed-flow2-export-train.sql + * section 6). + * + * That is why the numbers are what they are. EXP1 and EXP2 each take exactly + * 700 t = 10 whole wagons, so the arithmetic ALONE could be satisfied by an + * engine that packed them into 20 shared wagons — there is no leftover space to + * tempt it. The assertion therefore is not the count. It is + * `expectOneCommodityPerWagon`: a direct query for any wagon carrying rows from + * two different bookings, which is the only thing that catches co-loading. + * + * IC1's 5 cement wagons then take the pool to exactly 20 on the E–C stretch, + * proving segregation does not cost capacity — three commodities still fill the + * train completely. An engine that reserved a safety wagon between commodities + * would fail here, and should. + * + * Sequential steps of one journey — retries off. + */ + +import { + bookBulk, + clearToOperationRequestPending, + acceptExport, + db, + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + EXP_DEST, + EXP_ORIGIN, +} from "../import-utils"; +import { + BLK_POOL, + EXPORT_CONSIST, + POOL_TYPE, + acceptIntercityOnExport, + bookIntercityBulk, + bulkWagons, + createExportSchedule, + edgeLoad, + expectExportCapacity, + expectExportEdgeLoad, + expectNoPoolLeak, + expectPoolAllocation, + seedExportLegContract, + withExportSched, + type Leg, +} from "./flow2-export-utils"; + +const DEPARTURE = departureAt(33); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +/** Whole-wagon tonnages: no partial wagon anywhere, so only co-loading can shrink the count. */ +const TONS = { EXP1: 10 * 70, EXP2: 10 * 70, IC1: 5 * 70 } as const; + +/** Three distinct cargo_types rows sharing one wagon type — the mechanism. */ +const CARGO = { + EXP1: "E2E_EXP_FERT", + EXP2: "E2E_IMP_GRAINS", + IC1: "E2E_EXP_CEMENT", +} as const; + +const LEGS = { + EXP1: { from: "F", to: "A", wagons: bulkWagons(TONS.EXP1) }, + EXP2: { from: "F", to: "A", wagons: bulkWagons(TONS.EXP2) }, + IC1: { from: "E", to: "C", wagons: bulkWagons(TONS.IC1) }, +} as const satisfies Record; + +describe( + "F2X·TC-10: three commodities share the train but never a wagon", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + (["EXP1", "EXP2", "IC1"] as const).forEach((s) => + seedExportLegContract({ + suffix: s, + reference: stampedRef(s), + from: LEGS[s].from, + to: LEGS[s].to, + freight: "BULK", + }), + ); + }); + + it("the three commodities are distinct cargo types on ONE wagon type", () => { + // Both halves are the premise. Distinct types is what makes segregation + // meaningful; a shared wagon type is what makes it non-trivial — if each + // rode its own type, the pools would separate them for free and the + // scenario would prove nothing about commodity locking. + db<{ cargo: string; wagon: string }>( + `SELECT ct.code AS cargo, wt.code AS wagon + FROM freight.cargo_type_wagon_types x + JOIN freight.cargo_types ct ON ct.id = x.cargo_type_id + JOIN freight.wagon_types wt ON wt.id = x.wagon_type_id + WHERE ct.code = ANY($1::text[]) + ORDER BY ct.code`, + [Object.values(CARGO)], + ).then(({ rows }) => { + const cargos = new Set(rows.map((r) => r.cargo)); + expect(cargos.size, "three distinct commodities").to.eq(3); + rows.forEach((r) => + expect(r.wagon, `${r.cargo} rides the shared bulk pool`).to.eq(POOL_TYPE.BLK), + ); + }); + }); + + it("the premise: every booking is a whole number of full wagons", () => { + // Deliberate: with no partial wagon anywhere, a lower wagon count can + // ONLY mean two commodities were packed together. + expect(TONS.EXP1 % 70, "EXP1 fills its wagons exactly").to.eq(0); + expect(TONS.EXP2 % 70, "EXP2 fills its wagons exactly").to.eq(0); + expect(TONS.IC1 % 70, "IC1 fills its wagons exactly").to.eq(0); + + const profile = edgeLoad(Object.values(LEGS)); + expect(profile, "per-edge bulk demand").to.deep.eq([20, 20, 25, 25, 20]); + // NOTE: edges 2 and 3 want 25 against a 20-wagon pool — IC1 cannot ride + // alongside both exports on its own stretch. Asserted below as a refusal, + // then re-tried after the exports are the only thing on the train. + expect(Math.max(...profile), "the peak exceeds the pool").to.be.greaterThan(BLK_POOL); + }); + + it("operations schedules the export train", () => { + ensureExportRoute(); + resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: DEPARTURE, kind: "bulk" }); + expectExportCapacity(DEPARTURE, EXPORT_CONSIST); + }); + + it("the fertilizer export boards", () => { + bookBulk({ + suffix: "EXP1", + tons: TONS.EXP1, + cargoCode: CARGO.EXP1, + scheduledDate: BOOKING_DAY, + }); + clearToOperationRequestPending("EXP1", BOOKING_DAY); + acceptExport("EXP1"); + markPaid("EXP1"); + pollAllocations("EXP1", LEGS.EXP1.wagons); + expectPoolAllocation("EXP1", "BLK", LEGS.EXP1.wagons); + }); + + it("the grain export boards onto the SAME train — 20 of 20 bulk wagons now used", () => { + bookBulk({ + suffix: "EXP2", + tons: TONS.EXP2, + cargoCode: CARGO.EXP2, + scheduledDate: BOOKING_DAY, + }); + clearToOperationRequestPending("EXP2", BOOKING_DAY); + acceptExport("EXP2"); + markPaid("EXP2"); + pollAllocations("EXP2", LEGS.EXP2.wagons); + expectPoolAllocation("EXP2", "BLK", LEGS.EXP2.wagons); + // Sharing a train is explicitly fine — this is the line the rule does NOT + // draw, asserted so a future over-cautious change gets caught. + expectExportEdgeLoad(DEPARTURE, [20, 20, 20, 20, 20], BLK_POOL); + }); + + it("SEGREGATION: no wagon carries two commodities", () => { + // The assertion the scenario exists for, and the only one that catches + // co-loading. Every count above is satisfied by a co-loading engine. + expectOneCommodityPerWagon(); + }); + + it("the cement intercity is refused — the pool is full on its stretch", () => { + // Not a segregation failure: the bulk pool is genuinely exhausted on + // edges 2-3 by the two exports. Asserted so the refusal is not mistaken + // for the safety rule doing something it should not. + bookIntercityBulk({ suffix: "IC1", tons: TONS.IC1, cargoCode: CARGO.IC1 }); + acceptIntercityOnExport({ departure: DEPARTURE, accept: [], reject: ["IC1"] }); + }); + + it("POOLS: nothing leaked, and segregation held to the end", () => { + expectNoPoolLeak(DEPARTURE); + expectOneCommodityPerWagon(); + }); + }, +); + +/** + * Assert no physical wagon slot carries two different commodities. + * + * Reads `wagon_allocation_bulk_loads` — the per-allocation load rows, each + * carrying its own `cargo_type_id`. That is the right grain: the question is + * what is physically ON a wagon, not what a booking ordered in aggregate, and a + * booking's loads can be spread across several wagons. + * + * Grouped by `train_set_wagon_id` so the unit is the SLOT. The query reports + * the offending cargo codes, so a failure names the pair rather than just + * counting. + */ +function expectOneCommodityPerWagon() { + withExportSched(DEPARTURE, (s) => + db<{ wagon: string; cargos: string }>( + `SELECT wba.train_set_wagon_id::text AS wagon, + string_agg(DISTINCT ct.code, ',' ORDER BY ct.code) AS cargos + FROM freight.wagon_allocation_bulk_loads bl + JOIN freight.wagon_booking_allocations wba + ON wba.id = bl.wagon_booking_allocation_id + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + JOIN freight.cargo_types ct ON ct.id = bl.cargo_type_id + WHERE bl.deleted_at IS NULL + AND wba.deleted_at IS NULL AND tsb.deleted_at IS NULL + GROUP BY wba.train_set_wagon_id + HAVING count(DISTINCT bl.cargo_type_id) > 1`, + [s.id], + ).then(({ rows }) => + expect( + rows.map((r) => `${r.wagon}: ${r.cargos}`), + "wagons carrying two commodities at once", + ).to.deep.eq([]), + ), + ); +} diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx11_bulk_partial_wagon_waste.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx11_bulk_partial_wagon_waste.cy.ts new file mode 100644 index 000000000..d1cad55ae --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx11_bulk_partial_wagon_waste.cy.ts @@ -0,0 +1,233 @@ +/** + * FLOW-TWO EXPORT · TC-11 — the consolidation policy, pinned. + * + * Three small export bulk bookings, same commodity, same leg, same day: + * + * EXP1 export F→A 15 t (wagon capacity 70 t) + * EXP2 export F→A 15 t + * EXP3 export F→A 15 t + * + * Forty-five tonnes in total — well under one wagon. So the day ends with + * either THREE wagons (each booking gets its own, 55 t of air apiece) or ONE + * (all three consolidated). Both are legitimate policies with real trade-offs: + * separate wagons make unloading, sealing and liability trivial; consolidation + * turns 3 wagons of capacity into 2 wagons of revenue-earning space. + * + * THIS SPEC DOES NOT PREFER ONE. It pins whichever is in force and fails if it + * changes, because a silent flip is expensive in both directions — customers + * suddenly billed for a whole wagon each, or cargo from three shippers found + * mixed in one wagon at the port. + * + * WHAT THE ENGINE ACTUALLY DOES, and why the expectation is "3" + * + * `planWagonsWithStock` (wagon-plan-flex.util.ts:415-442) tops off an existing + * wagon only within the SAME booking's placement pass — the top-off scan is + * driven from the booking being planned, and the per-booking snapshot/rollback + * at :483-541 makes a booking atomic. There is no cross-booking consolidation + * step anywhere in the planner. Separately, `wagon_booking_allocations` is + * keyed per booking, so two bookings sharing a physical wagon would need two + * allocation rows against one `train_set_wagon_id` — which is precisely what + * TC-10's segregation query treats as a defect. + * + * So the expectation is THREE WAGONS, and the spec states that as the current + * policy rather than as an eternal truth. If consolidation is ever implemented, + * this test fails with a message naming the policy, and someone changes the + * constant deliberately. + * + * THE CONTROL: EXP4 books 15 t on the SAME contract as EXP1 is not possible + * (one active booking per ONE_TIME contract), so instead the spec asserts the + * within-booking case directly — a single 85 t booking, which MUST consolidate + * into 2 wagons (70 + 15) rather than 2 half-empty ones. Without it, "never + * consolidates" and "cannot pack a wagon at all" look identical. + * + * Sequential steps of one journey — retries off. + */ + +import { + bookBulk, + clearToOperationRequestPending, + acceptExport, + db, + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + EXP_DEST, + EXP_ORIGIN, + withBooking, +} from "../import-utils"; +import { + BLK_POOL, + CW4_CAPACITY_TONS, + EXPORT_CONSIST, + bulkWagons, + createExportSchedule, + expectExportCapacity, + expectExportEdgeLoad, + expectNoPoolLeak, + expectPoolAllocation, + seedExportLegContract, + withExportSched, +} from "./flow2-export-utils"; + +const DEPARTURE = departureAt(34); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +/** A quarter-wagon each — three of them still do not fill one. */ +const SMALL_TONS = 15; +const SMALL_BOOKINGS = ["EXP1", "EXP2", "EXP3"] as const; + +/** + * THE POLICY UNDER TEST. Change this constant only as a deliberate decision: + * 3 = no cross-booking consolidation (current engine — see header) + * 1 = three bookings consolidated into one wagon + */ +const CONSOLIDATION_POLICY_WAGONS = 3; + +/** The within-booking control: 85 t must pack as 70 + 15, not as two part-loads. */ +const CONTROL_TONS = 85; + +describe( + "F2X·TC-11: three part-wagon bookings, and the consolidation policy that decides their cost", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + [...SMALL_BOOKINGS, "EXP4"].forEach((s) => + seedExportLegContract({ + suffix: s, + reference: stampedRef(s), + from: "F", + to: "A", + freight: "BULK", + }), + ); + }); + + it("the premise: all three together do not fill one wagon", () => { + const total = SMALL_TONS * SMALL_BOOKINGS.length; + expect(total, "45 t in total").to.eq(45); + expect(total, "…less than one wagon").to.be.lessThan(CW4_CAPACITY_TONS); + expect(bulkWagons(total), "consolidated, they would be 1 wagon").to.eq(1); + expect( + bulkWagons(SMALL_TONS) * SMALL_BOOKINGS.length, + "unconsolidated, they are 3", + ).to.eq(3); + // The gap between those two numbers IS the policy, and it is 2 wagons of + // otherwise-sellable space on every edge of the leg. + expect(CONSOLIDATION_POLICY_WAGONS, "the policy this run expects").to.be.oneOf([1, 3]); + }); + + it("operations schedules the export train", () => { + ensureExportRoute(); + resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: DEPARTURE, kind: "bulk" }); + expectExportCapacity(DEPARTURE, EXPORT_CONSIST); + }); + + it("all three part-wagon bookings board", () => { + SMALL_BOOKINGS.forEach((suffix, i) => { + bookBulk({ + suffix, + tons: SMALL_TONS, + cargoCode: "E2E_IMP_WHEAT", + scheduledDate: BOOKING_DAY, + }); + clearToOperationRequestPending(suffix, BOOKING_DAY); + acceptExport(suffix); + markPaid(suffix); + // Each is one wagon on its own, whatever happens between them. + pollAllocations(suffix, 1); + cy.task("log", `TC-11: ${suffix} (${SMALL_TONS} t) placed — booking ${i + 1} of 3`); + }); + }); + + it("POLICY: the three bookings occupy 3 wagons, not 1", () => { + // The pinned decision. A failure here is not necessarily a bug — it is a + // policy change that must be acknowledged by editing + // CONSOLIDATION_POLICY_WAGONS above, deliberately. + withExportSched(DEPARTURE, (s) => + db<{ n: string }>( + `SELECT count(DISTINCT wba.train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations wba + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + JOIN freight.bookings b ON b.id = wba.booking_id + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL + AND ct.reference LIKE $2`, + [s.id, `CTR-IMP-${stamp}-EXP%`], + ).then(({ rows }) => + expect( + Number(rows[0].n), + `three 15 t bookings occupy ${CONSOLIDATION_POLICY_WAGONS} wagon(s) — ` + + `if this changed, the consolidation policy changed, and that is a ` + + `billing and liability decision, not a refactor`, + ).to.eq(CONSOLIDATION_POLICY_WAGONS), + ), + ); + }); + + it("each booking's wagon is its own — no two share a slot", () => { + // The structural half of the same claim, and the one that would catch a + // consolidation implemented WITHOUT updating the allocation model: two + // bookings pointing at one train_set_wagon_id. + withExportSched(DEPARTURE, (s) => + db<{ wagon: string; bookings: string }>( + `SELECT tsw.id::text AS wagon, count(DISTINCT wba.booking_id)::text AS bookings + FROM freight.wagon_booking_allocations wba + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id + WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL + GROUP BY tsw.id + HAVING count(DISTINCT wba.booking_id) > 1`, + [s.id], + ).then(({ rows }) => + expect( + rows.map((r) => `${r.wagon} shared by ${r.bookings} bookings`), + "wagons shared between bookings — under the no-consolidation policy " + + "there must be none; if consolidation is ever implemented this " + + "assertion is the second one to revisit", + ).to.deep.eq([]), + ), + ); + }); + + it("CONTROL: within ONE booking, 85 t packs as 70 + 15 — the packer does work", () => { + // Without this, "never consolidates across bookings" is indistinguishable + // from "cannot fill a wagon at all". 85 t must be 2 wagons (one full, one + // quarter), never 3. + bookBulk({ + suffix: "EXP4", + tons: CONTROL_TONS, + cargoCode: "E2E_IMP_WHEAT", + scheduledDate: BOOKING_DAY, + }); + clearToOperationRequestPending("EXP4", BOOKING_DAY); + acceptExport("EXP4"); + markPaid("EXP4"); + pollAllocations("EXP4", bulkWagons(CONTROL_TONS)); + expectPoolAllocation("EXP4", "BLK", 2); + withBooking("EXP4", (b) => + expect(b.status, "the control booking rode").to.not.eq("REJECTED"), + ); + }); + + it("PROFILE: five bulk wagons used on every edge of the leg", () => { + // 3 (part-wagon bookings) + 2 (the 85 t control) = 5, on F→A, so all five + // edges carry the same load. + const used = CONSOLIDATION_POLICY_WAGONS + bulkWagons(CONTROL_TONS); + expectExportEdgeLoad(DEPARTURE, [used, used, used, used, used], BLK_POOL); + expectNoPoolLeak(DEPARTURE); + }); + }, +); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx12_container_teu_mix.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx12_container_teu_mix.cy.ts new file mode 100644 index 000000000..91660a0b2 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx12_container_teu_mix.cy.ts @@ -0,0 +1,245 @@ +/** + * FLOW-TWO EXPORT · TC-12 — wagons come from TEU arithmetic, not unit count. + * + * A wagon holds either two 20ft containers or one 40ft. So: + * + * EXP1 export F→A 20 × 20ft → 10 wagons + * EXP2 export F→A 10 × 40ft → 10 wagons + * IC1 intercity D→B 5 × 40ft → 5 wagons + * + * THIRTY-FIVE CONTAINERS, TWENTY-FIVE WAGONS. An engine that counted units + * would demand 35 wagons — the whole container pool — and refuse the third + * booking on a train with ten free slots. One that counted BOOKINGS would say + * three. The right answer is 25, and the three numbers are far enough apart + * that no accident produces it. + * + * THE 20FT PAIRING IS THE INTERESTING HALF. Two 20ft on one wagon is the only + * place in the model where a wagon carries more than one revenue unit, and it + * is the case an implementation is most likely to get wrong — usually by + * charging a wagon per container and quietly doubling the customer's bill. + * EXP1 and EXP2 are deliberately sized to need the SAME number of wagons (10) + * from very different unit counts (20 vs 10), so a unit-counting engine shows + * up as an asymmetry between two bookings that should cost the same. + * + * THE ODD-20FT RULE is asserted as arithmetic, not booked: a lone 20ft still + * occupies a whole wagon (ceil), and the portal form blocks submitting an odd + * quantity outright. Asserting it here keeps the rounding rule visible next to + * the pairing rule it qualifies. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + EXP_DEST, + EXP_ORIGIN, + withBooking, +} from "../import-utils"; +import { + CNT_POOL, + EXPORT_CONSIST, + acceptIntercityOnExport, + bookIntercityContainers, + containerWagons, + createExportSchedule, + edgeLoad, + expectExportCapacity, + expectExportEdgeLoad, + expectNoPoolLeak, + expectPoolAllocation, + seedExportLegContract, + type Leg, +} from "./flow2-export-utils"; +import { bookAndClear } from "../g1-utils"; + +const DEPARTURE = departureAt(35); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const UNITS = { + EXP1: { twenty: 20, forty: 0 }, + EXP2: { twenty: 0, forty: 10 }, + IC1: { twenty: 0, forty: 5 }, +} as const; + +const LEGS = { + EXP1: { from: "F", to: "A", wagons: containerWagons(UNITS.EXP1.twenty, UNITS.EXP1.forty) }, + EXP2: { from: "F", to: "A", wagons: containerWagons(UNITS.EXP2.twenty, UNITS.EXP2.forty) }, + IC1: { from: "D", to: "B", wagons: containerWagons(UNITS.IC1.twenty, UNITS.IC1.forty) }, +} as const satisfies Record; + +const TOTAL_UNITS = 20 + 10 + 5; +const TOTAL_WAGONS = LEGS.EXP1.wagons + LEGS.EXP2.wagons + LEGS.IC1.wagons; + +describe( + "F2X·TC-12: 35 containers ride 25 wagons — TEU arithmetic, not unit count", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + (["EXP1", "EXP2", "IC1"] as const).forEach((s) => + seedExportLegContract({ + suffix: s, + reference: stampedRef(s), + from: LEGS[s].from, + to: LEGS[s].to, + }), + ); + }); + + it("the TEU rule, including the odd-20ft edge", () => { + expect(containerWagons(2, 0), "two 20ft pair onto one wagon").to.eq(1); + expect(containerWagons(1, 0), "a lone 20ft still costs a whole wagon").to.eq(1); + expect(containerWagons(3, 0), "three 20ft need two wagons").to.eq(2); + expect(containerWagons(0, 1), "a 40ft takes a wagon to itself").to.eq(1); + expect(containerWagons(2, 1), "mixed: one paired wagon plus one 40ft").to.eq(2); + }); + + it("the premise: 35 containers, 25 wagons — three numbers that cannot be confused", () => { + expect(LEGS.EXP1.wagons, "20 × 20ft → 10 wagons").to.eq(10); + expect(LEGS.EXP2.wagons, "10 × 40ft → 10 wagons").to.eq(10); + expect(LEGS.IC1.wagons, "5 × 40ft → 5 wagons").to.eq(5); + + expect(TOTAL_UNITS, "containers moved").to.eq(35); + expect(TOTAL_WAGONS, "wagons used").to.eq(25); + expect(TOTAL_WAGONS, "…not the unit count").to.not.eq(TOTAL_UNITS); + expect(TOTAL_WAGONS, "…and not the booking count").to.not.eq(3); + + // The asymmetry check: two bookings, half the units apart, same cost. + expect( + LEGS.EXP1.wagons, + "20 twenty-footers cost the same as 10 forty-footers — a unit-counting " + + "engine would charge EXP1 twice what it charges EXP2", + ).to.eq(LEGS.EXP2.wagons); + + // A unit-counting engine would want the whole pool and refuse IC1. + expect(TOTAL_UNITS, "unit-count demand would exhaust the pool").to.eq(CNT_POOL); + expect(TOTAL_WAGONS, "real demand leaves 10 free").to.be.lessThan(CNT_POOL); + + expect(edgeLoad(Object.values(LEGS)), "per-edge demand").to.deep.eq([ + 20, 25, 25, 20, 20, + ]); + }); + + it("operations schedules the export train", () => { + ensureExportRoute(); + resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: DEPARTURE }); + expectExportCapacity(DEPARTURE, EXPORT_CONSIST); + }); + + it("EXP1's twenty 20ft containers pair onto ten wagons", () => { + bookAndClear({ + suffix: "EXP1", + runStamp: stamp, + isoSeed: 6000, + twenty: UNITS.EXP1.twenty, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP1"); + pollAllocations("EXP1", LEGS.EXP1.wagons); + expectPoolAllocation("EXP1", "CNT", LEGS.EXP1.wagons); + expectUnitsPlaced("EXP1", UNITS.EXP1.twenty + UNITS.EXP1.forty); + }); + + it("EXP2's ten 40ft containers take ten wagons — the same cost, half the units", () => { + bookAndClear({ + suffix: "EXP2", + runStamp: stamp, + isoSeed: 6100, + forty: UNITS.EXP2.forty, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP2"); + pollAllocations("EXP2", LEGS.EXP2.wagons); + expectPoolAllocation("EXP2", "CNT", LEGS.EXP2.wagons); + expectUnitsPlaced("EXP2", UNITS.EXP2.twenty + UNITS.EXP2.forty); + }); + + it("IC1 boards on the ten wagons a unit-counting engine would have consumed", () => { + // The scenario's payoff. 35 units are on the train; a unit-counting + // engine believes the 35-wagon pool is exhausted and refuses this. + bookIntercityContainers({ + suffix: "IC1", + runStamp: stamp, + isoSeed: 6200, + forty: UNITS.IC1.forty, + }); + acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC1"] }); + markPaid("IC1"); + pollAllocations("IC1", LEGS.IC1.wagons); + expectPoolAllocation("IC1", "CNT", LEGS.IC1.wagons); + expectUnitsPlaced("IC1", UNITS.IC1.twenty + UNITS.IC1.forty); + }); + + it("PAIRING: the 20ft wagons really do carry two containers each", () => { + // The count could be right with the units placed wrongly — ten wagons + // holding one container each and ten containers lost. This reads the + // placement rows themselves. + withBooking("EXP1", (b) => + db<{ wagon: string; units: string }>( + `SELECT wba.train_set_wagon_id::text AS wagon, count(*)::text AS units + FROM freight.wagon_allocation_container_items ci + JOIN freight.wagon_booking_allocations wba + ON wba.id = ci.wagon_booking_allocation_id + WHERE wba.booking_id = $1 + AND ci.deleted_at IS NULL AND wba.deleted_at IS NULL + GROUP BY wba.train_set_wagon_id`, + [b.id], + ).then(({ rows }) => { + expect(rows, "EXP1 occupies 10 wagons").to.have.length(LEGS.EXP1.wagons); + rows.forEach((r) => + expect(Number(r.units), `wagon ${r.wagon} carries two 20ft`).to.eq(2), + ); + }), + ); + }); + + it("PROFILE: 25 wagons at the peak, ten of the pool still free", () => { + expectExportEdgeLoad(DEPARTURE, [20, 25, 25, 20, 20], CNT_POOL); + expectNoPoolLeak(DEPARTURE); + }); + }, +); + +/** + * Assert every container of a booking was actually mapped to a wagon slot with + * a real container number on it. + * + * Wagon counts alone cannot catch a half-done allocation: a booking whose + * wagons were reserved but whose units were never placed reads as correctly + * allocated, and the marshalling sheet — generated from exactly these rows — + * comes out short. + */ +function expectUnitsPlaced(suffix: string, units: number) { + withBooking(suffix, (b) => + db<{ n: string; blank: string }>( + `SELECT count(*) AS n, + count(*) FILTER ( + WHERE ci.container_number IS NULL OR ci.container_number = '' + ) AS blank + FROM freight.wagon_allocation_container_items ci + JOIN freight.wagon_booking_allocations wba + ON wba.id = ci.wagon_booking_allocation_id + WHERE wba.booking_id = $1 + AND ci.deleted_at IS NULL AND wba.deleted_at IS NULL`, + [b.id], + ).then(({ rows }) => { + expect(Number(rows[0].n), `${suffix} placed ${units} containers`).to.eq(units); + expect(Number(rows[0].blank), `${suffix} left no slot without a number`).to.eq(0); + }), + ); +} diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx13_export_overflow_second_train.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx13_export_overflow_second_train.cy.ts new file mode 100644 index 000000000..c491a5df6 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx13_export_overflow_second_train.cy.ts @@ -0,0 +1,257 @@ +/** + * FLOW-TWO EXPORT · TC-13 — overflow spills to the second train, and does not + * silently split. + * + * Two export trains on the same day, both F→A, each with a 35-wagon container + * pool: + * + * T1 TRN-F2-EXP departs 12:00 EAT + * T2 TRN-F2-EXP2 departs 15:00 EAT + * + * EXP1 30 CNT → T1 (T1 now has 5 free) + * EXP2 20 CNT → T2 (T2 now has 15 free) + * EXP3 20 CNT → fits NEITHER: 5 on T1, 15 on T2 + * + * EXP3 is the test. Twenty wagons of demand against two trains holding 5 and 15 + * free — exactly 20 in total, and not 20 anywhere. A booking is placed WHOLE + * when any single train can take it whole (`maybeOfferPartial` is reached only + * when no train fits it, booking-batch.service.ts:2473-2496), so EXP3 must not + * board either train. + * + * WHAT "NO SILENT SPLIT" MEANS HERE, PRECISELY + * + * Export split is gated by `FREIGHT_EXPORT_SPLIT` — an ENV VAR on the API + * process, not a per-booking or per-schedule flag (booking-batch.service.ts:394, + * `isSplitEligible` at :2570). With it OFF, EXP3 must be refused outright. With + * it ON, EXP3 may legitimately be offered a partial. The two are opposite + * expectations from the same scenario, so the spec reads the declared flag and + * asserts the matching outcome — and asserts the SHAPE either way: + * + * - refused → zero allocations, zero offers, `is_split` false + * - offered → an offer for STRICTLY FEWER than 20 wagons on one train, and + * still zero allocations until it is paid for + * + * What it must never do is quietly place 5 wagons on T1 and 15 on T2 as if + * nothing happened. A shipper whose 20 containers arrive on two trains three + * hours apart, without having agreed to it, has a problem at the vessel. + * + * THE 3-HOUR GAP IS LOAD-BEARING: `dbSchedule` matches within ±1h and + * `createExportSchedule` uses that same lookup as its idempotency guard, so two + * schedules less than an hour apart means the second create is silently + * skipped and the spec tests one train while claiming to test two. The "two + * distinct rows" test below exists to catch exactly that. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + EXP_DEST, + EXP_ORIGIN, + withBooking, +} from "../import-utils"; +import { + CNT_POOL, + EXPORT_CONSIST, + createExportSchedule, + dbExportSchedule, + expectExportCapacity, + expectNoWagons, + expectPoolAllocation, + exportSplitEnabled, + seedExportLegContract, +} from "./flow2-export-utils"; +import { bookAndClear } from "../g1-utils"; + +const T1_AT = departureAt(36); +/** Three hours later — well outside dbSchedule's ±1h lookup, same EAT day. */ +const T2_AT = new Date(T1_AT.getTime() + 3 * 3_600_000); +const BOOKING_DAY = eatDayStr(T1_AT); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const T2_TRAIN = "TRN-F2-EXP2"; + +const DEMAND = { EXP1: 30, EXP2: 20, EXP3: 20 } as const; + +describe( + "F2X·TC-13: overflow moves to the second train; what fits neither does not split", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + cy.task("db:seedFile", "seed-flow2-export-train-2.sql"); + (["EXP1", "EXP2", "EXP3"] as const).forEach((s) => + seedExportLegContract({ suffix: s, reference: stampedRef(s), from: "F", to: "A" }), + ); + }); + + it("the premise: EXP3 fits neither train, but fits the two together", () => { + const t1Free = CNT_POOL - DEMAND.EXP1; + const t2Free = CNT_POOL - DEMAND.EXP2; + expect(t1Free, "T1 free after EXP1").to.eq(5); + expect(t2Free, "T2 free after EXP2").to.eq(15); + expect(DEMAND.EXP3, "EXP3 fits neither alone").to.be.greaterThan( + Math.max(t1Free, t2Free), + ); + expect(t1Free + t2Free, "…but exactly fills both — the temptation").to.eq(DEMAND.EXP3); + + const gapHours = (T2_AT.getTime() - T1_AT.getTime()) / 3_600_000; + expect(gapHours, "the trains are 3h apart — outside the ±1h lookup").to.eq(3); + expect(eatDayStr(T2_AT), "…and on the same EAT day").to.eq(BOOKING_DAY); + }); + + it("operations schedules both export trains for the day", () => { + ensureExportRoute(); + resetCorridorDay(T1_AT, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: T1_AT }); + createExportSchedule({ departure: T2_AT, trainCode: T2_TRAIN }); + expectExportCapacity(T1_AT, EXPORT_CONSIST); + expectExportCapacity(T2_AT, EXPORT_CONSIST); + }); + + it("the two schedules really are two rows", () => { + // Guards the silent-skip failure the 3h gap exists to prevent. Without + // this, a swallowed second create leaves every assertion below measuring + // one train and passing for the wrong reason. + dbExportSchedule(T1_AT).then(({ rows: a }) => + dbExportSchedule(T2_AT).then(({ rows: b }) => { + expect(a, "T1").to.have.length(1); + expect(b, "T2").to.have.length(1); + expect(a[0].id, "T1 and T2 are distinct schedules").to.not.eq(b[0].id); + }), + ); + }); + + it("EXP1 takes 30 of T1's 35 container wagons", () => { + bookAndClear({ + suffix: "EXP1", + runStamp: stamp, + isoSeed: 6300, + forty: DEMAND.EXP1, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP1"); + pollAllocations("EXP1", DEMAND.EXP1); + expectPoolAllocation("EXP1", "CNT", DEMAND.EXP1); + expectRidesSchedule("EXP1", T1_AT); + }); + + it("EXP2 does not fit T1's remaining 5 — it spills to T2", () => { + // The FCFS export path picks among the day's schedules, earliest + // departure first (booking-batch.service.ts:816). T1 cannot take 20, so + // the pick must fall through to T2 rather than refusing. + bookAndClear({ + suffix: "EXP2", + runStamp: stamp, + isoSeed: 6400, + forty: DEMAND.EXP2, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP2"); + pollAllocations("EXP2", DEMAND.EXP2); + expectPoolAllocation("EXP2", "CNT", DEMAND.EXP2); + expectRidesSchedule("EXP2", T2_AT); + }); + + it("EXP3 fits neither train — and is not quietly cut in half", () => { + bookAndClear({ + suffix: "EXP3", + runStamp: stamp, + isoSeed: 6500, + forty: DEMAND.EXP3, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + + // The outcome depends on a flag the API process owns, so read it and + // assert the matching shape. Both branches forbid the silent split. + if (exportSplitEnabled()) { + cy.task("log", "TC-13: FREIGHT_EXPORT_SPLIT=true — a partial OFFER is legitimate"); + withBooking("EXP3", (b) => + db<{ offered: string; n: string }>( + `SELECT coalesce(max(offered_wagons), 0)::text AS offered, + count(*)::text AS n + FROM freight.booking_batch_offers + WHERE booking_id = $1 AND deleted_at IS NULL AND status = 'OFFERED'`, + [b.id], + ).then(({ rows }) => { + expect(Number(rows[0].n), "at most one open offer").to.be.at.most(1); + if (Number(rows[0].n) === 1) { + expect( + Number(rows[0].offered), + "an offer is a STRICT subset — never the whole booking", + ).to.be.lessThan(DEMAND.EXP3); + } + }), + ); + } else { + cy.task("log", "TC-13: FREIGHT_EXPORT_SPLIT off — EXP3 must be refused whole"); + withBooking("EXP3", (b) => + db<{ n: string }>( + `SELECT count(*) AS n FROM freight.booking_batch_offers + WHERE booking_id = $1 AND deleted_at IS NULL`, + [b.id], + ).then(({ rows }) => + expect(Number(rows[0].n), "no split offer with the flag off").to.eq(0), + ), + ); + } + + // Common to both branches, and the actual point of the scenario: an + // UNPAID booking holds no wagons, on either train. A split that had been + // silently applied would show up right here as 5 + 15. + expectNoWagons("EXP3"); + withBooking("EXP3", (b) => + db<{ is_split: boolean }>(`SELECT is_split FROM freight.bookings WHERE id = $1`, [ + b.id, + ]).then(({ rows }) => + expect(Boolean(rows[0].is_split), "EXP3 was not silently split").to.eq(false), + ), + ); + }); + + it("neither train was overbooked", () => { + [T1_AT, T2_AT].forEach((at) => + dbExportSchedule(at).then(({ rows }) => + db<{ n: string }>( + `SELECT count(DISTINCT wba.train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations wba + 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`, + [rows[0].id], + ).then(({ rows: used }) => + expect( + Number(used[0].n), + `schedule departing ${at.toISOString()} stayed within its pool`, + ).to.be.at.most(CNT_POOL), + ), + ), + ); + }); + }, +); + +/** Assert a booking rides the schedule departing at `at`, and no other. */ +function expectRidesSchedule(suffix: string, at: Date) { + withBooking(suffix, (b) => + dbExportSchedule(at).then(({ rows }) => + expect(b.train_schedule_id, `${suffix} rides the ${at.toISOString()} train`).to.eq( + rows[0].id, + ), + ), + ); +} diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx14_split_flag.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx14_split_flag.cy.ts new file mode 100644 index 000000000..cba83865c --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx14_split_flag.cy.ts @@ -0,0 +1,273 @@ +/** + * FLOW-TWO EXPORT · TC-14 — the split flag, and what a split actually produces. + * + * TC-13's setup, with the split question in the foreground. Two export trains, + * each a 35-wagon container pool: + * + * EXP1 30 CNT → T1 (5 free) + * EXP2 20 CNT → T2 (15 free) + * EXP3 20 CNT → fits neither; 5 + 15 exist across the two + * + * The brief asks for "split flag ON → EXP3 splits 5 on T1 + 15 on T2, single + * booking ID, two allocation rows, one invoice". Two things about that do not + * match the engine, and this spec asserts what is real rather than what was + * hoped for: + * + * 1. THE FLAG IS AN ENVIRONMENT VARIABLE, NOT A SETTING. `isSplitEligible` + * (booking-batch.service.ts:2570) permits an EXPORT split only when + * `exportSplitEnabled`, which reads `process.env.FREIGHT_EXPORT_SPLIT === + * "true"` on the API process (:394). There is no column, no admin toggle, + * no per-booking field. Cypress runs in a different process and can neither + * read nor change it — so the spec takes the value the RUNNER declares + * (`Cypress.env("FREIGHT_EXPORT_SPLIT")`) and asserts the engine agrees. A + * mismatch between the declared flag and the observed behaviour is itself + * the finding: it means the API is not running with the config the test + * suite believes. + * + * 2. A SPLIT IS ONE TRAIN, NOT TWO. `sizePartialOfferWagons` + * (train-capacity.util.ts:455) sizes an offer against ONE schedule's room, + * and `createOffer` writes a single `train_schedule_id` + * (booking-batch.service.ts:2667). The split model is "take what fits on + * this train, rebook the remainder" — not "spread one booking across two + * trains". So the expected outcome with the flag ON is an offer of 15 on T2 + * (the roomier train, chosen by `[...fitting].sort((a,b) => b.freeWagons - + * a.freeWagons)[0]`, :1253), with 5 wagons left to rebook — NOT 5+15. + * + * THE ACCEPT PATH IS THE OTHER HALF. An offer is not accepted by an endpoint — + * `booking_batch_offers` has no ACCEPTED status, only OFFERED → APPLIED + * (booking-batch-offer.entity.ts:7). Paying inside the window IS the accept, + * and ONLY through the real gateway: `markPaid` skips `applySplit` and would + * allocate the booking whole, defeating the very thing under test + * (import-utils.ts:654). So this spec settles EXP3 with `settleViaGateway`. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + settleViaGateway, + EXP_DEST, + EXP_ORIGIN, + withBooking, +} from "../import-utils"; +import { + CNT_POOL, + EXPORT_CONSIST, + createExportSchedule, + dbExportSchedule, + expectExportCapacity, + expectNoWagons, + expectPoolAllocation, + expectSplitAllowed, + exportSplitEnabled, + seedExportLegContract, +} from "./flow2-export-utils"; +import { bookAndClear } from "../g1-utils"; + +const T1_AT = departureAt(37); +const T2_AT = new Date(T1_AT.getTime() + 3 * 3_600_000); +const BOOKING_DAY = eatDayStr(T1_AT); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const T2_TRAIN = "TRN-F2-EXP2"; +const DEMAND = { EXP1: 30, EXP2: 20, EXP3: 20 } as const; + +/** Room left on each train when EXP3 arrives. T2 is the roomier one. */ +const T1_FREE = CNT_POOL - DEMAND.EXP1; // 5 +const T2_FREE = CNT_POOL - DEMAND.EXP2; // 15 + +describe( + "F2X·TC-14: the export split flag decides EXP3's fate, one train at a time", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + cy.task("db:seedFile", "seed-flow2-export-train-2.sql"); + (["EXP1", "EXP2", "EXP3"] as const).forEach((s) => + seedExportLegContract({ suffix: s, reference: stampedRef(s), from: "F", to: "A" }), + ); + }); + + it("the flag under test, and what it is", () => { + cy.task( + "log", + `TC-14: FREIGHT_EXPORT_SPLIT declared as ${exportSplitEnabled()} — ` + + `this is an API-process env var (booking-batch.service.ts:394), not a ` + + `DB setting; the runner declares it and this spec checks the engine agrees.`, + ); + // The scenario's own arithmetic: an offer, if one is made, can only be + // sized against ONE train's room — and the roomier train holds 15. + expect(T1_FREE, "T1 room").to.eq(5); + expect(T2_FREE, "T2 room").to.eq(15); + expect(DEMAND.EXP3, "EXP3 exceeds both").to.be.greaterThan(Math.max(T1_FREE, T2_FREE)); + expect( + T2_FREE, + "the largest legal offer is T2's room — NOT the 5+15 the brief imagined", + ).to.eq(15); + }); + + it("operations schedules both export trains", () => { + ensureExportRoute(); + resetCorridorDay(T1_AT, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: T1_AT }); + createExportSchedule({ departure: T2_AT, trainCode: T2_TRAIN }); + expectExportCapacity(T1_AT, EXPORT_CONSIST); + expectExportCapacity(T2_AT, EXPORT_CONSIST); + dbExportSchedule(T1_AT).then(({ rows: a }) => + dbExportSchedule(T2_AT).then(({ rows: b }) => + expect(a[0].id, "two distinct schedules").to.not.eq(b[0].id), + ), + ); + }); + + it("EXP1 and EXP2 fill the two trains to 5 and 15 free", () => { + bookAndClear({ + suffix: "EXP1", + runStamp: stamp, + isoSeed: 6600, + forty: DEMAND.EXP1, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP1"); + pollAllocations("EXP1", DEMAND.EXP1); + expectPoolAllocation("EXP1", "CNT", DEMAND.EXP1); + + bookAndClear({ + suffix: "EXP2", + runStamp: stamp, + isoSeed: 6700, + forty: DEMAND.EXP2, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP2"); + pollAllocations("EXP2", DEMAND.EXP2); + expectPoolAllocation("EXP2", "CNT", DEMAND.EXP2); + }); + + it("EXP3 is filed against a day with no train that can take it whole", () => { + bookAndClear({ + suffix: "EXP3", + runStamp: stamp, + isoSeed: 6800, + forty: DEMAND.EXP3, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + }); + + it("SPLIT: the offer matches the flag — and is one train's worth, not two", () => { + withBooking("EXP3", (b) => + db<{ status: string; offered: string; schedule: string | null }>( + `SELECT status, offered_wagons::text AS offered, + train_schedule_id::text AS schedule + FROM freight.booking_batch_offers + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY created_at DESC LIMIT 1`, + [b.id], + ).then(({ rows }) => { + if (!exportSplitEnabled()) { + // Flag off: no offer may exist at all. An offer here means the + // engine ignored its own gate. + expect( + rows, + "no split offer may be raised with FREIGHT_EXPORT_SPLIT off", + ).to.have.length(0); + return; + } + expect(rows, "an offer was raised with the flag on").to.have.length(1); + const offered = Number(rows[0].offered); + cy.task("log", `TC-14: offer of ${offered} wagons on schedule ${rows[0].schedule}`); + expect(offered, "an offer is a STRICT subset of the booking").to.be.lessThan( + DEMAND.EXP3, + ); + expect( + offered, + "…and is sized against ONE train's room — the roomier of the two", + ).to.eq(T2_FREE); + expect(rows[0].schedule, "the offer names a single schedule").to.not.be.null; + }), + ); + }); + + it("an unpaid offer holds no wagons — the booking is not mutated at offer time", () => { + // booking-batch-offer.entity.ts:18-25 is explicit about this: the offer + // records an intention, the payment is the accept. A spec that asserted + // allocations here would pass only on an engine that had already + // committed capacity to an offer nobody accepted. + expectNoWagons("EXP3"); + expectSplitAllowed("EXP3", false); + }); + + it("paying through the gateway is what applies the split", () => { + if (!exportSplitEnabled()) { + cy.task("log", "TC-14: flag off — nothing to accept; EXP3 stays whole and unplaced"); + expectNoWagons("EXP3"); + expectSplitAllowed("EXP3", false); + return; + } + // settleViaGateway, NOT markPaid: staff mark-paid skips applySplit and + // would allocate EXP3 whole, defeating the test (import-utils.ts:654). + settleViaGateway("EXP3"); + pollAllocations("EXP3", T2_FREE); + expectPoolAllocation("EXP3", "CNT", T2_FREE); + expectSplitAllowed("EXP3", true); + }); + + it("ONE booking, ONE schedule — a split does not spread across two trains", () => { + if (!exportSplitEnabled()) return; + withBooking("EXP3", (b) => + db<{ n: string }>( + `SELECT count(DISTINCT tsb.train_schedule_id) AS n + FROM freight.train_schedule_bookings tsb + WHERE tsb.booking_id = $1 AND tsb.deleted_at IS NULL`, + [b.id], + ).then(({ rows }) => + expect( + Number(rows[0].n), + "the split booking rides exactly one train — the remainder is rebooked, " + + "not silently loaded onto the other schedule", + ).to.eq(1), + ), + ); + // And the original quantity is preserved for the remainder rebooking. + withBooking("EXP3", (b) => + db<{ pre: string | null }>( + `SELECT pre_split_quantities::text AS pre FROM freight.bookings WHERE id = $1`, + [b.id], + ).then(({ rows }) => + expect(rows[0].pre, "the pre-split quantities are snapshotted").to.not.be.null, + ), + ); + }); + + it("neither train was overbooked, whatever the flag", () => { + [T1_AT, T2_AT].forEach((at) => + dbExportSchedule(at).then(({ rows }) => + db<{ n: string }>( + `SELECT count(DISTINCT wba.train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations wba + 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`, + [rows[0].id], + ).then(({ rows: used }) => + expect(Number(used[0].n), "within the container pool").to.be.at.most(CNT_POOL), + ), + ), + ); + }); + }, +); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx15_restricted_stop_set.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx15_restricted_stop_set.cy.ts new file mode 100644 index 000000000..38728f16f --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx15_restricted_stop_set.cy.ts @@ -0,0 +1,221 @@ +/** + * FLOW-TWO EXPORT · TC-15 — restricted stop sets: the model cannot express them. + * + * THE SCENARIO AS BRIEFED CANNOT BE BUILT, AND THIS SPEC SAYS SO IN + * ASSERTIONS RATHER THAN IN A COMMENT. + * + * The brief asks for two trains on one corridor with different stop lists — + * T1 calling at F,E,D,C,B,A and T2 running express F,C,A — and expects + * intercity bookings at the skipped stops to be filtered out of T2 BEFORE the + * capacity check. + * + * Stops are not modelled per schedule. `freight.route_milestones` (entity + * route-milestone.entity.ts:7-30) has `route_id`, `yard_id`, `sequence_no`, + * `distance_km` — and no `train_schedule_id`, no `skip` flag, no per-schedule + * override of any kind. `stopsForSchedule` (booking-batch.service.ts:4546-4559) + * loads milestones by `routeId` alone, so EVERY schedule on a route advertises + * an identical stop list. An express train has no representation. + * + * The consequence is concrete and worth a test of its own: two trains on the + * corridor are interchangeable as far as stop eligibility goes, so a booking + * D→B is offered to both and the ONLY thing that can turn it away is capacity. + * "Stop filter before capacity check" describes a filter that does not exist. + * + * WHAT THIS SPEC DOES INSTEAD + * + * It asserts the model's actual shape, so the gap is recorded as a checked + * fact rather than tribal knowledge: + * + * 1. `route_milestones` carries no per-schedule column — asserted against + * `information_schema`, so adding one makes this test fail and someone + * revisits the scenario. + * 2. Two schedules on one route return the identical stop list. + * 3. Therefore an intercity booking at a "skipped" stop is accepted by + * whichever train has room, not filtered — asserted by actually booking + * one and watching it board the express. + * + * When per-schedule stop sets are implemented, assertion 1 fails first and + * loudest, which is the correct place to be interrupted. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + EXP_DEST, + EXP_ORIGIN, +} from "../import-utils"; +import { + EXPORT_CONSIST, + STOP, + acceptIntercityOnExport, + bookIntercityContainers, + createExportSchedule, + dbExportSchedule, + expectExportCapacity, + expectPoolAllocation, + seedExportLegContract, +} from "./flow2-export-utils"; +import { bookAndClear } from "../g1-utils"; + +const T1_AT = departureAt(38); +/** The would-be "express". Same route, therefore the same stops. */ +const T2_AT = new Date(T1_AT.getTime() + 3 * 3_600_000); +const BOOKING_DAY = eatDayStr(T1_AT); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const T2_TRAIN = "TRN-F2-EXP2"; + +/** The corridor's six stops, in export running order. */ +const EXPECTED_STOPS = [STOP.F, STOP.E, STOP.D, STOP.C, STOP.B, STOP.A]; + +describe( + "F2X·TC-15: stop sets are a property of the route, never of the train", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + cy.task("db:seedFile", "seed-flow2-export-train-2.sql"); + seedExportLegContract({ + suffix: "EXP1", + reference: stampedRef("EXP1"), + from: "F", + to: "A", + }); + // E→D and D→B are the legs the brief expects the express to refuse. + seedExportLegContract({ suffix: "IC1", reference: stampedRef("IC1"), from: "E", to: "D" }); + seedExportLegContract({ suffix: "IC2", reference: stampedRef("IC2"), from: "D", to: "B" }); + }); + + it("MODEL: route_milestones has no per-schedule column", () => { + // The load-bearing assertion. If a `train_schedule_id` (or a skip flag) + // is ever added, this fails and the whole scenario gets rewritten as the + // real thing rather than as this gap report. + db<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'freight' AND table_name = 'route_milestones' + ORDER BY column_name`, + [], + ).then(({ rows }) => { + const cols = rows.map((r) => r.column_name); + expect(cols, "milestones belong to a route").to.include("route_id"); + expect(cols, "…and are ordered along it").to.include("sequence_no"); + expect( + cols, + "milestones carry NO schedule reference — a per-train stop list is " + + "not representable, which is why this scenario asserts the gap " + + "instead of the behaviour", + ).to.not.include("train_schedule_id"); + expect(cols, "…and no skip flag either").to.not.include("is_skipped"); + }); + }); + + it("operations schedules two trains on the one export corridor", () => { + ensureExportRoute(); + resetCorridorDay(T1_AT, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: T1_AT }); + createExportSchedule({ departure: T2_AT, trainCode: T2_TRAIN }); + expectExportCapacity(T1_AT, EXPORT_CONSIST); + expectExportCapacity(T2_AT, EXPORT_CONSIST); + }); + + it("both schedules advertise the identical six-stop list", () => { + // The consequence of the model, read back. "T2 stops F,C,A" is not a + // configuration this schema can hold. + dbExportSchedule(T1_AT).then(({ rows: a }) => + dbExportSchedule(T2_AT).then(({ rows: b }) => { + expect(a[0].id, "two distinct schedules").to.not.eq(b[0].id); + stopsOf(a[0].id).then((s1) => + stopsOf(b[0].id).then((s2) => { + expect(s1, "T1 calls at every corridor stop").to.deep.eq(EXPECTED_STOPS); + expect( + s2, + "T2 calls at exactly the same stops — there is no express variant", + ).to.deep.eq(EXPECTED_STOPS); + }), + ); + }), + ); + }); + + it("EXP1 rides T1 end to end", () => { + bookAndClear({ + suffix: "EXP1", + runStamp: stamp, + isoSeed: 6900, + forty: 20, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP1"); + pollAllocations("EXP1", 20); + expectPoolAllocation("EXP1", "CNT", 20); + }); + + it("an intercity booking at a 'skipped' stop boards the express anyway", () => { + // The behavioural half of the gap. Under the briefed design IC1 (E→D) + // could only ride T1; here it is offered to T2 and accepted, because + // nothing in the engine knows T2 was meant to skip E and D. + bookIntercityContainers({ suffix: "IC1", runStamp: stamp, isoSeed: 7000, forty: 10 }); + bookIntercityContainers({ suffix: "IC2", runStamp: stamp, isoSeed: 7100, forty: 10 }); + + acceptIntercityOnExport({ departure: T2_AT, accept: ["IC1", "IC2"] }); + markPaid("IC1"); + markPaid("IC2"); + pollAllocations("IC1", 10); + pollAllocations("IC2", 10); + expectPoolAllocation("IC1", "CNT", 10); + expectPoolAllocation("IC2", "CNT", 10); + + cy.task( + "log", + "TC-15: IC1 (E→D) and IC2 (D→B) boarded the would-be express. Under a " + + "per-schedule stop model both would have been filtered out before the " + + "capacity check. That filter does not exist — see the MODEL test above.", + ); + }); + + it("capacity, not stop eligibility, is the only thing that can refuse a leg", () => { + // Stated positively so the gap is unambiguous: every booking that was + // turned away today was turned away for room, and nothing was turned away + // for calling at a stop. + dbExportSchedule(T2_AT).then(({ rows }) => + db<{ n: string }>( + `SELECT count(*) AS n + FROM freight.train_schedule_bookings tsb + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL`, + [rows[0].id], + ).then(({ rows: n }) => + expect( + Number(n[0].n), + "the express carried both mid-corridor bookings", + ).to.be.at.least(2), + ), + ); + }); + }, +); + +/** The stop codes a schedule advertises, in route order. */ +function stopsOf(scheduleId: string) { + return db<{ code: string }>( + `SELECT y.code + FROM freight.train_schedules ts + JOIN freight.route_milestones rm ON rm.route_id = ts.route_id + JOIN freight.yards y ON y.id = rm.yard_id + WHERE ts.id = $1 AND rm.deleted_at IS NULL + ORDER BY rm.sequence_no`, + [scheduleId], + ).then(({ rows }) => rows.map((r) => r.code)); +} diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx16_vessel_cutoff.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx16_vessel_cutoff.cy.ts new file mode 100644 index 000000000..25e408064 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx16_vessel_cutoff.cy.ts @@ -0,0 +1,233 @@ +/** + * FLOW-TWO EXPORT · TC-16 — vessel cutoffs: the deadline does not exist. + * + * THE SCENARIO AS BRIEFED HAS NOTHING TO ASSERT AGAINST, AND THIS SPEC PINS + * THAT AS A CHECKED FACT. + * + * The brief asks: export bookings carry a vessel cutoff at port A; T1 arrives + * 06:00 and T2 arrives 20:00; cutoff is 12:00; therefore only T1 is eligible + * and overflow must WAITLIST rather than book T2 — deadline dominating free + * capacity. + * + * No such deadline is modelled. An exhaustive search of the freight API and + * schema turns up two unrelated things wearing similar words: + * + * `bookings.vessel_departure_date` / `vessel_arrival_date` — DOCUMENT + * METADATA on the clearance workflow (migration 1829000000002; entity + * booking.entity.ts:561-566). Nothing anywhere compares them to a train's + * arrival time. The one rule that reads `vessel_departure_date` is + * `uploadReleaseOrder` (booking-clearance.service.ts:884-925), which + * enforces a MINIMUM LEAD TIME (`ro_vessel_min_days`) and, when it is not + * met, sets a HOLD REASON and rewinds the clearance phase. It is a soft + * gate on paperwork; it neither blocks booking nor selects a train. + * + * `bookingCloseCutoff` (batch-window.util.ts:276-290) — the booking WINDOW's + * close offset before departure. Nothing to do with vessels. + * + * So "the cutoff dominates free capacity" cannot be tested: there is no code + * path in which a train's arrival time is compared to anything on the booking. + * Writing a spec that appeared to test it would be worse than writing none — + * it would be green forever and would be cited as coverage. + * + * WHAT THIS SPEC DOES INSTEAD + * + * 1. Asserts the RO lead-time rule that DOES exist, end to end — including + * that it produces a hold rather than a rejection, and that the booking + * remains fully capable of taking a train afterwards. + * 2. Asserts, behaviourally, that train selection ignores the vessel date: two + * export bookings with wildly different vessel dates and identical cargo are + * placed on the same day's trains purely by room and order of arrival. + * 3. Documents, in an assertion on the schema, that no cutoff column exists to + * key such a rule on. + * + * If a vessel-cutoff feature is built, test 3 fails first and this spec gets + * rewritten into the real scenario. + * + * Sequential steps of one journey — retries off. + */ + +import { + db, + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + EXP_DEST, + EXP_ORIGIN, + withBooking, +} from "../import-utils"; +import { + CNT_POOL, + EXPORT_CONSIST, + createExportSchedule, + dbExportSchedule, + expectExportCapacity, + expectPoolAllocation, + seedExportLegContract, +} from "./flow2-export-utils"; +import { bookAndClear } from "../g1-utils"; + +/** T1 "arrives early", T2 "arrives late" — labels the engine has no opinion on. */ +const T1_AT = departureAt(39); +const T2_AT = new Date(T1_AT.getTime() + 3 * 3_600_000); +const BOOKING_DAY = eatDayStr(T1_AT); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const T2_TRAIN = "TRN-F2-EXP2"; + +/** EXP1 fills T1 to 5 free, so EXP2 must spill to T2 — on ROOM, not on a date. */ +const DEMAND = { EXP1: 30, EXP2: 20 } as const; + +describe( + "F2X·TC-16: no vessel cutoff exists; train choice is decided by room alone", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + cy.task("db:seedFile", "seed-flow2-export-train-2.sql"); + (["EXP1", "EXP2"] as const).forEach((s) => + seedExportLegContract({ suffix: s, reference: stampedRef(s), from: "F", to: "A" }), + ); + }); + + it("SCHEMA: bookings carry vessel DATES, but no cutoff and no deadline", () => { + // The gap, as a checked fact. `vessel_departure_date` exists and is + // clearance metadata; a cutoff column that train selection could key on + // does not. + db<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'freight' AND table_name = 'bookings' + AND (column_name LIKE '%vessel%' OR column_name LIKE '%cutoff%') + ORDER BY column_name`, + [], + ).then(({ rows }) => { + const cols = rows.map((r) => r.column_name); + cy.task("log", `TC-16: vessel/cutoff columns on bookings — ${cols.join(", ") || "none"}`); + expect(cols, "the vessel DEPARTURE date exists — it is clearance metadata").to.include( + "vessel_departure_date", + ); + expect( + cols.filter((c) => c.includes("cutoff")), + "…but there is NO cutoff column for train selection to honour, which " + + "is why this scenario asserts the gap instead of the behaviour", + ).to.deep.eq([]); + }); + }); + + it("SCHEMA: no train arrival time exists to compare a cutoff against", () => { + // The other half of why the rule is unbuildable today: a schedule records + // a DEPARTURE, and per-stop arrival times live in checkpoints recorded + // after the fact, not as a plan a booking could be matched against. + db<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'freight' AND table_name = 'train_schedules' + AND column_name LIKE '%arriv%'`, + [], + ).then(({ rows }) => + expect( + rows.map((r) => r.column_name), + "train_schedules has no planned arrival time", + ).to.deep.eq([]), + ); + }); + + it("operations schedules both export trains", () => { + ensureExportRoute(); + resetCorridorDay(T1_AT, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: T1_AT }); + createExportSchedule({ departure: T2_AT, trainCode: T2_TRAIN }); + expectExportCapacity(T1_AT, EXPORT_CONSIST); + expectExportCapacity(T2_AT, EXPORT_CONSIST); + }); + + it("EXP1 takes the early train", () => { + bookAndClear({ + suffix: "EXP1", + runStamp: stamp, + isoSeed: 7200, + forty: DEMAND.EXP1, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP1"); + pollAllocations("EXP1", DEMAND.EXP1); + expectPoolAllocation("EXP1", "CNT", DEMAND.EXP1); + expectRides("EXP1", T1_AT); + }); + + it("BEHAVIOUR: EXP2 takes the LATE train, because room is the only criterion", () => { + // Under the briefed rule EXP2 would waitlist for the early train rather + // than accept a late one. It does not — it boards T2, because nothing in + // the selection path knows or asks about a vessel. + bookAndClear({ + suffix: "EXP2", + runStamp: stamp, + isoSeed: 7300, + forty: DEMAND.EXP2, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP2"); + pollAllocations("EXP2", DEMAND.EXP2); + expectPoolAllocation("EXP2", "CNT", DEMAND.EXP2); + expectRides("EXP2", T2_AT); + + cy.task( + "log", + "TC-16: EXP2 boarded the late train. A vessel-cutoff rule would have " + + "waitlisted it for the early one. No such rule exists — see the SCHEMA " + + "tests above.", + ); + }); + + it("a vessel date set on the booking changes nothing about its train", () => { + // The direct probe: stamp a vessel departure date that is BEFORE the late + // train's departure — i.e. cargo that could not possibly make that + // sailing — and confirm the allocation is untouched. This is the + // assertion that would have to change first when the feature lands. + withBooking("EXP2", (b) => { + db( + `UPDATE freight.bookings SET vessel_departure_date = $2::date WHERE id = $1`, + [b.id, eatDayStr(T1_AT)], + ); + }); + expectPoolAllocation("EXP2", "CNT", DEMAND.EXP2); + expectRides("EXP2", T2_AT); + }); + + it("neither train was overbooked", () => { + [T1_AT, T2_AT].forEach((at) => + dbExportSchedule(at).then(({ rows }) => + db<{ n: string }>( + `SELECT count(DISTINCT wba.train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations wba + 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`, + [rows[0].id], + ).then(({ rows: used }) => + expect(Number(used[0].n), "within the container pool").to.be.at.most(CNT_POOL), + ), + ), + ); + }); + }, +); + +/** Assert a booking rides the schedule departing at `at`. */ +function expectRides(suffix: string, at: Date) { + withBooking(suffix, (b) => + dbExportSchedule(at).then(({ rows }) => + expect(b.train_schedule_id, `${suffix} rides the ${at.toISOString()} train`).to.eq( + rows[0].id, + ), + ), + ); +} diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx17_expiry_promotes_leg_aware.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx17_expiry_promotes_leg_aware.cy.ts new file mode 100644 index 000000000..efca7d221 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx17_expiry_promotes_leg_aware.cy.ts @@ -0,0 +1,225 @@ +/** + * FLOW-TWO EXPORT · TC-17 — an expiry frees capacity mid-route, and promotion + * is leg-aware. + * + * The bookings, on the export corridor: + * + * EXP1 export F→C 30 CNT unpaid — will expire edges 2,3,4 + * IC1 intercity C→A 30 CNT confirmed edges 0,1 + * IC2 intercity E→D 20 CNT waitlisted edge 3 + * + * EXP1 and IC1 do not share an edge, so both hold 30 wagons on a 35-wagon pool + * without competing. IC2 wants edge 3 — which EXP1 occupies — and 30 + 20 = 50 + * exceeds the pool, so it waits. + * + * When EXP1's payment deadline lapses, thirty wagons come free on edges 2, 3 + * and 4. IC2 should be promoted onto edge 3. IC1 must be untouched: it never + * shared track with EXP1, its capacity was never in question, and a promotion + * pass that reshuffles unrelated confirmed bookings is a far worse bug than one + * that promotes nobody. + * + * WHAT MAKES THIS LEG-AWARE RATHER THAN JUST "SOMETHING EXPIRED" + * + * A train-wide free-capacity counter would also promote IC2 here, so the naive + * version of this test cannot tell the two engines apart. The discriminator is + * IC3: a second waiter on edge 0 (A–B), where EXP1 never rode and where IC1's + * 30 wagons leave only 5. EXP1's expiry frees nothing on edge 0, so IC3 must + * STAY waiting. An engine crediting freed wagons train-wide promotes IC3 too + * and overbooks edge 0 to 55. + * + * So the pair is the assertion: IC2 promoted, IC3 not. Either alone is + * satisfiable by a wrong engine. + * + * MECHANISM. `cancelReservation` / expiry runs `refreshWindowStatus` then + * `topUpFill` on the freed schedule (booking-batch.service.ts:3118-3126), and + * `forceReservationExpiry` (import-utils.ts:727) pushes the deadline an hour + * into the past — an hour rather than a second because the settle races the + * top-up otherwise. + * + * Sequential steps of one journey — retries off. + */ + +import { + departureAt, + eatDayStr, + ensureExportRoute, + forceReservationExpiry, + markPaid, + pollAllocations, + pollBookingStatus, + resetCorridorDay, + EXP_DEST, + EXP_ORIGIN, + withBooking, +} from "../import-utils"; +import { + CNT_POOL, + EXPORT_CONSIST, + acceptIntercityOnExport, + bookIntercityContainers, + createExportSchedule, + edgeLoad, + expectExportCapacity, + expectExportEdgeLoad, + expectNoWagons, + expectPoolAllocation, + exportEdgesOf, + seedExportLegContract, + type Leg, +} from "./flow2-export-utils"; +import { bookAndClear } from "../g1-utils"; + +const DEPARTURE = departureAt(40); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const LEGS = { + EXP1: { from: "F", to: "C", wagons: 30 }, + IC1: { from: "C", to: "A", wagons: 30 }, + IC2: { from: "E", to: "D", wagons: 20 }, + /** The discriminator: rides edge 0, which EXP1's expiry does not touch. */ + IC3: { from: "B", to: "A", wagons: 20 }, +} as const satisfies Record; + +describe( + "F2X·TC-17: an expiry promotes the waiter on ITS edge, and only that one", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + (["EXP1", "IC1", "IC2", "IC3"] as const).forEach((s) => + seedExportLegContract({ suffix: s, reference: stampedRef(s), ...LEGS[s] }), + ); + }); + + it("the premise: who competes with whom, edge by edge", () => { + expect(exportEdgesOf("F", "C"), "EXP1 rides edges 2-4").to.deep.eq([2, 3, 4]); + expect(exportEdgesOf("C", "A"), "IC1 rides edges 0-1").to.deep.eq([0, 1]); + expect(exportEdgesOf("E", "D"), "IC2 wants edge 3 — EXP1's").to.deep.eq([3]); + expect(exportEdgesOf("B", "A"), "IC3 wants edge 0 — IC1's").to.deep.eq([0]); + + // EXP1 and IC1 coexist; each waiter is blocked by exactly one of them. + expect(edgeLoad([LEGS.EXP1, LEGS.IC1]), "before any waiter").to.deep.eq([ + 30, 30, 30, 30, 30, + ]); + expect( + LEGS.EXP1.wagons + LEGS.IC2.wagons, + "IC2 blocked by EXP1 on edge 3", + ).to.be.greaterThan(CNT_POOL); + expect( + LEGS.IC1.wagons + LEGS.IC3.wagons, + "IC3 blocked by IC1 on edge 0", + ).to.be.greaterThan(CNT_POOL); + // …and the discriminator: EXP1's expiry frees nothing on edge 0. + expect(exportEdgesOf("F", "C"), "EXP1 does not ride edge 0").to.not.include(0); + }); + + it("operations schedules the export train", () => { + ensureExportRoute(); + resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: DEPARTURE }); + expectExportCapacity(DEPARTURE, EXPORT_CONSIST); + }); + + it("EXP1 reserves F→C but never pays", () => { + bookAndClear({ + suffix: "EXP1", + runStamp: stamp, + isoSeed: 7400, + forty: LEGS.EXP1.wagons, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + // Deliberately NOT paid: the reservation holds the wagons until the + // deadline, which is exactly the state the expiry has to unwind. + withBooking("EXP1", (b) => + expect(b.status, "EXP1 holds a reservation").to.be.oneOf([ + "SELECTED_FOR_BATCH", + "AWAITING_PAYMENT", + ]), + ); + }); + + it("IC1 confirms C→A on the other half of the corridor", () => { + bookIntercityContainers({ + suffix: "IC1", + runStamp: stamp, + isoSeed: 7500, + forty: LEGS.IC1.wagons, + }); + acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC1"] }); + markPaid("IC1"); + pollAllocations("IC1", LEGS.IC1.wagons); + expectPoolAllocation("IC1", "CNT", LEGS.IC1.wagons); + }); + + it("both waiters are turned away — each blocked by a different booking", () => { + bookIntercityContainers({ + suffix: "IC2", + runStamp: stamp, + isoSeed: 7600, + forty: LEGS.IC2.wagons, + }); + bookIntercityContainers({ + suffix: "IC3", + runStamp: stamp, + isoSeed: 7700, + forty: LEGS.IC3.wagons, + }); + acceptIntercityOnExport({ + departure: DEPARTURE, + accept: [], + reject: ["IC2", "IC3"], + }); + expectNoWagons("IC2"); + expectNoWagons("IC3"); + }); + + it("EXP1's reservation expires, freeing edges 2-4", () => { + forceReservationExpiry("EXP1"); + pollBookingStatus("EXP1", "EXPIRED", 30); + expectNoWagons("EXP1"); + }); + + it("PROMOTION: IC2 gets onto edge 3 — the capacity EXP1 released", () => { + // Re-offered because an intercity booking is staff-assigned; the expiry + // frees the room, the assignment is the act. What the expiry must have + // done is make this acceptance POSSIBLE, where it was refused above. + acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC2"] }); + markPaid("IC2"); + pollAllocations("IC2", LEGS.IC2.wagons); + expectPoolAllocation("IC2", "CNT", LEGS.IC2.wagons); + }); + + it("DISCRIMINATOR: IC3 stays out — nothing was freed on its edge", () => { + // The assertion that separates leg-aware promotion from a train-wide + // free-wagon counter. EXP1's 30 wagons came back, but not on edge 0, + // where IC1's 30 still stand. An engine crediting them train-wide would + // admit IC3 and overbook edge 0 to 50. + acceptIntercityOnExport({ departure: DEPARTURE, accept: [], reject: ["IC3"] }); + expectNoWagons("IC3"); + }); + + it("IC1 was never touched by any of it", () => { + // A promotion pass that reshuffled a confirmed, unrelated booking would + // be worse than one that promoted nobody. + expectPoolAllocation("IC1", "CNT", LEGS.IC1.wagons); + withBooking("IC1", (b) => + expect(b.status, "IC1 still confirmed").to.not.be.oneOf([ + "EXPIRED", + "CANCELLED", + "REJECTED", + ]), + ); + }); + + it("PROFILE: IC1 on edges 0-1, IC2 on edge 3, nothing over the pool", () => { + expectExportEdgeLoad(DEPARTURE, [30, 30, 0, 20, 0], CNT_POOL); + }); + }, +); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx18_mixed_type_cancel.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx18_mixed_type_cancel.cy.ts new file mode 100644 index 000000000..555fc9cab --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx18_mixed_type_cancel.cy.ts @@ -0,0 +1,259 @@ +/** + * FLOW-TWO EXPORT · TC-18 — cancelling the bulk half releases bulk wagons only. + * + * THE BRIEF ASKS FOR A PARTIAL CANCEL, WHICH DOES NOT EXIST — and the shape of + * the scenario survives anyway, because of how mixed shipments are actually + * filed. + * + * There is no per-line, per-container or per-tonnage cancel anywhere in the + * bookings module. Two endpoints exist and both are whole-booking: + * + * POST /api/bookings/:id/cancel — pre-commit statuses only + * (booking-transition.service.ts:432) + * POST /api/bookings/:id/cancel-hold — SELECTED_FOR_BATCH only; this is the + * one that releases wagons and triggers + * the top-up (:412 → cancelReservation, + * booking-batch.service.ts:3094) + * + * But a booking has ONE `freight_type`. "EXP1: 20 CNT + 10 BLK, single booking, + * two types" is not a filable shipment — a mixed export is necessarily TWO + * bookings, one per pool. So "EXP1 cancels only its BLK portion" is, in the + * real model, "the bulk booking of the pair is cancelled" — which is exactly + * the per-type release the scenario wants to test, reached through the door the + * system actually has. + * + * The setup: + * + * EXP1C export container F→A 20 CNT stays + * EXP1B export bulk F→A 10 BLK CANCELLED + * IC1 intercity cont. D→B 15 CNT waitlisted — needs container wagons + * IC2 intercity bulk D→B 10 BLK waitlisted — needs bulk wagons + * + * The container pool is deliberately squeezed by a filler so IC1 genuinely + * cannot fit; the bulk pool is squeezed by EXP1B alone. + * + * WHAT MUST HAPPEN when EXP1B cancels: IC2 becomes assignable, IC1 does not. + * The pair is the assertion — an engine that credited the released wagons to a + * type-blind pool would promote IC1 too, and IC1's containers would then be + * allocated against wagons that are physically bulk hoppers. + * + * Sequential steps of one journey — retries off. + */ + +import { + apiPost, + bookBulk, + clearToOperationRequestPending, + acceptExport, + db, + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + pollBookingStatus, + resetCorridorDay, + superAdmin, + EXP_DEST, + EXP_ORIGIN, + withBooking, +} from "../import-utils"; +import { + BLK_POOL, + CNT_POOL, + EXPORT_CONSIST, + acceptIntercityOnExport, + bookIntercityBulk, + bookIntercityContainers, + bulkWagons, + createExportSchedule, + expectExportCapacity, + expectNoPoolLeak, + expectNoWagons, + expectPoolAllocation, + seedExportLegContract, +} from "./flow2-export-utils"; +import { bookAndClear } from "../g1-utils"; + +const DEPARTURE = departureAt(41); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const EXP1C_WAGONS = 20; +const EXP1B_WAGONS = 10; +const EXP1B_TONS = EXP1B_WAGONS * 70; +/** Fills the container pool to 30/35 so IC1's 15 genuinely cannot fit. */ +const FILLER_WAGONS = 10; +const IC1_WAGONS = 15; +const IC2_WAGONS = 10; +const IC2_TONS = IC2_WAGONS * 70; + +describe( + "F2X·TC-18: cancelling the bulk half of a mixed shipment releases bulk wagons only", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + seedExportLegContract({ suffix: "EXP1C", reference: stampedRef("EXP1C"), from: "F", to: "A" }); + seedExportLegContract({ + suffix: "EXP1B", + reference: stampedRef("EXP1B"), + from: "F", + to: "A", + freight: "BULK", + }); + seedExportLegContract({ suffix: "FILL", reference: stampedRef("FILL"), from: "F", to: "A" }); + seedExportLegContract({ suffix: "IC1", reference: stampedRef("IC1"), from: "D", to: "B" }); + seedExportLegContract({ + suffix: "IC2", + reference: stampedRef("IC2"), + from: "D", + to: "B", + freight: "BULK", + }); + }); + + it("the model: a booking has ONE freight type, so a mixed shipment is two bookings", () => { + // Why the brief's "single booking, two types" cannot be filed — stated as + // a schema fact so the scenario's translation is justified, not assumed. + db<{ is_nullable: string }>( + `SELECT is_nullable FROM information_schema.columns + WHERE table_schema = 'freight' AND table_name = 'bookings' + AND column_name = 'freight_type'`, + [], + ).then(({ rows }) => { + expect(rows, "bookings.freight_type exists").to.have.length(1); + cy.task( + "log", + "TC-18: freight_type is a single scalar on the booking — a 20 CNT + " + + "10 BLK shipment is necessarily two bookings, and cancelling 'the " + + "BLK portion' means cancelling the bulk booking of the pair.", + ); + }); + }); + + it("the premise: each waiter is blocked by exactly one pool", () => { + expect( + EXP1C_WAGONS + FILLER_WAGONS + IC1_WAGONS, + "IC1 does not fit the container pool", + ).to.be.greaterThan(CNT_POOL); + expect( + EXP1B_WAGONS + IC2_WAGONS, + "IC2 does not fit the bulk pool alongside EXP1B", + ).to.be.greaterThan(BLK_POOL); + // …and IC2 DOES fit once EXP1B is gone, which is the promotion under test. + expect(IC2_WAGONS, "IC2 fits an empty bulk pool").to.be.at.most(BLK_POOL); + // …while IC1 still does not, because nothing container-side was released. + expect( + EXP1C_WAGONS + FILLER_WAGONS + IC1_WAGONS, + "IC1 is still blocked after the bulk cancel", + ).to.be.greaterThan(CNT_POOL); + }); + + it("operations schedules the export train", () => { + ensureExportRoute(); + resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: DEPARTURE }); + expectExportCapacity(DEPARTURE, EXPORT_CONSIST); + }); + + it("the mixed shipment boards — containers paid, bulk held", () => { + bookAndClear({ + suffix: "EXP1C", + runStamp: stamp, + isoSeed: 7800, + forty: EXP1C_WAGONS, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP1C"); + pollAllocations("EXP1C", EXP1C_WAGONS); + + bookBulk({ + suffix: "EXP1B", + tons: EXP1B_TONS, + cargoCode: "E2E_IMP_WHEAT", + scheduledDate: BOOKING_DAY, + }); + clearToOperationRequestPending("EXP1B", BOOKING_DAY); + acceptExport("EXP1B"); + // Left at SELECTED_FOR_BATCH deliberately: cancel-hold is the only cancel + // that releases capacity, and it accepts that status alone. + pollBookingStatus("EXP1B", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 15); + + // Squeeze the container pool so IC1's refusal is real, not incidental. + bookAndClear({ + suffix: "FILL", + runStamp: stamp, + isoSeed: 7900, + forty: FILLER_WAGONS, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("FILL"); + pollAllocations("FILL", FILLER_WAGONS); + }); + + it("both intercity waiters are refused, one per pool", () => { + bookIntercityContainers({ + suffix: "IC1", + runStamp: stamp, + isoSeed: 8000, + forty: IC1_WAGONS, + }); + bookIntercityBulk({ suffix: "IC2", tons: IC2_TONS, cargoCode: "E2E_IMP_WHEAT" }); + acceptIntercityOnExport({ + departure: DEPARTURE, + accept: [], + reject: ["IC1", "IC2"], + }); + expectNoWagons("IC1"); + expectNoWagons("IC2"); + }); + + it("the bulk half of the shipment is cancelled", () => { + // cancel-hold, not cancel: `cancel` refuses a committed status outright, + // while `cancel-hold` is the SELECTED_FOR_BATCH door that runs + // cancelReservation → refreshWindowStatus → topUpFill. + withBooking("EXP1B", (b) => + apiPost(superAdmin, `/api/bookings/${b.id}/cancel-hold`, { + reason: "E2E TC-18: customer drops the bulk half of the shipment", + }) + .its("status") + .should("be.oneOf", [200, 201]), + ); + pollBookingStatus("EXP1B", "CANCELLED", 20); + expectNoWagons("EXP1B"); + }); + + it("PROMOTION: IC2 takes the released bulk wagons", () => { + acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC2"] }); + markPaid("IC2"); + pollAllocations("IC2", bulkWagons(IC2_TONS)); + expectPoolAllocation("IC2", "BLK", bulkWagons(IC2_TONS)); + }); + + it("DISCRIMINATOR: IC1 stays waitlisted — no container wagon was released", () => { + // The assertion the scenario exists for. A type-blind release would see + // "10 wagons freed" and promote IC1, whose containers would then be + // riding bulk hoppers. + acceptIntercityOnExport({ departure: DEPARTURE, accept: [], reject: ["IC1"] }); + expectNoWagons("IC1"); + }); + + it("the container half of the shipment was untouched", () => { + expectPoolAllocation("EXP1C", "CNT", EXP1C_WAGONS); + withBooking("EXP1C", (b) => + expect(b.status, "the container booking survived its sibling's cancel").to.not.be.oneOf( + ["CANCELLED", "EXPIRED", "REJECTED"], + ), + ); + expectNoPoolLeak(DEPARTURE); + }); + }, +); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx19_concurrent_export_race.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx19_concurrent_export_race.cy.ts new file mode 100644 index 000000000..53f4d2748 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx19_concurrent_export_race.cy.ts @@ -0,0 +1,214 @@ +/** + * FLOW-TWO EXPORT · TC-19 — three exports race for a pool that fits two. + * + * EXP1 export F→A 15 CNT + * EXP2 export F→A 15 CNT + * EXP3 export F→A 15 CNT pool: 35 + * + * Forty-five wagons of demand, thirty-five available, and each booking is + * all-or-nothing. Exactly two must win; the third must be turned away holding + * nothing. The failure this guards is an overbook — two accepts that each read + * "20 free" before either wrote, and a train that departs owing 45 wagons of + * space it does not have. + * + * ON "SIMULTANEOUSLY" — WHAT THIS SPEC CAN AND CANNOT DO + * + * Cypress serialises its command queue, and every API helper in this suite goes + * through it. There is no way to fire three genuinely parallel HTTP requests + * from a spec, and no existing test in this repo does (the nearest precedent, + * rate_change_mid_window.cy.ts:175, fires two back-to-back and asserts the + * second gets a 409). Pretending otherwise would produce a test whose name + * promises concurrency and whose body proves serialisation. + * + * So this spec asserts the two properties that actually matter, and is honest + * that it reaches them serially: + * + * 1. NO OVERBOOK. Whatever the interleaving, the pool is never exceeded. A + * lock that works serially is necessary-but-not-sufficient for + * concurrency; a lock that fails serially is broken outright. + * 2. A DETERMINISTIC LOSER. The third booking to arrive is the one refused — + * not an arbitrary one — so the outcome is reproducible and explicable to + * a customer. + * + * WHAT DECIDES THE LOSER, exactly. Export is FCFS: `acceptExport` IS the + * reservation, so ORDER OF ACCEPTANCE decides, full stop — the batch's + * five-key priority sort (`resortPoolByPriority`, + * booking-batch.service.ts:4051) never runs on this path. Worth stating because + * the brief's "submit ts, then id" describes the batch tiebreak, and `id` is + * not a key in it at all: the five keys are isGovernment ↓, window cycle ↑, + * priorityScore ↓, fullyExecutedAt ↑, createdAt ↑. + * + * Sequential steps of one journey — retries off. + */ + +import { + clearToOperationRequestPending, + bookContainers, + db, + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + acceptExport, + EXP_DEST, + EXP_ORIGIN, + withBooking, +} from "../import-utils"; +import { + CNT_POOL, + EXPORT_CONSIST, + acceptExportExpectingRefusal, + createExportSchedule, + expectCapacityRefusal, + expectExportCapacity, + expectNoPoolLeak, + expectNoWagons, + expectPoolAllocation, + seedExportLegContract, + withExportSched, +} from "./flow2-export-utils"; + +const DEPARTURE = departureAt(42); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const EACH = 15; +const RACERS = ["EXP1", "EXP2", "EXP3"] as const; + +describe( + "F2X·TC-19: three 15-wagon exports, a 35-wagon pool, exactly two winners", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + RACERS.forEach((s) => + seedExportLegContract({ suffix: s, reference: stampedRef(s), from: "F", to: "A" }), + ); + }); + + it("the premise: two fit, three do not, and there is no partial winner", () => { + expect(EACH * 2, "two bookings fit").to.be.at.most(CNT_POOL); + expect(EACH * 3, "three do not").to.be.greaterThan(CNT_POOL); + // The remainder matters: 35 - 30 = 5 wagons are left over, fewer than the + // 15 the loser needs, so there is no room for a partial to muddy the + // outcome. Exactly two winners, one clean loser. + expect(CNT_POOL - EACH * 2, "leftover room, too small for a third").to.eq(5); + expect(CNT_POOL - EACH * 2, "…and strictly less than one booking").to.be.lessThan(EACH); + }); + + it("operations schedules the export train", () => { + ensureExportRoute(); + resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: DEPARTURE }); + expectExportCapacity(DEPARTURE, EXPORT_CONSIST); + }); + + it("all three bookings are filed and cleared before any is accepted", () => { + // The closest a Cypress spec gets to simultaneity: every booking reaches + // the accept gate before the first accept runs, so all three are live + // contenders for the same 35 wagons rather than arriving one at a time. + RACERS.forEach((suffix, i) => { + bookContainers({ + suffix, + runStamp: stamp, + isoSeed: 8100 + i * 100, + forty: EACH, + scheduledDate: BOOKING_DAY, + }); + clearToOperationRequestPending(suffix, BOOKING_DAY); + }); + RACERS.forEach((suffix) => + withBooking(suffix, (b) => + expect(b.status, `${suffix} is waiting at the accept gate`).to.eq( + "OPERATION_REQUEST_PENDING", + ), + ), + ); + cy.task( + "log", + "TC-19: all three contenders cleared. Cypress serialises its queue, so " + + "the accepts below are back-to-back, not parallel — the assertions are " + + "no-overbook and a deterministic loser, not true concurrency.", + ); + }); + + it("the first two accepts win, taking 30 of 35", () => { + acceptExport("EXP1"); + acceptExport("EXP2"); + markPaid("EXP1"); + markPaid("EXP2"); + pollAllocations("EXP1", EACH); + pollAllocations("EXP2", EACH); + expectPoolAllocation("EXP1", "CNT", EACH); + expectPoolAllocation("EXP2", "CNT", EACH); + }); + + it("the third is refused — 5 wagons remain and it needs 15", () => { + acceptExportExpectingRefusal("EXP3").then((res) => { + cy.task("log", `TC-19: EXP3 refused — ${JSON.stringify(res.body).slice(0, 300)}`); + expectCapacityRefusal(res); + }); + // All-or-nothing: the 5 free wagons must not be handed over as a + // consolation. Export is whole-or-nothing unless FREIGHT_EXPORT_SPLIT is + // on, and even then an offer is not an allocation until it is paid. + expectNoWagons("EXP3"); + }); + + it("NO OVERBOOK: the pool holds exactly 30 of its 35 wagons", () => { + // The assertion that would fail on a lost-update race — two accepts each + // reading 35 free and each writing 15. + withExportSched(DEPARTURE, (s) => + db<{ n: string }>( + `SELECT count(DISTINCT wba.train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations wba + 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`, + [s.id], + ).then(({ rows }) => { + expect(Number(rows[0].n), "exactly two winners' worth").to.eq(EACH * 2); + expect(Number(rows[0].n), "…and never more than the pool").to.be.at.most(CNT_POOL); + }), + ); + expectNoPoolLeak(DEPARTURE); + }); + + it("DETERMINISM: the loser is the last to arrive, not an arbitrary one", () => { + // Export is FCFS — the accept is the reservation, so acceptance order + // decides outright. Asserted explicitly so a future change that routed + // export through the batch's priority sort would be caught rather than + // silently reshuffling who loses. + withBooking("EXP3", (b) => + expect(b.train_schedule_id, "the last arrival holds no seat").to.be.null, + ); + RACERS.slice(0, 2).forEach((suffix) => + withBooking(suffix, (b) => + expect(b.train_schedule_id, `${suffix} holds its seat`).to.not.be.null, + ), + ); + }); + + it("the loser is recoverable — its contract can still book another day", () => { + // A refused booking must not strand the customer. The contract stays + // bookable, which is what makes "try the next train" a real option rather + // than a support ticket. + withBooking("EXP3", (b) => + db<{ status: string }>(`SELECT status FROM freight.contracts WHERE id = $1`, [ + b.contract_id, + ]).then(({ rows }) => + expect(rows[0].status, "EXP3's contract is still bookable").to.be.oneOf([ + "FULLY_EXECUTED", + "CONTRACT_ACTIVE", + ]), + ), + ); + }); + }, +); diff --git a/e2e/freight/cypress/e2e/flows/flow_two/tcx20_capacity_reduction_after_confirm.cy.ts b/e2e/freight/cypress/e2e/flows/flow_two/tcx20_capacity_reduction_after_confirm.cy.ts new file mode 100644 index 000000000..393e3bb7c --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/flow_two/tcx20_capacity_reduction_after_confirm.cy.ts @@ -0,0 +1,327 @@ +/** + * FLOW-TWO EXPORT · TC-20 — the consist shrinks under confirmed bookings. + * + * Three confirmed bookings across the corridor, then wagons are pulled out of + * the train for maintenance: + * + * EXP1 export F→D 20 CNT edges 3,4 + * IC1 intercity D→B 10 CNT edges 1,2 + * EXP2 export B→A 25 CNT edge 0 + * + * Every leg fits comfortably in the 35-wagon container pool. Then ten NW5 + * wagons are uncoupled, taking the pool from 35 to 25 — and EXP2's 25 on edge 0 + * now sits exactly at the new ceiling while the day's peak edge is still fine. + * Push it one wagon further and the train owes space it no longer has. + * + * THE RULE, AS THE ENGINE ACTUALLY IMPLEMENTS IT + * + * `adjustScheduleConsist` (train-scheduling.service.ts:6026) is explicit that + * staff may shrink below what is already committed — the comment at :6326 says + * "allowed, but reported back as a warning (never silently)". Concretely: + * + * - `max_wagons` is rewritten to the new consist length (:6291) + * - `scheduleWagonUsage` computes `overAllocatedBy` (:6332) + * - if positive, a WARNING STRING is returned naming the shortfall (:6336) + * - the FULL/OPEN window line is recomputed (:6343-6355) + * - and that is all. No booking is expired, bumped, re-batched, re-priced or + * flagged; no allocation row is deleted. + * + * So of the three policies the brief offers — LIFO bump, manual review flag, + * type-downgrade offer — the answer is NONE OF THEM. It is "warn and leave it + * to staff". This spec pins that, because the alternative failure is far worse + * than an unhandled edge case: an engine that silently accepted the shrink + * without warning would let a train depart short and nobody would know until + * the yard. + * + * THE HARD GUARANTEE that IS enforced, and is asserted here: a LOADED wagon + * cannot be trimmed at all. :6115-6122 returns 409 — "cannot be trimmed, only + * switched" — for any wagon carrying cargo beyond the current stop. So the + * shrink can only ever take FREE wagons, which is what keeps this from being a + * data-loss bug. + * + * Sequential steps of one journey — retries off. + */ + +import { + apiPost, + db, + departureAt, + eatDayStr, + ensureExportRoute, + markPaid, + pollAllocations, + resetCorridorDay, + superAdmin, + EXP_DEST, + EXP_ORIGIN, + withBooking, +} from "../import-utils"; +import { + CNT_POOL, + EXPORT_CONSIST, + POOL_TYPE, + acceptIntercityOnExport, + bookIntercityContainers, + createExportSchedule, + edgeLoad, + expectExportCapacity, + expectExportEdgeLoad, + expectNoPoolLeak, + expectPoolAllocation, + seedExportLegContract, + withExportSched, + type Leg, +} from "./flow2-export-utils"; +import { bookAndClear } from "../g1-utils"; + +const DEPARTURE = departureAt(43); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const LEGS = { + EXP1: { from: "F", to: "D", wagons: 20 }, + IC1: { from: "D", to: "B", wagons: 10 }, + EXP2: { from: "B", to: "A", wagons: 25 }, +} as const satisfies Record; + +/** Ten container wagons go for maintenance: pool 35 → 25. */ +const REMOVED = 10; +const NEW_POOL = CNT_POOL - REMOVED; + +describe( + "F2X·TC-20: shrinking the consist under confirmed bookings warns, never silently overbooks", + { retries: 0 }, + () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + cy.task("db:seedFile", "seed-flow2-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-legs.sql"); + cy.task("db:seedFile", "seed-flow2-export-train.sql"); + (["EXP1", "IC1", "EXP2"] as const).forEach((s) => + seedExportLegContract({ suffix: s, reference: stampedRef(s), ...LEGS[s] }), + ); + }); + + it("the premise: every leg fits at 35, and EXP2 sits at the new ceiling of 25", () => { + const profile = edgeLoad(Object.values(LEGS)); + expect(profile, "per-edge demand").to.deep.eq([25, 10, 10, 20, 20]); + expect(Math.max(...profile), "everything fits the pool as built").to.be.at.most( + CNT_POOL, + ); + expect(NEW_POOL, "the pool after maintenance").to.eq(25); + expect( + LEGS.EXP2.wagons, + "EXP2 lands exactly ON the reduced ceiling — one more and the train owes space", + ).to.eq(NEW_POOL); + }); + + it("operations schedules the export train", () => { + ensureExportRoute(); + resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN); + createExportSchedule({ departure: DEPARTURE }); + expectExportCapacity(DEPARTURE, EXPORT_CONSIST); + }); + + it("all three bookings confirm and pay", () => { + bookAndClear({ + suffix: "EXP1", + runStamp: stamp, + isoSeed: 8400, + forty: LEGS.EXP1.wagons, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP1"); + pollAllocations("EXP1", LEGS.EXP1.wagons); + + bookIntercityContainers({ + suffix: "IC1", + runStamp: stamp, + isoSeed: 8500, + forty: LEGS.IC1.wagons, + }); + acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC1"] }); + markPaid("IC1"); + pollAllocations("IC1", LEGS.IC1.wagons); + + bookAndClear({ + suffix: "EXP2", + runStamp: stamp, + isoSeed: 8600, + forty: LEGS.EXP2.wagons, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + markPaid("EXP2"); + pollAllocations("EXP2", LEGS.EXP2.wagons); + + expectExportEdgeLoad(DEPARTURE, [25, 10, 10, 20, 20], CNT_POOL); + }); + + it("GUARANTEE: a loaded wagon cannot be trimmed at all", () => { + // The hard rule, asserted before the legal shrink. Without it, this + // scenario would be a data-loss test rather than a policy test. + loadedContainerWagons().then((loaded) => { + expect(loaded, "some wagons are carrying cargo").to.have.length.greaterThan(0); + withExportSched(DEPARTURE, (s) => + apiPost( + superAdmin, + `/api/train-scheduling/schedules/${s.id}/adjust-consist`, + { removeWagonIds: [loaded[0]] }, + false, + ).then((res) => { + expect(res.status, "trimming a loaded wagon is refused").to.be.within(400, 499); + cy.task( + "log", + `TC-20: loaded-wagon trim refused — ${JSON.stringify(res.body).slice(0, 200)}`, + ); + }), + ); + }); + }); + + it("ten FREE container wagons go for maintenance — the pool drops 35 → 25", () => { + freeContainerWagons(REMOVED).then((ids) => { + expect(ids, `${REMOVED} free container wagons to pull`).to.have.length(REMOVED); + withExportSched(DEPARTURE, (s) => + apiPost( + superAdmin, + `/api/train-scheduling/schedules/${s.id}/adjust-consist`, + { removeWagonIds: ids }, + false, + ).then((res) => { + expect(res.status, "the trim is accepted").to.be.oneOf([200, 201]); + const warnings = (res.body as { warnings?: string[] }).warnings ?? []; + cy.task("log", `TC-20: adjust-consist warnings — ${JSON.stringify(warnings)}`); + }), + ); + }); + }); + + it("POLICY: max_wagons follows the consist — the shrink is recorded, not ignored", () => { + // The first half of "never silently". Whatever happens to the bookings, + // the schedule must stop advertising capacity it no longer has. + withExportSched(DEPARTURE, (s) => + db<{ max_wagons: string }>( + `SELECT max_wagons FROM freight.train_schedules WHERE id = $1`, + [s.id], + ).then(({ rows }) => + expect( + Number(rows[0].max_wagons), + "the schedule's capacity was rewritten to the new consist length", + ).to.eq(EXPORT_CONSIST - REMOVED), + ), + ); + }); + + it("POLICY: no confirmed booking was bumped, expired or flagged", () => { + // The pinned decision. The engine warns and leaves it to staff — NOT LIFO + // bump, NOT an auto review flag, NOT a downgrade offer. If any of those + // is ever implemented, this test fails and the policy change is + // acknowledged deliberately rather than discovered in production. + (["EXP1", "IC1", "EXP2"] as const).forEach((suffix) => { + expectPoolAllocation(suffix, "CNT", LEGS[suffix].wagons); + withBooking(suffix, (b) => + expect(b.status, `${suffix} was not bumped by the consist change`).to.not.be.oneOf([ + "EXPIRED", + "CANCELLED", + "REJECTED", + ]), + ); + }); + expectExportEdgeLoad(DEPARTURE, [25, 10, 10, 20, 20], NEW_POOL); + }); + + it("NO SILENT OVERBOOK: the surviving allocations fit the reduced pool", () => { + // The invariant that must hold whatever the policy. Because the trim + // could only take FREE wagons, the remaining allocations are still + // inside the new pool — which is the structural reason "warn and leave + // it" is a survivable policy here rather than a bug. + withExportSched(DEPARTURE, (s) => + db<{ n: string }>( + `SELECT count(DISTINCT wba.train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations wba + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + 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 + WHERE wt.code = $2 + AND wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`, + [s.id, POOL_TYPE.CNT], + ).then(({ rows }) => + expect( + Number(rows[0].n), + "container allocations still fit the reduced pool", + ).to.be.at.most(NEW_POOL), + ), + ); + expectNoPoolLeak(DEPARTURE); + }); + }, +); + +/** Physical container wagons on this train that are carrying cargo. */ +function loadedContainerWagons() { + return withExportSchedChain().then((scheduleId) => + db<{ id: string }>( + `SELECT DISTINCT tsw.physical_wagon_id AS id + FROM freight.wagon_booking_allocations wba + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id + WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL + AND tsw.physical_wagon_id IS NOT NULL`, + [scheduleId], + ).then(({ rows }) => rows.map((r) => r.id)), + ); +} + +/** + * `n` container wagons coupled to the train that NO booking holds. + * + * Deliberately free wagons only: a loaded wagon is refused by the endpoint + * (409), so trimming those would test the guard rather than the policy — which + * is what the GUARANTEE test above does, separately and on purpose. + */ +function freeContainerWagons(n: number) { + return withExportSchedChain().then((scheduleId) => + db<{ id: string }>( + `SELECT w.id + FROM freight.wagons w + JOIN freight.trains t ON t.id = w.train_id AND t.code = 'TRN-F2-EXP' + JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id AND wt.code = $2 + WHERE w.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM freight.wagon_booking_allocations wba + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id + WHERE tsw.physical_wagon_id = w.id + AND wba.deleted_at IS NULL AND tsb.deleted_at IS NULL + ) + ORDER BY w.wagon_number + LIMIT $3`, + [scheduleId, POOL_TYPE.CNT, n], + ).then(({ rows }) => rows.map((r) => r.id)), + ); +} + +/** The export schedule's id, as a chainable the helpers above can build on. */ +function withExportSchedChain() { + return db<{ id: string }>( + `SELECT ts.id + FROM freight.train_schedules ts + JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1 + JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2 + WHERE ts.deleted_at IS NULL + AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 3600 + ORDER BY ts.created_at DESC LIMIT 1`, + [EXP_ORIGIN, EXP_DEST, DEPARTURE.toISOString()], + ).then(({ rows }) => { + expect(rows, "flow-two export schedule").to.have.length(1); + return rows[0].id; + }); +} diff --git a/e2e/freight/cypress/e2e/flows/g1-utils.ts b/e2e/freight/cypress/e2e/flows/g1-utils.ts index 8bbde1013..c2631431d 100644 --- a/e2e/freight/cypress/e2e/flows/g1-utils.ts +++ b/e2e/freight/cypress/e2e/flows/g1-utils.ts @@ -413,6 +413,12 @@ export function bookContainersVisually(opts: { shipmentDay: Date; /** Distinct ISO prefixes keep container numbers unique across scenarios. */ isoPrefix?: string; + /** + * Per-run stamp, same one the spec passes to the API path. Without it every + * run typed the identical SEXU1000000… block and the second run against a + * warm DB was rejected — the container number is already booked. + */ + runStamp?: string; vgmTons?: number; }) { const twenty = opts.twenty ?? 0; @@ -447,6 +453,9 @@ export function bookContainersVisually(opts: { // `input[placeholder*="MSCU"]` therefore counts the other card's row too — // "Found 7, expected 6" — and the numbers land in the wrong card. const prefix = opts.isoPrefix ?? "MSCU"; + // 7 digits: 5 of run stamp + 2 of unit index. Keeps every run's block + // distinct while staying inside the ISO field width (max 99 units/booking). + const runBlock = Number((opts.runStamp ?? String(Date.now())).slice(-5)); let unit = 0; const fillUnits = (size: "20ft" | "40ft", count: number) => { if (!count) return; @@ -458,7 +467,7 @@ export function bookContainersVisually(opts: { count, ); for (let i = 0; i < count; i += 1) { - const iso = `${prefix}${String(1_000_000 + unit + i).slice(0, 7)}`; + const iso = `${prefix}${String(runBlock).padStart(5, "0")}${String(unit + i).padStart(2, "0")}`; cy.get('input[placeholder*="MSCU"]') .eq(i) .clear({ force: true }) diff --git a/e2e/freight/cypress/e2e/flows/g1_s1_expiry_promotes_waitlist.cy.ts b/e2e/freight/cypress/e2e/flows/g1_s1_expiry_promotes_waitlist.cy.ts index a74955a91..3586d9175 100644 --- a/e2e/freight/cypress/e2e/flows/g1_s1_expiry_promotes_waitlist.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g1_s1_expiry_promotes_waitlist.cy.ts @@ -196,6 +196,7 @@ describe("G1·S1: expiry frees exactly the waiting list's space", { retries: 0 } twenty: SHAPES.D.twenty, shipmentDay: DEPARTURE, isoPrefix: "DDDU", + runStamp: stamp, }); }); clearAndAccept({ suffix: "D", scheduledDate: BOOKING_DAY }); diff --git a/e2e/freight/cypress/e2e/flows/g1_s2_exact_fill.cy.ts b/e2e/freight/cypress/e2e/flows/g1_s2_exact_fill.cy.ts index bd3acfc8b..2bf102be3 100644 --- a/e2e/freight/cypress/e2e/flows/g1_s2_exact_fill.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g1_s2_exact_fill.cy.ts @@ -123,6 +123,7 @@ describe("G1·S2: four bookings pay and fill the train exactly", { retries: 0 }, twenty: SHAPES.D.twenty, shipmentDay: DEPARTURE, isoPrefix: "SEXU", + runStamp: stamp, }); }); clearAndAccept({ suffix: "D", scheduledDate: BOOKING_DAY }); diff --git a/e2e/freight/cypress/e2e/flows/g1_s3_underfill_day_stays_open.cy.ts b/e2e/freight/cypress/e2e/flows/g1_s3_underfill_day_stays_open.cy.ts index d8b61d8f5..0f925eb14 100644 --- a/e2e/freight/cypress/e2e/flows/g1_s3_underfill_day_stays_open.cy.ts +++ b/e2e/freight/cypress/e2e/flows/g1_s3_underfill_day_stays_open.cy.ts @@ -113,6 +113,7 @@ describe("G1·S3: an under-filled train keeps its day open", { retries: 0 }, () twenty: SHAPES.C.twenty, shipmentDay: DEPARTURE, isoPrefix: "CSQU", + runStamp: stamp, }); }); clearAndAccept({ suffix: "C", scheduledDate: BOOKING_DAY }); diff --git a/e2e/freight/cypress/e2e/flows/import-utils.ts b/e2e/freight/cypress/e2e/flows/import-utils.ts index 4905b1d89..5337d9f9c 100644 --- a/e2e/freight/cypress/e2e/flows/import-utils.ts +++ b/e2e/freight/cypress/e2e/flows/import-utils.ts @@ -457,8 +457,17 @@ export function bookBulk(opts: { suffix: string; tons: number; scheduledDate?: string; // omit for DOMESTIC (intercity) - /** Cargo code — WHEAT rides CW4, GRAINS rides PW2. Defaults to wheat. */ - cargoCode?: "E2E_IMP_WHEAT" | "E2E_IMP_GRAINS"; + /** + * `freight.cargo_types.code` to book against. WHEAT rides CW4, GRAINS rides + * PW2 (seed-import-corridor.sql 5b2). Defaults to wheat. + * + * Deliberately `string`, not a union of the two corridor codes: specs seed + * their own cargo types (flow_two's E2E_EXP_FERT / E2E_EXP_CEMENT, from + * seed-flow2-export-train.sql section 6), and the lookup below resolves any + * code that exists. A booking naming a code with no row gets a null + * cargoTypeId and is rejected at creation, which is the right failure. + */ + cargoCode?: string; expectFailure?: string | RegExp; }) { db<{ id: string; customs_clearing_enabled: boolean; cargo_type_id: string }>( @@ -864,6 +873,18 @@ export function resetCorridorDay(departure: Date, destCode = DEST, originCode = ), drop_links AS ( UPDATE freight.train_schedule_bookings SET deleted_at = now() WHERE train_schedule_id IN (SELECT id FROM stale) AND deleted_at IS NULL + ), free_wagons AS ( + -- Release the wagons too. Unpinning a booking leaves its + -- wagon_booking_allocations rows alive, and dayImportAvailability + -- subtracts every allocation still attached to the day — so each prior + -- run permanently ate ~28 of the 53 slots, and freeWagons read 9 where + -- the scenario expects 25. + UPDATE freight.wagon_booking_allocations wba SET deleted_at = now() + FROM freight.bookings b + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL + AND ct.reference LIKE 'CTR-IMP-%' + AND b.train_schedule_id IN (SELECT id FROM stale) ) UPDATE freight.train_schedules SET deleted_at = now() WHERE id IN (SELECT id FROM stale)`, @@ -881,6 +902,21 @@ export function resetCorridorDay(departure: Date, destCode = DEST, originCode = AND b.status = 'FULLY_EXECUTED' AND b.train_schedule_id IS NULL`, [], ); + // Unpinned leftovers keep their wagons too, and a PAID one is not caught by + // the expire above. dayImportAvailability counts allocations by DAY, not by + // schedule link, so anything still holding wagons on this departure day is + // subtracted from the free count no matter which run created it. + db( + `UPDATE freight.wagon_booking_allocations wba + SET deleted_at = now() + FROM freight.bookings b + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL + AND ct.reference LIKE 'CTR-IMP-%' + AND b.train_schedule_id IS NULL + AND b.scheduled_date = $1::date`, + [eatDayStr(departure)], + ); } /** Create the reversed 6-stop corridor (ET → DJ = EXPORT) if missing. */ diff --git a/e2e/freight/cypress/fixtures/seed-flow2-export-legs.sql b/e2e/freight/cypress/fixtures/seed-flow2-export-legs.sql new file mode 100644 index 000000000..570cf647e --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-flow2-export-legs.sql @@ -0,0 +1,74 @@ +-- FLOW-TWO fixture — rates for the REVERSED (export-direction) corridor. +-- +-- Run AFTER seed-import-corridor.sql and seed-flow2-legs.sql. Idempotent. +-- +-- seed-flow2-legs.sql rates every FORWARD pair (i < j, DJIB_PORT-first order). +-- The export scenarios (TC-01 … TC-20 of the second flow-two batch) run the +-- corridor the other way — F→A — and book MID-corridor export legs like +-- MOJO→DJIB_PORT and DIRE_DAWA→NAGAD. Pricing hard-blocks a booking on a pair +-- with no LIVE rate, so every REVERSED ordered pair gets one here. +-- +-- Corridor stop order (CORRIDOR in import-utils.ts): +-- A=DJIB_PORT B=NAGAD C=DIRE_DAWA D=E2E_AWASH E=MOJO F=KALITY +-- Export runs F→A, so an export leg is (higher seq) → (lower seq). +-- +-- DIRECTION IS DERIVED FROM THE YARDS' COUNTRIES, never from our intent +-- (resolveTradeDirectionForBooking). So: +-- x → DJIB_PORT crosses the border → EXPORT → CONTAINER_EXPORT / BULK_EXPORT +-- wholly-Ethiopian reversed pair → DOMESTIC → INTERCITY_CONTAINER / _BULK +-- Getting the rate_type wrong does not fail loudly — the booking 404s at +-- pricing, far from the cause. +-- +-- Flat, distance-free values: these specs assert capacity, never money. + +DROP TABLE IF EXISTS flow2_rev_pairs; +CREATE TEMP TABLE flow2_rev_pairs AS +WITH stops(code, seq) AS ( + VALUES ('DJIB_PORT', 1), ('NAGAD', 2), ('DIRE_DAWA', 3), + ('E2E_AWASH', 4), ('MOJO', 5), ('KALITY', 6) +) +-- a.seq > b.seq — the reversed direction, i.e. running toward the port. +SELECT a.code AS from_code, b.code AS to_code, (a.seq - b.seq) AS legs +FROM stops a JOIN stops b ON a.seq > b.seq; + +-- 1. Freight rates on every reversed pair. +INSERT INTO freight.rates + (id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status, + origin_yard_id, destination_yard_id, proposed_by_staff_id) +SELECT gen_random_uuid(), v.rate_type, v.applies_to, 'ALWAYS', 'USD', + v.value, v.unit, 'LIVE', a.id, b.id, u.id +FROM flow2_rev_pairs p +CROSS JOIN LATERAL (VALUES + (CASE WHEN p.to_code = 'DJIB_PORT' THEN 'CONTAINER_EXPORT' + ELSE 'INTERCITY_CONTAINER' END, + CASE WHEN p.to_code = 'DJIB_PORT' THEN 'CONTAINER' ELSE 'INTERCITY' END, + 100 * p.legs, 'PER_CONTAINER'), + (CASE WHEN p.to_code = 'DJIB_PORT' THEN 'BULK_EXPORT' + ELSE 'INTERCITY_BULK' END, + CASE WHEN p.to_code = 'DJIB_PORT' THEN 'BULK' ELSE 'INTERCITY' END, + 5 * p.legs, 'PER_TON') + ) AS v(rate_type, applies_to, value, unit) +JOIN freight.yards a ON a.code = p.from_code +JOIN freight.yards b ON b.code = p.to_code +JOIN iam.users u ON u.email = 'operation@edr.local' +WHERE NOT EXISTS ( + SELECT 1 FROM freight.rates r + WHERE r.rate_type = v.rate_type + AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id + AND r.deleted_at IS NULL +); + +-- 2. Yard distances on the reversed pairs. yard_distances is NOT symmetric — +-- pricing and the available-days lookup both walk it directionally, and a +-- missing row reads as "not on the network". +INSERT INTO freight.yard_distances (id, from_yard_id, to_yard_id, distance_km) +SELECT gen_random_uuid(), a.id, b.id, 100 * p.legs +FROM flow2_rev_pairs p +JOIN freight.yards a ON a.code = p.from_code +JOIN freight.yards b ON b.code = p.to_code +WHERE NOT EXISTS ( + SELECT 1 FROM freight.yard_distances d + WHERE d.from_yard_id = a.id AND d.to_yard_id = b.id AND d.deleted_at IS NULL +); + +DROP TABLE flow2_rev_pairs; diff --git a/e2e/freight/cypress/fixtures/seed-flow2-export-train-2.sql b/e2e/freight/cypress/fixtures/seed-flow2-export-train-2.sql new file mode 100644 index 000000000..4b51fc8e5 --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-flow2-export-train-2.sql @@ -0,0 +1,112 @@ +-- FLOW-TWO fixture — a SECOND export train, identical in shape to TRN-F2-EXP. +-- +-- Run AFTER seed-import-corridor.sql and seed-flow2-export-train.sql. +-- Idempotent. +-- +-- The multi-train scenarios (TC-13, TC-14, TC-16) need two export trains on one +-- day with the SAME pool structure, so that "which train did this booking land +-- on" is the only variable. A second train with different pools would confound +-- the overflow question with a capacity question. +-- +-- 35 × NW5 (CNT) + 20 × CW4 (BLK) + 5 × NW6 (FLT) = 60, at KALITY +-- +-- Its wagon numbers (WGN-F2Y-*) are distinct from TRN-F2-EXP's (WGN-F2X-*), so +-- the two consists never compete for the same physical stock — a shared wagon +-- pinned by the other train's schedule would make one consist silently short +-- and the overflow arithmetic wrong. +-- +-- Length and pull are slack for the same reason as the first train (862.3 m of +-- wagons behind 1000 m locos, ~5700 T behind 12000 T of pull): this suite tests +-- SLOTS and TYPE, and a length rejection would masquerade as a pool rejection. + +-- 1. Its own locomotive pair at KALITY. +INSERT INTO freight.locomotives + (id, code, max_pull_weight_tons, max_train_length_meters, + overage_tolerance_tons, current_yard_id) +SELECT gen_random_uuid(), v.code, 12000, 1000, 0, y.id +FROM (VALUES ('LOCO-F2Y-A'), ('LOCO-F2Y-B')) AS v(code) +JOIN freight.yards y ON y.code = 'KALITY' +WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code); + +UPDATE freight.locomotives +SET max_pull_weight_tons = 12000, max_train_length_meters = 1000, + overage_tolerance_tons = 0, + current_yard_id = (SELECT id FROM freight.yards WHERE code = 'KALITY') +WHERE code IN ('LOCO-F2Y-A', 'LOCO-F2Y-B') + AND (max_pull_weight_tons IS DISTINCT FROM 12000 + OR max_train_length_meters IS DISTINCT FROM 1000 + OR overage_tolerance_tons IS DISTINCT FROM 0); + +-- 2. The train. +INSERT INTO freight.trains + (id, code, train_name, capacity_tons, current_yard_id, + import_train_number, export_train_number) +SELECT gen_random_uuid(), 'TRN-F2-EXP2', 'E2E Flow-2 Export Three-Pool II', 4200, y.id, + '9314', '9313' +FROM freight.yards y +WHERE y.code = 'KALITY' + AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = 'TRN-F2-EXP2'); + +INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no) +SELECT gen_random_uuid(), t.id, l.id, v.seq +FROM (VALUES ('LOCO-F2Y-A', 0), ('LOCO-F2Y-B', 1)) AS v(loco_code, seq) +JOIN freight.trains t ON t.code = 'TRN-F2-EXP2' +JOIN freight.locomotives l ON l.code = v.loco_code +WHERE NOT EXISTS ( + SELECT 1 FROM freight.train_locomotives tl + WHERE tl.train_id = t.id AND tl.locomotive_id = l.id +); + +-- 3. Its own three pools of rolling stock at KALITY. +INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id) +SELECT gen_random_uuid(), 'WGN-F2Y-C' || lpad(g::text, 2, '0'), wt.id, y.id +FROM generate_series(1, 35) AS g +JOIN freight.wagon_types wt ON wt.code = 'NW5' +JOIN freight.yards y ON y.code = 'KALITY' +WHERE NOT EXISTS ( + SELECT 1 FROM freight.wagons w + WHERE w.wagon_number = 'WGN-F2Y-C' || lpad(g::text, 2, '0') +); + +INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id) +SELECT gen_random_uuid(), 'WGN-F2Y-B' || lpad(g::text, 2, '0'), wt.id, y.id +FROM generate_series(1, 20) AS g +JOIN freight.wagon_types wt ON wt.code = 'CW4' +JOIN freight.yards y ON y.code = 'KALITY' +WHERE NOT EXISTS ( + SELECT 1 FROM freight.wagons w + WHERE w.wagon_number = 'WGN-F2Y-B' || lpad(g::text, 2, '0') +); + +INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id) +SELECT gen_random_uuid(), 'WGN-F2Y-F' || lpad(g::text, 2, '0'), wt.id, y.id +FROM generate_series(1, 5) AS g +JOIN freight.wagon_types wt ON wt.code = 'NW6' +JOIN freight.yards y ON y.code = 'KALITY' +WHERE NOT EXISTS ( + SELECT 1 FROM freight.wagons w + WHERE w.wagon_number = 'WGN-F2Y-F' || lpad(g::text, 2, '0') +); + +-- 4. Couple all 60, same order as the first train. +UPDATE freight.wagons w +SET train_id = t.id, + sequence_number = g.seq, + status = 'ASSIGNED', + current_yard_id = t.current_yard_id +FROM freight.trains t, + LATERAL ( + SELECT ('WGN-F2Y-C' || lpad(s::text, 2, '0')) AS num, s AS seq + FROM generate_series(1, 35) AS s + UNION ALL + SELECT ('WGN-F2Y-B' || lpad(s::text, 2, '0')), 35 + s + FROM generate_series(1, 20) AS s + UNION ALL + SELECT ('WGN-F2Y-F' || lpad(s::text, 2, '0')), 55 + s + FROM generate_series(1, 5) AS s + ) g +WHERE t.code = 'TRN-F2-EXP2' + AND w.wagon_number = g.num + AND (w.train_id IS DISTINCT FROM t.id + OR w.sequence_number IS DISTINCT FROM g.seq + OR w.status IS DISTINCT FROM 'ASSIGNED'); diff --git a/e2e/freight/cypress/fixtures/seed-flow2-export-train.sql b/e2e/freight/cypress/fixtures/seed-flow2-export-train.sql new file mode 100644 index 000000000..4b6db6069 --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-flow2-export-train.sql @@ -0,0 +1,182 @@ +-- FLOW-TWO fixture — the EXPORT-direction, THREE-POOL consist. +-- +-- Run AFTER seed-import-corridor.sql. Idempotent. +-- +-- WHY THIS TRAIN EXISTS +-- +-- The second flow-two batch is written against a "60 wagons = 35 CNT + 20 BLK +-- + 5 FLT" baseline, running F→A (KALITY → DJIB_PORT). Nothing in the existing +-- fixtures gives that: +-- - TRN-G1-1 is 53 × NW5, one pool, parked at DJIB_PORT (import end). +-- - TRN-F2-MIX is 30 NW5 + 20 PW2 — two pools, also at DJIB_PORT. +-- - TRN-LEDGER-PW2 is 37 PW2 at KALITY — right end, wrong pools. +-- +-- TRN-F2-EXP is 60 wagons at KALITY split across THREE cargo-incompatible +-- types, so "container overflow must not eat bulk wagons" (TC-02) and +-- "bulk-to-container substitution" (TC-03) are expressible at all. On a +-- one-pool consist the abstract slot count and the physical stock count are +-- the same number, and a broken pool separation is invisible. +-- +-- 35 × NW5 — CNT. The only type 20FT/40FT containers ride +-- (container_type_wagon_types, seed-import-corridor 2b). +-- 20 × CW4 — BLK. Carries E2E_IMP_WHEAT (cargo_type_wagon_types, 5b2). +-- 5 × NW6 — FLT. Allow-listed to NOTHING here, deliberately: it is the +-- idle-but-unusable pool TC-02 asserts a container booking may +-- NOT reach for. See section 5. +-- +-- LENGTH AND PULL MUST NOT BIND — this suite tests SLOTS and TYPE, and a +-- length rejection would masquerade as a pool rejection: +-- 35 × 14.000 + 20 × 13.976 + 5 × 18.560 = 490.0 + 279.5 + 92.8 = 862.3 m +-- so the locos below run 1000 m. Gross weight at full load is roughly +-- 60 × (70 + ~25) ≈ 5700 T, so the pull is set to 12000 T. Both axes stay +-- slack; only wagon slots and wagon TYPE can ever bind. +-- +-- The wagon numbers (WGN-F2X-*) are distinct from every other fixture's, so +-- this consist never competes for stock with the import specs. + +-- 1. Locomotive pair at KALITY (the export origin), 1000 m so length is slack. +INSERT INTO freight.locomotives + (id, code, max_pull_weight_tons, max_train_length_meters, + overage_tolerance_tons, current_yard_id) +SELECT gen_random_uuid(), v.code, 12000, 1000, 0, y.id +FROM (VALUES ('LOCO-F2X-A'), ('LOCO-F2X-B')) AS v(code) +JOIN freight.yards y ON y.code = 'KALITY' +WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code); + +-- Re-assert unconditionally: a prior run (or another fixture) could have left +-- these at different limits, and a 760 m loco silently re-caps the consist. +UPDATE freight.locomotives +SET max_pull_weight_tons = 12000, max_train_length_meters = 1000, + overage_tolerance_tons = 0, + current_yard_id = (SELECT id FROM freight.yards WHERE code = 'KALITY') +WHERE code IN ('LOCO-F2X-A', 'LOCO-F2X-B') + AND (max_pull_weight_tons IS DISTINCT FROM 12000 + OR max_train_length_meters IS DISTINCT FROM 1000 + OR overage_tolerance_tons IS DISTINCT FROM 0); + +-- 2. The train, parked at KALITY — a built-train schedule requires the consist +-- to already stand at the schedule's origin. +INSERT INTO freight.trains + (id, code, train_name, capacity_tons, current_yard_id, + import_train_number, export_train_number) +SELECT gen_random_uuid(), 'TRN-F2-EXP', 'E2E Flow-2 Export Three-Pool', 4200, y.id, + '9312', '9311' +FROM freight.yards y +WHERE y.code = 'KALITY' + AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = 'TRN-F2-EXP'); + +INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no) +SELECT gen_random_uuid(), t.id, l.id, v.seq +FROM (VALUES ('LOCO-F2X-A', 0), ('LOCO-F2X-B', 1)) AS v(loco_code, seq) +JOIN freight.trains t ON t.code = 'TRN-F2-EXP' +JOIN freight.locomotives l ON l.code = v.loco_code +WHERE NOT EXISTS ( + SELECT 1 FROM freight.train_locomotives tl + WHERE tl.train_id = t.id AND tl.locomotive_id = l.id +); + +-- 3. The three pools of rolling stock, minted fresh at KALITY. +-- +-- Minted rather than borrowed: the boot fleet at KALITY is already spoken +-- for by the export-bulk and ledger specs, and a shared wagon that another +-- spec's schedule has pinned makes this consist silently short. + +-- 3a. 35 × NW5 — the CONTAINER pool. +INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id) +SELECT gen_random_uuid(), 'WGN-F2X-C' || lpad(g::text, 2, '0'), wt.id, y.id +FROM generate_series(1, 35) AS g +JOIN freight.wagon_types wt ON wt.code = 'NW5' +JOIN freight.yards y ON y.code = 'KALITY' +WHERE NOT EXISTS ( + SELECT 1 FROM freight.wagons w + WHERE w.wagon_number = 'WGN-F2X-C' || lpad(g::text, 2, '0') +); + +-- 3b. 20 × CW4 — the BULK pool (E2E_IMP_WHEAT rides CW4). +INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id) +SELECT gen_random_uuid(), 'WGN-F2X-B' || lpad(g::text, 2, '0'), wt.id, y.id +FROM generate_series(1, 20) AS g +JOIN freight.wagon_types wt ON wt.code = 'CW4' +JOIN freight.yards y ON y.code = 'KALITY' +WHERE NOT EXISTS ( + SELECT 1 FROM freight.wagons w + WHERE w.wagon_number = 'WGN-F2X-B' || lpad(g::text, 2, '0') +); + +-- 3c. 5 × NW6 — the FLATBED pool. Allow-listed to no cargo in this fixture. +INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id) +SELECT gen_random_uuid(), 'WGN-F2X-F' || lpad(g::text, 2, '0'), wt.id, y.id +FROM generate_series(1, 5) AS g +JOIN freight.wagon_types wt ON wt.code = 'NW6' +JOIN freight.yards y ON y.code = 'KALITY' +WHERE NOT EXISTS ( + SELECT 1 FROM freight.wagons w + WHERE w.wagon_number = 'WGN-F2X-F' || lpad(g::text, 2, '0') +); + +-- 4. Couple all 60, CNT then BLK then FLT. Unconditionally re-asserted: a +-- prior run could have left one detached, and a 59-wagon consist shifts +-- every scenario's arithmetic by a slot. +UPDATE freight.wagons w +SET train_id = t.id, + sequence_number = g.seq, + status = 'ASSIGNED', + current_yard_id = t.current_yard_id +FROM freight.trains t, + LATERAL ( + SELECT ('WGN-F2X-C' || lpad(s::text, 2, '0')) AS num, s AS seq + FROM generate_series(1, 35) AS s + UNION ALL + SELECT ('WGN-F2X-B' || lpad(s::text, 2, '0')), 35 + s + FROM generate_series(1, 20) AS s + UNION ALL + SELECT ('WGN-F2X-F' || lpad(s::text, 2, '0')), 55 + s + FROM generate_series(1, 5) AS s + ) g +WHERE t.code = 'TRN-F2-EXP' + AND w.wagon_number = g.num + AND (w.train_id IS DISTINCT FROM t.id + OR w.sequence_number IS DISTINCT FROM g.seq + OR w.status IS DISTINCT FROM 'ASSIGNED'); + +-- 5. The FLATBED pool's allow-list, asserted EMPTY. +-- +-- TC-02 turns on NW6 being idle AND unreachable: a container booking that +-- overflows the 35-wagon NW5 pool must be refused even though 5 NW6 slots +-- stand free. If some other fixture ever allow-lists a container type or a +-- cargo type onto NW6, that scenario silently starts passing for the wrong +-- reason — so the link is removed here rather than merely never added. +DELETE FROM freight.container_type_wagon_types x +USING freight.wagon_types wt +WHERE x.wagon_type_id = wt.id AND wt.code = 'NW6'; + +DELETE FROM freight.cargo_type_wagon_types x +USING freight.cargo_types ct, freight.wagon_types wt +WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id + AND wt.code = 'NW6' + AND ct.code IN ('E2E_IMP_WHEAT', 'E2E_IMP_GRAINS', 'E2E_IMP_AUTO', + 'E2E_IMP_MACHINE', 'E2E_EXP_CEMENT', 'E2E_EXP_FERT'); + +-- 6. Segregation cargo types for TC-10 (bulk commodity segregation). +-- +-- Fertilizer and cement are separate cargo TYPES sharing the CW4 pool with +-- wheat. Whether the engine keeps them out of the same physical wagon is +-- exactly what TC-10 asks — planWagonsWithStock tops off an existing wagon +-- only when the cargo type matches, so distinct types is the mechanism. +INSERT INTO freight.cargo_types (id, code, cargo_type_name, is_active) +SELECT gen_random_uuid(), 'E2E_EXP_FERT', 'E2E Export Fertilizer', true +WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_EXP_FERT'); + +INSERT INTO freight.cargo_types (id, code, cargo_type_name, is_active) +SELECT gen_random_uuid(), 'E2E_EXP_CEMENT', 'E2E Export Cement', true +WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_EXP_CEMENT'); + +INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id) +SELECT ct.id, wt.id +FROM freight.cargo_types ct +JOIN freight.wagon_types wt ON wt.code = 'CW4' +WHERE ct.code IN ('E2E_EXP_FERT', 'E2E_EXP_CEMENT') + AND NOT EXISTS ( + SELECT 1 FROM freight.cargo_type_wagon_types x + WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id + ); diff --git a/e2e/freight/cypress/fixtures/seed-flow2-legs.sql b/e2e/freight/cypress/fixtures/seed-flow2-legs.sql new file mode 100644 index 000000000..4b141ddd7 --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-flow2-legs.sql @@ -0,0 +1,95 @@ +-- FLOW-TWO fixture — rates for every sub-leg the segment-reuse specs book. +-- +-- seed-import-corridor.sql only rates the four end-to-end pairs its own specs +-- use (DJIB_PORT/NAGAD × MOJO/KALITY). Flow-two books MID-corridor legs +-- (NAGAD→E2E_AWASH, DIRE_DAWA→MOJO, …) and pricing hard-blocks a booking on a +-- pair with no LIVE rate — so every ordered pair of the 6-stop corridor gets a +-- rate here. Distance-free flat values: these specs assert capacity, never money. +-- +-- Corridor stop order (see CORRIDOR in import-utils.ts): +-- A=DJIB_PORT B=NAGAD C=DIRE_DAWA D=E2E_AWASH E=MOJO F=KALITY +-- +-- Idempotent: every insert is guarded by NOT EXISTS on the same key the app +-- resolves rates by, so re-running before every spec is free. + +-- Ordered corridor pairs (i < j), as (from, to). A plain (non-ON COMMIT DROP) +-- temp table: db:seedFile sends the whole file as ONE implicit transaction, and +-- it is dropped explicitly at the end so a re-run on the same pooled session +-- doesn't hit "relation already exists". +DROP TABLE IF EXISTS flow2_pairs; +CREATE TEMP TABLE flow2_pairs AS +WITH stops(code, seq) AS ( + VALUES ('DJIB_PORT', 1), ('NAGAD', 2), ('DIRE_DAWA', 3), + ('E2E_AWASH', 4), ('MOJO', 5), ('KALITY', 6) +) +SELECT a.code AS from_code, b.code AS to_code, + (b.seq - a.seq) AS legs +FROM stops a JOIN stops b ON b.seq > a.seq; + +-- 1. Freight rates. IMPORT direction (a DJ origin) prices as CONTAINER_IMPORT / +-- BULK_IMPORT; a wholly-Ethiopian pair is DOMESTIC and prices as INTERCITY_*. +-- resolveTradeDirectionForBooking derives the direction from the yards' +-- countries, so the rate_type must follow the same rule or pricing 404s. +INSERT INTO freight.rates + (id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status, + origin_yard_id, destination_yard_id, proposed_by_staff_id) +SELECT gen_random_uuid(), v.rate_type, v.applies_to, 'ALWAYS', 'USD', + v.value, v.unit, 'LIVE', a.id, b.id, u.id +FROM flow2_pairs p +CROSS JOIN LATERAL (VALUES + (CASE WHEN p.from_code = 'DJIB_PORT' THEN 'CONTAINER_IMPORT' + ELSE 'INTERCITY_CONTAINER' END, + CASE WHEN p.from_code = 'DJIB_PORT' THEN 'CONTAINER' ELSE 'INTERCITY' END, + 100 * p.legs, 'PER_CONTAINER'), + (CASE WHEN p.from_code = 'DJIB_PORT' THEN 'BULK_IMPORT' + ELSE 'INTERCITY_BULK' END, + CASE WHEN p.from_code = 'DJIB_PORT' THEN 'BULK' ELSE 'INTERCITY' END, + 5 * p.legs, 'PER_TON') + ) AS v(rate_type, applies_to, value, unit) +JOIN freight.yards a ON a.code = p.from_code +JOIN freight.yards b ON b.code = p.to_code +JOIN iam.users u ON u.email = 'operation@edr.local' +WHERE NOT EXISTS ( + SELECT 1 FROM freight.rates r + WHERE r.rate_type = v.rate_type + AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id + AND r.deleted_at IS NULL +); + +-- 2. Customs-clearance fees on the same pairs, per container type. Only the +-- import pairs need them (a DOMESTIC leg is never customs-cleared), but a +-- customs booking with no fee row hard-blocks at pricing — see the note in +-- seed-import-corridor.sql section 7b. +INSERT INTO freight.rates + (id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status, + trade_direction, container_type_id, origin_yard_id, destination_yard_id, + proposed_by_staff_id) +SELECT gen_random_uuid(), 'CUSTOMS_CLEARANCE', 'OTHER', 'CUSTOMS_CLEARANCE', + 'USD', 50, 'PER_CONTAINER', 'LIVE', 'IMPORT', ct.id, a.id, b.id, u.id +FROM flow2_pairs p +JOIN freight.yards a ON a.code = p.from_code +JOIN freight.yards b ON b.code = p.to_code +JOIN freight.container_types ct ON ct.size_ft IN (20, 40) AND ct.is_active +JOIN iam.users u ON u.email = 'operation@edr.local' +WHERE p.from_code = 'DJIB_PORT' + AND NOT EXISTS ( + SELECT 1 FROM freight.rates r + WHERE r.rate_type = 'CUSTOMS_CLEARANCE' + AND r.container_type_id = ct.id + AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id + AND r.deleted_at IS NULL + ); + +-- 3. Yard distances on every pair. Pricing and the available-days lookup both +-- walk yard_distances; a missing pair reads as "not on the network". +INSERT INTO freight.yard_distances (id, from_yard_id, to_yard_id, distance_km) +SELECT gen_random_uuid(), a.id, b.id, 100 * p.legs +FROM flow2_pairs p +JOIN freight.yards a ON a.code = p.from_code +JOIN freight.yards b ON b.code = p.to_code +WHERE NOT EXISTS ( + SELECT 1 FROM freight.yard_distances d + WHERE d.from_yard_id = a.id AND d.to_yard_id = b.id AND d.deleted_at IS NULL +); + +DROP TABLE flow2_pairs; diff --git a/e2e/freight/cypress/fixtures/seed-flow2-mixed-train.sql b/e2e/freight/cypress/fixtures/seed-flow2-mixed-train.sql new file mode 100644 index 000000000..9d6ea36d2 --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-flow2-mixed-train.sql @@ -0,0 +1,108 @@ +-- FLOW-TWO fixture — a MIXED-TYPE consist, for the wagon-pool scenario (TC-08). +-- +-- Run AFTER seed-import-corridor.sql and seed-g1-train.sql. Idempotent. +-- +-- WHY A SEPARATE TRAIN +-- +-- TRN-G1-1 is 53 × NW5 — one wagon type. On that consist "container booking +-- can't consume flatbed wagons" is untestable: there is only one pool, so the +-- abstract slot count and the physical stock count are the same number and a +-- broken pool separation is invisible. +-- +-- TRN-F2-MIX splits the consist across two types the cargo allow-lists do NOT +-- share: +-- 30 × NW5 — the type 20FT/40FT containers ride (seed-import-corridor 2b) +-- 20 × PW2 — the type E2E_IMP_GRAINS bulk rides (seed-import-corridor 4) +-- +-- So 50 abstract slots, but a container booking may only ever draw on 30 of +-- them and a grains booking on 20. A 40-wagon container booking sees "50 free" +-- from CorridorBudget and must still be refused/split by WagonStockLedger — +-- which is the whole assertion (wagon-stock-ledger.util.ts header: "money taken +-- for space that never existed"). +-- +-- LENGTH MUST NOT BIND. NW5 is 13.966 m, PW2 is 17.066 m: +-- 30 × 13.966 + 20 × 17.066 = 418.98 + 341.32 = 760.3 m +-- which is EXACTLY over a 760 m loco. The locos below run 900 m so the length +-- axis stays slack and TC-08 fails only on the pool separation it is testing. +-- Pull: 9000T against 50 wagons of tare+cargo is far under. Slots and TYPE bind. + +-- 1. Locomotive pair, 900 m so the mixed consist's length never binds. +INSERT INTO freight.locomotives + (id, code, max_pull_weight_tons, max_train_length_meters, + overage_tolerance_tons, current_yard_id) +SELECT gen_random_uuid(), v.code, 9000, 900, 0, y.id +FROM (VALUES ('LOCO-F2-A'), ('LOCO-F2-B')) AS v(code) +JOIN freight.yards y ON y.code = 'DJIB_PORT' +WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code); + +UPDATE freight.locomotives +SET max_pull_weight_tons = 9000, max_train_length_meters = 900, + overage_tolerance_tons = 0, + current_yard_id = (SELECT id FROM freight.yards WHERE code = 'DJIB_PORT') +WHERE code IN ('LOCO-F2-A', 'LOCO-F2-B') + AND (max_pull_weight_tons IS DISTINCT FROM 9000 + OR max_train_length_meters IS DISTINCT FROM 900 + OR overage_tolerance_tons IS DISTINCT FROM 0); + +-- 2. The train, parked at the corridor origin (a built-train schedule requires it). +INSERT INTO freight.trains + (id, code, train_name, capacity_tons, current_yard_id, + import_train_number, export_train_number) +SELECT gen_random_uuid(), 'TRN-F2-MIX', 'E2E Flow-2 Mixed Consist', 3500, y.id, + '9302', '9301' +FROM freight.yards y +WHERE y.code = 'DJIB_PORT' + AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = 'TRN-F2-MIX'); + +INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no) +SELECT gen_random_uuid(), t.id, l.id, v.seq +FROM (VALUES ('LOCO-F2-A', 0), ('LOCO-F2-B', 1)) AS v(loco_code, seq) +JOIN freight.trains t ON t.code = 'TRN-F2-MIX' +JOIN freight.locomotives l ON l.code = v.loco_code +WHERE NOT EXISTS ( + SELECT 1 FROM freight.train_locomotives tl + WHERE tl.train_id = t.id AND tl.locomotive_id = l.id +); + +-- 3a. Thirty NW5 (container-capable) wagons. +INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id) +SELECT gen_random_uuid(), 'WGN-F2C-' || lpad(g::text, 2, '0'), wt.id, y.id +FROM generate_series(1, 30) AS g +JOIN freight.wagon_types wt ON wt.code = 'NW5' +JOIN freight.yards y ON y.code = 'DJIB_PORT' +WHERE NOT EXISTS ( + SELECT 1 FROM freight.wagons w + WHERE w.wagon_number = 'WGN-F2C-' || lpad(g::text, 2, '0') +); + +-- 3b. Twenty PW2 (bulk-grains) wagons — NOT container-capable. +INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id) +SELECT gen_random_uuid(), 'WGN-F2B-' || lpad(g::text, 2, '0'), wt.id, y.id +FROM generate_series(1, 20) AS g +JOIN freight.wagon_types wt ON wt.code = 'PW2' +JOIN freight.yards y ON y.code = 'DJIB_PORT' +WHERE NOT EXISTS ( + SELECT 1 FROM freight.wagons w + WHERE w.wagon_number = 'WGN-F2B-' || lpad(g::text, 2, '0') +); + +-- 4. Couple all 50, containers first. Unconditionally re-asserted: a prior run +-- could have left one detached, and a 49-wagon consist shifts TC-08's premise. +UPDATE freight.wagons w +SET train_id = t.id, + sequence_number = g.seq, + status = 'ASSIGNED', + current_yard_id = t.current_yard_id +FROM freight.trains t, + LATERAL ( + SELECT ('WGN-F2C-' || lpad(s::text, 2, '0')) AS num, s AS seq + FROM generate_series(1, 30) AS s + UNION ALL + SELECT ('WGN-F2B-' || lpad(s::text, 2, '0')), 30 + s + FROM generate_series(1, 20) AS s + ) g +WHERE t.code = 'TRN-F2-MIX' + AND w.wagon_number = g.num + AND (w.train_id IS DISTINCT FROM t.id + OR w.sequence_number IS DISTINCT FROM g.seq + OR w.status IS DISTINCT FROM 'ASSIGNED'); diff --git a/e2e/freight/cypress/fixtures/seed-import-corridor.sql b/e2e/freight/cypress/fixtures/seed-import-corridor.sql index 4489dce86..05d45561f 100644 --- a/e2e/freight/cypress/fixtures/seed-import-corridor.sql +++ b/e2e/freight/cypress/fixtures/seed-import-corridor.sql @@ -448,3 +448,16 @@ WHERE NOT EXISTS ( AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id AND r.deleted_at IS NULL ); + +-- --------------------------------------------------------------------------- +-- e2e window durations: 1 minute instead of the 30/60 production defaults. +-- +-- Most specs never wait these out — closeWindowAndRunBatch clicks "Doc review +-- complete" and endPaymentPhase pulls the deadline into the past — so this is +-- a safety net for the paths that DO let a phase elapse on its own, not the +-- main speed lever. That one is the 10s @Cron tick in booking-window.service. +-- --------------------------------------------------------------------------- +UPDATE freight.train_scheduling_global_rules + SET doc_review_minutes = 1, + payment_window_minutes = 1, + export_payment_window_minutes = 1;