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, expandBookingContainerUnits, expandContainerItems, roundTons, sumWagonsRequired, validate20ftContainerRules, validateContainerPlacements, } from './wagon-plan.util'; const nw5: WagonType = { id: 'wt-nw5', code: 'NW5', name: 'Flat Wagon', capacityTons: 70, lengthMeters: 14, maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, supportsContainer: true, } as WagonType; const cw3: WagonType = { id: 'wt-cw3', code: 'CW3', name: 'Covered Wagon', capacityTons: 60, lengthMeters: 14, maxWagonsPerTrain: 53, 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('6×20ft containers = 3 wagon slots (2 per wagon)', () => { // 20ft containers have wagonsPerUnit = 0.5, 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 20ft container over max individual weight', () => { 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, { max20ftContainerWeightTons: 30, max20ftPairWeightDiffTons: 10, }); expect(violations.some((v) => v.includes('exceeds max 30T'))).toBe(true); }); 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, { max20ftContainerWeightTons: 30, 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); }); });