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 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(), 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]); }); });