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 order and renumbers sequenceNo 1..N when the flag is true', () => { const reversed = applyWagonOrderReversal(plan, true); // Physically-last wagon (was seq 3, wt-c) is now position 1. expect(reversed.map((s) => s.wagonTypeId)).toEqual(['wt-c', 'wt-b', 'wt-a']); expect(reversed.map((s) => s.sequenceNo)).toEqual([1, 2, 3]); }); 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. expect(reversed[0].sequenceNo).toBe(1); expect( (reversed[0].allocations as { bookingId: string }[])[0].bookingId, ).toBe('BKG-C'); expect( (reversed[2].allocations as { bookingId: string }[])[0].bookingId, ).toBe('BKG-A'); }); 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']); }); });