mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 23:33:38 +00:00
602 lines
20 KiB
TypeScript
602 lines
20 KiB
TypeScript
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);
|
||
}
|
||
});
|
||
});
|