implement update train details feature: add DTO, service method, and UI modal for editing train name and run numbers

This commit is contained in:
Marshal
2026-07-15 20:14:39 +00:00
parent d7d9db9c3a
commit fc44eee25e
44 changed files with 970 additions and 14 deletions

View File

@@ -0,0 +1,29 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
/**
* Edit a built train's display identity: its name and its fixed import/export
* run numbers. Composition (yard, locomotives, wagons) has its own endpoints.
* Omitted fields keep their current value; an empty trainName clears the name.
*/
export class UpdateTrainDetailsDto {
@ApiPropertyOptional({ description: 'Display name; empty string clears it' })
@IsOptional()
@IsString()
@MaxLength(100)
trainName?: string;
@ApiPropertyOptional({ description: 'Fixed IMPORT (even) run number' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(20)
importTrainNumber?: string;
@ApiPropertyOptional({ description: 'Fixed EXPORT (odd) run number' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(20)
exportTrainNumber?: string;
}

View File

@@ -45,7 +45,7 @@ export class Train extends BaseEntity {
trainNumber?: string;
@Column({ name: 'train_name', type: 'varchar', length: 100, nullable: true })
trainName?: string;
trainName?: string | null;
@Column({ name: 'route_id', type: 'uuid', nullable: true })
routeId?: string;

View File

@@ -19,6 +19,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
import { TrainBuilderService } from './train-builder.service';
@@ -59,6 +60,18 @@ export class TrainBuilderController {
return this.trainBuilderService.setLocomotives(id, dto);
}
@Patch(':id/details')
@FleetManage()
@ApiOperation({
summary: "Edit the train's name and fixed import/export run numbers",
})
updateDetails(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTrainDetailsDto,
) {
return this.trainBuilderService.updateDetails(id, dto);
}
@Patch(':id/yard')
@FleetManage()
@ApiOperation({

View File

@@ -17,6 +17,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { TrainLocomotive } from './entities/train-locomotive.entity';
import { Train } from './entities/train.entity';
@@ -331,6 +332,53 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/**
* Edit a built train's display identity: name and fixed import/export run
* numbers. Mirrors the build-time number rules — the pair may not collide
* with any other train's pair or legacy number (friendly 409 ahead of the
* partial unique indexes). Blocked while the train is out on a dispatched
* run, like every other composition edit.
*/
async updateDetails(id: string, dto: UpdateTrainDetailsDto) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const patch: Partial<Train> = {};
if (dto.trainName !== undefined) {
patch.trainName = dto.trainName.trim() || null;
}
const importTrainNumber = dto.importTrainNumber?.trim();
const exportTrainNumber = dto.exportTrainNumber?.trim();
if (importTrainNumber) patch.importTrainNumber = importTrainNumber;
if (exportTrainNumber) patch.exportTrainNumber = exportTrainNumber;
if (importTrainNumber || exportTrainNumber) {
const nextImport = importTrainNumber ?? train.importTrainNumber ?? '';
const nextExport = exportTrainNumber ?? train.exportTrainNumber ?? '';
const numberClash: { code: string }[] = await manager.query(
`SELECT code FROM freight.trains
WHERE deleted_at IS NULL
AND id != $3
AND (import_train_number IN ($1, $2)
OR export_train_number IN ($1, $2)
OR train_number IN ($1, $2))
LIMIT 1`,
[nextImport, nextExport, train.id],
);
if (numberClash.length) {
throw new ConflictException(
`Train number ${nextImport}/${nextExport} is already used by train ${numberClash[0].code}`,
);
}
}
if (Object.keys(patch).length) {
await manager.getRepository(Train).update(train.id, patch);
}
});
return this.getComposition(id);
}
/**
* Relocate the train to another yard. The consist moves as one unit: every
* coupled locomotive and wagon follows to the new yard (so their current