mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
feat(freight): basics of train scheduling
This commit is contained in:
@@ -0,0 +1,678 @@
|
||||
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;
|
||||
|
||||
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.customer', 'customer')
|
||||
.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');
|
||||
|
||||
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) },
|
||||
);
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
queryBuilder.andWhere('booking.status = :status', { status: query.status });
|
||||
}
|
||||
|
||||
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.customer?.companyName ?? booking.customer?.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 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: { customer: 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?.customer?.companyName ??
|
||||
scheduleBooking.booking?.customer?.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: {
|
||||
customer: 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) {
|
||||
return Number(value.toFixed(3));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user