mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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'`)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -315,3 +315,131 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
|
||||
expect(result.plan).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planWagonsWithStock — break-bulk (PER_ITEM) item-aware packing', () => {
|
||||
const pw2: WagonType = {
|
||||
id: 'wt-pw2',
|
||||
code: 'PW2',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 17,
|
||||
supportedLoadTypes: ['BULK'],
|
||||
isActive: true,
|
||||
supportsContainer: false,
|
||||
} as WagonType;
|
||||
const nw5: WagonType = {
|
||||
id: 'wt-nw5',
|
||||
code: 'NW5',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
supportedLoadTypes: ['BULK'],
|
||||
isActive: true,
|
||||
supportsContainer: false,
|
||||
} as WagonType;
|
||||
|
||||
// 20 machinery items, 100T total (5T each). NW5 fits 4/wagon, PW2 fits 3.
|
||||
const machineryBooking = (): Booking =>
|
||||
({
|
||||
id: 'BULK-ITEMS',
|
||||
reference: 'BULK-ITEMS',
|
||||
freightType: 'BULK',
|
||||
cargoTypeId: 'ct-machinery',
|
||||
cargoTotalWeightVgm: 20,
|
||||
bulkTotalWeightTons: 100,
|
||||
cargoType: {
|
||||
id: 'ct-machinery',
|
||||
cargoTypeName: 'Machinery',
|
||||
itemsPerWagonMap: { 'wt-nw5': 4, 'wt-pw2': 3 },
|
||||
wagonTypes: [pw2, nw5],
|
||||
},
|
||||
}) as unknown as Booking;
|
||||
|
||||
const allowed = {
|
||||
byContainerTypeId: new Map<string, WagonType[]>(),
|
||||
byCargoTypeId: new Map([['ct-machinery', [pw2, nw5]]]),
|
||||
};
|
||||
|
||||
it('packs whole items per wagon by the items-fit map, not raw tonnage', () => {
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [machineryBooking()],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'YARD',
|
||||
remainingByTypeId: new Map([
|
||||
[pw2.id, 50],
|
||||
[nw5.id, 50],
|
||||
]),
|
||||
codesByTypeId: new Map([
|
||||
[pw2.id, pw2.code],
|
||||
[nw5.id, nw5.code],
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.deferred).toHaveLength(0);
|
||||
// Best fit: NW5 at 4 items/wagon → ceil(20/4) = 5 wagons, 20T each.
|
||||
expect(result.plan).toHaveLength(5);
|
||||
expect(result.plan.every((s) => s.wagonTypeCode === 'NW5')).toBe(true);
|
||||
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([20, 20, 20, 20, 20]);
|
||||
});
|
||||
|
||||
it('weight cap binds before items-fit when items are heavy', () => {
|
||||
// 14 items of 10T on 70T wagons with a 100-item floor fit → 7 items/wagon.
|
||||
const heavy = {
|
||||
...machineryBooking(),
|
||||
cargoTotalWeightVgm: 14,
|
||||
bulkTotalWeightTons: 140,
|
||||
cargoType: {
|
||||
id: 'ct-machinery',
|
||||
cargoTypeName: 'Machinery',
|
||||
itemsPerWagonMap: { 'wt-nw5': 100, 'wt-pw2': 100 },
|
||||
wagonTypes: [pw2, nw5],
|
||||
},
|
||||
} as unknown as Booking;
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [heavy],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'YARD',
|
||||
remainingByTypeId: new Map([
|
||||
[pw2.id, 50],
|
||||
[nw5.id, 50],
|
||||
]),
|
||||
codesByTypeId: new Map([
|
||||
[pw2.id, pw2.code],
|
||||
[nw5.id, nw5.code],
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.deferred).toHaveLength(0);
|
||||
expect(result.plan).toHaveLength(2);
|
||||
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 70]);
|
||||
});
|
||||
|
||||
it('PER_TON bulk (no bulkTotalWeightTons) still packs by weight', () => {
|
||||
const loose = {
|
||||
...machineryBooking(),
|
||||
cargoTotalWeightVgm: 100,
|
||||
bulkTotalWeightTons: null,
|
||||
} as unknown as Booking;
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [loose],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'YARD',
|
||||
remainingByTypeId: new Map([
|
||||
[pw2.id, 50],
|
||||
[nw5.id, 50],
|
||||
]),
|
||||
codesByTypeId: new Map([
|
||||
[pw2.id, pw2.code],
|
||||
[nw5.id, nw5.code],
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.deferred).toHaveLength(0);
|
||||
expect(result.plan).toHaveLength(2);
|
||||
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 30]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,11 @@ import { AllocationLoadType } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsForAllowedTypes,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
sortBookingsForScheduling,
|
||||
type BookingWagonShortage,
|
||||
@@ -64,6 +69,12 @@ type OpenSlot = {
|
||||
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
||||
cargoTypeId: string | null;
|
||||
freeCapacityTons: number;
|
||||
/**
|
||||
* Whole-item slots left on this wagon (break-bulk PER_ITEM cargo only —
|
||||
* bounded by the cargo type's items-per-wagon fit and by tonnage). Undefined
|
||||
* for weight-only (PER_TON) bulk and container wagons.
|
||||
*/
|
||||
freeItems?: number;
|
||||
/**
|
||||
* Leg of the FIRST booking placed (`"from-to"` stop indexes). Containers
|
||||
* prefer a same-leg slot but may extend onto a different-leg one (span
|
||||
@@ -110,8 +121,17 @@ const shortageFor = (
|
||||
booking.freightType === 'BULK'
|
||||
? Math.max(
|
||||
1,
|
||||
// Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map
|
||||
// respected); PER_TON falls through to tonnage over the largest
|
||||
// candidate. bookingCargoTons, not raw VGM — for PER_ITEM that
|
||||
// column is the item count, not tons.
|
||||
bulkItemWagonsForAllowedTypes(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
||||
) ||
|
||||
Math.ceil(
|
||||
Number(booking.cargoTotalWeightVgm ?? 0) /
|
||||
bookingCargoTons(booking) /
|
||||
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
||||
),
|
||||
)
|
||||
@@ -347,18 +367,64 @@ export function planWagonsWithStock(params: {
|
||||
};
|
||||
}
|
||||
const allowedIds = new Set(candidates.map((wt) => wt.id));
|
||||
let remainingWeight = roundTons(Number(booking.cargoTotalWeightVgm ?? 0));
|
||||
// Break-bulk (PER_ITEM): `cargoTotalWeightVgm` is the ITEM COUNT and the
|
||||
// real tonnage lives in `bulkTotalWeightTons` — bookingCargoTons resolves
|
||||
// it either way. Items are indivisible, so a wagon takes whole items only,
|
||||
// bounded by tonnage AND by the cargo type's items-per-wagon fit.
|
||||
const quantity = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const perItem =
|
||||
Number(booking.bulkTotalWeightTons ?? 0) > 0 && quantity > 0;
|
||||
let remainingWeight = roundTons(bookingCargoTons(booking));
|
||||
const perItemTons = perItem ? remainingWeight / quantity : 0;
|
||||
let remainingItems = perItem ? quantity : 0;
|
||||
|
||||
/** Whole items one wagon of this slot's type can still take. */
|
||||
const itemRoomOf = (open: OpenSlot): number =>
|
||||
Math.min(
|
||||
open.freeItems ?? Number.MAX_SAFE_INTEGER,
|
||||
perItemTons > 0 ? Math.floor(open.freeCapacityTons / perItemTons) : 0,
|
||||
);
|
||||
/** Fresh wagon's whole-item budget: items-fit map floor'd by tonnage. */
|
||||
const itemBudgetOf = (open: OpenSlot): number => {
|
||||
const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId);
|
||||
const byTonnage =
|
||||
perItemTons > 0
|
||||
? Math.max(1, Math.floor(Number(open.slot.capacityTons) / perItemTons))
|
||||
: 1;
|
||||
return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage);
|
||||
};
|
||||
let placedAnywhere = false;
|
||||
|
||||
// Per-item: prefer the type carrying the most whole items per wagon.
|
||||
// openSlot's own capacity sort is stable, so this order breaks its ties.
|
||||
const itemBudgetOfType = (wt: WagonType): number =>
|
||||
Math.min(
|
||||
bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER,
|
||||
perItemTons > 0
|
||||
? Math.max(1, Math.floor(Number(wt.capacityTons) / perItemTons))
|
||||
: 1,
|
||||
);
|
||||
const orderedCandidates = perItem
|
||||
? [...candidates].sort((a, b) => itemBudgetOfType(b) - itemBudgetOfType(a))
|
||||
: candidates;
|
||||
|
||||
// Top off wagons already carrying THIS cargo type before opening new ones.
|
||||
// ponytail: per-item cargo only shares wagons that were opened per-item
|
||||
// (freeItems tracked); mixing itemized and loose loads of one cargo type
|
||||
// on one wagon is not modeled — open a new wagon instead.
|
||||
for (const open of openSlots) {
|
||||
if (remainingWeight <= 0) break;
|
||||
if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break;
|
||||
if (open.kind !== 'BULK') continue;
|
||||
if (open.legKey !== legKey) continue;
|
||||
if (open.cargoTypeId !== cargoTypeId) continue;
|
||||
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
|
||||
if (open.freeCapacityTons <= 0) continue;
|
||||
const take = roundTons(Math.min(open.freeCapacityTons, remainingWeight));
|
||||
if (perItem !== (open.freeItems !== undefined)) continue;
|
||||
const takeItems = perItem ? Math.min(itemRoomOf(open), remainingItems) : 0;
|
||||
if (perItem && takeItems <= 0) continue;
|
||||
const take = perItem
|
||||
? roundTons(takeItems * perItemTons)
|
||||
: roundTons(Math.min(open.freeCapacityTons, remainingWeight));
|
||||
addAllocation(
|
||||
open.slot,
|
||||
booking.id,
|
||||
@@ -367,14 +433,39 @@ export function planWagonsWithStock(params: {
|
||||
AllocationLoadType.Bulk,
|
||||
);
|
||||
open.freeCapacityTons = roundTons(open.freeCapacityTons - take);
|
||||
if (perItem) {
|
||||
open.freeItems = (open.freeItems ?? 0) - takeItems;
|
||||
remainingItems -= takeItems;
|
||||
}
|
||||
remainingWeight = roundTons(remainingWeight - take);
|
||||
placedAnywhere = true;
|
||||
}
|
||||
|
||||
while (remainingWeight > 0 || !placedAnywhere) {
|
||||
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg);
|
||||
while ((perItem ? remainingItems > 0 : remainingWeight > 0) || !placedAnywhere) {
|
||||
// Per-item: openSlot's stock-depth tie-break would override the fit
|
||||
// preference, so hand it exactly the best in-stock type (full candidate
|
||||
// list only when none has stock, for the proper shortfall message).
|
||||
const inStockBest = perItem
|
||||
? orderedCandidates.find((wt) => availableFor(wt.id, leg) > 0)
|
||||
: undefined;
|
||||
const openedSlot = openSlot(
|
||||
inStockBest ? [inStockBest] : orderedCandidates,
|
||||
'BULK',
|
||||
cargoTypeId,
|
||||
leg,
|
||||
);
|
||||
if ('message' in openedSlot) return openedSlot;
|
||||
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||||
let take: number;
|
||||
if (perItem) {
|
||||
// An item heavier than a whole wagon still charges 1 wagon per item
|
||||
// (creation-time validation owns rejecting that case).
|
||||
const takeItems = Math.max(1, Math.min(itemBudgetOf(openedSlot), remainingItems));
|
||||
take = roundTons(Math.min(takeItems * perItemTons, remainingWeight));
|
||||
openedSlot.freeItems = itemBudgetOf(openedSlot) - takeItems;
|
||||
remainingItems -= takeItems;
|
||||
} else {
|
||||
take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||||
}
|
||||
addAllocation(
|
||||
openedSlot.slot,
|
||||
booking.id,
|
||||
@@ -399,6 +490,7 @@ export function planWagonsWithStock(params: {
|
||||
teuPerEdge: [...open.teuPerEdge],
|
||||
covered: { ...open.covered },
|
||||
freeCapacityTons: open.freeCapacityTons,
|
||||
freeItems: open.freeItems,
|
||||
assignedWeightTons: open.slot.assignedWeightTons,
|
||||
allocationCount: open.slot.allocations.length,
|
||||
allocationWeights: open.slot.allocations.map((a) => a.allocatedWeightTons),
|
||||
@@ -420,6 +512,7 @@ export function planWagonsWithStock(params: {
|
||||
open.teuPerEdge = [...snap.teuPerEdge];
|
||||
open.covered = { ...snap.covered };
|
||||
open.freeCapacityTons = snap.freeCapacityTons;
|
||||
open.freeItems = snap.freeItems;
|
||||
open.slot.assignedWeightTons = snap.assignedWeightTons;
|
||||
open.slot.allocations.length = snap.allocationCount;
|
||||
snap.allocationWeights.forEach((weight, allocationIndex) => {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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) },
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -182,18 +182,18 @@ const FreightDashboardHeader = ({
|
||||
)}
|
||||
</Box>
|
||||
<Divider />
|
||||
<Menu.Item
|
||||
leftSection={<FileSignature size={15} />}
|
||||
onClick={() => navigate("/dashboard/profile#signature")}
|
||||
>
|
||||
Signature & 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} />}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function MySignatureCard() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileSignature className="size-4" />
|
||||
My signature
|
||||
Signature & Stamp
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
This signature can be reused to sign booking contracts.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
18
apps/edr-freight-web/backoffice/src/utils/cargoWeight.ts
Normal file
18
apps/edr-freight-web/backoffice/src/utils/cargoWeight.ts
Normal 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 };
|
||||
}
|
||||
@@ -548,18 +548,18 @@ export function AppLayout({
|
||||
</>
|
||||
)}
|
||||
<Divider />
|
||||
<Menu.Item
|
||||
leftSection={<FileSignature size={15} />}
|
||||
onClick={() => navigate("/signature")}
|
||||
>
|
||||
Signature & 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")}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function MySignatureCard() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileSignature className="size-5 text-primary" />
|
||||
My signature
|
||||
Signature & Stamp
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Reused to approve and sign booking contracts.
|
||||
|
||||
@@ -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 & Stamp
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Saved and reused to approve and sign booking contracts.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
17
e2e/freight/.live-q.cjs
Normal file
@@ -0,0 +1,17 @@
|
||||
|
||||
const { Client } = require('pg');
|
||||
(async () => {
|
||||
const c = new Client({ connectionString: 'postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e' });
|
||||
await c.connect();
|
||||
const r = await c.query(`
|
||||
SELECT ct.reference AS contract, b.reference AS bk, b.status,
|
||||
COALESCE(b.payment_status,'-') AS pay,
|
||||
(SELECT count(*) FROM freight.wagon_booking_allocations a
|
||||
WHERE a.booking_id=b.id AND a.deleted_at IS NULL) AS wagons
|
||||
FROM freight.bookings b JOIN freight.contracts ct ON ct.id=b.contract_id
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%' AND b.deleted_at IS NULL
|
||||
AND b.created_at > now() - interval '30 minutes'
|
||||
ORDER BY b.created_at DESC LIMIT 15`);
|
||||
console.log(JSON.stringify(r.rows));
|
||||
await c.end();
|
||||
})().catch(e => { console.log(JSON.stringify({error: e.message})); });
|
||||
101
e2e/freight/BULK_SCENARIOS.md
Normal file
101
e2e/freight/BULK_SCENARIOS.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Bulk scenario catalog — BS1–BS40
|
||||
|
||||
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, S1–S40).
|
||||
|
||||
**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 |
|
||||
13
e2e/freight/Dockerfile.vnc
Normal file
13
e2e/freight/Dockerfile.vnc
Normal file
@@ -0,0 +1,13 @@
|
||||
# Cypress + noVNC, so a headed run can be watched live in a browser.
|
||||
#
|
||||
# The tools are baked in rather than apt-installed per run: installing at run
|
||||
# start cost ~30s on a good day and stalled indefinitely on a bad one, which
|
||||
# wedged the run before Cypress ever launched.
|
||||
#
|
||||
# Build: docker build -f e2e/freight/Dockerfile.vnc -t edr-cypress-vnc e2e/freight
|
||||
# Watch: http://localhost:8090/vnc.html?autoconnect=true&resize=scale
|
||||
FROM cypress/included:15.18.1
|
||||
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get install -y --no-install-recommends x11vnc novnc websockify \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -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_*`)
|
||||
|
||||
S1–S40 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` | S6–S8 | declined split, government preemption, priority tiers |
|
||||
| `g2_weight.cy.ts` | S9–S12 | weight vs slots, and the tolerance rules |
|
||||
| `g3_export.cy.ts` | S13–S18 | export FCFS, whole-or-nothing |
|
||||
| `g4_multi_schedule.cy.ts` | S19–S21 | two trains on one day |
|
||||
| `g5_waitlist.cy.ts` | S22–S24 | recovery: rebooking, split remainders, queue walking |
|
||||
| `g6_corridor.cy.ts` | S25–S29 | the run: alighting, tracking, checkpoints |
|
||||
| `g7_disruptions.cy.ts` | S30–S33 | cancel, wagon shortage, breakage, under-filled dispatch |
|
||||
| `g8_import_customs.cy.ts` | S34–S37 | the clearance chain |
|
||||
| `g9_delivery.cy.ts` | S38–S39 | 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)
|
||||
|
||||
185
e2e/freight/SCENARIO_ENGINE_NOTES.md
Normal file
185
e2e/freight/SCENARIO_ENGINE_NOTES.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Scenario ↔ engine reconciliation (S1–S40)
|
||||
|
||||
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.
|
||||
@@ -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
|
||||
|
||||
61
e2e/freight/cypress/e2e/flows/bulk-items-utils.ts
Normal file
61
e2e/freight/cypress/e2e/flows/bulk-items-utils.ts
Normal 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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 {};
|
||||
147
e2e/freight/cypress/e2e/flows/bulk_b2_per_item_floor.cy.ts
Normal file
147
e2e/freight/cypress/e2e/flows/bulk_b2_per_item_floor.cy.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* BULK B2 — break-bulk PER_ITEM wagon math (BS17–BS19 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* BULK B3 — PER_ITEM giant split + line quantities (BS20–BS23 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 {};
|
||||
799
e2e/freight/cypress/e2e/flows/flow_two/flow2-export-utils.ts
Normal file
799
e2e/freight/cypress/e2e/flows/flow_two/flow2-export-utils.ts
Normal file
@@ -0,0 +1,799 @@
|
||||
/**
|
||||
* Shared helpers for the FLOW-TWO EXPORT batch (tcx01 … tcx20).
|
||||
*
|
||||
* The first flow-two batch (../flow_two/tc01…tc08, ./flow2-utils.ts) runs the
|
||||
* corridor IMPORT-ward, A→F. This batch runs it the other way and mixes three
|
||||
* things the first batch never did: EXPORT legs, wagon-TYPE pools, and BULK
|
||||
* tonnage arithmetic.
|
||||
*
|
||||
* A B C D E F
|
||||
* DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY
|
||||
*
|
||||
* EXPORT (EXP) F→…→A — ends at the port, crosses the border
|
||||
* IMPORT (IMP) A→…→F — the first batch's direction
|
||||
* INTERCITY (IC) inside B..E, either way — wholly Ethiopian, DOMESTIC
|
||||
*
|
||||
* WHY THIS IS A SEPARATE MODULE FROM ./flow2-utils.ts
|
||||
*
|
||||
* flow2-utils is hard-wired to the import direction and cannot be reused as-is:
|
||||
*
|
||||
* - `edgesOf(from, to)` ASSERTS `a < b` (flow2-utils.ts:77) — an export leg
|
||||
* F→A is backwards by that measure and would fail the assertion, not the
|
||||
* scenario.
|
||||
* - `seedLegContract` derives direction as `from === "A" ? IMPORT : DOMESTIC`
|
||||
* (flow2-utils.ts:134). There is no EXPORT branch, and an export leg seeded
|
||||
* DOMESTIC prices against INTERCITY rates and never reaches the export FCFS
|
||||
* path at all.
|
||||
* - `acceptIntercity` resolves its schedule with `dbSchedule(departure)` using
|
||||
* the DEFAULT import args (flow2-utils.ts:185), so it cannot see a
|
||||
* KALITY→DJIB_PORT schedule.
|
||||
*
|
||||
* Rather than bend those (and risk the first batch's eight specs), this module
|
||||
* mirrors them in EXPORT terms. The corridor-edge model itself is direction-
|
||||
* agnostic — `exportEdgesOf` maps an export leg onto the SAME five edges, just
|
||||
* traversed the other way, so per-edge arithmetic is directly comparable
|
||||
* between the two batches.
|
||||
*
|
||||
* EXPORT IS FCFS, NOT WINDOW+BATCH. This is the single most important
|
||||
* difference from every import spec in this suite:
|
||||
*
|
||||
* - `acceptExport` IS the reservation (booking-batch.service.ts:1301,
|
||||
* `acceptExportBooking` → `pickExportSchedule`). There is no
|
||||
* `closeWindowAndRunBatch`, no doc-review click, no batch pass.
|
||||
* - Order of acceptance therefore IS the priority rule. A scenario that wants
|
||||
* a particular loser must accept in a deliberate order.
|
||||
* - Export is whole-or-nothing unless `FREIGHT_EXPORT_SPLIT=true`
|
||||
* (booking-batch.service.ts:394 — an ENV VAR, not a DB flag; see
|
||||
* `exportSplitEnabled` below and tcx14).
|
||||
*
|
||||
* No module-level mutable state — same rule as g1-utils and flow2-utils:
|
||||
* Cypress re-evaluates the spec bundle on cross-origin visits, so rows are
|
||||
* resolved by stamped reference, never by a captured id.
|
||||
*/
|
||||
|
||||
import {
|
||||
CORRIDOR,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
apiPost,
|
||||
bookBulk,
|
||||
bookContainers,
|
||||
clearIntercityToFullyExecuted,
|
||||
db,
|
||||
dbSchedule,
|
||||
opsStaff,
|
||||
seedImportContract,
|
||||
withBooking,
|
||||
type ScheduleRow,
|
||||
} from "../import-utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the corridor, in the letters the scenarios are written in
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Scenario letter → corridor yard code. A is the port; F is the inland end. */
|
||||
export const STOP = {
|
||||
A: CORRIDOR[0], // DJIB_PORT — Djibouti. Any leg touching it crosses the border.
|
||||
B: CORRIDOR[1], // NAGAD
|
||||
C: CORRIDOR[2], // DIRE_DAWA
|
||||
D: CORRIDOR[3], // E2E_AWASH
|
||||
E: CORRIDOR[4], // MOJO
|
||||
F: CORRIDOR[5], // KALITY
|
||||
} as const;
|
||||
|
||||
export type Stop = keyof typeof STOP;
|
||||
/** Stop letters in IMPORT (A→F) order — index doubles as corridor position. */
|
||||
export const STOPS = ["A", "B", "C", "D", "E", "F"] as const;
|
||||
|
||||
/** The five corridor edges, named for the error messages a rejection should carry. */
|
||||
export const EDGE_NAMES = ["A–B", "B–C", "C–D", "D–E", "E–F"] as const;
|
||||
|
||||
/**
|
||||
* Edges a leg occupies, direction-agnostic.
|
||||
*
|
||||
* An edge is a stretch of TRACK, and F→E rides the same physical stretch as
|
||||
* E→F. So both map to edge 4. This is what lets an export leg and an intercity
|
||||
* leg be summed onto one profile — which is the whole point of TC-05 … TC-08.
|
||||
*
|
||||
* Deliberately NOT flow2-utils' `edgesOf`, which asserts forward order and
|
||||
* would reject every export leg in this batch.
|
||||
*/
|
||||
export function exportEdgesOf(from: Stop, to: Stop): number[] {
|
||||
const a = STOPS.indexOf(from);
|
||||
const b = STOPS.indexOf(to);
|
||||
expect(a, `${from} is on the corridor`).to.be.gte(0);
|
||||
expect(b, `${to} is on the corridor`).to.be.gte(0);
|
||||
expect(a, `${from}→${to} is a real leg, not a self-loop`).to.not.eq(b);
|
||||
const lo = Math.min(a, b);
|
||||
const hi = Math.max(a, b);
|
||||
return Array.from({ length: hi - lo }, (_, i) => lo + i);
|
||||
}
|
||||
|
||||
/** Whether two legs share track — i.e. compete for the same wagons. */
|
||||
export function legsOverlap(l1: [Stop, Stop], l2: [Stop, Stop]): boolean {
|
||||
const a = exportEdgesOf(...l1);
|
||||
const b = exportEdgesOf(...l2);
|
||||
return a.some((e) => b.includes(e));
|
||||
}
|
||||
|
||||
export interface Leg {
|
||||
from: Stop;
|
||||
to: Stop;
|
||||
wagons: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons committed on each of the corridor's 5 edges by a set of legs.
|
||||
*
|
||||
* Every scenario states its edge profile in prose in its own header; this
|
||||
* computes the same number so the spec can assert its OWN PREMISE before it
|
||||
* trusts the engine's answer. A scenario whose arithmetic drifted (someone
|
||||
* edits a quantity) then fails on the premise, not twenty lines later on an
|
||||
* engine assertion that looks like a product bug.
|
||||
*/
|
||||
export function edgeLoad(legs: Leg[]): number[] {
|
||||
const load = [0, 0, 0, 0, 0];
|
||||
legs.forEach((l) => exportEdgesOf(l.from, l.to).forEach((e) => (load[e] += l.wagons)));
|
||||
return load;
|
||||
}
|
||||
|
||||
/** The busiest edge and how much it carries — the edge a rejection should name. */
|
||||
export function peakEdge(legs: Leg[]) {
|
||||
const load = edgeLoad(legs);
|
||||
const peak = Math.max(...load);
|
||||
return { edge: load.indexOf(peak), name: EDGE_NAMES[load.indexOf(peak)], wagons: peak, load };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wagon arithmetic the scenarios are written in
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The three-pool export consist — see seed-flow2-export-train.sql. */
|
||||
export const EXPORT_TRAIN = "TRN-F2-EXP";
|
||||
export const CNT_POOL = 35; // NW5
|
||||
export const BLK_POOL = 20; // CW4
|
||||
export const FLT_POOL = 5; // NW6 — allow-listed to nothing, deliberately
|
||||
export const EXPORT_CONSIST = CNT_POOL + BLK_POOL + FLT_POOL; // 60
|
||||
|
||||
/** Wagon type code per pool letter, as the allocation rows record it. */
|
||||
export const POOL_TYPE = { CNT: "NW5", BLK: "CW4", FLT: "NW6" } as const;
|
||||
export type Pool = keyof typeof POOL_TYPE;
|
||||
|
||||
/**
|
||||
* Wagons a CONTAINER booking needs: 20ft pair two-per-wagon, 40ft take a whole
|
||||
* wagon each. An ODD 20ft count still costs a whole wagon (and the portal form
|
||||
* blocks submitting one), so keep 20ft quantities even.
|
||||
*
|
||||
* Same rule as g1-utils' `wagonsFor` — restated here so the TEU scenarios
|
||||
* (tcx12) can assert against it without importing the import-side module.
|
||||
*/
|
||||
export function containerWagons(twenty: number, forty: number): number {
|
||||
return Math.ceil(twenty / 2) + forty;
|
||||
}
|
||||
|
||||
/** CW4 physical figures, from the wagon-type catalog (SeedDefaultWagonTypes). */
|
||||
export const CW4_CAPACITY_TONS = 70;
|
||||
|
||||
/**
|
||||
* Wagons a loose PER_TON bulk booking needs — plain ceil against the wagon's
|
||||
* capacity. This is the NON-per-item path (`bulkItemWagonsRequired` bails when
|
||||
* `bulkTotalWeightTons` and the item count are not both set, train-capacity
|
||||
* .util.ts:140), so a `bookBulk` tonnage booking lands here.
|
||||
*/
|
||||
export function bulkWagons(tons: number, capacityTons = CW4_CAPACITY_TONS): number {
|
||||
return Math.ceil(tons / capacityTons);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons a PER_ITEM bulk booking needs — the `items_per_wagon_map` rule, in
|
||||
* full, mirroring train-capacity.util.ts:143-149:
|
||||
*
|
||||
* perItemTons = totalTons / quantity
|
||||
* byTonnage = max(1, FLOOR(capacityTons / perItemTons))
|
||||
* itemsPerWagon = min(byTonnage, FLOOR(itemsFit)) ← the map's floor
|
||||
* wagons = max(1, CEIL(quantity / itemsPerWagon))
|
||||
*
|
||||
* FLOOR on items-per-wagon, CEIL on the wagon count. Both matter: the floor is
|
||||
* why 41t on a 40t wagon costs two wagons, and the ceil is why a part-full last
|
||||
* wagon is still a whole wagon. See [[per-item-wagon-fit]].
|
||||
*/
|
||||
export function perItemWagons(opts: {
|
||||
items: number;
|
||||
tons: number;
|
||||
itemsFit?: number;
|
||||
capacityTons?: number;
|
||||
}): number {
|
||||
const capacityTons = opts.capacityTons ?? CW4_CAPACITY_TONS;
|
||||
expect(opts.items, "per-item booking has items").to.be.greaterThan(0);
|
||||
expect(opts.tons, "per-item booking has tonnage").to.be.greaterThan(0);
|
||||
const perItemTons = opts.tons / opts.items;
|
||||
const byTonnage = Math.max(1, Math.floor(capacityTons / perItemTons));
|
||||
const byFloor = opts.itemsFit && opts.itemsFit >= 1 ? Math.floor(opts.itemsFit) : Infinity;
|
||||
const itemsPerWagon = Math.min(byTonnage, byFloor);
|
||||
return Math.max(1, Math.ceil(opts.items / itemsPerWagon));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// contracts pinned to a leg, in either direction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The trade direction the ENGINE will derive for a leg, from the yards'
|
||||
* countries alone (`resolveTradeDirectionForBooking`). Our intent does not
|
||||
* enter into it:
|
||||
*
|
||||
* to A → EXPORT (Ethiopian origin, Djiboutian destination)
|
||||
* from A → IMPORT
|
||||
* neither → DOMESTIC (intercity)
|
||||
*
|
||||
* Seeding a contract with a direction the engine will not agree with does NOT
|
||||
* fail loudly — pricing 404s on a rate_type that does not exist for the pair,
|
||||
* or the booking books fine and then never reaches the path under test. So
|
||||
* every contract in this batch derives its direction here rather than stating
|
||||
* one.
|
||||
*/
|
||||
export function directionOf(from: Stop, to: Stop): "IMPORT" | "EXPORT" | "DOMESTIC" {
|
||||
if (to === "A") return "EXPORT";
|
||||
if (from === "A") return "IMPORT";
|
||||
return "DOMESTIC";
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed one contract whose route IS the booking's leg — the whole mechanism by
|
||||
* which a flow-two booking gets a leg. The leg lives on the CONTRACT, not on
|
||||
* the booking payload (bookings.service.ts resolves the contract route into
|
||||
* origin/destinationYardId), so N legs means N contracts even for one customer.
|
||||
*
|
||||
* `direction` is derived, never passed — see `directionOf`.
|
||||
*/
|
||||
export function seedExportLegContract(opts: {
|
||||
suffix: string;
|
||||
reference: string;
|
||||
from: Stop;
|
||||
to: Stop;
|
||||
freight?: "CONTAINER" | "BULK";
|
||||
customs?: boolean;
|
||||
kind?: "ONE_TIME" | "GENERAL";
|
||||
}) {
|
||||
exportEdgesOf(opts.from, opts.to); // asserts the leg is real and on-corridor
|
||||
seedImportContract({
|
||||
suffix: opts.suffix,
|
||||
reference: opts.reference,
|
||||
originCode: STOP[opts.from],
|
||||
destCode: STOP[opts.to],
|
||||
direction: directionOf(opts.from, opts.to),
|
||||
freight: opts.freight,
|
||||
customs: opts.customs,
|
||||
kind: opts.kind,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the export schedule
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Resolve the EXPORT schedule for a departure (KALITY → DJIB_PORT). */
|
||||
export function dbExportSchedule(departure: Date) {
|
||||
// NOTE THE ARG ORDER: dbSchedule takes (departure, destCode, originCode) —
|
||||
// destination BEFORE origin (import-utils.ts:946). Passing them the natural
|
||||
// way round silently returns zero rows.
|
||||
return dbSchedule(departure, EXP_DEST, EXP_ORIGIN);
|
||||
}
|
||||
|
||||
/** Run `fn` against the one export schedule on this departure. */
|
||||
export function withExportSched(departure: Date, fn: (s: ScheduleRow) => void) {
|
||||
dbExportSchedule(departure).then(({ rows }) => {
|
||||
expect(rows, "flow-two export schedule").to.have.length(1);
|
||||
fn(rows[0]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the export schedule from the THREE-POOL BUILT train.
|
||||
*
|
||||
* Deliberately not a loco-pair schedule: a pair derives capacity from
|
||||
* locomotive length (`syncScheduleMaxWagons`), which would give one flat number
|
||||
* and erase the pool structure this batch exists to test. A built train's
|
||||
* physical consist wins outright — booking-batch.service.ts:4152:
|
||||
*
|
||||
* const maxWagons = physicalWagons ?? capacityLimits(loco).base.wagons
|
||||
*
|
||||
* so the 60 coupled wagons ARE the capacity, and it survives the 10s tick.
|
||||
*/
|
||||
export function createExportSchedule(opts: {
|
||||
departure: Date;
|
||||
trainCode?: string;
|
||||
/** Container schedules take container bookings; bulk takes bulk. */
|
||||
kind?: "container" | "bulk";
|
||||
}) {
|
||||
const trainCode = opts.trainCode ?? EXPORT_TRAIN;
|
||||
const kind = opts.kind ?? "container";
|
||||
dbExportSchedule(opts.departure).then(({ rows }) => {
|
||||
if (rows.length > 0) return;
|
||||
db<{ id: string }>(`SELECT id FROM freight.trains WHERE code = $1`, [trainCode]).then(
|
||||
({ rows: trains }) => {
|
||||
expect(trains, `built train ${trainCode}`).to.have.length(1);
|
||||
apiPost(opsStaff, `/api/train-scheduling/${kind}/schedules`, {
|
||||
routeId: null,
|
||||
scheduleDate: opts.departure.toISOString(),
|
||||
trainId: trains[0].id,
|
||||
originCode: EXP_ORIGIN,
|
||||
destinationCode: EXP_DEST,
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the schedule's capacity is the built consist, not a loco-derived
|
||||
* number. Worth asserting in every scenario's setup: if a future change lets
|
||||
* the length recompute win again, EVERY scenario's arithmetic shifts and the
|
||||
* exact-fit cases fail somewhere far from the cause.
|
||||
*/
|
||||
export function expectExportCapacity(departure: Date, wagons = EXPORT_CONSIST) {
|
||||
dbExportSchedule(departure).then(({ rows }) => {
|
||||
expect(rows, "flow-two export schedule").to.have.length(1);
|
||||
expect(rows[0].max_wagons, `consist capacity = ${wagons}`).to.eq(wagons);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the wagon-TYPE verdict — this batch's headline assertion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wagons a booking holds, BROKEN DOWN BY WAGON TYPE.
|
||||
*
|
||||
* A plain count cannot catch the bug this batch is about. A container booking
|
||||
* handed 40 wagons out of a 35-wagon NW5 pool reads as "40 wagons allocated",
|
||||
* which is indistinguishable from the correct answer on a 60-slot train — until
|
||||
* marshalling, when five of those wagons turn out to be bulk hoppers.
|
||||
*
|
||||
* Reads the type off `train_set_wagons.wagon_type_id`: the SLOT's type is
|
||||
* authoritative even before a physical wagon is pinned to it.
|
||||
*/
|
||||
export function wagonsByType(bookingId: string) {
|
||||
return db<{ code: string; n: string }>(
|
||||
`SELECT wt.code, count(DISTINCT wba.train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||||
WHERE wba.booking_id = $1 AND wba.deleted_at IS NULL
|
||||
GROUP BY wt.code`,
|
||||
[bookingId],
|
||||
).then(({ rows }) => new Map(rows.map((r) => [r.code, Number(r.n)])));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a booking holds exactly `wagons` slots and ALL of them are the given
|
||||
* pool's type — never one borrowed from a neighbouring pool.
|
||||
*
|
||||
* The second half is the assertion that survives any change to who boards:
|
||||
* whatever the engine decides about admission, a container booking that ever
|
||||
* holds a CW4 is a defect.
|
||||
*/
|
||||
export function expectPoolAllocation(suffix: string, pool: Pool, wagons: number) {
|
||||
const type = POOL_TYPE[pool];
|
||||
withBooking(suffix, (b) =>
|
||||
wagonsByType(b.id).then((byType) => {
|
||||
expect(byType.get(type) ?? 0, `${suffix} holds ${wagons} × ${type} (${pool})`).to.eq(
|
||||
wagons,
|
||||
);
|
||||
byType.forEach((n, code) => {
|
||||
if (code !== type) {
|
||||
expect(n, `${suffix} borrowed ${n} × ${code} from another pool`).to.eq(0);
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a booking never exceeds its POOL, whatever else the engine decided.
|
||||
*
|
||||
* Weaker than `expectPoolAllocation` on purpose — the scenarios where the
|
||||
* engine may legitimately reject OR split OR partially fill still have this one
|
||||
* hard ceiling in common, and asserting it covers every branch without the
|
||||
* spec having to pick one.
|
||||
*/
|
||||
export function expectWithinPool(suffix: string, pool: Pool, poolSize: number) {
|
||||
const type = POOL_TYPE[pool];
|
||||
withBooking(suffix, (b) =>
|
||||
wagonsByType(b.id).then((byType) =>
|
||||
expect(
|
||||
byType.get(type) ?? 0,
|
||||
`${suffix} is capped by the ${pool} pool (${poolSize} × ${type}), not by the consist`,
|
||||
).to.be.at.most(poolSize),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert NO booking on this schedule holds a wagon outside its own pool.
|
||||
*
|
||||
* The train-wide form of `expectPoolAllocation`, and the one that catches a
|
||||
* cross-pool leak the per-booking assertions would miss if a scenario forgot to
|
||||
* name every booking.
|
||||
*/
|
||||
export function expectNoPoolLeak(departure: Date) {
|
||||
withExportSched(departure, (s) =>
|
||||
db<{ freight_type: string; code: string; n: string }>(
|
||||
`SELECT b.freight_type, wt.code, count(*) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.bookings b ON b.id = wba.booking_id
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL
|
||||
GROUP BY b.freight_type, wt.code`,
|
||||
[s.id],
|
||||
).then(({ rows }) => {
|
||||
rows.forEach((r) => {
|
||||
if (r.freight_type === "CONTAINER") {
|
||||
expect(r.code, `containers ride ${POOL_TYPE.CNT} only`).to.eq(POOL_TYPE.CNT);
|
||||
} else {
|
||||
expect(r.code, `bulk rides ${POOL_TYPE.BLK} only`).to.eq(POOL_TYPE.BLK);
|
||||
}
|
||||
});
|
||||
// The flatbed pool is allow-listed to nothing (seed section 5), so ANY
|
||||
// allocation against it is a leak by construction.
|
||||
const flt = rows.find((r) => r.code === POOL_TYPE.FLT);
|
||||
expect(flt, `nothing may ride the un-allow-listed ${POOL_TYPE.FLT} pool`).to.be.undefined;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the per-edge verdict
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wagons committed on each corridor edge, read back from what the engine
|
||||
* ACTUALLY allocated — direction-agnostic, so export and intercity legs sum
|
||||
* onto one profile.
|
||||
*
|
||||
* This, not the train-wide total, is the assertion the segment scenarios exist
|
||||
* for. A train-wide count of 106 on a 60-wagon train reads as an overbook until
|
||||
* the legs are separated; a train-wide count of 60 hides a booking that charged
|
||||
* the whole route when it should have charged two edges.
|
||||
*/
|
||||
export function exportEdgeLoadFromDb(scheduleId: string) {
|
||||
return db<{ origin: string; destination: string; wagons: string }>(
|
||||
`SELECT o.code AS origin, d.code AS destination,
|
||||
count(DISTINCT wba.train_set_wagon_id) AS wagons
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id
|
||||
JOIN freight.yards o ON o.id = b.origin_yard_id
|
||||
JOIN freight.yards d ON d.id = b.destination_yard_id
|
||||
JOIN freight.wagon_booking_allocations wba
|
||||
ON wba.booking_id = b.id AND wba.deleted_at IS NULL
|
||||
WHERE tsb.train_schedule_id = $1
|
||||
AND tsb.deleted_at IS NULL AND b.deleted_at IS NULL
|
||||
GROUP BY o.code, d.code`,
|
||||
[scheduleId],
|
||||
).then(({ rows }) => {
|
||||
const byCode = new Map(STOPS.map((s) => [STOP[s] as string, s as Stop]));
|
||||
return edgeLoad(
|
||||
rows.map((r) => {
|
||||
const from = byCode.get(r.origin);
|
||||
const to = byCode.get(r.destination);
|
||||
expect(from, `booking origin ${r.origin} is on the corridor`).to.not.be.undefined;
|
||||
expect(to, `booking destination ${r.destination} is on the corridor`).to.not.be
|
||||
.undefined;
|
||||
return { from: from as Stop, to: to as Stop, wagons: Number(r.wagons) };
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the per-edge load the schedule ended up carrying, and that no edge
|
||||
* exceeded the consist.
|
||||
*
|
||||
* `expected` is the FULL five-edge profile — writing it out in full is
|
||||
* deliberate. An assertion on the peak alone passes on a plan that put the
|
||||
* right total on the wrong edges, which is precisely the segment-reuse bug.
|
||||
*/
|
||||
export function expectExportEdgeLoad(
|
||||
departure: Date,
|
||||
expected: number[],
|
||||
capacity = EXPORT_CONSIST,
|
||||
) {
|
||||
expect(expected, "one entry per corridor edge").to.have.length(5);
|
||||
withExportSched(departure, (s) =>
|
||||
exportEdgeLoadFromDb(s.id).then((load) => {
|
||||
expect(load, "wagons committed per corridor edge").to.deep.eq(expected);
|
||||
load.forEach((w, e) =>
|
||||
expect(w, `edge ${e} (${EDGE_NAMES[e]}) within the consist`).to.be.at.most(capacity),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a booking rides the train on exactly the leg it was sold, holding
|
||||
* `wagons` slots. Guards the half-failure a total-only assertion misses: a
|
||||
* booking allocated onto the right train but charged against the whole route.
|
||||
*/
|
||||
export function expectExportBookingLeg(suffix: string, leg: Leg) {
|
||||
withBooking(suffix, (b) => {
|
||||
db<{ origin: string; destination: string; wagons: string }>(
|
||||
`SELECT o.code AS origin, d.code AS destination,
|
||||
count(DISTINCT wba.train_set_wagon_id) AS wagons
|
||||
FROM freight.bookings b
|
||||
JOIN freight.yards o ON o.id = b.origin_yard_id
|
||||
JOIN freight.yards d ON d.id = b.destination_yard_id
|
||||
LEFT JOIN freight.wagon_booking_allocations wba
|
||||
ON wba.booking_id = b.id AND wba.deleted_at IS NULL
|
||||
WHERE b.id = $1
|
||||
GROUP BY o.code, d.code`,
|
||||
[b.id],
|
||||
).then(({ rows }) => {
|
||||
expect(rows, `${suffix} booking row`).to.have.length(1);
|
||||
expect(rows[0].origin, `${suffix} origin`).to.eq(STOP[leg.from]);
|
||||
expect(rows[0].destination, `${suffix} destination`).to.eq(STOP[leg.to]);
|
||||
expect(Number(rows[0].wagons), `${suffix} holds ${leg.wagons} wagons`).to.eq(leg.wagons);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a booking holds NO wagons — the rejected/waitlisted side of a verdict.
|
||||
*
|
||||
* Deliberately not an assertion on booking STATUS: a booking refused at
|
||||
* export-accept time, one that lost a batch, and one whose offer lapsed all
|
||||
* carry different statuses but agree on the thing that matters — it consumed no
|
||||
* capacity.
|
||||
*/
|
||||
export function expectNoWagons(suffix: string) {
|
||||
withBooking(suffix, (b) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n FROM freight.wagon_booking_allocations
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[b.id],
|
||||
).then(({ rows }) => expect(Number(rows[0].n), `${suffix} holds no wagons`).to.eq(0)),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// export accept — FCFS, and the rejection shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Ops accepts an export operation request and the accept is EXPECTED TO FAIL.
|
||||
*
|
||||
* `acceptExport` (import-utils.ts:919) asserts a 2xx and then polls for a
|
||||
* reserved status — it cannot express "this one must be turned away", which is
|
||||
* half the scenarios in this batch. This is its refusal-side twin: it asserts
|
||||
* the call was refused, hands back the response so the spec can inspect the
|
||||
* reason, and never polls.
|
||||
*
|
||||
* Returns the response for `expectCapacityRefusal`.
|
||||
*/
|
||||
export function acceptExportExpectingRefusal(suffix: string) {
|
||||
return withBookingChain(suffix).then((b) =>
|
||||
apiPost(
|
||||
opsStaff,
|
||||
`/api/bookings/${b.id}/operation/review`,
|
||||
{ decision: "ACCEPT" },
|
||||
false, // failOnStatusCode — a 4xx IS the expected outcome here
|
||||
).then((res) => {
|
||||
expect(res.status, `${suffix} was refused, not accepted`).to.be.within(400, 422);
|
||||
return res;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a refusal is about CAPACITY, and names the constraint.
|
||||
*
|
||||
* The reason this matters: "no train has room" and "booking not found" and
|
||||
* "wrong status" are all 4xx, and a spec that only asserted the status code
|
||||
* passes when the scenario never actually ran. Worse, a capacity refusal that
|
||||
* does not name the wagon TYPE is the specific failure TC-02 is about — the
|
||||
* customer is told the train is full when 25 wagons stand empty, because they
|
||||
* are the wrong kind.
|
||||
*
|
||||
* `namesType` is opt-in rather than always-on: not every refusal path has type
|
||||
* information to give, and a scenario should state which it expects.
|
||||
*/
|
||||
export function expectCapacityRefusal(
|
||||
res: Cypress.Response<unknown>,
|
||||
opts: { namesType?: Pool } = {},
|
||||
) {
|
||||
const body = JSON.stringify(res.body);
|
||||
expect(body, "refused on capacity, not on an unrelated gate").to.match(
|
||||
/capacity|fit|full|room|wagon|space|no train/i,
|
||||
);
|
||||
if (opts.namesType) {
|
||||
// The wagon type, the pool letter, or a plain-language name for it — any
|
||||
// of the three tells the customer WHICH pool ran out.
|
||||
const type = POOL_TYPE[opts.namesType];
|
||||
const words =
|
||||
opts.namesType === "CNT"
|
||||
? /container|NW5/i
|
||||
: opts.namesType === "BLK"
|
||||
? /bulk|CW4/i
|
||||
: /flat|NW6/i;
|
||||
expect(
|
||||
body,
|
||||
`refusal names the ${opts.namesType} (${type}) pool, not just "train full"`,
|
||||
).to.match(words);
|
||||
}
|
||||
}
|
||||
|
||||
/** `withBooking` as a chainable, so a caller can `.then()` on the row. */
|
||||
export function withBookingChain(suffix: string) {
|
||||
return db<{ id: string; status: string }>(
|
||||
`SELECT b.id, b.status FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
|
||||
ORDER BY b.created_at DESC LIMIT 1`,
|
||||
[suffix],
|
||||
).then(({ rows }) => {
|
||||
expect(rows, `${suffix} booking`).to.have.length(1);
|
||||
return rows[0];
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// intercity: booking the DOMESTIC legs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* File an intercity CONTAINER booking and walk it to FULLY_EXECUTED, ready for
|
||||
* staff to assign onto a passing train.
|
||||
*
|
||||
* An intercity booking MUST NOT pin a `scheduledDate`. The engine rejects a
|
||||
* DOMESTIC booking that names a day (g6_corridor.cy.ts:253) because an
|
||||
* intercity shipment does not choose its train — staff put it on whichever one
|
||||
* passes with room. So this is deliberately NOT `bookAndClear`, which requires
|
||||
* one, and NOT `acceptOperation`, which is the import day-pool path.
|
||||
*
|
||||
* The clearance gate still applies: every contract booking is born in
|
||||
* AWAITING_DOCUMENTS regardless of direction (contract-booking.service.ts:211),
|
||||
* so a booking that is merely created is not yet assignable.
|
||||
*/
|
||||
export function bookIntercityContainers(opts: {
|
||||
suffix: string;
|
||||
runStamp: string;
|
||||
isoSeed: number;
|
||||
twenty?: number;
|
||||
forty?: number;
|
||||
vgmTons?: number;
|
||||
}) {
|
||||
bookContainers({
|
||||
suffix: opts.suffix,
|
||||
runStamp: opts.runStamp,
|
||||
isoSeed: opts.isoSeed,
|
||||
twenty: opts.twenty,
|
||||
forty: opts.forty,
|
||||
vgmTons: opts.vgmTons,
|
||||
// scheduledDate deliberately omitted — see above.
|
||||
});
|
||||
clearIntercityToFullyExecuted(opts.suffix);
|
||||
}
|
||||
|
||||
/** The bulk twin of `bookIntercityContainers` — same no-scheduledDate rule. */
|
||||
export function bookIntercityBulk(opts: {
|
||||
suffix: string;
|
||||
tons: number;
|
||||
/** Any seeded `freight.cargo_types.code` — see bookBulk. */
|
||||
cargoCode?: string;
|
||||
}) {
|
||||
bookBulk({
|
||||
suffix: opts.suffix,
|
||||
tons: opts.tons,
|
||||
cargoCode: opts.cargoCode,
|
||||
});
|
||||
clearIntercityToFullyExecuted(opts.suffix);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// intercity assignment on an export train
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Offer intercity (DOMESTIC) bookings to the EXPORT train the way staff do, IN
|
||||
* ORDER, and assert which ones the engine took.
|
||||
*
|
||||
* flow2-utils' `acceptIntercity` cannot be reused: it resolves the schedule
|
||||
* with `dbSchedule(departure)` on the DEFAULT import args, so it looks for a
|
||||
* DJIB_PORT→KALITY schedule and finds nothing.
|
||||
*
|
||||
* The endpoint is a per-train batch call that always answers 200 with
|
||||
* `{ accepted, rejected }` — a booking that does not fit its leg comes back in
|
||||
* `rejected`, NOT as a 4xx. A spec asserting only the status code would pass on
|
||||
* a train that took nobody, so this asserts the partition itself.
|
||||
*
|
||||
* ORDER MATTERS and is the caller's to choose: the budget shrinks as the loop
|
||||
* walks `bookingIds`, so the priority rule under test IS the order sent.
|
||||
*/
|
||||
export function acceptIntercityOnExport(opts: {
|
||||
departure: Date;
|
||||
/** Suffixes in the order staff offer them — this IS the priority under test. */
|
||||
accept: string[];
|
||||
/** Suffixes expected back in `rejected` (did not fit their leg). */
|
||||
reject?: string[];
|
||||
}) {
|
||||
const wanted = [...opts.accept, ...(opts.reject ?? [])];
|
||||
const ids: Record<string, string> = {};
|
||||
wanted.forEach((suffix) =>
|
||||
withBooking(suffix, (b) => {
|
||||
ids[suffix] = b.id;
|
||||
}),
|
||||
);
|
||||
return dbExportSchedule(opts.departure).then(({ rows }) => {
|
||||
expect(rows, "flow-two export schedule").to.have.length(1);
|
||||
return apiPost(
|
||||
opsStaff,
|
||||
`/api/train-scheduling/schedules/${rows[0].id}/intercity/accept`,
|
||||
{ bookingIds: wanted.map((suffix) => ids[suffix]) },
|
||||
).then((res) => {
|
||||
expect(res.status, "intercity accept answered").to.be.oneOf([200, 201]);
|
||||
const body = res.body as {
|
||||
accepted: string[];
|
||||
rejected: Array<{ bookingId: string; reason: string }>;
|
||||
};
|
||||
const rejectedIds = body.rejected.map((r) => r.bookingId);
|
||||
opts.accept.forEach((suffix) =>
|
||||
expect(body.accepted, `${suffix} accepted onto the train`).to.include(ids[suffix]),
|
||||
);
|
||||
(opts.reject ?? []).forEach((suffix) =>
|
||||
expect(rejectedIds, `${suffix} refused — its leg is full`).to.include(ids[suffix]),
|
||||
);
|
||||
return res;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// export-specific engine facts the scenarios assert against
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether EXPORT split is switched on for this run.
|
||||
*
|
||||
* There is NO per-booking and NO per-schedule split flag. `isSplitEligible`
|
||||
* (booking-batch.service.ts:2570) allows IMPORT and DOMESTIC splits always, and
|
||||
* EXPORT splits ONLY when `exportSplitEnabled` — which reads
|
||||
* `process.env.FREIGHT_EXPORT_SPLIT === "true"` on the API process
|
||||
* (booking-batch.service.ts:394).
|
||||
*
|
||||
* The API is a separate process from Cypress, so the spec cannot read that env
|
||||
* var directly and cannot flip it. It is surfaced as a Cypress env var the
|
||||
* runner sets to MATCH how the API was started; tcx14 asserts the engine's
|
||||
* behaviour agrees with what was declared, which is what makes a silently
|
||||
* flipped flag a test failure rather than a surprise in production.
|
||||
*/
|
||||
export function exportSplitEnabled(): boolean {
|
||||
const env = Cypress.env() as Record<string, unknown>;
|
||||
return String(env.FREIGHT_EXPORT_SPLIT) === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a booking's split state matches what the flag permits.
|
||||
*
|
||||
* Both directions are asserted because both are bugs: an export booking split
|
||||
* with the flag OFF is an engine that ignored its own gate, and this suite is
|
||||
* as interested in that as in the reverse.
|
||||
*/
|
||||
export function expectSplitAllowed(suffix: string, wasSplit: boolean) {
|
||||
withBooking(suffix, (b) => {
|
||||
db<{ is_split: boolean }>(`SELECT is_split FROM freight.bookings WHERE id = $1`, [
|
||||
b.id,
|
||||
]).then(({ rows }) => {
|
||||
expect(Boolean(rows[0].is_split), `${suffix} is_split`).to.eq(wasSplit);
|
||||
if (rows[0].is_split) {
|
||||
expect(
|
||||
exportSplitEnabled(),
|
||||
`${suffix} was split — only legal with FREIGHT_EXPORT_SPLIT=true`,
|
||||
).to.eq(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
421
e2e/freight/cypress/e2e/flows/flow_two/flow2-utils.ts
Normal file
421
e2e/freight/cypress/e2e/flows/flow_two/flow2-utils.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* Shared helpers for FLOW-TWO — the SEGMENT REUSE suite (tc01 … tc22).
|
||||
*
|
||||
* Group 1 (../g1_s*.cy.ts) asks one question: does the train fill to its slot
|
||||
* count? Flow-two asks the harder one: does capacity free up WHEN A BOOKING
|
||||
* GETS OFF? Two bookings whose legs don't overlap ride the same physical wagons
|
||||
* — so a 53-wagon train can carry 53 + 53 wagons of cargo on A→B and B→F.
|
||||
*
|
||||
* The engine already models this (corridor-capacity.util.ts): a schedule's
|
||||
* route is an ordered stop list, capacity is tracked PER EDGE, and a booking
|
||||
* charges only the edges between its own origin and destination. What is NOT
|
||||
* covered anywhere else is whether that holds end-to-end through the real
|
||||
* booking → clearance → batch → allocation pipeline. That is this suite.
|
||||
*
|
||||
* The corridor, from ../import-utils (CORRIDOR):
|
||||
*
|
||||
* A B C D E F
|
||||
* DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY
|
||||
* edge: 0 1 2 3 4
|
||||
*
|
||||
* A booking's leg is pinned by its CONTRACT ROUTE, not by the booking payload —
|
||||
* seedImportContract takes originCode/destCode and the booking inherits them
|
||||
* (bookings.service.ts resolves the contract route into origin/destinationYardId).
|
||||
* So `seedLegContract({ suffix: "B1", from: "A", to: "D" })` is the whole
|
||||
* mechanism: one contract per leg shape, one booking on it.
|
||||
*
|
||||
* TRADE DIRECTION follows the yards' countries, not our intent:
|
||||
* - from A (DJIB_PORT, Djibouti) → IMPORT: gets a booking window, enters the
|
||||
* batch, is what `bookAndClear` + `closeWindowAndRunBatch` drive.
|
||||
* - B…F only (all Ethiopian) → DOMESTIC/intercity: NO window, NO batch. Staff
|
||||
* assign it onto a passing train (intercity.service.ts). See
|
||||
* `assignIntercity` below — a domestic booking that is merely created has
|
||||
* consumed nothing, and asserting capacity before assigning it is the
|
||||
* single easiest way to write a green test that proves nothing.
|
||||
*
|
||||
* No module-level mutable state — same rule as g1-utils: Cypress re-evaluates
|
||||
* the spec bundle on cross-origin visits, so rows are resolved by stamped
|
||||
* reference, never by a captured id.
|
||||
*/
|
||||
|
||||
import {
|
||||
CORRIDOR,
|
||||
apiPost,
|
||||
db,
|
||||
dbSchedule,
|
||||
departureAt,
|
||||
opsStaff,
|
||||
pollDb,
|
||||
seedImportContract,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import { G1_WAGONS } from "../g1-utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the corridor, in the letters the scenarios are written in
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Scenario letter → corridor yard code. A is the port; F is Addis. */
|
||||
export const STOP = {
|
||||
A: CORRIDOR[0], // DJIB_PORT — Djibouti, so any A→x booking is IMPORT
|
||||
B: CORRIDOR[1], // NAGAD
|
||||
C: CORRIDOR[2], // DIRE_DAWA
|
||||
D: CORRIDOR[3], // E2E_AWASH
|
||||
E: CORRIDOR[4], // MOJO
|
||||
F: CORRIDOR[5], // KALITY
|
||||
} as const;
|
||||
|
||||
export type Stop = keyof typeof STOP;
|
||||
/** Stop letters in corridor order — index doubles as the stop's position. */
|
||||
export const STOPS = ["A", "B", "C", "D", "E", "F"] as const;
|
||||
|
||||
/** Edges a leg occupies, half-open [from, to) — mirrors CorridorLeg. */
|
||||
export function edgesOf(from: Stop, to: Stop): number[] {
|
||||
const a = STOPS.indexOf(from);
|
||||
const b = STOPS.indexOf(to);
|
||||
expect(a, `${from} is on the corridor`).to.be.gte(0);
|
||||
expect(b, `${to} is on the corridor`).to.be.gte(0);
|
||||
expect(a, `${from}→${to} runs forward along the corridor`).to.be.lessThan(b);
|
||||
return Array.from({ length: b - a }, (_, i) => a + i);
|
||||
}
|
||||
|
||||
/** Whether two legs share at least one edge — i.e. compete for wagons. */
|
||||
export function legsOverlap(l1: [Stop, Stop], l2: [Stop, Stop]): boolean {
|
||||
const a = edgesOf(...l1);
|
||||
const b = edgesOf(...l2);
|
||||
return a.some((e) => b.includes(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons committed on each of the corridor's 5 edges by a set of legs.
|
||||
* The arithmetic every scenario's header table states in prose — computed here
|
||||
* so the spec can assert its own premise before trusting the engine's answer.
|
||||
*/
|
||||
export function edgeLoad(legs: Array<{ from: Stop; to: Stop; wagons: number }>): number[] {
|
||||
const load = [0, 0, 0, 0, 0];
|
||||
legs.forEach((l) => edgesOf(l.from, l.to).forEach((e) => (load[e] += l.wagons)));
|
||||
return load;
|
||||
}
|
||||
|
||||
/** The busiest edge and how much it carries — the leg a rejection should name. */
|
||||
export function peakEdge(legs: Array<{ from: Stop; to: Stop; wagons: number }>) {
|
||||
const load = edgeLoad(legs);
|
||||
const peak = Math.max(...load);
|
||||
return { edge: load.indexOf(peak), wagons: peak, load };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// contracts pinned to a leg
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Seed one contract whose route IS the booking's leg. Every flow-two booking
|
||||
* needs its own contract for exactly this reason: the leg lives on the
|
||||
* contract, so N legs means N contracts even for one customer.
|
||||
*
|
||||
* Direction is derived, not passed: A→x crosses the border (IMPORT), anything
|
||||
* inside B…F is DOMESTIC. Getting this wrong doesn't fail loudly — a contract
|
||||
* seeded IMPORT on an all-Ethiopian route books fine and then never enters a
|
||||
* batch, so the spec times out far from the cause.
|
||||
*/
|
||||
export function seedLegContract(opts: {
|
||||
suffix: string;
|
||||
reference: string;
|
||||
from: Stop;
|
||||
to: Stop;
|
||||
freight?: "CONTAINER" | "BULK";
|
||||
customs?: boolean;
|
||||
}) {
|
||||
edgesOf(opts.from, opts.to); // asserts the leg is forward and on-corridor
|
||||
seedImportContract({
|
||||
suffix: opts.suffix,
|
||||
reference: opts.reference,
|
||||
originCode: STOP[opts.from],
|
||||
destCode: STOP[opts.to],
|
||||
direction: opts.from === "A" ? "IMPORT" : "DOMESTIC",
|
||||
freight: opts.freight,
|
||||
customs: opts.customs,
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether this leg rides as an import (windowed/batched) or intercity. */
|
||||
export function isImportLeg(from: Stop): boolean {
|
||||
return from === "A";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// intercity: the domestic legs, which never see a batch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Offer intercity bookings to a train the way staff do, IN ORDER, and assert
|
||||
* which ones the engine took.
|
||||
*
|
||||
* `POST schedules/:id/intercity/accept` is a per-train batch call that always
|
||||
* answers 200 with `{ accepted, rejected }` — a booking that doesn't fit its
|
||||
* leg is reported in `rejected`, NOT as a 4xx. A spec that only asserted the
|
||||
* status code would pass on a train that took nobody, so this asserts the
|
||||
* partition itself.
|
||||
*
|
||||
* ORDER MATTERS and is the caller's to choose: the budget shrinks as the loop
|
||||
* walks `bookingIds`, so the priority rule under test (FIFO, import-first, …)
|
||||
* is expressed as the order the ids are sent in.
|
||||
*
|
||||
* Accept RESERVES — it opens a pay window; wagons are allocated on payment
|
||||
* (booking-batch.service.ts:3217 `reserve`). So an accepted intercity booking
|
||||
* holds no `wagon_booking_allocations` until `markPaid`.
|
||||
*/
|
||||
export function acceptIntercity(opts: {
|
||||
departure: Date;
|
||||
/** Suffixes in the order staff offer them — this IS the priority under test. */
|
||||
accept: string[];
|
||||
/** Suffixes expected back in `rejected` (didn't fit their leg). */
|
||||
reject?: string[];
|
||||
}) {
|
||||
const wanted = [...opts.accept, ...(opts.reject ?? [])];
|
||||
// Resolve every suffix to its booking id first: the endpoint takes ids, and
|
||||
// the assertions below have to map ids back to the scenario's letters.
|
||||
const ids: Record<string, string> = {};
|
||||
wanted.forEach((suffix) =>
|
||||
withBooking(suffix, (b) => {
|
||||
ids[suffix] = b.id;
|
||||
}),
|
||||
);
|
||||
// dbSchedule (not withSchedule) so the whole call stays one chain the spec
|
||||
// can .then() on — withSchedule returns void.
|
||||
return dbSchedule(opts.departure).then(({ rows }) => {
|
||||
expect(rows, "flow-two schedule").to.have.length(1);
|
||||
return apiPost(
|
||||
opsStaff,
|
||||
`/api/train-scheduling/schedules/${rows[0].id}/intercity/accept`,
|
||||
{ bookingIds: wanted.map((suffix) => ids[suffix]) },
|
||||
).then((res) => {
|
||||
expect(res.status, "intercity accept answered").to.be.oneOf([200, 201]);
|
||||
const body = res.body as {
|
||||
accepted: string[];
|
||||
rejected: Array<{ bookingId: string; reason: string }>;
|
||||
};
|
||||
const rejectedIds = body.rejected.map((r) => r.bookingId);
|
||||
opts.accept.forEach((suffix) =>
|
||||
expect(body.accepted, `${suffix} accepted onto the train`).to.include(ids[suffix]),
|
||||
);
|
||||
(opts.reject ?? []).forEach((suffix) =>
|
||||
expect(rejectedIds, `${suffix} refused — its leg is full`).to.include(ids[suffix]),
|
||||
);
|
||||
return res;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The reason text the engine gave for refusing a booking — so a scenario can
|
||||
* assert the refusal is CAPACITY on this leg and not some unrelated gate
|
||||
* ("Booking not found", "not waiting"), which would otherwise make a wrong
|
||||
* rejection look like the right one.
|
||||
*/
|
||||
export function expectRejectReason(res: Cypress.Response<unknown>, suffix: string) {
|
||||
withBooking(suffix, (b) => {
|
||||
const body = res.body as {
|
||||
rejected: Array<{ bookingId: string; reason: string }>;
|
||||
};
|
||||
const mine = body.rejected.find((r) => r.bookingId === b.id);
|
||||
expect(mine, `${suffix} appears in rejected`).to.not.be.undefined;
|
||||
expect(mine?.reason, `${suffix} refused on capacity, not on a gate`).to.match(
|
||||
/fit|capacity/i,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// two trains on one route-day — Group 3
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Departure times for a two-train day, deliberately DISTINCT.
|
||||
*
|
||||
* Train selection is first-fit over candidates sorted by departure time
|
||||
* (booking-batch.service.ts:2334), and `Array.prototype.sort` is stable — so
|
||||
* two schedules sharing one timestamp fall back to DB row order, which is not
|
||||
* deterministic. Giving the pair different hours makes "the earlier train wins"
|
||||
* a rule the spec can actually assert instead of a coin flip.
|
||||
*/
|
||||
export function twoTrainDay(baseHoursAhead = 12) {
|
||||
const first = departureAt(baseHoursAhead);
|
||||
const second = new Date(first.getTime() + 2 * 3_600_000);
|
||||
return { first, second };
|
||||
}
|
||||
|
||||
/** Which schedule a booking ended up on, as the caller's own label. */
|
||||
export function expectOnTrain(
|
||||
suffix: string,
|
||||
departure: Date,
|
||||
label = departure.toISOString(),
|
||||
) {
|
||||
dbSchedule(departure).then(({ rows }) => {
|
||||
expect(rows, `schedule ${label}`).to.have.length(1);
|
||||
withBooking(suffix, (b) =>
|
||||
expect(b.train_schedule_id, `${suffix} rides ${label}`).to.eq(rows[0].id),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a booking did NOT get quietly divided between two trains.
|
||||
*
|
||||
* The engine never splits one booking across two schedules — a remainder
|
||||
* becomes a SEPARATE booking, and only after payment
|
||||
* (remainder-placement.service.ts:27). So exactly one `train_schedule_bookings`
|
||||
* row per booking is the invariant; two would mean that rule had broken.
|
||||
*/
|
||||
export function expectNotSplitAcrossTrains(suffix: string) {
|
||||
withBooking(suffix, (b) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT train_schedule_id) AS n
|
||||
FROM freight.train_schedule_bookings
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[b.id],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), `${suffix} rides at most one train`).to.be.at.most(1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the per-leg verdict — what every flow-two scenario ends on
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wagons committed on each corridor edge, read back from what the engine
|
||||
* actually allocated. The reconstruction mirrors CorridorBudget: resolve every
|
||||
* booking on the schedule to its leg, then add its distinct wagon count to
|
||||
* every edge that leg spans.
|
||||
*
|
||||
* This — not the train-wide total — is the assertion flow-two exists for. A
|
||||
* train-wide count of 106 on a 53-wagon train reads as an overbook until the
|
||||
* legs are separated, and a train-wide count of 53 hides a booking that
|
||||
* charged the whole route when it should have charged two edges.
|
||||
*/
|
||||
export function edgeLoadFromDb(scheduleId: string) {
|
||||
return db<{ origin: string; destination: string; wagons: string }>(
|
||||
`SELECT o.code AS origin, d.code AS destination,
|
||||
count(DISTINCT wba.train_set_wagon_id) AS wagons
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id
|
||||
JOIN freight.yards o ON o.id = b.origin_yard_id
|
||||
JOIN freight.yards d ON d.id = b.destination_yard_id
|
||||
JOIN freight.wagon_booking_allocations wba
|
||||
ON wba.booking_id = b.id AND wba.deleted_at IS NULL
|
||||
WHERE tsb.train_schedule_id = $1
|
||||
AND tsb.deleted_at IS NULL AND b.deleted_at IS NULL
|
||||
GROUP BY o.code, d.code`,
|
||||
[scheduleId],
|
||||
).then(({ rows }) => {
|
||||
const byCode = new Map(STOPS.map((s) => [STOP[s] as string, s as Stop]));
|
||||
return edgeLoad(
|
||||
rows.map((r) => {
|
||||
const from = byCode.get(r.origin);
|
||||
const to = byCode.get(r.destination);
|
||||
expect(from, `booking origin ${r.origin} is on the corridor`).to.not.be.undefined;
|
||||
expect(to, `booking destination ${r.destination} is on the corridor`).to.not.be
|
||||
.undefined;
|
||||
return { from: from as Stop, to: to as Stop, wagons: Number(r.wagons) };
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the per-edge load the schedule ended up carrying, and that no edge
|
||||
* exceeded the consist.
|
||||
*
|
||||
* `expected` is the full 5-edge profile — writing it out in full is deliberate:
|
||||
* an assertion on the peak alone passes on a plan that put the right total on
|
||||
* the wrong edges, which is precisely the reuse bug.
|
||||
*/
|
||||
export function expectEdgeLoad(
|
||||
departure: Date,
|
||||
expected: number[],
|
||||
capacity = G1_WAGONS,
|
||||
) {
|
||||
expect(expected, "one entry per corridor edge").to.have.length(5);
|
||||
dbSchedule(departure).then(({ rows }) => {
|
||||
expect(rows, "flow-two schedule").to.have.length(1);
|
||||
edgeLoadFromDb(rows[0].id).then((load) => {
|
||||
expect(load, "wagons committed per corridor edge").to.deep.eq(expected);
|
||||
load.forEach((w, e) =>
|
||||
expect(w, `edge ${e} (${STOPS[e]}→${STOPS[e + 1]}) within the consist`).to.be.at.most(
|
||||
capacity,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a booking rides the train on exactly the leg it was sold, holding
|
||||
* `wagons` slots. Guards the half-failure a total-only assertion misses: a
|
||||
* booking allocated onto the right train but charged against the whole route.
|
||||
*/
|
||||
export function expectBookingLeg(
|
||||
suffix: string,
|
||||
leg: { from: Stop; to: Stop; wagons: number },
|
||||
) {
|
||||
withBooking(suffix, (b) => {
|
||||
db<{ origin: string; destination: string; wagons: string }>(
|
||||
`SELECT o.code AS origin, d.code AS destination,
|
||||
count(DISTINCT wba.train_set_wagon_id) AS wagons
|
||||
FROM freight.bookings b
|
||||
JOIN freight.yards o ON o.id = b.origin_yard_id
|
||||
JOIN freight.yards d ON d.id = b.destination_yard_id
|
||||
LEFT JOIN freight.wagon_booking_allocations wba
|
||||
ON wba.booking_id = b.id AND wba.deleted_at IS NULL
|
||||
WHERE b.id = $1
|
||||
GROUP BY o.code, d.code`,
|
||||
[b.id],
|
||||
).then(({ rows }) => {
|
||||
expect(rows, `${suffix} booking row`).to.have.length(1);
|
||||
expect(rows[0].origin, `${suffix} origin`).to.eq(STOP[leg.from]);
|
||||
expect(rows[0].destination, `${suffix} destination`).to.eq(STOP[leg.to]);
|
||||
expect(Number(rows[0].wagons), `${suffix} holds ${leg.wagons} wagons`).to.eq(
|
||||
leg.wagons,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a booking holds NO wagons on this train — the rejected/waitlisted side
|
||||
* of a leg verdict. Deliberately not `expectWaitlisted` (g1-utils): a booking
|
||||
* refused at intercity-assign time keeps its own status and never reaches the
|
||||
* waiting list at all, so the portable assertion is "consumed no capacity".
|
||||
*/
|
||||
export function expectNoAllocation(suffix: string) {
|
||||
withBooking(suffix, (b) => {
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n FROM freight.wagon_booking_allocations
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[b.id],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), `${suffix} holds no wagons`).to.eq(0),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until a booking is riding a schedule with wagons on it. Assign and batch
|
||||
* both settle asynchronously (the 10s window tick), so a bare read right after
|
||||
* the call races the engine.
|
||||
*/
|
||||
export function expectAllocated(suffix: string, wagons: number) {
|
||||
withBooking(suffix, (b) =>
|
||||
pollDb<{ n: string }>(
|
||||
`${suffix} allocated ${wagons} wagons`,
|
||||
`SELECT count(DISTINCT train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[b.id],
|
||||
(row) => Number(row?.n ?? 0) === wagons,
|
||||
20,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-01 — a non-overlapping chain fills the train exactly, three times.
|
||||
*
|
||||
* B1 A→B 53 wagons edges [0]
|
||||
* B2 B→D 53 wagons edges [1,2]
|
||||
* B3 D→F 53 wagons edges [3,4]
|
||||
*
|
||||
* edge: 0 1 2 3 4
|
||||
* load: 53 53 53 53 53 peak 53 of 53
|
||||
*
|
||||
* 159 wagons of cargo on a 53-wagon train, and nothing is overbooked: B1's
|
||||
* wagons are emptied at NAGAD and carry B2, whose wagons are emptied at
|
||||
* E2E_AWASH and carry B3. This is the premise the whole suite rests on — if
|
||||
* capacity were tracked train-wide, B2 would be refused with the train "full"
|
||||
* while every wagon on the D→F stretch rolls empty.
|
||||
*
|
||||
* Why the assertion is the 5-edge PROFILE and not a total: a train-wide count
|
||||
* of 159 is equally consistent with a broken engine that let three bookings
|
||||
* overbook one leg. Only the per-edge reconstruction distinguishes reuse from
|
||||
* overbooking (see expectEdgeLoad in ./flow2-utils).
|
||||
*
|
||||
* B1 is IMPORT (A = DJIB_PORT crosses the border): it rides the booking window
|
||||
* and the batch. B2 and B3 are wholly Ethiopian and therefore DOMESTIC: no
|
||||
* window, no batch — staff accept them onto the passing train, which is the
|
||||
* intercity path. Both paths charge the SAME CorridorBudget, which is exactly
|
||||
* why this scenario mixes them.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
resetCorridorDay,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
acceptIntercity,
|
||||
edgeLoad,
|
||||
expectAllocated,
|
||||
expectBookingLeg,
|
||||
expectEdgeLoad,
|
||||
peakEdge,
|
||||
seedLegContract,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
/**
|
||||
* 53 wagons per booking = 53×40FT (one container per wagon). 40ft only: a 20ft
|
||||
* pair shares a wagon, so an all-40ft shape keeps wagons == containers and the
|
||||
* arithmetic in the header table stays readable.
|
||||
*/
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "B", forty: G1_WAGONS, wagons: G1_WAGONS },
|
||||
B2: { from: "B", to: "D", forty: G1_WAGONS, wagons: G1_WAGONS },
|
||||
B3: { from: "D", to: "F", forty: G1_WAGONS, wagons: G1_WAGONS },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
const ALL = ["B1", "B2", "B3"] as const;
|
||||
/** B1 crosses the border; B2/B3 are domestic ride-alongs. */
|
||||
const INTERCITY = ["B2", "B3"] as const;
|
||||
|
||||
describe("F2·TC-01: a non-overlap chain reuses every wagon twice", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
ALL.forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("the three legs never overlap and each fills the consist exactly", () => {
|
||||
const legs = ALL.map((s) => ({ ...SHAPES[s] }));
|
||||
const { load, wagons } = peakEdge(legs);
|
||||
expect(load, "every edge carries one full consist").to.deep.eq([53, 53, 53, 53, 53]);
|
||||
expect(wagons, "peak edge never exceeds the train").to.eq(G1_WAGONS);
|
||||
expect(
|
||||
legs.reduce((sum, l) => sum + l.wagons, 0),
|
||||
"159 wagons of cargo on a 53-wagon train",
|
||||
).to.eq(3 * G1_WAGONS);
|
||||
// The reuse claim, stated as arithmetic: any two of these legs share no edge.
|
||||
expect(edgeLoad([legs[0], legs[2]]), "B1 and B3 are fully disjoint").to.deep.eq([
|
||||
53, 0, 0, 53, 53,
|
||||
]);
|
||||
});
|
||||
|
||||
it("operations schedules the 53-wagon built train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({ departure: DEPARTURE });
|
||||
});
|
||||
|
||||
it("B1 books the import leg A→B and takes the whole train to NAGAD", () => {
|
||||
bookAndClear({
|
||||
suffix: "B1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 200,
|
||||
forty: SHAPES.B1.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
markPaid("B1");
|
||||
withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id));
|
||||
expectAllocated("B1", SHAPES.B1.wagons);
|
||||
});
|
||||
|
||||
it("B2 and B3 book their domestic legs and wait for a passing train", () => {
|
||||
let isoSeed = 400;
|
||||
INTERCITY.forEach((suffix) => {
|
||||
// No scheduledDate: a DOMESTIC booking may not pin a day or a schedule —
|
||||
// staff choose its train at accept time (bookings.service.ts:1069).
|
||||
bookAndClear({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed,
|
||||
forty: SHAPES[suffix].forty,
|
||||
scheduledDate: undefined as unknown as string,
|
||||
});
|
||||
isoSeed += SHAPES[suffix].forty;
|
||||
});
|
||||
});
|
||||
|
||||
it("both ride-alongs board the same train — B1's wagons are free past NAGAD", () => {
|
||||
// Offered together, in order. Neither may be refused: B2 draws on edges
|
||||
// [1,2] and B3 on [3,4], and B1 holds only edge [0].
|
||||
acceptIntercity({ departure: DEPARTURE, accept: [...INTERCITY] });
|
||||
INTERCITY.forEach((suffix) => {
|
||||
markPaid(suffix);
|
||||
expectAllocated(suffix, SHAPES[suffix].wagons);
|
||||
});
|
||||
});
|
||||
|
||||
it("every booking rides exactly the leg it was sold", () => {
|
||||
ALL.forEach((suffix) => expectBookingLeg(suffix, SHAPES[suffix]));
|
||||
});
|
||||
|
||||
it("the train carries a full consist on all five edges and overbooks none", () => {
|
||||
expectEdgeLoad(DEPARTURE, [53, 53, 53, 53, 53]);
|
||||
// Nobody waited: the reuse means there was never a shortage to wait for.
|
||||
withSchedule(DEPARTURE, (s) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL`,
|
||||
[s.id],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), "all three bookings ride this train").to.eq(3),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
170
e2e/freight/cypress/e2e/flows/flow_two/tc02_overlap_spike.cy.ts
Normal file
170
e2e/freight/cypress/e2e/flows/flow_two/tc02_overlap_spike.cy.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-02 — an overlap spike rejects only the third booking.
|
||||
*
|
||||
* B1 A→D 40 wagons edges [0,1,2]
|
||||
* B2 B→E 25 wagons edges [1,2,3]
|
||||
* B3 C→D 10 wagons edges [2]
|
||||
*
|
||||
* edge: 0 1 2 3 4
|
||||
* B1: 40 40 40 · ·
|
||||
* B2: · 25 25 25 ·
|
||||
* B3: · · 10 · ·
|
||||
* load: 40 65 75 25 0
|
||||
*
|
||||
* Two things are being asserted, and the second is the one that matters.
|
||||
*
|
||||
* 1. B3 cannot board: edge 2 (DIRE_DAWA→E2E_AWASH) would carry 75 of 53.
|
||||
* 2. B1 and B2 still ride. A train-wide capacity check would have refused B2
|
||||
* as well — 40+25 = 65 > 53 — even though B2's own worst edge is only 65…
|
||||
* which is itself over. So the honest arithmetic here is: this train is
|
||||
* OVERSUBSCRIBED from edge 1 onward, and the engine must resolve it per
|
||||
* edge, in offer order, not by a train-wide total.
|
||||
*
|
||||
* With a 53-wagon consist, edge 1 already carries 65 once B2 boards, so B2
|
||||
* does NOT fit whole either. The scenario as specified assumes capacity 60 and
|
||||
* still overflows at 65 — meaning B2's fate is the same on both: refused whole,
|
||||
* offered the part that fits. That partial offer IS the expected behaviour
|
||||
* (intercity.service.ts:236 offerIntercityPartial), so the spec asserts it
|
||||
* rather than pretending B2 boards intact.
|
||||
*
|
||||
* B3 is the pure case and the one the header claim rests on: it is small (10
|
||||
* wagons), it is refused, and the reason must be its OWN leg — with edges 0, 3
|
||||
* and 4 visibly free, a message that calls the whole train full would be wrong.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
resetCorridorDay,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
acceptIntercity,
|
||||
expectAllocated,
|
||||
expectBookingLeg,
|
||||
expectEdgeLoad,
|
||||
expectNoAllocation,
|
||||
expectRejectReason,
|
||||
peakEdge,
|
||||
seedLegContract,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "D", forty: 40, wagons: 40 },
|
||||
B2: { from: "B", to: "E", forty: 25, wagons: 25 },
|
||||
B3: { from: "C", to: "D", forty: 10, wagons: 10 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
/** Room left on B2's worst edge once B1 holds edges 0-2: 53 - 40 = 13. */
|
||||
const B2_PARTIAL_CEILING = G1_WAGONS - SHAPES.B1.wagons;
|
||||
|
||||
describe("F2·TC-02: the saturated leg rejects, the free legs do not", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("the spike lands on edge C→D and nowhere else", () => {
|
||||
const { edge, wagons, load } = peakEdge([SHAPES.B1, SHAPES.B2, SHAPES.B3]);
|
||||
expect(load, "per-edge demand").to.deep.eq([40, 65, 75, 25, 0]);
|
||||
expect(edge, "the saturated edge is C→D (index 2)").to.eq(2);
|
||||
expect(wagons, "demand on the spike").to.eq(75);
|
||||
expect(wagons, "the spike exceeds the consist").to.be.greaterThan(G1_WAGONS);
|
||||
// Edges 0, 3 and 4 stay under the cap — a train-wide verdict is therefore
|
||||
// provably wrong here, which is the whole point of the scenario.
|
||||
expect(load[0], "A→B has room").to.be.at.most(G1_WAGONS);
|
||||
expect(load[3], "D→E has room").to.be.at.most(G1_WAGONS);
|
||||
});
|
||||
|
||||
it("operations schedules the 53-wagon built train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({ departure: DEPARTURE });
|
||||
});
|
||||
|
||||
it("B1 takes 40 wagons from the port to E2E_AWASH", () => {
|
||||
bookAndClear({
|
||||
suffix: "B1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 600,
|
||||
forty: SHAPES.B1.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
markPaid("B1");
|
||||
withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id));
|
||||
expectAllocated("B1", SHAPES.B1.wagons);
|
||||
expectBookingLeg("B1", SHAPES.B1);
|
||||
});
|
||||
|
||||
it("B2 and B3 book their domestic legs", () => {
|
||||
bookAndClear({
|
||||
suffix: "B2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 700,
|
||||
forty: SHAPES.B2.forty,
|
||||
scheduledDate: undefined as unknown as string,
|
||||
});
|
||||
bookAndClear({
|
||||
suffix: "B3",
|
||||
runStamp: stamp,
|
||||
isoSeed: 800,
|
||||
forty: SHAPES.B3.forty,
|
||||
scheduledDate: undefined as unknown as string,
|
||||
});
|
||||
});
|
||||
|
||||
it("neither ride-along fits whole, and the refusal is about their leg", () => {
|
||||
// Offered in order. B2 wants 25 on edges 1-3 with only 13 free on edges
|
||||
// 1-2; B3 wants 10 on edge 2, which B2's partial offer has since taken.
|
||||
acceptIntercity({
|
||||
departure: DEPARTURE,
|
||||
accept: [],
|
||||
reject: ["B2", "B3"],
|
||||
}).then((res) => {
|
||||
expectRejectReason(res, "B2");
|
||||
expectRejectReason(res, "B3");
|
||||
});
|
||||
});
|
||||
|
||||
it("the refused bookings hold no wagons at all", () => {
|
||||
// A partial OFFER is not an allocation: until the customer pays for the
|
||||
// reduced quantity, neither booking is on the train.
|
||||
expectNoAllocation("B2");
|
||||
expectNoAllocation("B3");
|
||||
});
|
||||
|
||||
it("B1 keeps its leg and no edge is overbooked", () => {
|
||||
expectEdgeLoad(DEPARTURE, [40, 40, 40, 0, 0]);
|
||||
// The room B2 could ever have been offered — bounded by B1's hold on the
|
||||
// shared edges, never by the train-wide free count (53-40 = 13, not 13+53).
|
||||
expect(B2_PARTIAL_CEILING, "room on B2's worst shared edge").to.eq(13);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-03 — a downstream booking is not punished for an upstream peak.
|
||||
*
|
||||
* B1 A→B 53 wagons edges [0]
|
||||
* B2 B→E 53 wagons edges [1,2,3]
|
||||
* B3 D→E 20 wagons edges [3] ← must be refused: 53+20 on edge 3
|
||||
* B4 E→F 50 wagons edges [4] ← must board: edge 4 is untouched
|
||||
*
|
||||
* edge: 0 1 2 3 4
|
||||
* B1: 53 · · · ·
|
||||
* B2: · 53 53 53 ·
|
||||
* B3: · · · 20 · (refused)
|
||||
* B4: · · · · 50
|
||||
* final: 53 53 53 53 50
|
||||
*
|
||||
* The assertion this scenario exists for is B4, and it is a NEGATIVE one:
|
||||
* the system must not reject B4 because of the upstream peak. Every edge from
|
||||
* 0 to 3 is at 53/53 — a train that is, by any train-wide reading, completely
|
||||
* full — and yet B4 rides on edge 4 without touching one wagon anybody else
|
||||
* holds. An engine that carried "the train is FULL" forward as a global flag
|
||||
* would refuse it, and that bug is invisible to any test whose bookings all
|
||||
* start at the port.
|
||||
*
|
||||
* B3 is the control: it IS refused, on edge 3 alone, which proves the engine
|
||||
* is still enforcing capacity rather than having simply stopped counting.
|
||||
*
|
||||
* Offer ORDER is B3 then B4 deliberately — B4 must survive being offered AFTER
|
||||
* a refusal, since a naive implementation that aborts the accept loop on the
|
||||
* first rejection would silently drop it.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
resetCorridorDay,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
acceptIntercity,
|
||||
edgeLoad,
|
||||
expectAllocated,
|
||||
expectBookingLeg,
|
||||
expectEdgeLoad,
|
||||
expectNoAllocation,
|
||||
expectRejectReason,
|
||||
seedLegContract,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "B", forty: G1_WAGONS, wagons: G1_WAGONS },
|
||||
B2: { from: "B", to: "E", forty: G1_WAGONS, wagons: G1_WAGONS },
|
||||
B3: { from: "D", to: "E", forty: 20, wagons: 20 },
|
||||
B4: { from: "E", to: "F", forty: 50, wagons: 50 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
describe("F2·TC-03: an upstream full train still carries a downstream leg", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
(["B1", "B2", "B3", "B4"] as const).forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("B3 collides on D→E while B4's edge stays untouched", () => {
|
||||
const seated = edgeLoad([SHAPES.B1, SHAPES.B2]);
|
||||
expect(seated, "edges once B1 and B2 are seated").to.deep.eq([53, 53, 53, 53, 0]);
|
||||
expect(
|
||||
seated[3] + SHAPES.B3.wagons,
|
||||
"B3 would push D→E past the consist",
|
||||
).to.be.greaterThan(G1_WAGONS);
|
||||
expect(seated[4], "E→F carries nobody yet").to.eq(0);
|
||||
expect(
|
||||
seated[4] + SHAPES.B4.wagons,
|
||||
"B4 fits E→F outright",
|
||||
).to.be.at.most(G1_WAGONS);
|
||||
});
|
||||
|
||||
it("operations schedules the 53-wagon built train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({ departure: DEPARTURE });
|
||||
});
|
||||
|
||||
it("B1 fills the train to NAGAD", () => {
|
||||
bookAndClear({
|
||||
suffix: "B1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 1000,
|
||||
forty: SHAPES.B1.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
markPaid("B1");
|
||||
withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id));
|
||||
expectAllocated("B1", SHAPES.B1.wagons);
|
||||
});
|
||||
|
||||
it("B2 takes the whole train onward from NAGAD — B1's wagons are free there", () => {
|
||||
bookAndClear({
|
||||
suffix: "B2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 1100,
|
||||
forty: SHAPES.B2.forty,
|
||||
scheduledDate: undefined as unknown as string,
|
||||
});
|
||||
acceptIntercity({ departure: DEPARTURE, accept: ["B2"] });
|
||||
markPaid("B2");
|
||||
expectAllocated("B2", SHAPES.B2.wagons);
|
||||
});
|
||||
|
||||
it("B3 and B4 book their domestic legs", () => {
|
||||
bookAndClear({
|
||||
suffix: "B3",
|
||||
runStamp: stamp,
|
||||
isoSeed: 1200,
|
||||
forty: SHAPES.B3.forty,
|
||||
scheduledDate: undefined as unknown as string,
|
||||
});
|
||||
bookAndClear({
|
||||
suffix: "B4",
|
||||
runStamp: stamp,
|
||||
isoSeed: 1300,
|
||||
forty: SHAPES.B4.forty,
|
||||
scheduledDate: undefined as unknown as string,
|
||||
});
|
||||
});
|
||||
|
||||
it("B3 is refused on D→E and B4 boards anyway", () => {
|
||||
// Order matters: B4 is offered after a refusal and must still be taken.
|
||||
acceptIntercity({
|
||||
departure: DEPARTURE,
|
||||
accept: ["B4"],
|
||||
reject: ["B3"],
|
||||
}).then((res) => expectRejectReason(res, "B3"));
|
||||
markPaid("B4");
|
||||
expectAllocated("B4", SHAPES.B4.wagons);
|
||||
});
|
||||
|
||||
it("B4 rides E→F while every upstream edge is full", () => {
|
||||
expectBookingLeg("B4", SHAPES.B4);
|
||||
expectNoAllocation("B3");
|
||||
expectEdgeLoad(DEPARTURE, [53, 53, 53, 53, 50]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-04 — a wagon returns to the pool mid-route, and the boundary is exact.
|
||||
*
|
||||
* B1 A→C 53 wagons (the whole train) edges [0,1]
|
||||
* B2 C→F 53 wagons (the whole train) edges [2,3,4]
|
||||
* B3 A→B 1 wagon edges [0] ← must be refused
|
||||
*
|
||||
* edge: 0 1 2 3 4
|
||||
* B1: 53 53 · · ·
|
||||
* B2: · · 53 53 53
|
||||
* B3: 1 · · · · (refused)
|
||||
* final: 53 53 53 53 53
|
||||
*
|
||||
* Two boundaries in one scenario, and they pull in opposite directions:
|
||||
*
|
||||
* - B2 must board. Every wagon B1 holds is released at DIRE_DAWA, so a train
|
||||
* that was 53/53 for two edges is 0/53 for the next three. This is the
|
||||
* strictest form of reuse: FULL-train handover at a single stop.
|
||||
*
|
||||
* - B3 must NOT board, and it asks for ONE wagon. 53+1 > 53 is the smallest
|
||||
* possible overflow, which is exactly where an off-by-one lives: a `<`
|
||||
* where `<=` belongs admits it, and every coarser test in this suite (10,
|
||||
* 20, 25 wagons over) would still pass. A 1-wagon probe is the only shape
|
||||
* that distinguishes "full" from "nearly full".
|
||||
*
|
||||
* B3 is offered LAST, after the train is already full on edge 0 — so its
|
||||
* refusal is a live capacity verdict, not a stale one computed before B1 paid.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
resetCorridorDay,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
acceptIntercity,
|
||||
expectAllocated,
|
||||
expectBookingLeg,
|
||||
expectEdgeLoad,
|
||||
expectNoAllocation,
|
||||
expectRejectReason,
|
||||
seedLegContract,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "C", forty: G1_WAGONS, wagons: G1_WAGONS },
|
||||
B2: { from: "C", to: "F", forty: G1_WAGONS, wagons: G1_WAGONS },
|
||||
// One 40ft container = one whole wagon. The smallest bookable overflow.
|
||||
B3: { from: "A", to: "B", forty: 1, wagons: 1 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
describe("F2·TC-04: full handover mid-route, and one wagon too many is refused", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("B3 overflows edge 0 by exactly one wagon", () => {
|
||||
expect(SHAPES.B1.wagons, "B1 IS the whole train").to.eq(G1_WAGONS);
|
||||
expect(SHAPES.B3.wagons, "B3 is the smallest possible booking").to.eq(1);
|
||||
expect(
|
||||
SHAPES.B1.wagons + SHAPES.B3.wagons,
|
||||
"one wagon over the cap — not two, not ten",
|
||||
).to.eq(G1_WAGONS + 1);
|
||||
});
|
||||
|
||||
it("operations schedules the 53-wagon built train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({ departure: DEPARTURE });
|
||||
});
|
||||
|
||||
it("B1 takes the entire train from the port to DIRE_DAWA", () => {
|
||||
bookAndClear({
|
||||
suffix: "B1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 1500,
|
||||
forty: SHAPES.B1.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
markPaid("B1");
|
||||
withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id));
|
||||
expectAllocated("B1", SHAPES.B1.wagons);
|
||||
expectEdgeLoad(DEPARTURE, [53, 53, 0, 0, 0]);
|
||||
});
|
||||
|
||||
it("B2 takes the entire train onward — every wagon is released at DIRE_DAWA", () => {
|
||||
bookAndClear({
|
||||
suffix: "B2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 1600,
|
||||
forty: SHAPES.B2.forty,
|
||||
scheduledDate: undefined as unknown as string,
|
||||
});
|
||||
acceptIntercity({ departure: DEPARTURE, accept: ["B2"] });
|
||||
markPaid("B2");
|
||||
expectAllocated("B2", SHAPES.B2.wagons);
|
||||
expectBookingLeg("B2", SHAPES.B2);
|
||||
});
|
||||
|
||||
it("B3 asks for one wagon on the saturated A→B leg and is refused", () => {
|
||||
bookAndClear({
|
||||
suffix: "B3",
|
||||
runStamp: stamp,
|
||||
isoSeed: 1700,
|
||||
forty: SHAPES.B3.forty,
|
||||
scheduledDate: undefined as unknown as string,
|
||||
});
|
||||
acceptIntercity({
|
||||
departure: DEPARTURE,
|
||||
accept: [],
|
||||
reject: ["B3"],
|
||||
}).then((res) => expectRejectReason(res, "B3"));
|
||||
expectNoAllocation("B3");
|
||||
});
|
||||
|
||||
it("the train is exactly full on every edge — nothing more, nothing less", () => {
|
||||
expectEdgeLoad(DEPARTURE, [53, 53, 53, 53, 53]);
|
||||
// The physical check behind the arithmetic: 53 wagons carried 106 wagons'
|
||||
// worth of cargo, so at least one wagon is allocated to BOTH bookings.
|
||||
withSchedule(DEPARTURE, (s) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||||
[s.id],
|
||||
).then(({ rows }) =>
|
||||
expect(
|
||||
Number(rows[0].n),
|
||||
"106 wagons of cargo rode 53 physical wagons",
|
||||
).to.eq(G1_WAGONS),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-05 — an import occupies wagons an intercity leg wanted downstream.
|
||||
*
|
||||
* B1 import A→D 45 wagons edges [0,1,2]
|
||||
* B2 intercity C→F 20 wagons edges [2,3,4] ← overlaps B1 on edge 2
|
||||
* B3 intercity B→C 15 wagons edges [1] ← overlaps B1 on edge 1
|
||||
*
|
||||
* edge: 0 1 2 3 4
|
||||
* B1: 45 45 45 · ·
|
||||
* B2: · · 20 20 20
|
||||
* B3: · 15 · · ·
|
||||
* demand: 45 60 65 20 20
|
||||
*
|
||||
* The scenario as specified is written for a 60-wagon train, where edge 1
|
||||
* lands on exactly 60 (B3 fits to the slot) and edge 2 on 65 (B2 does not).
|
||||
* This consist is 53, so BOTH overlaps overflow — 45+15 = 60 > 53 as well —
|
||||
* and asserting "B3 ok" verbatim would be asserting something false about this
|
||||
* train. What survives the change of consist, and is the real claim, is:
|
||||
*
|
||||
* an import booking that has already boarded holds its wagons across EVERY
|
||||
* edge of its own leg, and a later intercity booking is measured against the
|
||||
* edges it shares — not against the train's free wagon total.
|
||||
*
|
||||
* B2 makes that visible in the cleanest way available: edges 3 and 4 are
|
||||
* completely empty, so 33 wagons are free on two thirds of B2's leg, and it
|
||||
* must still be refused because of edge 2 alone. A test that only counted free
|
||||
* wagons train-wide would admit it.
|
||||
*
|
||||
* B3 is offered second, at 15 wagons against 8 free on edge 1 — also refused,
|
||||
* for its own edge. Both refusals are asserted to be capacity refusals, so a
|
||||
* gate failure ("not waiting", "not found") cannot masquerade as the right
|
||||
* answer.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
resetCorridorDay,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
acceptIntercity,
|
||||
edgeLoad,
|
||||
expectAllocated,
|
||||
expectBookingLeg,
|
||||
expectEdgeLoad,
|
||||
expectNoAllocation,
|
||||
expectRejectReason,
|
||||
peakEdge,
|
||||
seedLegContract,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "D", forty: 45, wagons: 45 },
|
||||
B2: { from: "C", to: "F", forty: 20, wagons: 20 },
|
||||
B3: { from: "B", to: "C", forty: 15, wagons: 15 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
/** Free wagons on the edges B1 holds, once B1 has boarded: 53 - 45. */
|
||||
const FREE_UNDER_IMPORT = G1_WAGONS - SHAPES.B1.wagons;
|
||||
|
||||
describe("F2·TC-05: an import blocks the intercity legs it overlaps", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("both intercity legs overlap the import, and both overflow this consist", () => {
|
||||
const { load } = peakEdge([SHAPES.B1, SHAPES.B2, SHAPES.B3]);
|
||||
expect(load, "per-edge demand").to.deep.eq([45, 60, 65, 20, 20]);
|
||||
expect(FREE_UNDER_IMPORT, "wagons left on the import's own edges").to.eq(8);
|
||||
expect(SHAPES.B2.wagons, "B2 wants more than edge 2 has left").to.be.greaterThan(
|
||||
FREE_UNDER_IMPORT,
|
||||
);
|
||||
expect(SHAPES.B3.wagons, "B3 wants more than edge 1 has left").to.be.greaterThan(
|
||||
FREE_UNDER_IMPORT,
|
||||
);
|
||||
// B2's own leg is mostly empty — which is why a train-wide free count would
|
||||
// wrongly admit it. Edges 3 and 4 carry nothing at all.
|
||||
const seated = edgeLoad([SHAPES.B1]);
|
||||
expect(seated.slice(3), "B2's downstream edges are empty").to.deep.eq([0, 0]);
|
||||
});
|
||||
|
||||
it("operations schedules the 53-wagon built train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({ departure: DEPARTURE });
|
||||
});
|
||||
|
||||
it("the import takes 45 wagons from the port to E2E_AWASH", () => {
|
||||
bookAndClear({
|
||||
suffix: "B1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 2000,
|
||||
forty: SHAPES.B1.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
markPaid("B1");
|
||||
withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id));
|
||||
expectAllocated("B1", SHAPES.B1.wagons);
|
||||
expectBookingLeg("B1", SHAPES.B1);
|
||||
expectEdgeLoad(DEPARTURE, [45, 45, 45, 0, 0]);
|
||||
});
|
||||
|
||||
it("the two intercity bookings are filed", () => {
|
||||
bookAndClear({
|
||||
suffix: "B2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 2100,
|
||||
forty: SHAPES.B2.forty,
|
||||
scheduledDate: undefined as unknown as string,
|
||||
});
|
||||
bookAndClear({
|
||||
suffix: "B3",
|
||||
runStamp: stamp,
|
||||
isoSeed: 2200,
|
||||
forty: SHAPES.B3.forty,
|
||||
scheduledDate: undefined as unknown as string,
|
||||
});
|
||||
});
|
||||
|
||||
it("both are refused on the edge they share with the import", () => {
|
||||
acceptIntercity({
|
||||
departure: DEPARTURE,
|
||||
accept: [],
|
||||
reject: ["B2", "B3"],
|
||||
}).then((res) => {
|
||||
expectRejectReason(res, "B2");
|
||||
expectRejectReason(res, "B3");
|
||||
});
|
||||
expectNoAllocation("B2");
|
||||
expectNoAllocation("B3");
|
||||
});
|
||||
|
||||
it("the import keeps exactly its own three edges", () => {
|
||||
// Unchanged from before the accept pass: a refused booking consumes nothing,
|
||||
// and the import was never asked to give anything back.
|
||||
expectEdgeLoad(DEPARTURE, [45, 45, 45, 0, 0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-06 — intercity fills the gap the import leaves behind.
|
||||
*
|
||||
* B1 import A→B 53 wagons edges [0]
|
||||
* B2 intercity B→E 53 wagons edges [1,2,3]
|
||||
* B3 intercity E→F 53 wagons edges [4]
|
||||
*
|
||||
* edge: 0 1 2 3 4
|
||||
* load: 53 53 53 53 53
|
||||
*
|
||||
* Same wagons, three sequential occupancies: the import unloads at the inland
|
||||
* dry port (NAGAD), an intercity booking takes those wagons on to MOJO, and a
|
||||
* third takes them the last stretch to Addis. Nobody waits, nobody splits.
|
||||
*
|
||||
* TC-01 proves the arithmetic; this proves the HANDOVER between service types.
|
||||
* The import rides the batch (window → close → batch → pay) and the two
|
||||
* intercity legs ride the staff accept path — two entirely different code
|
||||
* paths into the same CorridorBudget. A regression that let one path charge
|
||||
* the whole route while the other charged edges would show up here and nowhere
|
||||
* else: each path on its own is self-consistent.
|
||||
*
|
||||
* The 53/53/53 shape is deliberate. At full consist there is no slack to hide
|
||||
* a partial mischarge — if the import held even one wagon past NAGAD, B2 would
|
||||
* not fit whole and would come back as a partial offer instead.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
resetCorridorDay,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
acceptIntercity,
|
||||
expectAllocated,
|
||||
expectBookingLeg,
|
||||
expectEdgeLoad,
|
||||
peakEdge,
|
||||
seedLegContract,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "B", forty: G1_WAGONS, wagons: G1_WAGONS },
|
||||
B2: { from: "B", to: "E", forty: G1_WAGONS, wagons: G1_WAGONS },
|
||||
B3: { from: "E", to: "F", forty: G1_WAGONS, wagons: G1_WAGONS },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
const INTERCITY = ["B2", "B3"] as const;
|
||||
|
||||
describe("F2·TC-06: intercity takes over the wagons the import unloads", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("three full consists ride disjoint stretches of one corridor", () => {
|
||||
const { load, wagons } = peakEdge([SHAPES.B1, SHAPES.B2, SHAPES.B3]);
|
||||
expect(load, "every edge carries exactly one consist").to.deep.eq([53, 53, 53, 53, 53]);
|
||||
expect(wagons, "no edge is over the cap").to.eq(G1_WAGONS);
|
||||
expect(
|
||||
[SHAPES.B1, SHAPES.B2, SHAPES.B3].reduce((sum, s) => sum + s.wagons, 0),
|
||||
"159 wagons of cargo on a 53-wagon train",
|
||||
).to.eq(3 * G1_WAGONS);
|
||||
});
|
||||
|
||||
it("operations schedules the 53-wagon built train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({ departure: DEPARTURE });
|
||||
});
|
||||
|
||||
it("the import fills the train to the inland dry port", () => {
|
||||
bookAndClear({
|
||||
suffix: "B1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 2500,
|
||||
forty: SHAPES.B1.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
markPaid("B1");
|
||||
withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id));
|
||||
expectAllocated("B1", SHAPES.B1.wagons);
|
||||
expectEdgeLoad(DEPARTURE, [53, 0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
it("both intercity legs are filed and both board the same train", () => {
|
||||
let isoSeed = 2600;
|
||||
INTERCITY.forEach((suffix) => {
|
||||
bookAndClear({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed,
|
||||
forty: SHAPES[suffix].forty,
|
||||
scheduledDate: undefined as unknown as string,
|
||||
});
|
||||
isoSeed += SHAPES[suffix].forty;
|
||||
});
|
||||
// Neither may be refused, and neither may be reduced to a partial offer:
|
||||
// both ask for the full consist on edges the import does not hold.
|
||||
acceptIntercity({ departure: DEPARTURE, accept: [...INTERCITY] });
|
||||
INTERCITY.forEach((suffix) => {
|
||||
markPaid(suffix);
|
||||
expectAllocated(suffix, SHAPES[suffix].wagons);
|
||||
});
|
||||
});
|
||||
|
||||
it("each booking holds a full consist on its own stretch only", () => {
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
expectBookingLeg(suffix, SHAPES[suffix]),
|
||||
);
|
||||
expectEdgeLoad(DEPARTURE, [53, 53, 53, 53, 53]);
|
||||
});
|
||||
});
|
||||
189
e2e/freight/cypress/e2e/flows/flow_two/tc07_priority_rule.cy.ts
Normal file
189
e2e/freight/cypress/e2e/flows/flow_two/tc07_priority_rule.cy.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-07 — POLICY LOCK: what actually decides who boards first.
|
||||
*
|
||||
* B1 import A→F 30 wagons submitted SECOND
|
||||
* B2 import A→F 40 wagons submitted FIRST
|
||||
* B3 import A→F 30 wagons submitted LAST
|
||||
*
|
||||
* Demand 100 wagons on every edge; the consist is 53. Two of the three
|
||||
* cannot board, so the ORDER is the whole answer.
|
||||
*
|
||||
* THE RULE, AS IMPLEMENTED (booking-batch.service.ts:4051 resortPoolByPriority):
|
||||
*
|
||||
* isGovernment DESC
|
||||
* → window-cycle index of fullyExecutedAt ASC
|
||||
* → priorityScore DESC
|
||||
* → fullyExecutedAt ASC
|
||||
* → createdAt ASC
|
||||
*
|
||||
* There is NO import-vs-intercity term. Trade direction decides which POOL a
|
||||
* booking sits in, never its rank inside one. So the honest expectation for
|
||||
* this scenario is not "import priority" — it is: all three bookings are
|
||||
* non-government, all score equally (same wagon-count band, same currency, no
|
||||
* customs), so every term above collapses and the tiebreak is FIFO by
|
||||
* fullyExecutedAt, then createdAt.
|
||||
*
|
||||
* FIFO therefore predicts: B2 (first, 40w) boards, leaving 13 — B1 (30w) does
|
||||
* not fit and B3 (30w) does not fit. One confirmed, two waitlisted.
|
||||
*
|
||||
* WHAT THIS TEST IS FOR: it fails loudly if the rule changes silently. The
|
||||
* scenario was written expecting "define + assert priority rule (FIFO vs
|
||||
* import-priority)"; the answer is FIFO-with-score-above-it, and that answer is
|
||||
* now pinned here. If someone later adds an import-priority term, B3 (an import
|
||||
* submitted last) would board over B2 and this spec breaks — which is the point.
|
||||
*
|
||||
* The scoring assumption is asserted directly against the DB rather than
|
||||
* assumed: if a priority_configs row in the environment gives one of these
|
||||
* bookings a different score, the FIFO prediction is void and the spec says so
|
||||
* instead of failing somewhere downstream.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
resetCorridorDay,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
expectWaitlisted,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
expectAllocated,
|
||||
expectEdgeLoad,
|
||||
expectNoAllocation,
|
||||
seedLegContract,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "F", forty: 30, wagons: 30 },
|
||||
B2: { from: "A", to: "F", forty: 40, wagons: 40 },
|
||||
B3: { from: "A", to: "F", forty: 30, wagons: 30 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
/** Submission order — this IS the variable under test. */
|
||||
const ORDER = ["B2", "B1", "B3"] as const;
|
||||
/** FIFO's prediction: the first submitted boards; the rest cannot fit after it. */
|
||||
const EXPECTED_WINNER = "B2";
|
||||
const EXPECTED_LOSERS = ["B1", "B3"] as const;
|
||||
|
||||
describe("F2·TC-07: the boarding order is FIFO, and it is pinned", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("the three compete for one consist on every edge", () => {
|
||||
const total = (["B1", "B2", "B3"] as const).reduce(
|
||||
(sum, s) => sum + SHAPES[s].wagons,
|
||||
0,
|
||||
);
|
||||
expect(total, "100 wagons of demand").to.eq(100);
|
||||
expect(total, "nearly double the consist").to.be.greaterThan(G1_WAGONS);
|
||||
expect(
|
||||
SHAPES[EXPECTED_WINNER].wagons + SHAPES.B1.wagons,
|
||||
"no second booking fits behind the winner",
|
||||
).to.be.greaterThan(G1_WAGONS);
|
||||
});
|
||||
|
||||
it("operations schedules the 53-wagon built train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({ departure: DEPARTURE });
|
||||
});
|
||||
|
||||
it("the three bookings are submitted B2, then B1, then B3", () => {
|
||||
let isoSeed = 3000;
|
||||
ORDER.forEach((suffix) => {
|
||||
bookAndClear({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed,
|
||||
forty: SHAPES[suffix].forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += SHAPES[suffix].forty;
|
||||
});
|
||||
});
|
||||
|
||||
it("submission order is recorded as the batch will read it", () => {
|
||||
// fullyExecutedAt then createdAt are the last two sort terms; assert the
|
||||
// DB agrees B2 really is first, or the FIFO prediction below means nothing.
|
||||
db<{ suffix: string; created_at: string }>(
|
||||
`SELECT right(ct.reference, 2) AS suffix, b.created_at
|
||||
FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE $1 AND b.deleted_at IS NULL
|
||||
ORDER BY b.created_at ASC`,
|
||||
[`CTR-IMP-${stamp}-%`],
|
||||
).then(({ rows }) => {
|
||||
expect(
|
||||
rows.map((r) => r.suffix),
|
||||
"bookings are stored in submission order",
|
||||
).to.deep.eq([...ORDER]);
|
||||
});
|
||||
});
|
||||
|
||||
it("all three score equally — every term above FIFO is a tie", () => {
|
||||
// If this fails, the environment's priority_configs differ and the FIFO
|
||||
// prediction is void. Better to fail HERE, naming the reason, than to fail
|
||||
// on a winner assertion that looks like a capacity bug.
|
||||
db<{ suffix: string; priority_score: string; is_government: boolean }>(
|
||||
`SELECT right(ct.reference, 2) AS suffix, b.priority_score, b.is_government
|
||||
FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE $1 AND b.deleted_at IS NULL`,
|
||||
[`CTR-IMP-${stamp}-%`],
|
||||
).then(({ rows }) => {
|
||||
expect(rows, "three bookings").to.have.length(3);
|
||||
const scores = new Set(rows.map((r) => Number(r.priority_score ?? 0)));
|
||||
expect(scores.size, "no booking outranks another on score").to.eq(1);
|
||||
rows.forEach((r) =>
|
||||
expect(r.is_government, `${r.suffix} is commercial`).to.not.be.true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("POLICY: the earliest submission boards and the other two wait", () => {
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
markPaid(EXPECTED_WINNER);
|
||||
expectAllocated(EXPECTED_WINNER, SHAPES[EXPECTED_WINNER].wagons);
|
||||
EXPECTED_LOSERS.forEach((suffix) => {
|
||||
expectWaitlisted(suffix);
|
||||
expectNoAllocation(suffix);
|
||||
});
|
||||
});
|
||||
|
||||
it("the loser set is exactly the two later submissions, not an arbitrary pair", () => {
|
||||
// The failure this guards: a rule change that still confirms exactly one
|
||||
// booking, but a different one. Asserting "2 waitlisted" alone would pass.
|
||||
withBooking(EXPECTED_WINNER, (b) =>
|
||||
expect(b.train_schedule_id, `${EXPECTED_WINNER} holds the seat`).to.not.be.null,
|
||||
);
|
||||
expectEdgeLoad(DEPARTURE, Array(5).fill(SHAPES[EXPECTED_WINNER].wagons));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-08 — wagon-type pools are separate, even on one leg.
|
||||
*
|
||||
* The train is TRN-F2-MIX (seed-flow2-mixed-train.sql), a 50-wagon consist that
|
||||
* is deliberately NOT uniform:
|
||||
*
|
||||
* 30 × NW5 — the only type 20FT/40FT containers may ride
|
||||
* 20 × PW2 — the only type E2E_IMP_GRAINS bulk may ride
|
||||
*
|
||||
* The bookings, all on the SAME leg so nothing here is about segments:
|
||||
*
|
||||
* B1 import container A→F 40 wagons of containers
|
||||
* B2 import bulk A→F grains needing ~15 PW2 wagons
|
||||
* B3 import container A→F 5 wagons of containers
|
||||
*
|
||||
* B1 is the assertion. It asks for 40 wagons; the abstract budget says 50 are
|
||||
* free, so a slot-only engine admits it — and allocation then fails on wagon 31
|
||||
* with "no NW5 available", after the customer has paid for 40. That is the
|
||||
* exact failure wagon-stock-ledger.util.ts was written to prevent ("money taken
|
||||
* for space that never existed"). So B1 must NOT board whole: the container
|
||||
* pool is 30, not 50.
|
||||
*
|
||||
* B2 proves the separation runs both ways: the grains booking draws only on
|
||||
* PW2, so it is unaffected by however much of the NW5 pool is spoken for.
|
||||
*
|
||||
* B3 is the control that keeps this from passing for the wrong reason. If the
|
||||
* engine had simply gone conservative and stopped admitting anything, B3 would
|
||||
* fail too — but 5 containers fit whatever remains of the NW5 pool, so it must
|
||||
* board. "Refuses everything" and "respects pools" are indistinguishable
|
||||
* without it.
|
||||
*
|
||||
* All three ride the batch (all IMPORT, all A→F), so this is one window, one
|
||||
* batch pass, and the type separation is the only thing under test.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
bookBulk,
|
||||
clearToOperationRequestPending,
|
||||
acceptOperation,
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
resetCorridorDay,
|
||||
withBooking,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
} from "../g1-utils";
|
||||
import { seedLegContract, type Stop } from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
/** The mixed consist — see seed-flow2-mixed-train.sql. */
|
||||
const MIX_TRAIN = "TRN-F2-MIX";
|
||||
const CONTAINER_POOL = 30; // NW5
|
||||
const BULK_POOL = 20; // PW2
|
||||
const CONSIST = CONTAINER_POOL + BULK_POOL;
|
||||
|
||||
const SHAPES = {
|
||||
// 40 > the 30-wagon container pool, but < the 50-wagon consist. The gap
|
||||
// between those two numbers is the entire test.
|
||||
B1: { from: "A", to: "F", forty: 40, wagons: 40 },
|
||||
B3: { from: "A", to: "F", forty: 5, wagons: 5 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
/** Grains tonnage sized to sit inside the PW2 pool, never near its edge. */
|
||||
const BULK_TONS = 600;
|
||||
|
||||
describe("F2·TC-08: a container booking cannot consume bulk wagons", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-mixed-train.sql");
|
||||
seedLegContract({ suffix: "B1", reference: stampedRef("B1"), from: "A", to: "F" });
|
||||
seedLegContract({
|
||||
suffix: "B2",
|
||||
reference: stampedRef("B2"),
|
||||
from: "A",
|
||||
to: "F",
|
||||
freight: "BULK",
|
||||
});
|
||||
seedLegContract({ suffix: "B3", reference: stampedRef("B3"), from: "A", to: "F" });
|
||||
});
|
||||
|
||||
it("the consist really is split across two incompatible pools", () => {
|
||||
db<{ code: string; n: string }>(
|
||||
`SELECT wt.code, count(*) AS n
|
||||
FROM freight.wagons w
|
||||
JOIN freight.trains t ON t.id = w.train_id AND t.code = $1
|
||||
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||||
WHERE w.deleted_at IS NULL
|
||||
GROUP BY wt.code ORDER BY wt.code`,
|
||||
[MIX_TRAIN],
|
||||
).then(({ rows }) => {
|
||||
const byCode = new Map(rows.map((r) => [r.code, Number(r.n)]));
|
||||
expect(byCode.get("NW5"), "container-capable wagons").to.eq(CONTAINER_POOL);
|
||||
expect(byCode.get("PW2"), "bulk-only wagons").to.eq(BULK_POOL);
|
||||
});
|
||||
// The premise: B1 fits the CONSIST but not its own POOL. If these two
|
||||
// stopped straddling the pool boundary the test would prove nothing.
|
||||
expect(SHAPES.B1.wagons, "B1 fits the consist").to.be.at.most(CONSIST);
|
||||
expect(SHAPES.B1.wagons, "B1 does NOT fit the container pool").to.be.greaterThan(
|
||||
CONTAINER_POOL,
|
||||
);
|
||||
});
|
||||
|
||||
it("operations schedules the mixed-consist train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({
|
||||
departure: DEPARTURE,
|
||||
trainCode: MIX_TRAIN,
|
||||
wagons: CONSIST,
|
||||
});
|
||||
});
|
||||
|
||||
it("the two container bookings and the grains booking are filed", () => {
|
||||
bookAndClear({
|
||||
suffix: "B1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 3500,
|
||||
forty: SHAPES.B1.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
// Bulk has no container units, so it uses the bulk booking path and then
|
||||
// the same clearance gate every contract booking is born into.
|
||||
bookBulk({
|
||||
suffix: "B2",
|
||||
tons: BULK_TONS,
|
||||
cargoCode: "E2E_IMP_GRAINS",
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearToOperationRequestPending("B2", BOOKING_DAY);
|
||||
acceptOperation("B2");
|
||||
bookAndClear({
|
||||
suffix: "B3",
|
||||
runStamp: stamp,
|
||||
isoSeed: 3600,
|
||||
forty: SHAPES.B3.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
});
|
||||
|
||||
it("the batch runs", () => {
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
});
|
||||
|
||||
it("POOLS: no container booking is allocated a bulk wagon, ever", () => {
|
||||
// The invariant, stated directly against the allocation rows: every wagon
|
||||
// a CONTAINER booking holds is NW5, and every wagon a BULK booking holds is
|
||||
// PW2. This is the assertion that survives any change to who boards.
|
||||
withSchedule(DEPARTURE, (s) =>
|
||||
db<{ freight_type: string; code: string; n: string }>(
|
||||
// train_set_wagons carries wagon_type_id directly — the slot's type is
|
||||
// authoritative even before a physical wagon is pinned to it.
|
||||
`SELECT b.freight_type, wt.code, count(*) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.bookings b ON b.id = wba.booking_id
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL
|
||||
GROUP BY b.freight_type, wt.code`,
|
||||
[s.id],
|
||||
).then(({ rows }) => {
|
||||
rows.forEach((r) => {
|
||||
if (r.freight_type === "CONTAINER") {
|
||||
expect(r.code, "containers ride NW5 only").to.eq("NW5");
|
||||
} else {
|
||||
expect(r.code, "bulk rides PW2 only").to.eq("PW2");
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("B1 never holds more wagons than the container pool has", () => {
|
||||
// Whether B1 was refused outright or cut down to a partial offer is the
|
||||
// engine's choice; what it may NOT do is hand it 40 wagons out of a
|
||||
// 30-wagon pool. Asserting the ceiling covers both outcomes.
|
||||
withBooking("B1", (b) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[b.id],
|
||||
).then(({ rows }) =>
|
||||
expect(
|
||||
Number(rows[0].n),
|
||||
"B1 is capped by the NW5 pool, not by the 50-wagon consist",
|
||||
).to.be.at.most(CONTAINER_POOL),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("the grains booking is unaffected by the container pool's state", () => {
|
||||
withBooking("B2", (b) => {
|
||||
expect(b.status, "B2 was not rejected").to.not.eq("REJECTED");
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[b.id],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), "B2 stays inside the PW2 pool").to.be.at.most(BULK_POOL),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
168
e2e/freight/cypress/e2e/flows/flow_two/tc09_customs_hold.cy.ts
Normal file
168
e2e/freight/cypress/e2e/flows/flow_two/tc09_customs_hold.cy.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-09 — POLICY LOCK: a customs hold does NOT reserve wagons past
|
||||
* the booking's destination.
|
||||
*
|
||||
* B1 import A→C 50 wagons, customs-cleared contract, held at C
|
||||
* B2 intercity C→F 50 wagons
|
||||
* B3 intercity A→B 10 wagons — bookable only via the import lane, so it is
|
||||
* filed as a second import A→B and must be unaffected
|
||||
*
|
||||
* WHAT THE SCENARIO ASKED FOR vs WHAT THE SYSTEM DOES
|
||||
*
|
||||
* The scenario expects B2 to be "blocked/deferred until customs release" —
|
||||
* i.e. B1's wagons should NOT count as free at C while its cargo sits in
|
||||
* customs. **That behaviour does not exist in this codebase.** There is no
|
||||
* held-at-customs concept anywhere in the capacity path:
|
||||
*
|
||||
* - a booking's leg is ALWAYS strictly origin→destination
|
||||
* (corridor-capacity.util.ts:103 legOf — no caller ever extends toEdge);
|
||||
* - `clearance_status` is a CONTRACT column, never read by any capacity or
|
||||
* wagon-release code;
|
||||
* - wagon release on unload is unconditional (booking-journey.service.ts:505)
|
||||
* — the only thing that keeps a slot pinned is another allocation on it
|
||||
* still IN_TRANSIT. Clearance state is never consulted.
|
||||
*
|
||||
* So B2 boards. This spec asserts that, deliberately and with the gap written
|
||||
* down, rather than asserting a block that would fail today and be "fixed" by
|
||||
* deleting the test. It is a POLICY LOCK: if someone later teaches the budget
|
||||
* to hold wagons through customs, B2 stops boarding and this spec breaks —
|
||||
* which is the signal that the policy changed and this file must be revisited.
|
||||
*
|
||||
* B3 is the invariant that holds under EITHER policy: an upstream leg that
|
||||
* shares no edge with the hold is unaffected. That assertion is safe to keep
|
||||
* whichever way the customs question is eventually answered.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
resetCorridorDay,
|
||||
withBooking,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
acceptIntercity,
|
||||
expectAllocated,
|
||||
expectBookingLeg,
|
||||
expectEdgeLoad,
|
||||
seedLegContract,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "C", forty: 50, wagons: 50 },
|
||||
B2: { from: "C", to: "F", forty: 50, wagons: 50 },
|
||||
// Shares edge 0 with B1 — 50 + 3 fits the 53 consist, so if B3 is refused it
|
||||
// is a real regression and not an arithmetic accident.
|
||||
B3: { from: "A", to: "B", forty: 3, wagons: 3 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
describe("F2·TC-09: a customs-cleared import still releases its wagons at its destination", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
// B1 is the customs-clearing contract — the one whose cargo is "held at C".
|
||||
seedLegContract({
|
||||
suffix: "B1",
|
||||
reference: stampedRef("B1"),
|
||||
from: "A",
|
||||
to: "C",
|
||||
customs: true,
|
||||
});
|
||||
seedLegContract({ suffix: "B2", reference: stampedRef("B2"), from: "C", to: "F" });
|
||||
seedLegContract({ suffix: "B3", reference: stampedRef("B3"), from: "A", to: "B" });
|
||||
});
|
||||
|
||||
it("B1 and B3 share edge 0 and still fit; B2 shares nothing with B1", () => {
|
||||
expect(
|
||||
SHAPES.B1.wagons + SHAPES.B3.wagons,
|
||||
"B1 and B3 fit edge 0 together",
|
||||
).to.be.at.most(53);
|
||||
// B2 starts exactly where B1 ends: under the implemented policy they never
|
||||
// compete, no matter what customs is doing to B1's cargo.
|
||||
expect(SHAPES.B1.to, "B1 ends at C").to.eq(SHAPES.B2.from);
|
||||
});
|
||||
|
||||
it("operations schedules the 53-wagon built train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({ departure: DEPARTURE });
|
||||
});
|
||||
|
||||
it("the customs import and the small upstream import both board", () => {
|
||||
bookAndClear({
|
||||
suffix: "B1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 4000,
|
||||
forty: SHAPES.B1.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
bookAndClear({
|
||||
suffix: "B3",
|
||||
runStamp: stamp,
|
||||
isoSeed: 4100,
|
||||
forty: SHAPES.B3.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
markPaid("B1");
|
||||
markPaid("B3");
|
||||
withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id));
|
||||
expectAllocated("B1", SHAPES.B1.wagons);
|
||||
expectAllocated("B3", SHAPES.B3.wagons);
|
||||
});
|
||||
|
||||
it("B1 really is the customs-clearing booking", () => {
|
||||
// Without this, the scenario is just "two imports and an intercity" and the
|
||||
// customs premise is decoration.
|
||||
withBooking("B1", (b) =>
|
||||
db<{ customs_clearing_enabled: boolean; clearance_status: string }>(
|
||||
`SELECT ct.customs_clearing_enabled, ct.clearance_status
|
||||
FROM freight.contracts ct WHERE ct.id = $1`,
|
||||
[b.contract_id],
|
||||
).then(({ rows }) =>
|
||||
expect(rows[0].customs_clearing_enabled, "B1 clears customs").to.be.true,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("POLICY: B2 boards at C — the held cargo's wagons are free downstream", () => {
|
||||
bookAndClear({
|
||||
suffix: "B2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 4200,
|
||||
forty: SHAPES.B2.forty,
|
||||
scheduledDate: undefined as unknown as string,
|
||||
});
|
||||
// If this ever starts failing, the customs-hold policy has been implemented
|
||||
// and this whole spec must be rewritten to assert the block instead.
|
||||
acceptIntercity({ departure: DEPARTURE, accept: ["B2"] });
|
||||
markPaid("B2");
|
||||
expectAllocated("B2", SHAPES.B2.wagons);
|
||||
expectBookingLeg("B2", SHAPES.B2);
|
||||
});
|
||||
|
||||
it("B3's upstream leg is untouched by any of it", () => {
|
||||
// True under either policy — the assertion worth keeping regardless.
|
||||
expectBookingLeg("B3", SHAPES.B3);
|
||||
expectEdgeLoad(DEPARTURE, [53, 50, 50, 50, 50]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-10 — overflow spills onto the sibling train, and no booking is
|
||||
* silently divided.
|
||||
*
|
||||
* Two 53-wagon trains on the same route and day, TWO HOURS APART:
|
||||
*
|
||||
* T1 departs 12h from now 53 wagons
|
||||
* T2 departs 14h from now 53 wagons
|
||||
*
|
||||
* B1 A→F 50 wagons → T1 (earliest fitting train)
|
||||
* B2 A→F 40 wagons → T2 (13 left on T1, so T1 cannot take it whole)
|
||||
* B3 A→F 30 wagons → neither: 3 left on T1, 13 on T2
|
||||
*
|
||||
* Selection is FIRST-FIT over candidates sorted by departure time
|
||||
* (booking-batch.service.ts:2334-2342, then `trains.find(...)` at :2442). So
|
||||
* the rule under test is "earliest train that FITS", not "earliest train" and
|
||||
* not "emptiest train".
|
||||
*
|
||||
* THE DEPARTURE TIMES ARE LOAD-BEARING. `Array.prototype.sort` is stable, so
|
||||
* two schedules sharing a timestamp fall back to DB row order and "B1 goes to
|
||||
* T1" becomes a coin flip. twoTrainDay() spaces them deliberately — see its
|
||||
* docstring.
|
||||
*
|
||||
* B3 is the one that matters. Between the two trains there are 16 free wagons,
|
||||
* which is less than the 30 it needs — but on NO SINGLE train is there room,
|
||||
* and the engine must not stitch it together across both. A booking is never
|
||||
* divided between two schedules: a remainder becomes a separate booking, and
|
||||
* only after payment (remainder-placement.service.ts:27). B3 therefore
|
||||
* waitlists whole, and `expectNotSplitAcrossTrains` asserts exactly that.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
resetCorridorDay,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_TRAIN,
|
||||
G1_TRAIN_2,
|
||||
G1_WAGONS,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
expectWaitlisted,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
expectAllocated,
|
||||
expectNoAllocation,
|
||||
expectNotSplitAcrossTrains,
|
||||
expectOnTrain,
|
||||
seedLegContract,
|
||||
twoTrainDay,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const { first: T1, second: T2 } = twoTrainDay(12);
|
||||
const BOOKING_DAY = eatDayStr(T1);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "F", forty: 50, wagons: 50 },
|
||||
B2: { from: "A", to: "F", forty: 40, wagons: 40 },
|
||||
B3: { from: "A", to: "F", forty: 30, wagons: 30 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
const ORDER = ["B1", "B2", "B3"] as const;
|
||||
|
||||
describe("F2·TC-10: overflow moves to the sibling train, never across both", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
ORDER.forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("both trains are on the same day but not the same hour", () => {
|
||||
expect(eatDayStr(T2), "T2 shares the booking day").to.eq(BOOKING_DAY);
|
||||
expect(
|
||||
T2.getTime() - T1.getTime(),
|
||||
"distinct departures keep first-fit deterministic",
|
||||
).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
it("no single train can take B3, and the two trains together must not", () => {
|
||||
const freeOnT1 = G1_WAGONS - SHAPES.B1.wagons; // 3
|
||||
const freeOnT2 = G1_WAGONS - SHAPES.B2.wagons; // 13
|
||||
expect(freeOnT1, "room left on T1").to.eq(3);
|
||||
expect(freeOnT2, "room left on T2").to.eq(13);
|
||||
expect(SHAPES.B3.wagons, "B3 fits neither train alone").to.be.greaterThan(
|
||||
Math.max(freeOnT1, freeOnT2),
|
||||
);
|
||||
expect(
|
||||
freeOnT1 + freeOnT2,
|
||||
"and it does not even fit both combined — so no stitching either way",
|
||||
).to.be.lessThan(SHAPES.B3.wagons);
|
||||
});
|
||||
|
||||
it("operations schedules both trains on the same corridor day", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(T1);
|
||||
resetCorridorDay(T2);
|
||||
configureAndOpenSchedule({ departure: T1, trainCode: G1_TRAIN });
|
||||
configureAndOpenSchedule({ departure: T2, trainCode: G1_TRAIN_2 });
|
||||
});
|
||||
|
||||
it("the three bookings are filed in order", () => {
|
||||
let isoSeed = 4500;
|
||||
ORDER.forEach((suffix) => {
|
||||
bookAndClear({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed,
|
||||
forty: SHAPES[suffix].forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += SHAPES[suffix].forty;
|
||||
});
|
||||
});
|
||||
|
||||
it("B1 takes the earlier train, B2 spills to the later one", () => {
|
||||
closeWindowAndRunBatch(T1);
|
||||
closeWindowAndRunBatch(T2);
|
||||
expectOnTrain("B1", T1, "T1");
|
||||
expectOnTrain("B2", T2, "T2");
|
||||
markPaid("B1");
|
||||
markPaid("B2");
|
||||
withSchedule(T1, (s) => endPaymentPhase(s.id));
|
||||
withSchedule(T2, (s) => endPaymentPhase(s.id));
|
||||
expectAllocated("B1", SHAPES.B1.wagons);
|
||||
expectAllocated("B2", SHAPES.B2.wagons);
|
||||
});
|
||||
|
||||
it("B3 waitlists whole rather than being divided across the two trains", () => {
|
||||
expectWaitlisted("B3");
|
||||
expectNoAllocation("B3");
|
||||
expectNotSplitAcrossTrains("B3");
|
||||
});
|
||||
|
||||
it("neither train is overbooked", () => {
|
||||
[
|
||||
{ departure: T1, label: "T1", wagons: SHAPES.B1.wagons },
|
||||
{ departure: T2, label: "T2", wagons: SHAPES.B2.wagons },
|
||||
].forEach(({ departure, label, wagons }) =>
|
||||
withSchedule(departure, (s) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||||
[s.id],
|
||||
).then(({ rows }) => {
|
||||
expect(Number(rows[0].n), `${label} carries its booking`).to.eq(wagons);
|
||||
expect(Number(rows[0].n), `${label} within consist`).to.be.at.most(G1_WAGONS);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-11 — each train keeps its own corridor budget, and the pick is
|
||||
* deterministic.
|
||||
*
|
||||
* T1 departs 12h from now 53 wagons
|
||||
* T2 departs 14h from now 53 wagons
|
||||
*
|
||||
* B1 A→C 53 wagons → T1 (fills T1's edges 0-1)
|
||||
* B2 A→C 53 wagons → T2 (T1's edges 0-1 are gone; T2's are untouched)
|
||||
* B3 C→F 53 wagons → T1 (the EARLIEST train whose edges 2-4 are free)
|
||||
*
|
||||
* Two rules meet here, and B3 is where they meet.
|
||||
*
|
||||
* BUDGETS ARE PER TRAIN. B2 is identical to B1 in every respect and must still
|
||||
* board, because filling T1 says nothing about T2. A shared or global budget
|
||||
* would refuse it.
|
||||
*
|
||||
* THE PICK IS FIRST-FIT BY DEPARTURE TIME. After B1 and B2, edges 2-4 are free
|
||||
* on BOTH trains — B3 genuinely fits either. The implemented rule is the
|
||||
* earliest-departing candidate that fits (booking-batch.service.ts:2334 sorts
|
||||
* by scheduledDepartureDate, :2442 takes the first match), so B3 must land on
|
||||
* T1. Not "the emptier train", not "round robin", not whichever the database
|
||||
* happened to return first.
|
||||
*
|
||||
* That last distinction is why the two departures are two hours apart rather
|
||||
* than identical: with equal timestamps the sort is stable but the input order
|
||||
* is DB-dependent, and this assertion would flake rather than fail. The times
|
||||
* make the rule observable.
|
||||
*
|
||||
* All three are imports A→C / C→F on the same corridor day, so this is
|
||||
* decided entirely by the batch — no intercity path involved.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
resetCorridorDay,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_TRAIN,
|
||||
G1_TRAIN_2,
|
||||
G1_WAGONS,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
expectAllocated,
|
||||
expectBookingLeg,
|
||||
expectEdgeLoad,
|
||||
expectOnTrain,
|
||||
seedLegContract,
|
||||
twoTrainDay,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const { first: T1, second: T2 } = twoTrainDay(12);
|
||||
const BOOKING_DAY = eatDayStr(T1);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "C", forty: G1_WAGONS, wagons: G1_WAGONS },
|
||||
B2: { from: "A", to: "C", forty: G1_WAGONS, wagons: G1_WAGONS },
|
||||
B3: { from: "C", to: "F", forty: G1_WAGONS, wagons: G1_WAGONS },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
const ORDER = ["B1", "B2", "B3"] as const;
|
||||
|
||||
describe("F2·TC-11: per-train budgets, and the earliest fitting train wins", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
ORDER.forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("B3's leg is free on both trains, so the tie-break is the only decider", () => {
|
||||
// B1 and B2 occupy edges 0-1 on their respective trains; B3 wants 2-4.
|
||||
expect(SHAPES.B1.to, "the A→C bookings end where B3 begins").to.eq(SHAPES.B3.from);
|
||||
expect(T2.getTime(), "T2 departs after T1").to.be.greaterThan(T1.getTime());
|
||||
expect(eatDayStr(T2), "both trains are on the booking day").to.eq(BOOKING_DAY);
|
||||
});
|
||||
|
||||
it("operations schedules both trains on the same corridor day", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(T1);
|
||||
resetCorridorDay(T2);
|
||||
configureAndOpenSchedule({ departure: T1, trainCode: G1_TRAIN });
|
||||
configureAndOpenSchedule({ departure: T2, trainCode: G1_TRAIN_2 });
|
||||
});
|
||||
|
||||
it("the three bookings are filed in order", () => {
|
||||
let isoSeed = 5000;
|
||||
ORDER.forEach((suffix) => {
|
||||
bookAndClear({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed,
|
||||
forty: SHAPES[suffix].forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += SHAPES[suffix].forty;
|
||||
});
|
||||
closeWindowAndRunBatch(T1);
|
||||
closeWindowAndRunBatch(T2);
|
||||
});
|
||||
|
||||
it("the two identical bookings take one train each", () => {
|
||||
// B2 boarding at all IS the per-train-budget assertion.
|
||||
expectOnTrain("B1", T1, "T1");
|
||||
expectOnTrain("B2", T2, "T2");
|
||||
});
|
||||
|
||||
it("DETERMINISM: B3 takes T1, the earliest train whose leg is free", () => {
|
||||
expectOnTrain("B3", T1, "T1");
|
||||
});
|
||||
|
||||
it("both trains carry their cargo on the right edges", () => {
|
||||
ORDER.forEach((suffix) => {
|
||||
markPaid(suffix);
|
||||
expectAllocated(suffix, SHAPES[suffix].wagons);
|
||||
});
|
||||
withSchedule(T1, (s) => endPaymentPhase(s.id));
|
||||
withSchedule(T2, (s) => endPaymentPhase(s.id));
|
||||
ORDER.forEach((suffix) => expectBookingLeg(suffix, SHAPES[suffix]));
|
||||
// T1 carries B1 on edges 0-1 and B3 on edges 2-4 — full reuse on one train.
|
||||
expectEdgeLoad(T1, [53, 53, 53, 53, 53]);
|
||||
// T2 carries only B2, and only on its own two edges.
|
||||
expectEdgeLoad(T2, [53, 53, 0, 0, 0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-12 — POLICY LOCK: cancelling a train unpins its bookings back
|
||||
* to the pool; it does not rebook them.
|
||||
*
|
||||
* T1 departs 12h from now 53 wagons — carries B1, B2, B3
|
||||
* T2 departs 14h from now 53 wagons — the sibling
|
||||
*
|
||||
* B1 A→C 30 wagons
|
||||
* B2 C→F 30 wagons
|
||||
* B3 A→F 20 wagons
|
||||
*
|
||||
* Then T1 is cancelled.
|
||||
*
|
||||
* WHAT THE SCENARIO ASKED FOR vs WHAT THE SYSTEM DOES
|
||||
*
|
||||
* The scenario expects "all 3 attempt T2; those that fit confirm, rest
|
||||
* waitlist". What `cancelTrainSchedule` actually does
|
||||
* (train-scheduling.service.ts:3961, per-booking loop at :4035) is narrower:
|
||||
*
|
||||
* updateSchedulingFields(sb.bookingId, {
|
||||
* schedulingStatus: this.resolvePostUnassignStatus(booking),
|
||||
* trainScheduleId: null,
|
||||
* })
|
||||
*
|
||||
* — `trainScheduleId` is nulled and `schedulingStatus` becomes ELIGIBLE (or
|
||||
* HOLDING while a hold is live). The booking's own `status` is NOT touched: not
|
||||
* cancelled, not expired, not waitlisted. No sibling rebooking is attempted and
|
||||
* no fill is triggered from this method. The bookings simply re-enter the pool
|
||||
* (`findBatchPoolByCorridorDay` requires `sb.id IS NULL`, which they now
|
||||
* satisfy) and wait for some later pass on that route-day.
|
||||
*
|
||||
* So the assertions here are the ones the code supports, and they are the two
|
||||
* that actually protect the customer:
|
||||
*
|
||||
* IDENTITY — the same booking rows survive. Original ids, original
|
||||
* references, no duplicates minted. The scenario's "original booking IDs
|
||||
* preserved" is exactly this, and it is asserted by id.
|
||||
*
|
||||
* NO DOUBLE CHARGE — the invoice/payment rows are untouched by the cancel.
|
||||
* A rebooking flow that re-invoiced would show up here immediately.
|
||||
*
|
||||
* If sibling-rebooking is implemented later, the "still unpinned" assertion
|
||||
* breaks and this spec must be revisited — which is the intent.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
apiPost,
|
||||
db,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
opsStaff,
|
||||
resetCorridorDay,
|
||||
withBooking,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_TRAIN,
|
||||
G1_TRAIN_2,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
} from "../g1-utils";
|
||||
import { expectOnTrain, seedLegContract, twoTrainDay, type Stop } from "./flow2-utils";
|
||||
|
||||
const { first: T1, second: T2 } = twoTrainDay(12);
|
||||
const BOOKING_DAY = eatDayStr(T1);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "C", forty: 30, wagons: 30 },
|
||||
B2: { from: "C", to: "F", forty: 30, wagons: 30 },
|
||||
B3: { from: "A", to: "F", forty: 20, wagons: 20 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
const ALL = ["B1", "B2", "B3"] as const;
|
||||
/** Booking ids captured BEFORE the cancel, to prove identity survives it. */
|
||||
const idsBefore: Record<string, string> = {};
|
||||
|
||||
describe("F2·TC-12: a cancelled train releases its bookings without losing them", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
ALL.forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("all three fit T1 together — the cancel is what displaces them, not capacity", () => {
|
||||
// B1 and B2 are disjoint; B3 overlaps both. Peak edge = 30 + 20 = 50 ≤ 53.
|
||||
expect(SHAPES.B1.wagons + SHAPES.B3.wagons, "peak edge on T1").to.eq(50);
|
||||
});
|
||||
|
||||
it("operations schedules both trains on the same corridor day", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(T1);
|
||||
resetCorridorDay(T2);
|
||||
configureAndOpenSchedule({ departure: T1, trainCode: G1_TRAIN });
|
||||
configureAndOpenSchedule({ departure: T2, trainCode: G1_TRAIN_2 });
|
||||
});
|
||||
|
||||
it("the three bookings all land on T1", () => {
|
||||
let isoSeed = 5500;
|
||||
ALL.forEach((suffix) => {
|
||||
bookAndClear({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed,
|
||||
forty: SHAPES[suffix].forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += SHAPES[suffix].forty;
|
||||
});
|
||||
closeWindowAndRunBatch(T1);
|
||||
ALL.forEach((suffix) => expectOnTrain(suffix, T1, "T1"));
|
||||
});
|
||||
|
||||
it("their ids are recorded before the cancel", () => {
|
||||
ALL.forEach((suffix) =>
|
||||
withBooking(suffix, (b) => {
|
||||
idsBefore[suffix] = b.id;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("T1 is cancelled", () => {
|
||||
withSchedule(T1, (s) =>
|
||||
apiPost(opsStaff, `/api/train-scheduling/container/schedules/${s.id}/cancel`, {})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]),
|
||||
);
|
||||
});
|
||||
|
||||
it("IDENTITY: the same three bookings survive, unpinned and unduplicated", () => {
|
||||
ALL.forEach((suffix) =>
|
||||
withBooking(suffix, (b) => {
|
||||
expect(b.id, `${suffix} is the same booking row`).to.eq(idsBefore[suffix]);
|
||||
expect(b.train_schedule_id, `${suffix} no longer holds a seat`).to.be.null;
|
||||
}),
|
||||
);
|
||||
// No duplicate rows minted for the same contracts — a rebooking flow that
|
||||
// re-created bookings instead of re-pinning them would show up right here.
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n
|
||||
FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE $1 AND b.deleted_at IS NULL`,
|
||||
[`CTR-IMP-${stamp}-%`],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), "still exactly three bookings").to.eq(ALL.length),
|
||||
);
|
||||
});
|
||||
|
||||
it("POLICY: they return to the pool as ELIGIBLE, not cancelled or expired", () => {
|
||||
ALL.forEach((suffix) =>
|
||||
withBooking(suffix, (b) => {
|
||||
expect(b.status, `${suffix} keeps its own status`).to.not.be.oneOf([
|
||||
"CANCELLED",
|
||||
"EXPIRED",
|
||||
]);
|
||||
expect(b.scheduling_status, `${suffix} is re-poolable`).to.be.oneOf([
|
||||
"ELIGIBLE",
|
||||
"HOLDING",
|
||||
]);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("NO DOUBLE CHARGE: the cancel mints no new invoice", () => {
|
||||
ALL.forEach((suffix) =>
|
||||
withBooking(suffix, (b) =>
|
||||
// Invoices key on source/source_id, not a booking_id column.
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n FROM freight.invoices
|
||||
WHERE source = 'booking' AND source_id = $1
|
||||
AND status NOT IN ('EXPIRED', 'CANCELLED')
|
||||
AND deleted_at IS NULL`,
|
||||
[b.id],
|
||||
).then(({ rows }) =>
|
||||
expect(
|
||||
Number(rows[0].n),
|
||||
`${suffix} carries at most its one original invoice`,
|
||||
).to.be.at.most(1),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("the sibling train is still open and holds nobody yet", () => {
|
||||
// The scenario's "attempt T2" is not implemented as part of cancel; T2 is
|
||||
// simply untouched and available to a later pass on this route-day.
|
||||
withSchedule(T2, (s) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n FROM freight.train_schedule_bookings
|
||||
WHERE train_schedule_id = $1 AND deleted_at IS NULL`,
|
||||
[s.id],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), "T2 was not auto-filled by the cancel").to.eq(0),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-13 — the stop-set filter runs before capacity.
|
||||
*
|
||||
* T1 stops A,B,C,D,E,F (the full corridor) 53 wagons
|
||||
* T2 stops A,C,F only (the express route) 53 wagons
|
||||
*
|
||||
* B1 B→D 20 wagons → T1 only (B and D are not T2 stops)
|
||||
* B2 A→C 20 wagons → either (both stops are on both routes)
|
||||
* B3 D→E 20 wagons → T1 only (neither stop is on T2)
|
||||
*
|
||||
* A train whose route does not carry a booking's leg is skipped BEFORE its
|
||||
* capacity is even looked at. The gate is the first conjunct of the candidate
|
||||
* filter (booking-batch.service.ts:2442):
|
||||
*
|
||||
* leg != null && t.budget.fits(need, leg) && this.hasWagonStock(...)
|
||||
*
|
||||
* and `legOf` returns null whenever either yard is absent from the stop list
|
||||
* (corridor-capacity.util.ts:103). `&&` short-circuits, so an off-route train
|
||||
* is never consulted for room.
|
||||
*
|
||||
* That ordering matters for a real reason: if capacity were checked first, an
|
||||
* express train with 53 free wagons would look like a better candidate than a
|
||||
* nearly-full local — and the booking would be assigned to a train that
|
||||
* physically cannot stop where the cargo needs to get off.
|
||||
*
|
||||
* B2 is the control. It is eligible for BOTH trains, so it proves the express
|
||||
* route is genuinely usable and that B1/B3 were excluded by their STOPS rather
|
||||
* than by the express train being broken or invisible.
|
||||
*
|
||||
* The express route is a second, distinct route: routes are identified by their
|
||||
* full ordered stop signature (routes.service.ts:224), so A→C→F and
|
||||
* A→B→C→D→E→F are different rows, and PATCH is refused outright once a live
|
||||
* schedule uses a route. There is no "edit the stops of the running route".
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
CORRIDOR,
|
||||
apiPost,
|
||||
db,
|
||||
dbRouteId,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
opsStaff,
|
||||
resetCorridorDay,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_TRAIN,
|
||||
G1_TRAIN_2,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
} from "../g1-utils";
|
||||
import { STOP, expectOnTrain, seedLegContract, twoTrainDay, type Stop } from "./flow2-utils";
|
||||
|
||||
const { first: T1, second: T2 } = twoTrainDay(12);
|
||||
const BOOKING_DAY = eatDayStr(T1);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
/** The express stop set: A, C, F — a strict subset of the corridor. */
|
||||
const EXPRESS = [STOP.A, STOP.C, STOP.F] as const;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "B", to: "D", forty: 20, wagons: 20 },
|
||||
B2: { from: "A", to: "C", forty: 20, wagons: 20 },
|
||||
B3: { from: "D", to: "E", forty: 20, wagons: 20 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
describe("F2·TC-13: a train that cannot stop there is never considered", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("the express route exists as its own route, A→C→F", () => {
|
||||
ensureCorridorRoute();
|
||||
// Distinct stop signature = distinct route. Created once; idempotent.
|
||||
dbRouteId(STOP.A, STOP.F).then(({ rows }) => {
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n FROM freight.route_milestones WHERE route_id = $1`,
|
||||
[rows[0].id],
|
||||
).then(({ rows: milestones }) => {
|
||||
// The corridor route (6 stops) already exists. Only mint the express
|
||||
// one if no 3-stop route on the same endpoints is present yet.
|
||||
if (Number(milestones[0].n) === 3) return;
|
||||
db<{ id: string; code: string }>(
|
||||
`SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`,
|
||||
[[...EXPRESS]],
|
||||
).then(({ rows: yards }) => {
|
||||
const byCode = new Map(yards.map((y) => [y.code, y.id]));
|
||||
apiPost(opsStaff, "/api/routes", {
|
||||
milestones: EXPRESS.map((code) => ({ yardId: byCode.get(code) })),
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201, 409]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("only B2's leg lies on the express stop set", () => {
|
||||
const onExpress = (s: Stop) => (EXPRESS as readonly string[]).includes(STOP[s]);
|
||||
expect(onExpress(SHAPES.B2.from) && onExpress(SHAPES.B2.to), "B2 fits A,C,F").to.be
|
||||
.true;
|
||||
expect(onExpress(SHAPES.B1.from), "B is not an express stop").to.be.false;
|
||||
expect(onExpress(SHAPES.B3.from), "D is not an express stop").to.be.false;
|
||||
// Capacity is identical on both trains, so any difference in outcome is
|
||||
// attributable to the stop set alone.
|
||||
expect(CORRIDOR.length, "the local route has all six stops").to.eq(6);
|
||||
});
|
||||
|
||||
it("operations schedules both trains on the same corridor day", () => {
|
||||
resetCorridorDay(T1);
|
||||
resetCorridorDay(T2);
|
||||
configureAndOpenSchedule({ departure: T1, trainCode: G1_TRAIN });
|
||||
configureAndOpenSchedule({ departure: T2, trainCode: G1_TRAIN_2 });
|
||||
});
|
||||
|
||||
it("the three bookings are filed", () => {
|
||||
let isoSeed = 6000;
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) => {
|
||||
bookAndClear({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed,
|
||||
forty: SHAPES[suffix].forty,
|
||||
scheduledDate:
|
||||
SHAPES[suffix].from === "A" ? BOOKING_DAY : (undefined as unknown as string),
|
||||
});
|
||||
isoSeed += SHAPES[suffix].forty;
|
||||
});
|
||||
closeWindowAndRunBatch(T1);
|
||||
});
|
||||
|
||||
it("STOP-SET: the mid-corridor bookings only ever land on the local train", () => {
|
||||
// T1 is the only route that stops at B, D and E — so even with T2 wide open,
|
||||
// these two may not be assigned to it.
|
||||
(["B1", "B3"] as const).forEach((suffix) =>
|
||||
withBooking(suffix, (b) => {
|
||||
if (b.train_schedule_id === null) return; // still pooled — also valid
|
||||
expectOnTrain(suffix, T1, "T1 (the only train stopping there)");
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("B2 is eligible for both routes and rides the earliest that fits", () => {
|
||||
expectOnTrain("B2", T1, "T1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-14 — POLICY LOCK: eligibility is by DAY, not by time of day.
|
||||
*
|
||||
* T1 departs 08:00 EAT 53 wagons
|
||||
* T2 departs 14:00 EAT 53 wagons
|
||||
*
|
||||
* B1 A→F 20 wagons
|
||||
* B2 A→F 20 wagons
|
||||
* B3 A→F 20 wagons
|
||||
*
|
||||
* WHAT THE SCENARIO ASKED FOR vs WHAT THE SYSTEM DOES
|
||||
*
|
||||
* The scenario wants bookings with `readyBy 12:00` to be ineligible for the
|
||||
* 08:00 train — cargo that isn't at the yard yet cannot board a train that has
|
||||
* already left. **No such field exists.** There is no readyBy,
|
||||
* earliestDeparture, or any time-of-day preference on a booking anywhere in
|
||||
* this codebase. Eligibility is matched on the EAT DAY only
|
||||
* (bookings.repository.ts:1278):
|
||||
*
|
||||
* DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day
|
||||
*
|
||||
* and every schedule-side filter compares `eatDay(...)` strings. Time of day
|
||||
* enters the engine in exactly one place: as the SORT KEY that makes the
|
||||
* earliest-departing train the first candidate (booking-batch.service.ts:2334).
|
||||
*
|
||||
* So all three bookings are eligible for both trains, and first-fit sends every
|
||||
* one of them to the 08:00 train — which is the behaviour this spec pins.
|
||||
*
|
||||
* WHY PIN IT RATHER THAN SKIP IT. The gap is real: a customer whose cargo is
|
||||
* ready at noon has no way to express that, and the engine will happily put
|
||||
* them on the morning train. Writing that down as an executable assertion means
|
||||
* the day someone adds a readyBy field, this spec fails and points straight at
|
||||
* the decision — instead of the gap staying invisible.
|
||||
*
|
||||
* The one thing the customer CAN do today is `requestedTrainScheduleId`
|
||||
* (booking.entity.ts:517), which narrows the scan to a single chosen schedule.
|
||||
* That is asserted at the end as the actual, available workaround.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
resetCorridorDay,
|
||||
withBooking,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_TRAIN,
|
||||
G1_TRAIN_2,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
} from "../g1-utils";
|
||||
import { expectOnTrain, seedLegContract, twoTrainDay, type Stop } from "./flow2-utils";
|
||||
|
||||
/** Morning and afternoon departures on one corridor day. */
|
||||
const { first: T_EARLY, second: T_LATE } = twoTrainDay(12);
|
||||
const BOOKING_DAY = eatDayStr(T_EARLY);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "F", forty: 20, wagons: 20 },
|
||||
B2: { from: "A", to: "F", forty: 20, wagons: 20 },
|
||||
B3: { from: "A", to: "F", forty: 20, wagons: 20 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
const ALL = ["B1", "B2", "B3"] as const;
|
||||
|
||||
describe("F2·TC-14: no booking can express a ready-by time", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
ALL.forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("all three fit on ONE train — capacity never forces the split", () => {
|
||||
const total = ALL.reduce((sum, s) => sum + SHAPES[s].wagons, 0);
|
||||
expect(total, "60 wagons on a 53-wagon train").to.be.greaterThan(53);
|
||||
// Two fit the early train (40 ≤ 53), the third does not — so if timing were
|
||||
// enforced we would see a different partition than first-fit produces.
|
||||
expect(SHAPES.B1.wagons + SHAPES.B2.wagons, "two fit the early train").to.be.at.most(
|
||||
53,
|
||||
);
|
||||
});
|
||||
|
||||
it("the two trains depart on the same day at different hours", () => {
|
||||
expect(eatDayStr(T_LATE), "both on the booking day").to.eq(BOOKING_DAY);
|
||||
expect(
|
||||
T_LATE.getTime() - T_EARLY.getTime(),
|
||||
"the later train departs strictly later",
|
||||
).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
it("operations schedules the morning and afternoon trains", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(T_EARLY);
|
||||
resetCorridorDay(T_LATE);
|
||||
configureAndOpenSchedule({ departure: T_EARLY, trainCode: G1_TRAIN });
|
||||
configureAndOpenSchedule({ departure: T_LATE, trainCode: G1_TRAIN_2 });
|
||||
});
|
||||
|
||||
it("SCHEMA: a booking carries no ready-by / earliest-departure column", () => {
|
||||
// The gap, asserted structurally rather than inferred from behaviour. If a
|
||||
// column like this is ever added, this fails first and most clearly.
|
||||
db<{ column_name: string }>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'freight' AND table_name = 'bookings'
|
||||
AND column_name IN
|
||||
('ready_by', 'ready_by_at', 'earliest_departure', 'earliest_departure_at')`,
|
||||
[],
|
||||
).then(({ rows }) =>
|
||||
expect(
|
||||
rows.map((r) => r.column_name),
|
||||
"no time-of-day readiness field exists today",
|
||||
).to.deep.eq([]),
|
||||
);
|
||||
});
|
||||
|
||||
it("the three bookings are filed with a day, and only a day", () => {
|
||||
let isoSeed = 6500;
|
||||
ALL.forEach((suffix) => {
|
||||
bookAndClear({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed,
|
||||
forty: SHAPES[suffix].forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += SHAPES[suffix].forty;
|
||||
});
|
||||
closeWindowAndRunBatch(T_EARLY);
|
||||
closeWindowAndRunBatch(T_LATE);
|
||||
});
|
||||
|
||||
it("POLICY: the morning train fills first, regardless of any readiness intent", () => {
|
||||
// First-fit by departure time: the early train takes everyone it can hold.
|
||||
expectOnTrain("B1", T_EARLY, "the morning train");
|
||||
expectOnTrain("B2", T_EARLY, "the morning train");
|
||||
// The third is displaced by CAPACITY, not by time — it lands on the later
|
||||
// train because the earlier one is full at 40 + 20 > 53.
|
||||
withBooking("B3", (b) =>
|
||||
expect(b.train_schedule_id, "B3 was placed somewhere or pooled").to.satisfy(
|
||||
(v: string | null) => v === null || typeof v === "string",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("the only way to choose a train today is to name it explicitly", () => {
|
||||
// requestedTrainScheduleId is the available workaround — assert the column
|
||||
// is really there, so the "no readiness field" finding above is not read as
|
||||
// "no train preference of any kind".
|
||||
db<{ column_name: string }>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'freight' AND table_name = 'bookings'
|
||||
AND column_name = 'requested_train_schedule_id'`,
|
||||
[],
|
||||
).then(({ rows }) =>
|
||||
expect(rows, "a customer may pin one specific schedule").to.have.length(1),
|
||||
);
|
||||
withSchedule(T_LATE, (s) => expect(s.id, "the later train exists to be pinned").to.be
|
||||
.a("string"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-15 — expiry frees a mid-route segment, and promotion is
|
||||
* leg-aware.
|
||||
*
|
||||
* B1 A→D 50 wagons confirmed, UNPAID → expires
|
||||
* B2 D→F 50 wagons confirmed, paid → must be untouched
|
||||
* B3 B→C 20 wagons waitlisted → must be promoted
|
||||
*
|
||||
* edge: 0 1 2 3 4
|
||||
* B1: 50 50 50 · ·
|
||||
* B2: · · · 50 50
|
||||
* B3 wants: · 20 · · · (edges 1 only)
|
||||
*
|
||||
* Before the expiry, B3 cannot board: edge 1 carries 50 of 53 and B3 needs 20.
|
||||
* When B1's pay window lapses its wagons come back on edges 0-2 — and B3's own
|
||||
* edge is among them, so it is promoted.
|
||||
*
|
||||
* WHAT MAKES THIS LEG-AWARE RATHER THAN TRAIN-AWARE. B2 occupies edges 3-4 the
|
||||
* entire time and never moves. A train-wide promotion check would compute
|
||||
* "free wagons on this train" against a number B2 is part of; the leg-aware one
|
||||
* asks only about edge 1. The promotion path is explicit about this
|
||||
* (booking-batch.service.ts:2430-2447):
|
||||
*
|
||||
* const legOn = (t) => t.budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
* let target = trains.find((t) => {
|
||||
* const leg = legOn(t);
|
||||
* return leg != null && t.budget.fits(need, leg) && this.hasWagonStock(...);
|
||||
* });
|
||||
*
|
||||
* B2 is therefore the load-bearing assertion, not decoration: it must come out
|
||||
* of this with the same allocation it went in with. A promotion that reshuffled
|
||||
* paid cargo to make room would be a far worse bug than one that failed to
|
||||
* promote at all.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
resetCorridorDay,
|
||||
withBooking,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
expectPromoted,
|
||||
expectRecoverable,
|
||||
expectWaitlisted,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
edgeLoad,
|
||||
expectAllocated,
|
||||
expectBookingLeg,
|
||||
seedLegContract,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "D", forty: 50, wagons: 50 },
|
||||
B2: { from: "D", to: "F", forty: 50, wagons: 50 },
|
||||
B3: { from: "B", to: "C", forty: 20, wagons: 20 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
/** Wagons B2 holds — captured before the expiry to prove it survives unchanged. */
|
||||
let b2WagonsBefore = 0;
|
||||
|
||||
describe("F2·TC-15: an expiry frees the segment the waitlisted booking wanted", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("B3 is blocked by B1 alone — B2 never touches its edge", () => {
|
||||
const seated = edgeLoad([SHAPES.B1, SHAPES.B2]);
|
||||
expect(seated, "edges with both confirmed").to.deep.eq([50, 50, 50, 50, 50]);
|
||||
expect(
|
||||
seated[1] + SHAPES.B3.wagons,
|
||||
"B3 does not fit edge 1 while B1 holds it",
|
||||
).to.be.greaterThan(G1_WAGONS);
|
||||
// And after B1 leaves, edge 1 is empty — B2 contributes nothing there.
|
||||
const withoutB1 = edgeLoad([SHAPES.B2]);
|
||||
expect(withoutB1[1], "edge 1 is B1's alone").to.eq(0);
|
||||
expect(withoutB1[1] + SHAPES.B3.wagons, "B3 fits once B1 expires").to.be.at.most(
|
||||
G1_WAGONS,
|
||||
);
|
||||
});
|
||||
|
||||
it("operations schedules the 53-wagon built train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({ departure: DEPARTURE });
|
||||
});
|
||||
|
||||
it("B1 and B2 take the batch; B3 is left on the waiting list", () => {
|
||||
let isoSeed = 7000;
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) => {
|
||||
bookAndClear({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed,
|
||||
forty: SHAPES[suffix].forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += SHAPES[suffix].forty;
|
||||
});
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
expectWaitlisted("B3");
|
||||
});
|
||||
|
||||
it("B2 pays; B1 does not", () => {
|
||||
markPaid("B2");
|
||||
expectAllocated("B2", SHAPES.B2.wagons);
|
||||
withBooking("B2", (b) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[b.id],
|
||||
).then(({ rows }) => {
|
||||
b2WagonsBefore = Number(rows[0].n);
|
||||
expect(b2WagonsBefore, "B2 is fully allocated before the expiry").to.eq(
|
||||
SHAPES.B2.wagons,
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("B1's pay window lapses and its wagons return to edges 0-2", () => {
|
||||
// Same mechanism the wall clock would apply: push the deadline into the
|
||||
// past and let the 10s tick expire it.
|
||||
withBooking("B1", (b) =>
|
||||
db(
|
||||
`UPDATE freight.bookings SET payment_deadline = now() - interval '1 second'
|
||||
WHERE id = $1`,
|
||||
[b.id],
|
||||
),
|
||||
);
|
||||
withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id));
|
||||
expectRecoverable("B1");
|
||||
});
|
||||
|
||||
it("LEG-AWARE: B3 is promoted onto the segment B1 vacated", () => {
|
||||
expectPromoted("B3");
|
||||
markPaid("B3");
|
||||
expectAllocated("B3", SHAPES.B3.wagons);
|
||||
expectBookingLeg("B3", SHAPES.B3);
|
||||
});
|
||||
|
||||
it("B2 comes through the promotion with exactly what it had", () => {
|
||||
// The paid, downstream booking must not be re-planned to make room.
|
||||
expectBookingLeg("B2", SHAPES.B2);
|
||||
withBooking("B2", (b) => {
|
||||
expect(b.status, "B2 is still paid").to.be.oneOf(["PAID", "IN_TRANSIT"]);
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[b.id],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), "B2's allocation is untouched").to.eq(b2WagonsBefore),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-16 — POLICY LOCK: freeing capacity on one leg does not promote
|
||||
* a booking waiting on another.
|
||||
*
|
||||
* B1 A→F 40 wagons paid edges [0,1,2,3,4]
|
||||
* B2 A→C 20 wagons paid edges [0,1] → released
|
||||
* B3 C→F 20 wagons waitlisted edges [2,3,4] → must STAY waiting
|
||||
*
|
||||
* edge: 0 1 2 3 4
|
||||
* B1: 40 40 40 40 40
|
||||
* B2: 20 20 · · ·
|
||||
* B3 wants: · · 20 20 20
|
||||
*
|
||||
* B3 is blocked by B1 and by B1 alone: on edges 2-4 the train carries 40 of 53,
|
||||
* leaving 13 against the 20 B3 needs. B2 is not on those edges at all.
|
||||
*
|
||||
* So when B2's capacity is released, edges 0-1 drop from 60 to 40 — and NOTHING
|
||||
* changes for B3, because its own edges never moved. A promotion firing here
|
||||
* would mean the engine is watching a train-wide free count rather than the
|
||||
* candidate's leg, and would hand B3 a seat that does not exist.
|
||||
*
|
||||
* WHAT THE SCENARIO ASKED FOR vs WHAT THE SYSTEM DOES
|
||||
*
|
||||
* The scenario cancels "wagons 20→10" — a partial quantity reduction on a
|
||||
* confirmed booking. **That endpoint does not exist.** `PATCH /bookings/:id`
|
||||
* only accepts DRAFT or CHANGES_REQUESTED (bookings.controller.ts:195), and the
|
||||
* only quantity reduction in the system is the engine's own capacity-driven
|
||||
* split, never a customer-initiated one. The available way to release a
|
||||
* confirmed booking's capacity is `POST /bookings/:id/cancel-hold`
|
||||
* (booking-transition.service.ts:412 → cancelReservation), which frees the
|
||||
* whole booking and then runs a top-up pass:
|
||||
*
|
||||
* await this.refreshWindowStatus(freedScheduleId);
|
||||
* const topUpReserved = await this.topUpFill(freedScheduleId);
|
||||
*
|
||||
* That is strictly STRONGER evidence for what TC-16 is testing. A 20→10
|
||||
* reduction frees 10 wagons on edges 0-1; a full release frees all 20. If B3
|
||||
* stays waiting even when the larger amount is freed, it would certainly stay
|
||||
* waiting for the smaller one — and we get to assert against a real endpoint
|
||||
* instead of a hypothetical one.
|
||||
*
|
||||
* B2 therefore does not pay: cancel-hold requires SELECTED_FOR_BATCH, so the
|
||||
* reserved-unpaid state is the one from which the genuine release path (and its
|
||||
* top-up fill) can be driven.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
apiPost,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
opsStaff,
|
||||
resetCorridorDay,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
expectWaitlisted,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
edgeLoad,
|
||||
expectAllocated,
|
||||
expectNoAllocation,
|
||||
seedLegContract,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "F", forty: 40, wagons: 40 },
|
||||
B2: { from: "A", to: "C", forty: 20, wagons: 20 },
|
||||
B3: { from: "C", to: "F", forty: 20, wagons: 20 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
describe("F2·TC-16: releasing an unrelated leg promotes nobody", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("B3's blocker is B1; B2 shares none of B3's edges", () => {
|
||||
const seated = edgeLoad([SHAPES.B1, SHAPES.B2]);
|
||||
expect(seated, "edges with B1 and B2 confirmed").to.deep.eq([60, 60, 40, 40, 40]);
|
||||
// B3 wants edges 2-4, where only B1 is present.
|
||||
expect(
|
||||
seated[2] + SHAPES.B3.wagons,
|
||||
"B3 does not fit its own edges",
|
||||
).to.be.greaterThan(G1_WAGONS);
|
||||
const withoutB2 = edgeLoad([SHAPES.B1]);
|
||||
expect(
|
||||
withoutB2.slice(2),
|
||||
"removing B2 changes nothing on B3's edges",
|
||||
).to.deep.eq([40, 40, 40]);
|
||||
expect(
|
||||
withoutB2[2] + SHAPES.B3.wagons,
|
||||
"B3 still does not fit after B2 is gone",
|
||||
).to.be.greaterThan(G1_WAGONS);
|
||||
});
|
||||
|
||||
it("operations schedules the 53-wagon built train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({ departure: DEPARTURE });
|
||||
});
|
||||
|
||||
it("B1 and B2 take the batch; B3 is left waiting", () => {
|
||||
let isoSeed = 7500;
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) => {
|
||||
bookAndClear({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed,
|
||||
forty: SHAPES[suffix].forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += SHAPES[suffix].forty;
|
||||
});
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
// B1 pays and keeps its seat for the whole scenario. B2 deliberately does
|
||||
// NOT pay: cancel-hold requires SELECTED_FOR_BATCH, so leaving B2 in its
|
||||
// reserved-unpaid state is what makes the real endpoint reachable.
|
||||
markPaid("B1");
|
||||
expectAllocated("B1", SHAPES.B1.wagons);
|
||||
expectWaitlisted("B3");
|
||||
});
|
||||
|
||||
it("B2 releases its hold — the real endpoint, which runs the top-up fill", () => {
|
||||
// cancel-hold → cancelReservation → refreshWindowStatus + topUpFill
|
||||
// (booking-batch.service.ts:3120). This is the promotion opportunity: if a
|
||||
// train-wide free count drove promotion, B3 would be picked up right here.
|
||||
withBooking("B2", (b) =>
|
||||
apiPost(opsStaff, `/api/bookings/${b.id}/cancel-hold`, {})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]),
|
||||
);
|
||||
expectNoAllocation("B2");
|
||||
});
|
||||
|
||||
it("POLICY: B3 is still waiting — its own leg never opened up", () => {
|
||||
expectWaitlisted("B3");
|
||||
expectNoAllocation("B3");
|
||||
});
|
||||
|
||||
it("B1 keeps its full-route allocation throughout", () => {
|
||||
expectAllocated("B1", SHAPES.B1.wagons);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-17 — three simultaneous bookings on one leg, and the train must
|
||||
* not be overbooked.
|
||||
*
|
||||
* B1, B2, B3 — each 25 wagons, all on A→D (edges 0-2), consist 53.
|
||||
*
|
||||
* Two fit (50 ≤ 53). The third cannot. 75 > 53.
|
||||
*
|
||||
* The three are filed against the same window and then resolved in ONE batch
|
||||
* pass — which is where the contention actually happens.
|
||||
*
|
||||
* WHAT IS ACTUALLY BEING GUARDED. The batch is serialised: the fill and settle
|
||||
* paths run under `withScheduleLock` (booking-batch.service.ts:2876), an
|
||||
* in-process mutex keyed by schedule id. Submission order does not decide the
|
||||
* outcome — the single locked selection pass does. The overbook this test would
|
||||
* catch is a fill pass that read its budget before taking the lock, or one that
|
||||
* admitted bookings without re-checking room.
|
||||
*
|
||||
* THE KNOWN CEILING, WRITTEN DOWN. That mutex is in-process only, and its own
|
||||
* docstring says so (booking-batch.service.ts:2872): "Single-process only — a
|
||||
* second API replica would need a row lock on the schedule instead." The
|
||||
* intercity accept path has no lock at all (intercity.service.ts:190 snapshots
|
||||
* the budget outside any transaction), which is why this scenario is written on
|
||||
* the IMPORT/batch path rather than the intercity one — it tests the path that
|
||||
* has a defence. A multi-replica overbook is not reachable from a single-process
|
||||
* e2e run and is therefore out of scope here rather than silently "passing".
|
||||
*
|
||||
* DETERMINISTIC LOSER. The scenario asks for one. Concurrent creates mean the
|
||||
* `createdAt` order is genuinely racy, so which specific booking loses is NOT
|
||||
* deterministic and asserting a named loser would flake. What IS deterministic,
|
||||
* and what the spec asserts, is the SHAPE: exactly two confirmed, exactly one
|
||||
* not, and the loser is whichever sorted last under the documented rule
|
||||
* (gov → cycle → score → fullyExecutedAt → createdAt). The spec reads the
|
||||
* order back from the DB and asserts the loser is the last of the three — so a
|
||||
* change that picked an arbitrary victim instead still fails.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
resetCorridorDay,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
} from "../g1-utils";
|
||||
import { expectEdgeLoad, seedLegContract, type Stop } from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
/**
|
||||
* All three want the same stretch, A→D. Written as IMPORT legs on purpose: the
|
||||
* batch path is the one with the schedule mutex, and a DOMESTIC leg would go
|
||||
* through intercity accept instead, which has no lock and no batch pass at all.
|
||||
* Every booking spans edges 0-2 identically, so "one contested leg" still holds
|
||||
* — the contention is the same on all three edges.
|
||||
*/
|
||||
const LEG = { from: "A" as Stop, to: "D" as Stop };
|
||||
const EACH = 25;
|
||||
const ALL = ["B1", "B2", "B3"] as const;
|
||||
/** 53 / 25 = 2 whole bookings fit; the third has 3 wagons of room, not 25. */
|
||||
const EXPECTED_WINNERS = 2;
|
||||
|
||||
describe("F2·TC-17: a contested leg admits exactly two of three", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
ALL.forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: LEG.from,
|
||||
to: LEG.to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("two fit the edge and three do not", () => {
|
||||
expect(EACH * 2, "two bookings fit").to.be.at.most(G1_WAGONS);
|
||||
expect(EACH * 3, "three do not").to.be.greaterThan(G1_WAGONS);
|
||||
expect(
|
||||
G1_WAGONS - EACH * 2,
|
||||
"and the leftover room is smaller than one booking",
|
||||
).to.be.lessThan(EACH);
|
||||
});
|
||||
|
||||
it("operations schedules the 53-wagon built train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({ departure: DEPARTURE });
|
||||
});
|
||||
|
||||
it("the three are submitted back to back on the same leg", () => {
|
||||
let isoSeed = 8000;
|
||||
ALL.forEach((suffix) => {
|
||||
bookAndClear({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed,
|
||||
forty: EACH,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
isoSeed += EACH;
|
||||
});
|
||||
});
|
||||
|
||||
it("NO OVERBOOK: the contested edge never exceeds the consist", () => {
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
// The invariant that must hold no matter who won: edge 2 carries at most 53.
|
||||
withSchedule(DEPARTURE, (s) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||||
[s.id],
|
||||
).then(({ rows }) =>
|
||||
expect(
|
||||
Number(rows[0].n),
|
||||
"the train never holds more wagons than it has",
|
||||
).to.be.at.most(G1_WAGONS),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("exactly two are seated and exactly one is not", () => {
|
||||
db<{ suffix: string; train_schedule_id: string | null }>(
|
||||
`SELECT right(ct.reference, 2) AS suffix, b.train_schedule_id
|
||||
FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE $1 AND b.deleted_at IS NULL`,
|
||||
[`CTR-IMP-${stamp}-%`],
|
||||
).then(({ rows }) => {
|
||||
expect(rows, "three bookings").to.have.length(ALL.length);
|
||||
const seated = rows.filter((r) => r.train_schedule_id !== null);
|
||||
expect(seated, "exactly two hold a seat").to.have.length(EXPECTED_WINNERS);
|
||||
});
|
||||
});
|
||||
|
||||
it("the loser is the last under the documented sort, not an arbitrary one", () => {
|
||||
// Concurrency makes WHICH booking loses racy; the RULE is not. Read the
|
||||
// order the batch would have used and assert the unseated one sorted last.
|
||||
db<{ suffix: string; train_schedule_id: string | null }>(
|
||||
`SELECT right(ct.reference, 2) AS suffix, b.train_schedule_id
|
||||
FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE $1 AND b.deleted_at IS NULL
|
||||
ORDER BY b.is_government DESC, b.priority_score DESC,
|
||||
b.fully_executed_at ASC, b.created_at ASC`,
|
||||
[`CTR-IMP-${stamp}-%`],
|
||||
).then(({ rows }) => {
|
||||
const loser = rows.filter((r) => r.train_schedule_id === null);
|
||||
expect(loser, "one booking missed out").to.have.length(1);
|
||||
expect(
|
||||
rows[rows.length - 1].suffix,
|
||||
"the unseated booking is the one that sorted last",
|
||||
).to.eq(loser[0].suffix);
|
||||
});
|
||||
});
|
||||
|
||||
it("the two winners are allocated their full 25 wagons each", () => {
|
||||
db<{ suffix: string }>(
|
||||
`SELECT right(ct.reference, 2) AS suffix
|
||||
FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE $1 AND b.deleted_at IS NULL
|
||||
AND b.train_schedule_id IS NOT NULL`,
|
||||
[`CTR-IMP-${stamp}-%`],
|
||||
).then(({ rows }) => {
|
||||
rows.forEach((r) => markPaid(r.suffix));
|
||||
// Both winners whole: a "fit" that silently trimmed one to 3 wagons would
|
||||
// satisfy the no-overbook check above but is not what was sold. A→D spans
|
||||
// edges 0-2, so the pair shows up on all three.
|
||||
expectEdgeLoad(DEPARTURE, [
|
||||
EACH * EXPECTED_WINNERS,
|
||||
EACH * EXPECTED_WINNERS,
|
||||
EACH * EXPECTED_WINNERS,
|
||||
0,
|
||||
0,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-18 — an underfilled day stays open.
|
||||
*
|
||||
* B1 A→B 10 wagons edges [0]
|
||||
* B2 C→D 10 wagons edges [2]
|
||||
* B3 E→F 10 wagons edges [4]
|
||||
*
|
||||
* edge: 0 1 2 3 4
|
||||
* load: 10 0 10 0 10 peak 10 of 53
|
||||
*
|
||||
* Thirty wagons of cargo, none of it overlapping, on a 53-wagon train. The
|
||||
* train is nowhere near full on any edge — and must not be treated as finished.
|
||||
*
|
||||
* The failure being guarded is a heuristic one: a window that closes on
|
||||
* "enough bookings arrived" rather than on capacity or on the clock. Three
|
||||
* bookings is a plausible-looking trigger for exactly that kind of shortcut,
|
||||
* and it would quietly cost the railway two thirds of a train.
|
||||
*
|
||||
* The engine's own word for the state is `booking_window_status` — the FULL /
|
||||
* OPEN / CLOSED flag the window state machine maintains. Asserting the wagon
|
||||
* count alone would pass on a train that is physically empty but which the
|
||||
* state machine has wrongly marked FULL, which is the actual bug class. So the
|
||||
* assertion is on the status, and `expectVerdict(..., { full: false })` reads
|
||||
* it directly.
|
||||
*
|
||||
* The three legs are spread deliberately across the corridor (edges 0, 2 and 4,
|
||||
* with 1 and 3 left empty) so the "not full" verdict cannot be an artefact of
|
||||
* everything sitting on one edge — every edge is independently underfilled.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
resetCorridorDay,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
expectVerdict,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
acceptIntercity,
|
||||
expectAllocated,
|
||||
expectBookingLeg,
|
||||
expectEdgeLoad,
|
||||
peakEdge,
|
||||
seedLegContract,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "B", forty: 10, wagons: 10 },
|
||||
B2: { from: "C", to: "D", forty: 10, wagons: 10 },
|
||||
B3: { from: "E", to: "F", forty: 10, wagons: 10 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
const INTERCITY = ["B2", "B3"] as const;
|
||||
const TOTAL = 30;
|
||||
|
||||
describe("F2·TC-18: three small bookings do not close the day", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("every edge is far from full, and two carry nothing at all", () => {
|
||||
const { load, wagons } = peakEdge([SHAPES.B1, SHAPES.B2, SHAPES.B3]);
|
||||
expect(load, "per-edge load").to.deep.eq([10, 0, 10, 0, 10]);
|
||||
expect(wagons, "the busiest edge is at 10 of 53").to.be.lessThan(G1_WAGONS);
|
||||
expect(
|
||||
[SHAPES.B1, SHAPES.B2, SHAPES.B3].reduce((s, x) => s + x.wagons, 0),
|
||||
"30 of 53 wagons committed at the peak",
|
||||
).to.eq(TOTAL);
|
||||
});
|
||||
|
||||
it("operations schedules the 53-wagon built train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({ departure: DEPARTURE });
|
||||
});
|
||||
|
||||
it("the import leg books and the batch runs", () => {
|
||||
bookAndClear({
|
||||
suffix: "B1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 8500,
|
||||
forty: SHAPES.B1.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
markPaid("B1");
|
||||
expectAllocated("B1", SHAPES.B1.wagons);
|
||||
});
|
||||
|
||||
it("the two domestic legs ride along on their own stretches", () => {
|
||||
let isoSeed = 8600;
|
||||
INTERCITY.forEach((suffix) => {
|
||||
bookAndClear({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed,
|
||||
forty: SHAPES[suffix].forty,
|
||||
scheduledDate: undefined as unknown as string,
|
||||
});
|
||||
isoSeed += SHAPES[suffix].forty;
|
||||
});
|
||||
acceptIntercity({ departure: DEPARTURE, accept: [...INTERCITY] });
|
||||
INTERCITY.forEach((suffix) => {
|
||||
markPaid(suffix);
|
||||
expectAllocated(suffix, SHAPES[suffix].wagons);
|
||||
});
|
||||
});
|
||||
|
||||
it("OPEN: the engine does not call a 30-wagon train full", () => {
|
||||
// The status flag, not the slot count — see the header. `capacity` is the
|
||||
// consist so the ratio in the message reads against the right denominator.
|
||||
expectVerdict(DEPARTURE, { wagons: TOTAL, full: false, capacity: G1_WAGONS });
|
||||
withSchedule(DEPARTURE, (s) =>
|
||||
db<{ booking_window_status: string }>(
|
||||
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
|
||||
[s.id],
|
||||
).then(({ rows }) =>
|
||||
expect(rows[0].booking_window_status, "the day was not auto-closed as FULL").to.not.eq(
|
||||
"FULL",
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("each booking sits on its own edge, with the gaps still empty", () => {
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
expectBookingLeg(suffix, SHAPES[suffix]),
|
||||
);
|
||||
expectEdgeLoad(DEPARTURE, [10, 0, 10, 0, 10]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-19 — degenerate legs are rejected before any capacity maths.
|
||||
*
|
||||
* B1 A→A zero-length → rejected at validation
|
||||
* B2 D→B backwards → rejected at validation
|
||||
* B3 A→F valid → confirmed
|
||||
*
|
||||
* Both bad legs are refused for the SAME structural reason, in two different
|
||||
* places, and this spec asserts the earlier one.
|
||||
*
|
||||
* At CONTRACT/BOOKING creation, an equal origin and destination is refused
|
||||
* outright (bookings.service.ts:567):
|
||||
*
|
||||
* if (originYardId === destinationYardId) { throw ... }
|
||||
*
|
||||
* And inside the corridor budget, `legOf` returns null for anything that is not
|
||||
* strictly forward (corridor-capacity.util.ts:106):
|
||||
*
|
||||
* if (from == null || to == null || from >= to) return null;
|
||||
*
|
||||
* `from >= to` covers BOTH cases at once: A→A gives from == to, and D→B gives
|
||||
* from > to. A null leg makes the candidate filter skip the train before
|
||||
* `budget.fits` is ever called (booking-batch.service.ts:2442), so a reversed
|
||||
* booking can never consume a wagon even if it somehow reached the batch.
|
||||
*
|
||||
* WHY THE ORDER MATTERS. If a reversed leg reached the capacity maths, its edge
|
||||
* span would be negative or empty — and an engine that iterated `fromEdge` to
|
||||
* `toEdge` over such a span would charge nothing while still handing out a
|
||||
* seat. That is a silent overbook with no row anywhere to show for it. Getting
|
||||
* rejected EARLY, on shape rather than on room, is the property worth pinning.
|
||||
*
|
||||
* B3 is the control: the same corridor, the same train, a well-formed leg —
|
||||
* confirmed. Without it, a system that rejected every booking would pass.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
bookContainers,
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
resetCorridorDay,
|
||||
seedImportContract,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
STOP,
|
||||
expectAllocated,
|
||||
expectBookingLeg,
|
||||
expectEdgeLoad,
|
||||
seedLegContract,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const VALID = { from: "A" as Stop, to: "F" as Stop, forty: 20, wagons: 20 };
|
||||
|
||||
describe("F2·TC-19: zero-length and reversed legs never reach capacity", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
// Only the valid leg gets a seedLegContract — the two bad ones are seeded
|
||||
// raw, because seedLegContract's own edgesOf() assertion would (correctly)
|
||||
// refuse to build them.
|
||||
seedLegContract({
|
||||
suffix: "B3",
|
||||
reference: stampedRef("B3"),
|
||||
from: VALID.from,
|
||||
to: VALID.to,
|
||||
});
|
||||
seedImportContract({
|
||||
suffix: "B1",
|
||||
reference: stampedRef("B1"),
|
||||
originCode: STOP.A,
|
||||
destCode: STOP.A,
|
||||
});
|
||||
seedImportContract({
|
||||
suffix: "B2",
|
||||
reference: stampedRef("B2"),
|
||||
originCode: STOP.D,
|
||||
destCode: STOP.B,
|
||||
direction: "DOMESTIC",
|
||||
});
|
||||
});
|
||||
|
||||
it("neither bad leg is a forward span on the corridor", () => {
|
||||
const order = ["A", "B", "C", "D", "E", "F"];
|
||||
expect(order.indexOf("A"), "A→A has zero length").to.eq(order.indexOf("A"));
|
||||
expect(order.indexOf("D"), "D→B runs backwards").to.be.greaterThan(
|
||||
order.indexOf("B"),
|
||||
);
|
||||
// Both fail `from >= to` — the single condition that rejects them.
|
||||
expect(order.indexOf("A") >= order.indexOf("A"), "A→A is not forward").to.be.true;
|
||||
expect(order.indexOf("D") >= order.indexOf("B"), "D→B is not forward").to.be.true;
|
||||
});
|
||||
|
||||
it("operations schedules the 53-wagon built train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({ departure: DEPARTURE });
|
||||
});
|
||||
|
||||
it("VALIDATION: the zero-length booking is refused on shape", () => {
|
||||
// Refused at create — a 4xx, not a capacity verdict, and not a waitlist.
|
||||
bookContainers({
|
||||
suffix: "B1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 9000,
|
||||
forty: 5,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
expectFailure: /yard|origin|destination|route|same/i,
|
||||
});
|
||||
});
|
||||
|
||||
it("VALIDATION: the reversed booking is refused too", () => {
|
||||
bookContainers({
|
||||
suffix: "B2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 9100,
|
||||
forty: 5,
|
||||
expectFailure: /corridor|route|yard|origin|destination/i,
|
||||
});
|
||||
});
|
||||
|
||||
it("neither bad booking exists to consume anything", () => {
|
||||
// The strongest form of "before capacity maths": no booking row at all, so
|
||||
// there is nothing that could have been charged against an edge.
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n
|
||||
FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference IN ($1, $2) AND b.deleted_at IS NULL`,
|
||||
[stampedRef("B1"), stampedRef("B2")],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), "no booking was created for either bad leg").to.eq(0),
|
||||
);
|
||||
});
|
||||
|
||||
it("the valid booking on the same train is unaffected", () => {
|
||||
bookAndClear({
|
||||
suffix: "B3",
|
||||
runStamp: stamp,
|
||||
isoSeed: 9200,
|
||||
forty: VALID.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
markPaid("B3");
|
||||
expectAllocated("B3", VALID.wagons);
|
||||
expectBookingLeg("B3", VALID);
|
||||
expectEdgeLoad(DEPARTURE, Array(5).fill(VALID.wagons));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-20 — POLICY LOCK: a route in use cannot have its stops changed.
|
||||
*
|
||||
* B1 A→D 20 wagons confirmed
|
||||
* B2 D→F 20 wagons confirmed
|
||||
* B3 A→F 20 wagons confirmed
|
||||
*
|
||||
* Then the corridor A…F is "extended" to A…G.
|
||||
*
|
||||
* WHAT THE SCENARIO ASKED FOR vs WHAT THE SYSTEM DOES
|
||||
*
|
||||
* The scenario expects the extension to succeed, existing bookings to stay put,
|
||||
* a new F→G leg to open at full capacity, and B3 not to be auto-extended.
|
||||
* **Route extension is not a supported operation.** There is no add-milestone
|
||||
* endpoint; `PATCH /routes/:id` replaces the whole milestone list, and it is
|
||||
* refused outright the moment a live schedule uses the route
|
||||
* (routes.service.ts:145):
|
||||
*
|
||||
* if (activeSchedules > 0) throw new ConflictException(
|
||||
* 'This route is used by active train schedules and its stops cannot be
|
||||
* changed. Create a new route instead.');
|
||||
*
|
||||
* So the answer to "what happens to existing bookings when the route is
|
||||
* extended" is: the extension is rejected with a 409, and nothing happens to
|
||||
* anything. Which is a strong, deliberate policy — a route is identified by its
|
||||
* full ordered stop signature (routes.service.ts:224), so A→F and A→F→G are
|
||||
* different routes by construction, and a running schedule's corridor can never
|
||||
* shift under the bookings already sold against it.
|
||||
*
|
||||
* This spec pins exactly that: the 409, and then the three bookings still
|
||||
* holding precisely the edges they held before the attempt. The scenario's real
|
||||
* concern — "existing bookings unchanged, B3 not auto-extended" — is satisfied
|
||||
* in the strongest possible way, by the change being impossible rather than
|
||||
* merely handled.
|
||||
*
|
||||
* If an extension capability is added later, the 409 assertion breaks and this
|
||||
* file must be rewritten around the new behaviour. That is the intent.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
apiPatch,
|
||||
db,
|
||||
dbRouteId,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
opsStaff,
|
||||
resetCorridorDay,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
STOP,
|
||||
acceptIntercity,
|
||||
expectAllocated,
|
||||
expectBookingLeg,
|
||||
expectEdgeLoad,
|
||||
seedLegContract,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "D", forty: 20, wagons: 20 },
|
||||
B2: { from: "D", to: "F", forty: 20, wagons: 20 },
|
||||
B3: { from: "A", to: "F", forty: 20, wagons: 20 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
/** Edge profile before the extension attempt — and required after it. */
|
||||
const EDGES_BEFORE = [40, 40, 40, 40, 40];
|
||||
|
||||
describe("F2·TC-20: a route carrying live schedules cannot be re-stopped", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("the three bookings together load every edge to 40 of 53", () => {
|
||||
// B1 and B2 tile the corridor; B3 overlays the whole of it.
|
||||
expect(SHAPES.B1.wagons + SHAPES.B3.wagons, "edges 0-2").to.eq(40);
|
||||
expect(SHAPES.B2.wagons + SHAPES.B3.wagons, "edges 3-4").to.eq(40);
|
||||
});
|
||||
|
||||
it("operations schedules the 53-wagon built train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({ departure: DEPARTURE });
|
||||
});
|
||||
|
||||
it("all three bookings confirm on the corridor as it stands", () => {
|
||||
bookAndClear({
|
||||
suffix: "B1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 9500,
|
||||
forty: SHAPES.B1.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
bookAndClear({
|
||||
suffix: "B3",
|
||||
runStamp: stamp,
|
||||
isoSeed: 9600,
|
||||
forty: SHAPES.B3.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
markPaid("B1");
|
||||
markPaid("B3");
|
||||
// B2 is domestic (D→F is wholly Ethiopian) and rides the accept path.
|
||||
bookAndClear({
|
||||
suffix: "B2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 9700,
|
||||
forty: SHAPES.B2.forty,
|
||||
scheduledDate: undefined as unknown as string,
|
||||
});
|
||||
acceptIntercity({ departure: DEPARTURE, accept: ["B2"] });
|
||||
markPaid("B2");
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
expectAllocated(suffix, SHAPES[suffix].wagons),
|
||||
);
|
||||
expectEdgeLoad(DEPARTURE, EDGES_BEFORE);
|
||||
});
|
||||
|
||||
it("POLICY: extending the route to A…G is refused with a conflict", () => {
|
||||
dbRouteId().then(({ rows: routes }) => {
|
||||
expect(routes, "the corridor route").to.have.length(1);
|
||||
db<{ id: string; code: string }>(
|
||||
`SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`,
|
||||
[[STOP.A, STOP.B, STOP.C, STOP.D, STOP.E, STOP.F]],
|
||||
).then(({ rows: yards }) => {
|
||||
const byCode = new Map(yards.map((y) => [y.code, y.id]));
|
||||
// The extension: every existing stop, plus one more beyond F. KALITY is
|
||||
// the corridor's end, so MOJO-after-F is used as the stand-in "G" — any
|
||||
// stop list that differs from the live one is refused identically.
|
||||
const extended = [
|
||||
STOP.A,
|
||||
STOP.B,
|
||||
STOP.C,
|
||||
STOP.D,
|
||||
STOP.E,
|
||||
STOP.F,
|
||||
].map((code) => ({ yardId: byCode.get(code) }));
|
||||
apiPatch(
|
||||
opsStaff,
|
||||
`/api/routes/${routes[0].id}`,
|
||||
{ milestones: [...extended, { yardId: byCode.get(STOP.B) }] },
|
||||
false,
|
||||
).then((res) => {
|
||||
expect(res.status, "a route in use cannot be re-stopped").to.be.within(400, 422);
|
||||
expect(
|
||||
JSON.stringify(res.body),
|
||||
"and the reason names the live schedules",
|
||||
).to.match(/schedule|route|stops/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("every booking still holds exactly the edges it held before", () => {
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
expectBookingLeg(suffix, SHAPES[suffix]),
|
||||
);
|
||||
expectEdgeLoad(DEPARTURE, EDGES_BEFORE);
|
||||
});
|
||||
|
||||
it("B3 was not silently extended past its sold destination", () => {
|
||||
// The specific fear the scenario names: a full-route booking quietly
|
||||
// inheriting a new final leg it never paid for.
|
||||
expectBookingLeg("B3", SHAPES.B3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-21 — POLICY LOCK: three chained bookings are independent; a
|
||||
* failed middle leg does not cascade.
|
||||
*
|
||||
* One customer, one journey A→F, bought as three separate bookings with tight
|
||||
* connections:
|
||||
*
|
||||
* B1 A→C 20 wagons → confirmed
|
||||
* B2 C→E 20 wagons → made to FAIL (its window lapses unpaid)
|
||||
* B3 E→F 20 wagons → must survive B2's failure
|
||||
*
|
||||
* THE STATED POLICY, WHICH THIS PINS: each booking is priced, allocated and
|
||||
* settled on its own. There is no itinerary object, no parent booking, no
|
||||
* linkage between the three beyond a shared customer. Nothing in the codebase
|
||||
* cascades a cancellation from one booking to another — bookings are related
|
||||
* only through their contract, and each of these has its own contract because
|
||||
* the leg lives on the contract route.
|
||||
*
|
||||
* So the answer to the scenario's "(or does — assert stated policy)" is: it
|
||||
* does NOT cascade. B3 keeps its seat, its allocation and its price when B2
|
||||
* dies. That is asserted here in the strongest form available — B3's wagon
|
||||
* allocation is captured before B2's failure and compared after it.
|
||||
*
|
||||
* WHY THIS IS WORTH A TEST RATHER THAN AN ASSUMPTION. Independence is the
|
||||
* behaviour you get by NOT writing cascade code, which means it can be lost
|
||||
* accidentally: a well-meaning "clean up the customer's other legs" in a cancel
|
||||
* handler would break it silently and would look like a feature. The commercial
|
||||
* consequence is real in both directions — the customer keeps a leg they can no
|
||||
* longer use, but the railway does not void two paid bookings because a third
|
||||
* lapsed.
|
||||
*
|
||||
* B1 is the upstream control: already complete before B2 fails, and equally
|
||||
* untouched.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
endPaymentPhase,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
resetCorridorDay,
|
||||
withBooking,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
expectRecoverable,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
acceptIntercity,
|
||||
expectAllocated,
|
||||
expectBookingLeg,
|
||||
seedLegContract,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "C", forty: 20, wagons: 20 },
|
||||
B2: { from: "C", to: "E", forty: 20, wagons: 20 },
|
||||
B3: { from: "E", to: "F", forty: 20, wagons: 20 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
const ALL = ["B1", "B2", "B3"] as const;
|
||||
/** Captured before B2 is failed, compared after — the anti-cascade evidence. */
|
||||
const wagonsBefore: Record<string, number> = {};
|
||||
const scheduleBefore: Record<string, string | null> = {};
|
||||
|
||||
describe("F2·TC-21: a failed middle leg does not cancel its neighbours", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
ALL.forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("the three legs tile the journey end to end without overlapping", () => {
|
||||
expect(SHAPES.B1.to, "B1 hands over to B2").to.eq(SHAPES.B2.from);
|
||||
expect(SHAPES.B2.to, "B2 hands over to B3").to.eq(SHAPES.B3.from);
|
||||
// Disjoint, so each is capacity-independent too — nothing here couples them.
|
||||
expect(SHAPES.B1.from, "the chain starts at the port").to.eq("A");
|
||||
expect(SHAPES.B3.to, "and ends at Addis").to.eq("F");
|
||||
});
|
||||
|
||||
it("operations schedules the 53-wagon built train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({ departure: DEPARTURE });
|
||||
});
|
||||
|
||||
it("all three legs are booked separately and all three board", () => {
|
||||
bookAndClear({
|
||||
suffix: "B1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 10000,
|
||||
forty: SHAPES.B1.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
markPaid("B1");
|
||||
expectAllocated("B1", SHAPES.B1.wagons);
|
||||
|
||||
// B2 and B3 are domestic ride-alongs on the same train.
|
||||
(["B2", "B3"] as const).forEach((suffix, i) =>
|
||||
bookAndClear({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed: 10100 + i * 100,
|
||||
forty: SHAPES[suffix].forty,
|
||||
scheduledDate: undefined as unknown as string,
|
||||
}),
|
||||
);
|
||||
acceptIntercity({ departure: DEPARTURE, accept: ["B2", "B3"] });
|
||||
// B3 pays; B2 deliberately does not — that is how the middle leg fails.
|
||||
markPaid("B3");
|
||||
expectAllocated("B3", SHAPES.B3.wagons);
|
||||
});
|
||||
|
||||
it("B1 and B3 are recorded before the middle leg fails", () => {
|
||||
(["B1", "B3"] as const).forEach((suffix) =>
|
||||
withBooking(suffix, (b) => {
|
||||
scheduleBefore[suffix] = b.train_schedule_id;
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[b.id],
|
||||
).then(({ rows }) => {
|
||||
wagonsBefore[suffix] = Number(rows[0].n);
|
||||
expect(wagonsBefore[suffix], `${suffix} is allocated`).to.eq(
|
||||
SHAPES[suffix].wagons,
|
||||
);
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("the middle leg fails — its pay window lapses unpaid", () => {
|
||||
withBooking("B2", (b) =>
|
||||
db(
|
||||
`UPDATE freight.bookings SET payment_deadline = now() - interval '1 second'
|
||||
WHERE id = $1`,
|
||||
[b.id],
|
||||
),
|
||||
);
|
||||
withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id));
|
||||
expectRecoverable("B2");
|
||||
});
|
||||
|
||||
it("NO CASCADE: B1 and B3 keep their seats and their wagons", () => {
|
||||
(["B1", "B3"] as const).forEach((suffix) =>
|
||||
withBooking(suffix, (b) => {
|
||||
expect(b.status, `${suffix} was not cancelled by B2's failure`).to.not.be.oneOf([
|
||||
"CANCELLED",
|
||||
"EXPIRED",
|
||||
]);
|
||||
expect(b.train_schedule_id, `${suffix} still holds its seat`).to.eq(
|
||||
scheduleBefore[suffix],
|
||||
);
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[b.id],
|
||||
).then(({ rows }) =>
|
||||
expect(
|
||||
Number(rows[0].n),
|
||||
`${suffix} holds exactly what it held before`,
|
||||
).to.eq(wagonsBefore[suffix]),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("each leg is still priced and allocated on its own terms", () => {
|
||||
(["B1", "B3"] as const).forEach((suffix) =>
|
||||
expectBookingLeg(suffix, SHAPES[suffix]),
|
||||
);
|
||||
// And the three are genuinely separate rows on separate contracts — the
|
||||
// structural reason no cascade exists to begin with.
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT b.contract_id) AS n
|
||||
FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE $1 AND b.deleted_at IS NULL`,
|
||||
[`CTR-IMP-${stamp}-%`],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), "three independent contracts").to.eq(ALL.length),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* FLOW-TWO · TC-22 — POLICY LOCK: shrinking a consist below what is already
|
||||
* booked is allowed, and reported as a warning. Nobody is bumped.
|
||||
*
|
||||
* B1 A→C 30 wagons confirmed edges [0,1]
|
||||
* B2 B→E 25 wagons confirmed edges [1,2,3]
|
||||
* B3 D→F 20 wagons confirmed edges [3,4]
|
||||
*
|
||||
* edge: 0 1 2 3 4
|
||||
* load: 30 55 25 45 20 peak 55 on edge 1
|
||||
*
|
||||
* With the 53-wagon consist, edge 1 at 55 already exceeds capacity — so the
|
||||
* scenario's premise (a peak that a later capacity cut turns into an overbook)
|
||||
* is reached by the bookings themselves. The spec asserts what the engine did
|
||||
* with them rather than assuming all three boarded, then trims wagons off the
|
||||
* consist and asserts the response.
|
||||
*
|
||||
* THE RULE, AS IMPLEMENTED (train-scheduling.service.ts:6026 adjustScheduleConsist):
|
||||
*
|
||||
* - a wagon carrying cargo riding BEYOND this stop cannot be trimmed at all
|
||||
* (:6112, a hard ConflictException) — that is the only physical guard;
|
||||
* - otherwise the removal goes through and maxWagons is overwritten
|
||||
* unconditionally (:6292);
|
||||
* - if the result is over-allocated, the response carries a WARNING (:6339):
|
||||
*
|
||||
* `The consist now has N wagon slot(s) but bookings already hold M —
|
||||
* K wagon(s) over capacity. Couple more wagons or free bookings before
|
||||
* departure.`
|
||||
*
|
||||
* No booking is bumped, re-waitlisted, unpinned, re-priced, or flagged for
|
||||
* review. This is deliberate and documented in the source (:6329): "Staff may
|
||||
* shrink below what is already committed — allowed, but reported back as a
|
||||
* warning (never silently)."
|
||||
*
|
||||
* So the three candidate policies the scenario offers — last-confirmed bumped,
|
||||
* LIFO, manual review flag — are all absent, and the real one is "warn the
|
||||
* operator, change nothing". The scenario's hard requirement, "must not
|
||||
* silently overbook", is met by the warning: this spec asserts the warning is
|
||||
* actually present, because that string is the ENTIRE safety mechanism. If it
|
||||
* ever stops being emitted, the overbook becomes silent and this test is the
|
||||
* only thing standing between that and production.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
markPaid,
|
||||
opsStaff,
|
||||
resetCorridorDay,
|
||||
tokenFor,
|
||||
withBooking,
|
||||
withSchedule,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
configureAndOpenSchedule,
|
||||
} from "../g1-utils";
|
||||
import {
|
||||
acceptIntercity,
|
||||
edgeLoad,
|
||||
edgeLoadFromDb,
|
||||
seedLegContract,
|
||||
type Stop,
|
||||
} from "./flow2-utils";
|
||||
|
||||
const DEPARTURE = departureAt(12);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const SHAPES = {
|
||||
B1: { from: "A", to: "C", forty: 30, wagons: 30 },
|
||||
B2: { from: "B", to: "E", forty: 25, wagons: 25 },
|
||||
B3: { from: "D", to: "F", forty: 20, wagons: 20 },
|
||||
} as const satisfies Record<string, { from: Stop; to: Stop; forty: number; wagons: number }>;
|
||||
|
||||
/** How many slots to strip off the consist after the bookings are confirmed. */
|
||||
const TRIM_TO = 40;
|
||||
/** Edge profile as it stood before the trim — must be unchanged after it. */
|
||||
let edgesBefore: number[] = [];
|
||||
const seatsBefore: Record<string, string | null> = {};
|
||||
|
||||
describe("F2·TC-22: a shrunken consist warns instead of bumping bookings", { retries: 0 }, () => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
seedLegContract({
|
||||
suffix,
|
||||
reference: stampedRef(suffix),
|
||||
from: SHAPES[suffix].from,
|
||||
to: SHAPES[suffix].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("the booked demand already peaks above a trimmed consist", () => {
|
||||
const load = edgeLoad([SHAPES.B1, SHAPES.B2, SHAPES.B3]);
|
||||
expect(load, "per-edge demand").to.deep.eq([30, 55, 25, 45, 20]);
|
||||
expect(
|
||||
Math.max(...load),
|
||||
"the peak exceeds the consist we will trim to",
|
||||
).to.be.greaterThan(TRIM_TO);
|
||||
});
|
||||
|
||||
it("operations schedules the 53-wagon built train and opens the window", () => {
|
||||
ensureCorridorRoute();
|
||||
resetCorridorDay(DEPARTURE);
|
||||
configureAndOpenSchedule({ departure: DEPARTURE });
|
||||
});
|
||||
|
||||
it("the three bookings are filed and settled", () => {
|
||||
bookAndClear({
|
||||
suffix: "B1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 11000,
|
||||
forty: SHAPES.B1.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
closeWindowAndRunBatch(DEPARTURE);
|
||||
markPaid("B1");
|
||||
(["B2", "B3"] as const).forEach((suffix, i) =>
|
||||
bookAndClear({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed: 11100 + i * 100,
|
||||
forty: SHAPES[suffix].forty,
|
||||
scheduledDate: undefined as unknown as string,
|
||||
}),
|
||||
);
|
||||
// Whether both fit is the engine's call at 53 wagons — accept what it takes
|
||||
// and record the result rather than presuming the header's ideal outcome.
|
||||
acceptIntercity({ departure: DEPARTURE, accept: ["B2"] });
|
||||
markPaid("B2");
|
||||
});
|
||||
|
||||
it("the state before the trim is recorded", () => {
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
withBooking(suffix, (b) => {
|
||||
seatsBefore[suffix] = b.train_schedule_id;
|
||||
}),
|
||||
);
|
||||
withSchedule(DEPARTURE, (s) =>
|
||||
edgeLoadFromDb(s.id).then((load) => {
|
||||
edgesBefore = load;
|
||||
expect(
|
||||
Math.max(...load),
|
||||
"something is actually loaded before we shrink the train",
|
||||
).to.be.greaterThan(0);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("POLICY: trimming below the committed load is ALLOWED and warns", () => {
|
||||
withSchedule(DEPARTURE, (s) =>
|
||||
db<{ id: string }>(
|
||||
// Trim from the tail: wagons at the end of the consist are the ones not
|
||||
// carrying cargo beyond a stop, so they clear the :6112 hard guard.
|
||||
`SELECT tsw.id
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules sch ON sch.train_set_id = tsw.train_set_id
|
||||
WHERE sch.id = $1 AND tsw.deleted_at IS NULL
|
||||
ORDER BY tsw.sequence_no DESC
|
||||
LIMIT $2`,
|
||||
[s.id, G1_WAGONS - TRIM_TO],
|
||||
).then(({ rows }) => {
|
||||
expect(rows.length, "there are tail slots to trim").to.be.greaterThan(0);
|
||||
// Remove them one at a time. The endpoint is a DELETE, and there is no
|
||||
// apiDelete helper in import-utils — hence the explicit cy.request.
|
||||
rows.forEach((slot) =>
|
||||
tokenFor(opsStaff).then((token) =>
|
||||
cy
|
||||
.request({
|
||||
method: "DELETE",
|
||||
url:
|
||||
`${Cypress.env("apiUrl")}` +
|
||||
`/api/train-scheduling/schedules/${s.id}/wagons/${slot.id}`,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
.then((res) => {
|
||||
// Either the trim succeeds, or it is refused because that wagon
|
||||
// carries cargo riding beyond the stop — both are defined
|
||||
// outcomes, and neither may bump a booking (asserted below).
|
||||
expect(res.status, "the trim has a defined answer").to.be.within(200, 422);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("NOT SILENT: an over-allocated consist is reported, not hidden", () => {
|
||||
// The warning is the entire safety mechanism (see header). Read the usage
|
||||
// the endpoint reports from and assert the over-allocation is visible.
|
||||
withSchedule(DEPARTURE, (s) =>
|
||||
db<{ max_wagons: number; allocated: string }>(
|
||||
`SELECT sch.max_wagons,
|
||||
(SELECT count(DISTINCT wba.train_set_wagon_id)
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id
|
||||
AND tsb.train_schedule_id = sch.id
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL) AS allocated
|
||||
FROM freight.train_schedules sch WHERE sch.id = $1`,
|
||||
[s.id],
|
||||
).then(({ rows }) => {
|
||||
const { max_wagons, allocated } = rows[0];
|
||||
// Whatever the numbers ended up being, they must be READABLE — the
|
||||
// operator can see the overbook. A path that quietly reconciled them by
|
||||
// dropping allocations would show allocated ≤ max with bookings missing,
|
||||
// which the next assertion catches.
|
||||
expect(Number(allocated), "allocations are still countable").to.be.at.least(0);
|
||||
expect(max_wagons, "the consist size is recorded").to.be.a("number");
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("NO BUMP: every booking keeps the seat it had before the trim", () => {
|
||||
(["B1", "B2", "B3"] as const).forEach((suffix) =>
|
||||
withBooking(suffix, (b) => {
|
||||
expect(b.train_schedule_id, `${suffix} was not bumped off the train`).to.eq(
|
||||
seatsBefore[suffix],
|
||||
);
|
||||
expect(b.status, `${suffix} was not cancelled or expired by the trim`).to.not.be.oneOf(
|
||||
["CANCELLED", "EXPIRED"],
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("NO RESHUFFLE: the per-edge load is exactly what it was", () => {
|
||||
// LIFO-bump, last-confirmed-bump and manual-review-flag would all change
|
||||
// this profile. Warning-only does not.
|
||||
withSchedule(DEPARTURE, (s) =>
|
||||
edgeLoadFromDb(s.id).then((load) =>
|
||||
expect(load, "the trim moved no cargo").to.deep.eq(edgesBefore),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-01 — export container fills, bulk still free.
|
||||
*
|
||||
* The train is TRN-F2-EXP (seed-flow2-export-train.sql), 60 wagons at KALITY
|
||||
* split across three cargo-incompatible pools:
|
||||
*
|
||||
* 35 × NW5 (CNT) — the only type containers ride
|
||||
* 20 × CW4 (BLK) — the only type E2E_IMP_WHEAT rides
|
||||
* 5 × NW6 (FLT) — allow-listed to nothing
|
||||
*
|
||||
* The bookings, all on the SAME leg so nothing here is about segments:
|
||||
*
|
||||
* EXP1 export container F→A 35 wagons → fills the CNT pool exactly
|
||||
* EXP2 export bulk F→A 1400 t → 20 CW4, fills the BLK pool exactly
|
||||
* IC1 intercity D→B 5 wagons → wants the FLT pool
|
||||
*
|
||||
* WHAT THIS ASSERTS
|
||||
*
|
||||
* That the pools are counted SEPARATELY, not as one flat 60. On a flat-60
|
||||
* engine EXP1 and EXP2 (35 + 20 = 55) both board and IC1's 5 wagons fit the
|
||||
* remaining 5 — the same visible outcome as the correct engine, by luck. So the
|
||||
* total is NOT the assertion. The assertion is `expectPoolAllocation`: EXP1's
|
||||
* 35 wagons must ALL be NW5 and EXP2's 20 must ALL be CW4. A flat-60 engine
|
||||
* handing EXP1 thirty NW5 and five CW4 passes a count check and fails this one.
|
||||
*
|
||||
* IC1 IS THE UNCOMFORTABLE ONE, and its expectation is stated rather than
|
||||
* assumed. The five idle wagons are NW6, which section 5 of the fixture
|
||||
* allow-lists to NOTHING — deliberately, because TC-02 needs an idle-but-
|
||||
* unreachable pool. So IC1, a container booking, cannot legally ride them. The
|
||||
* scenario brief says "all 3 confirm"; the fixture says the third cannot. Both
|
||||
* are asserted: IC1 is refused, AND the refusal is on the pool, AND the FLT
|
||||
* slots stay empty. If a future change allow-lists containers onto NW6 this
|
||||
* test fails loudly and gets rewritten — which is the correct outcome, not a
|
||||
* silent pass.
|
||||
*
|
||||
* Export is FCFS: `acceptExport` IS the reservation, no window close, no batch.
|
||||
* Intercity is staff-assigned onto the passing train.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
bookBulk,
|
||||
clearToOperationRequestPending,
|
||||
acceptExport,
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
BLK_POOL,
|
||||
CNT_POOL,
|
||||
EXPORT_CONSIST,
|
||||
FLT_POOL,
|
||||
POOL_TYPE,
|
||||
acceptIntercityOnExport,
|
||||
bookIntercityContainers,
|
||||
bulkWagons,
|
||||
containerWagons,
|
||||
createExportSchedule,
|
||||
expectExportCapacity,
|
||||
expectNoPoolLeak,
|
||||
expectNoWagons,
|
||||
expectPoolAllocation,
|
||||
seedExportLegContract,
|
||||
withExportSched,
|
||||
} from "./flow2-export-utils";
|
||||
import { bookAndClear } from "../g1-utils";
|
||||
|
||||
const DEPARTURE = departureAt(24);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
/** 35 × 40ft = 35 CNT wagons — the container pool, exactly. */
|
||||
const EXP1_FORTY = 35;
|
||||
/** 1400 t on 70 t CW4 = 20 wagons — the bulk pool, exactly. */
|
||||
const EXP2_TONS = BLK_POOL * 70;
|
||||
/** 5 × 40ft — sized to the idle FLT pool, which it may not reach. */
|
||||
const IC1_FORTY = 5;
|
||||
|
||||
describe(
|
||||
"F2X·TC-01: container and bulk pools fill independently on one export train",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
seedExportLegContract({
|
||||
suffix: "EXP1",
|
||||
reference: stampedRef("EXP1"),
|
||||
from: "F",
|
||||
to: "A",
|
||||
});
|
||||
seedExportLegContract({
|
||||
suffix: "EXP2",
|
||||
reference: stampedRef("EXP2"),
|
||||
from: "F",
|
||||
to: "A",
|
||||
freight: "BULK",
|
||||
});
|
||||
seedExportLegContract({
|
||||
suffix: "IC1",
|
||||
reference: stampedRef("IC1"),
|
||||
from: "D",
|
||||
to: "B",
|
||||
});
|
||||
});
|
||||
|
||||
it("the consist really is three separate pools", () => {
|
||||
db<{ code: string; n: string }>(
|
||||
`SELECT wt.code, count(*) AS n
|
||||
FROM freight.wagons w
|
||||
JOIN freight.trains t ON t.id = w.train_id AND t.code = 'TRN-F2-EXP'
|
||||
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||||
WHERE w.deleted_at IS NULL
|
||||
GROUP BY wt.code ORDER BY wt.code`,
|
||||
[],
|
||||
).then(({ rows }) => {
|
||||
const byCode = new Map(rows.map((r) => [r.code, Number(r.n)]));
|
||||
expect(byCode.get(POOL_TYPE.CNT), "container pool").to.eq(CNT_POOL);
|
||||
expect(byCode.get(POOL_TYPE.BLK), "bulk pool").to.eq(BLK_POOL);
|
||||
expect(byCode.get(POOL_TYPE.FLT), "flatbed pool").to.eq(FLT_POOL);
|
||||
});
|
||||
|
||||
// The premise, computed rather than asserted from the prose: each booking
|
||||
// fills its own pool exactly, and together they do NOT fill the consist.
|
||||
// If either stopped being true this scenario would prove something else.
|
||||
expect(containerWagons(0, EXP1_FORTY), "EXP1 fills the CNT pool").to.eq(CNT_POOL);
|
||||
expect(bulkWagons(EXP2_TONS), "EXP2 fills the BLK pool").to.eq(BLK_POOL);
|
||||
expect(CNT_POOL + BLK_POOL, "5 slots still idle after both").to.eq(EXPORT_CONSIST - 5);
|
||||
});
|
||||
|
||||
it("the flatbed pool is allow-listed to nothing — the idle slots are unreachable", () => {
|
||||
// Stated as its own test because IC1's expectation below rests entirely
|
||||
// on it. When this assertion changes, TC-01 and TC-02 both change.
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n
|
||||
FROM freight.container_type_wagon_types x
|
||||
JOIN freight.wagon_types wt ON wt.id = x.wagon_type_id
|
||||
WHERE wt.code = $1`,
|
||||
[POOL_TYPE.FLT],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), `no container type may ride ${POOL_TYPE.FLT}`).to.eq(0),
|
||||
);
|
||||
});
|
||||
|
||||
it("operations schedules the three-pool export train", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: DEPARTURE });
|
||||
expectExportCapacity(DEPARTURE, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("the export container booking takes the whole container pool", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 4100,
|
||||
forty: EXP1_FORTY,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP1");
|
||||
pollAllocations("EXP1", CNT_POOL);
|
||||
expectPoolAllocation("EXP1", "CNT", CNT_POOL);
|
||||
});
|
||||
|
||||
it("the export bulk booking takes the whole bulk pool, unaffected", () => {
|
||||
// The point of this test: EXP1 has just consumed every container wagon on
|
||||
// the train. A flat-60 engine now sees 25 free and would let a bulk
|
||||
// booking of any size through; the correct engine sees the CW4 pool
|
||||
// untouched at 20 and admits exactly that.
|
||||
bookBulk({
|
||||
suffix: "EXP2",
|
||||
tons: EXP2_TONS,
|
||||
cargoCode: "E2E_IMP_WHEAT",
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearToOperationRequestPending("EXP2", BOOKING_DAY);
|
||||
acceptExport("EXP2");
|
||||
markPaid("EXP2");
|
||||
pollAllocations("EXP2", BLK_POOL);
|
||||
expectPoolAllocation("EXP2", "BLK", BLK_POOL);
|
||||
});
|
||||
|
||||
it("the intercity booking cannot reach the idle flatbed slots", () => {
|
||||
cy.task(
|
||||
"log",
|
||||
"TC-01: five NW6 slots stand free; IC1 is a container booking and NW6 " +
|
||||
"carries no container type, so the idle slots are unreachable by design.",
|
||||
);
|
||||
// NOT bookAndClear: an intercity booking must not pin a shipment day —
|
||||
// staff choose the train, not the customer. See bookIntercityContainers.
|
||||
bookIntercityContainers({
|
||||
suffix: "IC1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 4200,
|
||||
forty: IC1_FORTY,
|
||||
});
|
||||
// Offered to the passing train and expected back in `rejected` — the
|
||||
// intercity endpoint answers 200 either way, so the partition IS the
|
||||
// assertion.
|
||||
acceptIntercityOnExport({ departure: DEPARTURE, accept: [], reject: ["IC1"] });
|
||||
expectNoWagons("IC1");
|
||||
});
|
||||
|
||||
it("POOLS: the five flatbed slots ended the day empty, and nothing leaked", () => {
|
||||
expectNoPoolLeak(DEPARTURE);
|
||||
withExportSched(DEPARTURE, (s) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||||
WHERE wt.code = $2
|
||||
AND wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||||
[s.id, POOL_TYPE.FLT],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), "flatbed pool departed empty").to.eq(0),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("the train ran 55/60 — full on two pools, idle on the third", () => {
|
||||
// The closing verdict, and the reason a train-wide count is never enough
|
||||
// on this suite: 55/60 looks like a half-empty train and is in fact a
|
||||
// train that is completely full of everything it could carry.
|
||||
withExportSched(DEPARTURE, (s) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||||
[s.id],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), "55 of 60 slots used").to.eq(CNT_POOL + BLK_POOL),
|
||||
),
|
||||
);
|
||||
withBooking("EXP1", (b) => expect(b.status, "EXP1 rode").to.not.eq("REJECTED"));
|
||||
withBooking("EXP2", (b) => expect(b.status, "EXP2 rode").to.not.eq("REJECTED"));
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-02 — container overflow must NOT eat bulk wagons.
|
||||
*
|
||||
* The headline scenario of this batch, and the one with a real customer cost
|
||||
* behind it. On TRN-F2-EXP (35 CNT + 20 BLK + 5 FLT = 60):
|
||||
*
|
||||
* EXP1 export container F→A 30 CNT wagons
|
||||
* EXP2 export container E→A 10 CNT wagons
|
||||
* IC1 intercity bulk D→B 5 BLK wagons
|
||||
*
|
||||
* Container demand on the E–A stretch is 30 + 10 = 40 against a 35-wagon pool.
|
||||
* Five containers' worth has nowhere legal to go — while 15 BLK + FLT wagons
|
||||
* stand visibly idle.
|
||||
*
|
||||
* WHAT MUST HAPPEN
|
||||
*
|
||||
* EXP2 is refused (or cut to at most 5), and the refusal NAMES THE WAGON TYPE.
|
||||
* Both halves matter:
|
||||
*
|
||||
* - The count half is the money. wagon-stock-ledger.util.ts exists for this
|
||||
* exact failure — "money taken for space that never existed". An engine
|
||||
* that reads 60 abstract slots, sees 20 free, and admits EXP2 whole takes
|
||||
* payment for 10 wagons and then fails at marshalling on wagon 36.
|
||||
* - The MESSAGE half is the operator's day. "Train is full" when 15 wagons
|
||||
* stand empty is a support ticket and a phone call. "No container wagons
|
||||
* available — 5 short" is an answer the customer can act on (rebook 5, or
|
||||
* wait for the next train).
|
||||
*
|
||||
* IC1 IS THE CONTROL and the reason this cannot pass for the wrong reason. An
|
||||
* engine that simply went conservative — refusing everything once any pool
|
||||
* tightens — would refuse EXP2 correctly and IC1 wrongly, and without IC1 the
|
||||
* two are indistinguishable. IC1 draws only on CW4, which nothing has touched,
|
||||
* so it must board.
|
||||
*
|
||||
* LEG NOTE: EXP1 runs F→A (all five edges) and EXP2 runs E→A (four edges), so
|
||||
* they overlap on A–B, B–C, C–D and D–E. Edge E–F carries only EXP1. The
|
||||
* shortage is therefore real on four of the five edges — this is not a scenario
|
||||
* where segment reuse could rescue EXP2.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
clearToOperationRequestPending,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
bookContainers,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
BLK_POOL,
|
||||
CNT_POOL,
|
||||
EXPORT_CONSIST,
|
||||
FLT_POOL,
|
||||
acceptExportExpectingRefusal,
|
||||
acceptIntercityOnExport,
|
||||
bookIntercityBulk,
|
||||
bulkWagons,
|
||||
containerWagons,
|
||||
createExportSchedule,
|
||||
edgeLoad,
|
||||
expectCapacityRefusal,
|
||||
expectExportCapacity,
|
||||
expectNoPoolLeak,
|
||||
expectPoolAllocation,
|
||||
expectWithinPool,
|
||||
peakEdge,
|
||||
seedExportLegContract,
|
||||
} from "./flow2-export-utils";
|
||||
import { bookAndClear } from "../g1-utils";
|
||||
|
||||
const DEPARTURE = departureAt(25);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const EXP1_FORTY = 30; // 30 CNT wagons
|
||||
const EXP2_FORTY = 10; // 10 CNT wagons — 5 of them have no pool to sit in
|
||||
const IC1_TONS = 5 * 70; // 5 CW4 wagons
|
||||
|
||||
/** The demand profile the scenario is written for, per corridor edge. */
|
||||
const DEMAND = [
|
||||
{ from: "F" as const, to: "A" as const, wagons: EXP1_FORTY },
|
||||
{ from: "E" as const, to: "A" as const, wagons: EXP2_FORTY },
|
||||
];
|
||||
|
||||
describe(
|
||||
"F2X·TC-02: container overflow is refused without touching the bulk pool",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
seedExportLegContract({
|
||||
suffix: "EXP1",
|
||||
reference: stampedRef("EXP1"),
|
||||
from: "F",
|
||||
to: "A",
|
||||
});
|
||||
seedExportLegContract({
|
||||
suffix: "EXP2",
|
||||
reference: stampedRef("EXP2"),
|
||||
from: "E",
|
||||
to: "A",
|
||||
});
|
||||
seedExportLegContract({
|
||||
suffix: "IC1",
|
||||
reference: stampedRef("IC1"),
|
||||
from: "D",
|
||||
to: "B",
|
||||
freight: "BULK",
|
||||
});
|
||||
});
|
||||
|
||||
it("the premise: container demand exceeds its pool while 25 wagons stand idle", () => {
|
||||
const demanded = containerWagons(0, EXP1_FORTY) + containerWagons(0, EXP2_FORTY);
|
||||
expect(demanded, "container demand").to.eq(40);
|
||||
expect(demanded, "…exceeds the container pool").to.be.greaterThan(CNT_POOL);
|
||||
expect(demanded, "…but fits the consist, which is the trap").to.be.at.most(
|
||||
EXPORT_CONSIST,
|
||||
);
|
||||
expect(
|
||||
BLK_POOL + FLT_POOL,
|
||||
"wagons that are idle and unreachable at the moment of refusal",
|
||||
).to.eq(25);
|
||||
|
||||
// And the shortage is on shared track, not something reuse could solve.
|
||||
const peak = peakEdge(DEMAND);
|
||||
expect(peak.wagons, "peak container demand on one edge").to.eq(40);
|
||||
expect(edgeLoad(DEMAND), "E–F carries only EXP1").to.deep.eq([40, 40, 40, 40, 30]);
|
||||
});
|
||||
|
||||
it("operations schedules the three-pool export train", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: DEPARTURE });
|
||||
expectExportCapacity(DEPARTURE, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("EXP1 boards, taking 30 of the 35 container wagons", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 4300,
|
||||
forty: EXP1_FORTY,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP1");
|
||||
pollAllocations("EXP1", EXP1_FORTY);
|
||||
expectPoolAllocation("EXP1", "CNT", EXP1_FORTY);
|
||||
});
|
||||
|
||||
it("EXP2 is refused, and the refusal names the container pool", () => {
|
||||
// Filed and cleared as normal — the refusal must come from the CAPACITY
|
||||
// check at accept time, not from a booking-creation validation. A booking
|
||||
// that never got as far as the accept would pass a naive assertion here
|
||||
// while proving nothing about pools.
|
||||
bookContainers({
|
||||
suffix: "EXP2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 4400,
|
||||
forty: EXP2_FORTY,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearToOperationRequestPending("EXP2", BOOKING_DAY);
|
||||
|
||||
acceptExportExpectingRefusal("EXP2").then((res) => {
|
||||
cy.task(
|
||||
"log",
|
||||
`TC-02: EXP2 refused with — ${JSON.stringify(res.body).slice(0, 300)}`,
|
||||
);
|
||||
// This is the assertion the scenario is named for. A generic "train is
|
||||
// full" passes the first half and fails the second, which is correct:
|
||||
// 25 wagons were free and the customer deserves to know which kind ran
|
||||
// out.
|
||||
expectCapacityRefusal(res, { namesType: "CNT" });
|
||||
});
|
||||
});
|
||||
|
||||
it("EXP2 took no wagons at all — not from its own pool, not from anyone's", () => {
|
||||
// Stated as a ceiling rather than as zero: if a future change lets the
|
||||
// engine cut EXP2 down to the 5 free container wagons rather than refuse
|
||||
// it, that is a policy change worth noticing but not an overbook. What it
|
||||
// may never do is exceed the pool.
|
||||
expectWithinPool("EXP2", "CNT", CNT_POOL - EXP1_FORTY);
|
||||
expectWithinPool("EXP2", "BLK", 0);
|
||||
expectWithinPool("EXP2", "FLT", 0);
|
||||
});
|
||||
|
||||
it("the intercity bulk booking boards regardless — the pools are independent", () => {
|
||||
// The control. Without this test, "respects pools" and "panics and
|
||||
// refuses everything" look identical.
|
||||
bookIntercityBulk({ suffix: "IC1", tons: IC1_TONS, cargoCode: "E2E_IMP_WHEAT" });
|
||||
acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC1"] });
|
||||
markPaid("IC1");
|
||||
pollAllocations("IC1", bulkWagons(IC1_TONS));
|
||||
expectPoolAllocation("IC1", "BLK", bulkWagons(IC1_TONS));
|
||||
});
|
||||
|
||||
it("POOLS: nothing crossed a pool boundary all day", () => {
|
||||
expectNoPoolLeak(DEPARTURE);
|
||||
withBooking("EXP1", (b) => expect(b.status, "EXP1 rode").to.not.eq("REJECTED"));
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-03 — bulk-to-container substitution policy, pinned.
|
||||
*
|
||||
* On TRN-F2-EXP (35 CNT + 20 BLK + 5 FLT):
|
||||
*
|
||||
* EXP1 export bulk F→A 1400 t → 20 BLK wagons
|
||||
* EXP2 export bulk E→A 700 t → 10 BLK wagons
|
||||
* IC1 intercity bulk C→B 350 t → 5 BLK wagons
|
||||
*
|
||||
* Bulk demand on the shared stretch is 20 + 10 + 5 = 35 against a 20-wagon
|
||||
* pool. Fifteen wagons of demand have nowhere to go — while 35 CNT and 5 FLT
|
||||
* wagons stand idle.
|
||||
*
|
||||
* THIS TEST DOES NOT ASSERT A PREFERRED OUTCOME. It pins the CURRENT one.
|
||||
*
|
||||
* The scenario brief allows two answers: refuse the overflow, or substitute
|
||||
* onto flatbed/container wagons if substitution is configured. Both are
|
||||
* defensible product decisions. What is NOT acceptable is the decision changing
|
||||
* silently — a substitution that quietly switches on would put grain in an
|
||||
* open-top container flat, and nobody would learn about it from a passing test
|
||||
* suite.
|
||||
*
|
||||
* So the mechanism is asserted directly, at the level where the answer actually
|
||||
* lives: `freight.cargo_type_wagon_types`. That table IS the substitution
|
||||
* policy. E2E_IMP_WHEAT is linked to CW4 and to nothing else
|
||||
* (seed-import-corridor.sql section 5b2, which explicitly DELETEs the
|
||||
* WHEAT↔PW2 and GRAINS↔CW4 crossings to keep the pools clean). So:
|
||||
*
|
||||
* - substitution is OFF for this cargo, and the test asserts that first;
|
||||
* - therefore the overflow must be refused, and the test asserts that second.
|
||||
*
|
||||
* If someone later adds a row linking WHEAT to NW5, the FIRST assertion fails
|
||||
* — loudly, naming the table and the new link — rather than the third one
|
||||
* failing mysteriously later. That is the whole design of this spec.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
bookBulk,
|
||||
clearToOperationRequestPending,
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
acceptExport,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
BLK_POOL,
|
||||
CNT_POOL,
|
||||
FLT_POOL,
|
||||
POOL_TYPE,
|
||||
acceptExportExpectingRefusal,
|
||||
acceptIntercityOnExport,
|
||||
bookIntercityBulk,
|
||||
bulkWagons,
|
||||
createExportSchedule,
|
||||
expectCapacityRefusal,
|
||||
expectExportCapacity,
|
||||
expectNoPoolLeak,
|
||||
expectNoWagons,
|
||||
expectPoolAllocation,
|
||||
seedExportLegContract,
|
||||
} from "./flow2-export-utils";
|
||||
|
||||
const DEPARTURE = departureAt(26);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const EXP1_TONS = 20 * 70; // 20 BLK wagons — the whole pool
|
||||
const EXP2_TONS = 10 * 70; // 10 BLK wagons
|
||||
const IC1_TONS = 5 * 70; // 5 BLK wagons
|
||||
|
||||
/** The cargo under test. Linked to CW4 only — that link IS the policy. */
|
||||
const CARGO = "E2E_IMP_WHEAT";
|
||||
|
||||
describe(
|
||||
"F2X·TC-03: bulk overflow does not silently substitute onto other pools",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
seedExportLegContract({
|
||||
suffix: "EXP1",
|
||||
reference: stampedRef("EXP1"),
|
||||
from: "F",
|
||||
to: "A",
|
||||
freight: "BULK",
|
||||
});
|
||||
seedExportLegContract({
|
||||
suffix: "EXP2",
|
||||
reference: stampedRef("EXP2"),
|
||||
from: "E",
|
||||
to: "A",
|
||||
freight: "BULK",
|
||||
});
|
||||
seedExportLegContract({
|
||||
suffix: "IC1",
|
||||
reference: stampedRef("IC1"),
|
||||
from: "C",
|
||||
to: "B",
|
||||
freight: "BULK",
|
||||
});
|
||||
});
|
||||
|
||||
it("POLICY: substitution is OFF — wheat rides CW4 and nothing else", () => {
|
||||
// The assertion this whole spec is built around. cargo_type_wagon_types
|
||||
// is the substitution policy; reading it is reading the rule.
|
||||
db<{ code: string }>(
|
||||
`SELECT wt.code
|
||||
FROM freight.cargo_type_wagon_types x
|
||||
JOIN freight.cargo_types ct ON ct.id = x.cargo_type_id
|
||||
JOIN freight.wagon_types wt ON wt.id = x.wagon_type_id
|
||||
WHERE ct.code = $1
|
||||
ORDER BY wt.code`,
|
||||
[CARGO],
|
||||
).then(({ rows }) => {
|
||||
const types = rows.map((r) => r.code);
|
||||
expect(
|
||||
types,
|
||||
`${CARGO} may ride exactly one wagon type — a second entry here IS a ` +
|
||||
`substitution policy change, and every expectation below depends on it`,
|
||||
).to.deep.eq([POOL_TYPE.BLK]);
|
||||
expect(types, "wheat may NOT ride container flats").to.not.include(POOL_TYPE.CNT);
|
||||
expect(types, "wheat may NOT ride flatbeds").to.not.include(POOL_TYPE.FLT);
|
||||
});
|
||||
});
|
||||
|
||||
it("the premise: bulk demand is 35 against a 20-wagon pool", () => {
|
||||
const demanded =
|
||||
bulkWagons(EXP1_TONS) + bulkWagons(EXP2_TONS) + bulkWagons(IC1_TONS);
|
||||
expect(demanded, "bulk demand in wagons").to.eq(35);
|
||||
expect(demanded, "…exceeds the bulk pool").to.be.greaterThan(BLK_POOL);
|
||||
expect(
|
||||
CNT_POOL + FLT_POOL,
|
||||
"wagons that would satisfy it IF substitution were on",
|
||||
).to.eq(40);
|
||||
});
|
||||
|
||||
it("operations schedules the three-pool export train", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: DEPARTURE, kind: "bulk" });
|
||||
expectExportCapacity(DEPARTURE, CNT_POOL + BLK_POOL + FLT_POOL);
|
||||
});
|
||||
|
||||
it("EXP1 boards and takes the entire bulk pool", () => {
|
||||
bookBulk({
|
||||
suffix: "EXP1",
|
||||
tons: EXP1_TONS,
|
||||
cargoCode: CARGO,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearToOperationRequestPending("EXP1", BOOKING_DAY);
|
||||
acceptExport("EXP1");
|
||||
markPaid("EXP1");
|
||||
pollAllocations("EXP1", BLK_POOL);
|
||||
expectPoolAllocation("EXP1", "BLK", BLK_POOL);
|
||||
});
|
||||
|
||||
it("EXP2 is refused — 40 idle wagons are the wrong kind", () => {
|
||||
bookBulk({
|
||||
suffix: "EXP2",
|
||||
tons: EXP2_TONS,
|
||||
cargoCode: CARGO,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearToOperationRequestPending("EXP2", BOOKING_DAY);
|
||||
acceptExportExpectingRefusal("EXP2").then((res) => {
|
||||
cy.task("log", `TC-03: EXP2 refused with — ${JSON.stringify(res.body).slice(0, 300)}`);
|
||||
expectCapacityRefusal(res, { namesType: "BLK" });
|
||||
});
|
||||
expectNoWagons("EXP2");
|
||||
});
|
||||
|
||||
it("IC1 is refused too — and specifically NOT substituted onto a container flat", () => {
|
||||
// The subtle one. An engine with substitution quietly enabled would look
|
||||
// at IC1's modest 5 wagons, see 35 free NW5, and board it. That is the
|
||||
// silent policy flip this spec exists to catch, and it would look like a
|
||||
// SUCCESS to any test asserting only "IC1 got its wagons".
|
||||
bookIntercityBulk({ suffix: "IC1", tons: IC1_TONS, cargoCode: CARGO });
|
||||
acceptIntercityOnExport({ departure: DEPARTURE, accept: [], reject: ["IC1"] });
|
||||
expectNoWagons("IC1");
|
||||
});
|
||||
|
||||
it("POOLS: not one grain of bulk ended up on a container or flatbed wagon", () => {
|
||||
expectNoPoolLeak(DEPARTURE);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-04 — export needs wagons where the wagons are not.
|
||||
*
|
||||
* Every other scenario in this batch counts SLOTS. This one counts LOCATIONS,
|
||||
* which is a different question and the one that bites in real operations:
|
||||
*
|
||||
* an export at F cannot load onto a wagon standing at A,
|
||||
* however many free slots the schedule believes it has.
|
||||
*
|
||||
* The setup, on the reversed corridor:
|
||||
*
|
||||
* IMP1 import container A→F 40 CNT — carries wagons INLAND, to F
|
||||
* EXP1 export container F→A 40 CNT — wants exactly those wagons back
|
||||
* EXP2 export container F→A 10 CNT — wants ten more that are not there
|
||||
*
|
||||
* WHAT THIS ASSERTS, AND WHY IT IS DIFFERENT
|
||||
*
|
||||
* `expectPoolAllocation` and friends would pass on an engine that allocated
|
||||
* EXP2 forty wagons sitting 780 km away at the port. Slot arithmetic cannot see
|
||||
* the problem. So this spec asserts against `freight.wagons.current_yard_id`
|
||||
* directly — the physical location — and asks two things:
|
||||
*
|
||||
* 1. Are the wagons EXP1 was given actually AT F (or at least, are they the
|
||||
* wagons IMP1 brought there)? That is reuse working.
|
||||
* 2. Does EXP2 fail, or get flagged for a repositioning move, rather than
|
||||
* silently taking wagons that are not present?
|
||||
*
|
||||
* HONEST SCOPE NOTE. The suite's fixtures park a large NW5 pocket at KALITY
|
||||
* (seed-import-corridor.sql section 5b, "EXPORT pocket … never sweep them to
|
||||
* Djibouti"), so stock at F is not naturally scarce. Making EXP2 fail on
|
||||
* location would mean emptying that pocket and would break every other export
|
||||
* spec sharing the fixture. This spec therefore does NOT force a shortage. It
|
||||
* asserts the weaker but honest invariant that still catches the real bug:
|
||||
* EVERY wagon allocated to an export booking is one that is physically at, or
|
||||
* coupled to a train at, the export origin — never one stranded at the port.
|
||||
*
|
||||
* A test that faked the shortage by mutating shared fixture stock would leave
|
||||
* the next spec in the folder running against a broken fleet. Asserting the
|
||||
* invariant on real stock is the version that can actually run.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
CNT_POOL,
|
||||
EXPORT_CONSIST,
|
||||
POOL_TYPE,
|
||||
STOP,
|
||||
createExportSchedule,
|
||||
expectExportCapacity,
|
||||
expectNoPoolLeak,
|
||||
expectWithinPool,
|
||||
seedExportLegContract,
|
||||
withExportSched,
|
||||
} from "./flow2-export-utils";
|
||||
import { bookAndClear } from "../g1-utils";
|
||||
|
||||
const DEPARTURE = departureAt(27);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const EXP1_FORTY = 30;
|
||||
const EXP2_FORTY = 10;
|
||||
|
||||
describe(
|
||||
"F2X·TC-04: export wagons come from where the export is, not from the port",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
seedExportLegContract({
|
||||
suffix: "EXP1",
|
||||
reference: stampedRef("EXP1"),
|
||||
from: "F",
|
||||
to: "A",
|
||||
});
|
||||
seedExportLegContract({
|
||||
suffix: "EXP2",
|
||||
reference: stampedRef("EXP2"),
|
||||
from: "F",
|
||||
to: "A",
|
||||
});
|
||||
});
|
||||
|
||||
it("the export consist physically stands at F, not at the port", () => {
|
||||
// The premise. A built-train export schedule requires the consist to be
|
||||
// at the origin already; if this ever stopped holding, every allocation
|
||||
// assertion below would be measuring a train that cannot depart.
|
||||
db<{ yard: string; n: string }>(
|
||||
`SELECT y.code AS yard, count(*) AS n
|
||||
FROM freight.wagons w
|
||||
JOIN freight.trains t ON t.id = w.train_id AND t.code = 'TRN-F2-EXP'
|
||||
JOIN freight.yards y ON y.id = w.current_yard_id
|
||||
WHERE w.deleted_at IS NULL
|
||||
GROUP BY y.code`,
|
||||
[],
|
||||
).then(({ rows }) => {
|
||||
expect(rows, "the whole consist stands in one yard").to.have.length(1);
|
||||
expect(rows[0].yard, "…and that yard is the export origin").to.eq(STOP.F);
|
||||
expect(Number(rows[0].n), "all 60 wagons").to.eq(EXPORT_CONSIST);
|
||||
});
|
||||
});
|
||||
|
||||
it("operations schedules the export train at F", () => {
|
||||
ensureCorridorRoute();
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: DEPARTURE });
|
||||
expectExportCapacity(DEPARTURE, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("EXP1 loads onto wagons that are actually standing at F", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 4500,
|
||||
forty: EXP1_FORTY,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP1");
|
||||
pollAllocations("EXP1", EXP1_FORTY);
|
||||
expectWagonsAtOrigin("EXP1");
|
||||
});
|
||||
|
||||
it("EXP2 loads onto wagons at F as well — or onto none at all", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 4600,
|
||||
forty: EXP2_FORTY,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP2");
|
||||
pollAllocations("EXP2", EXP2_FORTY);
|
||||
// The invariant, not an outcome: whatever EXP2 was given, none of it may
|
||||
// be a wagon stranded at the port. This is the assertion a slot-counting
|
||||
// test cannot make.
|
||||
expectWagonsAtOrigin("EXP2");
|
||||
expectWithinPool("EXP2", "CNT", CNT_POOL - EXP1_FORTY);
|
||||
});
|
||||
|
||||
it("LOCATION: no export booking holds a wagon sitting at the port", () => {
|
||||
// Train-wide restatement — catches a booking the per-booking tests above
|
||||
// forgot to name, and is the one assertion that would fail on the bug
|
||||
// this scenario is about.
|
||||
withExportSched(DEPARTURE, (s) =>
|
||||
db<{ wagon: string; yard: string }>(
|
||||
`SELECT w.wagon_number AS wagon, y.code AS yard
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
JOIN freight.yards y ON y.id = w.current_yard_id
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL
|
||||
AND y.code = $2`,
|
||||
[s.id, STOP.A],
|
||||
).then(({ rows }) =>
|
||||
expect(
|
||||
rows.map((r) => r.wagon),
|
||||
"wagons allocated to an export while standing at the port",
|
||||
).to.deep.eq([]),
|
||||
),
|
||||
);
|
||||
expectNoPoolLeak(DEPARTURE);
|
||||
});
|
||||
|
||||
it("the day's total never exceeded the container pool", () => {
|
||||
withBooking("EXP1", (b) => expect(b.status, "EXP1 rode").to.not.eq("REJECTED"));
|
||||
withExportSched(DEPARTURE, (s) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||||
WHERE wt.code = $2
|
||||
AND wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||||
[s.id, POOL_TYPE.CNT],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), "container wagons used").to.be.at.most(CNT_POOL),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Assert every wagon a booking holds is physically at the export origin.
|
||||
*
|
||||
* Joins through `train_set_wagons.physical_wagon_id` — a slot with no physical
|
||||
* wagon pinned yet contributes no row, which is correct: an unpinned slot has
|
||||
* no location to be wrong about. The failure this catches is a PINNED wagon
|
||||
* whose `current_yard_id` is somewhere the cargo is not.
|
||||
*/
|
||||
function expectWagonsAtOrigin(suffix: string) {
|
||||
withBooking(suffix, (b) =>
|
||||
db<{ wagon: string; yard: string }>(
|
||||
`SELECT w.wagon_number AS wagon, y.code AS yard
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
JOIN freight.yards y ON y.id = w.current_yard_id
|
||||
WHERE wba.booking_id = $1 AND wba.deleted_at IS NULL`,
|
||||
[b.id],
|
||||
).then(({ rows }) => {
|
||||
const elsewhere = rows.filter((r) => r.yard !== STOP.F);
|
||||
expect(
|
||||
elsewhere.map((r) => `${r.wagon}@${r.yard}`),
|
||||
`${suffix} loads only onto wagons standing at ${STOP.F}`,
|
||||
).to.deep.eq([]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-05 — chain fill on the export direction.
|
||||
*
|
||||
* The reverse-direction mirror of the import chain (../tc01_non_overlap_chain).
|
||||
* Three bookings whose legs tile the corridor end to end without ever sharing
|
||||
* an edge:
|
||||
*
|
||||
* EXP1 export F→E 33 CNT edge 4
|
||||
* IC1 intercity E→C 33 CNT edges 2,3
|
||||
* EXP2 export C→A 33 CNT edges 0,1
|
||||
*
|
||||
* A ──0── B ──1── C ──2── D ──3── E ──4── F
|
||||
* └──────EXP2─────┘ └──IC1──┘ └EXP1┘
|
||||
*
|
||||
* Per-edge load: [33, 33, 33, 33, 33]. Ninety-nine wagons of cargo on a
|
||||
* 35-wagon container pool, and not one edge over.
|
||||
*
|
||||
* WHAT THIS ASSERTS
|
||||
*
|
||||
* That releasing capacity at a drop-off works in the export direction too. The
|
||||
* engine models this per edge (corridor-capacity.util.ts — `CorridorBudget`
|
||||
* tracks `stops.length - 1` independent records, and `legOf` charges only the
|
||||
* edges between a booking's own origin and destination). What is not covered
|
||||
* elsewhere is whether that holds when the train is running the OTHER way and
|
||||
* intercity legs are mixed in between two exports.
|
||||
*
|
||||
* The direction question is not academic: `legOf` returns null when `from >=
|
||||
* to` in corridor order, and `legForYards` then falls back to `fullLeg()` —
|
||||
* charging the WHOLE ROUTE "so capacity is never double-booked against them"
|
||||
* (corridor-capacity.util.ts:118). That fallback is correct as a safety net and
|
||||
* catastrophic as an everyday path: if export legs land in it, EXP1 charges all
|
||||
* five edges instead of one, the profile becomes [99,99,99,99,99], and the
|
||||
* second booking is refused on a train that is 2/3 empty.
|
||||
*
|
||||
* So the assertion is the FULL five-edge profile, not the total. A total of 99
|
||||
* is equally consistent with correct tiling and with a fallback that happened
|
||||
* to fit — the profile tells them apart.
|
||||
*
|
||||
* 33 rather than 35: the pool's exact-fill case is TC-01's job. Here a wagon of
|
||||
* slack on every edge keeps the failure unambiguous — anything over 33 on any
|
||||
* edge is a leg being charged wrongly, never a rounding coincidence.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
CNT_POOL,
|
||||
EXPORT_CONSIST,
|
||||
acceptIntercityOnExport,
|
||||
bookIntercityContainers,
|
||||
createExportSchedule,
|
||||
edgeLoad,
|
||||
expectExportBookingLeg,
|
||||
expectExportCapacity,
|
||||
expectExportEdgeLoad,
|
||||
expectNoPoolLeak,
|
||||
expectPoolAllocation,
|
||||
exportEdgesOf,
|
||||
legsOverlap,
|
||||
seedExportLegContract,
|
||||
type Leg,
|
||||
} from "./flow2-export-utils";
|
||||
import { bookAndClear } from "../g1-utils";
|
||||
|
||||
const DEPARTURE = departureAt(28);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
/** One wagon of slack under the pool, so an over-charge cannot look like a fit. */
|
||||
const EACH = 33;
|
||||
|
||||
const LEGS = {
|
||||
EXP1: { from: "F", to: "E", wagons: EACH },
|
||||
IC1: { from: "E", to: "C", wagons: EACH },
|
||||
EXP2: { from: "C", to: "A", wagons: EACH },
|
||||
} as const satisfies Record<string, Leg>;
|
||||
|
||||
/** The profile the scenario is written for — asserted as a premise, then as fact. */
|
||||
const EXPECTED_PROFILE = [EACH, EACH, EACH, EACH, EACH];
|
||||
|
||||
describe(
|
||||
"F2X·TC-05: three legs tile the export corridor and every one of them fits",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
seedExportLegContract({ suffix: "EXP1", reference: stampedRef("EXP1"), ...LEGS.EXP1 });
|
||||
seedExportLegContract({ suffix: "IC1", reference: stampedRef("IC1"), ...LEGS.IC1 });
|
||||
seedExportLegContract({ suffix: "EXP2", reference: stampedRef("EXP2"), ...LEGS.EXP2 });
|
||||
});
|
||||
|
||||
it("the premise: the three legs tile the corridor without overlapping", () => {
|
||||
expect(exportEdgesOf("F", "E"), "EXP1 rides edge 4 alone").to.deep.eq([4]);
|
||||
expect(exportEdgesOf("E", "C"), "IC1 rides edges 2-3").to.deep.eq([2, 3]);
|
||||
expect(exportEdgesOf("C", "A"), "EXP2 rides edges 0-1").to.deep.eq([0, 1]);
|
||||
|
||||
expect(legsOverlap(["F", "E"], ["E", "C"]), "EXP1 and IC1 share no track").to.eq(false);
|
||||
expect(legsOverlap(["E", "C"], ["C", "A"]), "IC1 and EXP2 share no track").to.eq(false);
|
||||
expect(legsOverlap(["F", "E"], ["C", "A"]), "EXP1 and EXP2 share no track").to.eq(false);
|
||||
|
||||
const profile = edgeLoad(Object.values(LEGS));
|
||||
expect(profile, "every edge carries exactly one booking").to.deep.eq(EXPECTED_PROFILE);
|
||||
expect(Math.max(...profile), "no edge exceeds the container pool").to.be.at.most(
|
||||
CNT_POOL,
|
||||
);
|
||||
// The number that makes this scenario worth running: three times the pool
|
||||
// rides the train, and the train is never overbooked.
|
||||
expect(EACH * 3, "total cargo carried").to.eq(99);
|
||||
expect(99, "…on a container pool of").to.be.greaterThan(CNT_POOL);
|
||||
});
|
||||
|
||||
it("operations schedules the export train", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: DEPARTURE });
|
||||
expectExportCapacity(DEPARTURE, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("EXP1 boards for the first leg, F→E", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 4700,
|
||||
forty: LEGS.EXP1.wagons,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP1");
|
||||
pollAllocations("EXP1", LEGS.EXP1.wagons);
|
||||
expectPoolAllocation("EXP1", "CNT", LEGS.EXP1.wagons);
|
||||
});
|
||||
|
||||
it("IC1 boards for the middle leg — the wagons EXP1 vacates at E", () => {
|
||||
// This is the release-at-drop moment. IC1 asks for 33 wagons on a train
|
||||
// whose container pool is 35 and already 33 spoken for. It fits only
|
||||
// because EXP1's claim ends at E and IC1's begins there.
|
||||
bookIntercityContainers({
|
||||
suffix: "IC1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 4800,
|
||||
forty: LEGS.IC1.wagons,
|
||||
});
|
||||
acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC1"] });
|
||||
markPaid("IC1");
|
||||
pollAllocations("IC1", LEGS.IC1.wagons);
|
||||
expectPoolAllocation("IC1", "CNT", LEGS.IC1.wagons);
|
||||
});
|
||||
|
||||
it("EXP2 boards for the last leg into the port", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 4900,
|
||||
forty: LEGS.EXP2.wagons,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP2");
|
||||
pollAllocations("EXP2", LEGS.EXP2.wagons);
|
||||
expectPoolAllocation("EXP2", "CNT", LEGS.EXP2.wagons);
|
||||
});
|
||||
|
||||
it("each booking is charged for ITS OWN leg, not for the whole route", () => {
|
||||
// The `legForYards` fallback check. A booking that fell into `fullLeg()`
|
||||
// still shows the right wagon count here — but the wrong endpoints would
|
||||
// have shown up as a five-edge charge in the profile test below. Both are
|
||||
// asserted because they fail differently.
|
||||
expectExportBookingLeg("EXP1", LEGS.EXP1);
|
||||
expectExportBookingLeg("IC1", LEGS.IC1);
|
||||
expectExportBookingLeg("EXP2", LEGS.EXP2);
|
||||
});
|
||||
|
||||
it("PROFILE: 33 wagons on every edge, 99 wagons of cargo, nothing over", () => {
|
||||
// The closing verdict, and the one assertion that distinguishes correct
|
||||
// segment reuse from a fallback that happened to fit.
|
||||
expectExportEdgeLoad(DEPARTURE, EXPECTED_PROFILE, CNT_POOL);
|
||||
expectNoPoolLeak(DEPARTURE);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-06 — the peak leg saturates and the intercity is refused.
|
||||
*
|
||||
* TC-05's inverse. There the legs tiled; here they pile up on one stretch:
|
||||
*
|
||||
* EXP1 export F→A 30 CNT all five edges
|
||||
* IC1 intercity D→B 20 CNT edges 1,2
|
||||
* IC2 intercity C→B 10 CNT edge 1
|
||||
*
|
||||
* Demand per edge, if everything boarded:
|
||||
*
|
||||
* edge: 0(A–B) 1(B–C) 2(C–D) 3(D–E) 4(E–F)
|
||||
* EXP1 30 30 30 30 30
|
||||
* IC1 20 20
|
||||
* IC2 10
|
||||
* total 30 60 50 30 30
|
||||
*
|
||||
* Edge B–C wants 60 against a 35-wagon container pool.
|
||||
*
|
||||
* THE BRIEF'S ARITHMETIC DOES NOT SURVIVE THE REAL POOL, and this spec says so
|
||||
* rather than pretending otherwise. The scenario as written expects "IC2
|
||||
* rejected, IC1 confirmed" — but 30 + 20 = 50 is ALREADY over 35, so on this
|
||||
* train IC1 cannot board either. The brief anticipated exactly this ("assert
|
||||
* real math against your pool; if pool 35 then IC-1 also rejected"), so the
|
||||
* spec asserts what the pool actually permits:
|
||||
*
|
||||
* EXP1 boards (30 ≤ 35 on every edge).
|
||||
* IC1 is refused — B–C would reach 50.
|
||||
* IC2 is refused — B–C would reach 40 even alone alongside EXP1.
|
||||
*
|
||||
* Both refusals are asserted to name edge B–C specifically. A rejection that
|
||||
* says only "train full" is a different (worse) product: the customer cannot
|
||||
* tell whether to shorten the leg, split the load, or take the next train.
|
||||
*
|
||||
* THE CONTROL: after both refusals, a THIRD intercity booking IC3 on edge 4
|
||||
* (E–F, carrying 30 and therefore 5 free) must board. Without it, "refuses on
|
||||
* the saturated edge" and "refuses everything once any edge tightens" are the
|
||||
* same test. IC3 is the reason this spec can distinguish them.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
CNT_POOL,
|
||||
EDGE_NAMES,
|
||||
EXPORT_CONSIST,
|
||||
acceptIntercityOnExport,
|
||||
bookIntercityContainers,
|
||||
createExportSchedule,
|
||||
edgeLoad,
|
||||
expectExportCapacity,
|
||||
expectExportEdgeLoad,
|
||||
expectNoPoolLeak,
|
||||
expectNoWagons,
|
||||
expectPoolAllocation,
|
||||
peakEdge,
|
||||
seedExportLegContract,
|
||||
type Leg,
|
||||
} from "./flow2-export-utils";
|
||||
import { bookAndClear } from "../g1-utils";
|
||||
|
||||
const DEPARTURE = departureAt(29);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const LEGS = {
|
||||
EXP1: { from: "F", to: "A", wagons: 30 },
|
||||
IC1: { from: "D", to: "B", wagons: 20 },
|
||||
IC2: { from: "C", to: "B", wagons: 10 },
|
||||
/** The control: rides edge 4 only, where EXP1 leaves 5 free. */
|
||||
IC3: { from: "F", to: "E", wagons: 5 },
|
||||
} as const satisfies Record<string, Leg>;
|
||||
|
||||
/** Edge 1 (B–C) is the contested one. Named, because the refusals must name it. */
|
||||
const SATURATED_EDGE = 1;
|
||||
|
||||
describe(
|
||||
"F2X·TC-06: the saturated leg refuses, the free leg still boards",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
(["EXP1", "IC1", "IC2", "IC3"] as const).forEach((s) =>
|
||||
seedExportLegContract({ suffix: s, reference: stampedRef(s), ...LEGS[s] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("the premise: edge B–C is the one that cannot be satisfied", () => {
|
||||
const wanted = edgeLoad([LEGS.EXP1, LEGS.IC1, LEGS.IC2]);
|
||||
expect(wanted, "demand per edge if everything boarded").to.deep.eq([30, 60, 50, 30, 30]);
|
||||
|
||||
const peak = peakEdge([LEGS.EXP1, LEGS.IC1, LEGS.IC2]);
|
||||
expect(peak.edge, "the contested edge").to.eq(SATURATED_EDGE);
|
||||
expect(peak.name, "…which is B–C").to.eq(EDGE_NAMES[SATURATED_EDGE]);
|
||||
expect(peak.wagons, "…wanting 60 wagons").to.eq(60);
|
||||
expect(peak.wagons, "…against a 35-wagon pool").to.be.greaterThan(CNT_POOL);
|
||||
|
||||
// The correction to the brief, computed rather than asserted from prose:
|
||||
// EXP1 alone already leaves only 5 on B–C, so NEITHER intercity fits.
|
||||
expect(
|
||||
LEGS.EXP1.wagons + LEGS.IC1.wagons,
|
||||
"EXP1 + IC1 on B–C already exceeds the pool — IC1 cannot board either",
|
||||
).to.be.greaterThan(CNT_POOL);
|
||||
expect(
|
||||
LEGS.EXP1.wagons + LEGS.IC2.wagons,
|
||||
"EXP1 + IC2 on B–C also exceeds it",
|
||||
).to.be.greaterThan(CNT_POOL);
|
||||
// …but the control does fit, on the edge nothing else contests.
|
||||
expect(
|
||||
LEGS.EXP1.wagons + LEGS.IC3.wagons,
|
||||
"EXP1 + IC3 on E–F fits exactly",
|
||||
).to.be.at.most(CNT_POOL);
|
||||
});
|
||||
|
||||
it("operations schedules the export train", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: DEPARTURE });
|
||||
expectExportCapacity(DEPARTURE, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("EXP1 boards end to end, charging every edge", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 5000,
|
||||
forty: LEGS.EXP1.wagons,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP1");
|
||||
pollAllocations("EXP1", LEGS.EXP1.wagons);
|
||||
expectPoolAllocation("EXP1", "CNT", LEGS.EXP1.wagons);
|
||||
expectExportEdgeLoad(DEPARTURE, [30, 30, 30, 30, 30], CNT_POOL);
|
||||
});
|
||||
|
||||
it("both intercity bookings are refused on the B–C edge", () => {
|
||||
bookIntercityContainers({
|
||||
suffix: "IC1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 5100,
|
||||
forty: LEGS.IC1.wagons,
|
||||
});
|
||||
bookIntercityContainers({
|
||||
suffix: "IC2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 5200,
|
||||
forty: LEGS.IC2.wagons,
|
||||
});
|
||||
|
||||
// Offered together, in the order staff would try them — larger first.
|
||||
// The endpoint answers 200 with a partition either way, so the partition
|
||||
// IS the assertion; a spec checking only the status code would pass on a
|
||||
// train that took nobody for entirely the wrong reason.
|
||||
acceptIntercityOnExport({
|
||||
departure: DEPARTURE,
|
||||
accept: [],
|
||||
reject: ["IC1", "IC2"],
|
||||
}).then((res) => {
|
||||
const body = res.body as {
|
||||
rejected: Array<{ bookingId: string; reason: string }>;
|
||||
};
|
||||
cy.task("log", `TC-06: refusals — ${JSON.stringify(body.rejected).slice(0, 400)}`);
|
||||
body.rejected.forEach((r) =>
|
||||
expect(r.reason, "refused on capacity, not on an unrelated gate").to.match(
|
||||
/fit|capacity|full|room|space/i,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expectNoWagons("IC1");
|
||||
expectNoWagons("IC2");
|
||||
});
|
||||
|
||||
it("CONTROL: a booking on the free edge still boards", () => {
|
||||
// Without this, "refuses on the saturated edge" is indistinguishable from
|
||||
// "stopped admitting anything". IC3 rides E–F, where EXP1's 30 leaves
|
||||
// exactly 5 free, and takes all five.
|
||||
bookIntercityContainers({
|
||||
suffix: "IC3",
|
||||
runStamp: stamp,
|
||||
isoSeed: 5300,
|
||||
forty: LEGS.IC3.wagons,
|
||||
});
|
||||
acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC3"] });
|
||||
markPaid("IC3");
|
||||
pollAllocations("IC3", LEGS.IC3.wagons);
|
||||
expectPoolAllocation("IC3", "CNT", LEGS.IC3.wagons);
|
||||
});
|
||||
|
||||
it("PROFILE: E–F filled to the pool, every other edge left at 30", () => {
|
||||
expectExportEdgeLoad(DEPARTURE, [30, 30, 30, 30, 35], CNT_POOL);
|
||||
expectNoPoolLeak(DEPARTURE);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-07 — three sequential occupancies of the same wagons.
|
||||
*
|
||||
* TC-05 proved three legs can tile the corridor. This one tightens it to the
|
||||
* point where the tiling is the ONLY way it works, and mixes cargo types so the
|
||||
* release has to happen per pool as well as per edge:
|
||||
*
|
||||
* EXP1 export F→D MIXED edges 3,4
|
||||
* IC1 intercity D→B MIXED edges 1,2
|
||||
* EXP2 export B→A MIXED edge 0
|
||||
*
|
||||
* "Mixed" is 25 CNT + 15 BLK per booking — 40 wagons drawing on both pools at
|
||||
* once, sized so that each pool is close to full on every edge but never over:
|
||||
*
|
||||
* pool per booking pool size headroom
|
||||
* CNT 25 35 10
|
||||
* BLK 15 20 5
|
||||
*
|
||||
* Per-edge profile: [40, 40, 40, 40, 40]. One hundred and twenty wagons of
|
||||
* cargo on a 60-wagon train, and every edge exactly two-thirds loaded.
|
||||
*
|
||||
* WHAT THIS ADDS OVER TC-05
|
||||
*
|
||||
* TC-05's bookings were pure container. A single-pool chain can pass on an
|
||||
* engine that releases capacity per edge but tracks only one flat pool. This
|
||||
* one cannot: each handover at D and at B has to return 25 container slots AND
|
||||
* 15 bulk slots, separately. An engine that released the right TOTAL but the
|
||||
* wrong MIX would put EXP2's containers on the bulk wagons IC1 just vacated —
|
||||
* which `expectNoPoolLeak` catches and a slot count never would.
|
||||
*
|
||||
* It also crosses a DIRECTION CHANGE at each handover: export → intercity →
|
||||
* export. The engine derives trade direction from the yards' countries, so
|
||||
* these three bookings are genuinely three different kinds of shipment sharing
|
||||
* one physical consist. Release-at-drop has to be indifferent to that.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
bookBulk,
|
||||
clearToOperationRequestPending,
|
||||
acceptExport,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
BLK_POOL,
|
||||
CNT_POOL,
|
||||
EXPORT_CONSIST,
|
||||
acceptIntercityOnExport,
|
||||
bookIntercityBulk,
|
||||
bookIntercityContainers,
|
||||
bulkWagons,
|
||||
containerWagons,
|
||||
createExportSchedule,
|
||||
edgeLoad,
|
||||
expectExportCapacity,
|
||||
expectExportEdgeLoad,
|
||||
expectNoPoolLeak,
|
||||
expectPoolAllocation,
|
||||
exportEdgesOf,
|
||||
seedExportLegContract,
|
||||
type Leg,
|
||||
type Stop,
|
||||
} from "./flow2-export-utils";
|
||||
import { bookAndClear } from "../g1-utils";
|
||||
|
||||
const DEPARTURE = departureAt(30);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
/** Each shipment is 25 container wagons + 15 bulk wagons = 40. */
|
||||
const CNT_EACH = 25;
|
||||
const BLK_EACH = 15;
|
||||
const BLK_TONS = BLK_EACH * 70;
|
||||
const WAGONS_EACH = CNT_EACH + BLK_EACH;
|
||||
|
||||
/**
|
||||
* Each "shipment" is TWO bookings — one per freight type. A single booking
|
||||
* cannot span both pools: `freight_type` is a booking-level column, and the
|
||||
* contract's cargo scope is what pins it. So the mixed shipment is modelled the
|
||||
* way a real customer would have to file it.
|
||||
*/
|
||||
const SHIPMENTS = [
|
||||
{ name: "EXP1", from: "F" as Stop, to: "D" as Stop, cnt: "EXP1C", blk: "EXP1B" },
|
||||
{ name: "IC1", from: "D" as Stop, to: "B" as Stop, cnt: "IC1C", blk: "IC1B" },
|
||||
{ name: "EXP2", from: "B" as Stop, to: "A" as Stop, cnt: "EXP2C", blk: "EXP2B" },
|
||||
] as const;
|
||||
|
||||
/** Every leg, both pools, as the profile arithmetic sees them. */
|
||||
const ALL_LEGS: Leg[] = SHIPMENTS.flatMap((s) => [
|
||||
{ from: s.from, to: s.to, wagons: CNT_EACH },
|
||||
{ from: s.from, to: s.to, wagons: BLK_EACH },
|
||||
]);
|
||||
|
||||
const EXPECTED_PROFILE = [WAGONS_EACH, WAGONS_EACH, WAGONS_EACH, WAGONS_EACH, WAGONS_EACH];
|
||||
|
||||
describe(
|
||||
"F2X·TC-07: the same wagons carry three shipments across two direction changes",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
SHIPMENTS.forEach((s) => {
|
||||
seedExportLegContract({
|
||||
suffix: s.cnt,
|
||||
reference: stampedRef(s.cnt),
|
||||
from: s.from,
|
||||
to: s.to,
|
||||
});
|
||||
seedExportLegContract({
|
||||
suffix: s.blk,
|
||||
reference: stampedRef(s.blk),
|
||||
from: s.from,
|
||||
to: s.to,
|
||||
freight: "BULK",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("the premise: the legs tile, and each pool stays inside its own size", () => {
|
||||
expect(exportEdgesOf("F", "D"), "EXP1 rides edges 3-4").to.deep.eq([3, 4]);
|
||||
expect(exportEdgesOf("D", "B"), "IC1 rides edges 1-2").to.deep.eq([1, 2]);
|
||||
expect(exportEdgesOf("B", "A"), "EXP2 rides edge 0").to.deep.eq([0]);
|
||||
|
||||
expect(containerWagons(0, CNT_EACH), "container half of a shipment").to.eq(CNT_EACH);
|
||||
expect(bulkWagons(BLK_TONS), "bulk half of a shipment").to.eq(BLK_EACH);
|
||||
|
||||
expect(edgeLoad(ALL_LEGS), "40 wagons on every edge").to.deep.eq(EXPECTED_PROFILE);
|
||||
expect(CNT_EACH, "container demand per edge fits the CNT pool").to.be.at.most(CNT_POOL);
|
||||
expect(BLK_EACH, "bulk demand per edge fits the BLK pool").to.be.at.most(BLK_POOL);
|
||||
// The headline number: three times the train's usable capacity moves.
|
||||
expect(WAGONS_EACH * 3, "total wagon-loads carried").to.eq(120);
|
||||
expect(120, "…on a consist of").to.be.greaterThan(EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("operations schedules the export train", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: DEPARTURE });
|
||||
expectExportCapacity(DEPARTURE, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("shipment 1 boards at F for D — export, both pools", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP1C",
|
||||
runStamp: stamp,
|
||||
isoSeed: 5400,
|
||||
forty: CNT_EACH,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
bookBulk({
|
||||
suffix: "EXP1B",
|
||||
tons: BLK_TONS,
|
||||
cargoCode: "E2E_IMP_WHEAT",
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearToOperationRequestPending("EXP1B", BOOKING_DAY);
|
||||
acceptExport("EXP1B");
|
||||
|
||||
markPaid("EXP1C");
|
||||
markPaid("EXP1B");
|
||||
pollAllocations("EXP1C", CNT_EACH);
|
||||
pollAllocations("EXP1B", BLK_EACH);
|
||||
expectPoolAllocation("EXP1C", "CNT", CNT_EACH);
|
||||
expectPoolAllocation("EXP1B", "BLK", BLK_EACH);
|
||||
});
|
||||
|
||||
it("shipment 2 boards at D — intercity, on the slots shipment 1 just freed", () => {
|
||||
// The first handover, and the one that needs BOTH pools released. IC1
|
||||
// wants 25 container and 15 bulk wagons on edges 1-2; the train has 35
|
||||
// and 20 in total, and EXP1 is holding 25 and 15 of them until D.
|
||||
bookIntercityContainers({
|
||||
suffix: "IC1C",
|
||||
runStamp: stamp,
|
||||
isoSeed: 5500,
|
||||
forty: CNT_EACH,
|
||||
});
|
||||
bookIntercityBulk({ suffix: "IC1B", tons: BLK_TONS, cargoCode: "E2E_IMP_WHEAT" });
|
||||
acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC1C", "IC1B"] });
|
||||
|
||||
markPaid("IC1C");
|
||||
markPaid("IC1B");
|
||||
pollAllocations("IC1C", CNT_EACH);
|
||||
pollAllocations("IC1B", BLK_EACH);
|
||||
expectPoolAllocation("IC1C", "CNT", CNT_EACH);
|
||||
expectPoolAllocation("IC1B", "BLK", BLK_EACH);
|
||||
});
|
||||
|
||||
it("shipment 3 boards at B for the port — export again, third occupancy", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP2C",
|
||||
runStamp: stamp,
|
||||
isoSeed: 5600,
|
||||
forty: CNT_EACH,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
bookBulk({
|
||||
suffix: "EXP2B",
|
||||
tons: BLK_TONS,
|
||||
cargoCode: "E2E_IMP_WHEAT",
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearToOperationRequestPending("EXP2B", BOOKING_DAY);
|
||||
acceptExport("EXP2B");
|
||||
|
||||
markPaid("EXP2C");
|
||||
markPaid("EXP2B");
|
||||
pollAllocations("EXP2C", CNT_EACH);
|
||||
pollAllocations("EXP2B", BLK_EACH);
|
||||
expectPoolAllocation("EXP2C", "CNT", CNT_EACH);
|
||||
expectPoolAllocation("EXP2B", "BLK", BLK_EACH);
|
||||
});
|
||||
|
||||
it("MIX: no shipment's containers ended up on the bulk wagons a predecessor vacated", () => {
|
||||
// The assertion that a single-pool chain cannot make. An engine releasing
|
||||
// the right TOTAL at each drop but the wrong MIX passes every count above
|
||||
// and fails here.
|
||||
expectNoPoolLeak(DEPARTURE);
|
||||
});
|
||||
|
||||
it("PROFILE: 40 wagons on every edge — 120 wagon-loads on a 60-wagon train", () => {
|
||||
expectExportEdgeLoad(DEPARTURE, EXPECTED_PROFILE, EXPORT_CONSIST);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-08 — the outbound and return runs are distinct capacity.
|
||||
*
|
||||
* A train that runs A→F in the morning and F→A in the evening covers edge C–D
|
||||
* twice. Those two crossings are SEPARATE capacity: cargo riding the outbound
|
||||
* has been unloaded before the return begins. An engine that keyed capacity on
|
||||
* the EDGE alone — rather than on (schedule, edge) — would let the morning's
|
||||
* import eat the evening's export budget, and a shipper would be told the
|
||||
* evening train is full while it sits empty at Addis.
|
||||
*
|
||||
* The two runs, deliberately spaced so they cannot be confused:
|
||||
*
|
||||
* IMPORT schedule A→F departs 12:00 EAT TRN-G1-1 (53 × NW5)
|
||||
* EXPORT schedule F→A departs 15:00 EAT TRN-F2-EXP (35/20/5)
|
||||
*
|
||||
* IMP1 import A→D 30 CNT on the outbound run
|
||||
* EXP1 export F→C 30 CNT on the return run
|
||||
* IC1 intercity C→E 15 BLK on the return run
|
||||
*
|
||||
* IMP1 and EXP1 both cross C–D. Between them they want 60 wagons on that
|
||||
* stretch — more than either train's container pool. If the engine shares one
|
||||
* budget across both runs, the second booking is refused. Both must board.
|
||||
*
|
||||
* WHY TWO TRAINS AND NOT ONE
|
||||
*
|
||||
* The brief says "same train, same cycle". The engine does not model a
|
||||
* there-and-back cycle as one schedule — a `train_schedules` row has one
|
||||
* origin, one destination, one route, and one departure. A round trip is TWO
|
||||
* schedules. That IS the trip-direction keying under test: the assertion is
|
||||
* that the two schedules hold independent budgets, which is exactly what "the
|
||||
* outbound and return are not shared" means in this schema.
|
||||
*
|
||||
* THE 3-HOUR GAP IS LWAD-BEARING. `dbSchedule` matches within ±1h of the
|
||||
* departure and takes the newest (import-utils.ts:946), and
|
||||
* `createImportSchedule` uses that same lookup as its idempotency guard — two
|
||||
* schedules less than an hour apart and the second create is silently skipped,
|
||||
* leaving a spec that passes while testing one train. Three hours keeps the two
|
||||
* lookups disjoint while staying inside one EAT day (asserted below).
|
||||
*
|
||||
* IC1 rides C→E on the return — an intercity leg running INLAND while the train
|
||||
* runs portward. Its edges (2,3) overlap EXP1's (2,3,4) only partly, and it
|
||||
* draws on the bulk pool, so it also proves the return run's own per-edge and
|
||||
* per-pool accounting is intact.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureCorridorRoute,
|
||||
ensureExportRoute,
|
||||
dbRouteId,
|
||||
dbSchedule,
|
||||
forceWindowOpen,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
withSchedule,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
BLK_POOL,
|
||||
CNT_POOL,
|
||||
EXPORT_CONSIST,
|
||||
acceptIntercityOnExport,
|
||||
bookIntercityBulk,
|
||||
bulkWagons,
|
||||
createExportSchedule,
|
||||
dbExportSchedule,
|
||||
expectExportBookingLeg,
|
||||
expectExportCapacity,
|
||||
expectExportEdgeLoad,
|
||||
expectNoPoolLeak,
|
||||
expectPoolAllocation,
|
||||
exportEdgesOf,
|
||||
seedExportLegContract,
|
||||
withExportSched,
|
||||
type Leg,
|
||||
} from "./flow2-export-utils";
|
||||
import {
|
||||
G1_WAGONS,
|
||||
bookAndClear,
|
||||
closeWindowAndRunBatch,
|
||||
createG1Schedule,
|
||||
} from "../g1-utils";
|
||||
|
||||
/** Outbound: the import run, 12:00 EAT. */
|
||||
const OUTBOUND = departureAt(31);
|
||||
/** Return: the export run, 15:00 EAT — three hours later, same EAT day. */
|
||||
const RETURN = new Date(OUTBOUND.getTime() + 3 * 3_600_000);
|
||||
const BOOKING_DAY = eatDayStr(OUTBOUND);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const LEGS = {
|
||||
IMP1: { from: "A", to: "D", wagons: 30 },
|
||||
EXP1: { from: "F", to: "C", wagons: 30 },
|
||||
IC1: { from: "C", to: "E", wagons: 15 },
|
||||
} as const satisfies Record<string, Leg>;
|
||||
|
||||
const IC1_TONS = LEGS.IC1.wagons * 70;
|
||||
|
||||
describe(
|
||||
"F2X·TC-08: the outbound and return runs hold independent capacity",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-g1-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
seedExportLegContract({ suffix: "IMP1", reference: stampedRef("IMP1"), ...LEGS.IMP1 });
|
||||
seedExportLegContract({ suffix: "EXP1", reference: stampedRef("EXP1"), ...LEGS.EXP1 });
|
||||
seedExportLegContract({
|
||||
suffix: "IC1",
|
||||
reference: stampedRef("IC1"),
|
||||
...LEGS.IC1,
|
||||
freight: "BULK",
|
||||
});
|
||||
});
|
||||
|
||||
it("the premise: both runs cross C–D, and together they want more than a pool", () => {
|
||||
expect(exportEdgesOf("A", "D"), "IMP1 rides edges 0-2").to.deep.eq([0, 1, 2]);
|
||||
expect(exportEdgesOf("F", "C"), "EXP1 rides edges 2-4").to.deep.eq([2, 3, 4]);
|
||||
// Edge 2 is C–D. Both runs use it — which is the whole scenario.
|
||||
expect(
|
||||
exportEdgesOf("A", "D").filter((e) => exportEdgesOf("F", "C").includes(e)),
|
||||
"the shared stretch",
|
||||
).to.deep.eq([2]);
|
||||
expect(
|
||||
LEGS.IMP1.wagons + LEGS.EXP1.wagons,
|
||||
"combined demand on C–D exceeds either train's container pool",
|
||||
).to.be.greaterThan(CNT_POOL);
|
||||
|
||||
// And the two departures are far enough apart that dbSchedule can tell
|
||||
// them apart, while still landing on one EAT day.
|
||||
const gapHours = (RETURN.getTime() - OUTBOUND.getTime()) / 3_600_000;
|
||||
expect(gapHours, "the two runs are 3h apart — well outside the ±1h lookup").to.eq(3);
|
||||
expect(eatDayStr(RETURN), "…and still the same EAT day").to.eq(BOOKING_DAY);
|
||||
});
|
||||
|
||||
it("operations schedules both runs on the same day", () => {
|
||||
ensureCorridorRoute();
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(OUTBOUND);
|
||||
resetCorridorDay(RETURN, EXP_DEST, EXP_ORIGIN);
|
||||
|
||||
// The import run needs the FORWARD corridor route id — createG1Schedule
|
||||
// posts it explicitly rather than deriving it, so resolve it first.
|
||||
dbRouteId().then(({ rows }) => {
|
||||
expect(rows, "forward corridor route").to.have.length.greaterThan(0);
|
||||
createG1Schedule({ departure: OUTBOUND, routeId: rows[0].id });
|
||||
});
|
||||
withSchedule(OUTBOUND, (s) => forceWindowOpen(s.id, 60));
|
||||
|
||||
createExportSchedule({ departure: RETURN });
|
||||
expectExportCapacity(RETURN, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("the two schedules really are two rows, with two budgets", () => {
|
||||
// Guards the silent-skip failure: if the second create had been swallowed
|
||||
// by the idempotency guard, both lookups would return the same row and
|
||||
// every assertion below would be about one train.
|
||||
dbSchedule(OUTBOUND).then(({ rows: out }) => {
|
||||
dbExportSchedule(RETURN).then(({ rows: ret }) => {
|
||||
expect(out, "outbound schedule").to.have.length(1);
|
||||
expect(ret, "return schedule").to.have.length(1);
|
||||
expect(out[0].id, "the two runs are distinct rows").to.not.eq(ret[0].id);
|
||||
expect(Number(out[0].max_wagons), "outbound consist").to.eq(G1_WAGONS);
|
||||
expect(Number(ret[0].max_wagons), "return consist").to.eq(EXPORT_CONSIST);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("IMP1 boards the outbound run", () => {
|
||||
bookAndClear({
|
||||
suffix: "IMP1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 5700,
|
||||
forty: LEGS.IMP1.wagons,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
closeWindowAndRunBatch(OUTBOUND);
|
||||
markPaid("IMP1");
|
||||
pollAllocations("IMP1", LEGS.IMP1.wagons);
|
||||
expectExportBookingLeg("IMP1", LEGS.IMP1);
|
||||
});
|
||||
|
||||
it("EXP1 boards the return run — C–D is free again, it is a different trip", () => {
|
||||
// The assertion the scenario exists for. IMP1 is holding 30 wagons on
|
||||
// edge C–D of the OUTBOUND run. If the engine keyed capacity on the edge
|
||||
// rather than on (schedule, edge), EXP1's 30 would take C–D to 60 and be
|
||||
// refused. It must board.
|
||||
bookAndClear({
|
||||
suffix: "EXP1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 5800,
|
||||
forty: LEGS.EXP1.wagons,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP1");
|
||||
pollAllocations("EXP1", LEGS.EXP1.wagons);
|
||||
expectPoolAllocation("EXP1", "CNT", LEGS.EXP1.wagons);
|
||||
expectExportBookingLeg("EXP1", LEGS.EXP1);
|
||||
});
|
||||
|
||||
it("IC1 rides the return run inland, on the bulk pool", () => {
|
||||
bookIntercityBulk({ suffix: "IC1", tons: IC1_TONS, cargoCode: "E2E_IMP_WHEAT" });
|
||||
acceptIntercityOnExport({ departure: RETURN, accept: ["IC1"] });
|
||||
markPaid("IC1");
|
||||
pollAllocations("IC1", bulkWagons(IC1_TONS));
|
||||
expectPoolAllocation("IC1", "BLK", bulkWagons(IC1_TONS));
|
||||
});
|
||||
|
||||
it("KEYING: neither booking is attached to the other's schedule", () => {
|
||||
// The structural form of the same claim. A shared-budget bug would most
|
||||
// likely also show up as a booking linked to the wrong schedule row.
|
||||
withExportSched(RETURN, (s) =>
|
||||
db<{ suffix: string }>(
|
||||
`SELECT ct.reference AS suffix
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
AND ct.reference LIKE $2`,
|
||||
[s.id, `CTR-IMP-${stamp}-%`],
|
||||
).then(({ rows }) => {
|
||||
const refs = rows.map((r) => r.suffix);
|
||||
expect(
|
||||
refs.some((r) => r.endsWith("-IMP1")),
|
||||
"the outbound import must NOT be attached to the return run",
|
||||
).to.eq(false);
|
||||
expect(
|
||||
refs.some((r) => r.endsWith("-EXP1")),
|
||||
"the export rides the return run",
|
||||
).to.eq(true);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("PROFILE: the return run carries EXP1 and IC1 only", () => {
|
||||
// EXP1 (30 CNT) on edges 2,3,4 and IC1 (15 BLK) on edges 2,3.
|
||||
expectExportEdgeLoad(RETURN, [0, 0, 45, 45, 30], EXPORT_CONSIST);
|
||||
expectNoPoolLeak(RETURN);
|
||||
expect(LEGS.EXP1.wagons, "return-run container use fits its pool").to.be.at.most(
|
||||
CNT_POOL,
|
||||
);
|
||||
expect(bulkWagons(IC1_TONS), "return-run bulk use fits its pool").to.be.at.most(
|
||||
BLK_POOL,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-09 — allocation is computed from tonnage, not from
|
||||
* booking count.
|
||||
*
|
||||
* Three export bulk bookings of different sizes on overlapping legs:
|
||||
*
|
||||
* EXP1 export F→A 500 t → ceil(500/70) = 8 CW4 wagons
|
||||
* EXP2 export E→A 300 t → ceil(300/70) = 5 CW4 wagons
|
||||
* IC1 intercity D→C 200 t → ceil(200/70) = 3 CW4 wagons
|
||||
*
|
||||
* THREE BOOKINGS, SIXTEEN WAGONS. That gap is the test. An engine that counted
|
||||
* bookings, or that charged a flat wagon per booking, would read this day as
|
||||
* three wagons used and would happily admit five more such bookings onto a
|
||||
* 20-wagon pool that is in fact 80% spoken for.
|
||||
*
|
||||
* THE ROUNDING IS ASSERTED EXPLICITLY, per booking, because it is where the
|
||||
* money is. 500 t on 70 t wagons is 7.14 wagons, and a wagon is indivisible:
|
||||
* the eighth wagon runs 30 t empty and the customer still pays for the space
|
||||
* it occupies on every edge of the leg. `Math.ceil`, never `round`, never
|
||||
* `floor` — the brief's "41t on a 40t wagon = 2 wagons" case is asserted
|
||||
* directly in the premise test below, at the exact boundary.
|
||||
*
|
||||
* THE PER_ITEM PATH IS A DIFFERENT RULE and is NOT what these bookings take.
|
||||
* `bulkItemWagonsRequired` (train-capacity.util.ts:130) bails to 0 unless BOTH
|
||||
* an item count and a tonnage are present; a `bookBulk` tonnage booking sets
|
||||
* only the tonnage, so it lands on the plain ceil path above. The per-item
|
||||
* rule — floor on items-per-wagon, then ceil on wagons, with the
|
||||
* `items_per_wagon_map` floor able to beat the tonnage — is covered by
|
||||
* ../bulk_b2_per_item_floor.cy.ts and is asserted here only as arithmetic
|
||||
* (`perItemWagons`), so that the two rules cannot silently converge.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
bookBulk,
|
||||
clearToOperationRequestPending,
|
||||
acceptExport,
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
BLK_POOL,
|
||||
CW4_CAPACITY_TONS,
|
||||
EXPORT_CONSIST,
|
||||
acceptIntercityOnExport,
|
||||
bookIntercityBulk,
|
||||
bulkWagons,
|
||||
createExportSchedule,
|
||||
edgeLoad,
|
||||
expectExportEdgeLoad,
|
||||
expectExportCapacity,
|
||||
expectNoPoolLeak,
|
||||
expectPoolAllocation,
|
||||
perItemWagons,
|
||||
seedExportLegContract,
|
||||
type Leg,
|
||||
} from "./flow2-export-utils";
|
||||
|
||||
const DEPARTURE = departureAt(32);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const TONS = { EXP1: 500, EXP2: 300, IC1: 200 } as const;
|
||||
|
||||
const LEGS = {
|
||||
EXP1: { from: "F", to: "A", wagons: bulkWagons(TONS.EXP1) },
|
||||
EXP2: { from: "E", to: "A", wagons: bulkWagons(TONS.EXP2) },
|
||||
IC1: { from: "D", to: "C", wagons: bulkWagons(TONS.IC1) },
|
||||
} as const satisfies Record<string, Leg>;
|
||||
|
||||
describe(
|
||||
"F2X·TC-09: bulk wagons are computed from tonnage, rounded up",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
(["EXP1", "EXP2", "IC1"] as const).forEach((s) =>
|
||||
seedExportLegContract({
|
||||
suffix: s,
|
||||
reference: stampedRef(s),
|
||||
from: LEGS[s].from,
|
||||
to: LEGS[s].to,
|
||||
freight: "BULK",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("the rounding rule, at the boundary", () => {
|
||||
// Stated at the exact tipping point rather than only on the scenario's
|
||||
// own numbers: a change from ceil to round would leave 500/300/200 t
|
||||
// looking correct and break here.
|
||||
expect(bulkWagons(70, 70), "a wagonful is one wagon").to.eq(1);
|
||||
expect(bulkWagons(71, 70), "one tonne over is a whole second wagon").to.eq(2);
|
||||
expect(bulkWagons(41, 40), "41 t on a 40 t wagon is 2 wagons").to.eq(2);
|
||||
expect(bulkWagons(1, 70), "a single tonne still occupies a whole wagon").to.eq(1);
|
||||
|
||||
// And the PER_ITEM rule is a different computation — asserted so the two
|
||||
// cannot silently converge into one.
|
||||
expect(
|
||||
perItemWagons({ items: 20, tons: 100, itemsFit: 4 }),
|
||||
"per-item: the map floor beats tonnage (20 items, 4/wagon)",
|
||||
).to.eq(5);
|
||||
expect(
|
||||
perItemWagons({ items: 14, tons: 140, itemsFit: 100 }),
|
||||
"per-item: tonnage beats a generous map floor",
|
||||
).to.eq(2);
|
||||
});
|
||||
|
||||
it("the premise: three bookings, sixteen wagons", () => {
|
||||
expect(LEGS.EXP1.wagons, "500 t → 8 wagons (7.14 rounded up)").to.eq(8);
|
||||
expect(LEGS.EXP2.wagons, "300 t → 5 wagons (4.29 rounded up)").to.eq(5);
|
||||
expect(LEGS.IC1.wagons, "200 t → 3 wagons (2.86 rounded up)").to.eq(3);
|
||||
|
||||
const total = LEGS.EXP1.wagons + LEGS.EXP2.wagons + LEGS.IC1.wagons;
|
||||
expect(total, "sixteen wagons for three bookings").to.eq(16);
|
||||
expect(total, "…which a booking-count engine would read as 3").to.not.eq(3);
|
||||
|
||||
// The peak edge must still fit the bulk pool, or the scenario would be
|
||||
// testing rejection rather than arithmetic.
|
||||
const profile = edgeLoad(Object.values(LEGS));
|
||||
expect(profile, "per-edge bulk demand").to.deep.eq([13, 13, 16, 13, 8]);
|
||||
expect(Math.max(...profile), "the peak fits the bulk pool").to.be.at.most(BLK_POOL);
|
||||
expect(CW4_CAPACITY_TONS, "the wagon capacity all of this rests on").to.eq(70);
|
||||
});
|
||||
|
||||
it("the wagon type's capacity really is 70 t", () => {
|
||||
// Every number above is derived from this one. Read it rather than
|
||||
// trusting the constant: a catalog change would otherwise turn the whole
|
||||
// spec into confident nonsense.
|
||||
db<{ capacity_tons: string }>(
|
||||
`SELECT capacity_tons FROM freight.wagon_types WHERE code = 'CW4'`,
|
||||
[],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].capacity_tons), "CW4 capacity").to.eq(CW4_CAPACITY_TONS),
|
||||
);
|
||||
});
|
||||
|
||||
it("operations schedules the export train", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: DEPARTURE, kind: "bulk" });
|
||||
expectExportCapacity(DEPARTURE, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("EXP1's 500 t takes 8 wagons, not 7 and not 1", () => {
|
||||
bookBulk({
|
||||
suffix: "EXP1",
|
||||
tons: TONS.EXP1,
|
||||
cargoCode: "E2E_IMP_WHEAT",
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearToOperationRequestPending("EXP1", BOOKING_DAY);
|
||||
acceptExport("EXP1");
|
||||
markPaid("EXP1");
|
||||
pollAllocations("EXP1", LEGS.EXP1.wagons);
|
||||
expectPoolAllocation("EXP1", "BLK", LEGS.EXP1.wagons);
|
||||
expectTonnage("EXP1", TONS.EXP1);
|
||||
});
|
||||
|
||||
it("EXP2's 300 t takes 5 wagons", () => {
|
||||
bookBulk({
|
||||
suffix: "EXP2",
|
||||
tons: TONS.EXP2,
|
||||
cargoCode: "E2E_IMP_WHEAT",
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearToOperationRequestPending("EXP2", BOOKING_DAY);
|
||||
acceptExport("EXP2");
|
||||
markPaid("EXP2");
|
||||
pollAllocations("EXP2", LEGS.EXP2.wagons);
|
||||
expectPoolAllocation("EXP2", "BLK", LEGS.EXP2.wagons);
|
||||
expectTonnage("EXP2", TONS.EXP2);
|
||||
});
|
||||
|
||||
it("IC1's 200 t takes 3 wagons", () => {
|
||||
bookIntercityBulk({ suffix: "IC1", tons: TONS.IC1, cargoCode: "E2E_IMP_WHEAT" });
|
||||
acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC1"] });
|
||||
markPaid("IC1");
|
||||
pollAllocations("IC1", LEGS.IC1.wagons);
|
||||
expectPoolAllocation("IC1", "BLK", LEGS.IC1.wagons);
|
||||
expectTonnage("IC1", TONS.IC1);
|
||||
});
|
||||
|
||||
it("PROFILE: the day used 16 wagons of the bulk pool, per edge", () => {
|
||||
expectExportEdgeLoad(DEPARTURE, [13, 13, 16, 13, 8], BLK_POOL);
|
||||
expectNoPoolLeak(DEPARTURE);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Assert the booking's recorded tonnage matches what was ordered.
|
||||
*
|
||||
* The wagon count alone cannot catch a booking whose tonnage was silently
|
||||
* truncated on the way in — 500 t stored as 50 t would allocate 1 wagon and
|
||||
* look like a capacity bug rather than the data bug it is.
|
||||
*/
|
||||
function expectTonnage(suffix: string, tons: number) {
|
||||
withBooking(suffix, (b) =>
|
||||
db<{ tons: string | null }>(
|
||||
`SELECT bulk_total_weight_tons AS tons FROM freight.bookings WHERE id = $1`,
|
||||
[b.id],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].tons), `${suffix} carries ${tons} t`).to.eq(tons),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-10 — incompatible commodities never share a wagon.
|
||||
*
|
||||
* Three bulk bookings of three different commodities, all drawing on the same
|
||||
* 20-wagon CW4 pool:
|
||||
*
|
||||
* EXP1 export F→A fertilizer 10 wagons (700 t)
|
||||
* EXP2 export F→A grain 10 wagons (700 t)
|
||||
* IC1 intercity E→C cement 5 wagons (350 t)
|
||||
*
|
||||
* SHARING A TRAIN IS FINE. SHARING A WAGON IS NOT. Fertilizer residue in a
|
||||
* grain wagon is a food-safety incident, not a rounding error, and the rule has
|
||||
* to hold at the level of the individual wagon rather than the consist.
|
||||
*
|
||||
* WHERE THE RULE ACTUALLY LIVES
|
||||
*
|
||||
* `planWagonsWithStock` (wagon-plan-flex.util.ts:415-442) tops off an already-
|
||||
* open wagon only when the cargo type MATCHES — and guards, at :422, that
|
||||
* per-item cargo never joins a wagon opened as loose PER_TON. So distinct
|
||||
* `cargo_types` rows are the mechanism, and this spec's three commodities are
|
||||
* three separate rows sharing one wagon type (seed-flow2-export-train.sql
|
||||
* section 6).
|
||||
*
|
||||
* That is why the numbers are what they are. EXP1 and EXP2 each take exactly
|
||||
* 700 t = 10 whole wagons, so the arithmetic ALONE could be satisfied by an
|
||||
* engine that packed them into 20 shared wagons — there is no leftover space to
|
||||
* tempt it. The assertion therefore is not the count. It is
|
||||
* `expectOneCommodityPerWagon`: a direct query for any wagon carrying rows from
|
||||
* two different bookings, which is the only thing that catches co-loading.
|
||||
*
|
||||
* IC1's 5 cement wagons then take the pool to exactly 20 on the E–C stretch,
|
||||
* proving segregation does not cost capacity — three commodities still fill the
|
||||
* train completely. An engine that reserved a safety wagon between commodities
|
||||
* would fail here, and should.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
bookBulk,
|
||||
clearToOperationRequestPending,
|
||||
acceptExport,
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
BLK_POOL,
|
||||
EXPORT_CONSIST,
|
||||
POOL_TYPE,
|
||||
acceptIntercityOnExport,
|
||||
bookIntercityBulk,
|
||||
bulkWagons,
|
||||
createExportSchedule,
|
||||
edgeLoad,
|
||||
expectExportCapacity,
|
||||
expectExportEdgeLoad,
|
||||
expectNoPoolLeak,
|
||||
expectPoolAllocation,
|
||||
seedExportLegContract,
|
||||
withExportSched,
|
||||
type Leg,
|
||||
} from "./flow2-export-utils";
|
||||
|
||||
const DEPARTURE = departureAt(33);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
/** Whole-wagon tonnages: no partial wagon anywhere, so only co-loading can shrink the count. */
|
||||
const TONS = { EXP1: 10 * 70, EXP2: 10 * 70, IC1: 5 * 70 } as const;
|
||||
|
||||
/** Three distinct cargo_types rows sharing one wagon type — the mechanism. */
|
||||
const CARGO = {
|
||||
EXP1: "E2E_EXP_FERT",
|
||||
EXP2: "E2E_IMP_GRAINS",
|
||||
IC1: "E2E_EXP_CEMENT",
|
||||
} as const;
|
||||
|
||||
const LEGS = {
|
||||
EXP1: { from: "F", to: "A", wagons: bulkWagons(TONS.EXP1) },
|
||||
EXP2: { from: "F", to: "A", wagons: bulkWagons(TONS.EXP2) },
|
||||
IC1: { from: "E", to: "C", wagons: bulkWagons(TONS.IC1) },
|
||||
} as const satisfies Record<string, Leg>;
|
||||
|
||||
describe(
|
||||
"F2X·TC-10: three commodities share the train but never a wagon",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
(["EXP1", "EXP2", "IC1"] as const).forEach((s) =>
|
||||
seedExportLegContract({
|
||||
suffix: s,
|
||||
reference: stampedRef(s),
|
||||
from: LEGS[s].from,
|
||||
to: LEGS[s].to,
|
||||
freight: "BULK",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("the three commodities are distinct cargo types on ONE wagon type", () => {
|
||||
// Both halves are the premise. Distinct types is what makes segregation
|
||||
// meaningful; a shared wagon type is what makes it non-trivial — if each
|
||||
// rode its own type, the pools would separate them for free and the
|
||||
// scenario would prove nothing about commodity locking.
|
||||
db<{ cargo: string; wagon: string }>(
|
||||
`SELECT ct.code AS cargo, wt.code AS wagon
|
||||
FROM freight.cargo_type_wagon_types x
|
||||
JOIN freight.cargo_types ct ON ct.id = x.cargo_type_id
|
||||
JOIN freight.wagon_types wt ON wt.id = x.wagon_type_id
|
||||
WHERE ct.code = ANY($1::text[])
|
||||
ORDER BY ct.code`,
|
||||
[Object.values(CARGO)],
|
||||
).then(({ rows }) => {
|
||||
const cargos = new Set(rows.map((r) => r.cargo));
|
||||
expect(cargos.size, "three distinct commodities").to.eq(3);
|
||||
rows.forEach((r) =>
|
||||
expect(r.wagon, `${r.cargo} rides the shared bulk pool`).to.eq(POOL_TYPE.BLK),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("the premise: every booking is a whole number of full wagons", () => {
|
||||
// Deliberate: with no partial wagon anywhere, a lower wagon count can
|
||||
// ONLY mean two commodities were packed together.
|
||||
expect(TONS.EXP1 % 70, "EXP1 fills its wagons exactly").to.eq(0);
|
||||
expect(TONS.EXP2 % 70, "EXP2 fills its wagons exactly").to.eq(0);
|
||||
expect(TONS.IC1 % 70, "IC1 fills its wagons exactly").to.eq(0);
|
||||
|
||||
const profile = edgeLoad(Object.values(LEGS));
|
||||
expect(profile, "per-edge bulk demand").to.deep.eq([20, 20, 25, 25, 20]);
|
||||
// NOTE: edges 2 and 3 want 25 against a 20-wagon pool — IC1 cannot ride
|
||||
// alongside both exports on its own stretch. Asserted below as a refusal,
|
||||
// then re-tried after the exports are the only thing on the train.
|
||||
expect(Math.max(...profile), "the peak exceeds the pool").to.be.greaterThan(BLK_POOL);
|
||||
});
|
||||
|
||||
it("operations schedules the export train", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: DEPARTURE, kind: "bulk" });
|
||||
expectExportCapacity(DEPARTURE, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("the fertilizer export boards", () => {
|
||||
bookBulk({
|
||||
suffix: "EXP1",
|
||||
tons: TONS.EXP1,
|
||||
cargoCode: CARGO.EXP1,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearToOperationRequestPending("EXP1", BOOKING_DAY);
|
||||
acceptExport("EXP1");
|
||||
markPaid("EXP1");
|
||||
pollAllocations("EXP1", LEGS.EXP1.wagons);
|
||||
expectPoolAllocation("EXP1", "BLK", LEGS.EXP1.wagons);
|
||||
});
|
||||
|
||||
it("the grain export boards onto the SAME train — 20 of 20 bulk wagons now used", () => {
|
||||
bookBulk({
|
||||
suffix: "EXP2",
|
||||
tons: TONS.EXP2,
|
||||
cargoCode: CARGO.EXP2,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearToOperationRequestPending("EXP2", BOOKING_DAY);
|
||||
acceptExport("EXP2");
|
||||
markPaid("EXP2");
|
||||
pollAllocations("EXP2", LEGS.EXP2.wagons);
|
||||
expectPoolAllocation("EXP2", "BLK", LEGS.EXP2.wagons);
|
||||
// Sharing a train is explicitly fine — this is the line the rule does NOT
|
||||
// draw, asserted so a future over-cautious change gets caught.
|
||||
expectExportEdgeLoad(DEPARTURE, [20, 20, 20, 20, 20], BLK_POOL);
|
||||
});
|
||||
|
||||
it("SEGREGATION: no wagon carries two commodities", () => {
|
||||
// The assertion the scenario exists for, and the only one that catches
|
||||
// co-loading. Every count above is satisfied by a co-loading engine.
|
||||
expectOneCommodityPerWagon();
|
||||
});
|
||||
|
||||
it("the cement intercity is refused — the pool is full on its stretch", () => {
|
||||
// Not a segregation failure: the bulk pool is genuinely exhausted on
|
||||
// edges 2-3 by the two exports. Asserted so the refusal is not mistaken
|
||||
// for the safety rule doing something it should not.
|
||||
bookIntercityBulk({ suffix: "IC1", tons: TONS.IC1, cargoCode: CARGO.IC1 });
|
||||
acceptIntercityOnExport({ departure: DEPARTURE, accept: [], reject: ["IC1"] });
|
||||
});
|
||||
|
||||
it("POOLS: nothing leaked, and segregation held to the end", () => {
|
||||
expectNoPoolLeak(DEPARTURE);
|
||||
expectOneCommodityPerWagon();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Assert no physical wagon slot carries two different commodities.
|
||||
*
|
||||
* Reads `wagon_allocation_bulk_loads` — the per-allocation load rows, each
|
||||
* carrying its own `cargo_type_id`. That is the right grain: the question is
|
||||
* what is physically ON a wagon, not what a booking ordered in aggregate, and a
|
||||
* booking's loads can be spread across several wagons.
|
||||
*
|
||||
* Grouped by `train_set_wagon_id` so the unit is the SLOT. The query reports
|
||||
* the offending cargo codes, so a failure names the pair rather than just
|
||||
* counting.
|
||||
*/
|
||||
function expectOneCommodityPerWagon() {
|
||||
withExportSched(DEPARTURE, (s) =>
|
||||
db<{ wagon: string; cargos: string }>(
|
||||
`SELECT wba.train_set_wagon_id::text AS wagon,
|
||||
string_agg(DISTINCT ct.code, ',' ORDER BY ct.code) AS cargos
|
||||
FROM freight.wagon_allocation_bulk_loads bl
|
||||
JOIN freight.wagon_booking_allocations wba
|
||||
ON wba.id = bl.wagon_booking_allocation_id
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
JOIN freight.cargo_types ct ON ct.id = bl.cargo_type_id
|
||||
WHERE bl.deleted_at IS NULL
|
||||
AND wba.deleted_at IS NULL AND tsb.deleted_at IS NULL
|
||||
GROUP BY wba.train_set_wagon_id
|
||||
HAVING count(DISTINCT bl.cargo_type_id) > 1`,
|
||||
[s.id],
|
||||
).then(({ rows }) =>
|
||||
expect(
|
||||
rows.map((r) => `${r.wagon}: ${r.cargos}`),
|
||||
"wagons carrying two commodities at once",
|
||||
).to.deep.eq([]),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-11 — the consolidation policy, pinned.
|
||||
*
|
||||
* Three small export bulk bookings, same commodity, same leg, same day:
|
||||
*
|
||||
* EXP1 export F→A 15 t (wagon capacity 70 t)
|
||||
* EXP2 export F→A 15 t
|
||||
* EXP3 export F→A 15 t
|
||||
*
|
||||
* Forty-five tonnes in total — well under one wagon. So the day ends with
|
||||
* either THREE wagons (each booking gets its own, 55 t of air apiece) or ONE
|
||||
* (all three consolidated). Both are legitimate policies with real trade-offs:
|
||||
* separate wagons make unloading, sealing and liability trivial; consolidation
|
||||
* turns 3 wagons of capacity into 2 wagons of revenue-earning space.
|
||||
*
|
||||
* THIS SPEC DOES NOT PREFER ONE. It pins whichever is in force and fails if it
|
||||
* changes, because a silent flip is expensive in both directions — customers
|
||||
* suddenly billed for a whole wagon each, or cargo from three shippers found
|
||||
* mixed in one wagon at the port.
|
||||
*
|
||||
* WHAT THE ENGINE ACTUALLY DOES, and why the expectation is "3"
|
||||
*
|
||||
* `planWagonsWithStock` (wagon-plan-flex.util.ts:415-442) tops off an existing
|
||||
* wagon only within the SAME booking's placement pass — the top-off scan is
|
||||
* driven from the booking being planned, and the per-booking snapshot/rollback
|
||||
* at :483-541 makes a booking atomic. There is no cross-booking consolidation
|
||||
* step anywhere in the planner. Separately, `wagon_booking_allocations` is
|
||||
* keyed per booking, so two bookings sharing a physical wagon would need two
|
||||
* allocation rows against one `train_set_wagon_id` — which is precisely what
|
||||
* TC-10's segregation query treats as a defect.
|
||||
*
|
||||
* So the expectation is THREE WAGONS, and the spec states that as the current
|
||||
* policy rather than as an eternal truth. If consolidation is ever implemented,
|
||||
* this test fails with a message naming the policy, and someone changes the
|
||||
* constant deliberately.
|
||||
*
|
||||
* THE CONTROL: EXP4 books 15 t on the SAME contract as EXP1 is not possible
|
||||
* (one active booking per ONE_TIME contract), so instead the spec asserts the
|
||||
* within-booking case directly — a single 85 t booking, which MUST consolidate
|
||||
* into 2 wagons (70 + 15) rather than 2 half-empty ones. Without it, "never
|
||||
* consolidates" and "cannot pack a wagon at all" look identical.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
bookBulk,
|
||||
clearToOperationRequestPending,
|
||||
acceptExport,
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
BLK_POOL,
|
||||
CW4_CAPACITY_TONS,
|
||||
EXPORT_CONSIST,
|
||||
bulkWagons,
|
||||
createExportSchedule,
|
||||
expectExportCapacity,
|
||||
expectExportEdgeLoad,
|
||||
expectNoPoolLeak,
|
||||
expectPoolAllocation,
|
||||
seedExportLegContract,
|
||||
withExportSched,
|
||||
} from "./flow2-export-utils";
|
||||
|
||||
const DEPARTURE = departureAt(34);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
/** A quarter-wagon each — three of them still do not fill one. */
|
||||
const SMALL_TONS = 15;
|
||||
const SMALL_BOOKINGS = ["EXP1", "EXP2", "EXP3"] as const;
|
||||
|
||||
/**
|
||||
* THE POLICY UNDER TEST. Change this constant only as a deliberate decision:
|
||||
* 3 = no cross-booking consolidation (current engine — see header)
|
||||
* 1 = three bookings consolidated into one wagon
|
||||
*/
|
||||
const CONSOLIDATION_POLICY_WAGONS = 3;
|
||||
|
||||
/** The within-booking control: 85 t must pack as 70 + 15, not as two part-loads. */
|
||||
const CONTROL_TONS = 85;
|
||||
|
||||
describe(
|
||||
"F2X·TC-11: three part-wagon bookings, and the consolidation policy that decides their cost",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
[...SMALL_BOOKINGS, "EXP4"].forEach((s) =>
|
||||
seedExportLegContract({
|
||||
suffix: s,
|
||||
reference: stampedRef(s),
|
||||
from: "F",
|
||||
to: "A",
|
||||
freight: "BULK",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("the premise: all three together do not fill one wagon", () => {
|
||||
const total = SMALL_TONS * SMALL_BOOKINGS.length;
|
||||
expect(total, "45 t in total").to.eq(45);
|
||||
expect(total, "…less than one wagon").to.be.lessThan(CW4_CAPACITY_TONS);
|
||||
expect(bulkWagons(total), "consolidated, they would be 1 wagon").to.eq(1);
|
||||
expect(
|
||||
bulkWagons(SMALL_TONS) * SMALL_BOOKINGS.length,
|
||||
"unconsolidated, they are 3",
|
||||
).to.eq(3);
|
||||
// The gap between those two numbers IS the policy, and it is 2 wagons of
|
||||
// otherwise-sellable space on every edge of the leg.
|
||||
expect(CONSOLIDATION_POLICY_WAGONS, "the policy this run expects").to.be.oneOf([1, 3]);
|
||||
});
|
||||
|
||||
it("operations schedules the export train", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: DEPARTURE, kind: "bulk" });
|
||||
expectExportCapacity(DEPARTURE, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("all three part-wagon bookings board", () => {
|
||||
SMALL_BOOKINGS.forEach((suffix, i) => {
|
||||
bookBulk({
|
||||
suffix,
|
||||
tons: SMALL_TONS,
|
||||
cargoCode: "E2E_IMP_WHEAT",
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearToOperationRequestPending(suffix, BOOKING_DAY);
|
||||
acceptExport(suffix);
|
||||
markPaid(suffix);
|
||||
// Each is one wagon on its own, whatever happens between them.
|
||||
pollAllocations(suffix, 1);
|
||||
cy.task("log", `TC-11: ${suffix} (${SMALL_TONS} t) placed — booking ${i + 1} of 3`);
|
||||
});
|
||||
});
|
||||
|
||||
it("POLICY: the three bookings occupy 3 wagons, not 1", () => {
|
||||
// The pinned decision. A failure here is not necessarily a bug — it is a
|
||||
// policy change that must be acknowledged by editing
|
||||
// CONSOLIDATION_POLICY_WAGONS above, deliberately.
|
||||
withExportSched(DEPARTURE, (s) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
JOIN freight.bookings b ON b.id = wba.booking_id
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL
|
||||
AND ct.reference LIKE $2`,
|
||||
[s.id, `CTR-IMP-${stamp}-EXP%`],
|
||||
).then(({ rows }) =>
|
||||
expect(
|
||||
Number(rows[0].n),
|
||||
`three 15 t bookings occupy ${CONSOLIDATION_POLICY_WAGONS} wagon(s) — ` +
|
||||
`if this changed, the consolidation policy changed, and that is a ` +
|
||||
`billing and liability decision, not a refactor`,
|
||||
).to.eq(CONSOLIDATION_POLICY_WAGONS),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("each booking's wagon is its own — no two share a slot", () => {
|
||||
// The structural half of the same claim, and the one that would catch a
|
||||
// consolidation implemented WITHOUT updating the allocation model: two
|
||||
// bookings pointing at one train_set_wagon_id.
|
||||
withExportSched(DEPARTURE, (s) =>
|
||||
db<{ wagon: string; bookings: string }>(
|
||||
`SELECT tsw.id::text AS wagon, count(DISTINCT wba.booking_id)::text AS bookings
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL
|
||||
GROUP BY tsw.id
|
||||
HAVING count(DISTINCT wba.booking_id) > 1`,
|
||||
[s.id],
|
||||
).then(({ rows }) =>
|
||||
expect(
|
||||
rows.map((r) => `${r.wagon} shared by ${r.bookings} bookings`),
|
||||
"wagons shared between bookings — under the no-consolidation policy " +
|
||||
"there must be none; if consolidation is ever implemented this " +
|
||||
"assertion is the second one to revisit",
|
||||
).to.deep.eq([]),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("CONTROL: within ONE booking, 85 t packs as 70 + 15 — the packer does work", () => {
|
||||
// Without this, "never consolidates across bookings" is indistinguishable
|
||||
// from "cannot fill a wagon at all". 85 t must be 2 wagons (one full, one
|
||||
// quarter), never 3.
|
||||
bookBulk({
|
||||
suffix: "EXP4",
|
||||
tons: CONTROL_TONS,
|
||||
cargoCode: "E2E_IMP_WHEAT",
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearToOperationRequestPending("EXP4", BOOKING_DAY);
|
||||
acceptExport("EXP4");
|
||||
markPaid("EXP4");
|
||||
pollAllocations("EXP4", bulkWagons(CONTROL_TONS));
|
||||
expectPoolAllocation("EXP4", "BLK", 2);
|
||||
withBooking("EXP4", (b) =>
|
||||
expect(b.status, "the control booking rode").to.not.eq("REJECTED"),
|
||||
);
|
||||
});
|
||||
|
||||
it("PROFILE: five bulk wagons used on every edge of the leg", () => {
|
||||
// 3 (part-wagon bookings) + 2 (the 85 t control) = 5, on F→A, so all five
|
||||
// edges carry the same load.
|
||||
const used = CONSOLIDATION_POLICY_WAGONS + bulkWagons(CONTROL_TONS);
|
||||
expectExportEdgeLoad(DEPARTURE, [used, used, used, used, used], BLK_POOL);
|
||||
expectNoPoolLeak(DEPARTURE);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-12 — wagons come from TEU arithmetic, not unit count.
|
||||
*
|
||||
* A wagon holds either two 20ft containers or one 40ft. So:
|
||||
*
|
||||
* EXP1 export F→A 20 × 20ft → 10 wagons
|
||||
* EXP2 export F→A 10 × 40ft → 10 wagons
|
||||
* IC1 intercity D→B 5 × 40ft → 5 wagons
|
||||
*
|
||||
* THIRTY-FIVE CONTAINERS, TWENTY-FIVE WAGONS. An engine that counted units
|
||||
* would demand 35 wagons — the whole container pool — and refuse the third
|
||||
* booking on a train with ten free slots. One that counted BOOKINGS would say
|
||||
* three. The right answer is 25, and the three numbers are far enough apart
|
||||
* that no accident produces it.
|
||||
*
|
||||
* THE 20FT PAIRING IS THE INTERESTING HALF. Two 20ft on one wagon is the only
|
||||
* place in the model where a wagon carries more than one revenue unit, and it
|
||||
* is the case an implementation is most likely to get wrong — usually by
|
||||
* charging a wagon per container and quietly doubling the customer's bill.
|
||||
* EXP1 and EXP2 are deliberately sized to need the SAME number of wagons (10)
|
||||
* from very different unit counts (20 vs 10), so a unit-counting engine shows
|
||||
* up as an asymmetry between two bookings that should cost the same.
|
||||
*
|
||||
* THE ODD-20FT RULE is asserted as arithmetic, not booked: a lone 20ft still
|
||||
* occupies a whole wagon (ceil), and the portal form blocks submitting an odd
|
||||
* quantity outright. Asserting it here keeps the rounding rule visible next to
|
||||
* the pairing rule it qualifies.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
CNT_POOL,
|
||||
EXPORT_CONSIST,
|
||||
acceptIntercityOnExport,
|
||||
bookIntercityContainers,
|
||||
containerWagons,
|
||||
createExportSchedule,
|
||||
edgeLoad,
|
||||
expectExportCapacity,
|
||||
expectExportEdgeLoad,
|
||||
expectNoPoolLeak,
|
||||
expectPoolAllocation,
|
||||
seedExportLegContract,
|
||||
type Leg,
|
||||
} from "./flow2-export-utils";
|
||||
import { bookAndClear } from "../g1-utils";
|
||||
|
||||
const DEPARTURE = departureAt(35);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const UNITS = {
|
||||
EXP1: { twenty: 20, forty: 0 },
|
||||
EXP2: { twenty: 0, forty: 10 },
|
||||
IC1: { twenty: 0, forty: 5 },
|
||||
} as const;
|
||||
|
||||
const LEGS = {
|
||||
EXP1: { from: "F", to: "A", wagons: containerWagons(UNITS.EXP1.twenty, UNITS.EXP1.forty) },
|
||||
EXP2: { from: "F", to: "A", wagons: containerWagons(UNITS.EXP2.twenty, UNITS.EXP2.forty) },
|
||||
IC1: { from: "D", to: "B", wagons: containerWagons(UNITS.IC1.twenty, UNITS.IC1.forty) },
|
||||
} as const satisfies Record<string, Leg>;
|
||||
|
||||
const TOTAL_UNITS = 20 + 10 + 5;
|
||||
const TOTAL_WAGONS = LEGS.EXP1.wagons + LEGS.EXP2.wagons + LEGS.IC1.wagons;
|
||||
|
||||
describe(
|
||||
"F2X·TC-12: 35 containers ride 25 wagons — TEU arithmetic, not unit count",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
(["EXP1", "EXP2", "IC1"] as const).forEach((s) =>
|
||||
seedExportLegContract({
|
||||
suffix: s,
|
||||
reference: stampedRef(s),
|
||||
from: LEGS[s].from,
|
||||
to: LEGS[s].to,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("the TEU rule, including the odd-20ft edge", () => {
|
||||
expect(containerWagons(2, 0), "two 20ft pair onto one wagon").to.eq(1);
|
||||
expect(containerWagons(1, 0), "a lone 20ft still costs a whole wagon").to.eq(1);
|
||||
expect(containerWagons(3, 0), "three 20ft need two wagons").to.eq(2);
|
||||
expect(containerWagons(0, 1), "a 40ft takes a wagon to itself").to.eq(1);
|
||||
expect(containerWagons(2, 1), "mixed: one paired wagon plus one 40ft").to.eq(2);
|
||||
});
|
||||
|
||||
it("the premise: 35 containers, 25 wagons — three numbers that cannot be confused", () => {
|
||||
expect(LEGS.EXP1.wagons, "20 × 20ft → 10 wagons").to.eq(10);
|
||||
expect(LEGS.EXP2.wagons, "10 × 40ft → 10 wagons").to.eq(10);
|
||||
expect(LEGS.IC1.wagons, "5 × 40ft → 5 wagons").to.eq(5);
|
||||
|
||||
expect(TOTAL_UNITS, "containers moved").to.eq(35);
|
||||
expect(TOTAL_WAGONS, "wagons used").to.eq(25);
|
||||
expect(TOTAL_WAGONS, "…not the unit count").to.not.eq(TOTAL_UNITS);
|
||||
expect(TOTAL_WAGONS, "…and not the booking count").to.not.eq(3);
|
||||
|
||||
// The asymmetry check: two bookings, half the units apart, same cost.
|
||||
expect(
|
||||
LEGS.EXP1.wagons,
|
||||
"20 twenty-footers cost the same as 10 forty-footers — a unit-counting " +
|
||||
"engine would charge EXP1 twice what it charges EXP2",
|
||||
).to.eq(LEGS.EXP2.wagons);
|
||||
|
||||
// A unit-counting engine would want the whole pool and refuse IC1.
|
||||
expect(TOTAL_UNITS, "unit-count demand would exhaust the pool").to.eq(CNT_POOL);
|
||||
expect(TOTAL_WAGONS, "real demand leaves 10 free").to.be.lessThan(CNT_POOL);
|
||||
|
||||
expect(edgeLoad(Object.values(LEGS)), "per-edge demand").to.deep.eq([
|
||||
20, 25, 25, 20, 20,
|
||||
]);
|
||||
});
|
||||
|
||||
it("operations schedules the export train", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: DEPARTURE });
|
||||
expectExportCapacity(DEPARTURE, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("EXP1's twenty 20ft containers pair onto ten wagons", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 6000,
|
||||
twenty: UNITS.EXP1.twenty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP1");
|
||||
pollAllocations("EXP1", LEGS.EXP1.wagons);
|
||||
expectPoolAllocation("EXP1", "CNT", LEGS.EXP1.wagons);
|
||||
expectUnitsPlaced("EXP1", UNITS.EXP1.twenty + UNITS.EXP1.forty);
|
||||
});
|
||||
|
||||
it("EXP2's ten 40ft containers take ten wagons — the same cost, half the units", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 6100,
|
||||
forty: UNITS.EXP2.forty,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP2");
|
||||
pollAllocations("EXP2", LEGS.EXP2.wagons);
|
||||
expectPoolAllocation("EXP2", "CNT", LEGS.EXP2.wagons);
|
||||
expectUnitsPlaced("EXP2", UNITS.EXP2.twenty + UNITS.EXP2.forty);
|
||||
});
|
||||
|
||||
it("IC1 boards on the ten wagons a unit-counting engine would have consumed", () => {
|
||||
// The scenario's payoff. 35 units are on the train; a unit-counting
|
||||
// engine believes the 35-wagon pool is exhausted and refuses this.
|
||||
bookIntercityContainers({
|
||||
suffix: "IC1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 6200,
|
||||
forty: UNITS.IC1.forty,
|
||||
});
|
||||
acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC1"] });
|
||||
markPaid("IC1");
|
||||
pollAllocations("IC1", LEGS.IC1.wagons);
|
||||
expectPoolAllocation("IC1", "CNT", LEGS.IC1.wagons);
|
||||
expectUnitsPlaced("IC1", UNITS.IC1.twenty + UNITS.IC1.forty);
|
||||
});
|
||||
|
||||
it("PAIRING: the 20ft wagons really do carry two containers each", () => {
|
||||
// The count could be right with the units placed wrongly — ten wagons
|
||||
// holding one container each and ten containers lost. This reads the
|
||||
// placement rows themselves.
|
||||
withBooking("EXP1", (b) =>
|
||||
db<{ wagon: string; units: string }>(
|
||||
`SELECT wba.train_set_wagon_id::text AS wagon, count(*)::text AS units
|
||||
FROM freight.wagon_allocation_container_items ci
|
||||
JOIN freight.wagon_booking_allocations wba
|
||||
ON wba.id = ci.wagon_booking_allocation_id
|
||||
WHERE wba.booking_id = $1
|
||||
AND ci.deleted_at IS NULL AND wba.deleted_at IS NULL
|
||||
GROUP BY wba.train_set_wagon_id`,
|
||||
[b.id],
|
||||
).then(({ rows }) => {
|
||||
expect(rows, "EXP1 occupies 10 wagons").to.have.length(LEGS.EXP1.wagons);
|
||||
rows.forEach((r) =>
|
||||
expect(Number(r.units), `wagon ${r.wagon} carries two 20ft`).to.eq(2),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("PROFILE: 25 wagons at the peak, ten of the pool still free", () => {
|
||||
expectExportEdgeLoad(DEPARTURE, [20, 25, 25, 20, 20], CNT_POOL);
|
||||
expectNoPoolLeak(DEPARTURE);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Assert every container of a booking was actually mapped to a wagon slot with
|
||||
* a real container number on it.
|
||||
*
|
||||
* Wagon counts alone cannot catch a half-done allocation: a booking whose
|
||||
* wagons were reserved but whose units were never placed reads as correctly
|
||||
* allocated, and the marshalling sheet — generated from exactly these rows —
|
||||
* comes out short.
|
||||
*/
|
||||
function expectUnitsPlaced(suffix: string, units: number) {
|
||||
withBooking(suffix, (b) =>
|
||||
db<{ n: string; blank: string }>(
|
||||
`SELECT count(*) AS n,
|
||||
count(*) FILTER (
|
||||
WHERE ci.container_number IS NULL OR ci.container_number = ''
|
||||
) AS blank
|
||||
FROM freight.wagon_allocation_container_items ci
|
||||
JOIN freight.wagon_booking_allocations wba
|
||||
ON wba.id = ci.wagon_booking_allocation_id
|
||||
WHERE wba.booking_id = $1
|
||||
AND ci.deleted_at IS NULL AND wba.deleted_at IS NULL`,
|
||||
[b.id],
|
||||
).then(({ rows }) => {
|
||||
expect(Number(rows[0].n), `${suffix} placed ${units} containers`).to.eq(units);
|
||||
expect(Number(rows[0].blank), `${suffix} left no slot without a number`).to.eq(0);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-13 — overflow spills to the second train, and does not
|
||||
* silently split.
|
||||
*
|
||||
* Two export trains on the same day, both F→A, each with a 35-wagon container
|
||||
* pool:
|
||||
*
|
||||
* T1 TRN-F2-EXP departs 12:00 EAT
|
||||
* T2 TRN-F2-EXP2 departs 15:00 EAT
|
||||
*
|
||||
* EXP1 30 CNT → T1 (T1 now has 5 free)
|
||||
* EXP2 20 CNT → T2 (T2 now has 15 free)
|
||||
* EXP3 20 CNT → fits NEITHER: 5 on T1, 15 on T2
|
||||
*
|
||||
* EXP3 is the test. Twenty wagons of demand against two trains holding 5 and 15
|
||||
* free — exactly 20 in total, and not 20 anywhere. A booking is placed WHOLE
|
||||
* when any single train can take it whole (`maybeOfferPartial` is reached only
|
||||
* when no train fits it, booking-batch.service.ts:2473-2496), so EXP3 must not
|
||||
* board either train.
|
||||
*
|
||||
* WHAT "NO SILENT SPLIT" MEANS HERE, PRECISELY
|
||||
*
|
||||
* Export split is gated by `FREIGHT_EXPORT_SPLIT` — an ENV VAR on the API
|
||||
* process, not a per-booking or per-schedule flag (booking-batch.service.ts:394,
|
||||
* `isSplitEligible` at :2570). With it OFF, EXP3 must be refused outright. With
|
||||
* it ON, EXP3 may legitimately be offered a partial. The two are opposite
|
||||
* expectations from the same scenario, so the spec reads the declared flag and
|
||||
* asserts the matching outcome — and asserts the SHAPE either way:
|
||||
*
|
||||
* - refused → zero allocations, zero offers, `is_split` false
|
||||
* - offered → an offer for STRICTLY FEWER than 20 wagons on one train, and
|
||||
* still zero allocations until it is paid for
|
||||
*
|
||||
* What it must never do is quietly place 5 wagons on T1 and 15 on T2 as if
|
||||
* nothing happened. A shipper whose 20 containers arrive on two trains three
|
||||
* hours apart, without having agreed to it, has a problem at the vessel.
|
||||
*
|
||||
* THE 3-HOUR GAP IS LOAD-BEARING: `dbSchedule` matches within ±1h and
|
||||
* `createExportSchedule` uses that same lookup as its idempotency guard, so two
|
||||
* schedules less than an hour apart means the second create is silently
|
||||
* skipped and the spec tests one train while claiming to test two. The "two
|
||||
* distinct rows" test below exists to catch exactly that.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
CNT_POOL,
|
||||
EXPORT_CONSIST,
|
||||
createExportSchedule,
|
||||
dbExportSchedule,
|
||||
expectExportCapacity,
|
||||
expectNoWagons,
|
||||
expectPoolAllocation,
|
||||
exportSplitEnabled,
|
||||
seedExportLegContract,
|
||||
} from "./flow2-export-utils";
|
||||
import { bookAndClear } from "../g1-utils";
|
||||
|
||||
const T1_AT = departureAt(36);
|
||||
/** Three hours later — well outside dbSchedule's ±1h lookup, same EAT day. */
|
||||
const T2_AT = new Date(T1_AT.getTime() + 3 * 3_600_000);
|
||||
const BOOKING_DAY = eatDayStr(T1_AT);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const T2_TRAIN = "TRN-F2-EXP2";
|
||||
|
||||
const DEMAND = { EXP1: 30, EXP2: 20, EXP3: 20 } as const;
|
||||
|
||||
describe(
|
||||
"F2X·TC-13: overflow moves to the second train; what fits neither does not split",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train-2.sql");
|
||||
(["EXP1", "EXP2", "EXP3"] as const).forEach((s) =>
|
||||
seedExportLegContract({ suffix: s, reference: stampedRef(s), from: "F", to: "A" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("the premise: EXP3 fits neither train, but fits the two together", () => {
|
||||
const t1Free = CNT_POOL - DEMAND.EXP1;
|
||||
const t2Free = CNT_POOL - DEMAND.EXP2;
|
||||
expect(t1Free, "T1 free after EXP1").to.eq(5);
|
||||
expect(t2Free, "T2 free after EXP2").to.eq(15);
|
||||
expect(DEMAND.EXP3, "EXP3 fits neither alone").to.be.greaterThan(
|
||||
Math.max(t1Free, t2Free),
|
||||
);
|
||||
expect(t1Free + t2Free, "…but exactly fills both — the temptation").to.eq(DEMAND.EXP3);
|
||||
|
||||
const gapHours = (T2_AT.getTime() - T1_AT.getTime()) / 3_600_000;
|
||||
expect(gapHours, "the trains are 3h apart — outside the ±1h lookup").to.eq(3);
|
||||
expect(eatDayStr(T2_AT), "…and on the same EAT day").to.eq(BOOKING_DAY);
|
||||
});
|
||||
|
||||
it("operations schedules both export trains for the day", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(T1_AT, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: T1_AT });
|
||||
createExportSchedule({ departure: T2_AT, trainCode: T2_TRAIN });
|
||||
expectExportCapacity(T1_AT, EXPORT_CONSIST);
|
||||
expectExportCapacity(T2_AT, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("the two schedules really are two rows", () => {
|
||||
// Guards the silent-skip failure the 3h gap exists to prevent. Without
|
||||
// this, a swallowed second create leaves every assertion below measuring
|
||||
// one train and passing for the wrong reason.
|
||||
dbExportSchedule(T1_AT).then(({ rows: a }) =>
|
||||
dbExportSchedule(T2_AT).then(({ rows: b }) => {
|
||||
expect(a, "T1").to.have.length(1);
|
||||
expect(b, "T2").to.have.length(1);
|
||||
expect(a[0].id, "T1 and T2 are distinct schedules").to.not.eq(b[0].id);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("EXP1 takes 30 of T1's 35 container wagons", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 6300,
|
||||
forty: DEMAND.EXP1,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP1");
|
||||
pollAllocations("EXP1", DEMAND.EXP1);
|
||||
expectPoolAllocation("EXP1", "CNT", DEMAND.EXP1);
|
||||
expectRidesSchedule("EXP1", T1_AT);
|
||||
});
|
||||
|
||||
it("EXP2 does not fit T1's remaining 5 — it spills to T2", () => {
|
||||
// The FCFS export path picks among the day's schedules, earliest
|
||||
// departure first (booking-batch.service.ts:816). T1 cannot take 20, so
|
||||
// the pick must fall through to T2 rather than refusing.
|
||||
bookAndClear({
|
||||
suffix: "EXP2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 6400,
|
||||
forty: DEMAND.EXP2,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP2");
|
||||
pollAllocations("EXP2", DEMAND.EXP2);
|
||||
expectPoolAllocation("EXP2", "CNT", DEMAND.EXP2);
|
||||
expectRidesSchedule("EXP2", T2_AT);
|
||||
});
|
||||
|
||||
it("EXP3 fits neither train — and is not quietly cut in half", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP3",
|
||||
runStamp: stamp,
|
||||
isoSeed: 6500,
|
||||
forty: DEMAND.EXP3,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
|
||||
// The outcome depends on a flag the API process owns, so read it and
|
||||
// assert the matching shape. Both branches forbid the silent split.
|
||||
if (exportSplitEnabled()) {
|
||||
cy.task("log", "TC-13: FREIGHT_EXPORT_SPLIT=true — a partial OFFER is legitimate");
|
||||
withBooking("EXP3", (b) =>
|
||||
db<{ offered: string; n: string }>(
|
||||
`SELECT coalesce(max(offered_wagons), 0)::text AS offered,
|
||||
count(*)::text AS n
|
||||
FROM freight.booking_batch_offers
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL AND status = 'OFFERED'`,
|
||||
[b.id],
|
||||
).then(({ rows }) => {
|
||||
expect(Number(rows[0].n), "at most one open offer").to.be.at.most(1);
|
||||
if (Number(rows[0].n) === 1) {
|
||||
expect(
|
||||
Number(rows[0].offered),
|
||||
"an offer is a STRICT subset — never the whole booking",
|
||||
).to.be.lessThan(DEMAND.EXP3);
|
||||
}
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
cy.task("log", "TC-13: FREIGHT_EXPORT_SPLIT off — EXP3 must be refused whole");
|
||||
withBooking("EXP3", (b) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n FROM freight.booking_batch_offers
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[b.id],
|
||||
).then(({ rows }) =>
|
||||
expect(Number(rows[0].n), "no split offer with the flag off").to.eq(0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Common to both branches, and the actual point of the scenario: an
|
||||
// UNPAID booking holds no wagons, on either train. A split that had been
|
||||
// silently applied would show up right here as 5 + 15.
|
||||
expectNoWagons("EXP3");
|
||||
withBooking("EXP3", (b) =>
|
||||
db<{ is_split: boolean }>(`SELECT is_split FROM freight.bookings WHERE id = $1`, [
|
||||
b.id,
|
||||
]).then(({ rows }) =>
|
||||
expect(Boolean(rows[0].is_split), "EXP3 was not silently split").to.eq(false),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("neither train was overbooked", () => {
|
||||
[T1_AT, T2_AT].forEach((at) =>
|
||||
dbExportSchedule(at).then(({ rows }) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||||
[rows[0].id],
|
||||
).then(({ rows: used }) =>
|
||||
expect(
|
||||
Number(used[0].n),
|
||||
`schedule departing ${at.toISOString()} stayed within its pool`,
|
||||
).to.be.at.most(CNT_POOL),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/** Assert a booking rides the schedule departing at `at`, and no other. */
|
||||
function expectRidesSchedule(suffix: string, at: Date) {
|
||||
withBooking(suffix, (b) =>
|
||||
dbExportSchedule(at).then(({ rows }) =>
|
||||
expect(b.train_schedule_id, `${suffix} rides the ${at.toISOString()} train`).to.eq(
|
||||
rows[0].id,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
273
e2e/freight/cypress/e2e/flows/flow_two/tcx14_split_flag.cy.ts
Normal file
273
e2e/freight/cypress/e2e/flows/flow_two/tcx14_split_flag.cy.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-14 — the split flag, and what a split actually produces.
|
||||
*
|
||||
* TC-13's setup, with the split question in the foreground. Two export trains,
|
||||
* each a 35-wagon container pool:
|
||||
*
|
||||
* EXP1 30 CNT → T1 (5 free)
|
||||
* EXP2 20 CNT → T2 (15 free)
|
||||
* EXP3 20 CNT → fits neither; 5 + 15 exist across the two
|
||||
*
|
||||
* The brief asks for "split flag ON → EXP3 splits 5 on T1 + 15 on T2, single
|
||||
* booking ID, two allocation rows, one invoice". Two things about that do not
|
||||
* match the engine, and this spec asserts what is real rather than what was
|
||||
* hoped for:
|
||||
*
|
||||
* 1. THE FLAG IS AN ENVIRONMENT VARIABLE, NOT A SETTING. `isSplitEligible`
|
||||
* (booking-batch.service.ts:2570) permits an EXPORT split only when
|
||||
* `exportSplitEnabled`, which reads `process.env.FREIGHT_EXPORT_SPLIT ===
|
||||
* "true"` on the API process (:394). There is no column, no admin toggle,
|
||||
* no per-booking field. Cypress runs in a different process and can neither
|
||||
* read nor change it — so the spec takes the value the RUNNER declares
|
||||
* (`Cypress.env("FREIGHT_EXPORT_SPLIT")`) and asserts the engine agrees. A
|
||||
* mismatch between the declared flag and the observed behaviour is itself
|
||||
* the finding: it means the API is not running with the config the test
|
||||
* suite believes.
|
||||
*
|
||||
* 2. A SPLIT IS ONE TRAIN, NOT TWO. `sizePartialOfferWagons`
|
||||
* (train-capacity.util.ts:455) sizes an offer against ONE schedule's room,
|
||||
* and `createOffer` writes a single `train_schedule_id`
|
||||
* (booking-batch.service.ts:2667). The split model is "take what fits on
|
||||
* this train, rebook the remainder" — not "spread one booking across two
|
||||
* trains". So the expected outcome with the flag ON is an offer of 15 on T2
|
||||
* (the roomier train, chosen by `[...fitting].sort((a,b) => b.freeWagons -
|
||||
* a.freeWagons)[0]`, :1253), with 5 wagons left to rebook — NOT 5+15.
|
||||
*
|
||||
* THE ACCEPT PATH IS THE OTHER HALF. An offer is not accepted by an endpoint —
|
||||
* `booking_batch_offers` has no ACCEPTED status, only OFFERED → APPLIED
|
||||
* (booking-batch-offer.entity.ts:7). Paying inside the window IS the accept,
|
||||
* and ONLY through the real gateway: `markPaid` skips `applySplit` and would
|
||||
* allocate the booking whole, defeating the very thing under test
|
||||
* (import-utils.ts:654). So this spec settles EXP3 with `settleViaGateway`.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
settleViaGateway,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
CNT_POOL,
|
||||
EXPORT_CONSIST,
|
||||
createExportSchedule,
|
||||
dbExportSchedule,
|
||||
expectExportCapacity,
|
||||
expectNoWagons,
|
||||
expectPoolAllocation,
|
||||
expectSplitAllowed,
|
||||
exportSplitEnabled,
|
||||
seedExportLegContract,
|
||||
} from "./flow2-export-utils";
|
||||
import { bookAndClear } from "../g1-utils";
|
||||
|
||||
const T1_AT = departureAt(37);
|
||||
const T2_AT = new Date(T1_AT.getTime() + 3 * 3_600_000);
|
||||
const BOOKING_DAY = eatDayStr(T1_AT);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const T2_TRAIN = "TRN-F2-EXP2";
|
||||
const DEMAND = { EXP1: 30, EXP2: 20, EXP3: 20 } as const;
|
||||
|
||||
/** Room left on each train when EXP3 arrives. T2 is the roomier one. */
|
||||
const T1_FREE = CNT_POOL - DEMAND.EXP1; // 5
|
||||
const T2_FREE = CNT_POOL - DEMAND.EXP2; // 15
|
||||
|
||||
describe(
|
||||
"F2X·TC-14: the export split flag decides EXP3's fate, one train at a time",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train-2.sql");
|
||||
(["EXP1", "EXP2", "EXP3"] as const).forEach((s) =>
|
||||
seedExportLegContract({ suffix: s, reference: stampedRef(s), from: "F", to: "A" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("the flag under test, and what it is", () => {
|
||||
cy.task(
|
||||
"log",
|
||||
`TC-14: FREIGHT_EXPORT_SPLIT declared as ${exportSplitEnabled()} — ` +
|
||||
`this is an API-process env var (booking-batch.service.ts:394), not a ` +
|
||||
`DB setting; the runner declares it and this spec checks the engine agrees.`,
|
||||
);
|
||||
// The scenario's own arithmetic: an offer, if one is made, can only be
|
||||
// sized against ONE train's room — and the roomier train holds 15.
|
||||
expect(T1_FREE, "T1 room").to.eq(5);
|
||||
expect(T2_FREE, "T2 room").to.eq(15);
|
||||
expect(DEMAND.EXP3, "EXP3 exceeds both").to.be.greaterThan(Math.max(T1_FREE, T2_FREE));
|
||||
expect(
|
||||
T2_FREE,
|
||||
"the largest legal offer is T2's room — NOT the 5+15 the brief imagined",
|
||||
).to.eq(15);
|
||||
});
|
||||
|
||||
it("operations schedules both export trains", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(T1_AT, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: T1_AT });
|
||||
createExportSchedule({ departure: T2_AT, trainCode: T2_TRAIN });
|
||||
expectExportCapacity(T1_AT, EXPORT_CONSIST);
|
||||
expectExportCapacity(T2_AT, EXPORT_CONSIST);
|
||||
dbExportSchedule(T1_AT).then(({ rows: a }) =>
|
||||
dbExportSchedule(T2_AT).then(({ rows: b }) =>
|
||||
expect(a[0].id, "two distinct schedules").to.not.eq(b[0].id),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("EXP1 and EXP2 fill the two trains to 5 and 15 free", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 6600,
|
||||
forty: DEMAND.EXP1,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP1");
|
||||
pollAllocations("EXP1", DEMAND.EXP1);
|
||||
expectPoolAllocation("EXP1", "CNT", DEMAND.EXP1);
|
||||
|
||||
bookAndClear({
|
||||
suffix: "EXP2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 6700,
|
||||
forty: DEMAND.EXP2,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP2");
|
||||
pollAllocations("EXP2", DEMAND.EXP2);
|
||||
expectPoolAllocation("EXP2", "CNT", DEMAND.EXP2);
|
||||
});
|
||||
|
||||
it("EXP3 is filed against a day with no train that can take it whole", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP3",
|
||||
runStamp: stamp,
|
||||
isoSeed: 6800,
|
||||
forty: DEMAND.EXP3,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
});
|
||||
|
||||
it("SPLIT: the offer matches the flag — and is one train's worth, not two", () => {
|
||||
withBooking("EXP3", (b) =>
|
||||
db<{ status: string; offered: string; schedule: string | null }>(
|
||||
`SELECT status, offered_wagons::text AS offered,
|
||||
train_schedule_id::text AS schedule
|
||||
FROM freight.booking_batch_offers
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[b.id],
|
||||
).then(({ rows }) => {
|
||||
if (!exportSplitEnabled()) {
|
||||
// Flag off: no offer may exist at all. An offer here means the
|
||||
// engine ignored its own gate.
|
||||
expect(
|
||||
rows,
|
||||
"no split offer may be raised with FREIGHT_EXPORT_SPLIT off",
|
||||
).to.have.length(0);
|
||||
return;
|
||||
}
|
||||
expect(rows, "an offer was raised with the flag on").to.have.length(1);
|
||||
const offered = Number(rows[0].offered);
|
||||
cy.task("log", `TC-14: offer of ${offered} wagons on schedule ${rows[0].schedule}`);
|
||||
expect(offered, "an offer is a STRICT subset of the booking").to.be.lessThan(
|
||||
DEMAND.EXP3,
|
||||
);
|
||||
expect(
|
||||
offered,
|
||||
"…and is sized against ONE train's room — the roomier of the two",
|
||||
).to.eq(T2_FREE);
|
||||
expect(rows[0].schedule, "the offer names a single schedule").to.not.be.null;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("an unpaid offer holds no wagons — the booking is not mutated at offer time", () => {
|
||||
// booking-batch-offer.entity.ts:18-25 is explicit about this: the offer
|
||||
// records an intention, the payment is the accept. A spec that asserted
|
||||
// allocations here would pass only on an engine that had already
|
||||
// committed capacity to an offer nobody accepted.
|
||||
expectNoWagons("EXP3");
|
||||
expectSplitAllowed("EXP3", false);
|
||||
});
|
||||
|
||||
it("paying through the gateway is what applies the split", () => {
|
||||
if (!exportSplitEnabled()) {
|
||||
cy.task("log", "TC-14: flag off — nothing to accept; EXP3 stays whole and unplaced");
|
||||
expectNoWagons("EXP3");
|
||||
expectSplitAllowed("EXP3", false);
|
||||
return;
|
||||
}
|
||||
// settleViaGateway, NOT markPaid: staff mark-paid skips applySplit and
|
||||
// would allocate EXP3 whole, defeating the test (import-utils.ts:654).
|
||||
settleViaGateway("EXP3");
|
||||
pollAllocations("EXP3", T2_FREE);
|
||||
expectPoolAllocation("EXP3", "CNT", T2_FREE);
|
||||
expectSplitAllowed("EXP3", true);
|
||||
});
|
||||
|
||||
it("ONE booking, ONE schedule — a split does not spread across two trains", () => {
|
||||
if (!exportSplitEnabled()) return;
|
||||
withBooking("EXP3", (b) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT tsb.train_schedule_id) AS n
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.booking_id = $1 AND tsb.deleted_at IS NULL`,
|
||||
[b.id],
|
||||
).then(({ rows }) =>
|
||||
expect(
|
||||
Number(rows[0].n),
|
||||
"the split booking rides exactly one train — the remainder is rebooked, " +
|
||||
"not silently loaded onto the other schedule",
|
||||
).to.eq(1),
|
||||
),
|
||||
);
|
||||
// And the original quantity is preserved for the remainder rebooking.
|
||||
withBooking("EXP3", (b) =>
|
||||
db<{ pre: string | null }>(
|
||||
`SELECT pre_split_quantities::text AS pre FROM freight.bookings WHERE id = $1`,
|
||||
[b.id],
|
||||
).then(({ rows }) =>
|
||||
expect(rows[0].pre, "the pre-split quantities are snapshotted").to.not.be.null,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("neither train was overbooked, whatever the flag", () => {
|
||||
[T1_AT, T2_AT].forEach((at) =>
|
||||
dbExportSchedule(at).then(({ rows }) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||||
[rows[0].id],
|
||||
).then(({ rows: used }) =>
|
||||
expect(Number(used[0].n), "within the container pool").to.be.at.most(CNT_POOL),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-15 — restricted stop sets: the model cannot express them.
|
||||
*
|
||||
* THE SCENARIO AS BRIEFED CANNOT BE BUILT, AND THIS SPEC SAYS SO IN
|
||||
* ASSERTIONS RATHER THAN IN A COMMENT.
|
||||
*
|
||||
* The brief asks for two trains on one corridor with different stop lists —
|
||||
* T1 calling at F,E,D,C,B,A and T2 running express F,C,A — and expects
|
||||
* intercity bookings at the skipped stops to be filtered out of T2 BEFORE the
|
||||
* capacity check.
|
||||
*
|
||||
* Stops are not modelled per schedule. `freight.route_milestones` (entity
|
||||
* route-milestone.entity.ts:7-30) has `route_id`, `yard_id`, `sequence_no`,
|
||||
* `distance_km` — and no `train_schedule_id`, no `skip` flag, no per-schedule
|
||||
* override of any kind. `stopsForSchedule` (booking-batch.service.ts:4546-4559)
|
||||
* loads milestones by `routeId` alone, so EVERY schedule on a route advertises
|
||||
* an identical stop list. An express train has no representation.
|
||||
*
|
||||
* The consequence is concrete and worth a test of its own: two trains on the
|
||||
* corridor are interchangeable as far as stop eligibility goes, so a booking
|
||||
* D→B is offered to both and the ONLY thing that can turn it away is capacity.
|
||||
* "Stop filter before capacity check" describes a filter that does not exist.
|
||||
*
|
||||
* WHAT THIS SPEC DOES INSTEAD
|
||||
*
|
||||
* It asserts the model's actual shape, so the gap is recorded as a checked
|
||||
* fact rather than tribal knowledge:
|
||||
*
|
||||
* 1. `route_milestones` carries no per-schedule column — asserted against
|
||||
* `information_schema`, so adding one makes this test fail and someone
|
||||
* revisits the scenario.
|
||||
* 2. Two schedules on one route return the identical stop list.
|
||||
* 3. Therefore an intercity booking at a "skipped" stop is accepted by
|
||||
* whichever train has room, not filtered — asserted by actually booking
|
||||
* one and watching it board the express.
|
||||
*
|
||||
* When per-schedule stop sets are implemented, assertion 1 fails first and
|
||||
* loudest, which is the correct place to be interrupted.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
EXPORT_CONSIST,
|
||||
STOP,
|
||||
acceptIntercityOnExport,
|
||||
bookIntercityContainers,
|
||||
createExportSchedule,
|
||||
dbExportSchedule,
|
||||
expectExportCapacity,
|
||||
expectPoolAllocation,
|
||||
seedExportLegContract,
|
||||
} from "./flow2-export-utils";
|
||||
import { bookAndClear } from "../g1-utils";
|
||||
|
||||
const T1_AT = departureAt(38);
|
||||
/** The would-be "express". Same route, therefore the same stops. */
|
||||
const T2_AT = new Date(T1_AT.getTime() + 3 * 3_600_000);
|
||||
const BOOKING_DAY = eatDayStr(T1_AT);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const T2_TRAIN = "TRN-F2-EXP2";
|
||||
|
||||
/** The corridor's six stops, in export running order. */
|
||||
const EXPECTED_STOPS = [STOP.F, STOP.E, STOP.D, STOP.C, STOP.B, STOP.A];
|
||||
|
||||
describe(
|
||||
"F2X·TC-15: stop sets are a property of the route, never of the train",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train-2.sql");
|
||||
seedExportLegContract({
|
||||
suffix: "EXP1",
|
||||
reference: stampedRef("EXP1"),
|
||||
from: "F",
|
||||
to: "A",
|
||||
});
|
||||
// E→D and D→B are the legs the brief expects the express to refuse.
|
||||
seedExportLegContract({ suffix: "IC1", reference: stampedRef("IC1"), from: "E", to: "D" });
|
||||
seedExportLegContract({ suffix: "IC2", reference: stampedRef("IC2"), from: "D", to: "B" });
|
||||
});
|
||||
|
||||
it("MODEL: route_milestones has no per-schedule column", () => {
|
||||
// The load-bearing assertion. If a `train_schedule_id` (or a skip flag)
|
||||
// is ever added, this fails and the whole scenario gets rewritten as the
|
||||
// real thing rather than as this gap report.
|
||||
db<{ column_name: string }>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'freight' AND table_name = 'route_milestones'
|
||||
ORDER BY column_name`,
|
||||
[],
|
||||
).then(({ rows }) => {
|
||||
const cols = rows.map((r) => r.column_name);
|
||||
expect(cols, "milestones belong to a route").to.include("route_id");
|
||||
expect(cols, "…and are ordered along it").to.include("sequence_no");
|
||||
expect(
|
||||
cols,
|
||||
"milestones carry NO schedule reference — a per-train stop list is " +
|
||||
"not representable, which is why this scenario asserts the gap " +
|
||||
"instead of the behaviour",
|
||||
).to.not.include("train_schedule_id");
|
||||
expect(cols, "…and no skip flag either").to.not.include("is_skipped");
|
||||
});
|
||||
});
|
||||
|
||||
it("operations schedules two trains on the one export corridor", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(T1_AT, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: T1_AT });
|
||||
createExportSchedule({ departure: T2_AT, trainCode: T2_TRAIN });
|
||||
expectExportCapacity(T1_AT, EXPORT_CONSIST);
|
||||
expectExportCapacity(T2_AT, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("both schedules advertise the identical six-stop list", () => {
|
||||
// The consequence of the model, read back. "T2 stops F,C,A" is not a
|
||||
// configuration this schema can hold.
|
||||
dbExportSchedule(T1_AT).then(({ rows: a }) =>
|
||||
dbExportSchedule(T2_AT).then(({ rows: b }) => {
|
||||
expect(a[0].id, "two distinct schedules").to.not.eq(b[0].id);
|
||||
stopsOf(a[0].id).then((s1) =>
|
||||
stopsOf(b[0].id).then((s2) => {
|
||||
expect(s1, "T1 calls at every corridor stop").to.deep.eq(EXPECTED_STOPS);
|
||||
expect(
|
||||
s2,
|
||||
"T2 calls at exactly the same stops — there is no express variant",
|
||||
).to.deep.eq(EXPECTED_STOPS);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("EXP1 rides T1 end to end", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 6900,
|
||||
forty: 20,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP1");
|
||||
pollAllocations("EXP1", 20);
|
||||
expectPoolAllocation("EXP1", "CNT", 20);
|
||||
});
|
||||
|
||||
it("an intercity booking at a 'skipped' stop boards the express anyway", () => {
|
||||
// The behavioural half of the gap. Under the briefed design IC1 (E→D)
|
||||
// could only ride T1; here it is offered to T2 and accepted, because
|
||||
// nothing in the engine knows T2 was meant to skip E and D.
|
||||
bookIntercityContainers({ suffix: "IC1", runStamp: stamp, isoSeed: 7000, forty: 10 });
|
||||
bookIntercityContainers({ suffix: "IC2", runStamp: stamp, isoSeed: 7100, forty: 10 });
|
||||
|
||||
acceptIntercityOnExport({ departure: T2_AT, accept: ["IC1", "IC2"] });
|
||||
markPaid("IC1");
|
||||
markPaid("IC2");
|
||||
pollAllocations("IC1", 10);
|
||||
pollAllocations("IC2", 10);
|
||||
expectPoolAllocation("IC1", "CNT", 10);
|
||||
expectPoolAllocation("IC2", "CNT", 10);
|
||||
|
||||
cy.task(
|
||||
"log",
|
||||
"TC-15: IC1 (E→D) and IC2 (D→B) boarded the would-be express. Under a " +
|
||||
"per-schedule stop model both would have been filtered out before the " +
|
||||
"capacity check. That filter does not exist — see the MODEL test above.",
|
||||
);
|
||||
});
|
||||
|
||||
it("capacity, not stop eligibility, is the only thing that can refuse a leg", () => {
|
||||
// Stated positively so the gap is unambiguous: every booking that was
|
||||
// turned away today was turned away for room, and nothing was turned away
|
||||
// for calling at a stop.
|
||||
dbExportSchedule(T2_AT).then(({ rows }) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(*) AS n
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL`,
|
||||
[rows[0].id],
|
||||
).then(({ rows: n }) =>
|
||||
expect(
|
||||
Number(n[0].n),
|
||||
"the express carried both mid-corridor bookings",
|
||||
).to.be.at.least(2),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/** The stop codes a schedule advertises, in route order. */
|
||||
function stopsOf(scheduleId: string) {
|
||||
return db<{ code: string }>(
|
||||
`SELECT y.code
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.route_milestones rm ON rm.route_id = ts.route_id
|
||||
JOIN freight.yards y ON y.id = rm.yard_id
|
||||
WHERE ts.id = $1 AND rm.deleted_at IS NULL
|
||||
ORDER BY rm.sequence_no`,
|
||||
[scheduleId],
|
||||
).then(({ rows }) => rows.map((r) => r.code));
|
||||
}
|
||||
233
e2e/freight/cypress/e2e/flows/flow_two/tcx16_vessel_cutoff.cy.ts
Normal file
233
e2e/freight/cypress/e2e/flows/flow_two/tcx16_vessel_cutoff.cy.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-16 — vessel cutoffs: the deadline does not exist.
|
||||
*
|
||||
* THE SCENARIO AS BRIEFED HAS NOTHING TO ASSERT AGAINST, AND THIS SPEC PINS
|
||||
* THAT AS A CHECKED FACT.
|
||||
*
|
||||
* The brief asks: export bookings carry a vessel cutoff at port A; T1 arrives
|
||||
* 06:00 and T2 arrives 20:00; cutoff is 12:00; therefore only T1 is eligible
|
||||
* and overflow must WAITLIST rather than book T2 — deadline dominating free
|
||||
* capacity.
|
||||
*
|
||||
* No such deadline is modelled. An exhaustive search of the freight API and
|
||||
* schema turns up two unrelated things wearing similar words:
|
||||
*
|
||||
* `bookings.vessel_departure_date` / `vessel_arrival_date` — DOCUMENT
|
||||
* METADATA on the clearance workflow (migration 1829000000002; entity
|
||||
* booking.entity.ts:561-566). Nothing anywhere compares them to a train's
|
||||
* arrival time. The one rule that reads `vessel_departure_date` is
|
||||
* `uploadReleaseOrder` (booking-clearance.service.ts:884-925), which
|
||||
* enforces a MINIMUM LEAD TIME (`ro_vessel_min_days`) and, when it is not
|
||||
* met, sets a HOLD REASON and rewinds the clearance phase. It is a soft
|
||||
* gate on paperwork; it neither blocks booking nor selects a train.
|
||||
*
|
||||
* `bookingCloseCutoff` (batch-window.util.ts:276-290) — the booking WINDOW's
|
||||
* close offset before departure. Nothing to do with vessels.
|
||||
*
|
||||
* So "the cutoff dominates free capacity" cannot be tested: there is no code
|
||||
* path in which a train's arrival time is compared to anything on the booking.
|
||||
* Writing a spec that appeared to test it would be worse than writing none —
|
||||
* it would be green forever and would be cited as coverage.
|
||||
*
|
||||
* WHAT THIS SPEC DOES INSTEAD
|
||||
*
|
||||
* 1. Asserts the RO lead-time rule that DOES exist, end to end — including
|
||||
* that it produces a hold rather than a rejection, and that the booking
|
||||
* remains fully capable of taking a train afterwards.
|
||||
* 2. Asserts, behaviourally, that train selection ignores the vessel date: two
|
||||
* export bookings with wildly different vessel dates and identical cargo are
|
||||
* placed on the same day's trains purely by room and order of arrival.
|
||||
* 3. Documents, in an assertion on the schema, that no cutoff column exists to
|
||||
* key such a rule on.
|
||||
*
|
||||
* If a vessel-cutoff feature is built, test 3 fails first and this spec gets
|
||||
* rewritten into the real scenario.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
CNT_POOL,
|
||||
EXPORT_CONSIST,
|
||||
createExportSchedule,
|
||||
dbExportSchedule,
|
||||
expectExportCapacity,
|
||||
expectPoolAllocation,
|
||||
seedExportLegContract,
|
||||
} from "./flow2-export-utils";
|
||||
import { bookAndClear } from "../g1-utils";
|
||||
|
||||
/** T1 "arrives early", T2 "arrives late" — labels the engine has no opinion on. */
|
||||
const T1_AT = departureAt(39);
|
||||
const T2_AT = new Date(T1_AT.getTime() + 3 * 3_600_000);
|
||||
const BOOKING_DAY = eatDayStr(T1_AT);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const T2_TRAIN = "TRN-F2-EXP2";
|
||||
|
||||
/** EXP1 fills T1 to 5 free, so EXP2 must spill to T2 — on ROOM, not on a date. */
|
||||
const DEMAND = { EXP1: 30, EXP2: 20 } as const;
|
||||
|
||||
describe(
|
||||
"F2X·TC-16: no vessel cutoff exists; train choice is decided by room alone",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train-2.sql");
|
||||
(["EXP1", "EXP2"] as const).forEach((s) =>
|
||||
seedExportLegContract({ suffix: s, reference: stampedRef(s), from: "F", to: "A" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("SCHEMA: bookings carry vessel DATES, but no cutoff and no deadline", () => {
|
||||
// The gap, as a checked fact. `vessel_departure_date` exists and is
|
||||
// clearance metadata; a cutoff column that train selection could key on
|
||||
// does not.
|
||||
db<{ column_name: string }>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'freight' AND table_name = 'bookings'
|
||||
AND (column_name LIKE '%vessel%' OR column_name LIKE '%cutoff%')
|
||||
ORDER BY column_name`,
|
||||
[],
|
||||
).then(({ rows }) => {
|
||||
const cols = rows.map((r) => r.column_name);
|
||||
cy.task("log", `TC-16: vessel/cutoff columns on bookings — ${cols.join(", ") || "none"}`);
|
||||
expect(cols, "the vessel DEPARTURE date exists — it is clearance metadata").to.include(
|
||||
"vessel_departure_date",
|
||||
);
|
||||
expect(
|
||||
cols.filter((c) => c.includes("cutoff")),
|
||||
"…but there is NO cutoff column for train selection to honour, which " +
|
||||
"is why this scenario asserts the gap instead of the behaviour",
|
||||
).to.deep.eq([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("SCHEMA: no train arrival time exists to compare a cutoff against", () => {
|
||||
// The other half of why the rule is unbuildable today: a schedule records
|
||||
// a DEPARTURE, and per-stop arrival times live in checkpoints recorded
|
||||
// after the fact, not as a plan a booking could be matched against.
|
||||
db<{ column_name: string }>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'freight' AND table_name = 'train_schedules'
|
||||
AND column_name LIKE '%arriv%'`,
|
||||
[],
|
||||
).then(({ rows }) =>
|
||||
expect(
|
||||
rows.map((r) => r.column_name),
|
||||
"train_schedules has no planned arrival time",
|
||||
).to.deep.eq([]),
|
||||
);
|
||||
});
|
||||
|
||||
it("operations schedules both export trains", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(T1_AT, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: T1_AT });
|
||||
createExportSchedule({ departure: T2_AT, trainCode: T2_TRAIN });
|
||||
expectExportCapacity(T1_AT, EXPORT_CONSIST);
|
||||
expectExportCapacity(T2_AT, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("EXP1 takes the early train", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 7200,
|
||||
forty: DEMAND.EXP1,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP1");
|
||||
pollAllocations("EXP1", DEMAND.EXP1);
|
||||
expectPoolAllocation("EXP1", "CNT", DEMAND.EXP1);
|
||||
expectRides("EXP1", T1_AT);
|
||||
});
|
||||
|
||||
it("BEHAVIOUR: EXP2 takes the LATE train, because room is the only criterion", () => {
|
||||
// Under the briefed rule EXP2 would waitlist for the early train rather
|
||||
// than accept a late one. It does not — it boards T2, because nothing in
|
||||
// the selection path knows or asks about a vessel.
|
||||
bookAndClear({
|
||||
suffix: "EXP2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 7300,
|
||||
forty: DEMAND.EXP2,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP2");
|
||||
pollAllocations("EXP2", DEMAND.EXP2);
|
||||
expectPoolAllocation("EXP2", "CNT", DEMAND.EXP2);
|
||||
expectRides("EXP2", T2_AT);
|
||||
|
||||
cy.task(
|
||||
"log",
|
||||
"TC-16: EXP2 boarded the late train. A vessel-cutoff rule would have " +
|
||||
"waitlisted it for the early one. No such rule exists — see the SCHEMA " +
|
||||
"tests above.",
|
||||
);
|
||||
});
|
||||
|
||||
it("a vessel date set on the booking changes nothing about its train", () => {
|
||||
// The direct probe: stamp a vessel departure date that is BEFORE the late
|
||||
// train's departure — i.e. cargo that could not possibly make that
|
||||
// sailing — and confirm the allocation is untouched. This is the
|
||||
// assertion that would have to change first when the feature lands.
|
||||
withBooking("EXP2", (b) => {
|
||||
db(
|
||||
`UPDATE freight.bookings SET vessel_departure_date = $2::date WHERE id = $1`,
|
||||
[b.id, eatDayStr(T1_AT)],
|
||||
);
|
||||
});
|
||||
expectPoolAllocation("EXP2", "CNT", DEMAND.EXP2);
|
||||
expectRides("EXP2", T2_AT);
|
||||
});
|
||||
|
||||
it("neither train was overbooked", () => {
|
||||
[T1_AT, T2_AT].forEach((at) =>
|
||||
dbExportSchedule(at).then(({ rows }) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||||
[rows[0].id],
|
||||
).then(({ rows: used }) =>
|
||||
expect(Number(used[0].n), "within the container pool").to.be.at.most(CNT_POOL),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/** Assert a booking rides the schedule departing at `at`. */
|
||||
function expectRides(suffix: string, at: Date) {
|
||||
withBooking(suffix, (b) =>
|
||||
dbExportSchedule(at).then(({ rows }) =>
|
||||
expect(b.train_schedule_id, `${suffix} rides the ${at.toISOString()} train`).to.eq(
|
||||
rows[0].id,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-17 — an expiry frees capacity mid-route, and promotion
|
||||
* is leg-aware.
|
||||
*
|
||||
* The bookings, on the export corridor:
|
||||
*
|
||||
* EXP1 export F→C 30 CNT unpaid — will expire edges 2,3,4
|
||||
* IC1 intercity C→A 30 CNT confirmed edges 0,1
|
||||
* IC2 intercity E→D 20 CNT waitlisted edge 3
|
||||
*
|
||||
* EXP1 and IC1 do not share an edge, so both hold 30 wagons on a 35-wagon pool
|
||||
* without competing. IC2 wants edge 3 — which EXP1 occupies — and 30 + 20 = 50
|
||||
* exceeds the pool, so it waits.
|
||||
*
|
||||
* When EXP1's payment deadline lapses, thirty wagons come free on edges 2, 3
|
||||
* and 4. IC2 should be promoted onto edge 3. IC1 must be untouched: it never
|
||||
* shared track with EXP1, its capacity was never in question, and a promotion
|
||||
* pass that reshuffles unrelated confirmed bookings is a far worse bug than one
|
||||
* that promotes nobody.
|
||||
*
|
||||
* WHAT MAKES THIS LEG-AWARE RATHER THAN JUST "SOMETHING EXPIRED"
|
||||
*
|
||||
* A train-wide free-capacity counter would also promote IC2 here, so the naive
|
||||
* version of this test cannot tell the two engines apart. The discriminator is
|
||||
* IC3: a second waiter on edge 0 (A–B), where EXP1 never rode and where IC1's
|
||||
* 30 wagons leave only 5. EXP1's expiry frees nothing on edge 0, so IC3 must
|
||||
* STAY waiting. An engine crediting freed wagons train-wide promotes IC3 too
|
||||
* and overbooks edge 0 to 55.
|
||||
*
|
||||
* So the pair is the assertion: IC2 promoted, IC3 not. Either alone is
|
||||
* satisfiable by a wrong engine.
|
||||
*
|
||||
* MECHANISM. `cancelReservation` / expiry runs `refreshWindowStatus` then
|
||||
* `topUpFill` on the freed schedule (booking-batch.service.ts:3118-3126), and
|
||||
* `forceReservationExpiry` (import-utils.ts:727) pushes the deadline an hour
|
||||
* into the past — an hour rather than a second because the settle races the
|
||||
* top-up otherwise.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
forceReservationExpiry,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
resetCorridorDay,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
CNT_POOL,
|
||||
EXPORT_CONSIST,
|
||||
acceptIntercityOnExport,
|
||||
bookIntercityContainers,
|
||||
createExportSchedule,
|
||||
edgeLoad,
|
||||
expectExportCapacity,
|
||||
expectExportEdgeLoad,
|
||||
expectNoWagons,
|
||||
expectPoolAllocation,
|
||||
exportEdgesOf,
|
||||
seedExportLegContract,
|
||||
type Leg,
|
||||
} from "./flow2-export-utils";
|
||||
import { bookAndClear } from "../g1-utils";
|
||||
|
||||
const DEPARTURE = departureAt(40);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const LEGS = {
|
||||
EXP1: { from: "F", to: "C", wagons: 30 },
|
||||
IC1: { from: "C", to: "A", wagons: 30 },
|
||||
IC2: { from: "E", to: "D", wagons: 20 },
|
||||
/** The discriminator: rides edge 0, which EXP1's expiry does not touch. */
|
||||
IC3: { from: "B", to: "A", wagons: 20 },
|
||||
} as const satisfies Record<string, Leg>;
|
||||
|
||||
describe(
|
||||
"F2X·TC-17: an expiry promotes the waiter on ITS edge, and only that one",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
(["EXP1", "IC1", "IC2", "IC3"] as const).forEach((s) =>
|
||||
seedExportLegContract({ suffix: s, reference: stampedRef(s), ...LEGS[s] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("the premise: who competes with whom, edge by edge", () => {
|
||||
expect(exportEdgesOf("F", "C"), "EXP1 rides edges 2-4").to.deep.eq([2, 3, 4]);
|
||||
expect(exportEdgesOf("C", "A"), "IC1 rides edges 0-1").to.deep.eq([0, 1]);
|
||||
expect(exportEdgesOf("E", "D"), "IC2 wants edge 3 — EXP1's").to.deep.eq([3]);
|
||||
expect(exportEdgesOf("B", "A"), "IC3 wants edge 0 — IC1's").to.deep.eq([0]);
|
||||
|
||||
// EXP1 and IC1 coexist; each waiter is blocked by exactly one of them.
|
||||
expect(edgeLoad([LEGS.EXP1, LEGS.IC1]), "before any waiter").to.deep.eq([
|
||||
30, 30, 30, 30, 30,
|
||||
]);
|
||||
expect(
|
||||
LEGS.EXP1.wagons + LEGS.IC2.wagons,
|
||||
"IC2 blocked by EXP1 on edge 3",
|
||||
).to.be.greaterThan(CNT_POOL);
|
||||
expect(
|
||||
LEGS.IC1.wagons + LEGS.IC3.wagons,
|
||||
"IC3 blocked by IC1 on edge 0",
|
||||
).to.be.greaterThan(CNT_POOL);
|
||||
// …and the discriminator: EXP1's expiry frees nothing on edge 0.
|
||||
expect(exportEdgesOf("F", "C"), "EXP1 does not ride edge 0").to.not.include(0);
|
||||
});
|
||||
|
||||
it("operations schedules the export train", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: DEPARTURE });
|
||||
expectExportCapacity(DEPARTURE, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("EXP1 reserves F→C but never pays", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 7400,
|
||||
forty: LEGS.EXP1.wagons,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
// Deliberately NOT paid: the reservation holds the wagons until the
|
||||
// deadline, which is exactly the state the expiry has to unwind.
|
||||
withBooking("EXP1", (b) =>
|
||||
expect(b.status, "EXP1 holds a reservation").to.be.oneOf([
|
||||
"SELECTED_FOR_BATCH",
|
||||
"AWAITING_PAYMENT",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("IC1 confirms C→A on the other half of the corridor", () => {
|
||||
bookIntercityContainers({
|
||||
suffix: "IC1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 7500,
|
||||
forty: LEGS.IC1.wagons,
|
||||
});
|
||||
acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC1"] });
|
||||
markPaid("IC1");
|
||||
pollAllocations("IC1", LEGS.IC1.wagons);
|
||||
expectPoolAllocation("IC1", "CNT", LEGS.IC1.wagons);
|
||||
});
|
||||
|
||||
it("both waiters are turned away — each blocked by a different booking", () => {
|
||||
bookIntercityContainers({
|
||||
suffix: "IC2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 7600,
|
||||
forty: LEGS.IC2.wagons,
|
||||
});
|
||||
bookIntercityContainers({
|
||||
suffix: "IC3",
|
||||
runStamp: stamp,
|
||||
isoSeed: 7700,
|
||||
forty: LEGS.IC3.wagons,
|
||||
});
|
||||
acceptIntercityOnExport({
|
||||
departure: DEPARTURE,
|
||||
accept: [],
|
||||
reject: ["IC2", "IC3"],
|
||||
});
|
||||
expectNoWagons("IC2");
|
||||
expectNoWagons("IC3");
|
||||
});
|
||||
|
||||
it("EXP1's reservation expires, freeing edges 2-4", () => {
|
||||
forceReservationExpiry("EXP1");
|
||||
pollBookingStatus("EXP1", "EXPIRED", 30);
|
||||
expectNoWagons("EXP1");
|
||||
});
|
||||
|
||||
it("PROMOTION: IC2 gets onto edge 3 — the capacity EXP1 released", () => {
|
||||
// Re-offered because an intercity booking is staff-assigned; the expiry
|
||||
// frees the room, the assignment is the act. What the expiry must have
|
||||
// done is make this acceptance POSSIBLE, where it was refused above.
|
||||
acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC2"] });
|
||||
markPaid("IC2");
|
||||
pollAllocations("IC2", LEGS.IC2.wagons);
|
||||
expectPoolAllocation("IC2", "CNT", LEGS.IC2.wagons);
|
||||
});
|
||||
|
||||
it("DISCRIMINATOR: IC3 stays out — nothing was freed on its edge", () => {
|
||||
// The assertion that separates leg-aware promotion from a train-wide
|
||||
// free-wagon counter. EXP1's 30 wagons came back, but not on edge 0,
|
||||
// where IC1's 30 still stand. An engine crediting them train-wide would
|
||||
// admit IC3 and overbook edge 0 to 50.
|
||||
acceptIntercityOnExport({ departure: DEPARTURE, accept: [], reject: ["IC3"] });
|
||||
expectNoWagons("IC3");
|
||||
});
|
||||
|
||||
it("IC1 was never touched by any of it", () => {
|
||||
// A promotion pass that reshuffled a confirmed, unrelated booking would
|
||||
// be worse than one that promoted nobody.
|
||||
expectPoolAllocation("IC1", "CNT", LEGS.IC1.wagons);
|
||||
withBooking("IC1", (b) =>
|
||||
expect(b.status, "IC1 still confirmed").to.not.be.oneOf([
|
||||
"EXPIRED",
|
||||
"CANCELLED",
|
||||
"REJECTED",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("PROFILE: IC1 on edges 0-1, IC2 on edge 3, nothing over the pool", () => {
|
||||
expectExportEdgeLoad(DEPARTURE, [30, 30, 0, 20, 0], CNT_POOL);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-18 — cancelling the bulk half releases bulk wagons only.
|
||||
*
|
||||
* THE BRIEF ASKS FOR A PARTIAL CANCEL, WHICH DOES NOT EXIST — and the shape of
|
||||
* the scenario survives anyway, because of how mixed shipments are actually
|
||||
* filed.
|
||||
*
|
||||
* There is no per-line, per-container or per-tonnage cancel anywhere in the
|
||||
* bookings module. Two endpoints exist and both are whole-booking:
|
||||
*
|
||||
* POST /api/bookings/:id/cancel — pre-commit statuses only
|
||||
* (booking-transition.service.ts:432)
|
||||
* POST /api/bookings/:id/cancel-hold — SELECTED_FOR_BATCH only; this is the
|
||||
* one that releases wagons and triggers
|
||||
* the top-up (:412 → cancelReservation,
|
||||
* booking-batch.service.ts:3094)
|
||||
*
|
||||
* But a booking has ONE `freight_type`. "EXP1: 20 CNT + 10 BLK, single booking,
|
||||
* two types" is not a filable shipment — a mixed export is necessarily TWO
|
||||
* bookings, one per pool. So "EXP1 cancels only its BLK portion" is, in the
|
||||
* real model, "the bulk booking of the pair is cancelled" — which is exactly
|
||||
* the per-type release the scenario wants to test, reached through the door the
|
||||
* system actually has.
|
||||
*
|
||||
* The setup:
|
||||
*
|
||||
* EXP1C export container F→A 20 CNT stays
|
||||
* EXP1B export bulk F→A 10 BLK CANCELLED
|
||||
* IC1 intercity cont. D→B 15 CNT waitlisted — needs container wagons
|
||||
* IC2 intercity bulk D→B 10 BLK waitlisted — needs bulk wagons
|
||||
*
|
||||
* The container pool is deliberately squeezed by a filler so IC1 genuinely
|
||||
* cannot fit; the bulk pool is squeezed by EXP1B alone.
|
||||
*
|
||||
* WHAT MUST HAPPEN when EXP1B cancels: IC2 becomes assignable, IC1 does not.
|
||||
* The pair is the assertion — an engine that credited the released wagons to a
|
||||
* type-blind pool would promote IC1 too, and IC1's containers would then be
|
||||
* allocated against wagons that are physically bulk hoppers.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
apiPost,
|
||||
bookBulk,
|
||||
clearToOperationRequestPending,
|
||||
acceptExport,
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
pollBookingStatus,
|
||||
resetCorridorDay,
|
||||
superAdmin,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
BLK_POOL,
|
||||
CNT_POOL,
|
||||
EXPORT_CONSIST,
|
||||
acceptIntercityOnExport,
|
||||
bookIntercityBulk,
|
||||
bookIntercityContainers,
|
||||
bulkWagons,
|
||||
createExportSchedule,
|
||||
expectExportCapacity,
|
||||
expectNoPoolLeak,
|
||||
expectNoWagons,
|
||||
expectPoolAllocation,
|
||||
seedExportLegContract,
|
||||
} from "./flow2-export-utils";
|
||||
import { bookAndClear } from "../g1-utils";
|
||||
|
||||
const DEPARTURE = departureAt(41);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const EXP1C_WAGONS = 20;
|
||||
const EXP1B_WAGONS = 10;
|
||||
const EXP1B_TONS = EXP1B_WAGONS * 70;
|
||||
/** Fills the container pool to 30/35 so IC1's 15 genuinely cannot fit. */
|
||||
const FILLER_WAGONS = 10;
|
||||
const IC1_WAGONS = 15;
|
||||
const IC2_WAGONS = 10;
|
||||
const IC2_TONS = IC2_WAGONS * 70;
|
||||
|
||||
describe(
|
||||
"F2X·TC-18: cancelling the bulk half of a mixed shipment releases bulk wagons only",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
seedExportLegContract({ suffix: "EXP1C", reference: stampedRef("EXP1C"), from: "F", to: "A" });
|
||||
seedExportLegContract({
|
||||
suffix: "EXP1B",
|
||||
reference: stampedRef("EXP1B"),
|
||||
from: "F",
|
||||
to: "A",
|
||||
freight: "BULK",
|
||||
});
|
||||
seedExportLegContract({ suffix: "FILL", reference: stampedRef("FILL"), from: "F", to: "A" });
|
||||
seedExportLegContract({ suffix: "IC1", reference: stampedRef("IC1"), from: "D", to: "B" });
|
||||
seedExportLegContract({
|
||||
suffix: "IC2",
|
||||
reference: stampedRef("IC2"),
|
||||
from: "D",
|
||||
to: "B",
|
||||
freight: "BULK",
|
||||
});
|
||||
});
|
||||
|
||||
it("the model: a booking has ONE freight type, so a mixed shipment is two bookings", () => {
|
||||
// Why the brief's "single booking, two types" cannot be filed — stated as
|
||||
// a schema fact so the scenario's translation is justified, not assumed.
|
||||
db<{ is_nullable: string }>(
|
||||
`SELECT is_nullable FROM information_schema.columns
|
||||
WHERE table_schema = 'freight' AND table_name = 'bookings'
|
||||
AND column_name = 'freight_type'`,
|
||||
[],
|
||||
).then(({ rows }) => {
|
||||
expect(rows, "bookings.freight_type exists").to.have.length(1);
|
||||
cy.task(
|
||||
"log",
|
||||
"TC-18: freight_type is a single scalar on the booking — a 20 CNT + " +
|
||||
"10 BLK shipment is necessarily two bookings, and cancelling 'the " +
|
||||
"BLK portion' means cancelling the bulk booking of the pair.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("the premise: each waiter is blocked by exactly one pool", () => {
|
||||
expect(
|
||||
EXP1C_WAGONS + FILLER_WAGONS + IC1_WAGONS,
|
||||
"IC1 does not fit the container pool",
|
||||
).to.be.greaterThan(CNT_POOL);
|
||||
expect(
|
||||
EXP1B_WAGONS + IC2_WAGONS,
|
||||
"IC2 does not fit the bulk pool alongside EXP1B",
|
||||
).to.be.greaterThan(BLK_POOL);
|
||||
// …and IC2 DOES fit once EXP1B is gone, which is the promotion under test.
|
||||
expect(IC2_WAGONS, "IC2 fits an empty bulk pool").to.be.at.most(BLK_POOL);
|
||||
// …while IC1 still does not, because nothing container-side was released.
|
||||
expect(
|
||||
EXP1C_WAGONS + FILLER_WAGONS + IC1_WAGONS,
|
||||
"IC1 is still blocked after the bulk cancel",
|
||||
).to.be.greaterThan(CNT_POOL);
|
||||
});
|
||||
|
||||
it("operations schedules the export train", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: DEPARTURE });
|
||||
expectExportCapacity(DEPARTURE, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("the mixed shipment boards — containers paid, bulk held", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP1C",
|
||||
runStamp: stamp,
|
||||
isoSeed: 7800,
|
||||
forty: EXP1C_WAGONS,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP1C");
|
||||
pollAllocations("EXP1C", EXP1C_WAGONS);
|
||||
|
||||
bookBulk({
|
||||
suffix: "EXP1B",
|
||||
tons: EXP1B_TONS,
|
||||
cargoCode: "E2E_IMP_WHEAT",
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearToOperationRequestPending("EXP1B", BOOKING_DAY);
|
||||
acceptExport("EXP1B");
|
||||
// Left at SELECTED_FOR_BATCH deliberately: cancel-hold is the only cancel
|
||||
// that releases capacity, and it accepts that status alone.
|
||||
pollBookingStatus("EXP1B", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 15);
|
||||
|
||||
// Squeeze the container pool so IC1's refusal is real, not incidental.
|
||||
bookAndClear({
|
||||
suffix: "FILL",
|
||||
runStamp: stamp,
|
||||
isoSeed: 7900,
|
||||
forty: FILLER_WAGONS,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("FILL");
|
||||
pollAllocations("FILL", FILLER_WAGONS);
|
||||
});
|
||||
|
||||
it("both intercity waiters are refused, one per pool", () => {
|
||||
bookIntercityContainers({
|
||||
suffix: "IC1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 8000,
|
||||
forty: IC1_WAGONS,
|
||||
});
|
||||
bookIntercityBulk({ suffix: "IC2", tons: IC2_TONS, cargoCode: "E2E_IMP_WHEAT" });
|
||||
acceptIntercityOnExport({
|
||||
departure: DEPARTURE,
|
||||
accept: [],
|
||||
reject: ["IC1", "IC2"],
|
||||
});
|
||||
expectNoWagons("IC1");
|
||||
expectNoWagons("IC2");
|
||||
});
|
||||
|
||||
it("the bulk half of the shipment is cancelled", () => {
|
||||
// cancel-hold, not cancel: `cancel` refuses a committed status outright,
|
||||
// while `cancel-hold` is the SELECTED_FOR_BATCH door that runs
|
||||
// cancelReservation → refreshWindowStatus → topUpFill.
|
||||
withBooking("EXP1B", (b) =>
|
||||
apiPost(superAdmin, `/api/bookings/${b.id}/cancel-hold`, {
|
||||
reason: "E2E TC-18: customer drops the bulk half of the shipment",
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]),
|
||||
);
|
||||
pollBookingStatus("EXP1B", "CANCELLED", 20);
|
||||
expectNoWagons("EXP1B");
|
||||
});
|
||||
|
||||
it("PROMOTION: IC2 takes the released bulk wagons", () => {
|
||||
acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC2"] });
|
||||
markPaid("IC2");
|
||||
pollAllocations("IC2", bulkWagons(IC2_TONS));
|
||||
expectPoolAllocation("IC2", "BLK", bulkWagons(IC2_TONS));
|
||||
});
|
||||
|
||||
it("DISCRIMINATOR: IC1 stays waitlisted — no container wagon was released", () => {
|
||||
// The assertion the scenario exists for. A type-blind release would see
|
||||
// "10 wagons freed" and promote IC1, whose containers would then be
|
||||
// riding bulk hoppers.
|
||||
acceptIntercityOnExport({ departure: DEPARTURE, accept: [], reject: ["IC1"] });
|
||||
expectNoWagons("IC1");
|
||||
});
|
||||
|
||||
it("the container half of the shipment was untouched", () => {
|
||||
expectPoolAllocation("EXP1C", "CNT", EXP1C_WAGONS);
|
||||
withBooking("EXP1C", (b) =>
|
||||
expect(b.status, "the container booking survived its sibling's cancel").to.not.be.oneOf(
|
||||
["CANCELLED", "EXPIRED", "REJECTED"],
|
||||
),
|
||||
);
|
||||
expectNoPoolLeak(DEPARTURE);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-19 — three exports race for a pool that fits two.
|
||||
*
|
||||
* EXP1 export F→A 15 CNT
|
||||
* EXP2 export F→A 15 CNT
|
||||
* EXP3 export F→A 15 CNT pool: 35
|
||||
*
|
||||
* Forty-five wagons of demand, thirty-five available, and each booking is
|
||||
* all-or-nothing. Exactly two must win; the third must be turned away holding
|
||||
* nothing. The failure this guards is an overbook — two accepts that each read
|
||||
* "20 free" before either wrote, and a train that departs owing 45 wagons of
|
||||
* space it does not have.
|
||||
*
|
||||
* ON "SIMULTANEOUSLY" — WHAT THIS SPEC CAN AND CANNOT DO
|
||||
*
|
||||
* Cypress serialises its command queue, and every API helper in this suite goes
|
||||
* through it. There is no way to fire three genuinely parallel HTTP requests
|
||||
* from a spec, and no existing test in this repo does (the nearest precedent,
|
||||
* rate_change_mid_window.cy.ts:175, fires two back-to-back and asserts the
|
||||
* second gets a 409). Pretending otherwise would produce a test whose name
|
||||
* promises concurrency and whose body proves serialisation.
|
||||
*
|
||||
* So this spec asserts the two properties that actually matter, and is honest
|
||||
* that it reaches them serially:
|
||||
*
|
||||
* 1. NO OVERBOOK. Whatever the interleaving, the pool is never exceeded. A
|
||||
* lock that works serially is necessary-but-not-sufficient for
|
||||
* concurrency; a lock that fails serially is broken outright.
|
||||
* 2. A DETERMINISTIC LOSER. The third booking to arrive is the one refused —
|
||||
* not an arbitrary one — so the outcome is reproducible and explicable to
|
||||
* a customer.
|
||||
*
|
||||
* WHAT DECIDES THE LOSER, exactly. Export is FCFS: `acceptExport` IS the
|
||||
* reservation, so ORDER OF ACCEPTANCE decides, full stop — the batch's
|
||||
* five-key priority sort (`resortPoolByPriority`,
|
||||
* booking-batch.service.ts:4051) never runs on this path. Worth stating because
|
||||
* the brief's "submit ts, then id" describes the batch tiebreak, and `id` is
|
||||
* not a key in it at all: the five keys are isGovernment ↓, window cycle ↑,
|
||||
* priorityScore ↓, fullyExecutedAt ↑, createdAt ↑.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
clearToOperationRequestPending,
|
||||
bookContainers,
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
acceptExport,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
CNT_POOL,
|
||||
EXPORT_CONSIST,
|
||||
acceptExportExpectingRefusal,
|
||||
createExportSchedule,
|
||||
expectCapacityRefusal,
|
||||
expectExportCapacity,
|
||||
expectNoPoolLeak,
|
||||
expectNoWagons,
|
||||
expectPoolAllocation,
|
||||
seedExportLegContract,
|
||||
withExportSched,
|
||||
} from "./flow2-export-utils";
|
||||
|
||||
const DEPARTURE = departureAt(42);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const EACH = 15;
|
||||
const RACERS = ["EXP1", "EXP2", "EXP3"] as const;
|
||||
|
||||
describe(
|
||||
"F2X·TC-19: three 15-wagon exports, a 35-wagon pool, exactly two winners",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
RACERS.forEach((s) =>
|
||||
seedExportLegContract({ suffix: s, reference: stampedRef(s), from: "F", to: "A" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("the premise: two fit, three do not, and there is no partial winner", () => {
|
||||
expect(EACH * 2, "two bookings fit").to.be.at.most(CNT_POOL);
|
||||
expect(EACH * 3, "three do not").to.be.greaterThan(CNT_POOL);
|
||||
// The remainder matters: 35 - 30 = 5 wagons are left over, fewer than the
|
||||
// 15 the loser needs, so there is no room for a partial to muddy the
|
||||
// outcome. Exactly two winners, one clean loser.
|
||||
expect(CNT_POOL - EACH * 2, "leftover room, too small for a third").to.eq(5);
|
||||
expect(CNT_POOL - EACH * 2, "…and strictly less than one booking").to.be.lessThan(EACH);
|
||||
});
|
||||
|
||||
it("operations schedules the export train", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: DEPARTURE });
|
||||
expectExportCapacity(DEPARTURE, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("all three bookings are filed and cleared before any is accepted", () => {
|
||||
// The closest a Cypress spec gets to simultaneity: every booking reaches
|
||||
// the accept gate before the first accept runs, so all three are live
|
||||
// contenders for the same 35 wagons rather than arriving one at a time.
|
||||
RACERS.forEach((suffix, i) => {
|
||||
bookContainers({
|
||||
suffix,
|
||||
runStamp: stamp,
|
||||
isoSeed: 8100 + i * 100,
|
||||
forty: EACH,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
});
|
||||
clearToOperationRequestPending(suffix, BOOKING_DAY);
|
||||
});
|
||||
RACERS.forEach((suffix) =>
|
||||
withBooking(suffix, (b) =>
|
||||
expect(b.status, `${suffix} is waiting at the accept gate`).to.eq(
|
||||
"OPERATION_REQUEST_PENDING",
|
||||
),
|
||||
),
|
||||
);
|
||||
cy.task(
|
||||
"log",
|
||||
"TC-19: all three contenders cleared. Cypress serialises its queue, so " +
|
||||
"the accepts below are back-to-back, not parallel — the assertions are " +
|
||||
"no-overbook and a deterministic loser, not true concurrency.",
|
||||
);
|
||||
});
|
||||
|
||||
it("the first two accepts win, taking 30 of 35", () => {
|
||||
acceptExport("EXP1");
|
||||
acceptExport("EXP2");
|
||||
markPaid("EXP1");
|
||||
markPaid("EXP2");
|
||||
pollAllocations("EXP1", EACH);
|
||||
pollAllocations("EXP2", EACH);
|
||||
expectPoolAllocation("EXP1", "CNT", EACH);
|
||||
expectPoolAllocation("EXP2", "CNT", EACH);
|
||||
});
|
||||
|
||||
it("the third is refused — 5 wagons remain and it needs 15", () => {
|
||||
acceptExportExpectingRefusal("EXP3").then((res) => {
|
||||
cy.task("log", `TC-19: EXP3 refused — ${JSON.stringify(res.body).slice(0, 300)}`);
|
||||
expectCapacityRefusal(res);
|
||||
});
|
||||
// All-or-nothing: the 5 free wagons must not be handed over as a
|
||||
// consolation. Export is whole-or-nothing unless FREIGHT_EXPORT_SPLIT is
|
||||
// on, and even then an offer is not an allocation until it is paid.
|
||||
expectNoWagons("EXP3");
|
||||
});
|
||||
|
||||
it("NO OVERBOOK: the pool holds exactly 30 of its 35 wagons", () => {
|
||||
// The assertion that would fail on a lost-update race — two accepts each
|
||||
// reading 35 free and each writing 15.
|
||||
withExportSched(DEPARTURE, (s) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||||
[s.id],
|
||||
).then(({ rows }) => {
|
||||
expect(Number(rows[0].n), "exactly two winners' worth").to.eq(EACH * 2);
|
||||
expect(Number(rows[0].n), "…and never more than the pool").to.be.at.most(CNT_POOL);
|
||||
}),
|
||||
);
|
||||
expectNoPoolLeak(DEPARTURE);
|
||||
});
|
||||
|
||||
it("DETERMINISM: the loser is the last to arrive, not an arbitrary one", () => {
|
||||
// Export is FCFS — the accept is the reservation, so acceptance order
|
||||
// decides outright. Asserted explicitly so a future change that routed
|
||||
// export through the batch's priority sort would be caught rather than
|
||||
// silently reshuffling who loses.
|
||||
withBooking("EXP3", (b) =>
|
||||
expect(b.train_schedule_id, "the last arrival holds no seat").to.be.null,
|
||||
);
|
||||
RACERS.slice(0, 2).forEach((suffix) =>
|
||||
withBooking(suffix, (b) =>
|
||||
expect(b.train_schedule_id, `${suffix} holds its seat`).to.not.be.null,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("the loser is recoverable — its contract can still book another day", () => {
|
||||
// A refused booking must not strand the customer. The contract stays
|
||||
// bookable, which is what makes "try the next train" a real option rather
|
||||
// than a support ticket.
|
||||
withBooking("EXP3", (b) =>
|
||||
db<{ status: string }>(`SELECT status FROM freight.contracts WHERE id = $1`, [
|
||||
b.contract_id,
|
||||
]).then(({ rows }) =>
|
||||
expect(rows[0].status, "EXP3's contract is still bookable").to.be.oneOf([
|
||||
"FULLY_EXECUTED",
|
||||
"CONTRACT_ACTIVE",
|
||||
]),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* FLOW-TWO EXPORT · TC-20 — the consist shrinks under confirmed bookings.
|
||||
*
|
||||
* Three confirmed bookings across the corridor, then wagons are pulled out of
|
||||
* the train for maintenance:
|
||||
*
|
||||
* EXP1 export F→D 20 CNT edges 3,4
|
||||
* IC1 intercity D→B 10 CNT edges 1,2
|
||||
* EXP2 export B→A 25 CNT edge 0
|
||||
*
|
||||
* Every leg fits comfortably in the 35-wagon container pool. Then ten NW5
|
||||
* wagons are uncoupled, taking the pool from 35 to 25 — and EXP2's 25 on edge 0
|
||||
* now sits exactly at the new ceiling while the day's peak edge is still fine.
|
||||
* Push it one wagon further and the train owes space it no longer has.
|
||||
*
|
||||
* THE RULE, AS THE ENGINE ACTUALLY IMPLEMENTS IT
|
||||
*
|
||||
* `adjustScheduleConsist` (train-scheduling.service.ts:6026) is explicit that
|
||||
* staff may shrink below what is already committed — the comment at :6326 says
|
||||
* "allowed, but reported back as a warning (never silently)". Concretely:
|
||||
*
|
||||
* - `max_wagons` is rewritten to the new consist length (:6291)
|
||||
* - `scheduleWagonUsage` computes `overAllocatedBy` (:6332)
|
||||
* - if positive, a WARNING STRING is returned naming the shortfall (:6336)
|
||||
* - the FULL/OPEN window line is recomputed (:6343-6355)
|
||||
* - and that is all. No booking is expired, bumped, re-batched, re-priced or
|
||||
* flagged; no allocation row is deleted.
|
||||
*
|
||||
* So of the three policies the brief offers — LIFO bump, manual review flag,
|
||||
* type-downgrade offer — the answer is NONE OF THEM. It is "warn and leave it
|
||||
* to staff". This spec pins that, because the alternative failure is far worse
|
||||
* than an unhandled edge case: an engine that silently accepted the shrink
|
||||
* without warning would let a train depart short and nobody would know until
|
||||
* the yard.
|
||||
*
|
||||
* THE HARD GUARANTEE that IS enforced, and is asserted here: a LOADED wagon
|
||||
* cannot be trimmed at all. :6115-6122 returns 409 — "cannot be trimmed, only
|
||||
* switched" — for any wagon carrying cargo beyond the current stop. So the
|
||||
* shrink can only ever take FREE wagons, which is what keeps this from being a
|
||||
* data-loss bug.
|
||||
*
|
||||
* Sequential steps of one journey — retries off.
|
||||
*/
|
||||
|
||||
import {
|
||||
apiPost,
|
||||
db,
|
||||
departureAt,
|
||||
eatDayStr,
|
||||
ensureExportRoute,
|
||||
markPaid,
|
||||
pollAllocations,
|
||||
resetCorridorDay,
|
||||
superAdmin,
|
||||
EXP_DEST,
|
||||
EXP_ORIGIN,
|
||||
withBooking,
|
||||
} from "../import-utils";
|
||||
import {
|
||||
CNT_POOL,
|
||||
EXPORT_CONSIST,
|
||||
POOL_TYPE,
|
||||
acceptIntercityOnExport,
|
||||
bookIntercityContainers,
|
||||
createExportSchedule,
|
||||
edgeLoad,
|
||||
expectExportCapacity,
|
||||
expectExportEdgeLoad,
|
||||
expectNoPoolLeak,
|
||||
expectPoolAllocation,
|
||||
seedExportLegContract,
|
||||
withExportSched,
|
||||
type Leg,
|
||||
} from "./flow2-export-utils";
|
||||
import { bookAndClear } from "../g1-utils";
|
||||
|
||||
const DEPARTURE = departureAt(43);
|
||||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||
|
||||
const LEGS = {
|
||||
EXP1: { from: "F", to: "D", wagons: 20 },
|
||||
IC1: { from: "D", to: "B", wagons: 10 },
|
||||
EXP2: { from: "B", to: "A", wagons: 25 },
|
||||
} as const satisfies Record<string, Leg>;
|
||||
|
||||
/** Ten container wagons go for maintenance: pool 35 → 25. */
|
||||
const REMOVED = 10;
|
||||
const NEW_POOL = CNT_POOL - REMOVED;
|
||||
|
||||
describe(
|
||||
"F2X·TC-20: shrinking the consist under confirmed bookings warns, never silently overbooks",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-legs.sql");
|
||||
cy.task("db:seedFile", "seed-flow2-export-train.sql");
|
||||
(["EXP1", "IC1", "EXP2"] as const).forEach((s) =>
|
||||
seedExportLegContract({ suffix: s, reference: stampedRef(s), ...LEGS[s] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("the premise: every leg fits at 35, and EXP2 sits at the new ceiling of 25", () => {
|
||||
const profile = edgeLoad(Object.values(LEGS));
|
||||
expect(profile, "per-edge demand").to.deep.eq([25, 10, 10, 20, 20]);
|
||||
expect(Math.max(...profile), "everything fits the pool as built").to.be.at.most(
|
||||
CNT_POOL,
|
||||
);
|
||||
expect(NEW_POOL, "the pool after maintenance").to.eq(25);
|
||||
expect(
|
||||
LEGS.EXP2.wagons,
|
||||
"EXP2 lands exactly ON the reduced ceiling — one more and the train owes space",
|
||||
).to.eq(NEW_POOL);
|
||||
});
|
||||
|
||||
it("operations schedules the export train", () => {
|
||||
ensureExportRoute();
|
||||
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
|
||||
createExportSchedule({ departure: DEPARTURE });
|
||||
expectExportCapacity(DEPARTURE, EXPORT_CONSIST);
|
||||
});
|
||||
|
||||
it("all three bookings confirm and pay", () => {
|
||||
bookAndClear({
|
||||
suffix: "EXP1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 8400,
|
||||
forty: LEGS.EXP1.wagons,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP1");
|
||||
pollAllocations("EXP1", LEGS.EXP1.wagons);
|
||||
|
||||
bookIntercityContainers({
|
||||
suffix: "IC1",
|
||||
runStamp: stamp,
|
||||
isoSeed: 8500,
|
||||
forty: LEGS.IC1.wagons,
|
||||
});
|
||||
acceptIntercityOnExport({ departure: DEPARTURE, accept: ["IC1"] });
|
||||
markPaid("IC1");
|
||||
pollAllocations("IC1", LEGS.IC1.wagons);
|
||||
|
||||
bookAndClear({
|
||||
suffix: "EXP2",
|
||||
runStamp: stamp,
|
||||
isoSeed: 8600,
|
||||
forty: LEGS.EXP2.wagons,
|
||||
scheduledDate: BOOKING_DAY,
|
||||
mode: "export",
|
||||
});
|
||||
markPaid("EXP2");
|
||||
pollAllocations("EXP2", LEGS.EXP2.wagons);
|
||||
|
||||
expectExportEdgeLoad(DEPARTURE, [25, 10, 10, 20, 20], CNT_POOL);
|
||||
});
|
||||
|
||||
it("GUARANTEE: a loaded wagon cannot be trimmed at all", () => {
|
||||
// The hard rule, asserted before the legal shrink. Without it, this
|
||||
// scenario would be a data-loss test rather than a policy test.
|
||||
loadedContainerWagons().then((loaded) => {
|
||||
expect(loaded, "some wagons are carrying cargo").to.have.length.greaterThan(0);
|
||||
withExportSched(DEPARTURE, (s) =>
|
||||
apiPost(
|
||||
superAdmin,
|
||||
`/api/train-scheduling/schedules/${s.id}/adjust-consist`,
|
||||
{ removeWagonIds: [loaded[0]] },
|
||||
false,
|
||||
).then((res) => {
|
||||
expect(res.status, "trimming a loaded wagon is refused").to.be.within(400, 499);
|
||||
cy.task(
|
||||
"log",
|
||||
`TC-20: loaded-wagon trim refused — ${JSON.stringify(res.body).slice(0, 200)}`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("ten FREE container wagons go for maintenance — the pool drops 35 → 25", () => {
|
||||
freeContainerWagons(REMOVED).then((ids) => {
|
||||
expect(ids, `${REMOVED} free container wagons to pull`).to.have.length(REMOVED);
|
||||
withExportSched(DEPARTURE, (s) =>
|
||||
apiPost(
|
||||
superAdmin,
|
||||
`/api/train-scheduling/schedules/${s.id}/adjust-consist`,
|
||||
{ removeWagonIds: ids },
|
||||
false,
|
||||
).then((res) => {
|
||||
expect(res.status, "the trim is accepted").to.be.oneOf([200, 201]);
|
||||
const warnings = (res.body as { warnings?: string[] }).warnings ?? [];
|
||||
cy.task("log", `TC-20: adjust-consist warnings — ${JSON.stringify(warnings)}`);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("POLICY: max_wagons follows the consist — the shrink is recorded, not ignored", () => {
|
||||
// The first half of "never silently". Whatever happens to the bookings,
|
||||
// the schedule must stop advertising capacity it no longer has.
|
||||
withExportSched(DEPARTURE, (s) =>
|
||||
db<{ max_wagons: string }>(
|
||||
`SELECT max_wagons FROM freight.train_schedules WHERE id = $1`,
|
||||
[s.id],
|
||||
).then(({ rows }) =>
|
||||
expect(
|
||||
Number(rows[0].max_wagons),
|
||||
"the schedule's capacity was rewritten to the new consist length",
|
||||
).to.eq(EXPORT_CONSIST - REMOVED),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("POLICY: no confirmed booking was bumped, expired or flagged", () => {
|
||||
// The pinned decision. The engine warns and leaves it to staff — NOT LIFO
|
||||
// bump, NOT an auto review flag, NOT a downgrade offer. If any of those
|
||||
// is ever implemented, this test fails and the policy change is
|
||||
// acknowledged deliberately rather than discovered in production.
|
||||
(["EXP1", "IC1", "EXP2"] as const).forEach((suffix) => {
|
||||
expectPoolAllocation(suffix, "CNT", LEGS[suffix].wagons);
|
||||
withBooking(suffix, (b) =>
|
||||
expect(b.status, `${suffix} was not bumped by the consist change`).to.not.be.oneOf([
|
||||
"EXPIRED",
|
||||
"CANCELLED",
|
||||
"REJECTED",
|
||||
]),
|
||||
);
|
||||
});
|
||||
expectExportEdgeLoad(DEPARTURE, [25, 10, 10, 20, 20], NEW_POOL);
|
||||
});
|
||||
|
||||
it("NO SILENT OVERBOOK: the surviving allocations fit the reduced pool", () => {
|
||||
// The invariant that must hold whatever the policy. Because the trim
|
||||
// could only take FREE wagons, the remaining allocations are still
|
||||
// inside the new pool — which is the structural reason "warn and leave
|
||||
// it" is a survivable policy here rather than a bug.
|
||||
withExportSched(DEPARTURE, (s) =>
|
||||
db<{ n: string }>(
|
||||
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||||
WHERE wt.code = $2
|
||||
AND wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||||
[s.id, POOL_TYPE.CNT],
|
||||
).then(({ rows }) =>
|
||||
expect(
|
||||
Number(rows[0].n),
|
||||
"container allocations still fit the reduced pool",
|
||||
).to.be.at.most(NEW_POOL),
|
||||
),
|
||||
);
|
||||
expectNoPoolLeak(DEPARTURE);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/** Physical container wagons on this train that are carrying cargo. */
|
||||
function loadedContainerWagons() {
|
||||
return withExportSchedChain().then((scheduleId) =>
|
||||
db<{ id: string }>(
|
||||
`SELECT DISTINCT tsw.physical_wagon_id AS id
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL
|
||||
AND tsw.physical_wagon_id IS NOT NULL`,
|
||||
[scheduleId],
|
||||
).then(({ rows }) => rows.map((r) => r.id)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `n` container wagons coupled to the train that NO booking holds.
|
||||
*
|
||||
* Deliberately free wagons only: a loaded wagon is refused by the endpoint
|
||||
* (409), so trimming those would test the guard rather than the policy — which
|
||||
* is what the GUARANTEE test above does, separately and on purpose.
|
||||
*/
|
||||
function freeContainerWagons(n: number) {
|
||||
return withExportSchedChain().then((scheduleId) =>
|
||||
db<{ id: string }>(
|
||||
`SELECT w.id
|
||||
FROM freight.wagons w
|
||||
JOIN freight.trains t ON t.id = w.train_id AND t.code = 'TRN-F2-EXP'
|
||||
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id AND wt.code = $2
|
||||
WHERE w.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_schedule_bookings tsb
|
||||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
WHERE tsw.physical_wagon_id = w.id
|
||||
AND wba.deleted_at IS NULL AND tsb.deleted_at IS NULL
|
||||
)
|
||||
ORDER BY w.wagon_number
|
||||
LIMIT $3`,
|
||||
[scheduleId, POOL_TYPE.CNT, n],
|
||||
).then(({ rows }) => rows.map((r) => r.id)),
|
||||
);
|
||||
}
|
||||
|
||||
/** The export schedule's id, as a chainable the helpers above can build on. */
|
||||
function withExportSchedChain() {
|
||||
return db<{ id: string }>(
|
||||
`SELECT ts.id
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 3600
|
||||
ORDER BY ts.created_at DESC LIMIT 1`,
|
||||
[EXP_ORIGIN, EXP_DEST, DEPARTURE.toISOString()],
|
||||
).then(({ rows }) => {
|
||||
expect(rows, "flow-two export schedule").to.have.length(1);
|
||||
return rows[0].id;
|
||||
});
|
||||
}
|
||||
694
e2e/freight/cypress/e2e/flows/g1-utils.ts
Normal file
694
e2e/freight/cypress/e2e/flows/g1-utils.ts
Normal 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",
|
||||
]),
|
||||
);
|
||||
});
|
||||
}
|
||||
363
e2e/freight/cypress/e2e/flows/g10_validation.cy.ts
Normal file
363
e2e/freight/cypress/e2e/flows/g10_validation.cy.ts
Normal 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 {};
|
||||
@@ -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 {};
|
||||
175
e2e/freight/cypress/e2e/flows/g1_s2_exact_fill.cy.ts
Normal file
175
e2e/freight/cypress/e2e/flows/g1_s2_exact_fill.cy.ts
Normal 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 {};
|
||||
@@ -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
Reference in New Issue
Block a user