auto allocation and batch managemnt, tracking the train

This commit is contained in:
marshal
2026-06-12 11:42:46 +03:00
parent 8618ea2aa8
commit ef0abf1c41
61 changed files with 3541 additions and 378 deletions

View File

@@ -1,4 +1,4 @@
import { ConflictException } from '@nestjs/common';
import { BadRequestException, ConflictException } from '@nestjs/common';
import { WagonReadiness, WagonStatus } from '@edr/types';
import { Wagon } from '../wagons/entities/wagon.entity';
@@ -25,6 +25,7 @@ const locomotive = {
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
status: 'AVAILABLE',
readiness: WagonReadiness.ImportReady,
};
const cw3 = {
@@ -420,6 +421,9 @@ describe('TrainSchedulingService', () => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5, cw3]) };
}
throw new Error(`Unexpected repository ${(entity as { name?: string })?.name}`);
});
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'schedule-1' });
@@ -572,4 +576,202 @@ describe('TrainSchedulingService', () => {
}),
).rejects.toBeInstanceOf(ConflictException);
});
it('flags physical fleet shortfall when export schedule lacks EXPORT_READY wagons', async () => {
const exportBooking = makeBooking(
'exp-1',
'BKG-EXP',
50,
1,
'40FT',
1,
'2026-06-20T08:00:00.000Z',
'yard-addis',
'yard-djibouti',
{
originYard: { label: 'Addis Ababa', code: 'ADDIS', country: 'Ethiopia' },
destinationYard: { label: 'Djibouti', code: 'DJIBOUTI', country: 'Djibouti' },
},
);
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue([exportBooking]);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const importOnlyFleet = Array.from({ length: 5 }, (_, index) => ({
id: `wagon-nw5-${index}`,
wagonTypeId: nw5.id,
wagonNumber: `WGN-${index}`,
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(importOnlyFleet) };
}
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: ['exp-1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-addis',
destinationStationId: 'yard-djibouti',
});
expect(result.valid).toBe(false);
expect(
result.violations.some((v) => v.includes('EXPORT_READY') && v.includes('NW5')),
).toBe(true);
});
it('assignBookingsToSchedule rejects when physical wagons cannot be pinned', async () => {
const scheduleId = 'sched-assign-1';
const trainSetId = 'train-set-1';
const booking = makeBooking('b-pin', 'BKG-PIN', 50, 1, '40FT', 1);
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue([{ ...booking, trainScheduleId: scheduleId }]);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
trainSchedulesRepository.findById.mockResolvedValue({
id: scheduleId,
direction: 'IMPORT',
});
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: scheduleId,
status: 'DRAFT',
direction: 'IMPORT',
trainSetId,
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
trainSet: {
id: trainSetId,
locomotive,
wagons: [],
},
scheduleBookings: [],
});
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5]) };
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
const wagonRepo = {
find: jest.fn().mockResolvedValue([]),
update: jest.fn(),
};
const trainSetWagonRepo = {
delete: jest.fn(),
create: jest.fn((v) => v),
save: jest.fn(async (rows) =>
rows.map((r: { sequenceNo: number; wagonTypeId: string }, i: number) => ({
...r,
id: `slot-${i + 1}`,
})),
),
update: jest.fn(),
};
const manager = {
getRepository: jest.fn((entity: unknown) => {
if (entity === Wagon) return wagonRepo;
if (entity === WagonType) return { find: jest.fn().mockResolvedValue([nw5]) };
if (entity === TrainSetWagon) return trainSetWagonRepo;
if ((entity as { name?: string })?.name === 'TrainSet') return { update: jest.fn() };
if ((entity as { name?: string })?.name === 'TrainScheduleBooking') return { delete: jest.fn() };
if ((entity as { name?: string })?.name === 'WagonBookingAllocation') {
return {
create: jest.fn((v) => v),
save: jest.fn(async (v) => ({ ...v, id: 'alloc-1' })),
delete: jest.fn(),
};
}
return { delete: jest.fn(), update: jest.fn(), find: jest.fn().mockResolvedValue([]) };
}),
};
dataSource.transaction.mockImplementation(async (cb: (m: typeof manager) => Promise<void>) =>
cb(manager),
);
await expect(
service.assignBookingsToSchedule(
scheduleId,
{ bookingIds: ['b-pin'], containerPlacements: [] },
'CONTAINER',
),
).rejects.toBeInstanceOf(BadRequestException);
});
describe('getAvailableLocomotivesForRoute', () => {
it('filters to export-ready locomotives on Ethiopia → Djibouti routes', async () => {
const routeId = 'route-export';
const routeRepo = {
findOne: jest.fn().mockResolvedValue({
id: routeId,
name: 'Addis → Djibouti',
isActive: true,
originYard: { country: 'Ethiopia' },
destinationYard: { country: 'Djibouti' },
}),
};
dataSource.getRepository.mockImplementation((entity: unknown) => {
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
return { findOne: jest.fn(), update: jest.fn() };
});
locomotivesRepository.findAll.mockResolvedValue([
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', readiness: WagonReadiness.ImportReady },
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', readiness: WagonReadiness.ExportReady },
]);
const result = await service.getAvailableLocomotivesForRoute(routeId);
expect(result).toHaveLength(1);
expect(result[0].code).toBe('EXP');
});
it('returns all available locomotives on domestic routes', async () => {
const routeId = 'route-domestic';
const routeRepo = {
findOne: jest.fn().mockResolvedValue({
id: routeId,
name: 'Addis → Dire Dawa',
isActive: true,
originYard: { country: 'Ethiopia' },
destinationYard: { country: 'Ethiopia' },
}),
};
dataSource.getRepository.mockImplementation((entity: unknown) => {
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
return { findOne: jest.fn(), update: jest.fn() };
});
locomotivesRepository.findAll.mockResolvedValue([
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', readiness: WagonReadiness.ImportReady },
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', readiness: WagonReadiness.ExportReady },
]);
const result = await service.getAvailableLocomotivesForRoute(routeId);
expect(result).toHaveLength(2);
});
});
});