Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts
2026-07-07 21:38:45 +00:00

514 lines
18 KiB
TypeScript

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<void>) => {
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);
});
});
});