Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts
2026-08-21 23:16:06 +00:00

602 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
applyWagonOrderReversal,
planWagonsWithStock,
} from './wagon-plan-flex.util';
import type { WagonPlanSlot } from './utils/wagon-plan.util';
const nw6: WagonType = {
id: 'wt-nw6',
code: 'NW6',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
} as WagonType;
const cw3: WagonType = {
id: 'wt-cw3',
code: 'CW3',
name: 'Covered Wagon',
capacityTons: 60,
lengthMeters: 14,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,
} as WagonType;
const containerBooking = (id: string, quantity: number, wagonsRequired: number): Booking =>
({
id,
reference: id,
freightType: 'CONTAINER',
cargoTotalWeightVgm: quantity * 25,
bookingContainers: [
{
id: `${id}-line-0`,
containerTypeId: 'ct-1',
quantity,
wagonsRequired,
vgmPerUnitTons: 25,
},
],
}) as Booking;
describe('planWagonsWithStock — shortage detail', () => {
it('defers with a structured per-type shortage when container stock runs out', () => {
const result = planWagonsWithStock({
bookings: [containerBooking('BKG-1', 2, 1)],
allowed: {
byContainerTypeId: new Map([['ct-1', [nw6]]]),
byCargoTypeId: new Map(),
},
stock: {
mode: 'YARD',
remainingByTypeId: new Map([[nw6.id, 0]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
});
expect(result.fitting).toHaveLength(0);
expect(result.deferred).toHaveLength(1);
const row = result.deferred[0]!;
expect(row.reference).toBe('BKG-1');
expect(row.reason).toContain('No available NW6 wagon at the yard');
expect(row.reason).toContain('short 1');
expect(row.shortage).toEqual({
wagonTypeCodes: 'NW6',
wagonsNeeded: 1,
wagonsAvailable: 0,
wagonsShort: 1,
});
});
it('counts the stock the deferred booking actually saw, not its rolled-back usage', () => {
// Two wagons needed (2 × 40ft), one in stock: booking rolls back entirely,
// the shortage reports 1 available / 1 short.
const fortyFooter = containerBooking('BKG-2', 2, 2);
fortyFooter.bookingContainers![0]!.containerType = {
code: '40GP',
sizeFt: 40,
} as never;
const result = planWagonsWithStock({
bookings: [fortyFooter],
allowed: {
byContainerTypeId: new Map([['ct-1', [nw6]]]),
byCargoTypeId: new Map(),
},
stock: {
mode: 'YARD',
remainingByTypeId: new Map([[nw6.id, 1]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
});
expect(result.deferred).toHaveLength(1);
expect(result.deferred[0]?.shortage).toEqual({
wagonTypeCodes: 'NW6',
wagonsNeeded: 2,
wagonsAvailable: 1,
wagonsShort: 1,
});
// The rolled-back wagon is plannable again for later bookings.
expect(result.plan).toHaveLength(0);
});
it('leaves shortage unset for configuration problems', () => {
const bulkBooking = {
id: 'BKG-3',
reference: 'BKG-3',
freightType: 'BULK',
cargoTotalWeightVgm: 40,
cargoTypeId: 'cargo-1',
cargoType: { id: 'cargo-1', cargoTypeName: 'Fertilizer' },
bookingContainers: [],
} as unknown as Booking;
const result = planWagonsWithStock({
bookings: [bulkBooking],
allowed: {
byContainerTypeId: new Map(),
byCargoTypeId: new Map(), // no wagon types configured → config issue
},
stock: {
mode: 'YARD',
remainingByTypeId: new Map([[cw3.id, 5]]),
codesByTypeId: new Map([[cw3.id, cw3.code]]),
},
});
expect(result.configIssues).toHaveLength(1);
expect(result.deferred[0]?.shortage).toBeNull();
});
});
describe('applyWagonOrderReversal', () => {
const slot = (
seq: number,
wagonTypeId: string,
bookingId: string,
): WagonPlanSlot =>
({
sequenceNo: seq,
wagonTypeId,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 25,
allocations: [{ bookingId }],
}) as unknown as WagonPlanSlot;
const plan: WagonPlanSlot[] = [
slot(1, 'wt-a', 'BKG-A'),
slot(2, 'wt-b', 'BKG-B'),
slot(3, 'wt-c', 'BKG-C'),
];
it('returns the plan unchanged when the flag is false/absent', () => {
expect(applyWagonOrderReversal(plan, false)).toBe(plan);
expect(applyWagonOrderReversal(plan, undefined)).toBe(plan);
expect(applyWagonOrderReversal(plan, null)).toBe(plan);
});
it('flips the position numbers when the flag is true', () => {
const reversed = applyWagonOrderReversal(plan, true);
// Physically-last wagon (wt-c) is now position 1.
expect(reversed.map((s) => s.sequenceNo)).toEqual([3, 2, 1]);
});
it('keeps each booking with its own wagon — only the position changes', () => {
const reversed = applyWagonOrderReversal(plan, true);
// The booking that was in the last wagon now sits at sequenceNo 1.
const atPosition1 = reversed.find((s) => s.sequenceNo === 1);
expect(
(atPosition1?.allocations as { bookingId: string }[])[0].bookingId,
).toBe('BKG-C');
const atPosition3 = reversed.find((s) => s.sequenceNo === 3);
expect(
(atPosition3?.allocations as { bookingId: string }[])[0].bookingId,
).toBe('BKG-A');
});
// The regression that emptied every reversed train's container items: the
// placement generators pair unit k (booking order) with slot k of this array,
// and persistAllocationsAndLoads matches that sequenceNo against the
// allocation's booking. Array order must stay packing order.
it('keeps array order aligned with booking order so placements still match', () => {
const reversed = applyWagonOrderReversal(plan, true);
expect(
reversed.map((s) => (s.allocations as { bookingId: string }[])[0].bookingId),
).toEqual(['BKG-A', 'BKG-B', 'BKG-C']);
});
it('does not mutate the input plan', () => {
applyWagonOrderReversal(plan, true);
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
expect(plan.map((s) => s.wagonTypeId)).toEqual(['wt-a', 'wt-b', 'wt-c']);
});
});
describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => {
const allowed = {
byContainerTypeId: new Map([['ct-1', [nw6]]]),
byCargoTypeId: new Map(),
};
// Corridor Gelan(0) → Adama(1) → Doraleh(2): edges 0 and 1.
const legs = (entries: Array<[string, { from: number; to: number }]>) =>
new Map(entries);
it('lets an intercity booking ride the empty leg of a train that is full on the other leg', () => {
// 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only:
// the intercity 20ft alights where the export 20ft boards, so both share
// the single physical wagon (cross-leg TEU sharing).
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
containerBooking('INTERCITY-1', 1, 1),
],
allowed,
stock: {
mode: 'TRAIN',
remainingByTypeId: new Map([[nw6.id, 1]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
legs: legs([
['EXPORT-1', { from: 1, to: 2 }],
['INTERCITY-1', { from: 0, to: 1 }],
]),
edgeCount: 2,
});
expect(result.deferred).toHaveLength(0);
expect(result.fitting.map((b) => b.id).sort()).toEqual([
'EXPORT-1',
'INTERCITY-1',
]);
expect(result.plan).toHaveLength(1);
});
it('still defers when the wagon has no per-edge TEU room and stock is exhausted', () => {
// Export is a 40ft (2 TEU) riding the whole corridor — no edge has room
// for the intercity 20ft, and there is no second wagon to open.
const fortyFooter = containerBooking('EXPORT-1', 1, 1);
fortyFooter.bookingContainers![0]!.containerType = {
code: '40GP',
sizeFt: 40,
} as never;
const result = planWagonsWithStock({
bookings: [fortyFooter, containerBooking('INTERCITY-1', 1, 1)],
allowed,
stock: {
mode: 'TRAIN',
remainingByTypeId: new Map([[nw6.id, 1]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
legs: legs([
['EXPORT-1', { from: 0, to: 2 }],
['INTERCITY-1', { from: 0, to: 1 }],
]),
edgeCount: 2,
});
expect(result.fitting.map((b) => b.id)).toEqual(['EXPORT-1']);
expect(result.deferred).toHaveLength(1);
expect(result.deferred[0]!.reference).toBe('INTERCITY-1');
expect(result.deferred[0]!.reason).toContain('Train has no free NW6 wagon left');
});
it('packs disjoint-leg 20fts onto one wagon instead of appending a second', () => {
// Two 20ft units, two wagons in stock — cross-leg TEU sharing still fills
// the open wagon (span grows to the union) rather than opening wagon #2.
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
containerBooking('INTERCITY-1', 1, 1),
],
allowed,
stock: {
mode: 'TRAIN',
remainingByTypeId: new Map([[nw6.id, 2]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
legs: legs([
['EXPORT-1', { from: 1, to: 2 }],
['INTERCITY-1', { from: 0, to: 1 }],
]),
edgeCount: 2,
});
expect(result.deferred).toHaveLength(0);
expect(result.plan).toHaveLength(1);
const bookingsInSlot = [
...new Set(result.plan[0]!.allocations.map((a) => a.bookingId)),
].sort();
expect(bookingsInSlot).toEqual(['EXPORT-1', 'INTERCITY-1']);
});
it('behaves exactly like the whole-route planner when no legs are given', () => {
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
containerBooking('INTERCITY-1', 1, 1),
],
allowed,
stock: {
mode: 'TRAIN',
remainingByTypeId: new Map([[nw6.id, 1]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
});
// One wagon, two 20ft bookings: they TEU-share the single slot (legacy).
expect(result.deferred).toHaveLength(0);
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]);
});
});
describe('planWagonsWithStock — consist split across yards', () => {
const GMP = 'gmp', MOJO = 'mojo', DCT = 'dct';
const boards = (id: string, quantity: number, originYardId: string): Booking =>
({ ...containerBooking(id, quantity, quantity), originYardId, destinationYardId: DCT }) as Booking;
const allowed = { byContainerTypeId: new Map([['ct-1', [nw6]]]), byCargoTypeId: new Map() };
const legsFor = (bookings: Booking[]) =>
new Map(bookings.map((b) => [b.id, { from: b.originYardId === GMP ? 0 : 1, to: 2 }]));
const splitStock = {
mode: 'TRAIN' as const,
remainingByTypeId: new Map([[nw6.id, 46]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
byYardId: new Map([
[GMP, new Map([[nw6.id, 31]])],
[MOJO, new Map([[nw6.id, 15]])],
]),
};
it('seats a boarding yard only from the wagons planned there', () => {
// 20fts pack two per wagon: BKG-A's 30 boxes take all 15 Mojo wagons;
// BKG-B needs 2 more at Mojo → deferred, while BKG-C at Gelan still fits
// (the whole-train 46 is irrelevant).
const bookings = [boards('BKG-A', 30, MOJO), boards('BKG-B', 4, MOJO), boards('BKG-C', 2, GMP)];
const result = planWagonsWithStock({
bookings, allowed, stock: splitStock, legs: legsFor(bookings), edgeCount: 2, stops: [GMP, MOJO, DCT],
});
expect(result.fitting.map((b) => b.id)).toEqual(['BKG-A', 'BKG-C']);
expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-B']);
expect(result.deferred[0]!.reason).toContain('planned at the boarding yard');
expect(result.plan).toHaveLength(16);
});
it('never lets a Gelan 20ft share a wagon that only exists at Mojo', () => {
const stock = {
...splitStock,
remainingByTypeId: new Map([[nw6.id, 1]]),
byYardId: new Map([[MOJO, new Map([[nw6.id, 1]])]]),
};
const bookings = [boards('BKG-M', 1, MOJO), boards('BKG-G', 1, GMP)];
const result = planWagonsWithStock({
bookings, allowed, stock, legs: legsFor(bookings), edgeCount: 2, stops: [GMP, MOJO, DCT],
});
// The Mojo wagon has TEU room, but it is not standing in Gelan.
expect(result.fitting.map((b) => b.id)).toEqual(['BKG-M']);
expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-G']);
});
});
describe('planWagonsWithStock — scarcity-aware bulk (one booking per wagon, capped fill)', () => {
// The S-2026-00044 shape: Perishable rides NW5 (30T cap) or PW2 (20T cap);
// containers ride only NW5. NW5 is the shared, scarce type.
const nw5: WagonType = {
id: 'wt-nw5',
code: 'NW5',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
} as WagonType;
const pw2: WagonType = {
id: 'wt-pw2',
code: 'PW2',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,
} as WagonType;
const perishable = {
id: 'cargo-perishable',
cargoTypeName: 'Perishable',
wagonTypes: [nw5, pw2],
tonsPerWagonMap: { [nw5.id]: 30, [pw2.id]: 20 },
};
const bulkBooking = (id: string, tons: number): Booking =>
({
id,
reference: id,
freightType: 'BULK',
cargoTotalWeightVgm: tons,
cargoTypeId: perishable.id,
cargoType: perishable,
bookingContainers: [],
}) as unknown as Booking;
const allowed = {
byContainerTypeId: new Map([['ct-1', [nw5]]]),
byCargoTypeId: new Map([[perishable.id, [nw5, pw2]]]),
};
const stockOf = (nw5Count: number, pw2Count: number) => ({
mode: 'YARD' as const,
remainingByTypeId: new Map([
[nw5.id, nw5Count],
[pw2.id, pw2Count],
]),
codesByTypeId: new Map([
[nw5.id, nw5.code],
[pw2.id, pw2.code],
]),
});
it('fills the bulk-only PW2s first when containers compete for NW5', () => {
// 695T Perishable + one 40ft container. Smart split: 10 PW2 × 20T = 200T,
// remainder 495T → 17 NW5 × 30T. The container still gets an NW5.
const container = containerBooking('BKG-C', 1, 1);
container.bookingContainers![0]!.containerType = { code: '40GP', sizeFt: 40 } as never;
const result = planWagonsWithStock({
bookings: [bulkBooking('BKG-BULK', 695), container],
allowed,
stock: stockOf(18, 10),
});
expect(result.deferred).toEqual([]);
const bulkSlots = result.plan.filter((s) => s.slotLoadType === 'BULK');
expect(bulkSlots.filter((s) => s.wagonTypeCode === 'PW2')).toHaveLength(10);
expect(bulkSlots.filter((s) => s.wagonTypeCode === 'NW5')).toHaveLength(17);
// Capped fill: no PW2 slot above 20T, no NW5 bulk slot above 30T.
for (const slot of bulkSlots) {
expect(slot.assignedWeightTons).toBeLessThanOrEqual(
slot.wagonTypeCode === 'PW2' ? 20 : 30,
);
}
const containerSlots = result.plan.filter((s) => s.slotLoadType === 'CONTAINER');
expect(containerSlots).toHaveLength(1);
expect(containerSlots[0]?.wagonTypeCode).toBe('NW5');
});
it('prefers the bigger per-cargo take when nothing competes for the shared type', () => {
// Bulk alone (no containers in the run): NW5 30T beats PW2 20T — fewest
// wagons wins, PW2-first would waste consist length.
const result = planWagonsWithStock({
bookings: [bulkBooking('BKG-BULK', 60)],
allowed,
stock: stockOf(10, 10),
});
expect(result.deferred).toEqual([]);
expect(result.plan).toHaveLength(2);
expect(result.plan.every((s) => s.wagonTypeCode === 'NW5')).toBe(true);
});
it('never puts two bulk bookings on one wagon, even same cargo type', () => {
// 5T + 40T both fit one wagon's cap by tonnage — each still gets its own.
const result = planWagonsWithStock({
bookings: [bulkBooking('BKG-A', 5), bulkBooking('BKG-B', 40)],
allowed,
stock: stockOf(10, 0),
});
expect(result.deferred).toEqual([]);
expect(result.plan).toHaveLength(3); // 5T → 1 wagon; 40T @30 cap → 2 wagons
for (const slot of result.plan) {
expect(new Set(slot.allocations.map((a) => a.bookingId)).size).toBe(1);
}
});
});