mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
257 lines
9.0 KiB
TypeScript
257 lines
9.0 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;
|
|
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;
|
|
};
|
|
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([]),
|
|
};
|
|
|
|
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(), 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 trains on the same route + day, train A earlier than train B.
|
|
trainSchedulingService.getBookableSchedules.mockResolvedValue([
|
|
{
|
|
id: trainA,
|
|
scheduleDate: '2026-06-20T06:00:00.000Z',
|
|
bookingWindowStatus: 'OPEN',
|
|
},
|
|
{
|
|
id: trainB,
|
|
scheduleDate: '2026-06-20T09:00:00.000Z',
|
|
bookingWindowStatus: 'OPEN',
|
|
},
|
|
]);
|
|
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',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|