schedule logic

This commit is contained in:
Marshal
2026-06-10 09:22:57 +00:00
parent 0335555892
commit 088295d81f
23 changed files with 1766 additions and 319 deletions

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common';
import { WagonReadiness } from '@edr/types';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
@@ -12,12 +13,20 @@ export const LOCOMOTIVE_STATUSES = [
export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const;
/** Locomotives reuse the wagon readiness values (IMPORT_READY / EXPORT_READY). */
export const LOCOMOTIVE_READINESS_VALUES = [
WagonReadiness.ImportReady,
WagonReadiness.ExportReady,
] as const;
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number];
export type LocomotiveReadiness = (typeof LOCOMOTIVE_READINESS_VALUES)[number];
@Entity({ schema: 'freight', name: 'locomotives' })
@Index(['code'])
@Index(['status'])
@Index(['readiness'])
export class Locomotive extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
code!: string;
@@ -37,6 +46,9 @@ export class Locomotive extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
status!: LocomotiveStatus;
@Column({ name: 'readiness', type: 'varchar', length: 20, default: WagonReadiness.ImportReady })
readiness!: LocomotiveReadiness;
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
powerKw?: number | null;

View File

@@ -0,0 +1,34 @@
import { ApiProperty } from '@nestjs/swagger';
import { TrainCheckpointKind } from '@edr/types';
import {
IsEnum,
IsInt,
IsISO8601,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
export class RecordCheckpointDto {
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
@IsInt()
@Min(0)
sequenceNo!: number;
@ApiProperty({ enum: TrainCheckpointKind, required: false })
@IsOptional()
@IsEnum(TrainCheckpointKind)
kind?: TrainCheckpointKind;
@ApiProperty({ required: false, description: 'ISO timestamp; defaults to now.' })
@IsOptional()
@IsISO8601()
occurredAt?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}

View File

@@ -0,0 +1,45 @@
import { BaseEntity } from '@edr/api-common';
import { TrainCheckpointKind } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
/**
* One staff-logged tracking checkpoint for a dispatched train as it passes a
* station along its route (origin → milestones → destination).
*/
@Entity({ schema: 'freight', name: 'train_checkpoint_events' })
@Index(['trainScheduleId'])
@Index(['trainScheduleId', 'sequenceNo'])
export class TrainCheckpointEvent extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
@ManyToOne(() => TrainSchedule, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'train_schedule_id' })
trainSchedule?: TrainSchedule;
@Column({ name: 'yard_id', type: 'uuid' })
yardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'yard_id' })
yard?: Yard;
/** Position along the corridor: 0 = origin, N+1 = destination. */
@Column({ name: 'sequence_no', type: 'int' })
sequenceNo!: number;
@Column({ name: 'kind', type: 'varchar', length: 20 })
kind!: TrainCheckpointKind;
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;
@Column({ name: 'recorded_by_user_id', type: 'uuid', nullable: true })
recordedByUserId?: string | null;
}

View File

@@ -0,0 +1,24 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
@Injectable()
export class TrainCheckpointEventsRepository extends BaseRepository<TrainCheckpointEvent> {
constructor(
@InjectRepository(TrainCheckpointEvent)
repository: Repository<TrainCheckpointEvent>,
) {
super(repository);
}
findBySchedule(trainScheduleId: string): Promise<TrainCheckpointEvent[]> {
return this.findAll({
where: { trainScheduleId },
relations: { yard: true },
order: { sequenceNo: 'ASC', occurredAt: 'ASC' },
});
}
}

View File

@@ -21,6 +21,7 @@ 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 { RecordCheckpointDto } from './dto/record-checkpoint.dto';
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import { TrainSchedulingService } from './train-scheduling.service';
@@ -161,6 +162,30 @@ export class TrainSchedulingController {
return this.trainSchedulingService.dispatchSchedule(id);
}
@Get('schedules/:id/checkpoints')
@TrainSchedulingView()
@ApiOperation({ summary: 'Get the tracking corridor + logged checkpoints for a train' })
getScheduleCheckpoints(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getScheduleCheckpoints(id);
}
@Post('schedules/:id/checkpoints')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Log the train passing a station (final station triggers arrival)' })
recordCheckpoint(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RecordCheckpointDto,
) {
return this.trainSchedulingService.recordCheckpoint(id, dto);
}
@Post('schedules/:id/arrive')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Mark a dispatched train arrived (flip readiness, free assets)' })
arriveSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.arriveSchedule(id);
}
@Get('container/schedules')
@TrainSchedulingView()
@ApiOperation({ summary: 'List container train schedules' })

View File

@@ -14,7 +14,9 @@ import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
import { Wagon } from '../wagons/entities/wagon.entity';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { TrainSchedulingController } from './train-scheduling.controller';
import { TrainSchedulingService } from './train-scheduling.service';
@@ -29,6 +31,7 @@ import { TrainSchedulingService } from './train-scheduling.service';
Wagon,
Container,
TrainSchedulingGlobalRules,
TrainCheckpointEvent,
]),
BookingsModule,
LocomotivesModule,
@@ -38,7 +41,7 @@ import { TrainSchedulingService } from './train-scheduling.service';
RuleEngineModule,
],
controllers: [TrainSchedulingController],
providers: [TrainSchedulingService],
providers: [TrainSchedulingService, TrainCheckpointEventsRepository],
exports: [TrainSchedulingService],
})
export class TrainSchedulingModule {}

View File

@@ -125,6 +125,13 @@ describe('TrainSchedulingService', () => {
findAll: jest.fn().mockResolvedValue([]),
};
const trainCheckpointEventsRepository = {
findBySchedule: jest.fn().mockResolvedValue([]),
findAll: jest.fn().mockResolvedValue([]),
create: jest.fn(),
update: jest.fn(),
};
service = new TrainSchedulingService(
dataSource as never,
bookingsRepository as never,
@@ -135,6 +142,7 @@ describe('TrainSchedulingService', () => {
wagonBookingAllocationsRepository as never,
wagonAllocationContainerItemsRepository as never,
wagonAllocationBulkLoadsRepository as never,
trainCheckpointEventsRepository as never,
);
const defaultFleetWagons = [

View File

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

View File

@@ -16,3 +16,16 @@ export function wagonReadinessMatchesSchedule(
if (!required) return true;
return wagonReadiness === required;
}
/**
* Toggle a readiness value (IMPORT_READY ↔ EXPORT_READY). Used when a train
* reaches its destination: the asset has repositioned, so it is now ready for
* the opposite direction. Direction-agnostic so it handles round trips.
*/
export function flipReadiness(
readiness: WagonReadiness | string,
): WagonReadiness {
return readiness === WagonReadiness.ImportReady
? WagonReadiness.ExportReady
: WagonReadiness.ImportReady;
}