import { AllocationLoadType } from '@edr/types'; import { Booking } from '../../bookings/entities/booking.entity'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { buildBulkWagonPlan, buildContainerWagonPlan, buildMixedWagonPlan, containerWagonsForLines, expandBookingContainerUnits, expandContainerItems, maxEdgeConsistUsage, roundTons, sumWagonsRequired, validate20ftContainerRules, validateContainerPlacements, validateMixedTrainLimitsPerEdge, validateWagonCargoExclusivity, } from './wagon-plan.util'; const nw5: WagonType = { id: 'wt-nw5', code: 'NW5', 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 makeContainerBooking = ( id: string, lines: Array<{ quantity: number; wagonsRequired: number; vgmPerUnitTons?: number }>, ): Booking => ({ id, reference: id, freightType: 'CONTAINER', cargoTotalWeightVgm: lines.reduce( (sum, line) => sum + line.quantity * (line.vgmPerUnitTons ?? 25), 0, ), bookingContainers: lines.map((line, index) => ({ id: `${id}-line-${index}`, containerTypeId: `ct-${index}`, quantity: line.quantity, wagonsRequired: line.wagonsRequired, vgmPerUnitTons: line.vgmPerUnitTons ?? 25, })), }) as Booking; describe('wagon-plan.util', () => { it('uses slot-based planning: 2×20ft = 1 wagon slot', () => { const booking = makeContainerBooking('b1', [{ quantity: 2, wagonsRequired: 1 }]); const plan = buildContainerWagonPlan([booking], nw5); expect(plan).toHaveLength(1); expect(plan[0]?.allocations[0]?.loadType).toBe(AllocationLoadType.Container); }); it('uses slot-based planning: 1×40ft = 1 wagon slot', () => { const booking = makeContainerBooking('b2', [{ quantity: 1, wagonsRequired: 1 }]); const plan = buildContainerWagonPlan([booking], nw5); expect(plan).toHaveLength(1); }); it('sums wagons across multiple container lines', () => { const booking = makeContainerBooking('b3', [ { quantity: 2, wagonsRequired: 1 }, { quantity: 1, wagonsRequired: 1 }, ]); expect(sumWagonsRequired(booking)).toBe(2); const plan = buildContainerWagonPlan([booking], nw5); expect(plan).toHaveLength(2); }); it('counts a bulk booking\'s wagons from the plan, not a flat 1', () => { // 700T of sugar on 60T CW3 gondolas = 12 wagons; the stored wagonsRequired // must carry all of them so gross weight charges 12 tares downstream. const booking = { id: 'bulk-700', reference: 'bulk-700', freightType: 'BULK', cargoTotalWeightVgm: 700, bookingContainers: [], } as unknown as Booking; const plan = buildBulkWagonPlan([booking], cw3); expect(plan).toHaveLength(12); expect(sumWagonsRequired(booking, plan)).toBe(12); // Without a plan the pre-plan fallback still applies. expect(sumWagonsRequired(booking)).toBe(1); }); it('counts container wagons from the plan TEU packing', () => { const booking = makeContainerBooking('c-plan', [{ quantity: 6, wagonsRequired: 3 }]); const plan = buildContainerWagonPlan([booking], nw5); expect(sumWagonsRequired(booking, plan)).toBe(3); }); it('6×20ft containers = 3 wagon slots (2 per wagon)', () => { // 20ft containers take half a wagon each, so 6 * 0.5 = 3 wagons const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]); expect(sumWagonsRequired(booking)).toBe(3); const plan = buildContainerWagonPlan([booking], nw5); expect(plan).toHaveLength(3); // Verify sequence numbers are 1, 2, 3 expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]); }); it('expands container items per quantity', () => { const booking = makeContainerBooking('b4', [{ quantity: 3, wagonsRequired: 3 }]); const items = expandContainerItems(booking, 'alloc-1'); expect(items).toHaveLength(3); expect(items[0]?.wagonBookingAllocationId).toBe('alloc-1'); }); it('rounds tons to three decimal places', () => { expect(roundTons(1.23456)).toBe(1.235); expect(roundTons('bad')).toBe(0); }); it('builds mixed plan with container block before bulk', () => { const containerBooking = makeContainerBooking('c1', [{ quantity: 2, wagonsRequired: 2 }]); const bulkBooking = { id: 'b1', reference: 'BKG-BULK', freightType: 'BULK', cargoTotalWeightVgm: 120, bookingContainers: [], } as unknown as Booking; const plan = buildMixedWagonPlan([containerBooking], [bulkBooking], nw5, cw3); expect(plan).toHaveLength(4); expect(plan[0]?.slotLoadType).toBe('CONTAINER'); expect(plan[2]?.slotLoadType).toBe('BULK'); expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3, 4]); }); it('expands booking container units for UI rows', () => { const booking = makeContainerBooking('c2', [{ quantity: 3, wagonsRequired: 3 }]); const units = expandBookingContainerUnits([booking]); expect(units).toHaveLength(3); expect(units[1]?.unitIndex).toBe(1); expect(units[1]?.bookingContainerId).toBe('c2-line-0'); }); it('validates required placements per container unit', () => { const booking = makeContainerBooking('c3', [{ quantity: 2, wagonsRequired: 2 }]); const plan = buildContainerWagonPlan([booking], nw5); const violations = validateContainerPlacements([booking], plan, []); expect(violations.some((v) => v.includes('required'))).toBe(true); const units = expandBookingContainerUnits([booking]); const placements = units.map((unit, index) => ({ bookingContainerId: unit.bookingContainerId, unitIndex: unit.unitIndex, sequenceNo: plan[index]?.sequenceNo ?? 1, containerNumber: `CNTR-${index + 1}`, })); expect(validateContainerPlacements([booking], plan, placements)).toEqual([]); }); it('rejects a container over its line weight-limit-rule capacity', () => { const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]); const units = expandBookingContainerUnits([booking]); const placements = units.map((unit, index) => ({ bookingContainerId: unit.bookingContainerId, unitIndex: unit.unitIndex, sequenceNo: 1, containerNumber: `CNTR-${index + 1}`, })); const violations = validate20ftContainerRules(units, placements, { maxContainerWeightTonsByLineId: { [units[0]!.bookingContainerId]: 30 }, max20ftPairWeightDiffTons: 10, }); expect(violations.filter((v) => v.includes('weight limit rule capacity of 30T'))).toHaveLength(2); }); it('applies no per-box ceiling to a line without a weight-limit-rule capacity', () => { const booking = makeContainerBooking('c20b', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]); const units = expandBookingContainerUnits([booking]); const placements = units.map((unit, index) => ({ bookingContainerId: unit.bookingContainerId, unitIndex: unit.unitIndex, sequenceNo: 1, containerNumber: `CNTR-${index + 1}`, })); const violations = validate20ftContainerRules(units, placements, { maxContainerWeightTonsByLineId: {}, max20ftPairWeightDiffTons: 10, }); expect(violations).toEqual([]); }); it('rejects 20ft pair when weight difference exceeds limit', () => { const booking = makeContainerBooking('c21', [ { quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 }, ]); booking.bookingContainers![0]!.vgmPerUnitTons = 25; const units = expandBookingContainerUnits([booking]); units[1]!.grossWeightTons = 10; const placements = units.map((unit) => ({ bookingContainerId: unit.bookingContainerId, unitIndex: unit.unitIndex, sequenceNo: 1, containerNumber: `CNTR-${unit.unitIndex}`, })); const violations = validate20ftContainerRules(units, placements, { max20ftPairWeightDiffTons: 10, }); expect(violations.some((v) => v.includes('weight difference'))).toBe(true); }); it('builds bulk-only plan as degenerate mixed case', () => { const bulkBooking = { id: 'b2', reference: 'BKG-BULK-2', freightType: 'BULK', cargoTotalWeightVgm: 60, bookingContainers: [], } as unknown as Booking; const plan = buildMixedWagonPlan([], [bulkBooking], nw5, cw3); expect(plan).toHaveLength(1); expect(plan[0]?.slotLoadType).toBe('BULK'); expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1); }); it('never pools two bulk bookings on one wagon', () => { // 5T + 40T both fit a single 60T CW3 by tonnage — but a wagon with bulk // takes that one load only, so each booking gets its own wagon. const small = { id: 'bulk-5', reference: 'bulk-5', freightType: 'BULK', cargoTotalWeightVgm: 5, bookingContainers: [], } as unknown as Booking; const other = { id: 'bulk-40', reference: 'bulk-40', freightType: 'BULK', cargoTotalWeightVgm: 40, bookingContainers: [], } as unknown as Booking; const plan = buildBulkWagonPlan([small, other], cw3); expect(plan).toHaveLength(2); for (const slot of plan) { expect(slot.allocations).toHaveLength(1); } expect(plan[0]?.allocations[0]?.bookingId).toBe('bulk-5'); expect(plan[1]?.allocations[0]?.bookingId).toBe('bulk-40'); expect(validateWagonCargoExclusivity(plan)).toEqual([]); }); it('a multi-wagon bulk booking still spreads over its own wagons', () => { const big = { id: 'bulk-130', reference: 'bulk-130', freightType: 'BULK', cargoTotalWeightVgm: 130, bookingContainers: [], } as unknown as Booking; const plan = buildBulkWagonPlan([big], cw3); expect(plan).toHaveLength(3); expect(plan.map((s) => s.allocations[0]?.allocatedWeightTons)).toEqual([60, 60, 10]); }); it('flags a wagon mixing bulk with anything else', () => { const bulkAlloc = { bookingId: 'b', bookingReference: 'b', allocatedWeightTons: 5, loadType: AllocationLoadType.Bulk, }; const containerAlloc = { bookingId: 'c', bookingReference: 'c', allocatedWeightTons: 25, loadType: AllocationLoadType.Container, }; const slot = (allocations: (typeof bulkAlloc)[]) => ({ sequenceNo: 1, wagonTypeId: cw3.id, wagonTypeCode: cw3.code, capacityTons: 60, lengthMeters: 14, tareWeightTons: 24, assignedWeightTons: 0, allocations, }); // bulk + container on one wagon expect(validateWagonCargoExclusivity([slot([bulkAlloc, containerAlloc])])) .toHaveLength(1); // bulk + bulk on one wagon expect( validateWagonCargoExclusivity([slot([bulkAlloc, { ...bulkAlloc, bookingId: 'b2' }])]), ).toHaveLength(1); // bulk alone, and containers sharing, are fine expect(validateWagonCargoExclusivity([slot([bulkAlloc])])).toEqual([]); expect( validateWagonCargoExclusivity([ slot([containerAlloc, { ...containerAlloc, bookingId: 'c2' }]), ]), ).toEqual([]); }); }); describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => { const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({ quantity, wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit, containerType: { sizeFt: wagonsPerUnit >= 1 ? 40 : 20 }, }); it('20×20ft = 10 wagons (not 20)', () => { expect(containerWagonsForLines([line(20, 0.5)])).toBe(10); }); it('38×20ft = 19 wagons', () => { expect(containerWagonsForLines([line(38, 0.5)])).toBe(19); }); it('2×20ft = 1 wagon', () => { expect(containerWagonsForLines([line(2, 0.5)])).toBe(1); }); it('odd 3×20ft = 2 wagons (single line ceils)', () => { expect(containerWagonsForLines([line(3, 0.5)])).toBe(2); }); it('3×20ft + 3×20ft = 3 wagons (ceil TOTAL, not per line)', () => { // per-line ceil would give 2 + 2 = 4; the booking total is ceil(1.5+1.5)=3. expect(containerWagonsForLines([line(3, 0.5), line(3, 0.5)])).toBe(3); }); it('three 1×20ft lines = 2 wagons (ceil TOTAL)', () => { // per-line ceil would give 1+1+1 = 3; total is ceil(0.5*3)=ceil(1.5)=2. expect( containerWagonsForLines([line(1, 0.5), line(1, 0.5), line(1, 0.5)]), ).toBe(2); }); it('5×20ft + 2×40ft = 5 wagons', () => { expect(containerWagonsForLines([line(5, 0.5), line(2, 1)])).toBe(5); }); it('21×40ft = 21 wagons', () => { expect(containerWagonsForLines([line(21, 1)])).toBe(21); }); it('falls back to line wagonsRequired when containerType/sizeFt missing', () => { // No containerType relation loaded → use the stored (0.5-aware) fraction. expect( containerWagonsForLines([ { quantity: 20, wagonsRequired: 10 } as never, ]), ).toBe(10); }); it('empty line set = 0 wagons', () => { expect(containerWagonsForLines([])).toBe(0); }); }); describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () => { const slot = ( tare: number, cargo: number, length: number, board?: string | null, alight?: string | null, ) => ({ tareWeightTons: tare, assignedWeightTons: cargo, lengthMeters: length, boardYardId: board ?? null, alightYardId: alight ?? null, }) as never; const stops = ['a', 'b', 'c']; it('does not sum disjoint legs: intercity a→b + export b→c', () => { const plan = [ slot(24, 65, 14, null, 'b'), // intercity, rides a→b only slot(24, 65, 14, 'b', null), // export, rides b→c only ]; // Each edge carries one slot: 89T gross / 14m — never 178T. expect(maxEdgeConsistUsage(plan, stops)).toEqual({ grossWeightTons: 89, lengthMeters: 14, loadedWagonCount: 1, }); }); it('sums overlapping legs on their shared edge (the S-2026-00024 shape)', () => { // 20 intercity a→b wagons + 20 export a→c wagons, 23.94T tare, 64.75T cargo: // shared edge a→b carries all 40 slots = 3547.6T gross. const plan = [ ...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, 'b')), ...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, null)), ]; const usage = maxEdgeConsistUsage(plan, stops); expect(usage.grossWeightTons).toBeCloseTo(3547.6, 1); expect(usage.lengthMeters).toBe(560); }); it('degrades to whole-train totals on a two-stop route', () => { const plan = [slot(24, 65, 14), slot(24, 65, 14)]; expect(maxEdgeConsistUsage(plan, ['a', 'b'])).toEqual({ grossWeightTons: 178, lengthMeters: 28, loadedWagonCount: 2, }); }); it('with a legs map, shared-slot cargo weighs only its own edges (the S-2026-00045 shape)', () => { // One wagon reused across legs: booking X rides a→b (40T), booking Y // boards at b with 30T. The slot spans the whole route, but edge a→b // must weigh 24 + 40 = 64T — not 24 + 70. Tare rides both edges. const shared = { tareWeightTons: 24, assignedWeightTons: 70, lengthMeters: 14, boardYardId: null, alightYardId: null, allocations: [ { bookingId: 'X', allocatedWeightTons: 40 }, { bookingId: 'Y', allocatedWeightTons: 30 }, ], } as never; const legs = new Map([ ['X', { from: 0, to: 1 }], ['Y', { from: 1, to: 2 }], ]); // Without legs: whole-span scalar on both edges (94T binding edge). expect(maxEdgeConsistUsage([shared], stops).grossWeightTons).toBe(94); // With legs: heaviest edge is a→b at 64T (b→c is 54T). expect(maxEdgeConsistUsage([shared], stops, legs).grossWeightTons).toBe(64); }); it('falls back to the whole-span scalar when an allocation has no readable weight', () => { const shared = { tareWeightTons: 24, assignedWeightTons: 70, lengthMeters: 14, boardYardId: null, alightYardId: null, allocations: [{ bookingId: 'X' }], } as never; const legs = new Map([['X', { from: 0, to: 1 }]]); expect(maxEdgeConsistUsage([shared], stops, legs).grossWeightTons).toBe(94); }); }); describe('validateMixedTrainLimitsPerEdge — leg-aware cargo weighing', () => { it('does not flag a leg whose overweight is only later-boarding cargo (S-2026-00045)', () => { // 2 shared wagons, 100T cap. Booking X rides a→b with 30T/wagon, booking Y // boards at b with 25T/wagon. Whole-span scalars read every edge as // 2×(20 + 55) = 150T > 100T; the cargo actually aboard is 100T (a→b) and // 90T (b→c) — both fit. const slot = (seq: number) => ({ sequenceNo: seq, wagonTypeId: 'wt-nw5', wagonTypeCode: 'NW5', capacityTons: 70, lengthMeters: 14, tareWeightTons: 20, assignedWeightTons: 55, boardYardId: null, alightYardId: null, allocations: [ { bookingId: 'X', bookingReference: 'X', allocatedWeightTons: 30, loadType: AllocationLoadType.Container, }, { bookingId: 'Y', bookingReference: 'Y', allocatedWeightTons: 25, loadType: AllocationLoadType.Container, }, ], }); const legs = new Map([ ['X', { from: 0, to: 1 }], ['Y', { from: 1, to: 2 }], ]); const run = (withLegs?: typeof legs) => validateMixedTrainLimitsPerEdge( [slot(1), slot(2)] as never, [{ lengthMeters: 14 }], { maxWeightTons: 100 }, ['a', 'b', 'c'], undefined, withLegs, ); expect(run()).toHaveLength(2); // both edges falsely overweight without legs expect(run(legs)).toHaveLength(0); }); });