Replace wagon/locomotive readiness with yard tracking, assign unassigned bookings from origin-yard fleet, standardize rates on USD with CBE ETB conversion, and update fleet/scheduling UI

This commit is contained in:
marshal
2026-06-14 01:32:39 +03:00
parent b73bf2154e
commit 87b0ce6339
45 changed files with 1249 additions and 520 deletions

View File

@@ -1,10 +1,11 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { WagonReadiness, WagonStatus } from '@edr/types';
import { 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 { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedulingService } from './train-scheduling.service';
const nw5 = {
@@ -25,7 +26,7 @@ const locomotive = {
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
status: 'AVAILABLE',
readiness: WagonReadiness.ImportReady,
currentYardId: 'yard-origin',
};
const cw3 = {
@@ -96,6 +97,7 @@ describe('TrainSchedulingService', () => {
bookingsRepository = {
findEligibleForScheduling: jest.fn(),
findByIdsForScheduling: jest.fn(),
findAll: jest.fn(),
updateSchedulingFields: jest.fn(),
};
locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() };
@@ -152,14 +154,14 @@ describe('TrainSchedulingService', () => {
id: `wagon-nw5-${index}`,
wagonTypeId: nw5.id,
status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady,
currentYardId: 'yard-origin',
currentTrainScheduleId: null,
})),
...Array.from({ length: 50 }, (_, index) => ({
id: `wagon-cw3-${index}`,
wagonTypeId: cw3.id,
status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady,
currentYardId: 'yard-origin',
currentTrainScheduleId: null,
})),
];
@@ -193,7 +195,7 @@ describe('TrainSchedulingService', () => {
id: `wagon-${index}`,
wagonTypeId: nw5.id,
status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady,
currentYardId: 'yard-origin',
currentTrainScheduleId: null,
}));
@@ -534,14 +536,14 @@ describe('TrainSchedulingService', () => {
).rejects.toBeInstanceOf(ConflictException);
});
it('rejects pin when wagon readiness does not match schedule direction', async () => {
it('rejects pin when wagon is not at the schedule origin yard', async () => {
const scheduleId = 'sched-1';
const slotId = 'slot-1';
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: scheduleId,
status: 'DRAFT',
direction: 'IMPORT',
originStationId: 'yard-origin',
trainSet: {
wagons: [{ id: slotId, physicalWagonId: null }],
},
@@ -555,7 +557,7 @@ describe('TrainSchedulingService', () => {
id: 'wagon-1',
wagonNumber: 'WGN-001',
status: WagonStatus.Available,
readiness: WagonReadiness.ExportReady,
currentYardId: 'yard-other',
currentTrainScheduleId: null,
}),
update: jest.fn(),
@@ -578,7 +580,7 @@ describe('TrainSchedulingService', () => {
).rejects.toBeInstanceOf(ConflictException);
});
it('flags physical fleet shortfall when export schedule lacks EXPORT_READY wagons', async () => {
it('flags physical fleet shortfall when wagons are not at the origin yard', async () => {
const exportBooking = makeBooking(
'exp-1',
'BKG-EXP',
@@ -598,14 +600,16 @@ describe('TrainSchedulingService', () => {
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue([exportBooking]);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
locomotivesRepository.findAll.mockResolvedValue([
{ ...locomotive, currentYardId: 'yard-addis' },
]);
const importOnlyFleet = Array.from({ length: 5 }, (_, index) => ({
const wrongYardFleet = Array.from({ length: 5 }, (_, index) => ({
id: `wagon-nw5-${index}`,
wagonTypeId: nw5.id,
wagonNumber: `WGN-${index}`,
status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady,
currentYardId: 'yard-djibouti',
currentTrainScheduleId: null,
}));
@@ -614,7 +618,7 @@ describe('TrainSchedulingService', () => {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(importOnlyFleet) };
return { find: jest.fn().mockResolvedValue(wrongYardFleet) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5]) };
@@ -631,7 +635,7 @@ describe('TrainSchedulingService', () => {
expect(result.valid).toBe(false);
expect(
result.violations.some((v) => v.includes('EXPORT_READY') && v.includes('NW5')),
result.violations.some((v) => v.includes('available at yard') && v.includes('NW5')),
).toBe(true);
});
@@ -723,14 +727,160 @@ describe('TrainSchedulingService', () => {
).rejects.toBeInstanceOf(BadRequestException);
});
describe('getUnassignedBookings', () => {
const scheduleId = 'sched-unassigned-1';
const trainSetId = 'train-set-unassigned';
const assignedBooking = makeBooking('b-assigned', 'BKG-ASSIGNED', 50, 1, '40FT', 1);
const unassignedBooking = makeBooking('b-unassigned', 'BKG-UNASSIGNED', 60, 1, '40FT', 1);
const buildScheduleGraph = () => ({
id: scheduleId,
status: 'DRAFT',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
trainSet: {
id: trainSetId,
locomotive: { ...locomotive, status: 'ASSIGNED', currentYardId: 'yard-origin' },
wagons: [{ id: 'slot-1', sequenceNo: 1, wagonTypeId: nw5.id, allocations: [] }],
},
scheduleBookings: [],
});
beforeEach(() => {
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
bookingsRepository.findAll.mockResolvedValue([
{
...assignedBooking,
trainScheduleId: scheduleId,
paymentStatus: 'PAID',
isGovernment: false,
},
{
...unassignedBooking,
trainScheduleId: scheduleId,
paymentStatus: 'PAID',
isGovernment: false,
},
]);
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(buildScheduleGraph());
});
it('allows assign when train slots are full but origin yard has matching wagons', async () => {
const yardFleet = [
{
id: 'wagon-pinned',
wagonTypeId: nw5.id,
status: WagonStatus.Assigned,
currentYardId: 'yard-origin',
currentTrainScheduleId: scheduleId,
},
...Array.from({ length: 2 }, (_, index) => ({
id: `wagon-yard-${index}`,
wagonTypeId: nw5.id,
status: WagonStatus.Available,
currentYardId: 'yard-origin',
currentTrainScheduleId: null,
})),
];
bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => {
const map = new Map([
[assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }],
[unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }],
]);
return ids.map((id) => map.get(id)).filter(Boolean);
});
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(yardFleet) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5]) };
}
if (entity === WagonBookingAllocation) {
return {
find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]),
};
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
const result = await service.getUnassignedBookings(scheduleId);
expect(result.bookings).toHaveLength(1);
expect(result.bookings[0].id).toBe(unassignedBooking.id);
expect(result.bookings[0].canAssign).toBe(true);
expect(result.bookings[0].blockReason).toBeNull();
expect(
result.fleetAtOrigin.some(
(row: { wagonTypeCode: string; available: number }) =>
row.wagonTypeCode === 'NW5' && row.available >= 2,
),
).toBe(true);
});
it('blocks assign when origin yard lacks wagons of the required type', async () => {
const yardFleet = [
{
id: 'wagon-pinned',
wagonTypeId: nw5.id,
status: WagonStatus.Assigned,
currentYardId: 'yard-origin',
currentTrainScheduleId: scheduleId,
},
];
bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => {
const map = new Map([
[assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }],
[unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }],
]);
return ids.map((id) => map.get(id)).filter(Boolean);
});
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(yardFleet) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5]) };
}
if (entity === WagonBookingAllocation) {
return {
find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]),
};
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
const result = await service.getUnassignedBookings(scheduleId);
expect(result.bookings).toHaveLength(1);
expect(result.bookings[0].canAssign).toBe(false);
expect(result.bookings[0].blockReason).toBeTruthy();
});
});
describe('getAvailableLocomotivesForRoute', () => {
it('filters to export-ready locomotives on Ethiopia → Djibouti routes', async () => {
it('returns locomotives at the route origin yard', async () => {
const routeId = 'route-export';
const originYardId = 'yard-addis';
const routeRepo = {
findOne: jest.fn().mockResolvedValue({
id: routeId,
name: 'Addis → Djibouti',
isActive: true,
originYardId,
originYard: { country: 'Ethiopia' },
destinationYard: { country: 'Djibouti' },
}),
@@ -740,23 +890,28 @@ describe('TrainSchedulingService', () => {
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 },
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
]);
const result = await service.getAvailableLocomotivesForRoute(routeId);
expect(locomotivesRepository.findAll).toHaveBeenCalledWith({
where: { status: 'AVAILABLE', currentYardId: originYardId },
order: { code: 'ASC' },
});
expect(result).toHaveLength(1);
expect(result[0].code).toBe('EXP');
});
it('returns all available locomotives on domestic routes', async () => {
it('returns all locomotives returned by the repository for domestic routes', async () => {
const routeId = 'route-domestic';
const originYardId = 'yard-addis';
const routeRepo = {
findOne: jest.fn().mockResolvedValue({
id: routeId,
name: 'Addis → Dire Dawa',
isActive: true,
originYardId,
originYard: { country: 'Ethiopia' },
destinationYard: { country: 'Ethiopia' },
}),
@@ -766,8 +921,8 @@ describe('TrainSchedulingService', () => {
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 },
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', currentYardId: originYardId },
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
]);
const result = await service.getAvailableLocomotivesForRoute(routeId);