import { ContractBookingService } from './contract-booking.service'; import { Booking } from '../bookings/entities/booking.entity'; /** * The GL contract-drawdown path must run wagon consolidation before invoicing. * A partial-wagon drawdown (e.g. 21× 20FT → one leftover container) parks in * PENDING_CONSOLIDATION and is NOT finalized (no invoice / milestones) until it * pairs with a wagon partner. These tests exercise the two new hooks directly. */ describe('ContractBookingService — drawdown consolidation gate', () => { function makeService(overrides: { consolidationService?: Partial>; bookingsRepository?: Partial>; invoiceService?: Partial>; milestoneService?: Partial>; contractsRepository?: Partial>; }) { const consolidationService = { slotsFromBooking: jest.fn().mockResolvedValue([]), describePaired: jest.fn().mockReturnValue('paired'), describePending: jest.fn().mockReturnValue('pending'), needsConsolidationFromBooking: jest.fn().mockResolvedValue(false), ...overrides.consolidationService, }; const bookingsRepository = { findConsolidationPartner: jest.fn().mockResolvedValue(null), pairConsolidation: jest.fn().mockResolvedValue(undefined), parkForConsolidation: jest.fn().mockResolvedValue(undefined), findByIdWithFiles: jest.fn(), ...overrides.bookingsRepository, }; const invoiceService = { ensureInvoiceForBooking: jest.fn().mockResolvedValue({ id: 'inv-1' }), ...overrides.invoiceService, }; const milestoneService = { seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined), seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined), ensureBookingMilestones: jest.fn().mockResolvedValue(undefined), ...overrides.milestoneService, }; const contractsRepository = { findByIdWithRelations: jest.fn(), currentCycle: jest.fn().mockResolvedValue(null), linkBooking: jest.fn().mockResolvedValue(undefined), update: jest.fn().mockResolvedValue(undefined), ...overrides.contractsRepository, }; const service = new ContractBookingService( contractsRepository as never, bookingsRepository as never, {} as never, // bookingPricingService consolidationService as never, {} as never, // containerTypesService {} as never, // ruleEngineService milestoneService as never, {} as never, // workflowService invoiceService as never, {} as never, // clearanceFeeService {} as never, // dataSource {} as never, // trainSchedulingService {} as never, // bookingBatchService {} as never, // bookingTransitionService ); return { service, consolidationService, bookingsRepository, invoiceService, milestoneService, contractsRepository, }; } const booking = { id: 'b-1', reference: 'BK-1' } as Booking; it('parks (not pairs) when no complementary partner exists', async () => { const { service, bookingsRepository } = makeService({ consolidationService: { slotsFromBooking: jest .fn() .mockResolvedValue([{ containerTypeId: 'ct', slotsNeeded: 1 }]), }, bookingsRepository: { findConsolidationPartner: jest.fn().mockResolvedValue(null), }, }); const result = await (service as never as { consolidateDrawdown: (b: Booking, s: string) => Promise<{ paired: boolean }>; }).consolidateDrawdown(booking, 'OPERATION_REQUEST_PENDING'); expect(result.paired).toBe(false); expect(bookingsRepository.parkForConsolidation).toHaveBeenCalledWith( 'b-1', 'OPERATION_REQUEST_PENDING', ); expect(bookingsRepository.pairConsolidation).not.toHaveBeenCalled(); }); it('pairs when a complementary partner exists', async () => { const { service, bookingsRepository } = makeService({ consolidationService: { slotsFromBooking: jest .fn() .mockResolvedValue([{ containerTypeId: 'ct', slotsNeeded: 1 }]), }, bookingsRepository: { findConsolidationPartner: jest .fn() .mockResolvedValue({ id: 'p-1', reference: 'BK-2' }), }, }); const result = await (service as never as { consolidateDrawdown: (b: Booking, s: string) => Promise<{ paired: boolean }>; }).consolidateDrawdown(booking, 'AWAITING_DOCUMENTS'); expect(result.paired).toBe(true); expect(bookingsRepository.pairConsolidation).toHaveBeenCalledWith('b-1', 'p-1'); expect(bookingsRepository.parkForConsolidation).not.toHaveBeenCalled(); }); it('onConsolidationPaired finalizes a resumed contract booking (invoice + milestones)', async () => { const paired = { id: 'b-1', reference: 'BK-1', contractId: 'c-1', status: 'OPERATION_REQUEST_PENDING', } as Booking; const contract = { id: 'c-1', contractKind: 'GENERAL', customsClearingEnabled: true, tradeDirection: 'EXPORT', }; const { service, invoiceService, milestoneService } = makeService({ bookingsRepository: { findByIdWithFiles: jest.fn().mockResolvedValue(paired), }, contractsRepository: { findByIdWithRelations: jest.fn().mockResolvedValue(contract), }, }); await service.onConsolidationPaired({ bookingIds: ['b-1'] }); expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); // GENERAL customs → per-booking milestones, via the idempotent ensure so a // pairing replay (or an initiated instance's pre-seeded timeline) never // duplicates rows. expect(milestoneService.ensureBookingMilestones).toHaveBeenCalledWith( 'b-1', 'EXPORT', ); }); it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => { const stillPending = { id: 'b-1', contractId: 'c-1', status: 'PENDING_CONSOLIDATION', } as Booking; const { service, invoiceService } = makeService({ bookingsRepository: { findByIdWithFiles: jest.fn().mockResolvedValue(stillPending), }, }); await service.onConsolidationPaired({ bookingIds: ['b-1'] }); expect(invoiceService.ensureInvoiceForBooking).not.toHaveBeenCalled(); }); it('onConsolidationPaired ignores a non-contract (direct) booking', async () => { const direct = { id: 'd-1', status: 'SUBMITTED', contractId: null } as Booking; const { service, invoiceService, contractsRepository } = makeService({ bookingsRepository: { findByIdWithFiles: jest.fn().mockResolvedValue(direct), }, }); await service.onConsolidationPaired({ bookingIds: ['d-1'] }); expect(contractsRepository.findByIdWithRelations).not.toHaveBeenCalled(); expect(invoiceService.ensureInvoiceForBooking).not.toHaveBeenCalled(); }); });