feat(freight): changed the train schedule

This commit is contained in:
Michael Abebe
2026-06-06 16:31:19 +03:00
parent a9c2f2eb97
commit b45421d2a2
7 changed files with 392 additions and 740 deletions

View File

@@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Route } from '../../routes/entities/route.entity';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { TrainScheduleBooking } from './train-schedule-booking.entity';
@@ -26,6 +27,13 @@ export class TrainSchedule extends BaseEntity {
@JoinColumn({ name: 'train_set_id' })
trainSet?: TrainSet;
@Column({ name: 'route_id', type: 'uuid', nullable: true })
routeId?: string | null;
@ManyToOne(() => Route)
@JoinColumn({ name: 'route_id' })
route?: Route | null;
@Column({ name: 'origin_station_id', type: 'uuid' })
originStationId!: string;

View File

@@ -1,9 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
import { IsDateString, IsUUID } from 'class-validator';
import { PreviewContainerTrainScheduleDto } from './preview-container-train-schedule.dto';
export class CreateContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
routeId!: string;
@ApiProperty({ example: '2026-06-20T08:00:00.000Z' })
@IsDateString()
scheduleDate!: string;
export class CreateContainerTrainScheduleDto extends PreviewContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
locomotiveId!: string;

View File

@@ -203,47 +203,12 @@ describe('TrainSchedulingService', () => {
});
it('creates a schedule transactionally when validation passes', async () => {
const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')];
const validation = {
valid: true,
violations: [],
bookings,
wagonType: nw5,
summary: {
totalBookings: 1,
totalWeightTons: 140,
wagonType: 'NW5',
wagonsNeeded: 2,
totalLengthMeters: 28,
},
wagonPlan: [
{
sequenceNo: 1,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [
{
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
},
],
},
{
sequenceNo: 2,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [
{
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
},
],
},
],
const route = {
id: 'route-1',
name: 'Djibouti to Addis',
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
isActive: true,
};
const lockedLocomotiveRepo = {
@@ -254,23 +219,6 @@ describe('TrainSchedulingService', () => {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
};
const trainScheduleBookingRepo = {
count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const trainSetWagonRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
find: jest.fn().mockResolvedValue([
{ id: 'wagon-1', sequenceNo: 1 },
{ id: 'wagon-2', sequenceNo: 2 },
]),
};
const wagonAllocRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const trainSetRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
@@ -282,12 +230,6 @@ describe('TrainSchedulingService', () => {
return lockedLocomotiveRepo;
case 'TrainSchedule':
return trainScheduleRepo;
case 'TrainScheduleBooking':
return trainScheduleBookingRepo;
case 'TrainSetWagon':
return trainSetWagonRepo;
case 'WagonBookingAllocation':
return wagonAllocRepo;
case 'TrainSet':
return trainSetRepo;
default:
@@ -296,70 +238,60 @@ describe('TrainSchedulingService', () => {
}),
};
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Route') {
return { findOne: jest.fn().mockResolvedValue(route) };
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never);
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
const result = await service.createContainerTrainSchedule({
bookingIds: ['b1'],
routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
locomotiveId: 'loc-1',
});
expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(trainSetWagonRepo.save).toHaveBeenCalled();
expect(wagonAllocRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
expect(result).toEqual({ id: 'schedule-1' });
});
it('rejects create when the locked locomotive is no longer available', async () => {
const validation = {
valid: true,
violations: [],
bookings: [makeBooking('b1', 'BKG-CONT-001', 70, 1, '40FT')],
wagonType: nw5,
summary: {
totalBookings: 1,
totalWeightTons: 70,
wagonType: 'NW5',
wagonsNeeded: 1,
totalLengthMeters: 14,
},
wagonPlan: [
{
sequenceNo: 1,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [],
},
],
};
const manager = {
getRepository: jest.fn(() => ({
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }),
})),
};
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Route') {
return {
findOne: jest.fn().mockResolvedValue({
id: 'route-1',
name: 'Djibouti to Addis',
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
isActive: true,
}),
};
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
await expect(
service.createContainerTrainSchedule({
bookingIds: ['b1'],
routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
locomotiveId: 'loc-1',
}),
).rejects.toBeInstanceOf(ConflictException);

View File

@@ -15,9 +15,9 @@ import {
import { LocomotivesRepository } from "../locomotives/locomotives.repository";
import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity";
import { TrainSet } from "../train-sets/entities/train-set.entity";
import { Route } from "../routes/entities/route.entity";
import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity";
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
import { WagonTypesRepository } from "../wagon-types/wagon-types.repository";
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
@@ -179,19 +179,12 @@ export class TrainSchedulingService {
}
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
const validation = await this.validateContainerBookingsForScheduling(dto);
if (!validation.valid) {
throw new BadRequestException({
message: "train_schedule_invalid",
violations: validation.violations,
});
}
const route = await this.getActiveRoute(dto.routeId);
const locomotive = await this.selectOrValidateLocomotive(
dto.locomotiveId,
validation.summary.totalWeightTons,
validation.summary.totalLengthMeters,
0,
0,
);
const createdSchedule = await this.dataSource.transaction(
@@ -212,99 +205,24 @@ export class TrainSchedulingService {
);
}
if (
Number(lockedLocomotive.maxPullWeightTons) <
validation.summary.totalWeightTons
) {
throw new BadRequestException(
`Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`,
);
}
if (
Number(lockedLocomotive.maxTrainLengthMeters) <
validation.summary.totalLengthMeters
) {
throw new BadRequestException(
`Locomotive ${lockedLocomotive.code} cannot support ${validation.summary.totalLengthMeters}m`,
);
}
const existingScheduleCount = await manager
.getRepository(TrainScheduleBooking)
.count({
where: {
bookingId: In(validation.bookings.map((booking) => booking.id)),
},
});
if (existingScheduleCount > 0) {
throw new BadRequestException(
"One or more bookings are already scheduled",
);
}
const trainSet = await this.buildTrainSet(
const trainSet = await this.buildEmptyTrainSet(
manager,
lockedLocomotive,
validation.wagonType,
validation.summary.totalWeightTons,
validation.summary.totalLengthMeters,
validation.wagonPlan,
);
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
routeId: route.id,
originStationId: route.originYardId,
destinationStationId: route.destinationYardId,
scheduledDepartureDate: new Date(dto.scheduleDate),
status: "SCHEDULED",
status: "DRAFT",
});
const savedSchedule = await manager
.getRepository(TrainSchedule)
.save(schedule);
const scheduleBookings = validation.bookings.map((booking) =>
manager.getRepository(TrainScheduleBooking).create({
trainScheduleId: savedSchedule.id,
bookingId: booking.id,
}),
);
await manager
.getRepository(TrainScheduleBooking)
.save(scheduleBookings);
const savedWagons = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: trainSet.id },
order: { sequenceNo: "ASC" },
});
const wagonBySequence = new Map(
savedWagons.map((wagon) => [wagon.sequenceNo, wagon]),
);
const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => {
const wagon = wagonBySequence.get(wagonPlan.sequenceNo);
if (!wagon) {
throw new BadRequestException(
`Missing wagon sequence ${wagonPlan.sequenceNo}`,
);
}
return wagonPlan.allocations.map((allocation) =>
manager.getRepository(WagonBookingAllocation).create({
trainSetWagonId: wagon.id,
bookingId: allocation.bookingId,
allocatedWeightTons: allocation.allocatedWeightTons,
}),
);
});
await manager
.getRepository(WagonBookingAllocation)
.save(allocationRows);
await locomotiveRepository.update(lockedLocomotive.id, {
status: "ASSIGNED",
});
@@ -589,6 +507,21 @@ export class TrainSchedulingService {
return savedTrainSet;
}
async buildEmptyTrainSet(
manager: EntityManager,
locomotive: Locomotive,
) {
const trainSet = manager.getRepository(TrainSet).create({
locomotiveId: locomotive.id,
totalWeightTons: 0,
totalLengthMeters: 0,
wagonCount: 0,
status: 'DRAFT',
});
return manager.getRepository(TrainSet).save(trainSet);
}
allocateBookingsToWagons(
bookings: Booking[],
baseWagonPlan: WagonPlanRecord[],
@@ -648,6 +581,7 @@ export class TrainSchedulingService {
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
relations: {
trainSet: { locomotive: true },
route: true,
originStation: true,
destinationStation: true,
scheduleBookings: true,
@@ -658,6 +592,7 @@ export class TrainSchedulingService {
return schedules.map((schedule) => ({
id: schedule.id,
scheduleDate: schedule.scheduledDepartureDate,
routeName: schedule.route?.name ?? null,
origin:
schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination:
@@ -689,6 +624,7 @@ export class TrainSchedulingService {
.findOne({
where: { id },
relations: {
route: true,
trainSet: {
locomotive: true,
wagons: { wagonType: true, allocations: { booking: true } },
@@ -708,6 +644,12 @@ export class TrainSchedulingService {
return {
id: schedule.id,
status: schedule.status,
route: schedule.route
? {
id: schedule.route.id,
name: schedule.route.name,
}
: null,
scheduledDepartureDate: schedule.scheduledDepartureDate,
scheduledArrivalDate: schedule.scheduledArrivalDate,
originStation: schedule.originStation,
@@ -830,6 +772,22 @@ export class TrainSchedulingService {
});
}
private async getActiveRoute(routeId: string) {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id: routeId },
});
if (!route) {
throw new NotFoundException(`Route ${routeId} not found`);
}
if (!route.isActive) {
throw new BadRequestException(`Route ${route.name} is inactive`);
}
return route;
}
private toUtcDateKey(value: Date | string) {
const date = value instanceof Date ? value : new Date(value);
return date.toISOString().slice(0, 10);