add reference field to train schedules and implement unique sequence generation

This commit is contained in:
Marshal
2026-07-07 23:06:44 +00:00
parent 88b1e548a2
commit 9dd9ace313
9 changed files with 299 additions and 21 deletions

View File

@@ -17,7 +17,7 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm';
import { DataSource, EntityManager, In, IsNull, Not, QueryFailedError } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
@@ -242,6 +242,7 @@ const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
interface BookingWindowRow {
schedule_id: string;
reference: string | null;
contract_id: string | null;
contract_kind: string | null;
direction: string | null;
@@ -821,20 +822,24 @@ export class TrainSchedulingService {
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
routeId: route.id,
originStationId: route.originYardId,
destinationStationId: route.destinationYardId,
scheduledDepartureDate: departure,
status: TrainScheduleStatusEnum.Draft,
direction,
maxWagons: (
await this.resolveTrainLimitConfig(dto, limitLoco)
).maxWagonsPerTrain,
...windowFields,
});
const saved = await manager.getRepository(TrainSchedule).save(schedule);
const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco))
.maxWagonsPerTrain;
// Retry past a concurrent insert that grabbed the same S-<year> sequence
// (the unique index rejects the loser; it re-reads the max and tries again).
const saved = await this.insertScheduleWithReference(manager, (reference) =>
manager.getRepository(TrainSchedule).create({
reference,
trainSetId: trainSet.id,
routeId: route.id,
originStationId: route.originYardId,
destinationStationId: route.destinationYardId,
scheduledDepartureDate: departure,
status: TrainScheduleStatusEnum.Draft,
direction,
maxWagons,
...windowFields,
}),
);
// Locomotives stay in their current status until dispatch — advance scheduling
// must not block the locomotive from serving earlier trains.
return saved.id;
@@ -2560,7 +2565,8 @@ export class TrainSchedulingService {
destinationStation: true,
scheduleBookings: { booking: true },
},
order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' },
// Newest-created first (the client can re-sort; this is the default order).
order: { createdAt: 'DESC', scheduledDepartureDate: 'DESC' },
});
return schedules.map((s) => this.mapScheduleListItem(s));
}
@@ -3785,9 +3791,41 @@ export class TrainSchedulingService {
return null;
}
/**
* Insert a schedule with a freshly generated S-<year>-NNNNN reference, retrying
* past a concurrent insert that grabbed the same sequence (the unique index
* rejects the loser). Mirrors insertWithGeneratedReference for bookings, but
* runs inside the caller's transaction manager so the row joins the same commit.
*/
private async insertScheduleWithReference(
manager: EntityManager,
build: (reference: string) => TrainSchedule,
): Promise<TrainSchedule> {
const year = new Date().getFullYear();
const repo = manager.getRepository(TrainSchedule);
for (let attempt = 0; attempt < 5; attempt += 1) {
const seq = await this.trainSchedulesRepository.maxReferenceSequence(year);
const reference = `S-${year}-${String(seq + 1).padStart(5, '0')}`;
try {
return await repo.save(build(reference));
} catch (err) {
// 23505 = unique_violation on ux_train_schedules_reference; re-read + retry.
const code = (err as { driverError?: { code?: string } })?.driverError?.code;
if (err instanceof QueryFailedError && code === '23505' && attempt < 4) {
continue;
}
throw err;
}
}
// Unreachable — the loop either returns or throws — but satisfies the compiler.
throw new ConflictException('Could not allocate a unique schedule reference');
}
private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) {
return {
id: schedule.id,
reference: schedule.reference ?? null,
createdAt: schedule.createdAt ?? null,
scheduleDate: schedule.scheduledDepartureDate,
trainNumber: schedule.trainNumber ?? null,
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
@@ -3882,6 +3920,7 @@ export class TrainSchedulingService {
const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ON (ts.id)
ts.id AS schedule_id,
ts.reference AS reference,
cr.contract_id AS contract_id,
c.contract_kind AS contract_kind,
ts.direction,
@@ -3934,6 +3973,7 @@ export class TrainSchedulingService {
async getBookingWindowsForContract(contractId: string) {
const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ts.id AS schedule_id,
ts.reference AS reference,
cr.contract_id AS contract_id,
c.contract_kind AS contract_kind,
ts.direction,
@@ -3981,6 +4021,7 @@ export class TrainSchedulingService {
}
> = await this.dataSource.query(
`SELECT ts.id AS schedule_id,
ts.reference AS reference,
ts.train_number,
ts.direction,
ts.window_phase,
@@ -4016,6 +4057,7 @@ export class TrainSchedulingService {
private mapBookingWindowRow(r: BookingWindowRow) {
return {
scheduleId: r.schedule_id,
reference: r.reference ?? null,
contractId: r.contract_id,
contractKind: r.contract_kind,
direction: r.direction,
@@ -4377,6 +4419,7 @@ export class TrainSchedulingService {
return {
id: schedule.id,
reference: schedule.reference ?? null,
status: schedule.status,
freightType: this.resolveScheduleFreightType(schedule),
trainNumber: schedule.trainNumber ?? null,