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

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
export class AssignUnassignedBookingDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
bookingId!: string;
}

View File

@@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
export class AvailableLocomotivesQueryDto {
@ApiProperty({ format: 'uuid', description: 'Route used to derive import/export/domestic readiness' })
@ApiProperty({ format: 'uuid', description: 'Route used to filter locomotives at the origin yard' })
@IsUUID()
routeId!: string;
}

View File

@@ -16,6 +16,7 @@ import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
import { AssignUnassignedBookingDto } from './dto/assign-unassigned-booking.dto';
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
@@ -79,7 +80,7 @@ export class TrainSchedulingController {
@Get('available-locomotives')
@TrainSchedulingView()
@ApiOperation({
summary: 'List AVAILABLE locomotives filtered by route corridor readiness',
summary: 'List AVAILABLE locomotives at the route origin yard',
})
getAvailableLocomotives(@Query() query: AvailableLocomotivesQueryDto) {
return this.trainSchedulingService.getAvailableLocomotivesForRoute(query.routeId);
@@ -213,6 +214,18 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getUnassignedBookings(id);
}
@Post('schedules/:id/assign-unassigned-booking')
@TrainSchedulingManage()
@ApiOperation({
summary: 'Assign one linked unallocated booking to wagons (preserves existing assignments)',
})
assignUnassignedBooking(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AssignUnassignedBookingDto,
) {
return this.trainSchedulingService.assignUnassignedBookingToWagons(id, dto.bookingId);
}
@Get('schedules/:id/composition-removals')
@TrainSchedulingView()
@ApiOperation({ summary: 'Get removal log for a schedule' })
@@ -315,7 +328,7 @@ export class TrainSchedulingController {
@Post('schedules/:id/arrive')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Mark a dispatched train arrived (flip readiness, free assets)' })
@ApiOperation({ summary: 'Mark a dispatched train arrived (move assets to destination yard, free assets)' })
arriveSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.arriveSchedule(id);
}

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);

View File

@@ -55,6 +55,7 @@ import {
selectBookingsWithinFleetCap,
summarizeFleetWarnings,
totalAssignedWeight,
wagonsRequiredForBooking,
type DeferredBookingRow,
type FleetAvailabilityRow,
} from './fleet-plan.util';
@@ -78,11 +79,6 @@ import {
pickBulkWagonType,
} from './wagon-type-resolver.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import {
flipReadiness,
requiredWagonReadiness,
wagonReadinessMatchesSchedule,
} from './wagon-readiness.util';
import {
deriveTrainCapacityFromLocomotive,
wagonTypeDimensionsFromEntity,
@@ -124,6 +120,26 @@ export interface WagonAllocationAttemptResult {
violations: string[];
}
export interface CompositionUnassignedBookingRow {
id: string;
reference: string | null;
freightType: string | null;
priorityScore: number;
cargoTotalWeightVgm: number;
status: string | null;
schedulingStatus: string | null;
wagonsRequired: number;
requiredWagonTypeCode: string;
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
}
export interface UnassignedBookingsResponse {
fleetAtOrigin: FleetAvailabilityRow[];
bookings: CompositionUnassignedBookingRow[];
}
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
maxWeightTons: 3500,
maxLengthMeters: 760,
@@ -273,9 +289,9 @@ export class TrainSchedulingService {
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
);
if (!wagonReadinessMatchesSchedule(lockedLocomotive.readiness, direction)) {
if (lockedLocomotive.currentYardId !== route.originYardId) {
throw new ConflictException(
`Locomotive ${lockedLocomotive.code} is ${lockedLocomotive.readiness} and cannot run a ${direction} schedule`,
`Locomotive ${lockedLocomotive.code} is at yard ${lockedLocomotive.currentYardId} but schedule originates from ${route.originYardId}`,
);
}
@@ -462,7 +478,7 @@ export class TrainSchedulingService {
await this.autoPinWagonsForSchedule(
manager,
scheduleId,
schedule.direction ?? null,
schedule.originStationId,
savedWagons,
);
});
@@ -584,9 +600,9 @@ export class TrainSchedulingService {
`Wagon ${physicalWagon.wagonNumber} is not available`,
);
}
if (!wagonReadinessMatchesSchedule(physicalWagon.readiness, schedule.direction)) {
if (physicalWagon.currentYardId !== schedule.originStationId) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is ${physicalWagon.readiness} but schedule is ${schedule.direction ?? 'unknown'}`,
`Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
);
}
@@ -846,8 +862,8 @@ export class TrainSchedulingService {
}
/**
* Mark a dispatched train arrived: close out the schedule, flip readiness on the
* locomotive + wagons (they have repositioned), and free the assets for re-use.
* Mark a dispatched train arrived: close out the schedule, move the locomotive
* and wagons to the destination yard, and free the assets for re-use.
*/
async arriveSchedule(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
@@ -858,7 +874,6 @@ export class TrainSchedulingService {
throw new BadRequestException('Only DISPATCHED trains can arrive');
}
const isDomestic = schedule.direction === 'DOMESTIC';
const now = new Date();
await this.dataSource.transaction(async (manager) => {
@@ -882,7 +897,7 @@ export class TrainSchedulingService {
if (loco) {
await manager.getRepository(Locomotive).update(loco.id, {
status: 'AVAILABLE',
readiness: isDomestic ? loco.readiness : flipReadiness(loco.readiness),
currentYardId: schedule.destinationStationId,
});
}
}
@@ -897,7 +912,7 @@ export class TrainSchedulingService {
currentTrainScheduleId: null,
trainSetWagonId: null,
status: WagonStatus.Available,
readiness: isDomestic ? wagon.readiness : flipReadiness(wagon.readiness),
currentYardId: schedule.destinationStationId,
});
}
@@ -1047,11 +1062,15 @@ export class TrainSchedulingService {
}
if (
bookings.some(
(b) =>
bookings.some((b) => {
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
return false;
}
return (
b.originYardId !== dto.originStationId ||
b.destinationYardId !== dto.destinationStationId,
)
b.destinationYardId !== dto.destinationStationId
);
})
) {
violations.push('Selected bookings must share the same origin and destination as the schedule');
}
@@ -1102,8 +1121,8 @@ export class TrainSchedulingService {
: buildBulkWagonPlan(bookings, wagonType);
}
const scheduleDirection = await this.resolveScheduleDirection(targetScheduleId, bookings);
const fleetCounts = await this.countFleetAvailability(scheduleDirection, targetScheduleId);
const originYardId = dto.originStationId;
const fleetCounts = await this.countFleetAvailability(originYardId, targetScheduleId);
const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available]));
fleetAvailability = computeFleetAvailability(
demandPlan,
@@ -1132,7 +1151,7 @@ export class TrainSchedulingService {
violations.push(
...(await this.validatePhysicalFleetForPlan(
wagonPlan,
scheduleDirection,
originYardId,
targetScheduleId,
)),
);
@@ -1189,26 +1208,43 @@ export class TrainSchedulingService {
}
}
const availableLocomotives = (
await this.locomotivesRepository.findAll({
where: { status: 'AVAILABLE' },
})
).filter((l) => wagonReadinessMatchesSchedule(l.readiness, scheduleDirection));
if (!availableLocomotives.length) {
const readinessHint = requiredWagonReadiness(scheduleDirection);
violations.push(
readinessHint
? `No available ${readinessHint} locomotive exists for this ${scheduleDirection} schedule`
: 'No available locomotive exists for scheduling',
);
} else if (
!availableLocomotives.some(
(l) =>
Number(l.maxPullWeightTons) >= totalWeightTons &&
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
)
) {
violations.push('No available locomotive can support the total train weight and length');
let assignedLocomotive: Locomotive | null = null;
if (targetScheduleId) {
const targetSchedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
assignedLocomotive = targetSchedule?.trainSet?.locomotive ?? null;
}
if (assignedLocomotive) {
if (assignedLocomotive.currentYardId !== originYardId) {
violations.push(
`Locomotive ${assignedLocomotive.code} is not at the schedule origin yard`,
);
} else if (
Number(assignedLocomotive.maxPullWeightTons) < totalWeightTons ||
Number(assignedLocomotive.maxTrainLengthMeters) < totalLengthMeters
) {
violations.push(
'Assigned locomotive cannot support the total train weight and length',
);
}
} else {
const availableLocomotives = (
await this.locomotivesRepository.findAll({
where: { status: 'AVAILABLE' },
})
).filter((l) => l.currentYardId === originYardId);
if (!availableLocomotives.length) {
violations.push('No available locomotive at the schedule origin yard');
} else if (
!availableLocomotives.some(
(l) =>
Number(l.maxPullWeightTons) >= totalWeightTons &&
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
)
) {
violations.push('No available locomotive can support the total train weight and length');
}
}
return {
@@ -1353,26 +1389,8 @@ export class TrainSchedulingService {
];
}
private async resolveScheduleDirection(
targetScheduleId: string | undefined,
bookings: Booking[],
): Promise<string | null> {
if (targetScheduleId) {
const schedule = await this.trainSchedulesRepository.findById(targetScheduleId);
if (schedule?.direction) return schedule.direction;
}
const booking = bookings[0];
if (!booking) return null;
return deriveScheduleDirection(
booking.originYard ?? { country: null },
booking.destinationYard ?? { country: null },
);
}
private async countFleetAvailability(
scheduleDirection: string | null,
originYardId: string,
targetScheduleId?: string,
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
const [wagons, wagonTypes] = await Promise.all([
@@ -1387,7 +1405,7 @@ export class TrainSchedulingService {
? wagon.currentTrainScheduleId === targetScheduleId
: false;
if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue;
if (!wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection)) continue;
if (wagon.currentYardId !== originYardId) continue;
const typeId = wagon.wagonTypeId;
const code = typeCodeById.get(typeId) ?? typeId;
@@ -1418,7 +1436,7 @@ export class TrainSchedulingService {
private async autoPinWagonsForSchedule(
manager: EntityManager,
scheduleId: string,
scheduleDirection: string | null,
originYardId: string,
slots: TrainSetWagon[],
) {
const wagons = await manager.getRepository(Wagon).find();
@@ -1438,7 +1456,7 @@ export class TrainSchedulingService {
planSlots,
wagons,
scheduleId,
scheduleDirection,
originYardId,
);
if (unpinnable.length) {
throw new BadRequestException({
@@ -1453,7 +1471,7 @@ export class TrainSchedulingService {
slot,
wagons,
scheduleId,
scheduleDirection,
originYardId,
assignedPhysicalIds,
);
if (!physical) continue;
@@ -1474,7 +1492,7 @@ export class TrainSchedulingService {
/** Pre-assign check: every planned slot must have a matching physical wagon. */
private async validatePhysicalFleetForPlan(
wagonPlan: WagonPlanSlot[],
scheduleDirection: string | null,
originYardId: string,
targetScheduleId?: string,
): Promise<string[]> {
if (!wagonPlan.length) return [];
@@ -1488,7 +1506,7 @@ export class TrainSchedulingService {
})),
wagons,
targetScheduleId,
scheduleDirection,
originYardId,
);
}
@@ -1496,24 +1514,22 @@ export class TrainSchedulingService {
slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>,
wagons: Wagon[],
scheduleId: string | undefined,
scheduleDirection: string | null,
originYardId: string,
): string[] {
const violations: string[] = [];
const assignedPhysicalIds = new Set<string>();
const required = requiredWagonReadiness(scheduleDirection);
const readinessLabel = required ?? 'any readiness';
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
const physical = this.pickPhysicalWagonForSlot(
slot,
wagons,
scheduleId,
scheduleDirection,
originYardId,
assignedPhysicalIds,
);
if (!physical) {
violations.push(
`No ${readinessLabel} ${slot.wagonTypeCode} wagon available for slot #${slot.sequenceNo}`,
`No ${slot.wagonTypeCode} wagon available at yard for slot #${slot.sequenceNo}`,
);
continue;
}
@@ -1527,7 +1543,7 @@ export class TrainSchedulingService {
slot: { wagonTypeId: string },
wagons: Wagon[],
scheduleId: string | undefined,
scheduleDirection: string | null,
originYardId: string,
assignedPhysicalIds: Set<string>,
): Wagon | undefined {
return wagons.find((wagon) => {
@@ -1537,7 +1553,7 @@ export class TrainSchedulingService {
? wagon.currentTrainScheduleId === scheduleId
: false;
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false;
return wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection);
return wagon.currentYardId === originYardId;
});
}
@@ -1853,7 +1869,7 @@ export class TrainSchedulingService {
id: schedule.trainSet.locomotive.id,
code: schedule.trainSet.locomotive.code,
name: schedule.trainSet.locomotive.name ?? null,
readiness: schedule.trainSet.locomotive.readiness ?? null,
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
}
: null,
wagonCount: schedule.trainSet?.wagonCount ?? 0,
@@ -1871,47 +1887,80 @@ export class TrainSchedulingService {
};
}
/** AVAILABLE locomotives whose readiness matches the corridor implied by the route. */
/** AVAILABLE locomotives at the route's origin yard. */
async getAvailableLocomotivesForRoute(routeId: string): Promise<Locomotive[]> {
const route = await this.getActiveRoute(routeId);
const direction = deriveScheduleDirection(
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
);
const requiredReadiness = requiredWagonReadiness(direction);
const locomotives = await this.locomotivesRepository.findAll({
where: { status: 'AVAILABLE' },
where: { status: 'AVAILABLE', currentYardId: route.originYardId },
order: { code: 'ASC' },
});
if (!requiredReadiness) {
return locomotives;
}
return locomotives.filter((l) => wagonReadinessMatchesSchedule(l.readiness, direction));
return locomotives;
}
/** OPEN, same-route schedules a new booking may target (with rough remaining capacity). */
/** OPEN schedules a new booking may target (with rough remaining capacity).
* Supports sub-route matching: if originYardId and/or destinationYardId are provided,
* returns schedules whose route passes through both yards in the correct order.
*/
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
const schedules = await this.trainSchedulesRepository.findAll({
where: {
bookingWindowStatus: 'OPEN',
...(originYardId ? { originStationId: originYardId } : {}),
...(destinationYardId ? { destinationStationId: destinationYardId } : {}),
},
relations: {
trainSet: { locomotive: true },
route: true,
route: { milestones: true },
originStation: true,
destinationStation: true,
scheduleBookings: { booking: true },
},
order: { scheduledDepartureDate: 'ASC' },
});
return schedules
const filteredSchedules = schedules
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
.filter((s) => {
// Build the full stop list: origin -> milestones (ordered) -> destination
const milestones = s.route?.milestones ?? [];
const sortedMilestones = [...milestones].sort((a, b) => a.sequenceNo - b.sequenceNo);
const stopYardIds = [s.originStationId, ...sortedMilestones.map((m) => m.yardId), s.destinationStationId];
// Remove duplicates while preserving order (in case origin/destination appears in milestones)
const uniqueStopYardIds: string[] = [];
for (const yardId of stopYardIds) {
if (!uniqueStopYardIds.includes(yardId)) {
uniqueStopYardIds.push(yardId);
}
}
// Check origin yard filter
if (originYardId) {
if (!uniqueStopYardIds.includes(originYardId)) {
return false;
}
}
// Check destination yard filter
if (destinationYardId) {
if (!uniqueStopYardIds.includes(destinationYardId)) {
return false;
}
// Ensure destination comes after origin (if both are specified)
if (originYardId) {
const originIndex = uniqueStopYardIds.indexOf(originYardId);
const destIndex = uniqueStopYardIds.indexOf(destinationYardId);
if (destIndex <= originIndex) {
return false;
}
}
}
return true;
})
.map((s) => this.mapScheduleListItem(s));
return filteredSchedules;
}
private async mapScheduleDetail(
@@ -1971,7 +2020,7 @@ export class TrainSchedulingService {
code: schedule.trainSet.locomotive.code,
name: schedule.trainSet.locomotive.name,
status: schedule.trainSet.locomotive.status,
readiness: schedule.trainSet.locomotive.readiness ?? null,
currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
maxPullWeightTons: roundTons(
Number(schedule.trainSet.locomotive.maxPullWeightTons),
),
@@ -2050,6 +2099,102 @@ export class TrainSchedulingService {
return SchedulingStatus.Eligible;
}
/** Assign one linked-but-unallocated booking onto wagons, preserving existing wagon assignments. */
async assignUnassignedBookingToWagons(scheduleId: string, bookingId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!schedule.trainSet?.locomotive) {
throw new BadRequestException('Schedule has no locomotive — cannot assign booking');
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot assign bookings to schedule in status ${schedule.status}`,
);
}
const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
if (booking.trainScheduleId !== scheduleId) {
throw new BadRequestException('Booking is not linked to this schedule');
}
if (!this.isReadyToLoadBooking(booking)) {
throw new BadRequestException('Booking is not paid and ready to load');
}
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
if (wagonAssignedIds.has(bookingId)) {
throw new BadRequestException('Booking is already assigned to a wagon');
}
const allBookingIds = [...wagonAssignedIds, bookingId];
const previewDto = {
bookingIds: allBookingIds,
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
};
const limits = await this.resolveTrainLimitConfig(undefined, schedule.trainSet.locomotive);
const validation = await this.validateBookingsForScheduling(
previewDto,
null,
false,
[],
false,
limits,
scheduleId,
);
if (!validation.valid) {
throw new BadRequestException({
message: 'Booking validation failed',
violations: validation.violations,
warnings: validation.warnings,
});
}
if (!validation.bookings.some((b) => b.id === bookingId)) {
const deferred = validation.deferredBookings.find((d) => d.id === bookingId);
throw new BadRequestException({
message: deferred?.reason ?? 'Booking does not fit on available fleet wagons',
violations: validation.violations,
warnings: validation.warnings,
deferredBookings: validation.deferredBookings,
});
}
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
const placements = autoFillPlacements(units, slots);
const missingForBooking = findMissingContainerNumberIssues(units, placements).find(
(m) => m.bookingId === bookingId,
);
if (missingForBooking) {
throw new BadRequestException({
message: missingForBooking.issue,
violations: [missingForBooking.issue],
});
}
const assignableSet = new Set(validation.bookings.map((b) => b.id));
const assignPlacements = placementsForBookings(placements, assignableSet, units);
const needsPlacements = containerBookings.length > 0;
return this.assignBookingsToSchedule(
scheduleId,
{
bookingIds: validation.bookings.map((b) => b.id),
containerPlacements: needsPlacements ? assignPlacements : undefined,
},
undefined,
);
}
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
async previewAllocationForSchedule(
scheduleId: string,
@@ -2331,7 +2476,7 @@ export class TrainSchedulingService {
return { id: itemId, containerNumber: dto.containerNumber ?? null };
}
async getUnassignedBookings(scheduleId: string): Promise<any[]> {
async getUnassignedBookings(scheduleId: string): Promise<UnassignedBookingsResponse> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
@@ -2339,13 +2484,213 @@ export class TrainSchedulingService {
const allBookings = await this.bookingsRepository.findAll({
where: { trainScheduleId: scheduleId },
select: ['id', 'reference', 'freightType', 'priorityScore', 'cargoTotalWeightVgm', 'status', 'schedulingStatus'],
select: [
'id',
'reference',
'freightType',
'priorityScore',
'cargoTotalWeightVgm',
'status',
'schedulingStatus',
'paymentStatus',
'isGovernment',
],
});
const allocatedBookingIds = await this.getWagonAssignedBookingIds(scheduleId);
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
const unassigned = allBookings.filter((b: any) => !allocatedBookingIds.has(b.id));
return unassigned.sort((a: any, b: any) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0));
const unassigned = allBookings
.filter((b) => !wagonAssignedIds.has(b.id) && this.isReadyToLoadBooking(b))
.sort((a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0));
const fleetCounts = await this.countFleetAvailability(
schedule.originStationId,
scheduleId,
);
const fleetByTypeId = new Map(
fleetCounts.map((row) => [
row.wagonTypeId,
{ code: row.wagonTypeCode, available: row.available },
]),
);
const fleetAtOrigin: FleetAvailabilityRow[] = fleetCounts.map((row) => ({
wagonTypeId: row.wagonTypeId,
wagonTypeCode: row.wagonTypeCode,
needed: 0,
available: row.available,
shortfall: 0,
}));
const bookings = await Promise.all(
unassigned.map(async (b) => {
const assignability = await this.previewUnassignedBookingAssignability(
schedule,
wagonAssignedIds,
b as Booking,
fleetByTypeId,
);
return {
id: b.id,
reference: b.reference ?? null,
freightType: b.freightType ?? null,
priorityScore: b.priorityScore ?? 0,
cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0),
status: b.status ?? null,
schedulingStatus: b.schedulingStatus ?? null,
...assignability,
};
}),
);
return { fleetAtOrigin, bookings };
}
private async previewUnassignedBookingAssignability(
schedule: TrainSchedule,
wagonAssignedIds: Set<string>,
booking: Booking,
fleetByTypeId: Map<string, { code: string; available: number }>,
): Promise<{
wagonsRequired: number;
requiredWagonTypeCode: string;
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
}> {
if (!schedule.trainSet?.locomotive) {
return {
wagonsRequired: 0,
requiredWagonTypeCode: '',
yardWagonsAvailable: 0,
canAssign: false,
blockReason: 'Schedule has no locomotive',
};
}
const freightType = booking.freightType === 'BULK' ? 'BULK' : 'CONTAINER';
let wagonType: WagonType;
try {
wagonType = await this.resolveWagonType(freightType, [booking.id]);
} catch {
return {
wagonsRequired: 0,
requiredWagonTypeCode: '',
yardWagonsAvailable: 0,
canAssign: false,
blockReason: 'No suitable wagon type found',
};
}
const bulkCapacity =
freightType === 'BULK' ? Number(wagonType.capacityTons) : undefined;
const [fullBooking] = await this.bookingsRepository.findByIdsForScheduling([booking.id]);
const resolvedBooking = fullBooking ?? booking;
const wagonsRequired = wagonsRequiredForBooking(resolvedBooking, bulkCapacity);
const yardWagonsAvailable = fleetByTypeId.get(wagonType.id)?.available ?? 0;
const allBookingIds = [...wagonAssignedIds, booking.id];
const previewDto = {
bookingIds: allBookingIds,
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
};
const limits = await this.resolveTrainLimitConfig(
undefined,
schedule.trainSet.locomotive,
);
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
try {
validation = await this.validateBookingsForScheduling(
previewDto,
null,
false,
[],
false,
limits,
schedule.id,
);
} catch (err) {
return {
wagonsRequired,
requiredWagonTypeCode: wagonType.code,
yardWagonsAvailable,
canAssign: false,
blockReason: err instanceof Error ? err.message : 'Validation failed',
};
}
if (!validation.valid) {
return {
wagonsRequired,
requiredWagonTypeCode: wagonType.code,
yardWagonsAvailable,
canAssign: false,
blockReason: validation.violations[0] ?? 'Booking validation failed',
};
}
const fittingIds = new Set(validation.bookings.map((b) => b.id));
if (!fittingIds.has(booking.id)) {
const deferred = validation.deferredBookings.find((d) => d.id === booking.id);
const yardShortfall =
yardWagonsAvailable < wagonsRequired
? `No ${wagonType.code} wagons at origin yard (need ${wagonsRequired}, ${yardWagonsAvailable} available)`
: null;
return {
wagonsRequired,
requiredWagonTypeCode: wagonType.code,
yardWagonsAvailable,
canAssign: false,
blockReason:
deferred?.reason ??
yardShortfall ??
`Need ${wagonsRequired} ${wagonType.code} wagon(s) at origin yard`,
};
}
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
if (containerBookings.some((b) => b.id === booking.id)) {
const units = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
const placements = autoFillPlacements(units, slots);
const missing = findMissingContainerNumberIssues(units, placements).find(
(m) => m.bookingId === booking.id,
);
if (missing) {
return {
wagonsRequired,
requiredWagonTypeCode: wagonType.code,
yardWagonsAvailable,
canAssign: false,
blockReason: missing.issue,
};
}
}
return {
wagonsRequired,
requiredWagonTypeCode: wagonType.code,
yardWagonsAvailable,
canAssign: true,
blockReason: null,
};
}
/** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */
private isReadyToLoadBooking(booking: {
status: string;
paymentStatus?: string | null;
isGovernment?: boolean;
}): boolean {
if (booking.status === 'EXPIRED') return false;
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
return false;
}
if (booking.status === 'PAID' || booking.paymentStatus === 'PAID') return true;
if (booking.isGovernment) return true;
return false;
}
async getCompositionRemovals(scheduleId: string): Promise<any[]> {

View File

@@ -1,5 +1,6 @@
import { WagonReadiness, type ScheduleTradeDirection } from '@edr/types';
/** @deprecated Replaced by yard-based fleet filtering via `currentYardId`. */
export function requiredWagonReadiness(
direction: ScheduleTradeDirection | string | null | undefined,
): WagonReadiness | null {
@@ -8,6 +9,7 @@ export function requiredWagonReadiness(
return null;
}
/** @deprecated Replaced by `wagon.currentYardId === originYardId` checks. */
export function wagonReadinessMatchesSchedule(
wagonReadiness: WagonReadiness | string,
direction: ScheduleTradeDirection | string | null | undefined,
@@ -18,9 +20,7 @@ export function wagonReadinessMatchesSchedule(
}
/**
* Toggle a readiness value (IMPORT_READY ↔ EXPORT_READY). Used when a train
* reaches its destination: the asset has repositioned, so it is now ready for
* the opposite direction. Direction-agnostic so it handles round trips.
* @deprecated Replaced by setting `currentYardId = schedule.destinationStationId` on arrival.
*/
export function flipReadiness(
readiness: WagonReadiness | string,