import { BookingBatchService } from './booking-batch.service'; import { Booking } from '../bookings/entities/booking.entity'; describe('BookingBatchService — PAID reconcile', () => { const scheduleId = 'schedule-1'; const bookingId = 'booking-1'; const paidBooking = { id: bookingId, reference: 'BK-2026-000034', trainScheduleId: scheduleId, status: 'PAID', paymentStatus: 'PAID', isGovernment: false, cargoTotalWeightVgm: 20, bookingContainers: [], } as unknown as Booking; let service: BookingBatchService; let bookingsRepository: { findPaidUnlinkedForSchedule: jest.Mock; findBatchPool: jest.Mock; findBatchPoolByRouteDay: jest.Mock; findBatchPoolByCorridorDay: jest.Mock; findUnacceptedForRouteDay: jest.Mock; findReservedForSchedule: jest.Mock; update: jest.Mock; }; let trainScheduleBookingsRepository: { existsForBooking: jest.Mock; createMany: jest.Mock; }; let trainSchedulesRepository: { findByIdWithFullGraph: jest.Mock; findAll: jest.Mock; }; let trainSchedulingService: { tryAutoWagonAllocation: jest.Mock; getBookableSchedules: jest.Mock; getWindowConfig: jest.Mock; }; let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; }; let notifier: { payNow: jest.Mock; secured: jest.Mock; expired: jest.Mock; unplaced: jest.Mock; }; beforeEach(() => { bookingsRepository = { findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]), findBatchPool: jest.fn().mockResolvedValue([]), findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]), findBatchPoolByCorridorDay: jest.fn().mockResolvedValue([]), findUnacceptedForRouteDay: jest.fn().mockResolvedValue([]), findReservedForSchedule: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(undefined), }; trainScheduleBookingsRepository = { existsForBooking: jest.fn().mockResolvedValue(false), createMany: jest.fn().mockResolvedValue(undefined), }; trainSchedulesRepository = { findByIdWithFullGraph: jest.fn().mockResolvedValue({ id: scheduleId, maxWagons: 10, bookingWindowStatus: 'OPEN', trainSet: { locomotive: { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 } }, scheduleBookings: [], }), findAll: jest.fn().mockResolvedValue([]), }; trainSchedulingService = { tryAutoWagonAllocation: jest.fn().mockResolvedValue({ assignedBookingIds: [], deferred: [], issues: [], violations: [], }), getBookableSchedules: jest.fn().mockResolvedValue([]), getWindowConfig: jest.fn().mockResolvedValue({ importWindowLeadDays: 3, exportBookingLeadHours: 24, windowOpenHour: 8, windowCloseHour: 17, windowDurationHours: 3, docReviewMinutes: 30, paymentWindowMinutes: 60, reopenDelayMinutes: 90, }), }; const bookingRepo = { findOne: jest.fn().mockResolvedValue(paidBooking), update: jest.fn().mockResolvedValue(undefined), // WagonType.find() / global-rules find() fall back to defaults when empty. find: jest.fn().mockResolvedValue([]), }; dataSource = { getRepository: jest.fn().mockReturnValue(bookingRepo), transaction: jest.fn(async (fn: (m: unknown) => Promise) => { const manager = { getRepository: () => bookingRepo, }; await fn(manager); }), }; notifier = { payNow: jest.fn(), secured: jest.fn(), expired: jest.fn(), unplaced: jest.fn(), }; service = new BookingBatchService( dataSource as never, bookingsRepository as never, trainSchedulesRepository as never, trainScheduleBookingsRepository as never, notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, { syncPayableDueDate: jest.fn().mockResolvedValue(undefined), expirePayable: jest.fn().mockResolvedValue(undefined), } as never, { emitPhase: jest.fn() } as never, ); }); it('reconcilePaidUnlinked links PAID bookings without a schedule row', async () => { bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([paidBooking]); await service.reconcilePaidUnlinked(scheduleId); expect(bookingsRepository.findPaidUnlinkedForSchedule).toHaveBeenCalledWith(scheduleId); expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledWith( [{ trainScheduleId: scheduleId, bookingId }], expect.anything(), ); }); it('ensurePaidBookingAllocated links PAID booking when not yet linked', async () => { await service.ensurePaidBookingAllocated(bookingId); expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledTimes(1); expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledWith(scheduleId); }); it('ensurePaidBookingAllocated is idempotent when already linked', async () => { trainScheduleBookingsRepository.existsForBooking.mockResolvedValue(true); await service.ensurePaidBookingAllocated(bookingId); await service.ensurePaidBookingAllocated(bookingId); expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2); }); it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => { const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(undefined); const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined); const reconcileSpy = jest.spyOn(service, 'reconcilePaidUnlinked').mockResolvedValue(undefined); await service.processSchedule(scheduleId); expect(fillSpy).toHaveBeenCalledWith(scheduleId); expect(settleSpy).toHaveBeenCalledWith(scheduleId); expect(reconcileSpy).toHaveBeenCalledWith(scheduleId); expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledWith(scheduleId); const fillOrder = fillSpy.mock.invocationCallOrder[0]; const reconcileOrder = reconcileSpy.mock.invocationCallOrder[0]; const wagonOrder = trainSchedulingService.tryAutoWagonAllocation.mock.invocationCallOrder[0]; expect(fillOrder).toBeLessThan(reconcileOrder); expect(reconcileOrder).toBeLessThan(wagonOrder); }); describe('fillRouteDay — day-level distribution', () => { const originYardId = 'yard-origin'; const destinationYardId = 'yard-dest'; const day = '2026-06-20'; // 06:00Z and 09:00Z on 2026-06-20 both land on the same EAT day. const trainA = 'train-a'; const trainB = 'train-b'; // A tiny locomotive: default wagon = 14m / 70t → exactly 1 wagon slot fits. const smallLoco = { maxPullWeightTons: 70, maxTrainLengthMeters: 14 }; const commercial = (id: string, priority: number): Booking => ({ id, reference: id, isGovernment: false, priorityScore: priority, status: 'FULLY_EXECUTED', wagonsRequired: 1, cargoTotalWeightVgm: 10, freightType: 'CONTAINER', bookingContainers: [], originYardId, destinationYardId, }) as unknown as Booking; beforeEach(() => { // Two OPEN legacy trains on the same route + day, train A earlier than train B. // fillRouteDay now selects fillable schedules straight from the repository. trainSchedulesRepository.findAll.mockResolvedValue([ { id: trainA, originStationId: originYardId, destinationStationId: destinationYardId, scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), bookingWindowStatus: 'OPEN', windowPhase: null, }, { id: trainB, originStationId: originYardId, destinationStationId: destinationYardId, scheduledDepartureDate: new Date('2026-06-20T09:00:00.000Z'), bookingWindowStatus: 'OPEN', windowPhase: null, }, ]); trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) => Promise.resolve({ id, maxWagons: 1, bookingWindowStatus: 'OPEN', trainSetId: `set-${id}`, trainSet: { locomotive: smallLoco }, scheduleBookings: [], originStationId: originYardId, destinationStationId: destinationYardId, }), ); }); it('spills overflow to the next train by priority, then reports unplaced', async () => { // 3 commercial bookings, descending priority; only 1 fits per train (2 total). bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([ commercial('hi', 30), commercial('mid', 20), commercial('lo', 10), ]); const touched = await service.fillRouteDay(originYardId, destinationYardId, day); expect(bookingsRepository.findBatchPoolByCorridorDay).toHaveBeenCalledWith( [originYardId, destinationYardId], day, ); // Both trains were processed. expect(touched).toEqual([trainA, trainB]); // Highest priority reserved on train A, next on train B (commercial → reserve). const reservedOn = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id); expect(reservedOn).toEqual(['hi', 'mid']); // The third booking fits no train and is reported unplaced (and only it). expect(notifier.unplaced).toHaveBeenCalledTimes(1); expect((notifier.unplaced.mock.calls[0][0] as Booking).id).toBe('lo'); expect(notifier.unplaced.mock.calls[0][1]).toBe(day); }); it('reserves the chosen train id on each commercial booking', async () => { bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([commercial('hi', 30)]); await service.fillRouteDay(originYardId, destinationYardId, day); // reserve() persists trainScheduleId so the settle lifecycle can find the train. expect(bookingsRepository.update).toHaveBeenCalledWith( 'hi', expect.objectContaining({ trainScheduleId: trainA, status: 'SELECTED_FOR_BATCH', }), ); }); it('reserves both partners of a consolidated pair together on one train', async () => { // Two 20ft bookings, 1 container each — a shared wagon. Both in the pool. const consol = (id: string, partnerId: string, priority: number): Booking => ({ id, reference: id, isGovernment: false, priorityScore: priority, status: 'FULLY_EXECUTED', wagonsRequired: 1, cargoTotalWeightVgm: 10, freightType: 'CONTAINER', consolidationPartnerId: partnerId, bookingContainers: [{ quantity: 1 }], originYardId, destinationYardId, }) as unknown as Booking; bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([ consol('a', 'b', 30), consol('b', 'a', 20), ]); await service.fillRouteDay(originYardId, destinationYardId, day); // Both reserved on the same (first) train; neither reported unplaced. const reservedIds = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id); expect(reservedIds.sort()).toEqual(['a', 'b']); expect(notifier.unplaced).not.toHaveBeenCalled(); }); it('skips a consolidated booking whose partner is not in the pool (both-or-neither)', async () => { const lonely = { id: 'a', reference: 'a', isGovernment: false, priorityScore: 30, status: 'FULLY_EXECUTED', wagonsRequired: 1, cargoTotalWeightVgm: 10, freightType: 'CONTAINER', consolidationPartnerId: 'missing-partner', bookingContainers: [{ quantity: 1 }], originYardId, destinationYardId, } as unknown as Booking; bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([lonely]); await service.fillRouteDay(originYardId, destinationYardId, day); // Never reserved — waits for its partner in a later cycle. expect(notifier.payNow).not.toHaveBeenCalled(); }); }); describe('expireUnacceptedForRouteDay — doc-review sweep', () => { const originYardId = 'yard-origin'; const destinationYardId = 'yard-dest'; const day = '2026-06-20'; const pendingBooking = { id: 'pending-1', reference: 'BK-PENDING-1', status: 'OPERATION_REQUEST_PENDING', isGovernment: false, originYardId, destinationYardId, } as unknown as Booking; beforeEach(() => { // One fillable schedule on this corridor/day so corridorYardsForRouteDay // resolves a non-empty yard set (legacy two-stop route → [origin, dest]). trainSchedulesRepository.findAll.mockResolvedValue([ { id: 'sched-1', originStationId: originYardId, destinationStationId: destinationYardId, scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), }, ]); }); it('expires each un-accepted booking and clears its scheduled day', async () => { bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([pendingBooking]); await service.expireUnacceptedForRouteDay({ originYardId, destinationYardId, day, }); expect(bookingsRepository.findUnacceptedForRouteDay).toHaveBeenCalledWith( expect.arrayContaining([originYardId, destinationYardId]), day, ); expect(bookingsRepository.update).toHaveBeenCalledWith( 'pending-1', expect.objectContaining({ status: 'EXPIRED', schedulingStatus: 'ELIGIBLE', scheduledDate: null, }), ); expect(notifier.expired).toHaveBeenCalledWith(pendingBooking); }); it('is a no-op when nothing is un-accepted', async () => { bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([]); await service.expireUnacceptedForRouteDay({ originYardId, destinationYardId, day, }); expect(bookingsRepository.update).not.toHaveBeenCalled(); expect(notifier.expired).not.toHaveBeenCalled(); }); it('does nothing when the route-day has no fillable schedule', async () => { trainSchedulesRepository.findAll.mockResolvedValue([]); await service.expireUnacceptedForRouteDay({ originYardId, destinationYardId, day, }); expect(bookingsRepository.findUnacceptedForRouteDay).not.toHaveBeenCalled(); }); }); describe('maybeOfferPartial — split-eligibility gate', () => { const importGeneral = { id: 'b1', reference: 'b1', isGovernment: false, tradeDirection: 'IMPORT', contractKind: 'GENERAL', consolidationPartnerId: null, } as unknown as Booking; const call = (booking: Booking, isPair: boolean): boolean => ( service as unknown as { isSplitEligible: (b: Booking, p: boolean) => boolean; } ).isSplitEligible(booking, isPair); it('allows IMPORT + GENERAL when splitService is present', () => { const withSplit = new BookingBatchService( dataSource as never, bookingsRepository as never, trainSchedulesRepository as never, trainScheduleBookingsRepository as never, notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, undefined, { findOpenOffer: jest.fn() } as never, ); const eligible = ( withSplit as unknown as { isSplitEligible: (b: Booking, p: boolean) => boolean; } ).isSplitEligible(importGeneral, false); expect(eligible).toBe(true); }); it('allows IMPORT + ONE_TIME (promoted to GENERAL on split)', () => { const withSplit = new BookingBatchService( dataSource as never, bookingsRepository as never, trainSchedulesRepository as never, trainScheduleBookingsRepository as never, notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, undefined, { findOpenOffer: jest.fn() } as never, ); const eligible = ( withSplit as unknown as { isSplitEligible: (b: Booking, p: boolean) => boolean; } ).isSplitEligible( { ...importGeneral, contractKind: 'ONE_TIME' } as Booking, false, ); expect(eligible).toBe(true); }); it('rejects when splitService is absent (default test service)', () => { // `service` from the outer beforeEach was built without a splitService. expect(call(importGeneral, false)).toBe(false); }); it('rejects EXPORT, government, consolidated pairs, and other directions', () => { const withSplit = new BookingBatchService( dataSource as never, bookingsRepository as never, trainSchedulesRepository as never, trainScheduleBookingsRepository as never, notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, undefined, { findOpenOffer: jest.fn() } as never, ); const check = ( withSplit as unknown as { isSplitEligible: (b: Booking, p: boolean) => boolean; } ).isSplitEligible.bind(withSplit); expect(check({ ...importGeneral, tradeDirection: 'EXPORT' } as Booking, false)).toBe(false); expect(check({ ...importGeneral, isGovernment: true } as Booking, false)).toBe(false); expect(check(importGeneral, true)).toBe(false); // consolidated pair expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false); }); }); });