Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts
2026-08-02 10:26:36 +00:00

446 lines
14 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 './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]);
});
});