mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 17:23:38 +00:00
booking operations and trains scheduling also allocations
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user