This commit is contained in:
Marshal
2026-07-14 11:06:49 +00:00
parent 957a185a4d
commit 6d0cf50b4d
64 changed files with 4896 additions and 404 deletions

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsUUID } from 'class-validator';
export class AssignTrainWagonsDto {
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Wagons to append to the consist, in order. Each must be AVAILABLE in the train\'s yard.',
})
@IsArray()
@ArrayMinSize(1)
@IsUUID('all', { each: true })
wagonIds!: string[];
}

View File

@@ -0,0 +1,51 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
export class BuildTrainDto {
@ApiProperty({ example: '81001', description: 'Operator-assigned train code (unique)' })
@IsString()
@MaxLength(32)
code!: string;
@ApiProperty({ format: 'uuid', description: 'Yard the train is built in' })
@IsUUID()
currentYardId!: string;
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Locomotives pulling the train (minimum 2 — front and back), in consist order',
})
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@IsUUID('all', { each: true })
locomotiveIds!: string[];
@ApiPropertyOptional({
type: [String],
format: 'uuid',
description: 'Wagons to attach at build time, in consist order (must sit in the same yard)',
})
@IsOptional()
@IsArray()
@IsUUID('all', { each: true })
wagonIds?: string[];
@ApiPropertyOptional({ maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
trainName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,22 @@
import { Freight } from '@edr/types';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsIn, IsOptional, IsUUID } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
export class ListBuiltTrainsQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: Freight.TrainStatus })
@IsOptional()
@IsEnum(Freight.TrainStatus)
status?: Freight.TrainStatus;
@ApiPropertyOptional({ format: 'uuid', description: 'Only trains sitting in this yard' })
@IsOptional()
@IsUUID()
currentYardId?: string;
@ApiPropertyOptional({ enum: ['code', 'trainName', 'status', 'createdAt'] })
@IsOptional()
@IsIn(['code', 'trainName', 'status', 'createdAt'])
sortBy?: 'code' | 'trainName' | 'status' | 'createdAt';
}

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsUUID } from 'class-validator';
export class ReorderTrainWagonsDto {
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Every wagon of the train, in the new consist order',
})
@IsArray()
@ArrayMinSize(1)
@IsUUID('all', { each: true })
wagonIds!: string[];
}

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsUUID } from 'class-validator';
export class UpdateTrainLocomotivesDto {
@ApiProperty({
type: [String],
format: 'uuid',
description: 'Full replacement locomotive set (minimum 2), in consist order',
})
@IsArray()
@ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' })
@IsUUID('all', { each: true })
locomotiveIds!: string[];
}

View File

@@ -0,0 +1,34 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { Train } from './train.entity';
/**
* Link row joining a built train to one of its locomotives. A train must be
* pulled by at least two locomotives (front + back); `sequenceNo` is the order
* in the consist — 0 is the lead locomotive.
*
* Mirrors `train_set_locomotives`, but for the persistent fleet `Train` built
* in the Train Builder rather than the per-departure operational train set.
*/
@Entity({ schema: 'freight', name: 'train_locomotives' })
@Index(['trainId', 'locomotiveId'], { unique: true })
export class TrainLocomotive extends BaseEntity {
@Column({ name: 'train_id', type: 'uuid' })
trainId!: string;
@ManyToOne(() => Train, (train) => train.locomotives, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'train_id' })
train?: Train;
@Column({ name: 'locomotive_id', type: 'uuid' })
locomotiveId!: string;
@ManyToOne(() => Locomotive)
@JoinColumn({ name: 'locomotive_id' })
locomotive?: Locomotive;
@Column({ name: 'sequence_no', type: 'int', default: 0 })
sequenceNo!: number;
}

View File

@@ -1,12 +1,16 @@
// apps/edr-freight-api/src/modules/trains/entities/train.entity.ts
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity, OneToMany } from 'typeorm';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { TrainLocomotive } from './train-locomotive.entity';
/**
* Fleet master data — named wagon consist in inventory (POST /trains).
* Operational departures use train_schedules + locomotives; scheduling never creates trains rows.
* Fleet master data — a train built in the Train Builder: a coded consist
* (e.g. 81001) of 2+ locomotives and ordered wagons, assembled in one yard.
* Operational departures reference it through `train_sets.train_id`; the
* schedule's own composition still lives on the train set.
*/
@Entity({ schema: 'freight', name: 'trains' })
export class Train extends BaseEntity {
@@ -56,7 +60,19 @@ export class Train extends BaseEntity {
@Column({ name: 'remarks', type: 'text', nullable: true })
remarks?: string;
/** Yard where the train currently sits (set at build, moved on schedule arrival). */
@Column({ name: 'current_yard_id', type: 'uuid', nullable: true })
currentYardId!: string | null;
@ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'current_yard_id' })
currentYard?: Yard | null;
// --- relationships ---
@OneToMany(() => Wagon, (wagon) => wagon.train)
wagons!: Wagon[]; // fixed typo: was 'wagens'
@OneToMany(() => Wagon, (wagon) => wagon.train)
wagons!: Wagon[];
/** Locomotives pulling this train (minimum 2), ordered by sequenceNo. */
@OneToMany(() => TrainLocomotive, (link) => link.train)
locomotives?: TrainLocomotive[];
}

View File

@@ -0,0 +1,91 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
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 { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { TrainBuilderService } from './train-builder.service';
@ApiTags('train-builder')
@ApiBearerAuth()
@Controller('train-builder')
@FleetView()
export class TrainBuilderController {
constructor(private readonly trainBuilderService: TrainBuilderService) {}
@Post()
@FleetManage()
@ApiOperation({ summary: 'Build a train: code + yard + 2+ locomotives (+ optional wagons)' })
build(@Body() dto: BuildTrainDto) {
return this.trainBuilderService.buildTrain(dto);
}
@Get()
@ApiOperation({ summary: 'Paginated built trains with composition summary' })
list(@Query() query: ListBuiltTrainsQueryDto) {
return this.trainBuilderService.listBuilt(query);
}
@Get(':id')
@ApiOperation({ summary: 'Full train composition: locomotives, ordered wagons, totals vs. limits' })
composition(@Param('id', ParseUUIDPipe) id: string) {
return this.trainBuilderService.getComposition(id);
}
@Put(':id/locomotives')
@FleetManage()
@ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' })
setLocomotives(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTrainLocomotivesDto,
) {
return this.trainBuilderService.setLocomotives(id, dto);
}
@Post(':id/wagons')
@FleetManage()
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) {
return this.trainBuilderService.assignWagons(id, dto);
}
@Delete(':id/wagons/:wagonId')
@FleetManage()
@ApiOperation({ summary: 'Detach one wagon from the consist' })
removeWagon(
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
) {
return this.trainBuilderService.removeWagon(id, wagonId);
}
@Post(':id/reorder-wagons')
@FleetManage()
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
reorderWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReorderTrainWagonsDto) {
return this.trainBuilderService.reorderWagons(id, dto);
}
@Delete(':id')
@FleetManage()
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' })
disband(@Param('id', ParseUUIDPipe) id: string) {
return this.trainBuilderService.disband(id);
}
}

View File

@@ -0,0 +1,507 @@
import { Freight, WagonStatus } from '@edr/types';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, ILike, In } from 'typeorm';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util';
import { Wagon } from '../wagons/entities/wagon.entity';
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 { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { TrainLocomotive } from './entities/train-locomotive.entity';
import { Train } from './entities/train.entity';
import {
buildPaginationMeta,
normalizePagination,
} from '../../common/utils/pagination.util';
const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100;
/**
* Train Builder — assembles persistent fleet trains (code + 2+ locomotives +
* ordered wagons, all in one yard) that scheduling can later reference as a
* unit instead of hand-picking locomotives per departure.
*
* Resource rules:
* - Locomotive double-use is prevented through the `train_locomotives` link
* table (a locomotive rides at most one built train); its `status` column
* keeps its operational meaning (ASSIGNED = out on a dispatched train).
* - Wagons attached to a train are flipped to ASSIGNED (same semantic the
* legacy assign-train flow uses), so no other train or schedule grabs them.
*/
@Injectable()
export class TrainBuilderService {
constructor(private readonly dataSource: DataSource) {}
async buildTrain(dto: BuildTrainDto) {
const locomotiveIds = [...new Set(dto.locomotiveIds)];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
}
const trainId = await this.dataSource.transaction(async (manager) => {
const code = dto.code.trim();
const existing = await manager.getRepository(Train).findOne({ where: { code } });
if (existing) {
throw new ConflictException(`Train code ${code} is already in use`);
}
const yard = await manager.getRepository(Yard).findOne({ where: { id: dto.currentYardId } });
if (!yard) throw new NotFoundException(`Yard ${dto.currentYardId} not found`);
const locomotives = await this.validateAndLockLocomotives(
manager,
locomotiveIds,
yard,
null,
);
// Effective haul capacity is capped by the weakest locomotive in the set.
const limits = minLocomotiveLimits(locomotives);
const train = await manager.getRepository(Train).save(
manager.getRepository(Train).create({
code,
currentYardId: yard.id,
capacityTons: round(limits?.maxPullWeightTons ?? 0),
status: Freight.TrainStatus.Available,
trainName: dto.trainName?.trim() || undefined,
notes: dto.notes?.trim() || undefined,
}),
);
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
if (dto.wagonIds?.length) {
await this.attachWagons(manager, train, dto.wagonIds, 0);
}
return train.id;
});
return this.getComposition(trainId);
}
/** Paginated builder list with a composition summary per train. */
async listBuilt(query: ListBuiltTrainsQueryDto) {
const { page, pageSize, skip, take } = normalizePagination(query);
const search = query.search?.trim();
const filters = {
...(query.status ? { status: query.status } : {}),
...(query.currentYardId ? { currentYardId: query.currentYardId } : {}),
};
const where = search
? [
{ ...filters, code: ILike(`%${search}%`) },
{ ...filters, trainName: ILike(`%${search}%`) },
]
: filters;
const [trains, total] = await this.dataSource.getRepository(Train).findAndCount({
where,
relations: {
currentYard: true,
locomotives: { locomotive: true },
wagons: { wagonType: true },
},
order: { [query.sortBy ?? 'createdAt']: query.sortOrder ?? 'DESC' },
skip,
take,
});
return {
items: trains.map((train) => this.mapSummary(train)),
meta: buildPaginationMeta(total, page, pageSize),
};
}
/** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */
async getComposition(id: string) {
const train = await this.dataSource.getRepository(Train).findOne({
where: { id },
relations: {
currentYard: true,
locomotives: { locomotive: { currentYard: true } },
wagons: { wagonType: true, currentYard: true },
},
order: {
locomotives: { sequenceNo: 'ASC' },
wagons: { sequenceNumber: 'ASC' },
},
});
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 locomotives = (train.locomotives ?? [])
.filter((link) => link.locomotive)
.map((link, index) => ({
id: link.locomotive!.id,
code: link.locomotive!.code,
name: link.locomotive!.name ?? null,
locomotiveType: link.locomotive!.locomotiveType,
status: link.locomotive!.status,
sequenceNo: link.sequenceNo,
role: index === 0 ? 'LEAD' : 'ASSIST',
currentYardId: link.locomotive!.currentYardId ?? null,
currentYard: link.locomotive!.currentYard
? {
id: link.locomotive!.currentYard.id,
code: link.locomotive!.currentYard.code,
label: link.locomotive!.currentYard.label,
}
: null,
maxPullWeightTons: round(link.locomotive!.maxPullWeightTons),
maxTrainLengthMeters: round(link.locomotive!.maxTrainLengthMeters),
}));
const wagons = (train.wagons ?? []).map((wagon) => ({
id: wagon.id,
wagonNumber: wagon.wagonNumber,
sequenceNumber: wagon.sequenceNumber,
status: wagon.status,
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
name: wagon.wagonType.name,
capacityTons: round(wagon.wagonType.capacityTons),
tareWeightTons: round(wagon.wagonType.tareWeightTons),
lengthMeters: round(wagon.wagonType.lengthMeters),
}
: null,
}));
const limits = minLocomotiveLimits(
(train.locomotives ?? [])
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco)),
);
const totalTareTons = round(
wagons.reduce((sum, w) => sum + (w.wagonType?.tareWeightTons ?? 0), 0),
);
const totalCapacityTons = round(
wagons.reduce((sum, w) => sum + (w.wagonType?.capacityTons ?? 0), 0),
);
const totalLengthMeters = round(
wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0),
);
const maxGrossTons = round(totalTareTons + totalCapacityTons);
const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0);
const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0);
return {
id: train.id,
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
notes: train.notes ?? null,
createdAt: train.createdAt,
currentYard: train.currentYard
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }
: null,
locomotives,
wagons,
totals: {
wagonCount: wagons.length,
totalTareTons,
totalCapacityTons,
maxGrossTons,
totalLengthMeters,
maxPullWeightTons,
maxTrainLengthMeters,
// Fully loaded gross vs. what the weakest locomotive can haul.
weightUtilizationPct: maxPullWeightTons
? round((maxGrossTons / maxPullWeightTons) * 100)
: null,
lengthUtilizationPct: maxTrainLengthMeters
? round((totalLengthMeters / maxTrainLengthMeters) * 100)
: null,
},
activeSchedules: schedules,
// Composition is frozen while the train is out on a dispatched run.
editable: !schedules.some((s) => s.status === 'DISPATCHED'),
};
}
/** Replace the locomotive set (still minimum 2, same-yard rule applies). */
async setLocomotives(id: string, dto: UpdateTrainLocomotivesDto) {
const locomotiveIds = [...new Set(dto.locomotiveIds)];
if (locomotiveIds.length < 2) {
throw new BadRequestException('A train must be pulled by at least two locomotives');
}
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const yard = await manager
.getRepository(Yard)
.findOne({ where: { id: train.currentYardId ?? '' } });
if (!yard) {
throw new BadRequestException('Train has no yard; set the yard before changing locomotives');
}
const locomotives = await this.validateAndLockLocomotives(
manager,
locomotiveIds,
yard,
train.id,
);
await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds);
const limits = minLocomotiveLimits(locomotives);
await manager
.getRepository(Train)
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) });
});
return this.getComposition(id);
}
/** Append AVAILABLE wagons from the train's own yard to the consist. */
async assignWagons(id: string, dto: AssignTrainWagonsDto) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const currentCount = await manager
.getRepository(Wagon)
.count({ where: { trainId: train.id } });
await this.attachWagons(manager, train, dto.wagonIds, currentCount);
});
return this.getComposition(id);
}
/** Detach one wagon and close the sequence gap it leaves. */
async removeWagon(id: string, wagonId: string) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
if (!wagon || wagon.trainId !== train.id) {
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
}
if (wagon.currentTrainScheduleId) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
);
}
await manager.getRepository(Wagon).update(wagon.id, {
trainId: null,
sequenceNumber: null,
status: WagonStatus.Available,
});
await this.resequenceWagons(manager, train.id);
});
return this.getComposition(id);
}
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagons = await manager
.getRepository(Wagon)
.find({ where: { trainId: train.id } });
const current = new Set(wagons.map((w) => w.id));
const incoming = new Set(dto.wagonIds);
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
}
for (let i = 0; i < dto.wagonIds.length; i++) {
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
}
});
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) => {
const train = await manager.getRepository(Train).findOne({ where: { id } });
if (!train) throw new NotFoundException(`Train ${id} not found`);
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 disbanding the train',
);
}
await manager
.getRepository(Wagon)
.update(
{ trainId: train.id },
{ trainId: null, sequenceNumber: null, status: WagonStatus.Available },
);
await manager.getRepository(TrainLocomotive).delete({ trainId: train.id });
await manager.getRepository(Train).remove(train);
});
}
// ---------------------------------------------------------------- internals
private mapSummary(train: Train) {
const locomotives = [...(train.locomotives ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco));
const wagons = train.wagons ?? [];
const maxGrossTons = round(
wagons.reduce(
(sum, w) =>
sum + (Number(w.wagonType?.tareWeightTons) || 0) + (Number(w.wagonType?.capacityTons) || 0),
0,
),
);
return {
id: train.id,
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
createdAt: train.createdAt,
currentYard: train.currentYard
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }
: null,
locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })),
wagonCount: wagons.length,
maxGrossTons,
totalLengthMeters: round(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
),
maxPullWeightTons: round(train.capacityTons),
};
}
/** Load + freeze the train row for edit; block edits while it is out on a run. */
private async getEditableTrain(manager: EntityManager, id: string): Promise<Train> {
const train = await manager.getRepository(Train).findOne({
where: { id },
lock: { mode: 'pessimistic_write' },
});
if (!train) throw new NotFoundException(`Train ${id} not found`);
if (train.status === Freight.TrainStatus.InService) {
throw new ConflictException(
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
);
}
return train;
}
/**
* Lock and validate the locomotives for a build/replace: each must exist, be
* serviceable, sit in the train's yard, and not ride another built train.
*/
private async validateAndLockLocomotives(
manager: EntityManager,
locomotiveIds: string[],
yard: Yard,
ownTrainId: string | null,
): Promise<Locomotive[]> {
const locomotives: Locomotive[] = [];
for (const locomotiveId of locomotiveIds) {
const locked = await manager.getRepository(Locomotive).findOne({
where: { id: locomotiveId },
lock: { mode: 'pessimistic_write' },
});
if (!locked) throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
if (locked.status === 'OUT_OF_SERVICE' || locked.status === 'MAINTENANCE') {
throw new ConflictException(`Locomotive ${locked.code} is ${locked.status.toLowerCase().replace('_', ' ')}`);
}
if (locked.currentYardId !== yard.id) {
throw new BadRequestException(
`Locomotive ${locked.code} is not in yard ${yard.label ?? yard.code}; a train can only be built from locomotives in its own yard`,
);
}
locomotives.push(locked);
}
const taken = await manager.getRepository(TrainLocomotive).find({
where: { locomotiveId: In(locomotiveIds) },
relations: { train: true },
});
const conflict = taken.find((link) => link.trainId !== ownTrainId);
if (conflict) {
const loco = locomotives.find((l) => l.id === conflict.locomotiveId);
throw new ConflictException(
`Locomotive ${loco?.code ?? conflict.locomotiveId} is already coupled to train ${conflict.train?.code ?? conflict.trainId}`,
);
}
return locomotives;
}
private async replaceLocomotiveLinks(
manager: EntityManager,
trainId: string,
locomotiveIds: string[],
): Promise<void> {
await manager.getRepository(TrainLocomotive).delete({ trainId });
await manager.getRepository(TrainLocomotive).save(
locomotiveIds.map((locomotiveId, index) =>
manager.getRepository(TrainLocomotive).create({ trainId, locomotiveId, sequenceNo: index }),
),
);
}
private async attachWagons(
manager: EntityManager,
train: Train,
wagonIds: string[],
startCount: number,
): Promise<void> {
const uniqueIds = [...new Set(wagonIds)];
let sequence = startCount;
for (const wagonId of uniqueIds) {
const wagon = await manager.getRepository(Wagon).findOne({
where: { id: wagonId },
lock: { mode: 'pessimistic_write' },
});
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
if (wagon.trainId === train.id) continue;
if (wagon.trainId) {
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`);
}
if (wagon.status !== WagonStatus.Available) {
throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`);
}
if (wagon.currentYardId !== train.currentYardId) {
throw new BadRequestException(
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
);
}
sequence += 1;
await manager.getRepository(Wagon).update(wagon.id, {
trainId: train.id,
sequenceNumber: sequence,
status: WagonStatus.Assigned,
});
}
}
/** Compact wagon sequence numbers back to 1..n after a removal. */
private async resequenceWagons(manager: EntityManager, trainId: string): Promise<void> {
const wagons = await manager.getRepository(Wagon).find({
where: { trainId },
order: { sequenceNumber: 'ASC' },
});
for (let i = 0; i < wagons.length; i++) {
if (wagons[i].sequenceNumber !== i + 1) {
await manager.getRepository(Wagon).update(wagons[i].id, { sequenceNumber: i + 1 });
}
}
}
}

View File

@@ -1,14 +1,17 @@
// apps/edr-freight-api/src/modules/trains/trains.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainLocomotive } from './entities/train-locomotive.entity';
import { Train } from './entities/train.entity';
import { TrainBuilderController } from './train-builder.controller';
import { TrainBuilderService } from './train-builder.service';
import { TrainsController } from './trains.controller';
import { TrainsService } from './trains.service';
@Module({
imports: [TypeOrmModule.forFeature([Train])],
controllers: [TrainsController],
providers: [TrainsService],
exports: [TrainsService], // if other modules need it
imports: [TypeOrmModule.forFeature([Train, TrainLocomotive])],
controllers: [TrainsController, TrainBuilderController],
providers: [TrainsService, TrainBuilderService],
exports: [TrainsService, TrainBuilderService],
})
export class TrainsModule {}
export class TrainsModule {}