import { Booking } from '../bookings/entities/booking.entity'; import { BookingBatchService } from './booking-batch.service'; import { WagonStockLedger } from './wagon-stock-ledger.util'; /** * smartBulkNeed math in isolation: the private helpers it touches * (allowedDimsWithTypes) read only their arguments, so a bare prototype * instance is enough — no Nest wiring. */// describe('BookingBatchService.smartBulkNeed', () => { const service = Object.create(BookingBatchService.prototype) as BookingBatchService; const call = ( booking: Booking, stock: WagonStockLedger, rank: Map, ) => ( service as unknown as { smartBulkNeed: ( b: Booking, d: unknown, s: WagonStockLedger, l: { fromEdge: number; toEdge: number }, r: Map, ids: readonly string[], ) => { need: { wagons: number }; perType: Array<{ wagonTypeId: string; wagons: number }> } | null; } ).smartBulkNeed(booking, wagonDims, stock, { fromEdge: 0, toEdge: 1 }, rank, allowedIds); const nw5 = { id: 'wt-nw5', capacityTons: 70 }; const pw2 = { id: 'wt-pw2', capacityTons: 70 }; // Shaped like a BATCH POOL entity: cargoType WITHOUT the wagonTypes // relation (the pool query never joins it) — allowed types must come from // the ids parameter, or every pool bulk booking reads as unseatable. const perishable = { id: 'cargo-perishable', tonsPerWagonMap: { [nw5.id]: 30, [pw2.id]: 20 }, }; const allowedIds = [nw5.id, pw2.id]; const wagonDims = { container: { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }, bulk: { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }, byWagonTypeId: new Map([ [nw5.id, { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }], [pw2.id, { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }], ]), }; const booking = (tons: number): Booking => ({ id: 'b1', reference: 'b1', freightType: 'BULK', cargoTotalWeightVgm: tons, cargoTypeId: perishable.id, cargoType: perishable, bookingContainers: [], }) as unknown as Booking; // Containers compete for NW5 → NW5 rank 2, PW2 rank 1. const contested = new Map([ [nw5.id, 2], [pw2.id, 1], ]); it('seats 695T as 10 PW2 (20T) + 17 NW5 (30T) = 27 wagons, PW2 first', () => { const stock = new WagonStockLedger( new Map([ [nw5.id, 18], [pw2.id, 10], ]), 1, ); const smart = call(booking(695), stock, contested); expect(smart).not.toBeNull(); expect(smart!.need.wagons).toBe(27); expect(smart!.perType).toEqual([ { wagonTypeId: pw2.id, wagons: 10 }, { wagonTypeId: nw5.id, wagons: 17 }, ]); }); it('returns null when the free stock cannot seat the whole booking', () => { const stock = new WagonStockLedger( new Map([ [nw5.id, 5], [pw2.id, 10], ]), 1, ); // 10×20 + 5×30 = 350T < 695T. expect(call(booking(695), stock, contested)).toBeNull(); }); it('S-2026-00020 shape: 42x40ft eat the NW5, 200T bulk still seats on the 10 coupled PW2', () => { // The staging complaint: a built train of 42 NW5 + 10 PW2, containers // hold every NW5, and a bulk booking sits in "Ready for batch" while the // PW2 ride empty. The chain: committed containers drain NW5 from the // ledger (their types cannot touch PW2), then the smart gate must seat // 200T of Perishable on the 10 PW2 at the 20T cap. const stock = new WagonStockLedger( new Map([ [nw5.id, 42], [pw2.id, 10], ]), 2, // DCT -> Dire -> GMP: two edges ); // Committed container booking rides Dire->GMP (edge 1) on 42 NW5 — // container-capable types only, exactly how stockLedgerFor debits it. stock.consume([nw5.id], 42, { fromEdge: 1, toEdge: 2 }); expect(stock.availableFor([nw5.id], { fromEdge: 0, toEdge: 2 })).toBe(0); expect(stock.availableFor([pw2.id], { fromEdge: 0, toEdge: 2 })).toBe(10); const smart = ( service as unknown as { smartBulkNeed: ( b: Booking, d: unknown, s: WagonStockLedger, l: { fromEdge: number; toEdge: number }, r: Map, ids: readonly string[], ) => { need: { wagons: number }; perType: Array<{ wagonTypeId: string; wagons: number }> } | null; } ).smartBulkNeed(booking(200), wagonDims, stock, { fromEdge: 0, toEdge: 2 }, contested, allowedIds); expect(smart).not.toBeNull(); expect(smart!.perType).toEqual([{ wagonTypeId: pw2.id, wagons: 10 }]); }); it('uncontested types fall back to biggest per-cargo take (fewest wagons)', () => { const stock = new WagonStockLedger( new Map([ [nw5.id, 10], [pw2.id, 10], ]), 1, ); const even = new Map([ [nw5.id, 1], [pw2.id, 1], ]); const smart = call(booking(60), stock, even); expect(smart!.perType).toEqual([{ wagonTypeId: nw5.id, wagons: 2 }]); }); });