mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
824 lines
26 KiB
TypeScript
824 lines
26 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from "@nestjs/common";
|
|
import { InjectDataSource } from "@nestjs/typeorm";
|
|
import { DataSource, EntityManager, In } from "typeorm";
|
|
|
|
import { Booking } from "../bookings/entities/booking.entity";
|
|
import {
|
|
Locomotive,
|
|
type LocomotiveStatus,
|
|
} from "../locomotives/entities/locomotive.entity";
|
|
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 { 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";
|
|
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
|
|
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
|
|
|
|
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;
|
|
|
|
type EligibleBookingItem = {
|
|
id: string;
|
|
reference: string;
|
|
customer: string;
|
|
containerType: string;
|
|
quantity: number;
|
|
weightTons: number;
|
|
origin: string;
|
|
destination: string;
|
|
preferredDepartureDate: string;
|
|
status: string;
|
|
};
|
|
|
|
type WagonAllocationRecord = {
|
|
bookingId: string;
|
|
bookingReference: string;
|
|
allocatedWeightTons: number;
|
|
};
|
|
|
|
type WagonPlanRecord = {
|
|
sequenceNo: number;
|
|
capacityTons: number;
|
|
lengthMeters: number;
|
|
assignedWeightTons: number;
|
|
allocations: WagonAllocationRecord[];
|
|
};
|
|
|
|
type ValidationResult = {
|
|
valid: boolean;
|
|
violations: string[];
|
|
bookings: Booking[];
|
|
wagonType: WagonType;
|
|
summary: {
|
|
totalBookings: number;
|
|
totalWeightTons: number;
|
|
wagonType: string;
|
|
wagonsNeeded: number;
|
|
totalLengthMeters: number;
|
|
};
|
|
wagonPlan: WagonPlanRecord[];
|
|
};
|
|
|
|
@Injectable()
|
|
export class TrainSchedulingService {
|
|
constructor(
|
|
@InjectDataSource()
|
|
private readonly dataSource: DataSource,
|
|
private readonly locomotivesRepository: LocomotivesRepository,
|
|
private readonly wagonTypesRepository: WagonTypesRepository,
|
|
) { }
|
|
|
|
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
|
|
const bookingRepository = this.dataSource.getRepository(Booking);
|
|
const queryBuilder = bookingRepository
|
|
.createQueryBuilder("booking")
|
|
.leftJoinAndSelect("booking.company", "company")
|
|
.leftJoinAndSelect("booking.originYard", "originYard")
|
|
.leftJoinAndSelect("booking.destinationYard", "destinationYard")
|
|
.leftJoinAndSelect("booking.bookingContainers", "bookingContainer")
|
|
.leftJoinAndSelect("bookingContainer.containerType", "containerType")
|
|
.leftJoin(
|
|
TrainScheduleBooking,
|
|
"scheduleBooking",
|
|
"scheduleBooking.booking_id = booking.id",
|
|
)
|
|
.where("booking.freightType = :freightType", { freightType: "CONTAINER" })
|
|
.andWhere("scheduleBooking.id IS NULL");
|
|
|
|
queryBuilder.andWhere("booking.status IN (:...schedulableStatuses)", {
|
|
schedulableStatuses: SCHEDULABLE_BOOKING_STATUSES,
|
|
});
|
|
|
|
if (query.originStationId) {
|
|
queryBuilder.andWhere("booking.originYardId = :originStationId", {
|
|
originStationId: query.originStationId,
|
|
});
|
|
}
|
|
|
|
if (query.destinationStationId) {
|
|
queryBuilder.andWhere(
|
|
"booking.destinationYardId = :destinationStationId",
|
|
{
|
|
destinationStationId: query.destinationStationId,
|
|
},
|
|
);
|
|
}
|
|
|
|
if (query.scheduleDate) {
|
|
queryBuilder.andWhere(
|
|
`DATE(booking.scheduled_date AT TIME ZONE 'UTC') = :scheduleDate`,
|
|
{ scheduleDate: this.toUtcDateKey(query.scheduleDate) },
|
|
);
|
|
}
|
|
|
|
const bookings = await queryBuilder
|
|
.orderBy("booking.scheduled_date", "ASC")
|
|
.addOrderBy("booking.created_at", "ASC")
|
|
.getMany();
|
|
|
|
const items: EligibleBookingItem[] = bookings.map((booking) => ({
|
|
id: booking.id,
|
|
reference: booking.reference,
|
|
customer:
|
|
booking.company?.name ?? booking.company?.email ?? "Unknown customer",
|
|
containerType:
|
|
booking.bookingContainers
|
|
?.map(
|
|
(container) =>
|
|
container.containerType?.label ??
|
|
container.containerType?.code ??
|
|
"Container",
|
|
)
|
|
.join(", ") ?? "Container",
|
|
quantity:
|
|
booking.bookingContainers?.reduce(
|
|
(sum, container) => sum + Number(container.quantity ?? 0),
|
|
0,
|
|
) ?? 0,
|
|
weightTons: this.roundTons(booking.cargoTotalWeightVgm),
|
|
origin:
|
|
booking.originYard?.label ??
|
|
booking.originYard?.code ??
|
|
"Unknown origin",
|
|
destination:
|
|
booking.destinationYard?.label ??
|
|
booking.destinationYard?.code ??
|
|
"Unknown destination",
|
|
preferredDepartureDate: booking.scheduledDate.toISOString(),
|
|
status: booking.status,
|
|
}));
|
|
|
|
return {
|
|
count: items.length,
|
|
items,
|
|
};
|
|
}
|
|
|
|
async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) {
|
|
const validation = await this.validateContainerBookingsForScheduling(dto);
|
|
|
|
return {
|
|
valid: validation.valid,
|
|
violations: validation.violations,
|
|
summary: validation.summary,
|
|
bookingIds: validation.bookings.map((booking) => booking.id),
|
|
wagonPlan: validation.wagonPlan,
|
|
};
|
|
}
|
|
|
|
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
|
|
const validation = await this.validateContainerBookingsForScheduling(dto);
|
|
|
|
if (!validation.valid) {
|
|
throw new BadRequestException({
|
|
message: "train_schedule_invalid",
|
|
violations: validation.violations,
|
|
});
|
|
}
|
|
|
|
const locomotive = await this.selectOrValidateLocomotive(
|
|
dto.locomotiveId,
|
|
validation.summary.totalWeightTons,
|
|
);
|
|
|
|
const createdSchedule = await this.dataSource.transaction(
|
|
async (manager) => {
|
|
const locomotiveRepository = manager.getRepository(Locomotive);
|
|
const lockedLocomotive = await locomotiveRepository.findOne({
|
|
where: { id: locomotive.id },
|
|
lock: { mode: "pessimistic_write" },
|
|
});
|
|
|
|
if (!lockedLocomotive) {
|
|
throw new NotFoundException(`Locomotive ${locomotive.id} not found`);
|
|
}
|
|
|
|
if (lockedLocomotive.status !== "AVAILABLE") {
|
|
throw new ConflictException(
|
|
`Locomotive ${lockedLocomotive.code} is not available`,
|
|
);
|
|
}
|
|
|
|
if (
|
|
Number(lockedLocomotive.maxPullWeightTons) <
|
|
validation.summary.totalWeightTons
|
|
) {
|
|
throw new BadRequestException(
|
|
`Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`,
|
|
);
|
|
}
|
|
|
|
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(
|
|
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,
|
|
scheduledDepartureDate: new Date(dto.scheduleDate),
|
|
status: "SCHEDULED",
|
|
});
|
|
|
|
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",
|
|
});
|
|
|
|
return savedSchedule.id;
|
|
},
|
|
);
|
|
|
|
return this.getContainerTrainScheduleById(createdSchedule);
|
|
}
|
|
|
|
async validateContainerBookingsForScheduling(
|
|
dto: PreviewContainerTrainScheduleDto,
|
|
): Promise<ValidationResult> {
|
|
const bookingIds = [...new Set(dto.bookingIds)];
|
|
|
|
if (!bookingIds.length) {
|
|
throw new BadRequestException("At least one booking is required");
|
|
}
|
|
|
|
const [wagonType] = await this.wagonTypesRepository.findAll({
|
|
where: { code: DEFAULT_WAGON_TYPE_CODE, isActive: true },
|
|
});
|
|
|
|
if (!wagonType) {
|
|
throw new NotFoundException(
|
|
`Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`,
|
|
);
|
|
}
|
|
|
|
const bookings = await this.loadBookingsForScheduling(bookingIds);
|
|
const violations: string[] = [];
|
|
|
|
if (bookings.length !== bookingIds.length) {
|
|
const foundIds = new Set(bookings.map((booking) => booking.id));
|
|
const missing = bookingIds.filter((id) => !foundIds.has(id));
|
|
violations.push(`Bookings not found: ${missing.join(", ")}`);
|
|
}
|
|
|
|
const scheduledLinks = await this.dataSource
|
|
.getRepository(TrainScheduleBooking)
|
|
.find({
|
|
where: { bookingId: In(bookingIds) },
|
|
select: { bookingId: true },
|
|
});
|
|
|
|
if (scheduledLinks.length > 0) {
|
|
violations.push(
|
|
"One or more selected bookings are already assigned to a train schedule",
|
|
);
|
|
}
|
|
|
|
const nonContainerBookings = bookings.filter(
|
|
(booking) => booking.freightType !== "CONTAINER",
|
|
);
|
|
if (nonContainerBookings.length > 0) {
|
|
violations.push(
|
|
"Only CONTAINER bookings are supported for train scheduling",
|
|
);
|
|
}
|
|
|
|
const invalidStatusBookings = bookings.filter(
|
|
(booking) => !SCHEDULABLE_BOOKING_STATUSES.includes(booking.status as "PAID"),
|
|
);
|
|
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(", ")}`,
|
|
);
|
|
}
|
|
|
|
const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate);
|
|
const routeMismatch = bookings.some(
|
|
(booking) =>
|
|
booking.originYardId !== dto.originStationId ||
|
|
booking.destinationYardId !== dto.destinationStationId,
|
|
);
|
|
if (routeMismatch) {
|
|
violations.push(
|
|
"Selected bookings must share the same origin and destination as the schedule",
|
|
);
|
|
}
|
|
|
|
const dateMismatch = bookings.some(
|
|
(booking) => this.toUtcDateKey(booking.scheduledDate) !== scheduleDateKey,
|
|
);
|
|
if (dateMismatch) {
|
|
violations.push("Selected bookings must share the same schedule date");
|
|
}
|
|
|
|
const uniqueOriginCount = new Set(
|
|
bookings.map((booking) => booking.originYardId),
|
|
).size;
|
|
if (uniqueOriginCount > 1) {
|
|
violations.push("Selected bookings must share the same origin station");
|
|
}
|
|
|
|
const uniqueDestinationCount = new Set(
|
|
bookings.map((booking) => booking.destinationYardId),
|
|
).size;
|
|
if (uniqueDestinationCount > 1) {
|
|
violations.push(
|
|
"Selected bookings must share the same destination station",
|
|
);
|
|
}
|
|
|
|
const uniqueDateCount = new Set(
|
|
bookings.map((booking) => this.toUtcDateKey(booking.scheduledDate)),
|
|
).size;
|
|
if (uniqueDateCount > 1) {
|
|
violations.push(
|
|
"Selected bookings must share the same preferred departure date",
|
|
);
|
|
}
|
|
|
|
const totalWeightTons = this.roundTons(
|
|
bookings.reduce(
|
|
(sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0),
|
|
0,
|
|
),
|
|
);
|
|
|
|
const wagonPlan = this.allocateBookingsToWagons(
|
|
bookings,
|
|
this.calculateNW5WagonPlan(totalWeightTons, wagonType),
|
|
);
|
|
const totalLengthMeters = this.roundTons(
|
|
wagonPlan.reduce((sum, wagon) => sum + wagon.lengthMeters, 0),
|
|
);
|
|
|
|
if (totalWeightTons > MAX_TRAIN_WEIGHT_TONS) {
|
|
violations.push(
|
|
`Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`,
|
|
);
|
|
}
|
|
|
|
if (totalLengthMeters > MAX_TRAIN_LENGTH_METERS) {
|
|
violations.push(
|
|
`Total wagon length ${totalLengthMeters}m exceeds max train length ${MAX_TRAIN_LENGTH_METERS}m`,
|
|
);
|
|
}
|
|
|
|
if (
|
|
wagonType.maxWagonsPerTrain != null &&
|
|
wagonPlan.length > Number(wagonType.maxWagonsPerTrain)
|
|
) {
|
|
violations.push(
|
|
`Wagon count ${wagonPlan.length} exceeds wagon marshalling limit ${wagonType.maxWagonsPerTrain}`,
|
|
);
|
|
}
|
|
|
|
const availableLocomotiveCount = await this.dataSource
|
|
.getRepository(Locomotive)
|
|
.count({
|
|
where: { status: "AVAILABLE" as LocomotiveStatus },
|
|
});
|
|
|
|
if (availableLocomotiveCount === 0) {
|
|
violations.push("No available locomotive exists for scheduling");
|
|
} else {
|
|
const capableLocomotives = await this.dataSource
|
|
.getRepository(Locomotive)
|
|
.find({
|
|
where: { status: "AVAILABLE" },
|
|
});
|
|
const canPull = capableLocomotives.some(
|
|
(locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons,
|
|
);
|
|
if (!canPull) {
|
|
violations.push("No available locomotive can pull the total weight");
|
|
}
|
|
}
|
|
|
|
return {
|
|
valid: violations.length === 0,
|
|
violations,
|
|
bookings,
|
|
wagonType,
|
|
summary: {
|
|
totalBookings: bookings.length,
|
|
totalWeightTons,
|
|
wagonType: wagonType.code,
|
|
wagonsNeeded: wagonPlan.length,
|
|
totalLengthMeters,
|
|
},
|
|
wagonPlan,
|
|
};
|
|
}
|
|
|
|
calculateNW5WagonPlan(
|
|
totalBookingWeightTons: number,
|
|
wagonType: WagonType,
|
|
): WagonPlanRecord[] {
|
|
const wagonCapacityTons = Number(wagonType.capacityTons);
|
|
const wagonsNeeded = Math.ceil(totalBookingWeightTons / wagonCapacityTons);
|
|
let remainingWeight = this.roundTons(totalBookingWeightTons);
|
|
|
|
return Array.from({ length: wagonsNeeded }, (_, index) => {
|
|
const assignedWeightTons = this.roundTons(
|
|
Math.min(wagonCapacityTons, remainingWeight),
|
|
);
|
|
remainingWeight = this.roundTons(
|
|
Math.max(0, remainingWeight - assignedWeightTons),
|
|
);
|
|
|
|
return {
|
|
sequenceNo: index + 1,
|
|
capacityTons: wagonCapacityTons,
|
|
lengthMeters: this.roundTons(Number(wagonType.lengthMeters)),
|
|
assignedWeightTons,
|
|
allocations: [],
|
|
};
|
|
});
|
|
}
|
|
|
|
async selectOrValidateLocomotive(
|
|
locomotiveId: string,
|
|
totalWeightTons: number,
|
|
) {
|
|
const locomotive = await this.locomotivesRepository.findById(locomotiveId);
|
|
|
|
if (!locomotive) {
|
|
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
|
|
}
|
|
|
|
if (locomotive.status !== "AVAILABLE") {
|
|
throw new BadRequestException(
|
|
`Locomotive ${locomotive.code} is not available`,
|
|
);
|
|
}
|
|
|
|
if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
|
|
throw new BadRequestException(
|
|
`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`,
|
|
);
|
|
}
|
|
|
|
return locomotive;
|
|
}
|
|
|
|
async buildTrainSet(
|
|
manager: EntityManager,
|
|
locomotive: Locomotive,
|
|
wagonType: WagonType,
|
|
totalWeightTons: number,
|
|
totalLengthMeters: number,
|
|
wagonPlan: WagonPlanRecord[],
|
|
) {
|
|
const trainSet = manager.getRepository(TrainSet).create({
|
|
locomotiveId: locomotive.id,
|
|
totalWeightTons,
|
|
totalLengthMeters,
|
|
wagonCount: wagonPlan.length,
|
|
status: "ASSIGNED",
|
|
});
|
|
const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet);
|
|
|
|
const wagons = wagonPlan.map((wagon) =>
|
|
manager.getRepository(TrainSetWagon).create({
|
|
trainSetId: savedTrainSet.id,
|
|
wagonTypeId: wagonType.id,
|
|
sequenceNo: wagon.sequenceNo,
|
|
capacityTons: wagon.capacityTons,
|
|
lengthMeters: wagon.lengthMeters,
|
|
assignedWeightTons: wagon.assignedWeightTons,
|
|
}),
|
|
);
|
|
|
|
await manager.getRepository(TrainSetWagon).save(wagons);
|
|
|
|
return savedTrainSet;
|
|
}
|
|
|
|
allocateBookingsToWagons(
|
|
bookings: Booking[],
|
|
baseWagonPlan: WagonPlanRecord[],
|
|
): WagonPlanRecord[] {
|
|
const remaining = bookings.map((booking) => ({
|
|
bookingId: booking.id,
|
|
bookingReference: booking.reference,
|
|
remainingWeightTons: this.roundTons(
|
|
Number(booking.cargoTotalWeightVgm ?? 0),
|
|
),
|
|
}));
|
|
let bookingIndex = 0;
|
|
|
|
return baseWagonPlan.map((wagon) => {
|
|
let wagonRemaining = this.roundTons(wagon.capacityTons);
|
|
const allocations: WagonAllocationRecord[] = [];
|
|
let assignedWeightTons = 0;
|
|
|
|
while (wagonRemaining > 0 && bookingIndex < remaining.length) {
|
|
const booking = remaining[bookingIndex];
|
|
const allocatedWeightTons = this.roundTons(
|
|
Math.min(wagonRemaining, booking.remainingWeightTons),
|
|
);
|
|
|
|
if (allocatedWeightTons <= 0) {
|
|
bookingIndex += 1;
|
|
continue;
|
|
}
|
|
|
|
allocations.push({
|
|
bookingId: booking.bookingId,
|
|
bookingReference: booking.bookingReference,
|
|
allocatedWeightTons,
|
|
});
|
|
booking.remainingWeightTons = this.roundTons(
|
|
booking.remainingWeightTons - allocatedWeightTons,
|
|
);
|
|
wagonRemaining = this.roundTons(wagonRemaining - allocatedWeightTons);
|
|
assignedWeightTons = this.roundTons(
|
|
assignedWeightTons + allocatedWeightTons,
|
|
);
|
|
|
|
if (booking.remainingWeightTons <= 0) {
|
|
bookingIndex += 1;
|
|
}
|
|
}
|
|
|
|
return {
|
|
...wagon,
|
|
assignedWeightTons,
|
|
allocations,
|
|
};
|
|
});
|
|
}
|
|
|
|
async getContainerTrainSchedules() {
|
|
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
|
|
relations: {
|
|
trainSet: { locomotive: true },
|
|
originStation: true,
|
|
destinationStation: true,
|
|
scheduleBookings: true,
|
|
},
|
|
order: { scheduledDepartureDate: "DESC", createdAt: "DESC" },
|
|
});
|
|
|
|
return schedules.map((schedule) => ({
|
|
id: schedule.id,
|
|
scheduleDate: schedule.scheduledDepartureDate,
|
|
origin:
|
|
schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
|
destination:
|
|
schedule.destinationStation?.label ??
|
|
schedule.destinationStation?.code ??
|
|
null,
|
|
locomotive: schedule.trainSet?.locomotive
|
|
? {
|
|
id: schedule.trainSet.locomotive.id,
|
|
code: schedule.trainSet.locomotive.code,
|
|
name: schedule.trainSet.locomotive.name ?? null,
|
|
}
|
|
: null,
|
|
wagonCount: schedule.trainSet?.wagonCount ?? 0,
|
|
totalWeightTons: this.roundTons(
|
|
Number(schedule.trainSet?.totalWeightTons ?? 0),
|
|
),
|
|
totalLengthMeters: this.roundTons(
|
|
Number(schedule.trainSet?.totalLengthMeters ?? 0),
|
|
),
|
|
bookingsCount: schedule.scheduleBookings?.length ?? 0,
|
|
status: schedule.status,
|
|
}));
|
|
}
|
|
|
|
async getContainerTrainScheduleById(id: string) {
|
|
const schedule = await this.dataSource
|
|
.getRepository(TrainSchedule)
|
|
.findOne({
|
|
where: { id },
|
|
relations: {
|
|
trainSet: {
|
|
locomotive: true,
|
|
wagons: { wagonType: true, allocations: { booking: true } },
|
|
},
|
|
originStation: true,
|
|
destinationStation: true,
|
|
scheduleBookings: {
|
|
booking: { company: true, originYard: true, destinationYard: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!schedule) {
|
|
throw new NotFoundException(`Train schedule ${id} not found`);
|
|
}
|
|
|
|
return {
|
|
id: schedule.id,
|
|
status: schedule.status,
|
|
scheduledDepartureDate: schedule.scheduledDepartureDate,
|
|
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
|
originStation: schedule.originStation,
|
|
destinationStation: schedule.destinationStation,
|
|
trainSet: schedule.trainSet
|
|
? {
|
|
id: schedule.trainSet.id,
|
|
status: schedule.trainSet.status,
|
|
wagonCount: schedule.trainSet.wagonCount,
|
|
totalWeightTons: this.roundTons(
|
|
Number(schedule.trainSet.totalWeightTons),
|
|
),
|
|
totalLengthMeters: this.roundTons(
|
|
Number(schedule.trainSet.totalLengthMeters),
|
|
),
|
|
locomotive: schedule.trainSet.locomotive
|
|
? {
|
|
id: schedule.trainSet.locomotive.id,
|
|
code: schedule.trainSet.locomotive.code,
|
|
name: schedule.trainSet.locomotive.name,
|
|
status: schedule.trainSet.locomotive.status,
|
|
maxPullWeightTons: this.roundTons(
|
|
Number(schedule.trainSet.locomotive.maxPullWeightTons),
|
|
),
|
|
}
|
|
: null,
|
|
wagons: [...(schedule.trainSet.wagons ?? [])]
|
|
.sort((left, right) => left.sequenceNo - right.sequenceNo)
|
|
.map((wagon) => ({
|
|
id: wagon.id,
|
|
sequenceNo: wagon.sequenceNo,
|
|
capacityTons: this.roundTons(Number(wagon.capacityTons)),
|
|
lengthMeters: this.roundTons(Number(wagon.lengthMeters)),
|
|
assignedWeightTons: this.roundTons(
|
|
Number(wagon.assignedWeightTons),
|
|
),
|
|
wagonType: wagon.wagonType
|
|
? {
|
|
id: wagon.wagonType.id,
|
|
code: wagon.wagonType.code,
|
|
name: wagon.wagonType.name,
|
|
}
|
|
: null,
|
|
allocations:
|
|
wagon.allocations?.map((allocation) => ({
|
|
id: allocation.id,
|
|
bookingId: allocation.bookingId,
|
|
bookingReference: allocation.booking?.reference ?? null,
|
|
allocatedWeightTons: this.roundTons(
|
|
Number(allocation.allocatedWeightTons),
|
|
),
|
|
})) ?? [],
|
|
})),
|
|
}
|
|
: null,
|
|
bookings:
|
|
schedule.scheduleBookings?.map((scheduleBooking) => ({
|
|
id: scheduleBooking.booking?.id ?? scheduleBooking.bookingId,
|
|
reference: scheduleBooking.booking?.reference ?? null,
|
|
customer:
|
|
scheduleBooking.booking?.company?.name ??
|
|
scheduleBooking.booking?.company?.email ??
|
|
null,
|
|
weightTons: this.roundTons(
|
|
Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0),
|
|
),
|
|
status: scheduleBooking.booking?.status ?? null,
|
|
})) ?? [],
|
|
};
|
|
}
|
|
|
|
async cancelTrainSchedule(id: string) {
|
|
const schedule = await this.dataSource
|
|
.getRepository(TrainSchedule)
|
|
.findOne({
|
|
where: { id },
|
|
relations: { trainSet: { locomotive: true } },
|
|
});
|
|
|
|
if (!schedule) {
|
|
throw new NotFoundException(`Train schedule ${id} not found`);
|
|
}
|
|
|
|
await this.dataSource.transaction(async (manager) => {
|
|
await manager.getRepository(TrainSchedule).update(schedule.id, {
|
|
status: "CANCELLED",
|
|
});
|
|
|
|
if (schedule.trainSetId) {
|
|
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
|
|
status: "CANCELLED",
|
|
});
|
|
}
|
|
|
|
if (schedule.trainSet?.locomotiveId) {
|
|
await manager
|
|
.getRepository(Locomotive)
|
|
.update(schedule.trainSet.locomotiveId, {
|
|
status: "AVAILABLE",
|
|
});
|
|
}
|
|
});
|
|
|
|
return this.getContainerTrainScheduleById(id);
|
|
}
|
|
|
|
private async loadBookingsForScheduling(bookingIds: string[]) {
|
|
return this.dataSource.getRepository(Booking).find({
|
|
where: { id: In(bookingIds) },
|
|
relations: {
|
|
company: true,
|
|
originYard: true,
|
|
destinationYard: true,
|
|
bookingContainers: { containerType: true },
|
|
},
|
|
order: { createdAt: "ASC" },
|
|
});
|
|
}
|
|
|
|
private toUtcDateKey(value: Date | string) {
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
private roundTons(value: number | string | null | undefined) {
|
|
const numericValue = typeof value === "number" ? value : Number(value ?? 0);
|
|
|
|
if (!Number.isFinite(numericValue)) {
|
|
return 0;
|
|
}
|
|
|
|
return Number(numericValue.toFixed(3));
|
|
}
|
|
}
|