mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
231 lines
7.5 KiB
TypeScript
231 lines
7.5 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
|
|
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
|
|
import { SchedulingRescheduleService } from './scheduling-reschedule.service';
|
|
|
|
const makeBooking = (
|
|
id: string,
|
|
reference: string,
|
|
extra: Record<string, unknown> = {},
|
|
) => ({
|
|
id,
|
|
reference,
|
|
freightType: 'CONTAINER',
|
|
cargoTotalWeightVgm: 100,
|
|
scheduledDate: new Date('2026-06-20T08:00:00.000Z'),
|
|
originYardId: 'yard-origin',
|
|
destinationYardId: 'yard-destination',
|
|
status: 'PAID',
|
|
isGovernment: false,
|
|
priorityScore: 50,
|
|
bookingContainers: [
|
|
{
|
|
id: `${id}-line`,
|
|
wagonsRequired: 5,
|
|
quantity: 1,
|
|
vgmPerUnitTons: 100,
|
|
},
|
|
],
|
|
...extra,
|
|
});
|
|
|
|
describe('compareSchedulingPriority', () => {
|
|
it('orders government before commercial', () => {
|
|
const sorted = [
|
|
{
|
|
isGovernment: false,
|
|
priorityScore: 50000,
|
|
scheduledDate: new Date('2026-06-20'),
|
|
},
|
|
{
|
|
isGovernment: true,
|
|
priorityScore: 100,
|
|
scheduledDate: new Date('2026-06-25'),
|
|
},
|
|
].sort(compareSchedulingPriority);
|
|
|
|
expect(sorted[0]?.isGovernment).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('SchedulingRescheduleService', () => {
|
|
let service: SchedulingRescheduleService;
|
|
let trainSchedulesRepository: Record<string, jest.Mock>;
|
|
let bookingsRepository: Record<string, jest.Mock>;
|
|
let trainSchedulingService: Record<string, jest.Mock>;
|
|
let schedulingRescheduleRepository: Record<string, jest.Mock>;
|
|
|
|
beforeEach(() => {
|
|
trainSchedulesRepository = {
|
|
findByIdWithFullGraph: jest.fn(),
|
|
updateStatus: jest.fn(),
|
|
};
|
|
bookingsRepository = {
|
|
findByIdsForScheduling: jest.fn(),
|
|
updateSchedulingFields: jest.fn(),
|
|
};
|
|
trainSchedulingService = {
|
|
previewTrainSchedule: jest.fn(),
|
|
unassignBooking: jest.fn(),
|
|
assignBookingsToSchedule: jest.fn(),
|
|
};
|
|
schedulingRescheduleRepository = {
|
|
createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }),
|
|
};
|
|
|
|
service = new SchedulingRescheduleService(
|
|
trainSchedulesRepository as never,
|
|
bookingsRepository as never,
|
|
trainSchedulingService as never,
|
|
schedulingRescheduleRepository as never,
|
|
);
|
|
});
|
|
|
|
it('rejects reschedule on dispatched trains', async () => {
|
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
|
id: 'sched-1',
|
|
status: 'DISPATCHED',
|
|
scheduleBookings: [],
|
|
});
|
|
|
|
await expect(
|
|
service.previewReschedule('sched-1', {
|
|
incomingBookingIds: ['gov-1'],
|
|
trigger: 'GOVERNMENT_PREEMPT',
|
|
}),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('displaces lower-priority commercial when government incoming exceeds capacity', async () => {
|
|
const commercial = makeBooking('c1', 'BKG-COMM', { priorityScore: 10, isGovernment: false });
|
|
const government = makeBooking('g1', 'BKG-GOV', {
|
|
isGovernment: true,
|
|
priorityScore: 60000,
|
|
governmentInstitution: 'Ministry',
|
|
});
|
|
|
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
|
id: 'sched-1',
|
|
status: 'DRAFT',
|
|
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
|
|
originStationId: 'yard-origin',
|
|
destinationStationId: 'yard-destination',
|
|
scheduleBookings: [{ bookingId: 'c1', booking: commercial }],
|
|
});
|
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue([government]);
|
|
|
|
trainSchedulingService.previewTrainSchedule.mockImplementation(
|
|
async ({ bookingIds }: { bookingIds: string[] }) => ({
|
|
valid: bookingIds.length <= 1,
|
|
violations: bookingIds.length > 1 ? ['Train capacity exceeded'] : [],
|
|
warnings: [],
|
|
}),
|
|
);
|
|
|
|
const plan = await service.previewReschedule('sched-1', {
|
|
incomingBookingIds: ['g1'],
|
|
trigger: 'GOVERNMENT_PREEMPT',
|
|
});
|
|
|
|
expect(plan.retained.map((b) => b.id)).toEqual(['g1']);
|
|
expect(plan.displaced.map((b) => b.id)).toEqual(['c1']);
|
|
expect(plan.finalBookingIds).toEqual(['g1']);
|
|
});
|
|
|
|
it('readmits high-priority commercial when spare capacity remains', async () => {
|
|
const low = makeBooking('c-low', 'BKG-LOW', { priorityScore: 5 });
|
|
const high = makeBooking('c-high', 'BKG-HIGH', { priorityScore: 500 });
|
|
const government = makeBooking('g1', 'BKG-GOV', { isGovernment: true, priorityScore: 60000 });
|
|
|
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
|
id: 'sched-1',
|
|
status: 'DRAFT',
|
|
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
|
|
originStationId: 'yard-origin',
|
|
destinationStationId: 'yard-destination',
|
|
scheduleBookings: [
|
|
{ bookingId: 'c-low', booking: low },
|
|
{ bookingId: 'c-high', booking: high },
|
|
],
|
|
});
|
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue([government]);
|
|
|
|
const fitAttempts = new Map<string, number>();
|
|
trainSchedulingService.previewTrainSchedule.mockImplementation(
|
|
async ({ bookingIds }: { bookingIds: string[] }) => {
|
|
const key = [...bookingIds].sort().join(',');
|
|
const attempt = (fitAttempts.get(key) ?? 0) + 1;
|
|
fitAttempts.set(key, attempt);
|
|
|
|
const fits =
|
|
bookingIds.length === 1 ||
|
|
(key === 'c-high,g1' && attempt > 1);
|
|
|
|
return {
|
|
valid: fits,
|
|
violations: fits ? [] : ['Train capacity exceeded'],
|
|
warnings: [],
|
|
};
|
|
},
|
|
);
|
|
|
|
const plan = await service.previewReschedule('sched-1', {
|
|
incomingBookingIds: ['g1'],
|
|
trigger: 'GOVERNMENT_PREEMPT',
|
|
});
|
|
|
|
expect(plan.retained.map((b) => b.id)).toEqual(['g1']);
|
|
expect(plan.readmitted.map((b) => b.id)).toEqual(['c-high']);
|
|
expect(plan.displaced.map((b) => b.id)).toEqual(['c-low']);
|
|
expect(plan.finalBookingIds).toEqual(['g1', 'c-high']);
|
|
});
|
|
|
|
it('maintenance reschedule updates departure and rebalances bookings', async () => {
|
|
const commercial = makeBooking('c1', 'BKG-COMM', { priorityScore: 10 });
|
|
const schedule = {
|
|
id: 'sched-1',
|
|
status: 'DRAFT',
|
|
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
|
|
originStationId: 'yard-origin',
|
|
destinationStationId: 'yard-destination',
|
|
scheduleBookings: [{ bookingId: 'c1', booking: commercial }],
|
|
};
|
|
|
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule);
|
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue([commercial]);
|
|
trainSchedulingService.previewTrainSchedule.mockResolvedValue({
|
|
valid: true,
|
|
violations: [],
|
|
warnings: [],
|
|
});
|
|
trainSchedulesRepository.updateStatus.mockResolvedValue(undefined);
|
|
trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' });
|
|
|
|
const result = await service.maintenanceReschedule(
|
|
'sched-1',
|
|
{
|
|
incomingBookingIds: ['c1'],
|
|
trigger: 'TRAIN_MAINTENANCE',
|
|
reason: 'Locomotive service',
|
|
newDepartureDate: '2026-06-22T10:00:00.000Z',
|
|
},
|
|
'staff-1',
|
|
);
|
|
|
|
expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith(
|
|
'sched-1',
|
|
'DRAFT',
|
|
{ scheduledDepartureDate: new Date('2026-06-22T10:00:00.000Z') },
|
|
);
|
|
expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
trigger: 'TRAIN_MAINTENANCE',
|
|
actorUserId: 'staff-1',
|
|
reason: 'Locomotive service',
|
|
}),
|
|
);
|
|
expect(result.plan.trigger).toBe('TRAIN_MAINTENANCE');
|
|
expect(result.plan.finalBookingIds).toEqual(['c1']);
|
|
});
|
|
});
|