booking operations and trains scheduling also allocations

This commit is contained in:
marshal
2026-06-10 00:48:32 +03:00
parent 675975bc08
commit 5774d7db9d
180 changed files with 13423 additions and 3877 deletions

View File

@@ -1,5 +1,10 @@
import { ConflictException } from '@nestjs/common';
import { WagonReadiness, WagonStatus } from '@edr/types';
import { Wagon } from '../wagons/entities/wagon.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from './train-scheduling.service';
const nw5 = {
@@ -11,6 +16,7 @@ const nw5 = {
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
};
const locomotive = {
@@ -21,15 +27,29 @@ const locomotive = {
status: 'AVAILABLE',
};
const cw3 = {
id: 'wagon-type-bulk',
code: 'CW3',
name: 'Covered Wagon',
capacityTons: 60,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,
};
const makeBooking = (
id: string,
reference: string,
weight: number,
quantity: number,
containerCode: string,
wagonsRequired: number,
scheduledDate = '2026-06-20T08:00:00.000Z',
originYardId = 'yard-origin',
destinationYardId = 'yard-destination',
extra: Record<string, unknown> = {},
) => ({
id,
reference,
@@ -39,75 +59,177 @@ const makeBooking = (
originYardId,
destinationYardId,
status: 'PAID',
customer: { companyName: 'Demo Customer' },
schedulingStatus: 'HOLDING',
holdExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
company: { companyName: 'Demo Customer' },
originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
bookingContainers: [
{
id: `${id}-line`,
containerTypeId: 'ct-1',
quantity,
wagonsRequired,
vgmPerUnitTons: weight / quantity,
isOverweight: false,
containerType: { code: containerCode, label: containerCode },
},
],
...extra,
});
describe('TrainSchedulingService', () => {
let service: TrainSchedulingService;
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
};
let locomotivesRepository: {
findById: jest.Mock;
};
let wagonTypesRepository: {
findAll: jest.Mock;
};
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock };
let bookingsRepository: Record<string, jest.Mock>;
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
let wagonTypesRepository: { findAll: jest.Mock };
let trainSchedulesRepository: Record<string, jest.Mock>;
let trainScheduleBookingsRepository: Record<string, jest.Mock>;
let wagonBookingAllocationsRepository: Record<string, jest.Mock>;
let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>;
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
beforeEach(() => {
dataSource = {
getRepository: jest.fn(),
transaction: jest.fn(),
dataSource = { getRepository: jest.fn(), transaction: jest.fn() };
bookingsRepository = {
findEligibleForScheduling: jest.fn(),
findByIdsForScheduling: jest.fn(),
updateSchedulingFields: jest.fn(),
};
locomotivesRepository = {
locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() };
wagonTypesRepository = { findAll: jest.fn() };
trainSchedulesRepository = {
findById: jest.fn(),
};
wagonTypesRepository = {
findByIdWithFullGraph: jest.fn(),
findAll: jest.fn(),
updateStatus: jest.fn(),
};
trainScheduleBookingsRepository = {
findByBookingIds: jest.fn(),
createMany: jest.fn(),
deleteByScheduleAndBooking: jest.fn(),
};
wagonBookingAllocationsRepository = {
deleteByTrainSetId: jest.fn().mockResolvedValue([]),
createMany: jest.fn(),
};
wagonAllocationContainerItemsRepository = {
createMany: jest.fn(),
deleteByAllocationIds: jest.fn(),
findAll: jest.fn().mockResolvedValue([]),
};
wagonAllocationBulkLoadsRepository = {
createMany: jest.fn(),
deleteByAllocationIds: jest.fn(),
findAll: jest.fn().mockResolvedValue([]),
};
service = new TrainSchedulingService(
dataSource as never,
bookingsRepository as never,
locomotivesRepository as never,
wagonTypesRepository as never,
trainSchedulesRepository as never,
trainScheduleBookingsRepository as never,
wagonBookingAllocationsRepository as never,
wagonAllocationContainerItemsRepository as never,
wagonAllocationBulkLoadsRepository as never,
);
const defaultFleetWagons = [
...Array.from({ length: 100 }, (_, index) => ({
id: `wagon-nw5-${index}`,
wagonTypeId: nw5.id,
status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady,
currentTrainScheduleId: null,
})),
...Array.from({ length: 50 }, (_, index) => ({
id: `wagon-cw3-${index}`,
wagonTypeId: cw3.id,
status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady,
currentTrainScheduleId: null,
})),
];
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(defaultFleetWagons) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5, cw3]) };
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
});
it('computes the expected valid preview for Group A', async () => {
it('returns fleet availability and defers bookings when fleet is insufficient', async () => {
const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT'),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT'),
makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT'),
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Booking') {
return { find: jest.fn().mockResolvedValue(bookings) };
}
if (entity?.name === 'TrainScheduleBooking') {
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const availableWagons = Array.from({ length: 15 }, (_, index) => ({
id: `wagon-${index}`,
wagonTypeId: nw5.id,
status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady,
currentTrainScheduleId: null,
}));
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity?.name === 'Locomotive') {
return {
count: jest.fn().mockResolvedValue(2),
find: jest.fn().mockResolvedValue([locomotive]),
};
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(availableWagons) };
}
throw new Error(`Unexpected repository ${entity?.name}`);
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5]) };
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
const result = await service.previewContainerTrainSchedule({
bookingIds: bookings.map((booking) => booking.id),
bookingIds: bookings.map((b) => b.id),
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.fleetAvailability?.length).toBeGreaterThan(0);
expect(result.fleetAvailability?.[0]?.shortfall).toBeGreaterThan(0);
expect(result.deferredBookings?.length).toBeGreaterThan(0);
expect(result.summary.wagonsNeeded).toBeLessThan(30);
expect(result.warnings.some((w) => w.includes('Fleet shortage') || w.includes('deferred'))).toBe(
true,
);
});
it('computes slot-based preview for Group A', async () => {
const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10),
makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT', 15),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: bookings.map((b) => b.id),
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
@@ -115,40 +237,50 @@ describe('TrainSchedulingService', () => {
expect(result.valid).toBe(true);
expect(result.violations).toEqual([]);
expect(result.summary).toEqual({
totalBookings: 3,
totalWeightTons: 1250,
wagonType: 'NW5',
wagonsNeeded: 18,
totalLengthMeters: 252,
});
expect(result.wagonPlan).toHaveLength(18);
expect(result.wagonPlan[0]?.allocations[0]).toEqual({
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
expect(result.summary.wagonsNeeded).toBe(45);
expect(result.wagonPlan).toHaveLength(45);
});
it('returns soft hold warnings without forceAssign', async () => {
const bookings = [makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2)];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b7'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.warnings.length).toBeGreaterThan(0);
expect(result.warnings[0]).toContain('soft hold window');
});
it('flags the overweight booking as invalid', async () => {
const bookings = [makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT')];
const bookings = [
makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT', 80, undefined, undefined, undefined, {
bookingContainers: [
{
id: 'b6-line',
containerTypeId: 'ct-1',
quantity: 80,
wagonsRequired: 80,
vgmPerUnitTons: 45,
isOverweight: true,
containerType: { code: '40FT', label: '40FT' },
},
],
}),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Booking') {
return { find: jest.fn().mockResolvedValue(bookings) };
}
if (entity?.name === 'TrainScheduleBooking') {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity?.name === 'Locomotive') {
return {
count: jest.fn().mockResolvedValue(1),
find: jest.fn().mockResolvedValue([locomotive]),
};
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b6'],
@@ -158,36 +290,70 @@ describe('TrainSchedulingService', () => {
});
expect(result.valid).toBe(false);
expect(result.summary.totalWeightTons).toBe(3600);
expect(result.violations).toContain(
'Total booking weight 3600T exceeds max train weight 3500T',
expect(result.violations.some((v) => v.includes('overweight'))).toBe(true);
});
it('allows preview when bookings are already on the target schedule', async () => {
const bookings = [makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20)];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([
{ bookingId: 'b1', trainScheduleId: 'sched-target' },
]);
trainSchedulesRepository.findById.mockResolvedValue({
id: 'sched-target',
direction: 'IMPORT',
});
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
targetScheduleId: 'sched-target',
});
expect(result.violations).not.toContain(
'One or more selected bookings are already assigned to a train schedule',
);
expect(result.valid).toBe(true);
});
it('allows preview when selected bookings are on different schedule dates', async () => {
const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: bookings.map((b) => b.id),
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.violations).not.toContain(
'Selected bookings must share the same schedule date',
);
expect(result.valid).toBe(true);
});
it('rejects bookings that are not in schedulable status', async () => {
const bookings = [
{
...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT'),
status: 'APPROVED',
},
{ ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2), status: 'APPROVED' },
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Booking') {
return { find: jest.fn().mockResolvedValue(bookings) };
}
if (entity?.name === 'TrainScheduleBooking') {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity?.name === 'Locomotive') {
return {
count: jest.fn().mockResolvedValue(1),
find: jest.fn().mockResolvedValue([locomotive]),
};
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b7'],
@@ -239,13 +405,16 @@ describe('TrainSchedulingService', () => {
};
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Route') {
dataSource.getRepository.mockImplementation((entity: unknown) => {
if ((entity as { name?: string })?.name === 'Route') {
return { findOne: jest.fn().mockResolvedValue(route) };
}
throw new Error(`Unexpected repository ${entity?.name}`);
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
throw new Error(`Unexpected repository ${(entity as { name?: string })?.name}`);
});
jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never);
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'schedule-1' });
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
@@ -259,7 +428,62 @@ describe('TrainSchedulingService', () => {
expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
expect(result).toEqual({ id: 'schedule-1' });
expect(result.id).toBe('schedule-1');
});
it('previews mixed container and bulk bookings', async () => {
const containerBooking = makeBooking('c1', 'BKG-CONT', 100, 2, '40FT', 2);
const bulkBooking = {
id: 'b1',
reference: 'BKG-BULK',
freightType: 'BULK',
cargoTotalWeightVgm: 120,
scheduledDate: new Date('2026-06-20T08:00:00.000Z'),
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
status: 'PAID',
bookingContainers: [],
cargoType: { code: 'COFFEE' },
};
wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
if (where?.code === 'NW5') return [nw5];
return [nw5, cw3];
});
bookingsRepository.findByIdsForScheduling.mockResolvedValue([containerBooking, bulkBooking]);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewTrainSchedule({
bookingIds: ['c1', 'b1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.valid).toBe(true);
expect(result.summary.wagonType).toBe('MIXED');
expect(result.wagonPlan.length).toBeGreaterThan(2);
expect(result.containerUnits).toHaveLength(2);
});
it('previews container bookings without requiring placements', async () => {
const bookings = [makeBooking('c2', 'BKG-CONT-2', 50, 1, '40FT', 1)];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewTrainSchedule({
bookingIds: ['c2'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.valid).toBe(true);
expect(result.containerUnits).toHaveLength(1);
});
it('rejects create when the locked locomotive is no longer available', async () => {
@@ -296,4 +520,48 @@ describe('TrainSchedulingService', () => {
}),
).rejects.toBeInstanceOf(ConflictException);
});
it('rejects pin when wagon readiness does not match schedule direction', async () => {
const scheduleId = 'sched-1';
const slotId = 'slot-1';
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: scheduleId,
status: 'DRAFT',
direction: 'IMPORT',
trainSet: {
wagons: [{ id: slotId, physicalWagonId: null }],
},
});
const manager = {
getRepository: jest.fn((entity: { name?: string }) => {
if (entity === Wagon) {
return {
findOne: jest.fn().mockResolvedValue({
id: 'wagon-1',
wagonNumber: 'WGN-001',
status: WagonStatus.Available,
readiness: WagonReadiness.ExportReady,
currentTrainScheduleId: null,
}),
update: jest.fn(),
};
}
if (entity === TrainSetWagon) {
return { update: jest.fn() };
}
throw new Error(`Unexpected repository ${entity?.name}`);
}),
};
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<void>) =>
callback(manager),
);
await expect(
service.pinWagons(scheduleId, {
assignments: [{ trainSetWagonId: slotId, physicalWagonId: 'wagon-1' }],
}),
).rejects.toBeInstanceOf(ConflictException);
});
});