remove reopen delay minutes from global rules and update related types

- Removed the  field from  and related components.
- Updated  to reflect the removal of the reopen delay input field.
- Modified  to include new train number fields:  and .
- Added  interface to manage active schedules with trade direction.
- Introduced  interface to track wagon shortages in bookings.
- Updated  logic to ensure consistent UI state representation.
- Created migrations to drop the  column and add  and  columns to the  table.
- Added tests for the new booking window display logic and wagon planning functionality.
This commit is contained in:
Marshal
2026-07-15 09:13:02 +00:00
parent 9be7f356f0
commit 11771e5f92
39 changed files with 1731 additions and 269 deletions

View File

@@ -5,6 +5,7 @@ import {
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
} from 'class-validator';
@@ -14,6 +15,22 @@ export class BuildTrainDto {
@MaxLength(32)
code!: string;
@ApiProperty({ example: '8001', description: 'EXPORT run number (odd, unique across trains)' })
@IsString()
@MaxLength(20)
@Matches(/^\d*[13579]$/, {
message: 'Export train number must be numeric and odd (e.g. 8001)',
})
exportTrainNumber!: string;
@ApiProperty({ example: '8002', description: 'IMPORT run number (even, unique across trains)' })
@IsString()
@MaxLength(20)
@Matches(/^\d*[02468]$/, {
message: 'Import train number must be numeric and even (e.g. 8002)',
})
importTrainNumber!: string;
@ApiProperty({ format: 'uuid', description: 'Yard the train is built in' })
@IsUUID()
currentYardId!: string;

View File

@@ -32,6 +32,14 @@ export class Train extends BaseEntity {
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
/** Fixed IMPORT (even) run number typed at build time; unique via partial index. */
@Column({ name: 'import_train_number', type: 'varchar', length: 20, nullable: true })
importTrainNumber!: string | null;
/** Fixed EXPORT (odd) run number typed at build time; unique via partial index. */
@Column({ name: 'export_train_number', type: 'varchar', length: 20, nullable: true })
exportTrainNumber!: string | null;
// --- new required fields ---
@Column({ name: 'train_number', type: 'varchar', length: 20, unique: true, nullable: true })
trainNumber?: string;

View File

@@ -27,6 +27,15 @@ import {
const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100;
/** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */
export interface ActiveScheduleRef {
id: string;
status: string;
reference: string | null;
direction: string | null;
trainNumber: string | null;
}
/**
* Train Builder — assembles persistent fleet trains (code + 2+ locomotives +
* ordered wagons, all in one yard) that scheduling can later reference as a
@@ -56,6 +65,25 @@ export class TrainBuilderService {
throw new ConflictException(`Train code ${code} is already in use`);
}
// Friendly 409 before the partial unique indexes (the race-proof backstop):
// the typed pair may not collide with any train's pair or legacy number.
const importTrainNumber = dto.importTrainNumber.trim();
const exportTrainNumber = dto.exportTrainNumber.trim();
const numberClash: { code: string }[] = await manager.query(
`SELECT code FROM freight.trains
WHERE deleted_at IS NULL
AND (import_train_number IN ($1, $2)
OR export_train_number IN ($1, $2)
OR train_number IN ($1, $2))
LIMIT 1`,
[importTrainNumber, exportTrainNumber],
);
if (numberClash.length) {
throw new ConflictException(
`Train number ${importTrainNumber}/${exportTrainNumber} is already used by train ${numberClash[0].code}`,
);
}
const yard = await manager.getRepository(Yard).findOne({ where: { id: dto.currentYardId } });
if (!yard) throw new NotFoundException(`Yard ${dto.currentYardId} not found`);
@@ -76,6 +104,8 @@ export class TrainBuilderService {
status: Freight.TrainStatus.Available,
trainName: dto.trainName?.trim() || undefined,
notes: dto.notes?.trim() || undefined,
importTrainNumber,
exportTrainNumber,
}),
);
@@ -117,12 +147,42 @@ export class TrainBuilderService {
take,
});
const activeByTrain = await this.loadActiveScheduleByTrain(trains.map((t) => t.id));
return {
items: trains.map((train) => this.mapSummary(train)),
items: trains.map((train) => this.mapSummary(train, activeByTrain.get(train.id) ?? null)),
meta: buildPaginationMeta(total, page, pageSize),
};
}
/**
* One ACTIVE schedule per train for the page (prefer the DISPATCHED run,
* else the earliest upcoming departure) — feeds the list's direction tint
* and in-use train number.
*/
private async loadActiveScheduleByTrain(
trainIds: string[],
): Promise<Map<string, ActiveScheduleRef>> {
if (!trainIds.length) return new Map();
const rows: (ActiveScheduleRef & { trainId: string })[] = await this.dataSource.query(
`SELECT DISTINCT ON (tset.train_id)
tset.train_id AS "trainId",
ts.id,
ts.status,
ts.reference,
ts.direction,
ts.train_number AS "trainNumber"
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = ANY($1)
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
ORDER BY tset.train_id, (ts.status = 'DISPATCHED') DESC, ts.scheduled_departure_date ASC`,
[trainIds],
);
return new Map(rows.map(({ trainId, ...schedule }) => [trainId, schedule]));
}
/** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */
async getComposition(id: string) {
const train = await this.dataSource.getRepository(Train).findOne({
@@ -139,17 +199,17 @@ export class TrainBuilderService {
});
if (!train) throw new NotFoundException(`Train ${id} not found`);
const schedules: { id: string; status: string; reference: string | null }[] =
await this.dataSource.query(
`SELECT ts.id, ts.status, ts.reference
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
ORDER BY ts.scheduled_departure_date ASC`,
[id],
);
const schedules: ActiveScheduleRef[] = await this.dataSource.query(
`SELECT ts.id, ts.status, ts.reference, ts.direction,
ts.train_number AS "trainNumber"
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
ORDER BY ts.scheduled_departure_date ASC`,
[id],
);
const locomotives = (train.locomotives ?? [])
.filter((link) => link.locomotive)
@@ -212,6 +272,8 @@ export class TrainBuilderService {
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
importTrainNumber: train.importTrainNumber ?? null,
exportTrainNumber: train.exportTrainNumber ?? null,
notes: train.notes ?? null,
createdAt: train.createdAt,
currentYard: train.currentYard
@@ -407,7 +469,7 @@ export class TrainBuilderService {
// ---------------------------------------------------------------- internals
private mapSummary(train: Train) {
private mapSummary(train: Train, activeSchedule: ActiveScheduleRef | null) {
const locomotives = [...(train.locomotives ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((link) => link.locomotive)
@@ -421,6 +483,9 @@ export class TrainBuilderService {
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
importTrainNumber: train.importTrainNumber ?? null,
exportTrainNumber: train.exportTrainNumber ?? null,
activeSchedule,
createdAt: train.createdAt,
currentYard: train.currentYard
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }