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 './utils/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]); }); }); describe('planWagonsWithStock — consist split across yards', () => { const GMP = 'gmp', MOJO = 'mojo', DCT = 'dct'; const boards = (id: string, quantity: number, originYardId: string): Booking => ({ ...containerBooking(id, quantity, quantity), originYardId, destinationYardId: DCT }) as Booking; const allowed = { byContainerTypeId: new Map([['ct-1', [nw6]]]), byCargoTypeId: new Map() }; const legsFor = (bookings: Booking[]) => new Map(bookings.map((b) => [b.id, { from: b.originYardId === GMP ? 0 : 1, to: 2 }])); const splitStock = { mode: 'TRAIN' as const, remainingByTypeId: new Map([[nw6.id, 46]]), codesByTypeId: new Map([[nw6.id, nw6.code]]), byYardId: new Map([ [GMP, new Map([[nw6.id, 31]])], [MOJO, new Map([[nw6.id, 15]])], ]), }; it('seats a boarding yard only from the wagons planned there', () => { // 20fts pack two per wagon: BKG-A's 30 boxes take all 15 Mojo wagons; // BKG-B needs 2 more at Mojo → deferred, while BKG-C at Gelan still fits // (the whole-train 46 is irrelevant). const bookings = [boards('BKG-A', 30, MOJO), boards('BKG-B', 4, MOJO), boards('BKG-C', 2, GMP)]; const result = planWagonsWithStock({ bookings, allowed, stock: splitStock, legs: legsFor(bookings), edgeCount: 2, stops: [GMP, MOJO, DCT], }); expect(result.fitting.map((b) => b.id)).toEqual(['BKG-A', 'BKG-C']); expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-B']); expect(result.deferred[0]!.reason).toContain('planned at the boarding yard'); expect(result.plan).toHaveLength(16); }); it('never lets a Gelan 20ft share a wagon that only exists at Mojo', () => { const stock = { ...splitStock, remainingByTypeId: new Map([[nw6.id, 1]]), byYardId: new Map([[MOJO, new Map([[nw6.id, 1]])]]), }; const bookings = [boards('BKG-M', 1, MOJO), boards('BKG-G', 1, GMP)]; const result = planWagonsWithStock({ bookings, allowed, stock, legs: legsFor(bookings), edgeCount: 2, stops: [GMP, MOJO, DCT], }); // The Mojo wagon has TEU room, but it is not standing in Gelan. expect(result.fitting.map((b) => b.id)).toEqual(['BKG-M']); expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-G']); }); }); describe('planWagonsWithStock — scarcity-aware bulk (one booking per wagon, capped fill)', () => { // The S-2026-00044 shape: Perishable rides NW5 (30T cap) or PW2 (20T cap); // containers ride only NW5. NW5 is the shared, scarce type. const nw5: WagonType = { id: 'wt-nw5', code: 'NW5', name: 'Flat Wagon', capacityTons: 70, lengthMeters: 14, supportedLoadTypes: ['CONTAINER'], isActive: true, supportsContainer: true, } as WagonType; const pw2: WagonType = { id: 'wt-pw2', code: 'PW2', name: 'Flat Wagon', capacityTons: 70, lengthMeters: 14, supportedLoadTypes: ['BULK'], isActive: true, supportsContainer: false, } as WagonType; const perishable = { id: 'cargo-perishable', cargoTypeName: 'Perishable', wagonTypes: [nw5, pw2], tonsPerWagonMap: { [nw5.id]: 30, [pw2.id]: 20 }, }; const bulkBooking = (id: string, tons: number): Booking => ({ id, reference: id, freightType: 'BULK', cargoTotalWeightVgm: tons, cargoTypeId: perishable.id, cargoType: perishable, bookingContainers: [], }) as unknown as Booking; const allowed = { byContainerTypeId: new Map([['ct-1', [nw5]]]), byCargoTypeId: new Map([[perishable.id, [nw5, pw2]]]), }; const stockOf = (nw5Count: number, pw2Count: number) => ({ mode: 'YARD' as const, remainingByTypeId: new Map([ [nw5.id, nw5Count], [pw2.id, pw2Count], ]), codesByTypeId: new Map([ [nw5.id, nw5.code], [pw2.id, pw2.code], ]), }); it('fills the bulk-only PW2s first when containers compete for NW5', () => { // 695T Perishable + one 40ft container. Smart split: 10 PW2 × 20T = 200T, // remainder 495T → 17 NW5 × 30T. The container still gets an NW5. const container = containerBooking('BKG-C', 1, 1); container.bookingContainers![0]!.containerType = { code: '40GP', sizeFt: 40 } as never; const result = planWagonsWithStock({ bookings: [bulkBooking('BKG-BULK', 695), container], allowed, stock: stockOf(18, 10), }); expect(result.deferred).toEqual([]); const bulkSlots = result.plan.filter((s) => s.slotLoadType === 'BULK'); expect(bulkSlots.filter((s) => s.wagonTypeCode === 'PW2')).toHaveLength(10); expect(bulkSlots.filter((s) => s.wagonTypeCode === 'NW5')).toHaveLength(17); // Capped fill: no PW2 slot above 20T, no NW5 bulk slot above 30T. for (const slot of bulkSlots) { expect(slot.assignedWeightTons).toBeLessThanOrEqual( slot.wagonTypeCode === 'PW2' ? 20 : 30, ); } const containerSlots = result.plan.filter((s) => s.slotLoadType === 'CONTAINER'); expect(containerSlots).toHaveLength(1); expect(containerSlots[0]?.wagonTypeCode).toBe('NW5'); }); it('prefers the bigger per-cargo take when nothing competes for the shared type', () => { // Bulk alone (no containers in the run): NW5 30T beats PW2 20T — fewest // wagons wins, PW2-first would waste consist length. const result = planWagonsWithStock({ bookings: [bulkBooking('BKG-BULK', 60)], allowed, stock: stockOf(10, 10), }); expect(result.deferred).toEqual([]); expect(result.plan).toHaveLength(2); expect(result.plan.every((s) => s.wagonTypeCode === 'NW5')).toBe(true); }); it('never puts two bulk bookings on one wagon, even same cargo type', () => { // 5T + 40T both fit one wagon's cap by tonnage — each still gets its own. const result = planWagonsWithStock({ bookings: [bulkBooking('BKG-A', 5), bulkBooking('BKG-B', 40)], allowed, stock: stockOf(10, 0), }); expect(result.deferred).toEqual([]); expect(result.plan).toHaveLength(3); // 5T → 1 wagon; 40T @30 cap → 2 wagons for (const slot of result.plan) { expect(new Set(slot.allocations.map((a) => a.bookingId)).size).toBe(1); } }); });