import { RemainderPlacementService } from './remainder-placement.service'; /** * The remainder placer reconstructs the outstanding split remainder as a new * booking. The delicate parts under test: bulk sizes from the outstanding tons; * container recovers real numbers from the SOFT-DELETED units (never fabricates) * and throws on a shortfall; and nothing is placed when there's no outstanding * or no fitting train. */ describe('RemainderPlacementService', () => { const DAY = '2026-07-20'; function make(opts: { freightType: 'CONTAINER' | 'BULK'; contractKind?: 'ONE_TIME' | 'GENERAL'; outstanding: unknown; createThrows?: Error; deferredUnits?: Array<{ containerNumber: string; vgmTons: number; isHazardous?: boolean; isReefer?: boolean; }>; fittingTrains?: Array<{ scheduleId: string }>; }) { const contract = { id: 'c-1', freightType: opts.freightType, contractKind: opts.contractKind ?? 'ONE_TIME', }; const contractsRepository = { findByIdWithRelations: jest.fn().mockResolvedValue(contract), }; const createUnderContract = opts.createThrows ? jest.fn().mockRejectedValue(opts.createThrows) : jest .fn() .mockResolvedValue({ booking: { id: 'rem-1', reference: 'BKG-R' }, warnings: [] }); const contractBookingService = { splitOutstanding: jest.fn().mockResolvedValue(opts.outstanding), createUnderContract, }; const bookingBatchService = { fittingTrainsForDay: jest .fn() .mockResolvedValue(opts.fittingTrains ?? [{ scheduleId: 's-2' }]), }; // getRepository is only hit on the container path (recoverDeferredUnits). const lineRepo = { find: jest.fn().mockResolvedValue([{ id: 'line-1' }]), }; const unitRepo = { find: jest.fn().mockResolvedValue(opts.deferredUnits ?? []), }; const dataSource = { getRepository: jest.fn((entity: { name?: string }) => { const n = entity?.name ?? ''; if (n.includes('Unit')) return unitRepo; return lineRepo; }), }; const notifier = { remainderPlaced: jest.fn() }; const service = new RemainderPlacementService( dataSource as never, contractsRepository as never, contractBookingService as never, bookingBatchService as never, notifier as never, ); return { service, createUnderContract, contractBookingService, bookingBatchService, notifier, }; } const splitBooking = { id: 'bk-1', reference: 'BKG-1', contractId: 'c-1', scheduledDate: new Date('2026-07-20T06:00:00Z'), createdByUserId: 'u-1', } as never; it('sizes a BULK remainder from the outstanding tons', async () => { const { service, createUnderContract } = make({ freightType: 'BULK', outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, }); const id = await service.placeRemainder(splitBooking); expect(id).toBe('rem-1'); const dto = createUnderContract.mock.calls[0][1]; expect(dto.bulkLines).toEqual([{ cargoWeightTons: 40 }]); expect(dto.scheduledDate).toBe(DAY); }); it('rebuilds a CONTAINER remainder from the soft-deleted units', async () => { const deferredUnits = [ { containerNumber: 'ABCD1234567', vgmTons: 12, isReefer: true }, { containerNumber: 'ABCD7654321', vgmTons: 10, isHazardous: true }, ]; const { service, createUnderContract } = make({ freightType: 'CONTAINER', outstanding: { bySize: new Map([['40ft', { total: 5, outstanding: 2 }]]), bulk: null, }, deferredUnits, }); const id = await service.placeRemainder(splitBooking); expect(id).toBe('rem-1'); const dto = createUnderContract.mock.calls[0][1]; expect(dto.containers).toHaveLength(1); const line = dto.containers[0]; expect(line.containerSize).toBe('40ft'); expect(line.quantity).toBe(2); expect(line.units.map((u: { containerNumber: string }) => u.containerNumber)).toEqual([ 'ABCD1234567', 'ABCD7654321', ]); expect(line.reeferQuantity).toBe(1); expect(line.hazardousQuantity).toBe(1); }); it('throws (→ no placement) when fewer units are recoverable than outstanding — never fabricates', async () => { const { service, createUnderContract } = make({ freightType: 'CONTAINER', outstanding: { bySize: new Map([['40ft', { total: 5, outstanding: 3 }]]), bulk: null, }, deferredUnits: [{ containerNumber: 'ABCD1234567', vgmTons: 12 }], // only 1, need 3 }); const id = await service.placeRemainder(splitBooking); expect(id).toBeNull(); expect(createUnderContract).not.toHaveBeenCalled(); }); it('is a no-op when there is no outstanding remainder', async () => { const { service, createUnderContract } = make({ freightType: 'BULK', outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 0 } }, }); const id = await service.placeRemainder(splitBooking); expect(id).toBeNull(); expect(createUnderContract).not.toHaveBeenCalled(); }); it('tells the customer the leftover wagons were booked on another train', async () => { const { service, notifier } = make({ freightType: 'BULK', outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, }); await service.placeRemainder(splitBooking); expect(notifier.remainderPlaced).toHaveBeenCalledWith( expect.objectContaining({ id: 'rem-1' }), 'BKG-1', ); }); it('never double-books the leftover when two payments land together', async () => { const { service, createUnderContract } = make({ freightType: 'BULK', outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, }); // Both callers enter before either create commits. await Promise.all([ service.placeRemainder(splitBooking), service.placeRemainder(splitBooking), ]); expect(createUnderContract).toHaveBeenCalledTimes(1); }); // splitOutstanding subtracts a CONTRACT-WIDE booked total from ONE booking's // snapshot — coherent only for ONE_TIME. On GENERAL that mixes scopes and // either drops a real remainder or double-draws the cap, so we must not place. it('never auto-places on a GENERAL contract (cap ledger mismatch)', async () => { const { service, createUnderContract, contractBookingService } = make({ freightType: 'BULK', contractKind: 'GENERAL', outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, }); const id = await service.placeRemainder(splitBooking); expect(id).toBeNull(); expect(createUnderContract).not.toHaveBeenCalled(); expect(contractBookingService.splitOutstanding).not.toHaveBeenCalled(); }); // The paid booking has already boarded — a create-gate rejection (e.g. the // export whole-train gate) must leave the remainder rebookable, not escape. it('swallows a create rejection and leaves the remainder for manual rebook', async () => { const { service } = make({ freightType: 'BULK', outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } }, createThrows: new Error('Not enough train space for this day.'), }); await expect(service.placeRemainder(splitBooking)).resolves.toBeNull(); }); it('is a no-op when the contract has no split chain', async () => { const { service, createUnderContract } = make({ freightType: 'BULK', outstanding: null, }); const id = await service.placeRemainder(splitBooking); expect(id).toBeNull(); expect(createUnderContract).not.toHaveBeenCalled(); }); });