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']); }); }); 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. 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', ]); // Two slots planned, but both drawn from the single physical wagon. expect(result.plan).toHaveLength(2); }); it('still defers when the legs overlap and stock is exhausted', () => { 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([ // Both ride edge 0 — they compete for the one wagon. ['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('never packs bookings with different legs into the same wagon slot', () => { // Two 20ft units with room to share one wagon by TEU — but disjoint legs // must open separate slots (each with its own leg), not one mixed slot. 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.plan).toHaveLength(2); const bookingsPerSlot = result.plan.map((s) => [...new Set(s.allocations.map((a) => a.bookingId))].sort(), ); expect(bookingsPerSlot).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); }); });