Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
2026-06-11 20:59:19 +00:00

1800 lines
66 KiB
TypeScript

import {
AllocationLoadType,
SchedulingStatus,
TrainCheckpointKind,
TrainScheduleStatus as TrainScheduleStatusEnum,
WagonStatus,
} from '@edr/types';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { Container } from '../container-management/entities/container.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
import { Route } from '../routes/entities/route.entity';
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 { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository';
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import { PinWagonsDto } from './dto/pin-wagons.dto';
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import {
buildCappedWagonPlan,
computeFleetAvailability,
selectBookingsWithinFleetCap,
summarizeFleetWarnings,
totalAssignedWeight,
type DeferredBookingRow,
type FleetAvailabilityRow,
} from './fleet-plan.util';
import {
buildBulkWagonPlan,
buildContainerWagonPlan,
buildMixedWagonPlan,
expandBookingContainerUnits,
getContainerSlotSequenceNos,
roundTons,
sumWagonsRequired,
type TrainLimitConfig,
validateContainerPlacements,
validateMixedTrainLimits,
validateTrainLimits,
type ContainerPlacementInput,
type WagonPlanSlot,
} from './wagon-plan.util';
import {
getDefaultContainerWagonTypeCode,
pickBulkWagonType,
} from './wagon-type-resolver.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { flipReadiness, wagonReadinessMatchesSchedule } from './wagon-readiness.util';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
maxWeightTons: 3500,
maxLengthMeters: 760,
maxWagonsPerTrain: 53,
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
};
@Injectable()
export class TrainSchedulingService {
constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly bookingsRepository: BookingsRepository,
private readonly locomotivesRepository: LocomotivesRepository,
private readonly wagonTypesRepository: WagonTypesRepository,
private readonly trainSchedulesRepository: TrainSchedulesRepository,
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
private readonly wagonBookingAllocationsRepository: WagonBookingAllocationsRepository,
private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository,
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
private readonly configService?: ConfigService,
) {}
async getEligibleBookings(query: GetEligibleBookingsDto) {
const bookings = await this.bookingsRepository.findEligibleForScheduling({
freightType: query.freightType,
originStationId: query.originStationId,
destinationStationId: query.destinationStationId,
schedulingStatus: query.schedulingStatus,
trainScheduleId: query.trainScheduleId,
});
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
}
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
return this.getEligibleBookings({ ...query, freightType: 'CONTAINER' });
}
async getEligibleBulkBookings(query: GetEligibleBulkBookingsDto) {
return this.getEligibleBookings({ ...query, freightType: 'BULK' });
}
async getTrainSchedulingGlobalRules() {
return this.loadGlobalRulesRow();
}
async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) {
const row = await this.loadGlobalRulesRow();
if (!row) {
throw new NotFoundException('Train scheduling global rules not configured');
}
if (dto.maxTrainLengthMeters != null) row.maxTrainLengthMeters = dto.maxTrainLengthMeters;
if (dto.maxTrainWeightTons != null) row.maxTrainWeightTons = dto.maxTrainWeightTons;
if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain;
if (dto.max20ftContainerWeightTons != null) {
row.max20ftContainerWeightTons = dto.max20ftContainerWeightTons;
}
if (dto.max20ftPairWeightDiffTons != null) {
row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons;
}
return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row);
}
async previewTrainSchedule(dto: PreviewTrainScheduleDto) {
const limits = await this.resolveTrainLimitConfig(dto);
return this.buildPreviewResponse(
await this.validateBookingsForScheduling(
dto,
null,
false,
[],
false,
limits,
dto.targetScheduleId,
),
);
}
async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) {
const limits = await this.resolveTrainLimitConfig(dto);
return this.buildPreviewResponse(
await this.validateBookingsForScheduling(
dto,
'CONTAINER',
false,
[],
false,
limits,
dto.targetScheduleId,
),
);
}
async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) {
const limits = await this.resolveTrainLimitConfig(dto);
return this.buildPreviewResponse(
await this.validateBookingsForScheduling(
dto,
'BULK',
false,
[],
false,
limits,
dto.targetScheduleId,
),
);
}
private buildPreviewResponse(validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>) {
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
return {
valid: validation.valid,
violations: validation.violations,
warnings: validation.warnings,
summary: validation.summary,
fleetAvailability: validation.fleetAvailability,
deferredBookings: validation.deferredBookings,
bookingIds: validation.bookings.map((b) => b.id),
wagonPlan: validation.wagonPlan,
containerUnits: containerBookings.length
? expandBookingContainerUnits(containerBookings)
: [],
containerSlotSequenceNos: getContainerSlotSequenceNos(validation.wagonPlan),
};
}
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
const route = await this.getActiveRoute(dto.routeId);
const locomotive = await this.selectOrValidateLocomotive(dto.locomotiveId, 0, 0);
const createdScheduleId = await this.dataSource.transaction(async (manager) => {
const lockedLocomotive = await manager.getRepository(Locomotive).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`);
}
const direction = deriveScheduleDirection(
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
);
if (!wagonReadinessMatchesSchedule(lockedLocomotive.readiness, direction)) {
throw new ConflictException(
`Locomotive ${lockedLocomotive.code} is ${lockedLocomotive.readiness} and cannot run a ${direction} schedule`,
);
}
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive);
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
routeId: route.id,
originStationId: route.originYardId,
destinationStationId: route.destinationYardId,
scheduledDepartureDate: new Date(dto.scheduleDate),
status: TrainScheduleStatusEnum.Draft,
direction,
maxWagons: (await this.resolveTrainLimitConfig(dto)).maxWagonsPerTrain,
});
const saved = await manager.getRepository(TrainSchedule).save(schedule);
await manager.getRepository(Locomotive).update(lockedLocomotive.id, { status: 'ASSIGNED' });
return saved.id;
});
return this.getTrainScheduleById(createdScheduleId);
}
async assignBookingsToSchedule(
scheduleId: string,
dto: AssignBookingsDto,
freightType?: 'CONTAINER' | 'BULK',
) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot assign bookings to schedule in status ${schedule.status}`,
);
}
if (!schedule.trainSet) {
throw new BadRequestException('Schedule has no train set');
}
// Batch parity: a schedule may only allocate bookings that targeted it. This mirrors
// the automatic fill, which only pulls bookings whose train_schedule_id is this schedule.
if (dto.bookingIds.length) {
const targeted = await this.bookingsRepository.findByIdsForScheduling(dto.bookingIds);
const stray = targeted.filter((b) => b.trainScheduleId !== scheduleId);
if (stray.length) {
throw new BadRequestException(
`These bookings are not assigned to this schedule: ${stray
.map((b) => b.reference ?? b.id)
.join(', ')}`,
);
}
}
const previewDto = {
bookingIds: dto.bookingIds,
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
maxTrainWeightTons: dto.maxTrainWeightTons,
maxTrainLengthMeters: dto.maxTrainLengthMeters,
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? schedule.maxWagons,
};
const limits = await this.resolveTrainLimitConfig(previewDto);
const validation = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
dto.forceAssign,
dto.containerPlacements,
true,
limits,
scheduleId,
);
if (!validation.valid) {
throw new BadRequestException({
message: 'Booking validation failed',
violations: validation.violations,
warnings: validation.warnings,
});
}
if (!validation.bookings.length) {
throw new BadRequestException({
message: 'No bookings fit on available fleet wagons',
violations: ['Insufficient fleet wagons for the selected bookings'],
warnings: validation.warnings,
deferredBookings: validation.deferredBookings,
});
}
const { bookings, wagonType, wagonPlan, warnings, deferredBookings } = validation;
const totalWeightTons = validation.summary.totalWeightTons;
const totalLengthMeters = validation.summary.totalLengthMeters;
const locomotive = schedule.trainSet.locomotive;
if (!locomotive) {
throw new BadRequestException('Schedule train set has no locomotive');
}
if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`,
);
}
if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
);
}
await this.dataSource.transaction(async (manager) => {
const trainSetId = schedule.trainSetId;
await this.releasePinnedWagonsForTrainSet(manager, trainSetId);
const deletedAllocationIds =
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager);
if (deletedAllocationIds.length) {
await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds(
deletedAllocationIds,
manager,
);
await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(
deletedAllocationIds,
manager,
);
}
await manager.getRepository(TrainSetWagon).delete({ trainSetId });
await manager.getRepository(TrainScheduleBooking).delete({ trainScheduleId: scheduleId });
await manager.getRepository(TrainSet).update(trainSetId, {
totalWeightTons,
totalLengthMeters,
wagonCount: wagonPlan.length,
status: 'ASSIGNED',
});
const savedWagons = await this.persistTrainSetWagons(
manager,
trainSetId,
wagonType,
wagonPlan,
);
const scheduleBookingRecords = bookings.map((booking) => ({
trainScheduleId: scheduleId,
bookingId: booking.id,
}));
await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager);
await this.persistAllocationsAndLoads(
manager,
savedWagons,
wagonPlan,
bookings,
dto.containerPlacements ?? [],
);
for (const booking of bookings) {
await this.bookingsRepository.updateSchedulingFields(
booking.id,
{
schedulingStatus: SchedulingStatus.Eligible,
wagonsRequired: sumWagonsRequired(booking),
},
manager,
);
}
if (schedule.status === TrainScheduleStatusEnum.Draft && bookings.length > 0) {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Draft,
{},
manager,
);
}
await this.autoPinWagonsForSchedule(
manager,
scheduleId,
schedule.direction ?? null,
savedWagons,
);
});
const detail = await this.getTrainScheduleById(scheduleId);
return { ...detail, warnings, deferredBookings };
}
async unassignBooking(scheduleId: string, bookingId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule');
}
const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId);
if (!link) {
throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`);
}
await this.dataSource.transaction(async (manager) => {
const allocationIds = (schedule.trainSet?.wagons ?? [])
.flatMap((w) => w.allocations ?? [])
.filter((a) => a.bookingId === bookingId)
.map((a) => a.id);
if (allocationIds.length) {
await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds(
allocationIds,
manager,
);
await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(allocationIds, manager);
await manager.getRepository(WagonBookingAllocation).delete(allocationIds);
}
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
scheduleId,
bookingId,
manager,
);
const booking = await this.bookingsRepository.findById(bookingId);
const schedulingStatus = this.resolvePostUnassignStatus(booking);
await this.bookingsRepository.updateSchedulingFields(
bookingId,
{ schedulingStatus, wagonsRequired: null },
manager,
);
const remainingBookings = (schedule.scheduleBookings ?? []).filter(
(sb) => sb.bookingId !== bookingId,
);
if (remainingBookings.length === 0) {
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
schedule.trainSetId,
manager,
);
await manager.getRepository(TrainSetWagon).delete({ trainSetId: schedule.trainSetId });
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
totalWeightTons: 0,
totalLengthMeters: 0,
wagonCount: 0,
status: 'DRAFT',
});
}
});
return this.getTrainScheduleById(scheduleId);
}
async pinWagons(scheduleId: string, dto: PinWagonsDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule');
}
const slotIds = new Set((schedule.trainSet?.wagons ?? []).map((w) => w.id));
await this.dataSource.transaction(async (manager) => {
for (const assignment of dto.assignments) {
if (!slotIds.has(assignment.trainSetWagonId)) {
throw new BadRequestException(
`Train set wagon ${assignment.trainSetWagonId} does not belong to this schedule`,
);
}
const physicalWagon = await manager.getRepository(Wagon).findOne({
where: { id: assignment.physicalWagonId },
});
if (!physicalWagon) {
throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`);
}
if (
physicalWagon.status !== WagonStatus.Available &&
physicalWagon.currentTrainScheduleId !== scheduleId
) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is not available`,
);
}
if (!wagonReadinessMatchesSchedule(physicalWagon.readiness, schedule.direction)) {
throw new ConflictException(
`Wagon ${physicalWagon.wagonNumber} is ${physicalWagon.readiness} but schedule is ${schedule.direction ?? 'unknown'}`,
);
}
await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
physicalWagonId: assignment.physicalWagonId,
status: 'RESERVED',
});
await manager.getRepository(Wagon).update(assignment.physicalWagonId, {
trainSetWagonId: assignment.trainSetWagonId,
currentTrainScheduleId: scheduleId,
status: WagonStatus.Assigned,
});
}
});
return this.getTrainScheduleById(scheduleId);
}
async finalizeSchedule(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== TrainScheduleStatusEnum.Draft) {
throw new BadRequestException('Only DRAFT schedules can be finalized');
}
if (!schedule.scheduleBookings?.length) {
throw new BadRequestException('Cannot finalize a schedule with no bookings');
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Scheduled,
{},
manager,
);
for (const sb of schedule.scheduleBookings ?? []) {
await this.bookingsRepository.updateSchedulingFields(
sb.bookingId,
{ schedulingStatus: SchedulingStatus.Scheduled, scheduledAt: now },
manager,
);
}
});
return this.getTrainScheduleById(scheduleId);
}
async dispatchSchedule(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Dispatched,
{ actualDepartureAt: now },
manager,
);
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' });
}
for (const sb of schedule.scheduleBookings ?? []) {
await this.bookingsRepository.updateSchedulingFields(
sb.bookingId,
{ schedulingStatus: SchedulingStatus.Dispatched },
manager,
);
}
// Close the booking window; any still-pending (unallocated) reservations don't ride this train.
await manager
.getRepository(TrainSchedule)
.update(scheduleId, { bookingWindowStatus: 'CLOSED' });
await manager
.getRepository(Booking)
.createQueryBuilder()
.update()
.set({
status: 'EXPIRED',
schedulingStatus: SchedulingStatus.Eligible,
paymentDeadline: null,
})
.where('train_schedule_id = :scheduleId', { scheduleId })
.andWhere(`status = 'AWAITING_PAYMENT'`)
.execute();
});
return this.getTrainScheduleById(scheduleId);
}
/** Open or close a schedule's booking window (staff override). */
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { bookingWindowStatus: status });
}
/** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */
private async buildScheduleStations(schedule: TrainSchedule) {
type Station = { sequenceNo: number; yardId: string; label: string; code: string };
const stations: Station[] = [];
const route = schedule.routeId
? await this.dataSource.getRepository(Route).findOne({
where: { id: schedule.routeId },
relations: { originYard: true, destinationYard: true, milestones: { yard: true } },
})
: null;
if (route) {
const origin = route.originYard;
const destination = route.destinationYard;
const milestones = [...(route.milestones ?? [])].sort(
(a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
);
stations.push({
sequenceNo: 0,
yardId: route.originYardId,
label: origin?.label ?? origin?.code ?? 'Origin',
code: origin?.code ?? '',
});
milestones.forEach((m, i) =>
stations.push({
sequenceNo: i + 1,
yardId: m.yardId,
label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
code: m.yard?.code ?? '',
}),
);
stations.push({
sequenceNo: milestones.length + 1,
yardId: route.destinationYardId,
label: destination?.label ?? destination?.code ?? 'Destination',
code: destination?.code ?? '',
});
return stations;
}
// Fallback: no route milestones — just origin → destination from the schedule stations.
stations.push({
sequenceNo: 0,
yardId: schedule.originStationId,
label: schedule.originStation?.label ?? schedule.originStation?.code ?? 'Origin',
code: schedule.originStation?.code ?? '',
});
stations.push({
sequenceNo: 1,
yardId: schedule.destinationStationId,
label:
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? 'Destination',
code: schedule.destinationStation?.code ?? '',
});
return stations;
}
/** Track payload for a schedule: ordered stations, logged checkpoints, current position. */
async getScheduleCheckpoints(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
const stations = await this.buildScheduleStations(schedule);
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
const currentSequenceNo = events.length
? Math.max(...events.map((e) => e.sequenceNo))
: -1;
return {
scheduleId,
status: schedule.status,
direction: schedule.direction ?? null,
trainNumber: schedule.trainNumber ?? null,
actualDepartureAt: schedule.actualDepartureAt
? schedule.actualDepartureAt.toISOString()
: null,
actualArrivalAt: schedule.actualArrivalAt
? schedule.actualArrivalAt.toISOString()
: null,
origin: stations[0]?.label ?? null,
destination: stations[stations.length - 1]?.label ?? null,
stations,
currentSequenceNo,
checkpoints: events.map((e) => ({
id: e.id,
sequenceNo: e.sequenceNo,
yardId: e.yardId,
label: e.yard?.label ?? e.yard?.code ?? null,
kind: e.kind,
occurredAt: e.occurredAt.toISOString(),
note: e.note ?? null,
})),
};
}
/** Log the train passing a station. Logging the destination station triggers arrival. */
async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
throw new BadRequestException('Only DISPATCHED trains can be tracked');
}
const stations = await this.buildScheduleStations(schedule);
const finalSeq = stations[stations.length - 1].sequenceNo;
const station = stations.find((s) => s.sequenceNo === dto.sequenceNo);
if (!station) {
throw new BadRequestException(`Station ${dto.sequenceNo} is not on this route`);
}
const kind =
dto.kind ??
(dto.sequenceNo === 0
? TrainCheckpointKind.Departed
: dto.sequenceNo === finalSeq
? TrainCheckpointKind.Arrived
: TrainCheckpointKind.Passed);
const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date();
// Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
const [existing] = await this.trainCheckpointEventsRepository.findAll({
where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo },
});
if (existing) {
await this.trainCheckpointEventsRepository.update(existing.id, {
kind,
occurredAt,
note: dto.note ?? null,
yardId: station.yardId,
});
} else {
await this.trainCheckpointEventsRepository.create({
trainScheduleId: scheduleId,
yardId: station.yardId,
sequenceNo: dto.sequenceNo,
kind,
occurredAt,
note: dto.note ?? null,
});
}
if (dto.sequenceNo === finalSeq) {
await this.arriveSchedule(scheduleId);
}
return this.getScheduleCheckpoints(scheduleId);
}
/**
* Mark a dispatched train arrived: close out the schedule, flip readiness on the
* locomotive + wagons (they have repositioned), and free the assets for re-use.
*/
async arriveSchedule(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
throw new BadRequestException('Only DISPATCHED trains can arrive');
}
const isDomestic = schedule.direction === 'DOMESTIC';
const now = new Date();
await this.dataSource.transaction(async (manager) => {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Arrived,
{ actualArrivalAt: now },
manager,
);
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
status: 'COMPLETED',
});
}
if (schedule.trainSet?.locomotiveId) {
const loco = await manager
.getRepository(Locomotive)
.findOne({ where: { id: schedule.trainSet.locomotiveId } });
if (loco) {
await manager.getRepository(Locomotive).update(loco.id, {
status: 'AVAILABLE',
readiness: isDomestic ? loco.readiness : flipReadiness(loco.readiness),
});
}
}
for (const slot of schedule.trainSet?.wagons ?? []) {
if (!slot.physicalWagonId) continue;
const wagon = await manager
.getRepository(Wagon)
.findOne({ where: { id: slot.physicalWagonId } });
if (!wagon) continue;
await manager.getRepository(Wagon).update(wagon.id, {
currentTrainScheduleId: null,
trainSetWagonId: null,
status: WagonStatus.Available,
readiness: isDomestic ? wagon.readiness : flipReadiness(wagon.readiness),
});
}
// Ensure a destination checkpoint exists so the timeline shows ARRIVED.
const stations = await this.buildScheduleStations(schedule);
const finalStation = stations[stations.length - 1];
const [existingFinal] = await this.trainCheckpointEventsRepository.findAll({
where: { trainScheduleId: scheduleId, sequenceNo: finalStation.sequenceNo },
});
if (!existingFinal) {
await manager.getRepository(TrainCheckpointEvent).save(
manager.getRepository(TrainCheckpointEvent).create({
trainScheduleId: scheduleId,
yardId: finalStation.yardId,
sequenceNo: finalStation.sequenceNo,
kind: TrainCheckpointKind.Arrived,
occurredAt: now,
}),
);
}
});
return this.getTrainScheduleById(scheduleId);
}
async getContainerTrainSchedules() {
const schedules = await this.trainSchedulesRepository.findAll({
relations: {
trainSet: { locomotive: true },
route: true,
originStation: true,
destinationStation: true,
scheduleBookings: { booking: true },
},
order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' },
});
return schedules.map((s) => this.mapScheduleListItem(s));
}
async getContainerTrainScheduleById(id: string) {
return this.getTrainScheduleById(id);
}
async cancelTrainSchedule(id: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
await this.dataSource.transaction(async (manager) => {
await this.trainSchedulesRepository.updateStatus(
id,
TrainScheduleStatusEnum.Cancelled,
{},
manager,
);
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',
});
}
for (const wagon of schedule.trainSet?.wagons ?? []) {
if (wagon.physicalWagonId) {
await manager.getRepository(Wagon).update(wagon.physicalWagonId, {
currentTrainScheduleId: null,
trainSetWagonId: null,
status: WagonStatus.Available,
});
}
}
for (const sb of schedule.scheduleBookings ?? []) {
const booking = await this.bookingsRepository.findById(sb.bookingId);
await this.bookingsRepository.updateSchedulingFields(
sb.bookingId,
{ schedulingStatus: this.resolvePostUnassignStatus(booking) },
manager,
);
}
});
return this.getTrainScheduleById(id);
}
private async getTrainScheduleById(id: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
return this.mapScheduleDetail(schedule);
}
private async validateBookingsForScheduling(
dto: PreviewContainerTrainScheduleDto | PreviewBulkTrainScheduleDto | PreviewTrainScheduleDto,
freightType: 'CONTAINER' | 'BULK' | null,
forceAssign = false,
containerPlacements: ContainerPlacementInput[] = [],
requireContainerPlacements = false,
trainLimits: Required<TrainLimitConfig>,
targetScheduleId?: string,
) {
const bookingIds = [...new Set(dto.bookingIds)];
if (!bookingIds.length) {
throw new BadRequestException('At least one booking is required');
}
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
const violations: string[] = [];
const warnings: string[] = [];
if (bookings.length !== bookingIds.length) {
const foundIds = new Set(bookings.map((b) => b.id));
violations.push(`Bookings not found: ${bookingIds.filter((id) => !foundIds.has(id)).join(', ')}`);
}
const scheduledLinks = await this.trainScheduleBookingsRepository.findByBookingIds(bookingIds);
const conflictingLinks = targetScheduleId
? scheduledLinks.filter((link) => link.trainScheduleId !== targetScheduleId)
: scheduledLinks;
if (conflictingLinks.length > 0) {
violations.push('One or more selected bookings are already assigned to a train schedule');
}
const bookingTypes = new Set(bookings.map((b) => b.freightType));
const isMixed = bookingTypes.size > 1;
const resolvedMode: 'CONTAINER' | 'BULK' | 'MIXED' =
freightType ?? (isMixed ? 'MIXED' : ([...bookingTypes][0] as 'CONTAINER' | 'BULK'));
if (freightType === 'CONTAINER' || freightType === 'BULK') {
const wrongType = bookings.filter((b) => b.freightType !== freightType);
if (wrongType.length) {
violations.push(`Only ${freightType} bookings are supported`);
}
}
const invalidStatus = bookings.filter(
(b) =>
!SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') && !b.isGovernment,
);
if (invalidStatus.length) {
const statuses = [...new Set(invalidStatus.map((b) => b.status))];
violations.push(
`Only ${SCHEDULABLE_BOOKING_STATUSES.join(', ')} bookings can be scheduled; received: ${statuses.join(', ')}`,
);
}
if (
bookings.some(
(b) =>
b.originYardId !== dto.originStationId ||
b.destinationYardId !== dto.destinationStationId,
)
) {
violations.push('Selected bookings must share the same origin and destination as the schedule');
}
if (!forceAssign) {
for (const booking of bookings) {
if (this.isHoldActive(booking)) {
warnings.push(
`Booking ${booking.reference} is within the soft hold window (expires ${booking.holdExpiresAt?.toISOString()})`,
);
}
const overweightLines = (booking.bookingContainers ?? []).filter((c) => c.isOverweight);
if (overweightLines.length) {
violations.push(
`Booking ${booking.reference} has overweight container lines; use forceAssign to override`,
);
}
}
}
let wagonType: WagonType;
let containerWagonType: WagonType;
let bulkWagonType: WagonType;
let demandPlan: WagonPlanSlot[];
let fittingBookings = bookings;
let deferredBookings: DeferredBookingRow[] = [];
let fleetAvailability: FleetAvailabilityRow[] = [];
if (resolvedMode === 'MIXED') {
const containerBookings = bookings.filter((b) => b.freightType === 'CONTAINER');
const bulkBookings = bookings.filter((b) => b.freightType === 'BULK');
containerWagonType = await this.resolveWagonType('CONTAINER', bookingIds);
bulkWagonType = await this.resolveWagonType('BULK', bookingIds);
wagonType = containerWagonType;
demandPlan = buildMixedWagonPlan(
containerBookings,
bulkBookings,
containerWagonType,
bulkWagonType,
);
} else {
wagonType = await this.resolveWagonType(resolvedMode, bookingIds);
containerWagonType = wagonType;
bulkWagonType = wagonType;
demandPlan =
resolvedMode === 'CONTAINER'
? buildContainerWagonPlan(bookings, wagonType)
: buildBulkWagonPlan(bookings, wagonType);
}
const scheduleDirection = await this.resolveScheduleDirection(targetScheduleId, bookings);
const fleetCounts = await this.countFleetAvailability(scheduleDirection, targetScheduleId);
const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available]));
fleetAvailability = computeFleetAvailability(
demandPlan,
fleetByTypeId,
new Map(fleetCounts.map((row) => [row.wagonTypeId, row.wagonTypeCode])),
);
const selection = selectBookingsWithinFleetCap(
bookings,
fleetByTypeId,
(booking) =>
booking.freightType === 'BULK' ? bulkWagonType.id : containerWagonType.id,
Number(bulkWagonType.capacityTons),
);
fittingBookings = selection.fitting;
deferredBookings = selection.deferred;
warnings.push(...summarizeFleetWarnings(fleetAvailability, deferredBookings));
const wagonPlan = buildCappedWagonPlan({
bookings: fittingBookings,
resolvedMode,
containerWagonType,
bulkWagonType,
});
const placementRules = {
max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
};
if (resolvedMode === 'MIXED') {
violations.push(
...validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits),
);
if (requireContainerPlacements) {
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
violations.push(
...validateContainerPlacements(
containerBookings,
wagonPlan,
containerPlacements,
placementRules,
),
);
violations.push(
...(await this.validateFleetContainers(containerPlacements, containerBookings)),
);
}
} else {
violations.push(...validateTrainLimits(wagonPlan, wagonType, trainLimits));
if (requireContainerPlacements && resolvedMode === 'CONTAINER') {
violations.push(
...validateContainerPlacements(
fittingBookings,
wagonPlan,
containerPlacements,
placementRules,
),
);
violations.push(
...(await this.validateFleetContainers(containerPlacements, fittingBookings)),
);
}
}
const totalWeightTons = totalAssignedWeight(fittingBookings);
const totalLengthMeters = roundTons(
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
);
if (totalWeightTons > trainLimits.maxWeightTons) {
const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`;
if (!violations.includes(message)) {
violations.push(message);
}
}
const availableLocomotives = await this.locomotivesRepository.findAll({
where: { status: 'AVAILABLE' },
});
if (!availableLocomotives.length) {
violations.push('No available locomotive exists for scheduling');
} else if (
!availableLocomotives.some(
(l) =>
Number(l.maxPullWeightTons) >= totalWeightTons &&
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
)
) {
violations.push('No available locomotive can support the total train weight and length');
}
return {
valid: violations.length === 0,
violations,
warnings,
bookings: fittingBookings,
wagonType,
wagonPlan,
fleetAvailability,
deferredBookings,
summary: {
totalBookings: fittingBookings.length,
totalWeightTons,
wagonType:
resolvedMode === 'MIXED' ? 'MIXED' : wagonType.code,
wagonsNeeded: wagonPlan.length,
totalLengthMeters,
freightMode: resolvedMode,
},
};
}
private async loadGlobalRulesRow(): Promise<TrainSchedulingGlobalRules | null> {
try {
const rows = await this.dataSource.getRepository(TrainSchedulingGlobalRules).find({
order: { createdAt: 'ASC' },
take: 1,
});
return rows[0] ?? null;
} catch {
return null;
}
}
private async resolveTrainLimitConfig(dto?: {
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
}): Promise<Required<TrainLimitConfig>> {
const row = await this.loadGlobalRulesRow();
const configured = this.configService?.get<{
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
}>('app.trainScheduling');
return {
maxWeightTons: this.positiveNumber(
dto?.maxTrainWeightTons,
Number(row?.maxTrainWeightTons) ||
configured?.maxTrainWeightTons ||
DEFAULT_TRAIN_LIMITS.maxWeightTons,
),
maxLengthMeters: this.positiveNumber(
dto?.maxTrainLengthMeters,
Number(row?.maxTrainLengthMeters) ||
configured?.maxTrainLengthMeters ||
DEFAULT_TRAIN_LIMITS.maxLengthMeters,
),
maxWagonsPerTrain: Math.floor(
this.positiveNumber(
dto?.maxWagonsPerTrain,
Number(row?.maxWagonsPerTrain) ||
configured?.maxWagonsPerTrain ||
DEFAULT_TRAIN_LIMITS.maxWagonsPerTrain,
),
),
max20ftContainerWeightTons: this.positiveNumber(
undefined,
Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
),
max20ftPairWeightDiffTons: this.positiveNumber(
undefined,
Number(row?.max20ftPairWeightDiffTons) ||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
),
};
}
private async resolveScheduleDirection(
targetScheduleId: string | undefined,
bookings: Booking[],
): Promise<string | null> {
if (targetScheduleId) {
const schedule = await this.trainSchedulesRepository.findById(targetScheduleId);
if (schedule?.direction) return schedule.direction;
}
const booking = bookings[0];
if (!booking) return null;
return deriveScheduleDirection(
booking.originYard ?? { country: null },
booking.destinationYard ?? { country: null },
);
}
private async countFleetAvailability(
scheduleDirection: string | null,
targetScheduleId?: string,
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
const [wagons, wagonTypes] = await Promise.all([
this.dataSource.getRepository(Wagon).find(),
this.dataSource.getRepository(WagonType).find(),
]);
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
const counts = new Map<string, { code: string; available: number }>();
for (const wagon of wagons) {
const pinnedOnTarget = targetScheduleId
? wagon.currentTrainScheduleId === targetScheduleId
: false;
if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue;
if (!wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection)) continue;
const typeId = wagon.wagonTypeId;
const code = typeCodeById.get(typeId) ?? typeId;
const existing = counts.get(typeId) ?? { code, available: 0 };
existing.available += 1;
counts.set(typeId, existing);
}
return [...counts.entries()].map(([wagonTypeId, value]) => ({
wagonTypeId,
wagonTypeCode: value.code,
available: value.available,
}));
}
private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) {
const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
for (const slot of slots) {
if (!slot.physicalWagonId) continue;
await manager.getRepository(Wagon).update(slot.physicalWagonId, {
status: WagonStatus.Available,
trainSetWagonId: null,
currentTrainScheduleId: null,
});
}
}
private async autoPinWagonsForSchedule(
manager: EntityManager,
scheduleId: string,
scheduleDirection: string | null,
slots: TrainSetWagon[],
) {
const wagons = await manager.getRepository(Wagon).find();
const assignedPhysicalIds = new Set<string>();
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
const candidates = wagons.filter((wagon) => {
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
if (assignedPhysicalIds.has(wagon.id)) return false;
const pinnedOnSchedule = wagon.currentTrainScheduleId === scheduleId;
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false;
return wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection);
});
const physical = candidates[0];
if (!physical) continue;
await manager.getRepository(TrainSetWagon).update(slot.id, {
physicalWagonId: physical.id,
status: 'RESERVED',
});
await manager.getRepository(Wagon).update(physical.id, {
trainSetWagonId: slot.id,
currentTrainScheduleId: scheduleId,
status: WagonStatus.Assigned,
});
assignedPhysicalIds.add(physical.id);
}
}
private positiveNumber(value: number | undefined, fallback: number): number {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
}
private async validateFleetContainers(
placements: ContainerPlacementInput[],
containerBookings: Booking[],
): Promise<string[]> {
const violations: string[] = [];
const inventoryIds = [
...new Set(placements.map((p) => p.containerId).filter((id): id is string => Boolean(id))),
];
if (!inventoryIds.length) return violations;
const lineById = new Map(
containerBookings.flatMap((b) =>
(b.bookingContainers ?? []).map((line) => [line.id, line] as const),
),
);
const containers = await this.dataSource.getRepository(Container).find({
where: { id: In(inventoryIds) },
});
const containerById = new Map(containers.map((c) => [c.id, c]));
for (const placement of placements) {
if (!placement.containerId) continue;
const fleet = containerById.get(placement.containerId);
if (!fleet) {
violations.push(`Fleet container ${placement.containerId} not found`);
continue;
}
if (fleet.status !== 'AVAILABLE') {
violations.push(`Container ${fleet.containerNumber} is not available`);
}
const line = lineById.get(placement.bookingContainerId);
if (line && fleet.containerTypeId !== line.containerTypeId) {
violations.push(
`Container ${fleet.containerNumber} type does not match booking line`,
);
}
if (
placement.containerNumber &&
fleet.containerNumber.toUpperCase() !== placement.containerNumber.trim().toUpperCase()
) {
violations.push(
`Container number ${placement.containerNumber} does not match fleet record ${fleet.containerNumber}`,
);
}
}
return violations;
}
private async resolveWagonType(
freightType: 'CONTAINER' | 'BULK',
bookingIds: string[],
): Promise<WagonType> {
if (freightType === 'CONTAINER') {
const [wagonType] = await this.wagonTypesRepository.findAll({
where: { code: getDefaultContainerWagonTypeCode(), isActive: true },
});
if (!wagonType) {
throw new NotFoundException(`Wagon type ${getDefaultContainerWagonTypeCode()} not found`);
}
return wagonType;
}
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
const cargoCode = bookings[0]?.cargoType?.code ?? null;
const wagonTypes = await this.wagonTypesRepository.findAll({ where: { isActive: true } });
const picked = pickBulkWagonType(wagonTypes, cargoCode);
if (!picked) {
throw new NotFoundException('No suitable bulk wagon type found');
}
return picked;
}
private async persistTrainSetWagons(
manager: EntityManager,
trainSetId: string,
wagonType: WagonType,
wagonPlan: WagonPlanSlot[],
) {
const wagons = wagonPlan.map((slot) =>
manager.getRepository(TrainSetWagon).create({
trainSetId,
wagonTypeId: slot.wagonTypeId ?? wagonType.id,
sequenceNo: slot.sequenceNo,
capacityTons: slot.capacityTons,
lengthMeters: slot.lengthMeters,
assignedWeightTons: slot.assignedWeightTons,
status: 'PLANNED',
}),
);
return manager.getRepository(TrainSetWagon).save(wagons);
}
private async persistAllocationsAndLoads(
manager: EntityManager,
savedWagons: TrainSetWagon[],
wagonPlan: WagonPlanSlot[],
bookings: Booking[],
containerPlacements: ContainerPlacementInput[] = [],
) {
const bookingById = new Map(bookings.map((b) => [b.id, b]));
const lineById = new Map(
bookings.flatMap((b) =>
(b.bookingContainers ?? []).map((line) => [line.id, { line, bookingId: b.id }] as const),
),
);
const allocationBySlotBooking = new Map<string, string>();
const containerItems: Array<{
wagonBookingAllocationId: string;
bookingContainerId: string;
containerTypeId: string | null;
grossWeightTons: number;
positionOnWagon: number | null;
containerId?: string | null;
containerNumber?: string | null;
sealNumber?: string | null;
}> = [];
const bulkLoads: Array<{
wagonBookingAllocationId: string;
bookingId: string;
cargoTypeId: string | null;
cargoDescription: string | null;
weightTons: number;
quantity: number;
}> = [];
for (let i = 0; i < savedWagons.length; i += 1) {
const slot = wagonPlan[i];
const trainSetWagon = savedWagons[i];
if (!slot || !trainSetWagon) continue;
for (const alloc of slot.allocations) {
const savedAllocation = await manager.getRepository(WagonBookingAllocation).save(
manager.getRepository(WagonBookingAllocation).create({
trainSetWagonId: trainSetWagon.id,
bookingId: alloc.bookingId,
allocatedWeightTons: alloc.allocatedWeightTons,
loadType: alloc.loadType,
status: 'PLANNED',
}),
);
allocationBySlotBooking.set(
`${slot.sequenceNo}:${alloc.bookingId}`,
savedAllocation.id,
);
const booking = bookingById.get(alloc.bookingId);
if (!booking) continue;
if (alloc.loadType === AllocationLoadType.Bulk) {
bulkLoads.push({
wagonBookingAllocationId: savedAllocation.id,
bookingId: booking.id,
cargoTypeId: booking.cargoTypeId ?? null,
cargoDescription: booking.cargoFreeText ?? null,
weightTons: alloc.allocatedWeightTons,
quantity: 1,
});
}
}
}
for (const placement of containerPlacements) {
const lineEntry = lineById.get(placement.bookingContainerId);
if (!lineEntry) continue;
// Durably persist the container number on the booking container line first, so it
// survives a refresh regardless of whether a wagon allocation slot can be matched
// below. booking_container is the source of truth re-read into the preview units.
if (placement.containerNumber && placement.containerNumber.trim()) {
await manager.getRepository(BookingContainer).update(placement.bookingContainerId, {
containerNumber: placement.containerNumber.trim(),
});
}
const allocationId = allocationBySlotBooking.get(
`${placement.sequenceNo}:${lineEntry.bookingId}`,
);
if (!allocationId) continue;
const { line } = lineEntry;
containerItems.push({
wagonBookingAllocationId: allocationId,
bookingContainerId: placement.bookingContainerId,
containerTypeId: line.containerTypeId ?? null,
grossWeightTons: Number(line.vgmPerUnitTons),
positionOnWagon: placement.unitIndex + 1,
containerId: placement.containerId ?? null,
containerNumber: placement.containerNumber?.trim() ?? null,
sealNumber: placement.sealNumber ?? null,
});
if (placement.containerId) {
await manager.getRepository(Container).update(placement.containerId, {
status: 'LOADED',
bookingId: lineEntry.bookingId,
wagonBookingAllocationId: allocationId,
bookingContainerId: placement.bookingContainerId,
});
}
}
if (containerItems.length) {
await this.wagonAllocationContainerItemsRepository.createMany(containerItems, manager);
}
if (bulkLoads.length) {
await this.wagonAllocationBulkLoadsRepository.createMany(bulkLoads, manager);
}
}
async selectOrValidateLocomotive(
locomotiveId: string,
totalWeightTons: number,
totalLengthMeters: 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`);
}
if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
);
}
return locomotive;
}
private 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);
}
private async getActiveRoute(routeId: string) {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id: routeId },
relations: { originYard: true, destinationYard: true },
});
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
if (!route.isActive) throw new BadRequestException(`Route ${route.name} is inactive`);
return route;
}
private mapEligibleBooking(booking: Booking) {
return {
id: booking.id,
reference: booking.reference,
freightType: booking.freightType,
customer: booking.company?.name ?? booking.company?.email ?? 'Unknown customer',
priorityScore: booking.priorityScore,
schedulingStatus: booking.schedulingStatus,
containerType:
booking.bookingContainers
?.map((c) => c.containerType?.label ?? c.containerType?.code ?? 'Container')
.join(', ') ?? (booking.cargoType?.cargoTypeName ?? 'Bulk'),
quantity:
booking.bookingContainers?.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0) ?? 0,
weightTons: 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,
};
}
private resolveScheduleFreightType(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
const types = new Set(
(schedule.scheduleBookings ?? [])
.map((sb) => sb.booking?.freightType)
.filter((t): t is string => Boolean(t)),
);
if (types.size === 1) return [...types][0] as 'CONTAINER' | 'BULK';
if (types.size > 1) return 'MIXED';
return null;
}
private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) {
return {
id: schedule.id,
scheduleDate: schedule.scheduledDepartureDate,
trainNumber: schedule.trainNumber ?? null,
routeName: schedule.route?.name ?? null,
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,
readiness: schedule.trainSet.locomotive.readiness ?? null,
}
: null,
wagonCount: schedule.trainSet?.wagonCount ?? 0,
totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
bookingsCount: schedule.scheduleBookings?.length ?? 0,
freightType: this.resolveScheduleFreightType(schedule),
status: schedule.status,
bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
maxWagons: schedule.maxWagons ?? 0,
remainingWagons: Math.max(
0,
(schedule.maxWagons ?? 0) - (schedule.trainSet?.wagonCount ?? 0),
),
};
}
/** OPEN, same-route schedules a new booking may target (with rough remaining capacity). */
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
const schedules = await this.trainSchedulesRepository.findAll({
where: {
bookingWindowStatus: 'OPEN',
...(originYardId ? { originStationId: originYardId } : {}),
...(destinationYardId ? { destinationStationId: destinationYardId } : {}),
},
relations: {
trainSet: { locomotive: true },
route: true,
originStation: true,
destinationStation: true,
scheduleBookings: { booking: true },
},
order: { scheduledDepartureDate: 'ASC' },
});
return schedules
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
.map((s) => this.mapScheduleListItem(s));
}
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {
const allocationIds = (schedule.trainSet?.wagons ?? [])
.flatMap((w) => w.allocations ?? [])
.map((a) => a.id);
const [containerItems, bulkLoads] = await Promise.all([
allocationIds.length
? this.wagonAllocationContainerItemsRepository.findAll({
where: { wagonBookingAllocationId: In(allocationIds) },
relations: { containerType: true, bookingContainer: true },
})
: [],
allocationIds.length
? this.wagonAllocationBulkLoadsRepository.findAll({
where: { wagonBookingAllocationId: In(allocationIds) },
relations: { cargoType: true },
})
: [],
]);
const containerItemsByAllocation = new Map<string, typeof containerItems>();
for (const item of containerItems) {
const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? [];
list.push(item);
containerItemsByAllocation.set(item.wagonBookingAllocationId, list);
}
const bulkLoadsByAllocation = new Map(
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
);
return {
id: schedule.id,
status: schedule.status,
freightType: this.resolveScheduleFreightType(schedule),
trainNumber: schedule.trainNumber ?? null,
direction: schedule.direction ?? null,
route: schedule.route ? { id: schedule.route.id, name: schedule.route.name } : null,
scheduledDepartureDate: schedule.scheduledDepartureDate,
scheduledArrivalDate: schedule.scheduledArrivalDate,
actualDepartureAt: schedule.actualDepartureAt ?? null,
originStation: schedule.originStation,
destinationStation: schedule.destinationStation,
trainSet: schedule.trainSet
? {
id: schedule.trainSet.id,
status: schedule.trainSet.status,
wagonCount: schedule.trainSet.wagonCount,
totalWeightTons: roundTons(Number(schedule.trainSet.totalWeightTons)),
totalLengthMeters: 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,
readiness: schedule.trainSet.locomotive.readiness ?? null,
maxPullWeightTons: roundTons(
Number(schedule.trainSet.locomotive.maxPullWeightTons),
),
maxTrainLengthMeters: roundTons(
Number(schedule.trainSet.locomotive.maxTrainLengthMeters),
),
}
: null,
wagons: [...(schedule.trainSet.wagons ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((wagon) => ({
id: wagon.id,
sequenceNo: wagon.sequenceNo,
capacityTons: roundTons(Number(wagon.capacityTons)),
lengthMeters: roundTons(Number(wagon.lengthMeters)),
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
status: wagon.status,
physicalWagonId: wagon.physicalWagonId ?? null,
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
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: roundTons(Number(allocation.allocatedWeightTons)),
loadType: allocation.loadType ?? null,
status: allocation.status,
containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map(
(item) => ({
id: item.id,
containerNumber: item.containerNumber ?? null,
containerTypeId: item.containerTypeId,
grossWeightTons: item.grossWeightTons ?? null,
containerId: item.containerId ?? null,
positionOnWagon: item.positionOnWagon ?? null,
bookingContainerId: item.bookingContainerId ?? null,
}),
),
bulkLoad: bulkLoadsByAllocation.get(allocation.id)
? {
id: bulkLoadsByAllocation.get(allocation.id)!.id,
weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons,
cargoDescription:
bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null,
}
: null,
})) ?? [],
})),
}
: null,
bookings:
schedule.scheduleBookings?.map((sb) => ({
id: sb.booking?.id ?? sb.bookingId,
reference: sb.booking?.reference ?? null,
customer: sb.booking?.company?.name ?? sb.booking?.company?.email ?? null,
weightTons: roundTons(Number(sb.booking?.cargoTotalWeightVgm ?? 0)),
status: sb.booking?.status ?? null,
schedulingStatus: sb.booking?.schedulingStatus ?? null,
})) ?? [],
};
}
private isHoldActive(booking: Booking): boolean {
if (!booking.holdExpiresAt) return false;
return booking.holdExpiresAt.getTime() > Date.now();
}
private resolvePostUnassignStatus(booking: Booking | null): string {
if (!booking) return SchedulingStatus.NotScheduled;
if (booking.holdExpiresAt && booking.holdExpiresAt.getTime() > Date.now()) {
return SchedulingStatus.Holding;
}
return SchedulingStatus.Eligible;
}
}