mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
schedule logic
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AllocationLoadType,
|
||||
SchedulingStatus,
|
||||
TrainCheckpointKind,
|
||||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||
WagonStatus,
|
||||
} from '@edr/types';
|
||||
@@ -74,7 +75,11 @@ import {
|
||||
pickBulkWagonType,
|
||||
} from './wagon-type-resolver.util';
|
||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||
import { wagonReadinessMatchesSchedule } from './wagon-readiness.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> = {
|
||||
@@ -98,6 +103,7 @@ export class TrainSchedulingService {
|
||||
private readonly wagonBookingAllocationsRepository: WagonBookingAllocationsRepository,
|
||||
private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository,
|
||||
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
|
||||
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
|
||||
private readonly configService?: ConfigService,
|
||||
) {}
|
||||
|
||||
@@ -219,11 +225,17 @@ export class TrainSchedulingService {
|
||||
throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`);
|
||||
}
|
||||
|
||||
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive);
|
||||
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,
|
||||
@@ -581,6 +593,237 @@ export class TrainSchedulingService {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
/** 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: {
|
||||
@@ -1339,6 +1582,7 @@ export class TrainSchedulingService {
|
||||
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,
|
||||
@@ -1407,6 +1651,7 @@ export class TrainSchedulingService {
|
||||
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),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user