Add train deactivation feature and import train number management

- Introduced DEACTIVATED status for trains, allowing staff to park trains indefinitely.
- Implemented methods to deactivate and reactivate trains in the TrainBuilderService.
- Added UI components for train deactivation and reactivation in TrainBuilderDetailPage.
- Created a dropdown setting for admin-managed import train numbers, with corresponding migrations.
- Updated yard code length to accommodate soft-delete suffix.
- Enhanced train status handling to include DEACTIVATED state.
This commit is contained in:
Marshal
2026-07-20 07:04:23 +00:00
parent ab355d0e16
commit 771aa2a605
20 changed files with 467 additions and 36 deletions

View File

@@ -1,5 +1,4 @@
import { Inject, Injectable } from "@nestjs/common";
import { In, Not } from "typeorm";
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../rule-engine/entities/container-type.entity";
@@ -34,8 +33,6 @@ import {
BookingReferenceYardDto,
} from "./dto/booking-reference-data.dto";
const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const;
export function buildCargoTypeTree(
rows: CargoType[],
): BookingReferenceCargoTypeGroupDto[] {
@@ -134,10 +131,7 @@ export class BookingReferenceDataService {
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
await Promise.all([
this.yardsRepository.findAll({
where: {
isActive: true,
code: Not(In([...LEGACY_YARD_CODES])),
},
where: { isActive: true },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.containerTypesRepository.findAll({

View File

@@ -7,7 +7,8 @@ import { Column, Entity, Index } from 'typeorm';
@Index(['country'])
@Index(['isActive'])
export class Yard extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
// 40 leaves room for the `@<epoch-ms>` suffix soft-delete appends to free the code.
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
code!: string;
@Column({ name: 'label', type: 'varchar', length: 100 })

View File

@@ -31,7 +31,7 @@ export class YardsService {
/** Create a yard. */
async create(dto: CreateYardDto): Promise<Yard> {
const code = generateCode(dto.label);
const code = generateCode(dto.label).slice(0, 40);
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
@@ -58,9 +58,19 @@ export class YardsService {
return updated;
}
/** Soft-delete a yard. */
/**
* Soft-delete a yard. The unique `code` (and the label) get a `@<epoch-ms>`
* suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the
* same name can be created later without tripping UQ_yards_code, which spans
* soft-deleted rows too.
*/
async remove(id: string): Promise<void> {
await this.findById(id);
const yard = await this.findById(id);
const suffix = `@${Date.now()}`;
await this.repository.update(id, {
code: `${yard.code.slice(0, 40 - suffix.length)}${suffix}`,
label: `${yard.label.slice(0, 100 - suffix.length)}${suffix}`,
});
await this.repository.softDelete(id);
}

View File

@@ -1283,7 +1283,8 @@ export class TrainSchedulingService {
}
if (
builtTrain.status === Freight.TrainStatus.OutOfService ||
builtTrain.status === Freight.TrainStatus.UnderMaintenance
builtTrain.status === Freight.TrainStatus.UnderMaintenance ||
builtTrain.status === Freight.TrainStatus.Deactivated
) {
throw new ConflictException(
`Train ${builtTrain.code} is ${builtTrain.status.toLowerCase().replace(/_/g, ' ')}`,
@@ -5219,7 +5220,11 @@ export class TrainSchedulingService {
const trains = await this.dataSource.getRepository(Train).find({
where: {
status: Not(
In([Freight.TrainStatus.OutOfService, Freight.TrainStatus.UnderMaintenance]),
In([
Freight.TrainStatus.OutOfService,
Freight.TrainStatus.UnderMaintenance,
Freight.TrainStatus.Deactivated,
]),
),
},
relations: {
@@ -5619,8 +5624,8 @@ export class TrainSchedulingService {
* Re-derive a built train's lifecycle status from its schedules after one of
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
* SCHEDULED; otherwise AVAILABLE. `moveToYardId` relocates the train (arrival
* at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE)
* keep their status — staff own that flag, not the scheduler.
* at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE
* / DEACTIVATED) keep their status — staff own that flag, not the scheduler.
*/
private async syncBuiltTrainAfterScheduleChange(
manager: EntityManager,

View File

@@ -44,6 +44,15 @@ export class TrainBuilderController {
return this.trainBuilderService.listBuilt(query);
}
// Must be declared before @Get(':id') so the path isn't captured as an id.
@Get('used-train-numbers')
@ApiOperation({
summary: 'Import/export run numbers already claimed by existing trains',
})
usedTrainNumbers() {
return this.trainBuilderService.usedTrainNumbers();
}
@Get(':id')
@ApiOperation({ summary: 'Full train composition: locomotives, ordered wagons, totals vs. limits' })
composition(@Param('id', ParseUUIDPipe) id: string) {
@@ -115,6 +124,22 @@ export class TrainBuilderController {
return this.trainBuilderService.reorderWagons(id, dto);
}
@Post(':id/deactivate')
@FleetManage()
@ApiOperation({
summary: 'Deactivate the train (park it) — only allowed with no active schedule',
})
deactivate(@Param('id', ParseUUIDPipe) id: string) {
return this.trainBuilderService.deactivate(id);
}
@Post(':id/activate')
@FleetManage()
@ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' })
activate(@Param('id', ParseUUIDPipe) id: string) {
return this.trainBuilderService.activate(id);
}
@Delete(':id')
@FleetManage()
@HttpCode(HttpStatus.NO_CONTENT)

View File

@@ -153,6 +153,38 @@ export class TrainBuilderService {
};
}
/**
* Run numbers already claimed by live (non-deleted) trains, split by
* direction. Legacy single `train_number` values are sorted into a side by
* parity (even = import, odd = export) so the pickers can grey them out too.
*/
async usedTrainNumbers() {
const rows: {
import_train_number: string | null;
export_train_number: string | null;
train_number: string | null;
}[] = await this.dataSource.query(
`SELECT import_train_number, export_train_number, train_number
FROM freight.trains
WHERE deleted_at IS NULL`,
);
const importTrainNumbers = new Set<string>();
const exportTrainNumbers = new Set<string>();
for (const row of rows) {
if (row.import_train_number) importTrainNumbers.add(row.import_train_number);
if (row.export_train_number) exportTrainNumbers.add(row.export_train_number);
const legacy = row.train_number?.trim();
if (legacy && /^\d+$/.test(legacy)) {
(Number(legacy) % 2 === 0 ? importTrainNumbers : exportTrainNumbers).add(legacy);
}
}
return {
importTrainNumbers: [...importTrainNumbers].sort(),
exportTrainNumbers: [...exportTrainNumbers].sort(),
};
}
/**
* One ACTIVE schedule per train for the page (prefer the DISPATCHED run,
* else the earliest upcoming departure) — feeds the list's direction tint
@@ -532,6 +564,50 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/**
* Park the train indefinitely (status DEACTIVATED). Blocked while it still
* has a live (DRAFT/SCHEDULED/DISPATCHED) schedule. The consist stays
* coupled; like UNDER_MAINTENANCE / OUT_OF_SERVICE the flag is staff-owned —
* the scheduler never overwrites it and refuses the train for new schedules.
*/
async deactivate(id: string) {
await this.dataSource.transaction(async (manager) => {
const train = await manager.getRepository(Train).findOne({ where: { id } });
if (!train) throw new NotFoundException(`Train ${id} not found`);
if (train.status === Freight.TrainStatus.Deactivated) return;
const active: { count: string }[] = await manager.query(
`SELECT COUNT(*)::text AS count
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')`,
[id],
);
if (Number(active[0]?.count ?? 0) > 0) {
throw new ConflictException(
'Train has active schedules; cancel them before deactivating the train',
);
}
await manager
.getRepository(Train)
.update(id, { status: Freight.TrainStatus.Deactivated });
});
return this.getComposition(id);
}
/** Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled again. */
async activate(id: string) {
const train = await this.dataSource.getRepository(Train).findOne({ where: { id } });
if (!train) throw new NotFoundException(`Train ${id} not found`);
if (train.status === Freight.TrainStatus.Deactivated) {
await this.dataSource
.getRepository(Train)
.update(id, { status: Freight.TrainStatus.Available });
}
return this.getComposition(id);
}
/** Disband the train: release wagons and locomotives, then delete it. */
async disband(id: string): Promise<void> {
await this.dataSource.transaction(async (manager) => {