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

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