import { ContractBookingService } from './contract-booking.service'; import { Booking } from '../bookings/entities/booking.entity'; /** * Manual (GL-driven) odd-20ft consolidation. On a customs contract GL completes * the booking, so GL also picks who shares its wagon: two bookings each carrying * an odd 20ft count are completed together onto one wagon. * * The two invariants that matter are that the pair is all-or-nothing (a failure * on either half must leave NEITHER booking completed and no link written) and * that the two bookings stay financially separate — one completion each, so one * price and one invoice each. */ describe('ContractBookingService — manual odd-20ft consolidation', () => { function makeService(overrides: { bookingsRepository?: Partial>; dataSource?: unknown; }) { const bookingsRepository = { findByIdWithFiles: jest.fn(), findManualConsolidationCandidates: jest.fn().mockResolvedValue([]), linkConsolidationPartners: jest.fn().mockResolvedValue(undefined), ...overrides.bookingsRepository, }; // A transaction that simply runs the callback — enough to assert the // all-or-nothing contract: whatever throws inside propagates out, and the // caller observes no link written. const dataSource = overrides.dataSource ?? { transaction: jest.fn(async (cb: (m: unknown) => Promise) => cb({})), }; const service = new ContractBookingService( { findByIdWithRelations: jest.fn() } as never, bookingsRepository as never, {} as never, // bookingPricingService {} as never, // consolidationService {} as never, // containerTypesService {} as never, // ruleEngineService {} as never, // milestoneService {} as never, // invoiceService {} as never, // bookingNotifier dataSource as never, {} as never, // trainSchedulingService {} as never, // bookingBatchService {} as never, // bookingTransitionService // The pairing is parked for approval rather than going straight to // Operations; the gate itself is covered by its own spec. { requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never, ); return { service, bookingsRepository, dataSource }; } const partnerBooking = { id: 'b-2', reference: 'BK-2', contractId: 'c-2', consolidationPartnerId: null, } as unknown as Booking; const pairDto = { partnerBookingId: 'b-2', booking: { scheduledDate: '2026-09-01' }, partner: { scheduledDate: '2026-09-01' }, }; it('completes both halves and links them', async () => { const { service, bookingsRepository } = makeService({ bookingsRepository: { findByIdWithFiles: jest .fn() // partner lookup before the transaction .mockResolvedValueOnce(partnerBooking) // the two reloads after it .mockResolvedValueOnce({ id: 'b-1', reference: 'BK-1' } as Booking) .mockResolvedValueOnce({ id: 'b-2', reference: 'BK-2' } as Booking), }, }); // Each half runs the ordinary completion machine — one call per booking, so // each is priced and invoiced on its own. const complete = jest .spyOn(service, 'completeUnderContract') .mockImplementation( async (_contractId, bookingId) => ({ booking: { id: bookingId } as Booking, warnings: [], }) as never, ); const result = await service.completeConsolidatedPair( 'c-1', 'b-1', pairDto as never, ); expect(complete).toHaveBeenCalledTimes(2); // The partner is completed against ITS OWN contract, not this one. expect(complete.mock.calls[0][0]).toBe('c-1'); expect(complete.mock.calls[1][0]).toBe('c-2'); // Neither half may re-enter the automatic matcher — GL links them here. expect(complete.mock.calls[0][2]).toMatchObject({ skipAutoConsolidation: true, }); expect(complete.mock.calls[1][2]).toMatchObject({ skipAutoConsolidation: true, }); expect(bookingsRepository.linkConsolidationPartners).toHaveBeenCalledWith( 'b-1', 'b-2', ); expect(result.booking.id).toBe('b-1'); expect(result.partner.id).toBe('b-2'); }); it('links nothing when the partner half fails (all-or-nothing)', async () => { const { service, bookingsRepository } = makeService({ bookingsRepository: { findByIdWithFiles: jest.fn().mockResolvedValue(partnerBooking), }, }); jest .spyOn(service, 'completeUnderContract') .mockImplementationOnce( async () => ({ booking: { id: 'b-1' } as Booking, warnings: [] }) as never, ) .mockImplementationOnce(async () => { throw new Error('no train space for the partner'); }); await expect( service.completeConsolidatedPair('c-1', 'b-1', pairDto as never), ).rejects.toThrow('no train space for the partner'); // The link is the last write in the transaction — it must never happen when // a half failed, so the rollback leaves no dangling pairing. expect(bookingsRepository.linkConsolidationPartners).not.toHaveBeenCalled(); }); it('refuses a partner that already shares a wagon', async () => { const { service } = makeService({ bookingsRepository: { findByIdWithFiles: jest.fn().mockResolvedValue({ ...partnerBooking, consolidationPartnerId: 'b-9', }), }, }); await expect( service.completeConsolidatedPair('c-1', 'b-1', pairDto as never), ).rejects.toThrow(/already shares a wagon/i); }); it('refuses to consolidate a booking with itself', async () => { const { service } = makeService({}); await expect( service.completeConsolidatedPair('c-1', 'b-1', { ...pairDto, partnerBookingId: 'b-1', } as never), ).rejects.toThrow(/cannot be consolidated with itself/i); }); it('offers only bookings whose own 20ft count is odd', async () => { // Two odd counts always sum to even, so an odd partner is exactly what fills // the wagon; an even one would leave the pair partial again. const rows = [ { id: 'odd', reference: 'BK-ODD', bookingContainers: [ { quantity: 3, containerType: { sizeFt: 20 } }, ], }, { id: 'even', reference: 'BK-EVEN', bookingContainers: [ { quantity: 4, containerType: { sizeFt: 20 } }, ], }, // A bare instance has no cargo yet — GL enters it on the split form, so it // stays a candidate. { id: 'bare', reference: 'BK-BARE', bookingContainers: [] }, ]; const { service } = makeService({ bookingsRepository: { findByIdWithFiles: jest .fn() .mockResolvedValue({ id: 'b-1', contractId: 'c-1' } as Booking), findManualConsolidationCandidates: jest.fn(async (booking: Booking) => // Mirror the repository's in-memory odd filter. rows.filter((row) => { void booking; const lines = row.bookingContainers ?? []; if (lines.length === 0) return true; const ft20 = lines .filter((l) => Number(l.containerType?.sizeFt) === 20) .reduce((sum, l) => sum + Number(l.quantity || 0), 0); return ft20 % 2 === 1; }), ), }, }); const candidates = await service.listConsolidationCandidates('c-1', 'b-1'); expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']); expect(candidates[0].ft20Quantity).toBe(3); expect(candidates[1].hasCargo).toBe(false); }); });