import { BadRequestException } from '@nestjs/common'; import { ContractBookingService } from './contract-booking.service'; import { Contract } from './entities/contract.entity'; /** * Contract auto-completion by quantity cap. Once a GENERAL contract's capped * scope is fully consumed (e.g. a split remainder rebooked), the contract moves * to CONTRACT_CLOSED even inside its validity window, and further bookings are * blocked — including while a booking window is open. Released capacity * (cancelled/expired booking) reopens the contract on the next attempt. */ describe('ContractBookingService — quantity-cap completion', () => { function makeService() { const contractsRepository = { findByIdWithRelations: jest.fn(), update: jest.fn().mockResolvedValue(undefined), }; const service = new ContractBookingService( contractsRepository as never, {} as never, // bookingsRepository {} as never, // bookingPricingService {} as never, // consolidationService {} as never, // containerTypesService {} as never, // ruleEngineService {} as never, // milestoneService {} as never, // invoiceService { createdToStaff: jest.fn() } as never, // bookingNotifier {} as never, // dataSource {} as never, // trainSchedulingService {} as never, // bookingBatchService {} as never, // bookingTransitionService {} as never, // consolidationApprovalService ); return { service, contractsRepository }; } type WithPrivate = { maybeCompleteContract: (c: Contract) => Promise; }; const generalContract = (status: string): Contract => ({ id: 'c-1', reference: 'CTR-1', contractKind: 'GENERAL', status, }) as Contract; it('closes a GENERAL contract when every capped line is exhausted', async () => { const { service, contractsRepository } = makeService(); jest.spyOn(service, 'computeCapacity').mockResolvedValue([ { containerSize: '20FT', cap: 10, booked: 10, remaining: 0 }, { containerSize: '40FT', cap: 4, booked: 4, remaining: 0 }, ]); await (service as never as WithPrivate).maybeCompleteContract( generalContract('CONTRACT_ACTIVE'), ); expect(contractsRepository.update).toHaveBeenCalledWith('c-1', { status: 'CONTRACT_CLOSED', }); }); it('absorbs bulk-ton float dust when judging exhaustion', async () => { const { service, contractsRepository } = makeService(); jest .spyOn(service, 'computeCapacity') .mockResolvedValue([{ cap: 100, booked: 99.9995, remaining: 0.0005 }]); await (service as never as WithPrivate).maybeCompleteContract( generalContract('FULLY_EXECUTED'), ); expect(contractsRepository.update).toHaveBeenCalledWith('c-1', { status: 'CONTRACT_CLOSED', }); }); it('keeps the contract open while any capped line has capacity left', async () => { const { service, contractsRepository } = makeService(); jest.spyOn(service, 'computeCapacity').mockResolvedValue([ { containerSize: '20FT', cap: 10, booked: 10, remaining: 0 }, { containerSize: '40FT', cap: 4, booked: 3, remaining: 1 }, ]); await (service as never as WithPrivate).maybeCompleteContract( generalContract('CONTRACT_ACTIVE'), ); expect(contractsRepository.update).not.toHaveBeenCalled(); }); it('never closes an uncapped contract', async () => { const { service, contractsRepository } = makeService(); jest.spyOn(service, 'computeCapacity').mockResolvedValue([]); await (service as never as WithPrivate).maybeCompleteContract( generalContract('CONTRACT_ACTIVE'), ); expect(contractsRepository.update).not.toHaveBeenCalled(); }); it('never closes a ONE_TIME contract (single-slot rule governs it)', async () => { const { service, contractsRepository } = makeService(); const spy = jest.spyOn(service, 'computeCapacity'); await (service as never as WithPrivate).maybeCompleteContract({ id: 'c-1', contractKind: 'ONE_TIME', status: 'FULLY_EXECUTED', } as Contract); expect(spy).not.toHaveBeenCalled(); expect(contractsRepository.update).not.toHaveBeenCalled(); }); it('rejects a new booking on a completed contract even inside an open window', async () => { const { service, contractsRepository } = makeService(); contractsRepository.findByIdWithRelations.mockResolvedValue( generalContract('CONTRACT_CLOSED'), ); jest .spyOn(service, 'computeCapacity') .mockResolvedValue([{ cap: 10, booked: 10, remaining: 0 }]); await expect( service.createUnderContract('c-1', {} as never, null, null), ).rejects.toThrow(BadRequestException); expect(contractsRepository.update).not.toHaveBeenCalled(); }); describe('completion on booking delivery', () => { function makeDeliveryService(contract: Partial) { const contractsRepository = { findById: jest.fn().mockResolvedValue(contract), update: jest.fn().mockResolvedValue(undefined), }; const bookingsRepository = { findById: jest .fn() .mockResolvedValue({ id: 'b-1', reference: 'BKG-1', contractId: 'c-1' }), }; const service = new ContractBookingService( contractsRepository as never, bookingsRepository as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, { createdToStaff: jest.fn() } as never, {} as never, {} as never, {} as never, {} as never, {} as never, // consolidationApprovalService ); return { service, contractsRepository }; } it('completes a ONE_TIME contract when its booking is delivered', async () => { const { service, contractsRepository } = makeDeliveryService({ id: 'c-1', reference: 'CTR-1', contractKind: 'ONE_TIME', status: 'CONTRACT_ACTIVE', }); jest.spyOn(service, 'splitOutstanding').mockResolvedValue(null); await service.onBookingCompleted({ bookingId: 'b-1' }); expect(contractsRepository.update).toHaveBeenCalledWith('c-1', { status: 'CONTRACT_CLOSED', }); }); it('keeps a split ONE_TIME contract open while a remainder is outstanding', async () => { const { service, contractsRepository } = makeDeliveryService({ id: 'c-1', reference: 'CTR-1', contractKind: 'ONE_TIME', freightType: 'CONTAINER', status: 'CONTRACT_ACTIVE', }); jest.spyOn(service, 'splitOutstanding').mockResolvedValue({ bySize: new Map([['20ft', { total: 5, outstanding: 2 }]]), bulk: null, }); await service.onBookingCompleted({ bookingId: 'b-1' }); expect(contractsRepository.update).not.toHaveBeenCalled(); }); it('leaves a GENERAL contract alone — it closes on cap or expiry', async () => { const { service, contractsRepository } = makeDeliveryService({ id: 'c-1', contractKind: 'GENERAL', status: 'CONTRACT_ACTIVE', }); await service.onBookingCompleted({ bookingId: 'b-1' }); expect(contractsRepository.update).not.toHaveBeenCalled(); }); }); it('reopens a completed contract when capacity was released', async () => { const { service, contractsRepository } = makeService(); contractsRepository.findByIdWithRelations.mockResolvedValue( generalContract('CONTRACT_CLOSED'), ); jest .spyOn(service, 'computeCapacity') .mockResolvedValue([{ cap: 10, booked: 8, remaining: 2 }]); // The create path continues past the gate and dies later on the bare mocks — // only the reopen transition is under test here. await service.createUnderContract('c-1', {} as never, null, null).catch(() => undefined); expect(contractsRepository.update).toHaveBeenCalledWith('c-1', { status: 'CONTRACT_ACTIVE', }); }); }); /** * The customer's shipment request is the order: GL may not change its container * sizes/quantities or billing currency at completion — only per-unit details. */ describe('ContractBookingService — shipment-request lock at completion', () => { type WithAssert = { assertMatchesShipmentRequest( bookingId: string, dto: { paymentCurrency?: string; containers?: Array<{ containerSize: string; quantity: number }>; bulkLines?: Array<{ cargoWeightTons?: number }>; }, ): Promise; }; const serviceWithRequest = (request: unknown): WithAssert => { const svc = Object.create(ContractBookingService.prototype) as WithAssert & { dataSource: unknown; }; svc.dataSource = { getRepository: () => ({ findOne: async () => request }), }; return svc; }; const request = { paymentCurrency: 'USD', requestedLines: { containers: [ { containerSize: '20ft', quantity: 2 }, { containerSize: '40ft', quantity: 1 }, ], }, }; it('accepts the exact requested quantities and currency', async () => { await expect( serviceWithRequest(request).assertMatchesShipmentRequest('b1', { paymentCurrency: 'USD', containers: [ { containerSize: '40ft', quantity: 1 }, { containerSize: '20ft', quantity: 2 }, ], }), ).resolves.toBeUndefined(); }); it('rejects changed quantities', async () => { await expect( serviceWithRequest(request).assertMatchesShipmentRequest('b1', { paymentCurrency: 'USD', containers: [ { containerSize: '20ft', quantity: 4 }, { containerSize: '40ft', quantity: 1 }, ], }), ).rejects.toBeInstanceOf(BadRequestException); }); it('rejects a changed billing currency', async () => { await expect( serviceWithRequest(request).assertMatchesShipmentRequest('b1', { paymentCurrency: 'ETB', containers: [ { containerSize: '20ft', quantity: 2 }, { containerSize: '40ft', quantity: 1 }, ], }), ).rejects.toBeInstanceOf(BadRequestException); }); it('is a no-op without a linked request', async () => { await expect( serviceWithRequest(null).assertMatchesShipmentRequest('b1', { paymentCurrency: 'ETB', containers: [{ containerSize: '20ft', quantity: 9 }], }), ).resolves.toBeUndefined(); }); });