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; 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([]), 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(), expirePayable: 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: [], }) 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: [], }), ); }); 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.findBatchPoolByRouteDay.mockResolvedValue([ commercial('hi', 30), commercial('mid', 20), commercial('lo', 10), ]); const touched = await service.fillRouteDay(originYardId, destinationYardId, day); expect(bookingsRepository.findBatchPoolByRouteDay).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.findBatchPoolByRouteDay.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 }], }) as unknown as Booking; bookingsRepository.findBatchPoolByRouteDay.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 }], } as unknown as Booking; bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([lonely]); await service.fillRouteDay(originYardId, destinationYardId, day); // Never reserved — waits for its partner in a later cycle. expect(notifier.payNow).not.toHaveBeenCalled(); }); }); });