mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix issue
This commit is contained in:
@@ -1289,6 +1289,38 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.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<Booking[]> {
|
||||
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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, WagonType[]>(),
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,8 +121,17 @@ const shortageFor = (
|
||||
booking.freightType === 'BULK'
|
||||
? Math.max(
|
||||
1,
|
||||
// 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(
|
||||
Number(booking.cargoTotalWeightVgm ?? 0) /
|
||||
bookingCargoTons(booking) /
|
||||
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
||||
),
|
||||
)
|
||||
@@ -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) => {
|
||||
|
||||
17
e2e/freight/.live-q.cjs
Normal file
17
e2e/freight/.live-q.cjs
Normal file
@@ -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})); });
|
||||
13
e2e/freight/Dockerfile.vnc
Normal file
13
e2e/freight/Dockerfile.vnc
Normal file
@@ -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/*
|
||||
@@ -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<string>();
|
||||
|
||||
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
|
||||
|
||||
799
e2e/freight/cypress/e2e/flows/flow_two/flow2-export-utils.ts
Normal file
799
e2e/freight/cypress/e2e/flows/flow_two/flow2-export-utils.ts
Normal file
@@ -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<unknown>,
|
||||
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<string, string> = {};
|
||||
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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
421
e2e/freight/cypress/e2e/flows/flow_two/flow2-utils.ts
Normal file
421
e2e/freight/cypress/e2e/flows/flow_two/flow2-utils.ts
Normal file
@@ -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<string, string> = {};
|
||||
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<unknown>, 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
170
e2e/freight/cypress/e2e/flows/flow_two/tc02_overlap_spike.cy.ts
Normal file
170
e2e/freight/cypress/e2e/flows/flow_two/tc02_overlap_spike.cy.ts
Normal file
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
/** 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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
/** 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]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
189
e2e/freight/cypress/e2e/flows/flow_two/tc07_priority_rule.cy.ts
Normal file
189
e2e/freight/cypress/e2e/flows/flow_two/tc07_priority_rule.cy.ts
Normal file
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
/** 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));
|
||||
});
|
||||
});
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
/** 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
168
e2e/freight/cypress/e2e/flows/flow_two/tc09_customs_hold.cy.ts
Normal file
168
e2e/freight/cypress/e2e/flows/flow_two/tc09_customs_hold.cy.ts
Normal file
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
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);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
const ALL = ["B1", "B2", "B3"] as const;
|
||||
/** Booking ids captured BEFORE the cancel, to prove identity survives it. */
|
||||
const idsBefore: Record<string, string> = {};
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
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"));
|
||||
});
|
||||
});
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
/** 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
/** 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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
const ALL = ["B1", "B2", "B3"] as const;
|
||||
/** Captured before B2 is failed, compared after — the anti-cascade evidence. */
|
||||
const wagonsBefore: Record<string, number> = {};
|
||||
const scheduleBefore: Record<string, string | null> = {};
|
||||
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
/** 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<string, string | null> = {};
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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"));
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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([]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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<string, Leg>;
|
||||
|
||||
/** 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);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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<string, Leg>;
|
||||
|
||||
/** 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);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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<string, Leg>;
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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<string, Leg>;
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<string, Leg>;
|
||||
|
||||
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([]),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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<string, Leg>;
|
||||
|
||||
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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
273
e2e/freight/cypress/e2e/flows/flow_two/tcx14_split_flag.cy.ts
Normal file
273
e2e/freight/cypress/e2e/flows/flow_two/tcx14_split_flag.cy.ts
Normal file
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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));
|
||||
}
|
||||
233
e2e/freight/cypress/e2e/flows/flow_two/tcx16_vessel_cutoff.cy.ts
Normal file
233
e2e/freight/cypress/e2e/flows/flow_two/tcx16_vessel_cutoff.cy.ts
Normal file
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<string, Leg>;
|
||||
|
||||
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);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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",
|
||||
]),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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<string, Leg>;
|
||||
|
||||
/** 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;
|
||||
});
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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. */
|
||||
|
||||
74
e2e/freight/cypress/fixtures/seed-flow2-export-legs.sql
Normal file
74
e2e/freight/cypress/fixtures/seed-flow2-export-legs.sql
Normal file
@@ -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;
|
||||
112
e2e/freight/cypress/fixtures/seed-flow2-export-train-2.sql
Normal file
112
e2e/freight/cypress/fixtures/seed-flow2-export-train-2.sql
Normal file
@@ -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');
|
||||
182
e2e/freight/cypress/fixtures/seed-flow2-export-train.sql
Normal file
182
e2e/freight/cypress/fixtures/seed-flow2-export-train.sql
Normal file
@@ -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
|
||||
);
|
||||
95
e2e/freight/cypress/fixtures/seed-flow2-legs.sql
Normal file
95
e2e/freight/cypress/fixtures/seed-flow2-legs.sql
Normal file
@@ -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;
|
||||
108
e2e/freight/cypress/fixtures/seed-flow2-mixed-train.sql
Normal file
108
e2e/freight/cypress/fixtures/seed-flow2-mixed-train.sql
Normal file
@@ -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');
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user