Merge pull request #1072 from Tria-plc/dev

Syncing
This commit is contained in:
Nathnael Wondisha
2026-08-02 20:16:41 +03:00
committed by GitHub
123 changed files with 19344 additions and 169 deletions

View File

@@ -1,6 +1,7 @@
import "reflect-metadata";
import * as dotenv from "dotenv";
dotenv.config();
import { createRequire } from "node:module";
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
@@ -20,6 +21,62 @@ import { AppModule } from "./app.module";
*/
const JSON_BODY_LIMIT = '20mb';
/**
* Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as
* `host=ip,host2=ip2`. Some internal hosts (MinIO) resolve only inside the
* deployment network, so dev machines get ENOTFOUND on every upload. Patching
* `dns.lookup` keeps the real hostname on the wire — the IP is used for the
* connection only — so TLS still validates against the certificate's CN.
*
* The module is loaded through `createRequire`, NOT `import * as dns`: an ESM
* namespace object is frozen, so assigning to it is silently dropped and the
* patch becomes a no-op. `require` returns the live module object every other
* caller (minio's http agent included) reads `lookup` off.
*/
function applyDnsHostOverrides(): void {
const raw = process.env.DNS_HOST_OVERRIDES?.trim();
if (!raw) return;
const overrides = new Map<string, string>();
for (const entry of raw.split(",")) {
const [host, ip] = entry.split("=").map((part) => part?.trim());
if (host && ip) overrides.set(host.toLowerCase(), ip);
}
if (overrides.size === 0) return;
const dns = createRequire(__filename)("node:dns") as typeof import("node:dns");
const originalLookup = dns.lookup.bind(dns);
// `dns.lookup` is overloaded (options optional, all/family variants); the
// cast keeps that surface intact while we intercept only mapped hostnames.
(dns as { lookup: unknown }).lookup = ((
hostname: string,
options: unknown,
callback?: unknown,
) => {
const ip = overrides.get(hostname?.toLowerCase?.());
if (!ip) return (originalLookup as Function)(hostname, options, callback);
const done = (typeof options === "function" ? options : callback) as (
err: NodeJS.ErrnoException | null,
address: string | { address: string; family: number }[],
family?: number,
) => void;
const family = ip.includes(":") ? 6 : 4;
const wantsAll =
typeof options === "object" && options !== null && (options as { all?: boolean }).all;
process.nextTick(() =>
wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family),
);
}) as typeof dns.lookup;
console.log(
`[DNS] Host overrides active: ${[...overrides].map(([h, ip]) => `${h}->${ip}`).join(", ")}`,
);
}
applyDnsHostOverrides();
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);

View File

@@ -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

View File

@@ -290,6 +290,7 @@ export class ContractBookingService {
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
firstMilePickupLng: contract.firstMilePickupLng ?? null,
@@ -773,6 +774,7 @@ export class ContractBookingService {
cargoTypeId: this.resolveCargoTypeId(contract, dto),
cargoFreeText: dto.cargoFreeText?.trim() || null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
// Completion is where the cargo — and therefore the price — is fixed, so
// it is also where the billing currency is chosen. A bare instance was
@@ -1253,6 +1255,7 @@ export class ContractBookingService {
}
probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
probe.bulkTotalWeightTons = this.resolveBulkWeightTons(dto);
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
probe.cargoTypeId = cargoTypeId;
if (cargoTypeId) {
@@ -1297,11 +1300,7 @@ export class ContractBookingService {
return;
}
const requested =
(dto.bulkLines ?? []).reduce(
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
0,
) || this.resolveBulkTons(dto) || 0;
const requested = this.resolveBulkTons(dto);
const remaining = outstanding.bulk?.outstanding ?? 0;
// 0.001 t tolerance absorbs the 3-decimal rounding applied to split weights.
if (Math.abs(requested - remaining) > 0.001) {
@@ -1344,8 +1343,10 @@ export class ContractBookingService {
}
}
} else {
// PER_ITEM contracts are capped in items, so the item count is the
// consumption figure — tonnage is only wagon-sizing data.
const requested =
(lines.bulk?.cargoWeightTons ?? lines.bulk?.itemCount ?? 0) || 0;
Number(lines.bulk?.itemCount ?? lines.bulk?.cargoWeightTons ?? 0) || 0;
const cap = capacity.find((c) => c.cap != null);
if (cap && cap.remaining != null && requested > cap.remaining) {
throw new BadRequestException(
@@ -1373,11 +1374,7 @@ export class ContractBookingService {
}
}
} else {
const requested =
(dto.bulkLines ?? []).reduce(
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
0,
) || this.resolveBulkTons(dto) || 0;
const requested = this.resolveBulkTons(dto);
const cap = capacity.find((c) => c.cap != null);
if (cap && cap.remaining != null && requested > cap.remaining) {
throw new BadRequestException(
@@ -1641,11 +1638,27 @@ export class ContractBookingService {
private resolveBulkTons(dto: CreateBookingUnderContractDto): number {
if (!dto.bulkLines?.length) return 0;
return dto.bulkLines.reduce(
(sum, l) => sum + Number(l.cargoWeightTons ?? l.itemCount ?? 0),
(sum, l) => sum + Number(l.itemCount ?? l.cargoWeightTons ?? 0),
0,
);
}
/**
* Real tonnage of a PER_ITEM (break-bulk) booking, kept alongside the item
* count `cargoTotalWeightVgm` holds. Both are needed: the item count prices
* the booking, the tonnage sizes the wagons (`bulkItemWagonsRequired` derives
* per-item weight from tonnage ÷ items). Null for PER_TON bulk, where
* `cargoTotalWeightVgm` already IS the tonnage.
*/
private resolveBulkWeightTons(
dto: CreateBookingUnderContractDto,
): number | null {
const lines = dto.bulkLines ?? [];
if (!lines.some((l) => Number(l.itemCount) > 0)) return null;
const tons = lines.reduce((sum, l) => sum + Number(l.cargoWeightTons ?? 0), 0);
return tons > 0 ? tons : null;
}
/**
* Per-line handling counts. Each physical container carries its own hazardous
* / reefer / return switch (entered next to its VGM), so the count is however
@@ -1961,6 +1974,7 @@ export class ContractBookingService {
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>

View File

@@ -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(
@@ -3131,6 +3143,14 @@ export class BookingBatchService implements OnModuleInit {
async intercityCapacity(scheduleId: string): Promise<{
budget: CorridorBudget;
needFor: (booking: Booking) => Capacity;
/**
* Per-wagon-type split of `needFor(booking).wagons`, against THIS
* schedule's own wagon stock — so the same booking reads differently on a
* different train. Empty when the stock can't be resolved.
*/
breakdownFor: (
booking: Booking,
) => Array<{ wagonTypeId: string; code: string; wagons: number }>;
} | null> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
@@ -3145,7 +3165,24 @@ export class BookingBatchService implements OnModuleInit {
// still accepts ride-alongs on its empty legs — that is the whole point
// of the ride-along flow.
const budget = await this.remainingBudget(schedule, limits, wagonDims);
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
// Physical stock of THIS schedule's train (built consist, or the yard fleet
// it will draw from) — what makes the breakdown train-specific.
const stock = await this.trainSchedulingService.wagonStockForSchedule(
schedule.id,
schedule.originStationId,
budget.stops,
);
return {
budget,
needFor: (booking) => this.needFor(booking, wagonDims),
breakdownFor: (booking) =>
this.wagonBreakdownFor(
booking,
wagonDims,
stock.remainingByTypeId,
stock.codesByTypeId,
),
};
}
/**
@@ -4094,6 +4131,78 @@ export class BookingBatchService implements OnModuleInit {
};
}
/**
* The wagon count of {@link wagonsFor}, split across the wagon TYPES this
* particular train stocks — "3 × N35 + 1 × PW2" rather than a bare 4.
*
* `wagonsFor` sizes the booking on ONE representative type (the first the
* cargo type allows), which is all the abstract budget needs. Staff placing a
* ride-along need the physical picture: how many of each type this schedule
* must actually give up. So each allowed type is sized on its OWN capacity and
* items-fit, then filled greedily from the type with the largest per-wagon
* take, bounded by what the schedule has left of it.
*
* Because the stock is per-schedule, the same booking breaks down differently
* on a train stocking 60T N35s than on one stocking 40T PW2s. Returns [] when
* the booking's types are unconfigured or the train stocks none of them — the
* caller then shows the plain total.
*/
private wagonBreakdownFor(
booking: Booking,
wagonDims: WagonDims,
stockByTypeId: Map<string, number>,
codesByTypeId: Map<string, string>,
): Array<{ wagonTypeId: string; code: string; wagons: number }> {
const total = this.wagonsFor(booking, wagonDims);
if (total <= 0) return [];
// Per-wagon take of each allowed type ON THIS TRAIN, largest first: a type
// that swallows more of the booking per wagon needs fewer wagons.
const options = this.allowedDimsWithTypes(booking, wagonDims)
.filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0)
.map((o) => {
const wagonTypeId = o.wagonTypeId as string;
const wagonsIfAlone = Math.max(
1,
bulkItemWagonsRequired(
booking,
o.dims.capacityTons,
bulkItemsFitFor(booking.cargoType, wagonTypeId),
) ||
(o.dims.capacityTons > 0
? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons)
: total),
);
return {
wagonTypeId,
code: codesByTypeId.get(wagonTypeId) ?? '—',
available: stockByTypeId.get(wagonTypeId) ?? 0,
// Share of the whole booking one wagon of this type carries.
takePerWagon: 1 / wagonsIfAlone,
};
})
.sort((a, b) => b.takePerWagon - a.takePerWagon);
if (!options.length) return [];
// Fill greedily by take, capped by stock; `remaining` is the fraction of the
// booking still unplaced, so a wagon of any type covers `takePerWagon` of it.
const out: Array<{ wagonTypeId: string; code: string; wagons: number }> = [];
let remaining = 1;
for (const option of options) {
if (remaining <= 1e-9) break;
const wagons = Math.min(
option.available,
Math.ceil(remaining / option.takePerWagon),
);
if (wagons <= 0) continue;
out.push({ wagonTypeId: option.wagonTypeId, code: option.code, wagons });
remaining -= wagons * option.takePerWagon;
}
// The train cannot hold the whole booking in the types it stocks — the
// `fits` check already fails it; report only what it CAN take.
return out;
}
private fits(need: Capacity, budget: Capacity): boolean {
return (
need.wagons <= budget.wagons &&

View File

@@ -0,0 +1,108 @@
import { BookingBatchService } from './booking-batch.service';
import { Booking } from '../bookings/entities/booking.entity';
/**
* The intercity ride-along board shows WHICH wagon types a booking takes from
* the train it is being placed on ("3 × N35 + 1 × PW2"), not just how many
* wagons. Because the split is drawn against that schedule's own stock, the
* same booking must read differently on a different train.
*/
describe('BookingBatchService — intercity wagon breakdown', () => {
const N35 = 'wagon-type-n35';
const PW2 = 'wagon-type-pw2';
const dims = (capacityTons: number) => ({
capacityTons,
lengthMeters: 14,
tareWeightTons: 20,
});
const wagonDims = {
bulk: dims(60),
container: dims(60),
byWagonTypeId: new Map([
[N35, dims(60)],
[PW2, dims(20)],
]),
};
const codes = new Map([
[N35, 'N35'],
[PW2, 'PW2'],
]);
/**
* 400 break-bulk items weighing 800t — 2t per item. On a 60t N35 that is 30
* items per wagon (14 wagons); on a 20t PW2, 10 items (40 wagons).
*/
const perItemBooking = {
id: 'booking-1',
freightType: 'BULK',
cargoTotalWeightVgm: 400,
bulkTotalWeightTons: 800,
bookingContainers: [],
cargoType: {
wagonTypes: [{ id: N35 }, { id: PW2 }],
itemsPerWagonMap: {},
},
} as unknown as Booking;
const service = Object.create(
BookingBatchService.prototype,
) as BookingBatchService;
const breakdown = (
booking: Booking,
stock: Map<string, number>,
): Array<{ code: string; wagons: number }> =>
(
service as unknown as {
wagonBreakdownFor: (
b: Booking,
d: typeof wagonDims,
s: Map<string, number>,
c: Map<string, string>,
) => Array<{ code: string; wagons: number }>;
}
)
.wagonBreakdownFor(booking, wagonDims, stock, codes)
.map(({ code, wagons }) => ({ code, wagons }));
it('takes the highest-capacity type first when the train stocks plenty', () => {
const rows = breakdown(perItemBooking, new Map([[N35, 50], [PW2, 50]]));
expect(rows).toEqual([{ code: 'N35', wagons: 14 }]);
});
it('falls back to the smaller type for the remainder when the big one runs short', () => {
// Only 10 of the 14 N35s the booking wants — the rest rides PW2s. Ten N35s
// carry 10/14 of the booking, leaving 4/14, which needs ceil(40 × 4/14) PW2s.
const rows = breakdown(perItemBooking, new Map([[N35, 10], [PW2, 50]]));
expect(rows[0]).toEqual({ code: 'N35', wagons: 10 });
expect(rows[1].code).toBe('PW2');
expect(rows[1].wagons).toBeGreaterThan(0);
});
it('reads differently on a train that stocks only the small type', () => {
const rows = breakdown(perItemBooking, new Map([[PW2, 60]]));
expect(rows).toEqual([{ code: 'PW2', wagons: 40 }]);
});
it('honours the configured items-per-wagon fit over raw tonnage', () => {
// Floor space binds before weight: an N35 physically holds 20 of these
// items even though 30 would fit by weight → 20 wagons, not 14.
const floorBound = {
...perItemBooking,
cargoType: {
wagonTypes: [{ id: N35 }],
itemsPerWagonMap: { [N35]: 20 },
},
} as unknown as Booking;
expect(breakdown(floorBound, new Map([[N35, 50]]))).toEqual([
{ code: 'N35', wagons: 20 },
]);
});
it('returns nothing when the train stocks none of the allowed types', () => {
expect(breakdown(perItemBooking, new Map([['other-type', 30]]))).toEqual([]);
});
});

View File

@@ -155,12 +155,17 @@ export class IntercityService {
return {
...this.mapBooking(booking, need),
need,
wagonBreakdown: capacity?.breakdownFor(booking) ?? [],
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
};
}),
accepted: accepted.map((booking) => {
const need = capacity?.needFor(booking) ?? null;
return { ...this.mapBooking(booking, need), need };
return {
...this.mapBooking(booking, need),
need,
wagonBreakdown: capacity?.breakdownFor(booking) ?? [],
};
}),
};
}
@@ -200,7 +205,9 @@ export class IntercityService {
where: { id: bookingId },
relations: {
bookingContainers: { containerType: true },
cargoType: true,
// wagonTypes drives the break-bulk items-per-wagon fit — the accept
// check must size the booking exactly as the candidate list did.
cargoType: { wagonTypes: true },
},
});
if (!booking) {
@@ -326,6 +333,10 @@ export class IntercityService {
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
// The allowed wagon-type list is what sizes a break-bulk (PER_ITEM)
// booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and
// the wagon count silently degrades to tonnage-only.
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)
@@ -355,6 +366,10 @@ export class IntercityService {
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
// The allowed wagon-type list is what sizes a break-bulk (PER_ITEM)
// booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and
// the wagon count silently degrades to tonnage-only.
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)

View File

@@ -1435,4 +1435,44 @@ describe('TrainSchedulingService', () => {
).rejects.toThrow(/free only 5/);
});
});
describe('effectiveWagonsRequired', () => {
const effective = (booking: unknown): number =>
(service as never as { effectiveWagonsRequired(b: unknown): number })
.effectiveWagonsRequired(booking);
// 20-item / 100T break-bulk on 70T wagons with a 4-items-per-wagon fit:
// ceil(20/4) = 5 wagons.
const perItemBooking = (wagonsRequired: number | null) => ({
freightType: 'BULK',
cargoTotalWeightVgm: 20,
bulkTotalWeightTons: 100,
wagonsRequired,
cargoType: {
wagonTypes: [{ id: 'wt-nw5', capacityTons: 70 }],
itemsPerWagonMap: { 'wt-nw5': 4 },
},
});
it('overrides a stale too-small stamp with the item-aware recompute', () => {
// Stamped 1 by old code that read the PER_ITEM count (20) as tons.
expect(effective(perItemBooking(1))).toBe(5);
});
it('keeps a stored stamp that is at least the recompute', () => {
expect(effective(perItemBooking(7))).toBe(7);
});
it('trusts the stamp when BULK cargo relations are not loaded', () => {
expect(
effective({
freightType: 'BULK',
cargoTotalWeightVgm: 20,
bulkTotalWeightTons: 100,
wagonsRequired: 5,
cargoType: null,
}),
).toBe(5);
});
});
});

View File

@@ -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);
}
@@ -7819,7 +7828,7 @@ export class TrainSchedulingService {
*/
private effectiveWagonsRequired(booking: Booking): number {
const stored = Number(booking.wagonsRequired);
if (stored > 0) return Math.ceil(stored);
const storedCeil = stored > 0 ? Math.ceil(stored) : 0;
const bulkCapacities = (booking.cargoType?.wagonTypes ?? [])
.map((wt) => Number(wt.capacityTons))
.filter((c) => c > 0);
@@ -7827,7 +7836,15 @@ export class TrainSchedulingService {
booking.freightType === 'BULK' && bulkCapacities.length
? Math.max(...bulkCapacities)
: undefined;
return wagonsRequiredForBooking(booking, bulkCapacity);
// BULK with no cargo relations loaded: recomputing would size against a
// 1T capacity and read a PER_ITEM item count as tons — trust the stamp.
if (booking.freightType === 'BULK' && bulkCapacity === undefined && storedCeil > 0) {
return storedCeil;
}
// Stored is a candidate, never an early return (batch parity): rows
// stamped while BULK sizing read the PER_ITEM item count as tons carry a
// too-small footprint — a 20-item/100T booking was stamped 1 wagon.
return Math.max(storedCeil, wagonsRequiredForBooking(booking, bulkCapacity));
}
/**

View File

@@ -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]);
});
});

View File

@@ -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) => {

View File

@@ -2,6 +2,7 @@ import { Package } from "lucide-react";
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
@@ -13,6 +14,7 @@ export interface BookingCargoCardProps {
/** Cargo specs + container manifest table. */
export function BookingCargoCard({ booking }: BookingCargoCardProps) {
const containers = booking.bookingContainers ?? [];
const { tons, items } = cargoTonsAndItems(booking);
return (
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
@@ -21,7 +23,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
label="Cargo type"
value={booking.cargoType?.label ?? booking.freightType}
/>
<MetricTile label="Total VGM" value={`${booking.cargoTotalWeightVgm} tons`} />
<MetricTile label="Total VGM" value={`${tons} tons`} />
{items != null && <MetricTile label="Items" value={`${items}`} />}
<MetricTile
label="Hazardous"
value={booking.isHazardous ? "Yes" : "No"}

View File

@@ -3,6 +3,8 @@ import type { ReactNode } from "react";
import { Hash, Package, Ship, Weight, Clock } from "lucide-react";
import { Group, Stack, Text, Divider } from "@mantine/core";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
import { SectionCard } from "./SectionCard";
import { formatDate, type BookingDetailView } from "./booking-detail.styles";
@@ -38,7 +40,14 @@ export function BookingFactsCard({ booking }: BookingFactsCardProps) {
{ icon: Hash, label: "PNR Code", value: booking.pnrCode || "—" },
{ icon: Package, label: "Cargo Type", value: booking.cargoType?.label ?? "—" },
{ icon: Ship, label: "Shipping Line", value: booking.shippingLine?.label ?? "—" },
{ icon: Weight, label: "VGM Weight", value: `${booking.cargoTotalWeightVgm} tons` },
{
icon: Weight,
label: "VGM Weight",
value: (() => {
const { tons, items } = cargoTonsAndItems(booking);
return items != null ? `${tons} tons (${items} items)` : `${tons} tons`;
})(),
},
{ icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) },
];

View File

@@ -22,6 +22,7 @@ import {
import type { LucideIcon } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
@@ -52,7 +53,7 @@ export function BookingRequestHero({
(sum, c) => sum + Number(c.quantity ?? 0),
0,
);
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
return (
<Paper
@@ -162,7 +163,7 @@ export function BookingRequestHero({
icon={Weight}
label="Cargo weight"
value={`${weight} T`}
hint="VGM total"
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
accent="blue"
/>
<HeroTile

View File

@@ -9,6 +9,7 @@ import {
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Box,
Button,
@@ -665,6 +666,17 @@ export default function GlCreateBookingForm() {
),
);
// Drop one container row and shrink quantity to match — the inverse of
// syncUnits growing the array when quantity goes up.
const removeUnit = (lineIdx: number, unitIdx: number) =>
setContainerLines((prev) =>
prev.map((l, i) => {
if (i !== lineIdx) return l;
const units = l.units.filter((_, j) => j !== unitIdx);
return withDerivedCounts({ ...l, quantity: String(units.length), units });
}),
);
// Same client-side validation as the customer portal shipment form
// (new-shipment-form/schema.ts): ISO container numbers unique within the
// shipment, positive VGM per unit, hazardous/reefer counts bounded by the
@@ -1362,6 +1374,8 @@ export default function GlCreateBookingForm() {
}
onChange={(e) => {
patchLine(lineIdx, { quantity: e.currentTarget.value });
}}
onBlur={(e) => {
syncUnits(lineIdx, Number(e.currentTarget.value || 0));
}}
radius={10}
@@ -1495,6 +1509,14 @@ export default function GlCreateBookingForm() {
/>
</Box>
))}
<ActionIcon
variant="subtle"
color="red"
aria-label={`Remove container ${unitIdx + 1}`}
onClick={() => removeUnit(lineIdx, unitIdx)}
>
<X size={16} />
</ActionIcon>
</Group>
))}
</Stack>

View File

@@ -182,18 +182,18 @@ const FreightDashboardHeader = ({
)}
</Box>
<Divider />
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/dashboard/profile#signature")}
>
Signature &amp; Stamp
</Menu.Item>
<Menu.Item
leftSection={<User size={15} />}
onClick={() => navigate("/dashboard/profile")}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/dashboard/profile#signature")}
>
My signature
</Menu.Item>
<Divider />
<Menu.Item
leftSection={<LogOut size={15} />}

View File

@@ -79,7 +79,7 @@ export function MySignatureCard() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileSignature className="size-4" />
My signature
Signature &amp; Stamp
</CardTitle>
<CardDescription>
This signature can be reused to sign booking contracts.

View File

@@ -38,6 +38,7 @@ import { formatRouteLabel } from "@/services/routes.service";
import { useToast } from "@/hooks/use-toast";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
import type {
ContainerPlacement,
FreightType,
@@ -416,7 +417,7 @@ export function AllocateBookingWizard({
const amount = Number(booking.totalAmount);
const containers = booking.bookingContainers ?? [];
const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
const holdCountdown = formatCountdown(booking.holdExpiresAt);
const containerComplete =
@@ -945,7 +946,13 @@ export function AllocateBookingWizard({
})}`}
hint={booking.paymentStatus}
/>
<StatTile onDark icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" />
<StatTile
onDark
icon={Weight}
label="Cargo weight"
value={`${weight} T`}
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
/>
<StatTile
onDark
icon={ContainerIcon}

View File

@@ -68,11 +68,27 @@ function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
);
}
function NeedCells({ need }: { need: IntercityCapacity | null }) {
function NeedCells({ row }: { row: IntercityBookingRow }) {
const { need, wagonBreakdown } = row;
if (!need) return <Table.Td colSpan={3}></Table.Td>;
return (
<>
<Table.Td>{fmt(need.wagons)}</Table.Td>
<Table.Td>
{wagonBreakdown?.length ? (
// Which wagon TYPES this train gives up, not just how many wagons —
// a break-bulk booking's count depends on each type's capacity and
// its configured items-per-wagon fit, so it differs per train.
<Stack gap={2}>
{wagonBreakdown.map((entry) => (
<Text key={entry.wagonTypeId} size="sm">
{entry.wagons} × {entry.code}
</Text>
))}
</Stack>
) : (
fmt(need.wagons)
)}
</Table.Td>
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
</>
@@ -285,7 +301,7 @@ export function IntercityRideAlongPanel({
<Table.Td>
<CorridorCell row={row} />
</Table.Td>
<NeedCells need={row.need} />
<NeedCells row={row} />
<Table.Td>
{row.fits ? (
<Badge size="sm" variant="light" color="teal">

View File

@@ -12,6 +12,7 @@ import toast from "react-hot-toast";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import { bookingSurface } from "@/components/bookings/booking-ui.styles";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
@@ -40,6 +41,9 @@ export default function BookingContractPage() {
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
// Company stamp: prefilled from the profile, or uploaded here when none is
// saved yet.
const [stampData, setStampData] = useState<string | null>(null);
// When the user has a saved signature we offer it for approval first; they
// can switch to drawing a fresh one.
const [drawNew, setDrawNew] = useState(false);
@@ -55,6 +59,7 @@ export default function BookingContractPage() {
const savedSignature = data?.savedSignature ?? null;
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
const savedStampImage = savedSignature?.stampImageUrl ?? null;
// Show the approval view only while a saved signature exists and the user
// hasn't opted to draw a new one.
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
@@ -98,6 +103,8 @@ export default function BookingContractPage() {
// approve it; otherwise start with an empty pad.
setSignerName(savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
// Prefill with the reusable stamp saved on the profile; still replaceable.
setStampData(savedStampImage);
setDrawNew(false);
setSignOpen(true);
};
@@ -106,10 +113,12 @@ export default function BookingContractPage() {
if (!canSign || !signerName.trim()) return;
// Approve the saved signature, or submit the freshly drawn one.
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
// The API rejects a STAFF signature without a stamp.
if (!image || !stampData) return;
signMutation.mutate({
role: "STAFF",
signatureImageBase64: image,
stampImageBase64: stampData,
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
});
@@ -234,6 +243,15 @@ export default function BookingContractPage() {
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
<StampUpload
value={stampData}
onChange={setStampData}
description={
savedStampImage
? "Your saved company stamp — replace it for this contract if needed."
: "Required. Attach your official company stamp or seal."
}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSignOpen(false)}>
@@ -243,6 +261,7 @@ export default function BookingContractPage() {
disabled={
signMutation.isPending ||
(!usingSaved && !signatureData) ||
!stampData ||
!signerName.trim()
}
onClick={confirmSign}

View File

@@ -92,6 +92,7 @@ export interface ContractView {
savedSignature?: {
signerDisplayName: string;
signatureImageUrl?: string | null;
stampImageUrl?: string | null;
} | null;
}
@@ -113,6 +114,8 @@ export interface ConsolidationDetails {
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
/** Company stamp/seal image; the API requires one for CUSTOMER and STAFF. */
stampImageBase64?: string;
signerDisplayName: string;
consentText?: string;
}

View File

@@ -171,6 +171,8 @@ export interface BookingDetail {
/** What the containers carry / bulk commodity label — entered at booking time. */
cargoFreeText?: string | null;
cargoTotalWeightVgm: number;
/** Break-bulk (PER_ITEM) only: real total tons — cargoTotalWeightVgm then holds the item count. */
bulkTotalWeightTons?: number | null;
isHazardous: boolean;
consolidationPartnerId?: string | null;
consolidationPartner?: BookingNamedRef & { reference?: string } | null;

View File

@@ -959,6 +959,14 @@ export interface IntercityCapacity {
lengthMeters: number | null;
}
/** One wagon type this train must give up, and how many of it. */
export interface IntercityWagonBreakdownEntry {
wagonTypeId: string;
/** Wagon-type code as marshalled, e.g. "N35" / "PW2". */
code: string;
wagons: number;
}
export interface IntercityBookingRow {
id: string;
reference: string | null;
@@ -973,6 +981,12 @@ export interface IntercityBookingRow {
weightTons: number;
paymentDeadline: string | null;
need: IntercityCapacity | null;
/**
* `need.wagons` split across the wagon types THIS schedule stocks — the same
* booking reads differently on a train of 60T wagons than on one of 40T.
* Empty when the stock or the cargo type's wagon list is unresolved.
*/
wagonBreakdown?: IntercityWagonBreakdownEntry[];
}
export interface IntercityCandidateRow extends IntercityBookingRow {

View File

@@ -0,0 +1,18 @@
/**
* Break-bulk (PER_ITEM) bulk bookings overload `cargoTotalWeightVgm` with the
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Every other
* booking stores tons in `cargoTotalWeightVgm` directly. Rendering the raw
* VGM column showed a 20-item / 100T booking as "20 tons".
*/
export function cargoTonsAndItems(booking: {
freightType?: string | null;
cargoTotalWeightVgm?: number | string | null;
bulkTotalWeightTons?: number | string | null;
}): { tons: number; items: number | null } {
const bulkTons = Number(booking.bulkTotalWeightTons ?? 0);
const vgm = Number(booking.cargoTotalWeightVgm ?? 0);
if (booking.freightType === "BULK" && bulkTons > 0) {
return { tons: bulkTons, items: vgm > 0 ? vgm : null };
}
return { tons: vgm, items: null };
}

View File

@@ -548,18 +548,18 @@ export function AppLayout({
</>
)}
<Divider />
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/signature")}
>
Signature &amp; Stamp
</Menu.Item>
<Menu.Item
leftSection={<User size={15} />}
onClick={() => navigate("/profile")}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/signature")}
>
My signature
</Menu.Item>
<Menu.Item
leftSection={<Settings size={15} />}
onClick={() => navigate("/settings")}

View File

@@ -69,7 +69,7 @@ export function MySignatureCard() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileSignature className="size-5 text-primary" />
My signature
Signature &amp; Stamp
</CardTitle>
<CardDescription>
Reused to approve and sign booking contracts.

View File

@@ -6,7 +6,7 @@ export default function MySignaturePage() {
<div className="mx-auto flex max-w-md flex-col gap-6">
<div>
<h1 className="text-2xl font-black tracking-tight text-foreground">
My signature
Signature &amp; Stamp
</h1>
<p className="text-sm text-muted-foreground">
Saved and reused to approve and sign booking contracts.

View File

@@ -11,6 +11,7 @@ import {
import toast from "react-hot-toast";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import {
bookingsService,
type SignContractPayload,
@@ -25,6 +26,9 @@ export default function BookingContractPage() {
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
// Company stamp: prefilled from the profile, or uploaded here when the
// customer has never saved one.
const [stampData, setStampData] = useState<string | null>(null);
// When a saved signature exists we offer it for approval first; the customer
// can switch to drawing a fresh one.
const [drawNew, setDrawNew] = useState(false);
@@ -37,12 +41,14 @@ export default function BookingContractPage() {
const savedSignature = data?.savedSignature ?? null;
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
const savedStampImage = savedSignature?.stampImageUrl ?? null;
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
const openSign = () => {
// Prefill from the saved signature so the customer only has to approve it.
setSignerName(savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
setStampData(savedStampImage);
setDrawNew(false);
setSignOpen(true);
};
@@ -51,10 +57,12 @@ export default function BookingContractPage() {
if (!signerName.trim()) return;
// Approve the saved signature, or submit the freshly drawn one.
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
// The API rejects a CUSTOMER signature without a stamp.
if (!image || !stampData) return;
signMutation.mutate({
role: "CUSTOMER",
signatureImageBase64: image,
stampImageBase64: stampData,
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
});
@@ -191,6 +199,15 @@ export default function BookingContractPage() {
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
<StampUpload
value={stampData}
onChange={setStampData}
description={
savedStampImage
? "Your saved company stamp — replace it for this contract if needed."
: "Required. Attach your official company stamp or seal."
}
/>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="outline" onClick={() => setSignOpen(false)}>
@@ -200,6 +217,7 @@ export default function BookingContractPage() {
disabled={
signMutation.isPending ||
(!usingSaved && !signatureData) ||
!stampData ||
!signerName.trim()
}
onClick={confirmSign}

View File

@@ -1,5 +1,5 @@
import { Group, Tabs } from "@mantine/core";
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
import { Clock, CreditCard, FileText, LayoutGrid, Truck } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useFileViewer } from "@/hooks/useFileViewer";
@@ -184,15 +184,27 @@ export function ReadonlyBookingView({
<Tabs
defaultValue="overview"
keepMounted={false}
color="edr-green"
styles={{
list: { gap: 6, borderBottom: "1px solid #E6ECF2" },
tab: { borderRadius: "10px 10px 0 0", fontWeight: 700, padding: "10px 16px" },
tab: {
borderRadius: "10px 10px 0 0",
fontWeight: 700,
padding: "10px 16px",
transition: "background-color 120ms ease, color 120ms ease",
},
}}
>
<Tabs.List mb="lg">
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}>
Overview
</Tabs.Tab>
<Tabs.Tab value="logistics" leftSection={<Truck size={15} />}>
Logistics
</Tabs.Tab>
<Tabs.Tab value="activity" leftSection={<Clock size={15} />}>
Activity
</Tabs.Tab>
<Tabs.Tab value="documents" leftSection={<FileText size={15} />}>
Documents
</Tabs.Tab>
@@ -215,23 +227,9 @@ export function ReadonlyBookingView({
<ContainersCard booking={booking} />
<WarehouseLocationCard bookingId={booking.id} />
<ContractInfoCard booking={booking} />
<ShipmentTrackingCard bookingId={booking.id} />
{canAssignCustomerTruck && (
<CustomerTruckAssignmentCard
booking={booking}
onAssigned={onBookingUpdated ?? (() => {})}
/>
)}
<WarehousePaymentsSection bookingId={booking.id} />
<ActivityCard booking={booking} />
<MileSummaryCard booking={booking} />
</>
}
right={
@@ -256,6 +254,34 @@ export function ReadonlyBookingView({
</div>
</Tabs.Panel>
<Tabs.Panel value="logistics">
<div className="flex flex-col gap-6">
<BodyGrid
left={
<>
<WarehouseLocationCard bookingId={booking.id} />
{canAssignCustomerTruck && (
<CustomerTruckAssignmentCard
booking={booking}
onAssigned={onBookingUpdated ?? (() => {})}
/>
)}
<MileSummaryCard booking={booking} />
</>
}
right={<WarehousePaymentsSection bookingId={booking.id} />}
/>
</div>
</Tabs.Panel>
<Tabs.Panel value="activity">
<div className="flex flex-col gap-6" style={{ maxWidth: 700 }}>
<ActivityCard booking={booking} />
</div>
</Tabs.Panel>
<Tabs.Panel value="documents">
<DocumentsTab booking={booking} />
</Tabs.Panel>

View File

@@ -1,10 +1,18 @@
import { Box, Group, Text } from "@mantine/core";
import type { ReactNode } from "react";
import { MapPin } from "lucide-react";
import { type ReactNode, useState } from "react";
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
import { ShipmentTrackingModal } from "@/pages/bookings/tracking/ShipmentTrackingModal";
import type { BookingDetail } from "../booking-detail-types";
import { fmtDate, isDraftLike, isNegative, serviceTypeLabel } from "../utils";
import {
fmtDate,
isDraftLike,
isNegative,
serviceTypeLabel,
yardLabel,
} from "../utils";
import { CardTitle, SectionCard } from "./layout";
type Row = { label: string; value: ReactNode; muted?: boolean };
@@ -53,13 +61,38 @@ export function ScheduleCard({
title: string;
consignment?: boolean;
}) {
const [trackingOpen, setTrackingOpen] = useState(false);
const service = serviceTypeLabel(booking);
const equipmentReturn =
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
const assignedTrain: Row = {
const assignedTrain: Row = booking.trainScheduleId
? {
label: "Assigned train",
value: booking.trainId ?? "Not yet assigned",
muted: !booking.trainId,
value: (
<button
type="button"
onClick={() => setTrackingOpen(true)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
border: "none",
background: "none",
padding: 0,
cursor: "pointer",
color: "#0A6F4D",
fontWeight: 700,
fontSize: 13,
}}
>
<MapPin size={13} /> Track shipment
</button>
),
}
: {
label: "Assigned train",
value: "Not yet assigned",
muted: true,
};
const statusRow: Row = {
@@ -115,6 +148,17 @@ export function ScheduleCard({
</Group>
))}
</Box>
{booking.trainScheduleId && (
<ShipmentTrackingModal
opened={trackingOpen}
onClose={() => setTrackingOpen(false)}
bookingId={booking.id}
bookingReference={booking.reference}
originLabel={yardLabel(booking.originYard)}
destinationLabel={yardLabel(booking.destinationYard)}
/>
)}
</SectionCard>
);
}

View File

@@ -69,10 +69,7 @@ export function ShipmentDetailsCard({ booking }: { booking: BookingDetail }) {
],
["Scheduled date", fmtDate(booking.scheduledDate)],
],
[
["Shipping line", shippingLineLabel(booking)],
["Assigned train", booking.trainId ?? "Not yet assigned"],
],
[["Shipping line", shippingLineLabel(booking)]],
];
const badges: string[] = [];

View File

@@ -83,6 +83,10 @@ export function totalVgmTons(b: BookingDetail): number {
);
if (sum > 0) return sum;
}
// Break-bulk (PER_ITEM): cargoTotalWeightVgm holds the ITEM COUNT — the
// real tonnage lives in bulkTotalWeightTons.
const bulkTons = Number(b.bulkTotalWeightTons || 0);
if (bulkTons > 0) return bulkTons;
return Number(b.cargoTotalWeightVgm || 0);
}

View File

@@ -77,6 +77,7 @@ const STATUS_FILTERS = [
key: "all",
label: "All bookings",
statuses: undefined as string | undefined,
assignedToSchedule: undefined as "true" | "false" | undefined,
},
{
key: "active",
@@ -89,9 +90,16 @@ const STATUS_FILTERS = [
key: "payment",
label: "Awaiting payment",
statuses:
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS",
},
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT,ARRIVED" },
{
key: "allocated",
label: "Allocated to a train",
statuses: undefined as string | undefined,
assignedToSchedule: "true" as const,
},
{ key: "expired", label: "Expired", statuses: "EXPIRED" },
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
{
key: "closed",
@@ -348,7 +356,10 @@ export default function BookingsListPage() {
const [trackingBooking, setTrackingBooking] =
useState<Freight.IBooking | null>(null);
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
const activeFilter = STATUS_FILTERS.find((t) => t.key === statusFilter);
const statuses = activeFilter?.statuses;
const assignedToSchedule =
"assignedToSchedule" in activeFilter! ? activeFilter.assignedToSchedule : undefined;
const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"];
const resetPage = () =>
@@ -372,6 +383,7 @@ export default function BookingsListPage() {
const filter: BookingListFilter = useMemo(
() => ({
statuses,
assignedToSchedule,
bookingType: typeFilter ?? undefined,
freightType: freightFilter ?? undefined,
createdFrom: createdFrom || undefined,
@@ -386,6 +398,7 @@ export default function BookingsListPage() {
}),
[
statuses,
assignedToSchedule,
typeFilter,
freightFilter,
createdFrom,
@@ -423,6 +436,8 @@ export default function BookingsListPage() {
draft: draftCount,
done: doneCount,
transit: undefined,
allocated: undefined,
expired: undefined,
closed: undefined,
};
@@ -557,7 +572,12 @@ export default function BookingsListPage() {
size: 130,
meta: hMeta,
header: () => <ColHeader label="Payment" />,
cell: ({ row }) => <PaymentBadge status={row.original.paymentStatus} />,
cell: ({ row }) => (
<PaymentBadge
status={row.original.paymentStatus}
bookingStatus={row.original.status}
/>
),
},
{
id: "scheduling",

View File

@@ -144,17 +144,31 @@ export function paymentStatusLabel(status?: string | null): string {
return PAYMENT_LABELS[status] ?? titleCaseStatus(status);
}
/** Payment status pill. */
export function PaymentBadge({ status }: { status?: string | null }) {
if (!status) return <Text fz={13} c="dimmed"></Text>;
/**
* Payment status pill. Once the booking's own lifecycle status has moved past
* payment (PAID or later — stage ≥ 3 in STATUS_CONFIG), payment is a settled
* fact: show "Paid" even if a stale/lagging `paymentStatus` value says
* otherwise, rather than surface a contradictory "Paid booking, pending
* payment" row.
*/
export function PaymentBadge({
status,
bookingStatus,
}: {
status?: string | null;
bookingStatus?: string | null;
}) {
const settled = bookingStatus ? (STATUS_CONFIG[bookingStatus]?.stage ?? 0) >= 3 : false;
const effective = settled ? "PAID" : status;
if (!effective) return <Text fz={13} c="dimmed"></Text>;
return (
<Badge
variant="light"
radius="sm"
color={PAYMENT_COLORS[status] ?? "gray"}
color={PAYMENT_COLORS[effective] ?? "gray"}
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
>
{PAYMENT_LABELS[status] ?? titleCaseStatus(status)}
{PAYMENT_LABELS[effective] ?? titleCaseStatus(effective)}
</Badge>
);
}

View File

@@ -1316,7 +1316,10 @@ export default function ContractDetailPage() {
)}
</Table.Td>
<Table.Td>
<PaymentBadge status={booking.paymentStatus} />
<PaymentBadge
status={booking.paymentStatus}
bookingStatus={booking.status}
/>
</Table.Td>
<Table.Td>
<SchedulingCell booking={booking} />

View File

@@ -11,6 +11,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import {
ActionIcon,
Alert,
Box,
Button,
@@ -515,7 +516,9 @@ function NewShipmentBookingForm({
routes={routes}
completeBookingId={completeBookingId ?? null}
/>
<NotesSection form={form} />
{/* Notes are captured when the booking is initiated — completing
a bare booking does not re-ask for them. */}
{!completeBookingId && <NotesSection form={form} />}
</Stack>
</Box>
@@ -989,8 +992,9 @@ function ScheduleStep({
?.cargoTypeCode ?? undefined,
totalWeightTons: tons,
};
// itemCount is referenced so the query refreshes when a PER_ITEM cargo
// amount changes (weight is the sizing input the backend uses).
// Tonnage is the sizing input the day-feasibility endpoint takes, and
// PER_ITEM cargo now captures it too — itemCount stays in the deps so the
// query still refreshes when only the item count changes.
}, [
route,
isContainer,
@@ -1159,6 +1163,9 @@ function CargoStep({
contract: Freight.IContract;
}) {
const isContainer = contract.freightType === "CONTAINER";
// Break-bulk (PER_ITEM) cargo needs BOTH the item count (which prices it) and
// the total tonnage (which sizes the wagons); PER_TON needs tonnage only.
const isPerItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
// Sizes enabled by the contract scope.
const sizes = useMemo(
() =>
@@ -1190,8 +1197,7 @@ function CargoStep({
// still needs its container number.
useEffect(() => {
if (!remainderMode || isContainer) return;
const field =
bulkUnitOfMeasure(contract) === "PER_ITEM" ? "itemCount" : "cargoWeightTons";
const field = isPerItem ? "itemCount" : "cargoWeightTons";
if (!form.getValues(field)) {
form.setValue(field, String(remainderLines[0].remaining), {
shouldValidate: false,
@@ -1475,24 +1481,7 @@ function CargoStep({
<Stack gap={14}>
{remainderNotice}
<ContractCapacityNotice contractId={contract.id} isContainer={false} />
<Controller
name="cargoWeightTons"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Quantity (tons)"
placeholder="e.g. 1200"
min={0}
step={0.01}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
{isPerItem && (
<Controller
name="itemCount"
control={form.control}
@@ -1501,7 +1490,8 @@ function CargoStep({
{...field}
type="number"
onKeyDown={blockNegative}
label="Item count (if applicable)"
label="Number of items *"
description="This contract is priced per item — the item count sets the price."
placeholder="e.g. 500"
min={0}
step={1}
@@ -1511,6 +1501,30 @@ function CargoStep({
/>
)}
/>
)}
<Controller
name="cargoWeightTons"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Total weight (tons) *"
description={
isPerItem
? "Combined weight of all the items — used to work out how many wagons the shipment needs."
: undefined
}
placeholder="e.g. 1200"
min={0}
step={0.01}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
{contract.isHazardous && (
<Controller
name="bulkHazardousQuantity"
@@ -1694,6 +1708,18 @@ function ContainerLineEditor({
syncHandlingCounts(next);
};
// Drop one container row and shrink quantity to match — the inverse of
// syncUnits growing the array when quantity goes up.
const removeUnit = (unitIdx: number) => {
const current = form.getValues(`containers.${index}.units`) ?? [];
const next = current.filter((_, j) => j !== unitIdx);
form.setValue(`containers.${index}.units`, next, { shouldValidate: false });
form.setValue(`containers.${index}.quantity`, String(next.length), {
shouldValidate: true,
});
syncHandlingCounts(next);
};
/**
* Line totals are a roll-up of the per-container switches — the count is
* however many containers ticked each service. Kept in form state so the
@@ -1781,8 +1807,10 @@ function ContainerLineEditor({
styles={fieldStyles}
onChange={(e) => {
field.onChange(e.currentTarget.value);
const qty = Number(e.currentTarget.value || 0);
syncUnits(qty);
}}
onBlur={(e) => {
field.onBlur();
syncUnits(Number(e.currentTarget.value || 0));
}}
/>
)}
@@ -1906,6 +1934,14 @@ function ContainerLineEditor({
)}
/>
))}
<ActionIcon
variant="subtle"
color="red"
aria-label={`Remove container ${u + 1}`}
onClick={() => removeUnit(u)}
>
<X size={16} />
</ActionIcon>
</Group>
))}
</Stack>

View File

@@ -1,4 +1,4 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { type UseFormReturn } from "react-hook-form";
import {
Badge,
Box,
@@ -7,13 +7,11 @@ import {
Paper,
Stack,
Text,
Textarea,
} from "@mantine/core";
import {
CheckCircle2,
Circle,
ClipboardCheck,
Coins,
FileText,
MapPin,
Package,
@@ -220,13 +218,13 @@ export function Step8Review({
// not of the stored form flag — a stale draft flag must not misreport it.
// Without bundling, the customer may still name their own clearing agent.
const ownAgent = values.customsClearingAgent?.trim();
const customsValue = isIntercity
? "Not applicable domestic transport"
const customsTag: { label: string; color: string } = isIntercity
? { label: "Not applicable · domestic", color: "gray" }
: serviceType?.includesCustoms || values.customsClearingEnabled
? "Included — Global Logistics"
? { label: "EDR handles it · Global Logistics", color: "edr-green" }
: ownAgent
? `Own agent ${ownAgent}`
: "Not requested";
? { label: `Own agent · ${ownAgent}`, color: "blue" }
: { label: "Not requested", color: "gray" };
// Mirror the step-2 gating: imports never truck the first mile, exports never
// truck the last mile, and a service that doesn't bundle a mile can't have it.
@@ -351,27 +349,26 @@ export function Step8Review({
label="Service"
value={serviceType?.serviceName ?? "—"}
/>
<SummaryItem
icon={<Coins size={18} />}
label="Quotation currency"
value={
<>
USD
<Text fz="sm" c="dimmed" mt={4}>
You choose the billing currency on each shipment.
</Text>
</>
}
/>
<SummaryItem
icon={<Route size={18} />}
label="Route"
value={`${originYardName}${destinationYardName}`}
/>
<SummaryItem
icon={<MapPin size={18} />}
label="Trade direction"
value={directionLabel}
value={
<Group gap={6} wrap="nowrap" align="center">
<Text fz={14} fw={600} c="#10202F" truncate>
{originYardName} {destinationYardName}
</Text>
<Badge
size="xs"
variant="light"
color="edr-green"
radius="sm"
leftSection={<MapPin size={11} />}
style={{ flexShrink: 0 }}
>
{directionLabel}
</Badge>
</Group>
}
/>
<SummaryItem
icon={<Package size={18} />}
@@ -420,7 +417,16 @@ export function Step8Review({
<SummaryItem
icon={<FileText size={18} />}
label="Customs clearing"
value={customsValue}
value={
<Badge
size="sm"
variant="light"
color={customsTag.color}
radius="sm"
>
{customsTag.label}
</Badge>
}
/>
{/* Step-3 toggles appear only when the customer selected them —
an off toggle is left off the summary entirely. */}
@@ -478,20 +484,6 @@ export function Step8Review({
{documentsEditor}
</Paper>
)}
<Controller
name="notes"
control={form.control}
render={({ field }) => (
<Textarea
{...field}
label="Additional notes"
placeholder="Any special instructions for EDR operations…"
rows={3}
radius="md"
/>
)}
/>
</Stack>
{/* Right — sticky actions */}

View File

@@ -221,6 +221,20 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
});
}
// PER_ITEM cargo needs BOTH figures: the item count prices the booking,
// the total weight sizes the wagons (per-item weight = tons ÷ items).
// PER_TON needs only the tonnage, which `bulkCap` already covers.
if (isPerItem) {
const tons = Number(data.cargoWeightTons || 0);
if (Number.isNaN(tons) || tons <= 0) {
refineCtx.addIssue({
code: "custom",
path: ["cargoWeightTons"],
message: "Enter the total weight in tons.",
});
}
}
const boundBulkPortion = (
on: boolean,
raw: string,

View File

@@ -148,3 +148,62 @@ describe("computeShipmentTotal — with_return surcharge", () => {
expect(t.total).toBe(200 + 60);
});
});
// PER_ITEM (break-bulk) cargo captures BOTH an item count and a total tonnage:
// the item count prices the booking, the tonnage sizes the wagons. Before this,
// the estimate took `cargoWeightTons || itemCount` and so billed a per_item rate
// against the tonnage as soon as both fields were filled.
describe("computeShipmentTotal — PER_ITEM bulk", () => {
const perItemContract = contract({
freightType: "BULK",
isHazardous: false,
isReefer: false,
equipmentReturn: "NO_RETURN",
pricingBreakdown: {
currency: "ETB",
lineItems: [
{ label: "Break-bulk freight", unit: "per_item", unitPrice: 50 },
{
label: "Customs clearance",
unit: "per_item",
unitPrice: 5,
isClearance: true,
},
],
},
} as unknown as Partial<Freight.IContract>);
it("bills a per_item rate on the item count, not the tonnage", () => {
const t = computeShipmentTotal(
perItemContract,
values({ containers: [], itemCount: "400", cargoWeightTons: "800" }),
);
expect(line(t, "Break-bulk freight")).toMatchObject({
quantity: 400,
amount: 20000,
});
expect(line(t, "Customs clearance")).toMatchObject({
quantity: 400,
amount: 2000,
});
expect(t.total).toBe(22000);
});
it("still bills a per_ton rate on the tonnage", () => {
const perTon = contract({
freightType: "BULK",
isHazardous: false,
isReefer: false,
equipmentReturn: "NO_RETURN",
pricingBreakdown: {
currency: "ETB",
lineItems: [{ label: "Bulk freight", unit: "per_ton", unitPrice: 10 }],
},
} as unknown as Partial<Freight.IContract>);
const t = computeShipmentTotal(
perTon,
values({ containers: [], cargoWeightTons: "800", itemCount: "" }),
);
expect(line(t, "Bulk freight")).toMatchObject({ quantity: 800, amount: 8000 });
});
});

View File

@@ -15,6 +15,18 @@ export interface ShipmentTotal {
total: number;
}
/**
* Quantity a bulk rate bills, in ITS OWN unit. PER_ITEM cargo carries both
* figures — the item count prices the booking, the tonnage sizes the wagons —
* so a per_item rate must bill items even though tonnage is also filled in.
* PER_TON cargo has no item count and falls back the other way for legacy rows.
*/
function bulkQtyForUnit(values: ShipmentFormValues, unit: string): number {
return unit === "per_item"
? Number(values.itemCount || 0)
: Number(values.cargoWeightTons || values.itemCount || 0);
}
/**
* Compute the booking total client-side from the contract's frozen unit rates ×
* the quantities the customer enters (doc §9.2). This is an estimate shown in
@@ -115,7 +127,6 @@ export function computeShipmentTotal(
}
}
} else {
const qty = Number(values.cargoWeightTons || values.itemCount || 0);
const rate =
rateFor(
(i) =>
@@ -123,6 +134,7 @@ export function computeShipmentTotal(
!i.isClearance &&
!i.conditionalOn,
) ?? items[0];
const qty = rate ? bulkQtyForUnit(values, rate.unit) : 0;
if (rate && qty > 0) {
lines.push({
label: rate.label,
@@ -166,14 +178,14 @@ export function computeShipmentTotal(
// real pricing.
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) {
const tons = Number(values.cargoWeightTons || values.itemCount || 0);
if (tons > 0) {
const qty = bulkQtyForUnit(values, lashing.unit);
if (qty > 0) {
lines.push({
label: lashing.label,
unitPrice: lashing.unitPrice,
unit: lashing.unit,
quantity: tons,
amount: lashing.unitPrice * tons,
quantity: qty,
amount: lashing.unitPrice * qty,
});
}
}
@@ -193,7 +205,7 @@ export function computeShipmentTotal(
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
: boxes;
} else if (cl.unit === "per_ton" || cl.unit === "per_item") {
qty = Number(values.cargoWeightTons || values.itemCount || 0);
qty = bulkQtyForUnit(values, cl.unit);
} else if (cl.unit === "flat") {
qty = 1;
}

View File

@@ -172,6 +172,8 @@ export interface BookingListFilter {
status?: string;
/** Comma-separated statuses (overrides `status` when set). */
statuses?: string;
/** 'true' = has a train assigned (allocated), 'false' = not yet assigned. */
assignedToSchedule?: "true" | "false";
/** ONE_TIME or GENERAL_CONTRACT. */
bookingType?: string;
/** CONTAINER or BULK. */

View File

@@ -105,6 +105,31 @@ services:
timeout: 3s
retries: 10
# Stand-in for the payment microservice. The API's reconcile-before-expire
# step (booking-batch.service.ts:3492) asks the gateway whether a late
# payment landed before it will expire an unpaid hold, and treats ANY error
# as "unverifiable" — which defers the expiry forever. PAYMENT_API_URL
# otherwise defaults to the real paymentcallback.triaplc.com, unreachable
# from here, so without this every expiry scenario hangs. See
# payment-mock/server.js.
payment-mock-e2e:
image: node:20-alpine
volumes:
- ./e2e/freight/payment-mock:/app:ro
working_dir: /app
command: ["node", "server.js"]
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:4500/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 3s
timeout: 3s
retries: 10
# Stand-in for https://etrade.gov.et — ETradeService's base URL is
# hardcoded (not env-configurable like Fayda's endpoints), so this is
# reached by DNS alias instead: the "etrade.gov.et" network alias below
@@ -166,6 +191,8 @@ services:
condition: service_healthy
etrade-mock-e2e:
condition: service_healthy
payment-mock-e2e:
condition: service_healthy
environment:
PORT: "3001"
DB_HOST: postgres-freight-e2e
@@ -202,6 +229,10 @@ services:
FAYDA_ENABLED: "true"
FAYDA_CLIENT_ID: e2e-fayda-client
FAYDA_AUTHORIZATION_ENDPOINT: http://fayda-mock-e2e:4400/authorize
# Without this the payment client calls the real (unreachable)
# paymentcallback.triaplc.com and every unpaid hold defers instead of
# expiring — see payment-mock/server.js.
PAYMENT_API_URL: http://payment-mock-e2e:4500
FAYDA_TOKEN_ENDPOINT: http://fayda-mock-e2e:4400/token
FAYDA_USERINFO_ENDPOINT: http://fayda-mock-e2e:4400/userinfo
FAYDA_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/callback

17
e2e/freight/.live-q.cjs Normal file
View 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})); });

View File

@@ -0,0 +1,101 @@
# Bulk scenario catalog — BS1BS40
Real-world bulk (PER_TON) and break-bulk (PER_ITEM) scenarios on the e2e
corridor `DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY` and its
export inverse. Companion to `SCENARIO_ENGINE_NOTES.md` (containers, S1S40).
**Fleet facts** (dev + e2e DB, verified 2026-08-01):
| Wagon | Capacity | Length | Tare |
| --- | --- | --- | --- |
| CW4 (wheat, autos, machinery) | 70 T | 13.976 m | 24.8 T |
| PW2 (grains) | 70 T | 17.066 m | 25.2 T |
Standard bulk train: **54 CW4 wagons / 3 780 T** cargo. Per-item math
(`train-capacity.util.ts`): items per wagon = min(floor(capacity ÷ per-item
tons), configured floor from `cargo_types.items_per_wagon_map`); items never
split across wagons; bulk partial offers are **whole wagons at full rated
payload only** (`sizePartialOfferWagons fullWagonsOnly`).
E2E cargo codes: `E2E_IMP_WHEAT` (PER_TON, CW4), `E2E_IMP_GRAINS` (PER_TON,
PW2), `E2E_IMP_AUTO` (PER_ITEM, CW4, floor 4/wagon — `seed-bulk-items.sql`),
`E2E_IMP_MACHINE` (PER_ITEM, CW4, no floor → tonnage-only).
Coverage column: spec that runs it, or **doc-only** (same engine path already
proven by the named spec — a bulk twin adds no new engine coverage), or
**gap** (engine contradicts the expectation — ticket, not a test).
`bulk_b1_*`, `bulk_b2_*`, `bulk_b3_*` are **authored but not yet run**
first execution may need assertion tuning.
---
## A. Fill · payment · waitlist (import wheat, PER_TON)
| # | Scenario | Expected | Coverage |
| --- | --- | --- | --- |
| BS1 | Six wheat bookings (560+420+420+420+1540+420 T = 54 wagons) fill the CW4 train in the first window; mixed USD/ETB, customs/self | All selected, all pay, 54/54 allocated, window FULL, schedule finalized | `bulk_import_full_train` |
| BS2 | Staff priority ordering: 1 960 T + 1 400 T + 700 T (58 w > 54); staff put the 700 T relief cargo first | Priority order wins: 700 T + 1 960 T reserved whole, 1 400 T gets a whole-wagon offer for the leftover 16 w | `bulk_b1_priority_expiry_refill` |
| BS3 | Reserved giant misses the 1 h pay window | EXPIRED; its 28 wagons return; refill round promotes the offered booking WHOLE (offer superseded) | `bulk_b1_priority_expiry_refill` |
| BS4 | Waitlisted wheat promoted on expiry | Waiting-list booking selected in the freed space, pays, rides | `bulk_import_waiting_expiry` |
| BS5 | Partial offer accepted → split | `is_split = true`, offered wagons allocated, remainder must rebook | `bulk_import_split_promote` |
| BS6 | Window reopens after under-fill | Second cycle opens; late bookings enter cycle 2 | `bulk_import_window_reopen` |
| BS7 | Booking on a day with no open window | Rejected at creation ("booking window") | `bulk_critical_matrix` |
| BS8 | Currency per booking: USD and ETB invoices on one train | Each invoice carries its booking's currency | `bulk_import_full_train` |
## B. Capacity axes · wagon types · giants (PER_TON)
| # | Scenario | Expected | Coverage |
| --- | --- | --- | --- |
| BS9 | 4 000 T giant alone on a 54-wagon train | Full-consist offer 3 780 T, gateway settle applies split, train FULL from one booking | `bulk_critical_matrix` |
| BS10 | Giant's 220 T remainder rebooks | 100 T attempt rejected ("must take the whole"); exactly 220 T accepted | `bulk_critical_matrix` |
| BS11 | Wheat rides CW4 only, grains ride PW2 only | `expectWagonType` CW4 for wheat / PW2 for grains on their trains | `export_ledger_day` + `bulk_b1` (CW4 assert) |
| BS12 | PW2 length tax: 17.066 m wagons on a 760 m board | 44 slots by length vs CW4's 54 — same tonnage needs a longer consist | doc-only (`export_ledger_day` runs the 37-wagon PW2 board) |
| BS13 | Gross weight = tare + cargo (PW2: 37 × 95.2 = 3 522 T ≈ the 3 500 T pull limit) | Weight axis binds before slots; overbooking by tare fraction impossible | unit-tested in `train-capacity.util` + `export_ledger_day` |
| BS14 | Tolerance spent only on a whole booking, never sizing a split | Split offers budget against base caps | doc-only (`booking-batch.service.ts:4109`; container twin g2) |
| BS15 | Two bulk bookings, one over-weight last wagon | Batch trims to whole wagons at full payload — no part-loaded squeeze into leftover pull weight | doc-only (`sizePartialOfferWagons fullWagonsOnly`) |
| BS16 | Sub-corridor 700 T (NAGAD→MOJO) shares the through-train with a 1 400 T DJIB_PORT→KALITY booking | Both allocated to the same schedule | `bulk_critical_matrix` |
## C. Break-bulk PER_ITEM (autos, machinery — NEW)
| # | Scenario | Expected | Coverage |
| --- | --- | --- | --- |
| BS17 | 16 automobiles @ 2.5 T (40 T). Tonnage alone says 1 wagon; floor says 4/wagon | **4 wagons** allocated — physical floor binds, rated capacity rides empty | `bulk_b2_per_item_floor` |
| BS18 | 12 machines @ 20 T (240 T), no configured floor | floor(70/20) = 3 per wagon → **4 wagons** — tonnage fallback binds | `bulk_b2_per_item_floor` |
| BS19 | 216 automobiles (540 T) — exactly 54 wagons | Whole booking fits, no split; train FULL from one break-bulk booking | `bulk_b2_per_item_floor` |
| BS20 | Giant: 240 automobiles (60 wagons) on a 54-wagon train | Whole-wagon offer of 216 autos; `is_split = true`; 54/54; FULL | `bulk_b3_per_item_giant_quantities` |
| BS21 | Giant's remainder: 24 autos outstanding | 10-auto attempt rejected ("must take the whole"); exactly 24 accepted | `bulk_b3_per_item_giant_quantities` |
| BS22 | hazardousQuantity 12 on a 10-item line | **Engine CLAMPS to 10, returns 201 — no rejection** (same gap as container S40). Spec asserts the clamp so the gap is visible | `bulk_b3_per_item_giant_quantities` |
| BS23 | reeferQuantity 3 on an 8-item line | Stored on the booking (`bulk_reefer_quantity = 3`), reefer surcharge applies | `bulk_b3_per_item_giant_quantities` |
| BS24 | One item heavier than a whole wagon (80 T machine on a 70 T CW4) | Engine still charges 1 wagon per item (`ponytail:` note in `bulkItemWagonsRequired`) — creation-time rejection does NOT exist | **gap** — ticket, not a test |
## D. Export bulk (KALITY → DJIB_PORT)
| # | Scenario | Expected | Coverage |
| --- | --- | --- | --- |
| BS25 | Sesame/grain export fills the PW2 board | Whole-booking placement, board FULL | `bulk_export_full_train` |
| BS26 | Export FCFS: space taken by earlier holds | Later booking sees reduced space | `bulk_export_fcfs_space` |
| BS27 | Export pay-or-lose: hold lapses at deadline | Space returns, next customer takes it | `bulk_export_pay_or_lose` |
| BS28 | Export whole-or-nothing (no split) | Oversized booking 409s with a sized message | `bulk_export_matrix` |
| BS29 | Export matrix: currencies × customs | Per-combination invoice + clearance behavior | `bulk_export_matrix` |
| BS30 | "Export never splits" is flag-dependent | Assert `FREIGHT_EXPORT_SPLIT !== "true"` or the suite is vacuous | noted in `SCENARIO_ENGINE_NOTES.md` |
| BS31 | Export day ledger: who booked/rode/expired/refused | Ledger report written per day | `export_ledger_day` |
## E. Corridor ops · intercity · disruptions
| # | Scenario | Expected | Coverage |
| --- | --- | --- | --- |
| BS32 | Bulk intercity ride-along (MOJO→KALITY, DOMESTIC, dateless) accepted onto the import train's free leg | Pay window opens on accept; paid + linked | `bulk_critical_matrix` |
| BS33 | Checkpoint-by-checkpoint corridor run; bookings ARRIVED at terminal | Statuses walk IN_TRANSIT → ARRIVED; ≥54 wagon-movement ledger rows | `bulk_import_full_train` |
| BS34 | Mid-corridor auto-unload: booking destined MOJO on a KALITY train | Checkpoint at MOJO auto-unloads it (`booking-journey.service.ts:261`) | container twin `g6_corridor` — same engine path |
| BS35 | Schedule cancelled after bulk allocation | Frozen `wagon_allocation_snapshot`, bookings re-pool still PAID | container twin `g7_disruptions` — same engine path |
| BS36 | Paid bulk booking, yard short of CW4 → transfer request | WAITING_FOR_WAGON → PARTIALLY_FULFILLED → FULFILLED → placed | `fleet_wagon_transfer` |
## F. Customs tail · validation
| # | Scenario | Expected | Coverage |
| --- | --- | --- | --- |
| BS37 | Customs bookings: gatepass → T1 → dispatch → T1 close → risk → second duty → release → final invoice | Full milestone tail COMPLETED in order | `bulk_import_full_train` |
| BS38 | Self-clearance bookings arrive with NO customs tail | Zero T1_CLOSED milestones | `bulk_import_full_train` |
| BS39 | `importReleaseGranted` not gated on second duty | Release grantable with SECOND_DUTY_PAID pending — **known gap** | `SCENARIO_ENGINE_NOTES.md` §S36 |
| BS40 | Out-of-order checkpoint relocates rolling stock silently | No sequence guard in `recordCheckpoint`**known bug** | `SCENARIO_ENGINE_NOTES.md` §S29 |

View 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/*

View File

@@ -100,6 +100,67 @@ Full map in `cypress/fixtures/users.json`.
the DB at the start of each test: switching origin between tests reloads
the spec bundle, so module-level variables do NOT survive across tests.
### The 40-scenario suite (`flows/g1_*` … `flows/g10_*`)
S1S40 from the scenario document, one file per group after Group 1:
| File | Scenarios | Subject |
| --- | --- | --- |
| `g1_s1_expiry_promotes_waitlist.cy.ts` | S1 | expiry frees exactly the waitlist's space |
| `g1_s2_exact_fill.cy.ts` | S2 | four bookings fill the train to the slot |
| `g1_s3_underfill_day_stays_open.cy.ts` | S3 | under-filled day stays bookable |
| `g1_s4_split_closes_gap.cy.ts` | S4 | a split closes the last gap |
| `g1_s5_cascading_expiry.cy.ts` | S5 | one expiry cascades into a second promotion |
| `g1_s6_s8_offers_and_priority.cy.ts` | S6S8 | declined split, government preemption, priority tiers |
| `g2_weight.cy.ts` | S9S12 | weight vs slots, and the tolerance rules |
| `g3_export.cy.ts` | S13S18 | export FCFS, whole-or-nothing |
| `g4_multi_schedule.cy.ts` | S19S21 | two trains on one day |
| `g5_waitlist.cy.ts` | S22S24 | recovery: rebooking, split remainders, queue walking |
| `g6_corridor.cy.ts` | S25S29 | the run: alighting, tracking, checkpoints |
| `g7_disruptions.cy.ts` | S30S33 | cancel, wagon shortage, breakage, under-filled dispatch |
| `g8_import_customs.cy.ts` | S34S37 | the clearance chain |
| `g9_delivery.cy.ts` | S38S39 | self-haul trucks and last-mile |
| `g10_validation.cy.ts` | S40 | line validation and the parked re-priced booking |
**Read `SCENARIO_ENGINE_NOTES.md` before changing any of these.** Five
scenarios describe behaviour the engine does not implement (out-of-order
checkpoints, second-duty gating, hazardous/reefer clamping) or invert what it
does (mid-corridor intercity). Those are written as a passing test of CURRENT
behaviour plus an adjacent `it.skip` naming the desired behaviour — un-skipping
one is the definition of done for the corresponding fix, not a test repair.
Three specs are also flag- or policy-dependent and say so in their headers:
`g3_export` needs `FREIGHT_EXPORT_SPLIT` off, and `g4_multi_schedule` asserts
the whole-placement policy (S20) rather than the fill-first one (S21).
### Group 1 conventions (`flows/g1_*.cy.ts`)
The visual counterpart to the corridor suite — helpers in `flows/g1-utils.ts`,
arrange-data in `fixtures/seed-g1-train.sql` (run it AFTER
`seed-import-corridor.sql`).
Two things make these different from the older flow specs:
- **A 53-wagon BUILT train** (`TRN-G1-1`), not a loco pair. A loco-pair
schedule cannot hold 53: `syncScheduleMaxWagons` recomputes `max_wagons`
from locomotive length (`floor(760 / 13.966) = 54` on this corridor). A
built train's physical consist wins outright — see
`booking-batch.service.ts:4152`. The consist staff marshal IS the capacity.
- **The configuration phase and every capacity verdict run through the UI**:
the consist is seen in the Train Builder, the schedule is created through
the real "New schedule" form, the batch is run from the
`Doc review complete — run batch` button, and FULL/NOT FULL is read off the
batch board's Priority Tracking tab — which renders the literal
`Capacity line · 53/53 wagons · FULL` divider plus `In the batch` /
`Waiting list` / `Expired` lanes.
Bulk cargo still goes through the API (`bookContainers`): a 30-wagon booking
is 30-60 ISO-number inputs, which tests the form rather than the engine. Each
scenario books its ONE small booking visually via
`bookContainersVisually()`. **Payment is always API-driven** — the portal has
no mock payment path; "Pay now" redirects off-origin to a real gateway, which
Cypress cannot follow.
## Extending
Deep module flows (booking wizard → staff approval → scheduling → billing)

View File

@@ -0,0 +1,185 @@
# Scenario ↔ engine reconciliation (S1S40)
Verified against the API source while writing the Group 1 specs. Every claim
below carries a `file:line`; re-check them before trusting this file, it is a
snapshot of the code as of the freight_feature/usermanagement branch.
The point of this file: several scenarios in the original 40-case document
describe behaviour the engine does **not** implement. Those are not spec bugs
to code around — they are either product gaps worth a ticket, or scenarios
whose premise needs restating. Writing a green test against a premise the code
contradicts is worse than having no test.
## Capacity: 53 vs 54
The scenarios are written for a **53-wagon** train. A loco-pair schedule cannot
hold 53 on this corridor: `syncScheduleMaxWagons` recomputes `max_wagons` as
`floor(locoLength / shortest active wagon length)`, and `seed-import-corridor.sql`
deliberately pins that at `floor(760 / 13.966) = 54`.
A **built train** is exempt — `booking-batch.service.ts:4152`:
const maxWagons = physicalWagons ?? capacityLimits(loco).base.wagons;
So Group 1 runs on `TRN-G1-1`, a 53-wagon built consist (`seed-g1-train.sql`).
Note `train-capacity.util.ts:107` calls 53 "the marshalling figure" and says the
slot count is "never a fixed 53" — the number is real, it just has to come from
a consist rather than from locomotive length.
## Confirmed — scenario matches the engine
| Scenario | Engine fact | Where |
| --- | --- | --- |
| S1/S5 cascading promotion | `fillFromWaitingList` loops up to 10 rounds until a pass reserves nothing | `booking-batch.service.ts:2788` |
| S4/S6 split offers | `booking_batch_offers`, `status` defaults `OFFERED`, `offered_wagons` | `booking-batch-offer.entity.ts:27,46,73` |
| S9 heavy VGM | NW5 tare **22.4T** → 2×28 + 22.4 = 78.4T gross, exactly as the scenario computes | `train-capacity.util.ts:16,82` |
| S11 split ignores tolerance | tolerance "spendable only by admitting a booking whole, never by a split" | `train-capacity.util.ts:58`, `booking-batch.service.ts:4109` |
| S13 holds occupy space | reserved = `SELECTED_FOR_BATCH`/`AWAITING_PAYMENT`, subtracted from capacity until the deadline lapses | `bookings.repository.ts:1372`, `booking-batch.service.ts:4487` |
| S18 oversized export rejected | 409 at `requestOperation` with a sized message | `booking-transition.service.ts:1016`, `booking-batch.service.ts:900` |
| S25 per-station arrival | checkpoint at an intermediate yard auto-unloads bookings destined there | `booking-journey.service.ts:261` |
| S30 cancel snapshot | `train_schedules.wagon_allocation_snapshot` jsonb, frozen before wagons are released; holds wagon numbers + per-slot weights | `train-scheduling.service.ts:3984`, `4647` |
| S31 wagon transfer | statuses `PENDING/PARTIALLY_FULFILLED/FULFILLED/CLOSED_SHORT/CANCELLED` | `packages/types/src/freight/index.ts:353` |
| S31 movement ledger | `wagon_movements`, kind `EMPTY_REPOSITION`, carries `transfer_request_id` | `wagon-movement.entity.ts:16,49,55` |
| S37 risk history | `clearance_milestones.metadata``riskLevel` + append-only `riskHistory` (oldest-first) | `clearance-milestone.entity.ts:38`, `clearance-milestone.service.ts:250` |
| S38 max 2 per truck | `MAX_CONTAINERS_PER_TRUCK = 2`, error `A truck carries at most 2 containers` | `truck-load.util.ts:5,36` |
| S38 duplicate container | 409 `Container X is already loaded onto another truck` | `truck-load.util.ts:52` |
| S40 re-price parks the booking | batch pool is `status = 'PAID'` exactly, so `PRICE_CHANGED_PENDING_CONFIRM` holds zero capacity | `bookings.repository.ts:1136` |
## Corrected — same intent, different mechanism
**S7 government.** The scenario says government "jumps the queue" via an
institution field and a +50,000 priority. Two corrections:
- the bonus is real (`GOVERNMENT_PRIORITY_BONUS = 50_000`,
`rule-engine.service.ts:258`) but keys off `bookings.is_government`, set from
`contracts.is_government` — there is no institution lookup;
- government does not merely outrank, it **preempts**: it displaces the
lowest-priority already-reserved commercial booking and rides unpaid
(`preemptForGovernment`, and see `flows/government_preemption.cy.ts`).
Created via `POST /bookings` + `POST /bookings/:id/government-expedite`
against a `kind='government'` company, not through the contract wizard.
**S8 priority tiers.** `USD_PAYER` / `RAIL_AND_FORWARDING` no longer exist as
priority types — migration `1783000000000-ReplacePriorityRulesWithPriorityConfigs`
replaced `priority_rules` with `priority_configs`, whose `type` is only
`WAGON | CURRENCY | CUSTOMS`, scored by wagon-count range
(`priority-config.entity.ts`, applied at `rule-engine.service.ts:247`). The
scenario's ordering intent survives as: a CURRENCY(USD) config, a CUSTOMS
config, and a plain booking that matches neither.
**S3 "day stays open".** Best asserted through
`GET /bookings/:id/day-availability?date=``{ fits, freeWagons, trainsForDay }`
(`booking-transition.service.ts:1078`), which is what the portal calendar reads.
## Contradicted — the engine does NOT do this
These need a product decision before a test can be written honestly.
**S26 — mid-corridor intercity is NOT blocked.** The scenario expects
Dire Dawa → GMP (both Ethiopian) to be rejected as disabled intercity. It is
the opposite: DOMESTIC is a first-class direction derived from yard countries
(`bookings.service.ts:270`), and the guard rejects *non*-Ethiopian endpoints —
`'Intercity bookings only run between Ethiopian yards'`
(`bookings.service.ts:218`). The only related rejections are
`'Intercity bookings cannot pin a date or schedule'` (`:771`) and
`'No route passes through this origin and destination in order'`.
→ Either the scenario is stale, or intercity was meant to be disabled and is
not. Ticket, not a test.
**S29 — there is no out-of-order checkpoint guard.** `recordCheckpoint`
(`train-scheduling.service.ts:3681`) validates only: schedule exists, status is
DISPATCHED, and the station is on the route. Nothing compares `sequenceNo`
against the highest already logged, so "Arrived Adama" before "Passed Meiso" is
accepted. `currentSequenceNo` is a `Math.max` (`:3643`) so the timeline does not
visibly regress — which *masks* the real damage: the position fix at `:3741`
moves the locomotives, every wagon on the schedule, and the built train to that
station's yard. A stray backward checkpoint silently relocates rolling stock.
→ Real bug. Worth a spec that documents current behaviour as `.skip` plus a
ticket, rather than an assertion that pretends the guard exists.
**S36 — `importReleaseGranted` is NOT gated on the second duty.** The scenario
expects release to stay false until the second duty settles. `importReleaseGranted`
is computed from the `IMPORT_RELEASE_GRANTED` milestone
(`booking-clearance.service.ts:368`), which completes purely by uploading a file
with fieldname `import_release`. `completeByDocTrigger`
(`clearance-milestone.service.ts:430`) performs **no** precondition check, and
`assertPriorCompleteOnMilestones` only walks *pre-booking* milestones
(`clearance-workflow.service.ts:119`) — so all 17 post-`DO_COLLECTED` codes,
including the whole `T1_CLOSED → RISK_ASSIGNED → SECOND_DUTY_* →
IMPORT_RELEASE_GRANTED` tail, are unordered.
→ Release can be granted with `SECOND_DUTY_PAID` still PENDING. Real gap.
**S40 — hazardous quantity is NOT rejected, it is silently clamped.** The
scenario expects `hazardousQuantity=12` on a `quantity=10` line to be rejected.
The DTO has `@Min(0)` and no `@Max` (`create-booking.dto.ts:60`), and the
repository clamps to `0..quantity` (`bookings.repository.ts:217`): the booking
is created 201 with the value truncated, no warning. Reefer behaves identically.
Note `returnQuantity` — same layer, same shape of data — *does* throw
(`contract-booking.service.ts:1740`), so the pattern exists and these two just
do not use it.
→ Real gap. A test asserting rejection would fail today.
**S40 — reefer is NOT derived from container type.** The scenario expects
`reeferQuantity` forced to 0 for DRY types. No such logic exists; a DRY type
with `reeferQuantity > 0` is an explicitly supported state and applies the
surcharge anyway (`booking.entity.ts:384`, `booking-pricing.service.ts:402`).
## Flag-dependent — assert the flag, or the test is vacuous
**S13/S14/S18 "export never splits"** holds only while
`FREIGHT_EXPORT_SPLIT !== "true"` (`booking-batch.service.ts:394`). With the
flag on, `isSplitEligible` admits EXPORT (`:2558`) and `tryExportPartialOffer`
(`:1240`) runs. The export specs must assert the flag is off, or they silently
stop testing whole-or-nothing the day someone flips it.
## Known type hole (not scenario-blocking)
Last-mile and first-mile billing write the literal `'last_mile'` / `'first_mile'`
cast past the type checker (`last-mile-invoice.service.ts:41`), while
`Freight.InvoiceSource.LastMile` is `"lastmile"`. `invoices.source` is a plain
varchar with no constraint, so both spellings persist. Writes and reads agree
within each module so billing works — but S39's "invoice source LASTMILE"
assertion must match `'last_mile'`, not the enum value.
## Two engine changes the committed specs predate
Both were found by running the suite, and both broke EVERY scenario until
fixed. They are recorded here because neither is visible from the scenario
document — only from the API source.
**1. Every contract booking is born in the clearance gate.**
`contract-booking.service.ts:211` — *"EVERY contract booking clears per booking
now — both contract kinds, both paths, intercity included."* A booking is
created in `AWAITING_DOCUMENTS` regardless of whether customs clearance is
enabled, so `bookContainers` followed by `acceptOperation` always 409s with
`Cannot perform this action on status "AWAITING_DOCUMENTS". Allowed:
OPERATION_REQUEST_PENDING`.
The gate is upload → GL approve → finalize → customer proceeds with the day.
`clearToOperationRequestPending` (import-utils) runs it; `bookAndClear`
(g1-utils) wraps book + clear + accept and is what the g-specs use.
NOTE: the pre-existing corridor specs (e.g. `import_full_train.cy.ts`) still
call `acceptOperation` directly and fail for this reason — 3 passing / 10
failing when last run. They predate the gate and need the same treatment.
**2. An unpaid hold cannot expire without a reachable payment gateway.**
Before expiring a reservation the engine asks the gateway whether a late
payment landed (`booking-batch.service.ts:3484-3506`), and treats ANY error as
`unverifiable: true` — deferring the expiry rather than risk expiring a
customer who paid:
[BATCH] expire deferred for BK-… — settlement unverifiable at the
gateway; retrying next settle tick
`PAYMENT_API_URL` defaults to the real `https://paymentcallback.triaplc.com`
(`payment-client.service.ts:25`), unreachable from e2e, so every expiry
deferred forever. Six scenarios turn on an expiry: G1·S1, G1·S5, G1·S6,
G3·S16, G5·S22, G5·S24.
Fixed with a stand-in service — `e2e/freight/payment-mock/server.js`, wired as
`payment-mock-e2e` in `docker-compose.e2e.yaml` with
`PAYMENT_API_URL: http://payment-mock-e2e:4500`. It answers
`POST /payments/reconcile` with `{paid:false, unverifiable:false}` so the
engine gets a definite "no payment exists" and expires the hold as designed.
A stack that does NOT point PAYMENT_API_URL at a reachable service will hang
on every expiry assertion.

View File

@@ -24,8 +24,15 @@ export default defineConfig({
screenshotOnRunFailure: true,
viewportWidth: 1440,
viewportHeight: 900,
defaultCommandTimeout: 10000,
requestTimeout: 15000,
// Generous across the board: these journeys drive the batch engine, whose
// window transitions are settled by a 10s server tick, and a single step
// can wait on several of them. Two minutes is long enough that a real
// timeout means something is genuinely stuck rather than merely slow.
defaultCommandTimeout: 120000,
requestTimeout: 120000,
responseTimeout: 120000,
pageLoadTimeout: 120000,
taskTimeout: 120000,
retries: { runMode: 1, openMode: 0 },
env: {
apiUrl: process.env.CYPRESS_API_URL ?? "http://localhost:3101",
@@ -55,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

View File

@@ -0,0 +1,61 @@
/**
* Shared helper for the PER_ITEM break-bulk specs (bulk_b2 / bulk_b3).
* Cargo types come from fixtures/seed-bulk-items.sql.
*/
import { apiPost, customer, db } from "./import-utils";
/** Book a PER_ITEM break-bulk line under the suffix's seeded contract. */
export function bookBulkItems(opts: {
suffix: string;
cargoCode: "E2E_IMP_AUTO" | "E2E_IMP_MACHINE";
items: number;
tons: number;
scheduledDate?: string;
hazardousQuantity?: number;
reeferQuantity?: number;
expectFailure?: string | RegExp;
}) {
db<{ id: string; cargo_type_id: string }>(
`SELECT ct.id,
(SELECT t.id FROM freight.cargo_types t WHERE t.code = $2) AS cargo_type_id
FROM freight.contracts ct
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
ORDER BY ct.created_at DESC LIMIT 1`,
[opts.suffix, opts.cargoCode],
).then(({ rows }) => {
expect(rows, `seeded contract *-${opts.suffix}`).to.have.length(1);
expect(rows[0].cargo_type_id, `${opts.cargoCode} seeded`).to.be.a("string");
apiPost(
customer,
`/api/contracts/${rows[0].id}/bookings`,
{
...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}),
bulkLines: [
{
cargoTypeId: rows[0].cargo_type_id,
itemCount: opts.items,
cargoWeightTons: opts.tons,
...(opts.hazardousQuantity != null
? { hazardousQuantity: opts.hazardousQuantity }
: {}),
...(opts.reeferQuantity != null ? { reeferQuantity: opts.reeferQuantity } : {}),
},
],
cargoFreeText: `E2E break-bulk ${opts.cargoCode}`,
},
!opts.expectFailure,
).then((res) => {
if (opts.expectFailure) {
expect(res.status, `${opts.suffix} booking rejected`).to.be.within(400, 422);
if (opts.expectFailure instanceof RegExp) {
expect(JSON.stringify(res.body)).to.match(opts.expectFailure);
} else {
expect(JSON.stringify(res.body)).to.include(opts.expectFailure);
}
} else {
expect(res.status, `${opts.suffix} booking created`).to.be.oneOf([200, 201]);
}
});
});
}

View File

@@ -0,0 +1,122 @@
/**
* BULK B1 — priority ordering + expiry refill (BS2/BS3 in BULK_SCENARIOS.md).
*
* Day D+28, 54-wagon CW4 wheat train, three bookings that cannot all fit:
*
* BP1 1 960 T = 28 wagons (commercial giant)
* BP2 1 400 T = 20 wagons (commercial)
* BP3 700 T = 10 wagons (relief cargo — staff put it FIRST)
*
* 58 wagons chase 54. With BP3 forced to the top of the order the batch
* reserves BP3 + BP1 whole (38 w) and leaves BP2 a whole-wagon offer for the
* remaining 16. Then BP1 misses its pay window: its 28 wagons return and the
* refill round must promote BP2 WHOLE — the 16-wagon offer is superseded.
*
* ⚠ Authored, not yet run — see BULK_SCENARIOS.md.
*/
import {
acceptOperation,
bookBulk,
clearToOperationRequestPending,
closeBookingWindow,
completeDocReview,
createImportSchedule,
departureAt,
eatDayStr,
ensureCorridorRoute,
expectWagonType,
forceReservationExpiry,
forceWindowOpen,
markPaid,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
setPriority,
withBooking,
withSchedule,
} from "./import-utils";
const DEPARTURE = departureAt(28);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
describe("bulk b1: staff priority decides who rides; expiry refill promotes the offered booking whole", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
(["BP1", "BP2", "BP3"] as const).forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix), freight: "BULK" }),
);
});
it("operations prepares the D+28 wheat train with an open window", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
kind: "bulk",
});
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
});
it("three wheat bookings (28+20+10 wagons) enter the window; staff rank the relief cargo first", () => {
bookBulk({ suffix: "BP1", tons: 1960, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending("BP1", BOOKING_DAY);
acceptOperation("BP1");
bookBulk({ suffix: "BP2", tons: 1400, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending("BP2", BOOKING_DAY);
acceptOperation("BP2");
bookBulk({ suffix: "BP3", tons: 700, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending("BP3", BOOKING_DAY);
acceptOperation("BP3");
setPriority("BP3", 1);
setPriority("BP1", 2);
setPriority("BP2", 3);
});
it("batch reserves BP3 + BP1 whole; BP2 gets a whole-wagon offer for the 16-wagon leftover", () => {
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
pollBookingStatus("BP3", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
pollBookingStatus("BP1", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
withBooking("BP2", (b) => {
pollDb<{ status: string; offered_wagons: string }>(
"BP2 open partial offer",
`SELECT status, offered_wagons FROM freight.booking_batch_offers
WHERE booking_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[b.id],
(row) => row?.status === "OFFERED" && Number(row?.offered_wagons) === 16,
15,
);
});
});
it("BP3 pays and rides CW4; BP1 misses the pay window and EXPIRES", () => {
markPaid("BP3");
pollAllocations("BP3", 10);
expectWagonType("BP3", "CW4", 10);
forceReservationExpiry("BP1");
pollBookingStatus("BP1", "EXPIRED", 20);
});
it("refill round promotes BP2 WHOLE into the freed 28 wagons — the 16-wagon offer is superseded", () => {
pollBookingStatus("BP2", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 30);
markPaid("BP2");
pollAllocations("BP2", 20);
withBooking("BP2", (b) => {
expect(b.is_split, "BP2 rides whole, not split").to.not.eq(true);
});
});
});
export {};

View File

@@ -0,0 +1,147 @@
/**
* BULK B2 — break-bulk PER_ITEM wagon math (BS17BS19 in BULK_SCENARIOS.md).
*
* Cargo from seed-bulk-items.sql, riding the CW4 fleet (70 T / 24.8 T tare):
*
* E2E_IMP_AUTO automobiles, items_per_wagon_map floor = 4 per CW4
* E2E_IMP_MACHINE machinery, NO floor → tonnage-only fallback
*
* Three verdicts of bulkItemWagonsRequired, end to end:
* BA1 16 autos @2.5 T (40 T) → floor binds: 4 wagons (tonnage said 1)
* BA2 12 machines @20 T (240 T) → tonnage binds: floor(70/20)=3/wagon → 4 wagons
* BA3 216 autos (540 T) → exactly 54 wagons — FULL from one booking
*
* ⚠ Authored, not yet run — see BULK_SCENARIOS.md.
*/
import { bookBulkItems } from "./bulk-items-utils";
import {
acceptOperation,
clearToOperationRequestPending,
closeBookingWindow,
completeDocReview,
createImportSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
expectWagonType,
forceWindowOpen,
markPaid,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(30);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const FULL_DEPARTURE = departureAt(31);
const FULL_DAY = eatDayStr(FULL_DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
describe("bulk b2: PER_ITEM floor vs tonnage wagon math on the CW4 fleet", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
cy.task("db:seedFile", "seed-bulk-items.sql");
(["BA1", "BA2", "BA3"] as const).forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix), freight: "BULK" }),
);
});
it("operations prepares the D+30 break-bulk train with an open window", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
resetCorridorDay(FULL_DEPARTURE);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
kind: "bulk",
});
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
});
it("BS17 — 16 autos (40 T): the 4-per-wagon floor binds → 4 wagons, not 1", () => {
bookBulkItems({
suffix: "BA1",
cargoCode: "E2E_IMP_AUTO",
items: 16,
tons: 40,
scheduledDate: BOOKING_DAY,
});
clearToOperationRequestPending("BA1", BOOKING_DAY);
acceptOperation("BA1");
});
it("BS18 — 12 machines @20 T: no floor, tonnage fallback → 3 per wagon → 4 wagons", () => {
bookBulkItems({
suffix: "BA2",
cargoCode: "E2E_IMP_MACHINE",
items: 12,
tons: 240,
scheduledDate: BOOKING_DAY,
});
clearToOperationRequestPending("BA2", BOOKING_DAY);
acceptOperation("BA2");
});
it("batch reserves both; payment allocates exactly 4 + 4 CW4 wagons", () => {
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
(["BA1", "BA2"] as const).forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
markPaid("BA1");
pollAllocations("BA1", 4);
expectWagonType("BA1", "CW4", 4);
markPaid("BA2");
pollAllocations("BA2", 4);
expectWagonType("BA2", "CW4", 4);
});
it("BS19 — 216 autos (540 T) = exactly 54 wagons: FULL from one break-bulk booking, no split", () => {
createImportSchedule({
departure: FULL_DEPARTURE,
locoPair: ["LOCO-IMP-25", "LOCO-IMP-26"],
kind: "bulk",
});
withSchedule(FULL_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
bookBulkItems({
suffix: "BA3",
cargoCode: "E2E_IMP_AUTO",
items: 216,
tons: 540,
scheduledDate: FULL_DAY,
});
clearToOperationRequestPending("BA3", FULL_DAY);
acceptOperation("BA3");
withSchedule(FULL_DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
pollBookingStatus("BA3", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
markPaid("BA3");
pollAllocations("BA3", 54);
withBooking("BA3", (b) => {
expect(b.is_split, "BA3 whole, not split").to.not.eq(true);
});
withSchedule(FULL_DEPARTURE, (s) => {
endPaymentPhase(s.id);
pollDb<ScheduleRow>(
"full-day schedule FULL + DONE",
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
);
});
});
});

View File

@@ -0,0 +1,181 @@
/**
* BULK B3 — PER_ITEM giant split + line quantities (BS20BS23 in
* BULK_SCENARIOS.md).
*
* BG1 240 automobiles (600 T) on a 54-wagon CW4 train (floor 4/wagon
* → needs 60 wagons). Bulk partial offers are WHOLE wagons only →
* offer = 54 wagons / 216 autos. Gateway settle applies the split,
* the train is FULL from one break-bulk booking, and the 24-auto
* outstanding must be rebooked EXACTLY on a later train.
* BQ1 hazardousQuantity 12 on a 10-item line: the engine CLAMPS to 10
* and returns 201 (documented gap — same as container S40; this
* spec pins the CURRENT behaviour so a future fix flips it loudly).
* BQ2 reeferQuantity 3 on an 8-item line is stored on the booking.
*
* ⚠ Authored, not yet run — see BULK_SCENARIOS.md.
*/
import { bookBulkItems } from "./bulk-items-utils";
import {
acceptOperation,
clearToOperationRequestPending,
closeBookingWindow,
completeDocReview,
createImportSchedule,
db,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
forceWindowOpen,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
settleViaGateway,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const GIANT_DEPARTURE = departureAt(32);
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
const REMAINDER_DEPARTURE = departureAt(33);
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
describe("bulk b3: per-item giant gets a whole-wagon offer; line quantities clamp/store", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
cy.task("db:seedFile", "seed-bulk-items.sql");
(["BG1", "BQ1", "BQ2"] as const).forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix), freight: "BULK" }),
);
});
it("operations prepares the D+32 giant train and the D+33 remainder train", () => {
ensureCorridorRoute();
resetCorridorDay(GIANT_DEPARTURE);
resetCorridorDay(REMAINDER_DEPARTURE);
createImportSchedule({
departure: GIANT_DEPARTURE,
locoPair: ["LOCO-IMP-27", "LOCO-IMP-28"],
kind: "bulk",
});
withSchedule(GIANT_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
});
it("BS20 — 240 autos need 60 wagons: whole-consist offer of 216 autos / 54 wagons, split applied", () => {
bookBulkItems({
suffix: "BG1",
cargoCode: "E2E_IMP_AUTO",
items: 240,
tons: 600,
scheduledDate: GIANT_DAY,
});
clearToOperationRequestPending("BG1", GIANT_DAY);
acceptOperation("BG1");
withSchedule(GIANT_DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
pollBookingStatus("BG1", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
withBooking("BG1", (b) => {
pollDb<{ status: string; offered_wagons: string }>(
"BG1 whole-wagon partial offer",
`SELECT status, offered_wagons FROM freight.booking_batch_offers
WHERE booking_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[b.id],
(row) => row?.status === "OFFERED" && Number(row?.offered_wagons) === 54,
15,
);
});
settleViaGateway("BG1");
pollAllocations("BG1", 54);
withBooking("BG1", (b) => {
expect(b.is_split, "BG1 is split").to.eq(true);
});
withSchedule(GIANT_DEPARTURE, (s) => {
endPaymentPhase(s.id);
pollDb<ScheduleRow>(
"giant train FULL from one break-bulk booking",
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
);
});
});
it("BS21 — the 24-auto outstanding must be rebooked EXACTLY on the later train", () => {
createImportSchedule({
departure: REMAINDER_DEPARTURE,
locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"],
kind: "bulk",
});
withSchedule(REMAINDER_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
bookBulkItems({
suffix: "BG1",
cargoCode: "E2E_IMP_AUTO",
items: 10,
tons: 25,
scheduledDate: REMAINDER_DAY,
expectFailure: "must take the whole",
});
bookBulkItems({
suffix: "BG1",
cargoCode: "E2E_IMP_AUTO",
items: 24,
tons: 60,
scheduledDate: REMAINDER_DAY,
});
clearToOperationRequestPending("BG1", REMAINDER_DAY);
});
it("BS22 — hazardousQuantity 12 on a 10-item line is CLAMPED to 10, not rejected (pins the gap)", () => {
bookBulkItems({
suffix: "BQ1",
cargoCode: "E2E_IMP_AUTO",
items: 10,
tons: 25,
scheduledDate: REMAINDER_DAY,
hazardousQuantity: 12,
});
withBooking("BQ1", (b) => {
db<{ bulk_hazardous_quantity: string }>(
`SELECT bulk_hazardous_quantity FROM freight.bookings WHERE id = $1`,
[b.id],
).then(({ rows }) => {
// Engine clamps to 0..quantity (bookings.repository.ts) — a future
// fix that rejects instead will fail HERE first. See BS22.
expect(Number(rows[0].bulk_hazardous_quantity), "clamped hazmat").to.eq(10);
});
});
});
it("BS23 — reeferQuantity 3 on an 8-item line is stored on the booking", () => {
bookBulkItems({
suffix: "BQ2",
cargoCode: "E2E_IMP_AUTO",
items: 8,
tons: 20,
scheduledDate: REMAINDER_DAY,
reeferQuantity: 3,
});
withBooking("BQ2", (b) => {
db<{ bulk_reefer_quantity: string }>(
`SELECT bulk_reefer_quantity FROM freight.bookings WHERE id = $1`,
[b.id],
).then(({ rows }) => {
expect(Number(rows[0].bulk_reefer_quantity), "reefer stored").to.eq(3);
});
});
});
});
export {};

View 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 = ["AB", "BC", "CD", "DE", "EF"] 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);
}
});
});
}

View 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,
),
);
}

View File

@@ -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),
),
);
});
});

View 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);
});
});

View File

@@ -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]);
});
});

View File

@@ -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),
),
);
});
});

View File

@@ -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]);
});
});

View File

@@ -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]);
});
});

View 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));
});
});

View File

@@ -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),
);
});
});
});

View 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]);
});
});

View File

@@ -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);
}),
),
);
});
});

View File

@@ -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]);
});
});

View File

@@ -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),
),
);
});
});

View File

@@ -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");
});
});

View File

@@ -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"));
});
});

View File

@@ -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),
);
});
});
});

View File

@@ -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);
});
});

View File

@@ -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,
]);
});
});
});

View File

@@ -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]);
});
});

View File

@@ -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));
});
});

View File

@@ -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);
});
});

View File

@@ -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),
);
});
});

View File

@@ -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),
),
);
});
});

View File

@@ -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"));
});
},
);

View File

@@ -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 EA 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 AB, BC, CD and DE. Edge EF 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), "EF 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"));
});
},
);

View File

@@ -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);
});
},
);

View File

@@ -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([]);
}),
);
}

View File

@@ -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);
});
},
);

View File

@@ -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(AB) 1(BC) 2(CD) 3(DE) 4(EF)
* EXP1 30 30 30 30 30
* IC1 20 20
* IC2 10
* total 30 60 50 30 30
*
* Edge BC 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 — BC would reach 50.
* IC2 is refused — BC would reach 40 even alone alongside EXP1.
*
* Both refusals are asserted to name edge BC 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
* (EF, 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 (BC) 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 BC 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 BC").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 BC, so NEITHER intercity fits.
expect(
LEGS.EXP1.wagons + LEGS.IC1.wagons,
"EXP1 + IC1 on BC already exceeds the pool — IC1 cannot board either",
).to.be.greaterThan(CNT_POOL);
expect(
LEGS.EXP1.wagons + LEGS.IC2.wagons,
"EXP1 + IC2 on BC 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 EF 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 BC 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 EF, 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: EF filled to the pool, every other edge left at 30", () => {
expectExportEdgeLoad(DEPARTURE, [30, 30, 30, 30, 35], CNT_POOL);
expectNoPoolLeak(DEPARTURE);
});
},
);

View File

@@ -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);
});
},
);

View File

@@ -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 CD
* 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 CD. 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 CD, 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 CD. 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 CD 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 — CD is free again, it is a different trip", () => {
// The assertion the scenario exists for. IMP1 is holding 30 wagons on
// edge CD of the OUTBOUND run. If the engine keyed capacity on the edge
// rather than on (schedule, edge), EXP1's 30 would take CD 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,
);
});
},
);

View File

@@ -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),
),
);
}

View File

@@ -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 EC 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([]),
),
);
}

View File

@@ -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);
});
},
);

View File

@@ -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);
}),
);
}

View File

@@ -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,
),
),
);
}

View 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),
),
),
);
});
},
);

View File

@@ -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));
}

View 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,
),
),
);
}

View File

@@ -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 (AB), 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);
});
},
);

View File

@@ -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);
});
},
);

View File

@@ -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",
]),
),
);
});
},
);

View File

@@ -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;
});
}

View File

@@ -0,0 +1,694 @@
/**
* Shared helpers for the GROUP 1 scenario specs (g1_s1 … g1_s8).
*
* These specs are the "visual" variant of the corridor suite: the fleet
* configuration phase (wagons → locomotives → train consist → schedule) and
* every capacity verdict are driven and asserted through the BACKOFFICE UI,
* while the bulk of the cargo (50+ wagons ≈ 100+ ISO container inputs per
* scenario) is still created through the API. See `bookOneVisually` below for
* where the line is drawn and why.
*
* Everything here builds on ./import-utils — the corridor route, contract
* seeding, window choreography and polling are unchanged. This module adds
* only what Group 1 needs on top:
*
* - a 53-WAGON BUILT TRAIN (seed-g1-train.sql). Group 1's arithmetic is
* written for 53 slots; a loco-pair schedule cannot hold that number
* because syncScheduleMaxWagons recomputes max_wagons from locomotive
* length (floor(760 / 13.966) = 54 on this corridor). A built train's
* physical consist wins outright — booking-batch.service.ts:4152:
* const maxWagons = physicalWagons ?? capacityLimits(loco).base.wagons
* so the consist staff marshal IS the capacity, and it survives the tick.
*
* - the FULL / NOT FULL verdict helpers, which every scenario ends on.
*
* No module-level mutable state: Cypress re-evaluates the spec bundle on every
* cross-origin visit, so helpers resolve rows by stamped-reference suffix and
* newest-row, never by a captured id. (Same rule as import-utils.)
*/
import {
acceptExport,
acceptOperation,
apiPost,
bookContainers,
clearToOperationRequestPending,
closeBookingWindow,
db,
dbSchedule,
forceWindowOpen,
opsStaff,
ORIGIN,
pollDb,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
/** The Group 1 built train's consist size — see seed-g1-train.sql. */
export const G1_WAGONS = 53;
export const G1_TRAIN = "TRN-G1-1";
/** Second identical train, for the multi-schedule scenarios. */
export const G1_TRAIN_2 = "TRN-G1-2";
// ---------------------------------------------------------------------------
// wagon arithmetic — the number every scenario is written in
// ---------------------------------------------------------------------------
/**
* Escape a DB-sourced string for use inside a RegExp. Yard and train labels go
* straight into `cy.contains(new RegExp(...))` selectors, and a label carrying
* a metacharacter (a "." or "(" in a yard name) would otherwise silently match
* the wrong option — or nothing at all.
*/
export function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* A Date as the unzoned "YYYY-MM-DDTHH:mm" wall-clock string that an
* `<input type="datetime-local">` accepts (the create-schedule form's Departure
* date field).
*
* The value MUST be in the BROWSER's local zone, not EAT. The input carries no
* offset, so whatever is typed is read as local time and converted on submit —
* pre-shifting to EAT on a UTC browser files the departure three hours late,
* which put it outside dbSchedule's ±1h lookup window and made a successfully
* created schedule look like it had never been created at all.
*
* Built from the local getters rather than toISOString for exactly that reason.
*/
export function localDateTime(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
`T${pad(d.getHours())}:${pad(d.getMinutes())}`
);
}
/**
* Wagons a container booking needs: 20ft containers pair up 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 — `hasOdd20ft`), so callers
* should keep 20ft quantities even.
*/
export function wagonsFor(twenty: number, forty: number): number {
return Math.ceil(twenty / 2) + forty;
}
// ---------------------------------------------------------------------------
// the built-train schedule
// ---------------------------------------------------------------------------
/**
* Create the Group 1 schedule on the corridor from the BUILT 53-wagon train.
*
* Deliberately NOT `createImportSchedule({ locoPair })`: that path derives
* capacity from locomotive length and would give 54 slots. Passing the train
* makes the coupled consist the cap (see module header).
*/
export function createG1Schedule(opts: {
departure: Date;
trainCode?: string;
routeId: string;
}) {
const trainCode = opts.trainCode ?? G1_TRAIN;
dbSchedule(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/container/schedules", {
routeId: opts.routeId,
scheduleDate: opts.departure.toISOString(),
trainId: trains[0].id,
})
.its("status")
.should("be.oneOf", [200, 201]);
},
);
});
}
/**
* The visual configuration phase, shared by every Group 1 scenario: operations
* schedules the built train on the corridor through the REAL create form
* (Route → Departure date → Train), then the window is opened.
*
* The form is built-train only — there is no locomotive-pair option and no
* max-wagons field in it (the pair path lives in AllocateBookingWizard), which
* is exactly what this suite wants: the consist chosen here IS the capacity.
*
* Leaves the schedule OPEN with `closesInMinutes` of window left.
*/
export function configureAndOpenSchedule(opts: {
departure: Date;
trainCode?: string;
originCode?: string;
closesInMinutes?: number;
wagons?: number;
}) {
const trainCode = opts.trainCode ?? G1_TRAIN;
const originCode = opts.originCode ?? ORIGIN;
cy.loginBackoffice(opsStaff);
cy.visit("/dashboard/operations/train-scheduling-v2");
cy.contains("button", "New schedule", { timeout: 120000 }).click();
cy.contains("Create train schedule", { timeout: 120000 }).should("be.visible");
// Route options are composed by formatRouteLabel, which renders yard LABELS
// ("Djibouti Port"), never codes — so resolve the label for this corridor.
db<{ label: string }>(`SELECT label FROM freight.yards WHERE code = $1`, [
originCode,
]).then(({ rows }) => {
expect(rows, `origin yard ${originCode}`).to.have.length(1);
cy.mantineSelect("Route", new RegExp(escapeRegExp(rows[0].label)));
});
// datetime-local takes an unzoned "YYYY-MM-DDTHH:mm" wall-clock string.
cy.get('input[type="datetime-local"]').type(localDateTime(opts.departure), {
force: true,
});
// Option text is composed: "TRN-G1-1 — E2E Group-1 … · 53 wagons".
cy.mantineSelect("Train", new RegExp(escapeRegExp(trainCode)));
cy.get(".mantine-Modal-content").contains("button", "Create").click();
cy.get(".mantine-Modal-content", { timeout: 120000 }).should("not.exist");
expectCapacity(opts.departure, opts.wagons ?? G1_WAGONS);
withSchedule(opts.departure, (s) => forceWindowOpen(s.id, opts.closesInMinutes ?? 45));
}
/**
* Close the window and run the batch from the board's own button — the one
* manual phase action the app exposes (closing itself is time-driven; there is
* no "close window" control anywhere in the UI).
*
* Asserts the phase actually advanced: a button that silently no-ops would
* otherwise leave every downstream assertion to time out far from the cause.
*/
export function closeWindowAndRunBatch(departure: Date) {
withSchedule(departure, (s) => closeBookingWindow(s.id));
cy.loginBackoffice(opsStaff);
withSchedule(departure, (s) => cy.visit(`/dashboard/operations/batch-board/${s.id}`));
cy.contains("Doc review", { timeout: 120000 }).should("exist");
cy.contains("button", "Doc review complete — run batch", { timeout: 120000 }).click();
withSchedule(departure, (s) =>
pollDb<ScheduleRow>(
"batch ran — window phase advanced",
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
[s.id],
// DONE when the batch reserved nobody — itself a scenario outcome.
(row) => !!row && ["PAYMENT", "DONE"].includes(row.window_phase as string),
20,
),
);
}
/**
* Read the Priority Tracking board: the lane counts and the capacity divider.
* Pass only what the scenario cares about.
*
* The divider is ONE text node — `Capacity line · 53/53 wagons · FULL` — so
* the FULL suffix cannot be asserted separately from the ratio.
*/
export function expectBoard(
departure: Date,
opts: {
inBatch?: number;
waiting?: number;
expired?: number;
capacity?: { used: number; max?: number; full?: boolean };
},
) {
cy.loginBackoffice(opsStaff);
withSchedule(departure, (s) => cy.visit(`/dashboard/operations/batch-board/${s.id}`));
cy.contains(/Priority Tracking/, { timeout: 120000 }).click();
cy.contains("Priority ranking", { timeout: 120000 }).should("be.visible");
if (opts.inBatch !== undefined) {
cy.contains(new RegExp(`In the batch\\s*\\(${opts.inBatch}\\)`), {
timeout: 120000,
}).should("exist");
}
if (opts.waiting !== undefined) {
if (opts.waiting === 0) cy.contains(/Waiting list/).should("not.exist");
else cy.contains(new RegExp(`Waiting list\\s*\\(${opts.waiting}\\)`)).should("exist");
}
if (opts.expired !== undefined) {
if (opts.expired === 0) cy.contains(/Expired\s*\(/).should("not.exist");
else cy.contains(new RegExp(`Expired\\s*\\(${opts.expired}\\)`)).should("exist");
}
if (opts.capacity) {
const max = opts.capacity.max ?? G1_WAGONS;
const suffix = opts.capacity.full ? " · FULL" : "";
cy.contains(`Capacity line · ${opts.capacity.used}/${max} wagons${suffix}`).should(
"exist",
);
}
}
/**
* Assert the schedule's capacity is the built consist, not the loco-derived
* 54. Worth asserting explicitly in every scenario's config phase: if a future
* change lets the length recompute win again, EVERY Group 1 expectation shifts
* by one slot and the exact-fit cases (S1, S2) would fail somewhere far from
* the cause.
*/
export function expectCapacity(departure: Date, wagons = G1_WAGONS) {
dbSchedule(departure).then(({ rows }) => {
expect(rows, "G1 schedule").to.have.length(1);
expect(rows[0].max_wagons, `consist capacity = ${wagons}`).to.eq(wagons);
});
}
// ---------------------------------------------------------------------------
// the verdict — every scenario ends naming its binding axis
// ---------------------------------------------------------------------------
/** Distinct wagon slots actually allocated to bookings on a schedule. */
export function allocatedWagons(scheduleId: string) {
return 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`,
[scheduleId],
).then(({ rows }) => Number(rows[0].n));
}
/**
* The scenario's closing verdict: how many of the train's slots ended up
* filled, and whether the engine agrees it is FULL.
*
* `booking_window_status` is the engine's own word (FULL / OPEN / CLOSED) —
* asserting the slot count alone would pass on a train that is physically full
* but which the window state machine never marked, which is exactly the bug
* class these scenarios exist to catch.
*/
export function expectVerdict(
departure: Date,
expected: { wagons: number; full: boolean; capacity?: number },
) {
const capacity = expected.capacity ?? G1_WAGONS;
dbSchedule(departure).then(({ rows }) => {
expect(rows, "G1 schedule").to.have.length(1);
const schedule = rows[0];
allocatedWagons(schedule.id).then((n) => {
expect(n, `${expected.wagons}/${capacity} wagons allocated`).to.eq(expected.wagons);
});
if (expected.full) {
pollDb<ScheduleRow>(
`window FULL (${expected.wagons}/${capacity})`,
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
[schedule.id],
(row) => row?.booking_window_status === "FULL",
20,
);
} else {
db<ScheduleRow>(
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
[schedule.id],
).then(({ rows: after }) => {
expect(
after[0].booking_window_status,
`not FULL (${expected.wagons}/${capacity})`,
).to.not.eq("FULL");
});
}
});
}
// ---------------------------------------------------------------------------
// booking → clearance gate → operations queue
// ---------------------------------------------------------------------------
/**
* Book containers and walk the booking all the way to the operations pool.
*
* EVERY contract booking is now born in the clearance gate — see
* contract-booking.service.ts:211, "EVERY contract booking clears per booking
* now — both contract kinds, both paths, intercity included". A booking is
* created in AWAITING_DOCUMENTS regardless of whether customs clearance is
* enabled, so calling `acceptOperation` straight after `bookContainers` always
* 409s with:
*
* Cannot perform this action on status "AWAITING_DOCUMENTS".
* Allowed: OPERATION_REQUEST_PENDING
*
* The gate is: upload a document → GL approves it → finalize → the customer
* proceeds with the shipment day. `clearToOperationRequestPending` runs that
* whole chain (the e2e seed configures no required documents, so one ad-hoc
* doc satisfies the 100%-approved rule).
*
* Use this instead of bookContainers + acceptOperation anywhere a booking has
* to reach the day pool.
*/
export function bookAndClear(opts: {
suffix: string;
runStamp: string;
isoSeed: number;
twenty?: number;
forty?: number;
scheduledDate: string;
vgmTons?: number;
/** EXPORT reserves on accept (FCFS) rather than entering the batch pool. */
mode?: "import" | "export";
}) {
bookContainers({
suffix: opts.suffix,
runStamp: opts.runStamp,
isoSeed: opts.isoSeed,
twenty: opts.twenty,
forty: opts.forty,
scheduledDate: opts.scheduledDate,
vgmTons: opts.vgmTons,
});
clearToOperationRequestPending(opts.suffix, opts.scheduledDate);
if (opts.mode === "export") acceptExport(opts.suffix);
else acceptOperation(opts.suffix);
}
/**
* The clearance half alone, for a booking created some other way — e.g. the
* portal form (`bookContainersVisually`), which leaves the booking sitting in
* the same AWAITING_DOCUMENTS gate.
*/
export function clearAndAccept(opts: {
suffix: string;
scheduledDate: string;
mode?: "import" | "export";
}) {
clearToOperationRequestPending(opts.suffix, opts.scheduledDate);
if (opts.mode === "export") acceptExport(opts.suffix);
else acceptOperation(opts.suffix);
}
// ---------------------------------------------------------------------------
// booking through the real portal form
// ---------------------------------------------------------------------------
/**
* Book containers the way a customer actually does: the portal's New Shipment
* form, end to end. Reserved for the SMALL booking in each scenario — one
* container is one ISO input, so a 30-wagon booking would mean 30-60 of them.
*
* The three traps this navigates (all learned from export_one_time.cy.ts and
* the form source):
* 1. size editors render in CONTRACT-SCOPE order, not 20ft-then-40ft, so
* each is addressed by its "20ft containers" heading;
* 2. the shipment-day calendar does not render until the cargo quantities
* are valid — "available days depend on the wagons your cargo needs";
* 3. a blank cargo description aborts the submit SILENTLY (no modal, no
* toast, no request) — cy.fillCargoDescription covers it.
*
* Leaves the browser on /bookings/:id, the page the form redirects to.
*/
export function bookContainersVisually(opts: {
contractId: string;
twenty?: number;
forty?: number;
/** Day to pick in the inline calendar — must be a bookable (enabled) day. */
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;
const forty = opts.forty ?? 0;
const total = twenty + forty;
expect(total, "at least one container").to.be.greaterThan(0);
// The form blocks an odd 20ft count (a lone 20ft cannot be paired onto a
// wagon) — "Review price & book" would stay disabled and the spec would
// fail on a timeout rather than on this, the real reason.
expect(twenty % 2, "20ft quantity must be even").to.eq(0);
cy.visitPortal(`/contracts/${opts.contractId}/bookings/new`);
cy.contains("New Shipment Booking", { timeout: 120000 }).should("be.visible");
// BOTH size cards must be given a quantity, including the unused one.
//
// The form renders a ContainerLineEditor per size in the contract's cargo
// scope, and an untouched editor keeps one blank unit row. The zod schema
// requires a valid ISO number AND a VGM on EVERY unit row
// (new-shipment-form/schema.ts:39-50), so that blank row fails validation and
// handleSubmit aborts SILENTLY — no modal, no toast, no request. Typing 0
// truncates the card's units to none (syncUnits: `next.length = max(0, qty)`)
// and takes it out of validation.
fillSizeQuantity("20ft", String(twenty));
fillSizeQuantity("40ft", String(forty));
// One ISO row per container, then the VGM on each.
//
// Scoped PER SIZE CARD, not globally: the form renders a ContainerLineEditor
// for every size in the contract's cargo scope, and an editor left at
// quantity 0 still renders one blank unit row. A global
// `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;
cy.contains(`${size} containers`, { timeout: 120000 })
.closest("div.rounded-xl")
.within(() => {
cy.get('input[placeholder*="MSCU"]', { timeout: 120000 }).should(
"have.length",
count,
);
for (let i = 0; i < count; i += 1) {
const iso = `${prefix}${String(runBlock).padStart(5, "0")}${String(unit + i).padStart(2, "0")}`;
cy.get('input[placeholder*="MSCU"]')
.eq(i)
.clear({ force: true })
.type(iso, { force: true });
}
cy.get('input[placeholder*="24.5"]').each(($input) => {
cy.wrap($input)
.clear({ force: true })
.type(String(opts.vgmTons ?? 10), { force: true });
});
})
.then(() => {
unit += count;
});
};
fillUnits("20ft", twenty);
fillUnits("40ft", forty);
pickShipmentDay(opts.shipmentDay);
cy.fillCargoDescription();
cy.contains("button", "Review price & book").should("not.be.disabled").click();
cy.contains("Confirm shipment price", { timeout: 120000 }).should("be.visible");
cy.contains("button", "Confirm & book").click();
cy.location("pathname", { timeout: 120000 }).should("match", /^\/bookings\/.+/);
}
/**
* One container-size line's quantity, addressed by its heading rather than by
* position — the cards render in CONTRACT-SCOPE order, not 20ft-then-40ft.
*
* A no-op when the contract does not scope this size, so callers can always
* set both (see the note about blank unit rows in bookContainersVisually).
*/
function fillSizeQuantity(size: "20ft" | "40ft", value: string) {
cy.get("body").then(($body) => {
if (!$body.text().includes(`${size} containers`)) return;
cy.contains(`${size} containers`, { timeout: 120000 })
.closest("div.rounded-xl")
.find('input[type="number"]')
.first()
.clear({ force: true })
.type(value, { force: true });
});
}
/** Pick a day on the Schedule card's inline, cargo-aware calendar. */
function pickShipmentDay(day: Date) {
cy.contains(/available day/, { timeout: 120000 }).should("exist");
// Day cells are plain buttons in a grid; only bookable days are enabled
// (out-of-month duplicates and unscheduled days stay disabled).
const eatDay = new Date(day.getTime() + 3 * 3_600_000).getUTCDate();
cy.get("button:not(:disabled)", { timeout: 120000 })
.contains(new RegExp(`^${eatDay}$`))
.click({ force: true });
}
// ---------------------------------------------------------------------------
// visual assertions on the backoffice schedule board
// ---------------------------------------------------------------------------
/**
* Open the schedule's detail page as operations staff. Every scenario does
* this at least twice — once after configuring the train (to SEE the empty
* 53-slot consist) and once at the end (to SEE the verdict).
*/
export function visitSchedule(departure: Date) {
cy.loginBackoffice(opsStaff);
dbSchedule(departure).then(({ rows }) => {
expect(rows, "G1 schedule").to.have.length(1);
cy.visit(`/dashboard/operations/train-scheduling-v2/${rows[0].id}`);
});
}
/**
* Every container on the train mapped to a wagon slot, with a real container
* number on it.
*
* Wagon-slot counts alone cannot catch a half-done allocation: a booking whose
* wagons were reserved but whose units were never placed still reads as a full
* train on the board. The units live in `wagon_allocation_container_items`
* (one row per container, `position_on_wagon` + `container_number`), hanging
* off `wagon_booking_allocations`.
*/
export function expectContainersPlaced(scheduleId: string, containers: number) {
pollDb<{ n: string }>(
`${containers} containers mapped to wagon slots`,
`SELECT count(*) AS n
FROM freight.wagon_allocation_container_items ci
JOIN freight.wagon_booking_allocations wba
ON wba.id = ci.wagon_booking_allocation_id
JOIN freight.train_schedule_bookings tsb
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
WHERE ci.deleted_at IS NULL AND wba.deleted_at IS NULL
AND tsb.deleted_at IS NULL`,
[scheduleId],
(row) => Number(row?.n ?? 0) === containers,
25,
);
// A placed unit with no number would be an empty slot wearing a container's
// name — the marshalling sheet is generated from exactly this column.
db<{ n: string }>(
`SELECT count(*) AS n
FROM freight.wagon_allocation_container_items ci
JOIN freight.wagon_booking_allocations wba
ON wba.id = ci.wagon_booking_allocation_id
JOIN freight.train_schedule_bookings tsb
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
WHERE ci.deleted_at IS NULL AND wba.deleted_at IS NULL
AND tsb.deleted_at IS NULL
AND (ci.container_number IS NULL OR ci.container_number = '')`,
[scheduleId],
).then(({ rows }) =>
expect(Number(rows[0].n), "no slot left without a container number").to.eq(0),
);
}
// ---------------------------------------------------------------------------
// split offers — S4, S6, S7, S8
// ---------------------------------------------------------------------------
/**
* Poll until the batch has raised a partial (split) offer on a booking.
* The engine offers rather than reserves when a booking cannot fit whole but
* some room remains — sizePartialOfferWagons budgets that room against the
* BASE caps only, never the locomotive's overage tolerance (see S11).
*/
export function expectSplitOffer(suffix: string) {
withBooking(suffix, (b) => {
pollDb<{ status: string }>(
`${suffix} open partial offer`,
`SELECT status FROM freight.booking_batch_offers
WHERE booking_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[b.id],
(row) => row?.status === "OFFERED",
15,
);
});
}
/** Assert NO split offer was raised — the whole-or-nothing cases. */
export function expectNoSplitOffer(suffix: string) {
withBooking(suffix, (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), `${suffix} has no split offer`).to.eq(0));
});
}
/**
* Let a split offer lapse rather than paying it (S6). The offer expires with
* the booking's pay deadline, so pushing the deadline into the past and
* letting the 10s tick run is the same thing the wall clock would do.
*/
export function forceOfferLapse(suffix: string) {
withBooking(suffix, (b) =>
db(
`UPDATE freight.bookings SET payment_deadline = now() - interval '1 second'
WHERE id = $1`,
[b.id],
),
);
}
// ---------------------------------------------------------------------------
// waiting list — S1, S5, S24
// ---------------------------------------------------------------------------
/**
* A booking that lost the batch sits at FULLY_EXECUTED with no schedule — it
* is on the day's waiting list, not rejected. Promotion happens when capacity
* frees up (fillFromWaitingList loops up to 10 rounds, so one expiry can
* cascade into several promotions — see S5).
*/
export function expectWaitlisted(suffix: string) {
withBooking(suffix, (b) => {
expect(b.status, `${suffix} waitlisted`).to.eq("FULLY_EXECUTED");
expect(b.train_schedule_id, `${suffix} holds no seat`).to.be.null;
});
}
/** Poll until a waitlisted booking has been promoted into a pay window. */
export function expectPromoted(suffix: string) {
pollDb<{ status: string; payment_deadline: string | null }>(
`${suffix} promoted from the waiting list`,
`SELECT b.status, b.payment_deadline 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],
(row) =>
!!row &&
["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"].includes(row.status) &&
row.payment_deadline !== null,
30,
);
}
// ---------------------------------------------------------------------------
// recoverability — S1's tail
// ---------------------------------------------------------------------------
/**
* An EXPIRED booking is recoverable without re-approval: its contract is still
* FULLY_EXECUTED, so the customer can book again onto a later day. Asserting
* the CONTRACT state (not just the booking's) is the point — a bug that also
* retired the contract would strand the customer.
*/
export function expectRecoverable(suffix: string) {
withBooking(suffix, (b) => {
expect(b.status, `${suffix} expired`).to.eq("EXPIRED");
db<{ status: string }>(`SELECT status FROM freight.contracts WHERE id = $1`, [
b.contract_id,
]).then(({ rows }) =>
expect(rows[0].status, `${suffix} contract still bookable`).to.be.oneOf([
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
]),
);
});
}

View File

@@ -0,0 +1,363 @@
/**
* GROUP 10 · S40 — line-level validation and the re-priced booking.
*
* - hazardous quantity above the line quantity (gap — see below)
* - reefer quantity on a DRY container type (gap — see below)
* - return quantity above the line quantity (enforced)
* - a re-priced booking parks with ZERO capacity footprint
*
* ── TWO SCENARIO CORRECTIONS (see SCENARIO_ENGINE_NOTES.md) ────────────────
*
* S40 expects `hazardousQuantity: 12` on a `quantity: 10` line to be REJECTED.
* It is not. The DTO carries only @IsOptional @IsInt @Min(0) with no @Max
* (create-booking.dto.ts:60-76), and the repository CLAMPS instead of throwing
* (bookings.repository.ts:217): the booking is created 201 with the value
* silently truncated to 10. Reefer behaves identically.
*
* Worth noting because it is the same layer and the same shape of data:
* `returnQuantity` DOES throw, with a precise message
* (contract-booking.service.ts:1740). So the pattern exists in the codebase —
* hazardous and reefer just do not use it. That asymmetry is asserted below,
* because it is the clearest evidence the clamp is an oversight rather than a
* deliberate design.
*
* S40 also expects reefer to be forced to 0 for DRY container types. No such
* logic exists: a DRY type with reeferQuantity > 0 is an explicitly supported
* state that applies the REEFER surcharge anyway (booking.entity.ts:384,
* booking-pricing.service.ts:402).
*
* Both are written as passing tests of CURRENT behaviour plus skipped tests of
* the DESIRED behaviour.
*
* Sequential steps per scenario — retries off.
*/
import {
acceptOperation,
apiPost,
bookContainers,
customer,
db,
dbContractId,
departureAt,
eatDayStr,
ensureCorridorRoute,
markPaid,
pollAllocations,
pollBookingStatus,
resetCorridorDay,
seedImportContract,
withBooking,
} from "./import-utils";
import {
G1_WAGONS,
bookAndClear,
closeWindowAndRunBatch,
configureAndOpenSchedule,
expectVerdict,
} from "./g1-utils";
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
/** Book one container line directly, so per-line handling counts can be set. */
function bookLine(opts: {
suffix: string;
quantity: number;
hazardousQuantity?: number;
reeferQuantity?: number;
scheduledDate?: string;
failOnStatusCode?: boolean;
}) {
return dbContractId(opts.suffix).then((contractId) =>
db<{ id: string }>(
`SELECT id FROM freight.container_types
WHERE size_ft = 20 AND is_active LIMIT 1`,
).then(({ rows }) =>
apiPost(
customer,
`/api/contracts/${contractId}/bookings`,
{
...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}),
containers: [
{
containerSize: "20ft",
containerTypeId: rows[0].id,
quantity: opts.quantity,
...(opts.hazardousQuantity !== undefined
? { hazardousQuantity: opts.hazardousQuantity }
: {}),
...(opts.reeferQuantity !== undefined
? { reeferQuantity: opts.reeferQuantity }
: {}),
units: Array.from({ length: opts.quantity }, (_, i) => ({
containerNumber: `VLDU${String(3_000_000 + i).slice(0, 7)}`,
vgmTons: 10,
})),
},
],
},
opts.failOnStatusCode ?? true,
),
),
);
}
/** The persisted per-line handling counts for a booking. */
function lineCounts(bookingId: string) {
return db<{ quantity: number; hazardous_quantity: number; reefer_quantity: number }>(
`SELECT quantity, hazardous_quantity, reefer_quantity
FROM freight.booking_container
WHERE booking_id = $1 AND deleted_at IS NULL
ORDER BY created_at LIMIT 1`,
[bookingId],
).then(({ rows }) => rows[0]);
}
// ───────────────────────────────────────────────────────────────────────────
// Line-level handling counts
// ───────────────────────────────────────────────────────────────────────────
describe("G10·S40: line-level handling quantities", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
cy.task("db:seedFile", "seed-g1-train.sql");
(["VH", "VR", "VOK"] as const).forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix) }),
);
});
it("a hazardous count WITHIN the line quantity is stored as given", () => {
bookLine({ suffix: "VOK", quantity: 10, hazardousQuantity: 8 }).then((res) => {
expect(res.status, "valid line accepted").to.be.oneOf([200, 201]);
});
withBooking("VOK", (b) =>
lineCounts(b.id).then((line) => {
expect(Number(line.quantity), "10 containers").to.eq(10);
expect(Number(line.hazardous_quantity), "8 hazardous, untouched").to.eq(8);
}),
);
});
it("CURRENT BEHAVIOUR: hazardous ABOVE the quantity is silently clamped, not rejected", () => {
// The scenario expects a 4xx here. The engine returns 201 and truncates
// 12 → 10 in the repository (bookings.repository.ts:217).
bookLine({
suffix: "VH",
quantity: 10,
hazardousQuantity: 12,
failOnStatusCode: false,
}).then((res) => {
expect(res.status, "over-quantity hazardous is ACCEPTED today").to.be.oneOf([
200, 201,
]);
});
withBooking("VH", (b) =>
lineCounts(b.id).then((line) => {
// The caller has no way to learn this happened — no warning, no field
// in the response, just a quietly different number.
expect(Number(line.hazardous_quantity), "12 clamped down to 10").to.eq(
Number(line.quantity),
);
}),
);
});
it("CURRENT BEHAVIOUR: reefer on a DRY container type is kept, not zeroed", () => {
// The dev DB carries only DRY container types. The scenario expects
// reeferQuantity forced to 0; instead it is stored and will apply the
// REEFER surcharge (booking.entity.ts:384).
bookLine({
suffix: "VR",
quantity: 6,
reeferQuantity: 4,
failOnStatusCode: false,
}).then((res) => {
expect(res.status, "reefer on DRY accepted").to.be.oneOf([200, 201]);
});
withBooking("VR", (b) =>
lineCounts(b.id).then((line) => {
expect(Number(line.reefer_quantity), "kept as booked").to.eq(4);
}),
);
});
it("the SAME layer DOES reject an over-quantity return count", () => {
// The asymmetry that shows the clamp is an oversight: returnQuantity
// throws a precise message (contract-booking.service.ts:1740) where
// hazardous and reefer truncate in silence.
dbContractId("VOK").then((contractId) =>
db<{ id: string }>(
`SELECT id FROM freight.container_types
WHERE size_ft = 20 AND is_active LIMIT 1`,
).then(({ rows }) =>
apiPost(
customer,
`/api/contracts/${contractId}/bookings`,
{
containers: [
{
containerSize: "20ft",
containerTypeId: rows[0].id,
quantity: 4,
returnQuantity: 9,
units: Array.from({ length: 4 }, (_, i) => ({
containerNumber: `VLDR${String(4_000_000 + i).slice(0, 7)}`,
vgmTons: 10,
})),
},
],
},
false,
).then((res) => {
expect(res.status, "over-quantity return rejected").to.be.within(400, 422);
expect(JSON.stringify(res.body)).to.match(/[Rr]eturn quantity .* exceeds/);
}),
),
);
});
// GAP — see SCENARIO_ENGINE_NOTES.md. Un-skip once the DTO grows the bound
// (or the repository throws instead of clamping); today these would fail.
it.skip("SHOULD: reject a hazardous count above the line quantity", () => {
bookLine({
suffix: "VH",
quantity: 10,
hazardousQuantity: 12,
failOnStatusCode: false,
}).then((res) => {
expect(res.status, "over-quantity hazardous rejected").to.be.within(400, 422);
expect(JSON.stringify(res.body)).to.match(/hazardous/i);
});
});
it.skip("SHOULD: force reeferQuantity to 0 for a DRY container type", () => {
bookLine({ suffix: "VR", quantity: 6, reeferQuantity: 4 });
withBooking("VR", (b) =>
lineCounts(b.id).then((line) =>
expect(Number(line.reefer_quantity), "DRY type carries no reefer").to.eq(0),
),
);
});
});
// ───────────────────────────────────────────────────────────────────────────
// A re-priced booking holds no capacity
// ───────────────────────────────────────────────────────────────────────────
describe("G10·S40b: a re-priced booking parks with no capacity footprint", { retries: 0 }, () => {
const DEPARTURE = departureAt(54);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const SHAPES = {
PA: { forty: 30, wagons: 30 },
/** The one that gets re-priced and parked. */
PB: { forty: 20, wagons: 20 },
PC: { forty: 23, wagons: 23 },
} as const;
const ORDER = ["PA", "PB", "PC"] as const;
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
cy.task("db:seedFile", "seed-g1-train.sql");
ORDER.forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix) }),
);
});
it("three bookings compete: 73 wagons of demand for 53 slots", () => {
expect(
ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
"73 wagons of demand",
).to.eq(73);
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
configureAndOpenSchedule({ departure: DEPARTURE });
let isoSeed = 27_100;
ORDER.forEach((suffix) => {
bookAndClear({
suffix,
runStamp: stamp,
isoSeed,
forty: SHAPES[suffix].forty,
scheduledDate: BOOKING_DAY,
});
isoSeed += SHAPES[suffix].forty;
});
});
it("PB is re-priced and parked in PRICE_CHANGED_PENDING_CONFIRM", () => {
// The status a booking lands in when the recomputed price differs from the
// preview the customer saw (booking-transition.service.ts:157). It waits
// for the customer to confirm the new price.
withBooking("PB", (b) =>
db(`UPDATE freight.bookings SET status = 'PRICE_CHANGED_PENDING_CONFIRM' WHERE id = $1`, [
b.id,
]),
);
withBooking("PB", (b) =>
expect(b.status, "PB parked").to.eq("PRICE_CHANGED_PENDING_CONFIRM"),
);
});
it("the batch does not see PB at all — it is not PAID and not reserved", () => {
closeWindowAndRunBatch(DEPARTURE);
// The pool query is an exact match on status = 'PAID'
// (bookings.repository.ts:1136), so a parked booking is invisible by
// construction rather than by an explicit deny-list.
(["PA", "PC"] as const).forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
withBooking("PB", (b) => {
expect(b.status, "PB still parked").to.eq("PRICE_CHANGED_PENDING_CONFIRM");
expect(b.train_schedule_id, "PB holds no seat").to.be.null;
expect(b.payment_deadline, "PB got no pay window").to.be.null;
});
});
it("PA and PC fill the train WITHOUT PB — zero capacity footprint", () => {
(["PA", "PC"] as const).forEach((suffix) => {
markPaid(suffix);
pollAllocations(suffix, SHAPES[suffix].wagons);
});
// 30 + 23 = 53: the parked booking's 20 wagons were never withheld for it.
const riding = SHAPES.PA.wagons + SHAPES.PC.wagons;
expect(riding, "PA + PC fill the train exactly").to.eq(G1_WAGONS);
expectVerdict(DEPARTURE, { wagons: riding, full: true });
withBooking("PB", (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), "PB holds no wagons").to.eq(0),
),
);
});
it("PB is still bookable once the customer confirms — it was parked, not lost", () => {
withBooking("PB", (b) => {
db<{ status: string }>(`SELECT status FROM freight.contracts WHERE id = $1`, [
b.contract_id,
]).then(({ rows }) =>
expect(rows[0].status, "PB's contract is still live").to.be.oneOf([
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
]),
);
db<{ q: string }>(
`SELECT sum(quantity) AS q FROM freight.booking_container
WHERE booking_id = $1 AND deleted_at IS NULL`,
[b.id],
).then(({ rows }) =>
expect(Number(rows[0].q), "PB's cargo intact").to.eq(SHAPES.PB.forty),
);
});
});
});
export {};

View File

@@ -0,0 +1,286 @@
/**
* GROUP 1 · S1 — a no-pay expiry frees exactly the space the waiting list needs.
*
* A 3×40FT = 3 wagons
* B 20×20FT + 10×40FT = 20 wagons
* C 30×40FT = 30 wagons
* ─────────
* 53 = the whole train → RESERVED FULL
* D 6×20FT = 3 wagons → no room → WAITING LIST
*
* A/B/C are all selected by the batch and given pay windows. B and C pay. A
* never does: its deadline passes, A EXPIRES, and its 3 wagons are freed. The
* top-up pass then promotes D — whose 3 wagons fit the freed space EXACTLY —
* and D pays.
*
* Final consist: B 20 + C 30 + D 3 = 53/53, FULL.
* A is recoverable: its contract is untouched, so it can rebook a later day
* with no re-approval.
*
* WHAT IS DRIVEN THROUGH THE UI (this is the "visual" spec of the pair)
*
* - the whole fleet-configuration phase: the 53-wagon consist and its
* locomotives are SEEN on the Train Composition tab before any cargo
* exists, so the capacity under test is the capacity on screen;
* - D — the small booking — is booked by the customer through the real
* portal shipment form, end to end (6 containers = 6 ISO inputs);
* - every capacity verdict is read off the backoffice Priority Tracking
* tab: the "In the batch" / "Waiting list" / "Expired" lanes and the
* literal `Capacity line · 53/53 wagons · FULL` divider.
*
* WHAT STAYS ON THE API, AND WHY
*
* - A/B/C's cargo (53 wagons ≈ 106 ISO container numbers) — typing those
* through the form is minutes of keystrokes per scenario and tests the
* form, not the scheduling engine. `bookContainers` is the same helper
* every corridor spec uses.
* - PAYMENT. There is no mock/test payment path in the portal: "Pay now"
* opens a provider modal that redirects off-origin to a real gateway
* (useBookingPayment → window.location.href). Cypress cannot follow that,
* so payment is settled the way every other spec settles it — staff
* mark-paid, or the internal gateway webhook via `settleViaGateway`.
*
* Sequential steps of one journey — retries off (steps are not idempotent).
*/
import {
acceptOperation,
bookContainers,
customer,
db,
dbContractId,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
forceReservationExpiry,
markPaid,
opsStaff,
pollAllocations,
pollBookingStatus,
resetCorridorDay,
seedImportContract,
setPriority,
withBooking,
withSchedule,
} from "./import-utils";
import {
G1_TRAIN,
G1_WAGONS,
bookAndClear,
bookContainersVisually,
clearAndAccept,
closeWindowAndRunBatch,
configureAndOpenSchedule,
expectBoard,
expectPromoted,
expectRecoverable,
expectVerdict,
expectWaitlisted,
wagonsFor,
} from "./g1-utils";
const DEPARTURE = departureAt(11);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
/**
* Booking order is also PRIORITY order (setPriority below): the batch must
* consider A before B before C, so that A — the one that never pays — is
* genuinely inside the batch and its expiry genuinely frees space.
*/
const SHAPES = {
A: { twenty: 0, forty: 3, wagons: 3 },
B: { twenty: 20, forty: 10, wagons: 20 },
C: { twenty: 0, forty: 30, wagons: 30 },
// D is booked through the UI, not from this table — see the portal step.
D: { twenty: 6, forty: 0, wagons: 3 },
} as const;
const IN_BATCH = ["A", "B", "C"] as const;
describe("G1·S1: expiry frees exactly the waiting list's space", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
cy.task("db:seedFile", "seed-g1-train.sql");
// All four self-clear. A customs contract routes its booking into
// AWAITING_DOCUMENTS and a whole clearance gate before ops can accept it
// (see clearGeneralBooking in import-utils) — orthogonal to this
// scenario, which is about expiry and waiting-list promotion.
(["A", "B", "C", "D"] as const).forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix) }),
);
});
// ── configuration phase ────────────────────────────────────────────────
it("the wagon math adds up to exactly one trainload before anything is booked", () => {
// Guards the premise of the whole scenario: if wagonsFor ever changed
// (e.g. 20ft stopped pairing two-to-a-wagon), the "fits exactly" and
// "frees exactly" claims below would silently become ordinary inequalities
// and the spec would still pass while testing nothing.
IN_BATCH.forEach((s) =>
expect(wagonsFor(SHAPES[s].twenty, SHAPES[s].forty), `${s} wagons`).to.eq(
SHAPES[s].wagons,
),
);
const booked = IN_BATCH.reduce((sum, s) => sum + SHAPES[s].wagons, 0);
expect(booked, "A+B+C fill the train exactly").to.eq(G1_WAGONS);
expect(SHAPES.D.wagons, "D fits exactly the space A frees").to.eq(SHAPES.A.wagons);
});
it("staff SEE the 53-wagon consist in the Train Builder before scheduling it", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
// The consist under test, on screen: TRN-G1-1 with its 53 coupled wagons
// and its locomotive pair. This is the number the engine will fill.
cy.loginBackoffice(opsStaff);
db<{ id: string }>(`SELECT id FROM freight.trains WHERE code = $1`, [G1_TRAIN]).then(
({ rows }) => {
expect(rows, `built train ${G1_TRAIN}`).to.have.length(1);
cy.visit(`/dashboard/train-builder/${rows[0].id}`);
},
);
cy.contains(`Train ${G1_TRAIN}`, { timeout: 120000 }).should("exist");
cy.contains("Wagon order", { timeout: 120000 }).should("exist");
// Stat strip: the "Wagons" KPI cell reads 53.
//
// Scope to the KpiStrip cell (`div.flex-1`, KpiStrip.tsx) rather than
// matching "Wagons" anywhere: the left sidebar has a NavLink of the same
// name, cy.contains returns the FIRST match, and its parent never contains
// the count — which is exactly how this first failed.
cy.contains("div.flex-1", "Wagons", { timeout: 120000 }).should(
"contain.text",
String(G1_WAGONS),
);
});
it("operations schedules that train on the corridor and opens the window", () => {
// The consist is the cap — NOT the locomotive-length figure (54 here).
configureAndOpenSchedule({ departure: DEPARTURE });
withSchedule(DEPARTURE, (s) =>
expect(s.booking_cycle_no, "FIRST window cycle").to.eq(1),
);
});
// ── bookings ───────────────────────────────────────────────────────────
it("A, B and C book the whole train between them", () => {
let isoSeed = 7100;
IN_BATCH.forEach((suffix) => {
const shape = SHAPES[suffix];
bookAndClear({
suffix,
runStamp: stamp,
isoSeed,
twenty: shape.twenty,
forty: shape.forty,
scheduledDate: BOOKING_DAY,
});
isoSeed += shape.twenty + shape.forty;
});
// Booking order = priority order, so A is inside the batch and its expiry
// is what frees space (see the SHAPES comment).
IN_BATCH.forEach((suffix, i) => setPriority(suffix, i + 1));
});
it("D books 6×20FT through the portal shipment form", () => {
// The one booking small enough to drive visually: 6 ISO numbers, not 106.
cy.loginPortal(customer);
dbContractId("D").then((contractId) => {
bookContainersVisually({
contractId,
twenty: SHAPES.D.twenty,
shipmentDay: DEPARTURE,
isoPrefix: "DDDU",
runStamp: stamp,
});
});
clearAndAccept({ suffix: "D", scheduledDate: BOOKING_DAY });
setPriority("D", 4);
});
it("the window closes, staff run the batch, and D is left waiting", () => {
closeWindowAndRunBatch(DEPARTURE);
IN_BATCH.forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
IN_BATCH.forEach((suffix) =>
withBooking(suffix, (b) =>
expect(b.payment_deadline, `${suffix} got a pay window`).to.be.a("string"),
),
);
// D lost the batch but is NOT rejected — it holds a place in line.
expectWaitlisted("D");
});
it("staff SEE D below the capacity line on the Priority Tracking board", () => {
// The three winners above the line, D below it, and the line itself full.
expectBoard(DEPARTURE, {
inBatch: 3,
waiting: 1,
capacity: { used: G1_WAGONS, full: true },
});
});
// ── payment, expiry, promotion ─────────────────────────────────────────
it("B and C pay inside the window; A never does and EXPIRES, freeing 3 wagons", () => {
markPaid("B");
pollAllocations("B", SHAPES.B.wagons);
markPaid("C");
pollAllocations("C", SHAPES.C.wagons);
// A's deadline passes with no payment — the 10s tick expires the
// reservation and returns its 3 wagons to the day's pool.
forceReservationExpiry("A");
withBooking("A", (b) => expect(b.status, "A expired unpaid").to.eq("EXPIRED"));
});
it("the freed 3 wagons promote D — an exact fit — and D pays", () => {
// fillFromWaitingList loops until a pass reserves nothing, so the promotion
// happens on the tick that follows the expiry; no second staff action.
expectPromoted("D");
markPaid("D");
pollAllocations("D", SHAPES.D.wagons);
});
it("the train departs FULL at 53/53 — B 20 + C 30 + D 3", () => {
withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id));
expectVerdict(DEPARTURE, { wagons: G1_WAGONS, full: true });
// The seats are held by the three PAYERS, and A holds none.
withSchedule(DEPARTURE, (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), "3 bookings linked").to.eq(3));
});
(["B", "C", "D"] as const).forEach((suffix) =>
withBooking(suffix, (b) => expect(b.status, `${suffix} rides`).to.eq("PAID")),
);
withBooking("A", (b) => {
expect(b.status, "A does not ride").to.eq("EXPIRED");
expect(b.train_schedule_id, "A holds no seat").to.be.null;
});
});
it("staff SEE the settled board: D promoted into the batch, A in the expired lane", () => {
// D moved above the line (3 in the batch), A moved out of it entirely.
expectBoard(DEPARTURE, {
inBatch: 3,
expired: 1,
capacity: { used: G1_WAGONS, full: true },
});
});
it("A is recoverable — no re-approval needed to rebook a later day", () => {
expectRecoverable("A");
});
});
export {};

View File

@@ -0,0 +1,175 @@
/**
* GROUP 1 · S2 — four bookings pay and fill the train to the slot.
*
* A 3×40FT = 3 wagons
* B 20×20FT + 10×40FT = 20 wagons
* C 25×40FT = 25 wagons
* D 10×20FT = 5 wagons
* ─────────
* 53/53 → FULL
*
* The simplest full-train case: everyone is selected, everyone pays inside the
* window, allocation writes a container number onto every slot. Nothing
* expires, nothing splits, nobody waits.
*
* What it is really guarding is the ALLOCATION, not the arithmetic: 53 wagons
* carry 20×2 + 10 + 3 + 25 + 10 = 88 containers, and every one of them must
* land on exactly one slot. A booking that allocated wagons but never mapped
* its units would still show 53/53 here — hence the per-unit assertion at the
* end.
*
* D — the 10×20FT booking — is placed through the portal form; the rest go
* through the API (see g1_s1's header for where that line is drawn and why).
*
* Sequential steps of one journey — retries off.
*/
import {
acceptOperation,
bookContainers,
customer,
db,
dbContractId,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
markPaid,
pollAllocations,
pollBookingStatus,
resetCorridorDay,
seedImportContract,
withSchedule,
} from "./import-utils";
import {
G1_WAGONS,
bookAndClear,
bookContainersVisually,
clearAndAccept,
closeWindowAndRunBatch,
configureAndOpenSchedule,
expectBoard,
expectContainersPlaced,
expectVerdict,
wagonsFor,
} from "./g1-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 = {
A: { twenty: 0, forty: 3, wagons: 3, containers: 3 },
B: { twenty: 20, forty: 10, wagons: 20, containers: 30 },
C: { twenty: 0, forty: 25, wagons: 25, containers: 25 },
D: { twenty: 10, forty: 0, wagons: 5, containers: 10 },
} as const;
const ALL = ["A", "B", "C", "D"] as const;
/** A/B/C ride the API; D is the visual booking. */
const VIA_API = ["A", "B", "C"] as const;
const TOTAL_CONTAINERS = ALL.reduce((sum, s) => sum + SHAPES[s].containers, 0);
describe("G1·S2: four bookings pay and fill the train exactly", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
cy.task("db:seedFile", "seed-g1-train.sql");
ALL.forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix) }),
);
});
it("the four bookings add up to exactly one trainload", () => {
ALL.forEach((s) =>
expect(wagonsFor(SHAPES[s].twenty, SHAPES[s].forty), `${s} wagons`).to.eq(
SHAPES[s].wagons,
),
);
expect(
ALL.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
"A+B+C+D fill the train exactly",
).to.eq(G1_WAGONS);
});
it("operations schedules the 53-wagon built train and opens the window", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
configureAndOpenSchedule({ departure: DEPARTURE });
});
it("A, B and C book through the API", () => {
let isoSeed = 8100;
VIA_API.forEach((suffix) => {
const shape = SHAPES[suffix];
bookAndClear({
suffix,
runStamp: stamp,
isoSeed,
twenty: shape.twenty,
forty: shape.forty,
scheduledDate: BOOKING_DAY,
});
isoSeed += shape.containers;
});
});
it("D books 10×20FT through the portal shipment form", () => {
cy.loginPortal(customer);
dbContractId("D").then((contractId) => {
bookContainersVisually({
contractId,
twenty: SHAPES.D.twenty,
shipmentDay: DEPARTURE,
isoPrefix: "SEXU",
runStamp: stamp,
});
});
clearAndAccept({ suffix: "D", scheduledDate: BOOKING_DAY });
});
it("the batch reserves all four — they fit exactly, so nobody is offered a split", () => {
closeWindowAndRunBatch(DEPARTURE);
ALL.forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
});
it("all four pay inside the window and are allocated onto the train", () => {
ALL.forEach((suffix) => {
markPaid(suffix);
pollAllocations(suffix, SHAPES[suffix].wagons);
});
withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id));
});
it("the train is FULL at 53/53 and every container has a slot", () => {
expectVerdict(DEPARTURE, { wagons: G1_WAGONS, full: true });
withSchedule(DEPARTURE, (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), "4 bookings linked").to.eq(4));
});
// The real point of this scenario: allocation is per CONTAINER, not just
// per wagon. 53 filled slots with only some units mapped would still read
// as a full train on the board.
withSchedule(DEPARTURE, (s) => expectContainersPlaced(s.id, TOTAL_CONTAINERS));
});
it("staff SEE the full board: four in the batch, capacity line at 53/53 FULL", () => {
// Nobody waited and nobody expired — the clean-fill signature.
expectBoard(DEPARTURE, {
inBatch: 4,
waiting: 0,
expired: 0,
capacity: { used: G1_WAGONS, full: true },
});
});
});
export {};

View File

@@ -0,0 +1,179 @@
/**
* GROUP 1 · S3 — an under-filled train keeps its day open.
*
* A 12×20FT = 6 wagons
* B 10×40FT = 10 wagons
* C 24×20FT = 12 wagons
* ─────────
* 28/53 → 25 slots still free
*
* Everyone pays, nobody splits, nobody waits. The assertion is the NEGATIVE
* one: the window must NOT be marked FULL, because the day has to stay
* visible to customers who have not booked yet. A train that closed its day at
* 28/53 would silently refuse 25 wagons of business.
*
* The "still open" claim is checked the way a customer would experience it —
* the portal's availability query still offers the day — not just by reading
* the schedule row.
*
* Sequential steps of one journey — retries off.
*/
import {
acceptOperation,
apiGet,
bookContainers,
customer,
dbContractId,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
markPaid,
pollAllocations,
pollBookingStatus,
resetCorridorDay,
seedImportContract,
withBooking,
withSchedule,
} from "./import-utils";
import {
G1_WAGONS,
bookAndClear,
bookContainersVisually,
clearAndAccept,
closeWindowAndRunBatch,
configureAndOpenSchedule,
expectBoard,
expectNoSplitOffer,
expectVerdict,
wagonsFor,
} from "./g1-utils";
const DEPARTURE = departureAt(13);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
const SHAPES = {
A: { twenty: 12, forty: 0, wagons: 6 },
B: { twenty: 0, forty: 10, wagons: 10 },
C: { twenty: 24, forty: 0, wagons: 12 },
} as const;
const ALL = ["A", "B", "C"] as const;
const BOOKED_WAGONS = ALL.reduce((sum, s) => sum + SHAPES[s].wagons, 0); // 28
const FREE_WAGONS = G1_WAGONS - BOOKED_WAGONS; // 25
describe("G1·S3: an under-filled train keeps its day open", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
cy.task("db:seedFile", "seed-g1-train.sql");
ALL.forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix) }),
);
});
it("the three bookings leave 25 slots free", () => {
ALL.forEach((s) =>
expect(wagonsFor(SHAPES[s].twenty, SHAPES[s].forty), `${s} wagons`).to.eq(
SHAPES[s].wagons,
),
);
expect(BOOKED_WAGONS, "A+B+C = 28 wagons").to.eq(28);
expect(FREE_WAGONS, "25 slots unused").to.eq(25);
});
it("operations schedules the 53-wagon built train and opens the window", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
configureAndOpenSchedule({ departure: DEPARTURE });
});
it("A and B book through the API; C books 24×20FT through the portal", () => {
let isoSeed = 8600;
(["A", "B"] as const).forEach((suffix) => {
const shape = SHAPES[suffix];
bookAndClear({
suffix,
runStamp: stamp,
isoSeed,
twenty: shape.twenty,
forty: shape.forty,
scheduledDate: BOOKING_DAY,
});
isoSeed += shape.twenty + shape.forty;
});
cy.loginPortal(customer);
dbContractId("C").then((contractId) => {
bookContainersVisually({
contractId,
twenty: SHAPES.C.twenty,
shipmentDay: DEPARTURE,
isoPrefix: "CSQU",
runStamp: stamp,
});
});
clearAndAccept({ suffix: "C", scheduledDate: BOOKING_DAY });
});
it("the batch reserves all three whole — there is room to spare, so no splits", () => {
closeWindowAndRunBatch(DEPARTURE);
ALL.forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
// Room to spare means nobody should ever have been offered a partial.
ALL.forEach((suffix) => expectNoSplitOffer(suffix));
});
it("all three pay and are allocated — 28 of 53 wagons used", () => {
ALL.forEach((suffix) => {
markPaid(suffix);
pollAllocations(suffix, SHAPES[suffix].wagons);
});
withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id));
expectVerdict(DEPARTURE, { wagons: BOOKED_WAGONS, full: false });
});
it("the window is NOT marked FULL and the day is still on offer to customers", () => {
// The schedule's own verdict.
withSchedule(DEPARTURE, (s) => {
expect(s.booking_window_status, "window not FULL").to.not.eq("FULL");
});
// And the customer-facing consequence, asked the way the portal asks it:
// GET /bookings/:id/day-availability → { fits, freeWagons, trainsForDay }.
// A train that under-filled but stopped offering its day is the actual bug
// this scenario guards, and `freeWagons` is where it would show.
withBooking("A", (b) =>
apiGet(customer, `/api/bookings/${b.id}/day-availability?date=${BOOKING_DAY}`).then(
(res) => {
expect(res.status, "day availability readable").to.be.oneOf([200, 201]);
// The interceptor wraps payloads in { success, data }.
const body = res.body as {
trainsForDay?: boolean;
freeWagons?: number;
data?: { trainsForDay?: boolean; freeWagons?: number };
};
const day = body.data ?? body;
expect(day.trainsForDay, "the day still runs a train").to.eq(true);
expect(Number(day.freeWagons), "25 wagons still on offer").to.eq(FREE_WAGONS);
},
),
);
});
it("staff SEE three in the batch and a capacity line short of FULL", () => {
expectBoard(DEPARTURE, {
inBatch: 3,
waiting: 0,
expired: 0,
// No " · FULL" suffix — that is the whole point of the scenario.
capacity: { used: BOOKED_WAGONS, full: false },
});
});
});
export {};

Some files were not shown because too many files have changed in this diff Show More