Train scheduling API,Routes and UI

This commit is contained in:
hagiye
2026-06-08 16:31:28 +03:00
parent 3d1f972e52
commit 7facbeda22
38 changed files with 1993 additions and 696 deletions

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDateString, IsUUID } from 'class-validator';
import { ArrayMinSize, IsArray, IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
export class CreateContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@@ -10,7 +10,31 @@ export class CreateContainerTrainScheduleDto {
@IsDateString()
scheduleDate!: string;
@ApiProperty({ example: '2026-06-22T08:00:00.000Z', required: false })
@IsOptional()
@IsDateString()
arrivalDate?: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
locomotiveId!: string;
@ApiProperty({ enum: ['CONTAINER', 'BULK'], default: 'CONTAINER' })
@IsOptional()
@IsIn(['CONTAINER', 'BULK'])
assignmentType?: 'CONTAINER' | 'BULK';
@ApiProperty({ type: [String] })
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@IsUUID('4', { each: true })
bookingIds?: string[];
@ApiProperty({ type: [String], required: false })
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@IsUUID('4', { each: true })
wagonIds?: string[];
}

View File

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsUUID } from 'class-validator';
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
export class GetEligibleContainerBookingsDto {
@ApiPropertyOptional({ format: 'uuid' })
@@ -16,4 +16,14 @@ export class GetEligibleContainerBookingsDto {
@IsOptional()
@IsDateString()
scheduleDate?: string;
@ApiPropertyOptional({ enum: ['CONTAINER', 'BULK'] })
@IsOptional()
@IsIn(['CONTAINER', 'BULK'])
assignmentType?: 'CONTAINER' | 'BULK';
@ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT', 'DOMESTIC'] })
@IsOptional()
@IsIn(['IMPORT', 'EXPORT', 'DOMESTIC'])
tradeDirection?: 'IMPORT' | 'EXPORT' | 'DOMESTIC';
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsDateString, IsUUID } from 'class-validator';
import { ArrayMinSize, IsArray, IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
export class PreviewContainerTrainScheduleDto {
@ApiProperty({ type: [String] })
@@ -19,4 +19,9 @@ export class PreviewContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
destinationStationId!: string;
@ApiProperty({ enum: ['CONTAINER', 'BULK'], default: 'CONTAINER' })
@IsOptional()
@IsIn(['CONTAINER', 'BULK'])
assignmentType?: 'CONTAINER' | 'BULK';
}

View File

@@ -55,4 +55,10 @@ export class TrainSchedulingController {
cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id);
}
@Post('container/schedules/:id/publish')
@ApiOperation({ summary: 'Publish container train schedule' })
publishTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.publishTrainSchedule(id);
}
}

View File

@@ -8,6 +8,7 @@ import { LocomotivesModule } from '../locomotives/locomotives.module';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
import { Wagon } from '../wagons/entities/wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSetsModule } from '../train-sets/train-sets.module';
@@ -26,6 +27,7 @@ import { TrainSchedulingService } from './train-scheduling.service';
BookingContainer,
Locomotive,
WagonType,
Wagon,
TrainSet,
TrainSetWagon,
TrainSchedule,

View File

@@ -38,7 +38,7 @@ const makeBooking = (
scheduledDate: new Date(scheduledDate),
originYardId,
destinationYardId,
status: 'PAID',
status: 'APPROVED',
customer: { companyName: 'Demo Customer' },
originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
@@ -164,11 +164,11 @@ describe('TrainSchedulingService', () => {
);
});
it('rejects bookings that are not in schedulable status', async () => {
it('rejects bookings that are not in assignable status', async () => {
const bookings = [
{
...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT'),
status: 'APPROVED',
status: 'PAID',
},
];
@@ -198,7 +198,7 @@ describe('TrainSchedulingService', () => {
expect(result.valid).toBe(false);
expect(result.violations).toContain(
'Only PAID bookings can be scheduled; received: APPROVED',
'Only APPROVED, READY_FOR_ASSIGNMENT bookings can be assigned; received: PAID',
);
});

View File

@@ -18,6 +18,8 @@ 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 { Wagon } from "../wagons/entities/wagon.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";
@@ -27,7 +29,10 @@ import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-
const DEFAULT_WAGON_TYPE_CODE = "NW5";
const MAX_TRAIN_WEIGHT_TONS = 3500;
const MAX_TRAIN_LENGTH_METERS = 760;
const SCHEDULABLE_BOOKING_STATUSES = ["PAID"] as const;
const ASSIGNABLE_BOOKING_STATUSES = ["APPROVED", "READY_FOR_ASSIGNMENT"] as const;
const EXCLUDED_BOOKING_STATUSES = ["CANCELLED", "COMPLETED", "IN_TRANSIT", "ARRIVED"] as const;
const MAX_CONTAINER_WAGONS = 53;
const MAX_BULK_WAGONS = 37;
type EligibleBookingItem = {
id: string;
@@ -94,11 +99,13 @@ export class TrainSchedulingService {
"scheduleBooking",
"scheduleBooking.booking_id = booking.id",
)
.where("booking.freightType = :freightType", { freightType: "CONTAINER" })
.where("booking.freightType = :freightType", {
freightType: query.assignmentType ?? "CONTAINER",
})
.andWhere("scheduleBooking.id IS NULL");
queryBuilder.andWhere("booking.status IN (:...schedulableStatuses)", {
schedulableStatuses: SCHEDULABLE_BOOKING_STATUSES,
queryBuilder.andWhere("booking.status IN (:...assignableStatuses)", {
assignableStatuses: ASSIGNABLE_BOOKING_STATUSES,
});
if (query.originStationId) {
@@ -116,6 +123,26 @@ export class TrainSchedulingService {
);
}
if (query.tradeDirection === "IMPORT") {
queryBuilder.andWhere(
`(
lower(originYard.country) IN ('djibouti', 'djoubti', 'dj')
OR lower(originYard.code) LIKE '%djib%'
OR lower(originYard.label) LIKE '%djib%'
)`,
);
}
if (query.tradeDirection === "EXPORT") {
queryBuilder.andWhere(
`(
lower(destinationYard.country) IN ('djibouti', 'djoubti', 'dj')
OR lower(destinationYard.code) LIKE '%djib%'
OR lower(destinationYard.label) LIKE '%djib%'
)`,
);
}
if (query.scheduleDate) {
queryBuilder.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'UTC') = :scheduleDate`,
@@ -141,7 +168,7 @@ export class TrainSchedulingService {
container.containerType?.code ??
"Container",
)
.join(", ") ?? "Container",
.join(", ") ?? (booking.freightType === "BULK" ? "Bulk cargo" : "Container"),
quantity:
booking.bookingContainers?.reduce(
(sum, container) => sum + Number(container.quantity ?? 0),
@@ -180,11 +207,27 @@ export class TrainSchedulingService {
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
const route = await this.getActiveRoute(dto.routeId);
const validation = dto.bookingIds?.length
? await this.validateContainerBookingsForScheduling({
bookingIds: dto.bookingIds,
scheduleDate: dto.scheduleDate,
originStationId: route.originYardId,
destinationStationId: route.destinationYardId,
assignmentType: dto.assignmentType ?? "CONTAINER",
})
: null;
if (validation && !validation.valid) {
throw new BadRequestException({
message: "Train schedule assignment is invalid",
violations: validation.violations,
});
}
const locomotive = await this.selectOrValidateLocomotive(
dto.locomotiveId,
0,
0,
validation?.summary.totalWeightTons ?? 0,
validation?.summary.totalLengthMeters ?? 0,
);
const createdSchedule = await this.dataSource.transaction(
@@ -205,10 +248,28 @@ export class TrainSchedulingService {
);
}
const trainSet = await this.buildEmptyTrainSet(
manager,
lockedLocomotive,
);
const selectedPhysicalWagons = validation
? await this.lockSelectedWagonsForSchedule(
manager,
dto.wagonIds ?? [],
validation.wagonPlan.length,
route,
dto.assignmentType ?? "CONTAINER",
)
: [];
const trainSetResult = validation
? await this.buildTrainSet(
manager,
lockedLocomotive,
validation.wagonType,
validation.summary.totalWeightTons,
validation.summary.totalLengthMeters,
validation.wagonPlan,
selectedPhysicalWagons,
)
: { trainSet: await this.buildEmptyTrainSet(manager, lockedLocomotive), wagons: [] };
const { trainSet, wagons } = trainSetResult;
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
@@ -216,13 +277,51 @@ export class TrainSchedulingService {
originStationId: route.originYardId,
destinationStationId: route.destinationYardId,
scheduledDepartureDate: new Date(dto.scheduleDate),
status: "DRAFT",
scheduledArrivalDate: dto.arrivalDate ? new Date(dto.arrivalDate) : null,
status: validation ? "READY" : "DRAFT",
});
const savedSchedule = await manager
.getRepository(TrainSchedule)
.save(schedule);
if (validation) {
await manager.getRepository(TrainScheduleBooking).save(
validation.bookings.map((booking) =>
manager.getRepository(TrainScheduleBooking).create({
trainScheduleId: savedSchedule.id,
bookingId: booking.id,
}),
),
);
const wagonBySequence = new Map(wagons.map((wagon) => [wagon.sequenceNo, wagon]));
const allocations = validation.wagonPlan.flatMap((wagonPlan) => {
const savedWagon = wagonBySequence.get(wagonPlan.sequenceNo);
if (!savedWagon) return [];
return wagonPlan.allocations.map((allocation) =>
manager.getRepository(WagonBookingAllocation).create({
trainSetWagonId: savedWagon.id,
bookingId: allocation.bookingId,
allocatedWeightTons: allocation.allocatedWeightTons,
}),
);
});
if (allocations.length > 0) {
await manager.getRepository(WagonBookingAllocation).save(allocations);
}
await manager.getRepository(Booking).update(
{ id: In(validation.bookings.map((booking) => booking.id)) },
{
status: "INVOICED",
paymentStatus: "PENDING",
},
);
}
await locomotiveRepository.update(lockedLocomotive.id, {
status: "ASSIGNED",
});
@@ -276,24 +375,32 @@ export class TrainSchedulingService {
}
const nonContainerBookings = bookings.filter(
(booking) => booking.freightType !== "CONTAINER",
(booking) => booking.freightType !== (dto.assignmentType ?? "CONTAINER"),
);
if (nonContainerBookings.length > 0) {
violations.push(
"Only CONTAINER bookings are supported for train scheduling",
`Only ${dto.assignmentType ?? "CONTAINER"} bookings are supported for this assignment`,
);
}
const invalidStatusBookings = bookings.filter(
(booking) => !SCHEDULABLE_BOOKING_STATUSES.includes(booking.status as "PAID"),
(booking) =>
!ASSIGNABLE_BOOKING_STATUSES.includes(booking.status as (typeof ASSIGNABLE_BOOKING_STATUSES)[number]),
);
if (invalidStatusBookings.length > 0) {
const invalidStatuses = [...new Set(invalidStatusBookings.map((booking) => booking.status))];
violations.push(
`Only ${SCHEDULABLE_BOOKING_STATUSES.join(", ")} bookings can be scheduled; received: ${invalidStatuses.join(", ")}`,
`Only ${ASSIGNABLE_BOOKING_STATUSES.join(", ")} bookings can be assigned; received: ${invalidStatuses.join(", ")}`,
);
}
const excludedStatusBookings = bookings.filter((booking) =>
EXCLUDED_BOOKING_STATUSES.includes(booking.status as (typeof EXCLUDED_BOOKING_STATUSES)[number]),
);
if (excludedStatusBookings.length > 0) {
violations.push(`Cancelled, completed, in-transit, or arrived bookings cannot be assigned`);
}
const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate);
const routeMismatch = bookings.some(
(booking) =>
@@ -366,11 +473,11 @@ export class TrainSchedulingService {
}
if (
wagonType.maxWagonsPerTrain != null &&
wagonPlan.length > Number(wagonType.maxWagonsPerTrain)
wagonPlan.length >
(dto.assignmentType === "BULK" ? MAX_BULK_WAGONS : MAX_CONTAINER_WAGONS)
) {
violations.push(
`Wagon count ${wagonPlan.length} exceeds wagon marshalling limit ${wagonType.maxWagonsPerTrain}`,
`Wagon count ${wagonPlan.length} exceeds ${dto.assignmentType === "BULK" ? "bulk" : "container"} limit ${dto.assignmentType === "BULK" ? MAX_BULK_WAGONS : MAX_CONTAINER_WAGONS}`,
);
}
@@ -474,6 +581,82 @@ export class TrainSchedulingService {
return locomotive;
}
async lockSelectedWagonsForSchedule(
manager: EntityManager,
wagonIds: string[],
requiredCount: number,
route: Route,
assignmentType: "CONTAINER" | "BULK",
) {
const uniqueWagonIds = [...new Set(wagonIds)];
if (uniqueWagonIds.length < requiredCount) {
throw new BadRequestException(
`Select at least ${requiredCount} available wagons for this schedule`,
);
}
const wagons = await manager
.getRepository(Wagon)
.createQueryBuilder("wagon")
.leftJoinAndSelect("wagon.wagonType", "wagonType")
.where("wagon.id IN (:...wagonIds)", { wagonIds: uniqueWagonIds })
.setLock("pessimistic_write")
.getMany();
if (wagons.length !== uniqueWagonIds.length) {
throw new BadRequestException("One or more selected wagons were not found");
}
const expectedStatus = this.expectedWagonStatusForRoute(route);
const allowedStatuses = new Set([
expectedStatus,
"AVAILABLE",
...(expectedStatus === "EXPORT_READY" ? ["IMPORT_READY"] : []),
]);
const invalidWagon = wagons.find(
(wagon) =>
wagon.trainId ||
wagon.status === "ASSIGNED" ||
wagon.currentLocationYardId !== route.originYardId ||
!allowedStatuses.has(wagon.status) ||
!this.wagonTypeSupportsAssignment(wagon, assignmentType),
);
if (invalidWagon) {
throw new BadRequestException(
`Wagon ${invalidWagon.wagonNumber} is not at the route origin or is not ready for this ${this.routeDirection(route).toLowerCase()} route`,
);
}
const wagonById = new Map(wagons.map((wagon) => [wagon.id, wagon]));
return uniqueWagonIds.slice(0, requiredCount).map((wagonId) => wagonById.get(wagonId)!);
}
private wagonTypeSupportsAssignment(wagon: Wagon, assignmentType: "CONTAINER" | "BULK") {
const supportedLoadTypes = wagon.wagonType?.supportedLoadTypes ?? [];
const normalized = supportedLoadTypes.map((loadType) => loadType.trim().toUpperCase());
return normalized.includes(assignmentType);
}
private routeDirection(route: Route) {
const originCountry = route.originYard?.country?.trim().toLowerCase();
const destinationCountry = route.destinationYard?.country?.trim().toLowerCase();
const isOriginEthiopia = originCountry === "ethiopia" || originCountry === "et";
const isDestinationEthiopia = destinationCountry === "ethiopia" || destinationCountry === "et";
if (!isOriginEthiopia && isDestinationEthiopia) return "IMPORT";
if (isOriginEthiopia && !isDestinationEthiopia) return "EXPORT";
return "DOMESTIC";
}
private expectedWagonStatusForRoute(route: Route) {
const direction = this.routeDirection(route);
if (direction === "IMPORT") return "IMPORT_READY";
if (direction === "EXPORT" || direction === "DOMESTIC") return "EXPORT_READY";
return "AVAILABLE";
}
async buildTrainSet(
manager: EntityManager,
locomotive: Locomotive,
@@ -481,6 +664,7 @@ export class TrainSchedulingService {
totalWeightTons: number,
totalLengthMeters: number,
wagonPlan: WagonPlanRecord[],
physicalWagons: Wagon[] = [],
) {
const trainSet = manager.getRepository(TrainSet).create({
locomotiveId: locomotive.id,
@@ -491,20 +675,35 @@ export class TrainSchedulingService {
});
const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet);
const wagons = wagonPlan.map((wagon) =>
manager.getRepository(TrainSetWagon).create({
const wagons = wagonPlan.map((wagon, index) => {
const physicalWagon = physicalWagons[index];
const selectedWagonType = physicalWagon?.wagonType ?? wagonType;
return manager.getRepository(TrainSetWagon).create({
trainSetId: savedTrainSet.id,
wagonTypeId: wagonType.id,
wagonTypeId: selectedWagonType.id,
physicalWagonId: physicalWagon?.id ?? null,
sequenceNo: wagon.sequenceNo,
capacityTons: wagon.capacityTons,
lengthMeters: wagon.lengthMeters,
capacityTons: Number(selectedWagonType.capacityTons),
lengthMeters: Number(selectedWagonType.lengthMeters),
assignedWeightTons: wagon.assignedWeightTons,
}),
);
});
});
await manager.getRepository(TrainSetWagon).save(wagons);
const savedWagons = await manager.getRepository(TrainSetWagon).save(wagons);
return savedTrainSet;
if (physicalWagons.length > 0) {
await Promise.all(
physicalWagons.map((wagon, index) =>
manager.getRepository(Wagon).update(wagon.id, {
status: "ASSIGNED",
sequenceNumber: index + 1,
}),
),
);
}
return { trainSet: savedTrainSet, wagons: savedWagons };
}
async buildEmptyTrainSet(
@@ -627,7 +826,7 @@ export class TrainSchedulingService {
route: true,
trainSet: {
locomotive: true,
wagons: { wagonType: true, allocations: { booking: true } },
wagons: { wagonType: true, physicalWagon: true, allocations: { booking: true } },
},
originStation: true,
destinationStation: true,
@@ -696,6 +895,13 @@ export class TrainSchedulingService {
name: wagon.wagonType.name,
}
: null,
physicalWagon: wagon.physicalWagon
? {
id: wagon.physicalWagon.id,
wagonNumber: wagon.physicalWagon.wagonNumber,
status: wagon.physicalWagon.status,
}
: null,
allocations:
wagon.allocations?.map((allocation) => ({
id: allocation.id,
@@ -729,7 +935,7 @@ export class TrainSchedulingService {
.getRepository(TrainSchedule)
.findOne({
where: { id },
relations: { trainSet: { locomotive: true } },
relations: { trainSet: { locomotive: true, wagons: true } },
});
if (!schedule) {
@@ -754,11 +960,58 @@ export class TrainSchedulingService {
status: "AVAILABLE",
});
}
const physicalWagonIds =
schedule.trainSet?.wagons
?.map((wagon) => wagon.physicalWagonId)
.filter((wagonId): wagonId is string => Boolean(wagonId)) ?? [];
if (physicalWagonIds.length > 0) {
const physicalWagons = await manager.getRepository(Wagon).find({
where: { id: In(physicalWagonIds) },
relations: { currentLocationYard: true },
});
await Promise.all(
physicalWagons.map((wagon) =>
manager.getRepository(Wagon).update(wagon.id, {
status: this.expectedWagonStatusForYard(wagon.currentLocationYard),
sequenceNumber: null,
}),
),
);
}
});
return this.getContainerTrainScheduleById(id);
}
async publishTrainSchedule(id: string) {
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
where: { id },
relations: { trainSet: true, scheduleBookings: true },
});
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (schedule.status === "CANCELLED") {
throw new BadRequestException("Cancelled schedules cannot be published");
}
if (!schedule.trainSet || schedule.trainSet.wagonCount <= 0) {
throw new BadRequestException("Allocate wagons before publishing the schedule");
}
if ((schedule.scheduleBookings?.length ?? 0) === 0) {
throw new BadRequestException("Assign bookings before publishing the schedule");
}
await this.dataSource.getRepository(TrainSchedule).update(id, { status: "PUBLISHED" });
return this.getContainerTrainScheduleById(id);
}
private async loadBookingsForScheduling(bookingIds: string[]) {
return this.dataSource.getRepository(Booking).find({
where: { id: In(bookingIds) },
@@ -775,6 +1028,7 @@ export class TrainSchedulingService {
private async getActiveRoute(routeId: string) {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id: routeId },
relations: { originYard: true, destinationYard: true },
});
if (!route) {
@@ -788,6 +1042,13 @@ export class TrainSchedulingService {
return route;
}
private expectedWagonStatusForYard(yard?: { country?: string } | null) {
const country = yard?.country?.trim().toLowerCase();
if (country === "ethiopia" || country === "et") return "EXPORT_READY";
if (country === "djibouti" || country === "djoubti" || country === "dj") return "IMPORT_READY";
return "AVAILABLE";
}
private toUtcDateKey(value: Date | string) {
const date = value instanceof Date ? value : new Date(value);
return date.toISOString().slice(0, 10);