mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
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:
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* New built-train lifecycle status DEACTIVATED: staff park a train indefinitely
|
||||
* (only allowed while it has no DRAFT/SCHEDULED/DISPATCHED schedule). Like
|
||||
* UNDER_MAINTENANCE / OUT_OF_SERVICE it is staff-owned — the scheduler never
|
||||
* overwrites it and refuses to schedule a deactivated train.
|
||||
*
|
||||
* Postgres cannot drop an enum value, so down() is a no-op.
|
||||
*/
|
||||
export class AddTrainDeactivatedStatus2380000000000 implements MigrationInterface {
|
||||
name = 'AddTrainDeactivatedStatus2380000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TYPE "freight"."train_status" ADD VALUE IF NOT EXISTS 'DEACTIVATED'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// Enum values cannot be removed in Postgres; leaving the label is harmless.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Admin-managed catalog of IMPORT run numbers (even, Djibouti → Ethiopia)
|
||||
* selectable in the Train Builder. The paired EXPORT number is derived
|
||||
* (import − 1), so only the import side is configured. Seeded with the runs
|
||||
* historically hardcoded in the backoffice's trainRuns constants; admins add
|
||||
* new runs from the Dropdown Settings editor.
|
||||
*/
|
||||
export class SeedImportTrainNumbers2390000000000 implements MigrationInterface {
|
||||
name = 'SeedImportTrainNumbers2390000000000';
|
||||
private readonly code = 'import_train_numbers';
|
||||
private readonly options: string[] = [
|
||||
'8002',
|
||||
'8102',
|
||||
'8202',
|
||||
'8302',
|
||||
'8402',
|
||||
'8502',
|
||||
'8602',
|
||||
'8702',
|
||||
'8802',
|
||||
'8902',
|
||||
'9002',
|
||||
];
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const existing = await queryRunner.query(
|
||||
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
|
||||
[this.code],
|
||||
);
|
||||
if (existing.length > 0) return;
|
||||
|
||||
const inserted = await queryRunner.query(
|
||||
`INSERT INTO freight.dropdown_settings (code, label, description, multiple, meta)
|
||||
VALUES ($1, $2, $3, false, $4::jsonb)
|
||||
RETURNING id;`,
|
||||
[
|
||||
this.code,
|
||||
'Import train numbers',
|
||||
'Even IMPORT run numbers (Djibouti → Ethiopia) selectable when building a train. The paired export number is derived automatically (import − 1).',
|
||||
JSON.stringify({ searchable: true, clearable: true }),
|
||||
],
|
||||
);
|
||||
const settingId = inserted[0].id;
|
||||
|
||||
for (let i = 0; i < this.options.length; i++) {
|
||||
const value = this.options[i];
|
||||
await queryRunner.query(
|
||||
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
|
||||
VALUES ($1, $2, $3, $4);`,
|
||||
[settingId, value, value, i],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [
|
||||
this.code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Yard soft-delete now appends `@<epoch-ms>` to the unique code (SEBETA →
|
||||
* SEBETA@1755612345678) so the name can be reused by a new yard while
|
||||
* UQ_yards_code still spans soft-deleted rows. varchar(20) can't hold long
|
||||
* codes plus the 14-char suffix, so widen to 40.
|
||||
*/
|
||||
export class WidenYardCodeForSoftDeleteSuffix2390000000000 implements MigrationInterface {
|
||||
name = 'WidenYardCodeForSoftDeleteSuffix2390000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."yards" ALTER COLUMN "code" TYPE varchar(40)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// Narrowing would fail on suffixed codes; keep 40.
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -48,6 +48,14 @@ const DEFAULT_DROPDOWN_SETTINGS: DefaultDropdownSetting[] = [
|
||||
"Minimum days between today and the vessel departure date on an export Release Order.",
|
||||
multiple: false,
|
||||
},
|
||||
{
|
||||
code: "import_train_numbers",
|
||||
label: "Import train numbers",
|
||||
description:
|
||||
"Even IMPORT run numbers (Djibouti → Ethiopia) selectable when building a train. The paired export number is derived automatically (import − 1).",
|
||||
multiple: false,
|
||||
meta: { searchable: true, clearable: true },
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
|
||||
Reference in New Issue
Block a user