feat(train): enhance train history and scheduling features

- Added a reason field to train history entries for detach/maintenance actions.
- Updated TrainHistoryPanel to display the reason for wagon detachments.
- Introduced per-wagon load/unload functionality in ScheduleWorkspacePanel with a modal for managing individual wagons.
- Implemented API endpoints for loading and unloading specific wagons, including the ability to cancel remaining wagons with a reason.
- Refactored detach request handling in TrainBuilderDetailPage to streamline the process and remove the approval flow, requiring a reason for detachments.
- Updated types and services to support new wagon loading/unloading features and booking wagon retrieval.
This commit is contained in:
Marshal
2026-08-28 07:33:28 +00:00
parent 8b8870e85e
commit ba56974e32
26 changed files with 1435 additions and 583 deletions

View File

@@ -1,15 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class SendWagonToMaintenanceDto {
@ApiPropertyOptional({
@ApiProperty({
description:
"Why the wagon is going to maintenance. Stored on the wagon's status-history " +
'log alongside the train it was detached from, matching the fleet desk flow.',
"Why the wagon is going to maintenance — required. Stored on the wagon's " +
'status-history log alongside the train it was detached from, and on the ' +
"train's wagon-adjustment history.",
maxLength: 500,
})
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(500)
note?: string;
note!: string;
}

View File

@@ -1,18 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { WagonDetachRequestAction } from '../entities/wagon-detach-request.entity';
export class CreateWagonDetachRequestDto {
/**
* Detach a wagon from the consist. The reason is always required — it is
* recorded both as an auto-approved wagon_detach_requests audit row and on the
* train's wagon-adjustment history (the History tab).
*/
export class DetachWagonDto {
@ApiProperty({
enum: WagonDetachRequestAction,
description: 'What approval is being asked for: a plain detach, or detach + MAINTENANCE.',
})
@IsEnum(WagonDetachRequestAction)
action!: WagonDetachRequestAction;
@ApiProperty({
description: 'Why the wagon must leave the scheduled consist. Shown to the approver.',
description: 'Why the wagon leaves the consist — required.',
maxLength: 500,
})
@IsString()
@@ -20,14 +16,3 @@ export class CreateWagonDetachRequestDto {
@MaxLength(500)
reason!: string;
}
export class DecideWagonDetachRequestDto {
@ApiPropertyOptional({
description: 'Decision note — required when rejecting, optional when approving.',
maxLength: 500,
})
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}

View File

@@ -25,10 +25,7 @@ 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 { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto';
import {
CreateWagonDetachRequestDto,
DecideWagonDetachRequestDto,
} from './dto/wagon-detach-request.dto';
import { DetachWagonDto } from './dto/wagon-detach-request.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';
@@ -51,7 +48,6 @@ import { TrainBuilderService } from './train-builder.service';
FREIGHT_PERMS.trains.changeWagonYard,
FREIGHT_PERMS.trains.toggleActive,
FREIGHT_PERMS.trains.disband,
FREIGHT_PERMS.trains.approveWagonDetach,
])
export class TrainBuilderController {
constructor(private readonly trainBuilderService: TrainBuilderService) {}
@@ -185,29 +181,38 @@ export class TrainBuilderController {
@Delete(':id/wagons/:wagonId')
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Detach one wagon from the consist' })
@ApiOperation({ summary: 'Detach one wagon from the consist — a reason is required' })
removeWagon(
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto: DetachWagonDto,
) {
return this.trainBuilderService.removeWagon(id, wagonId, resolveAuthUserId(user));
return this.trainBuilderService.removeWagon(
id,
wagonId,
resolveAuthUserId(user),
dto.reason,
);
}
@Post(':id/wagons/:wagonId/maintenance')
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
@ApiOperation({
summary:
'Detach one wagon and move it to MAINTENANCE status — a reason (note) is required',
})
sendWagonToMaintenance(
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto?: SendWagonToMaintenanceDto,
@Body() dto: SendWagonToMaintenanceDto,
) {
return this.trainBuilderService.sendWagonToMaintenance(
id,
wagonId,
resolveAuthUserId(user),
dto?.note,
dto.note,
);
}
@@ -220,65 +225,6 @@ export class TrainBuilderController {
return this.trainBuilderService.listDetachRequests(id);
}
@Post(':id/wagons/:wagonId/detach-requests')
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({
summary:
'Request approval to detach a wagon (or send it to maintenance) while the train is on a SCHEDULED run',
})
createDetachRequest(
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
@Body() dto: CreateWagonDetachRequestDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainBuilderService.createDetachRequest(
id,
wagonId,
dto,
resolveAuthUserId(user),
);
}
@Post(':id/detach-requests/:requestId/approve')
@FleetManage(FREIGHT_PERMS.trains.approveWagonDetach)
@ApiOperation({
summary:
'Approve a detach/maintenance request — the detach executes immediately; the approver must not be the requester',
})
approveDetachRequest(
@Param('id', ParseUUIDPipe) id: string,
@Param('requestId', ParseUUIDPipe) requestId: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto?: DecideWagonDetachRequestDto,
) {
return this.trainBuilderService.decideDetachRequest(
id,
requestId,
'APPROVE',
resolveAuthUserId(user),
dto?.note,
);
}
@Post(':id/detach-requests/:requestId/reject')
@FleetManage(FREIGHT_PERMS.trains.approveWagonDetach)
@ApiOperation({ summary: 'Reject a detach/maintenance request — a note explaining why is required' })
rejectDetachRequest(
@Param('id', ParseUUIDPipe) id: string,
@Param('requestId', ParseUUIDPipe) requestId: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto: DecideWagonDetachRequestDto,
) {
return this.trainBuilderService.decideDetachRequest(
id,
requestId,
'REJECT',
resolveAuthUserId(user),
dto.note,
);
}
@Post(':id/reorder-wagons')
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })

View File

@@ -0,0 +1,95 @@
import { BadRequestException } from '@nestjs/common';
import { TrainBuilderService } from './train-builder.service';
import { WagonDetachRequestAction } from './entities/wagon-detach-request.entity';
/**
* Every detach / send-to-maintenance carries a reason — scheduled run or not
* (the reason replaced the old second-staff approval). The recorder rejects a
* missing/blank one before anything is written, and stores a trimmed, capped
* copy on the audit row otherwise.
*/
describe('TrainBuilderService — detach reason is always required', () => {
const svc = Object.create(TrainBuilderService.prototype) as {
recordDetachReason(
manager: unknown,
trainId: string,
wagonId: string,
action: WagonDetachRequestAction,
reason: string | null | undefined,
userId?: string | null,
): Promise<void>;
};
/** Minimal EntityManager: records what the recorder would persist. */
const managerSpy = () => {
const saved: Array<Record<string, unknown>> = [];
return {
saved,
getRepository: (entity: { name: string }) =>
entity.name === 'Wagon'
? { findOne: async () => ({ wagonNumber: 'NW5-0412' }) }
: {
create: (row: Record<string, unknown>) => row,
save: async (row: Record<string, unknown>) => {
saved.push(row);
return row;
},
},
};
};
it.each([undefined, null, '', ' '])('refuses a blank reason (%p)', async (reason) => {
const manager = managerSpy();
await expect(
svc.recordDetachReason(
manager,
'train-1',
'wagon-1',
WagonDetachRequestAction.Detach,
reason,
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
// Nothing is written when the reason is missing.
expect(manager.saved).toHaveLength(0);
});
it('records the reason as an auto-approved audit row', async () => {
const manager = managerSpy();
await svc.recordDetachReason(
manager,
'train-1',
'wagon-1',
WagonDetachRequestAction.Maintenance,
' Brake shoe worn through ',
'user-1',
);
expect(manager.saved).toHaveLength(1);
const row = manager.saved[0];
expect(row).toMatchObject({
trainId: 'train-1',
wagonId: 'wagon-1',
wagonNumber: 'NW5-0412',
action: WagonDetachRequestAction.Maintenance,
reason: 'Brake shoe worn through',
// No second person: the actor is both requester and decider.
status: 'APPROVED',
requestedBy: 'user-1',
decidedBy: 'user-1',
});
});
it('caps an over-long reason at the column width', async () => {
const manager = managerSpy();
await svc.recordDetachReason(
manager,
'train-1',
'wagon-1',
WagonDetachRequestAction.Detach,
'x'.repeat(900),
null,
);
expect(String(manager.saved[0].reason)).toHaveLength(500);
});
});

View File

@@ -30,7 +30,6 @@ 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 { CreateWagonDetachRequestDto } from './dto/wagon-detach-request.dto';
import { TrainLocomotive } from './entities/train-locomotive.entity';
import { Train } from './entities/train.entity';
import {
@@ -253,6 +252,7 @@ export class TrainBuilderService {
yardLabel: string | null;
actor: string | null;
scheduleReference: string | null;
reason: string | null;
occurredAt: Date;
}>,
] = await Promise.all([
@@ -270,6 +270,7 @@ export class TrainBuilderService {
COALESCE(y.label, y.code) AS "yardLabel",
COALESCE(u.username, u.email) AS "actor",
ts.reference AS "scheduleReference",
l.reason,
l.occurred_at AS "occurredAt"
FROM freight.schedule_wagon_adjustment_logs l
LEFT JOIN freight.yards y ON y.id = l.yard_id
@@ -491,8 +492,9 @@ export class TrainBuilderService {
: null,
},
activeSchedules: schedules,
// Composition is frozen while the train is out on a dispatched run.
editable: !schedules.some((s) => s.status === 'DISPATCHED'),
// The built train is always editable — dispatched/arrived runs render from
// their frozen snapshot, so consist edits reach only DRAFT/SCHEDULED runs.
editable: true,
};
}
@@ -754,22 +756,40 @@ export class TrainBuilderService {
return this.getComposition(id);
}
/** Detach one wagon and close the sequence gap it leaves. */
async removeWagon(id: string, wagonId: string, userId?: string | null) {
/**
* Detach one wagon and close the sequence gap it leaves. On a SCHEDULED run
* the detach still executes directly, but a reason is required and recorded
* in wagon_detach_requests (auto-approved) — the audit trail without the
* former second-staff approval step.
*/
async removeWagon(
id: string,
wagonId: string,
userId?: string | null,
reason?: string | null,
) {
const pending = await this.dataSource.transaction(async (manager) => {
await this.assertDetachNeedsNoApproval(manager, id);
return this.removeWagonCore(manager, id, wagonId, userId);
await this.recordDetachReason(
manager,
id,
wagonId,
WagonDetachRequestAction.Detach,
reason,
userId,
);
return this.removeWagonCore(manager, id, wagonId, userId, reason);
});
await this.reconcileWindowAfterConsistChange(pending);
return this.getComposition(id);
}
/** Transactional body of removeWagon — also runs under an approved detach request. */
/** Transactional body of removeWagon — `reason` rides into the history log. */
private async removeWagonCore(
manager: EntityManager,
id: string,
wagonId: string,
userId?: string | null,
reason?: string | null,
): Promise<PendingWindowCheck | null> {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
@@ -791,6 +811,7 @@ export class TrainBuilderService {
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
userId ?? null,
wagon.currentYardId ?? train.currentYardId ?? null,
reason,
);
}
@@ -805,8 +826,16 @@ export class TrainBuilderService {
userId?: string | null,
note?: string | null,
) {
// `note` is the required reason — recordDetachReason rejects it empty.
const pending = await this.dataSource.transaction(async (manager) => {
await this.assertDetachNeedsNoApproval(manager, id);
await this.recordDetachReason(
manager,
id,
wagonId,
WagonDetachRequestAction.Maintenance,
note,
userId,
);
return this.sendWagonToMaintenanceCore(manager, id, wagonId, userId, note);
});
await this.reconcileWindowAfterConsistChange(pending);
@@ -883,96 +912,54 @@ export class TrainBuilderService {
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
userId ?? null,
yardId,
note,
);
}
}
/**
* Direct-detach guard: while this train carries a live SCHEDULED run,
* removing a wagon changes a departure customers already booked against, so
* it is a two-person action — refuse here and point at the request flow.
* DRAFT stays freely editable; DISPATCHED is already frozen by
* getEditableTrain (the train is IN_SERVICE).
* Every detach / send-to-maintenance carries a REASON — scheduled run or
* not — and an auto-approved wagon_detach_requests row records who did it
* and why (the audit trail that replaced the former second-staff approval).
* DISPATCHED trains never reach here: getEditableTrain freezes them.
*/
private async assertDetachNeedsNoApproval(
private async recordDetachReason(
manager: EntityManager,
trainId: string,
): Promise<void> {
const scheduled = await this.findScheduledRun(manager, trainId);
if (scheduled) {
throw new ConflictException(
`Train is on scheduled run ${scheduled.reference ?? scheduled.id} — detaching a wagon needs an approved detach request`,
);
}
}
private async findScheduledRun(
manager: EntityManager,
trainId: string,
): Promise<{ id: string; reference: string | null } | null> {
const rows: { id: string; reference: string | null }[] = await manager.query(
`SELECT ts.id, 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.status = 'SCHEDULED'
AND ts.deleted_at IS NULL
LIMIT 1`,
[trainId],
);
return rows[0] ?? null;
}
/**
* File a detach/maintenance approval request for a wagon on a SCHEDULED
* train. The request carries the reason; a different staffer with
* trains:approve_wagon_detach decides it (approval executes the detach).
*/
async createDetachRequest(
id: string,
wagonId: string,
dto: CreateWagonDetachRequestDto,
action: WagonDetachRequestAction,
reason: string | null | undefined,
userId?: string | null,
) {
return 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`);
}
const scheduled = await this.findScheduledRun(manager, train.id);
if (!scheduled) {
throw new ConflictException(
'This train has no SCHEDULED run — detach the wagon directly, no approval needed',
);
}
// Refuse up front what an approval could never execute (booked
// allocations pin the wagon) — but release nothing yet: slots are only
// touched when the approved detach actually runs.
await this.assertDetachableAndReleaseStaleSlots(manager, wagon, { checkOnly: true });
const repo = manager.getRepository(WagonDetachRequest);
const open = await repo.findOne({
where: { trainId: train.id, wagonId: wagon.id, status: WagonDetachRequestStatus.Pending },
});
if (open) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} already has a pending detach request`,
);
}
return repo.save(
repo.create({
trainId: train.id,
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
action: dto.action,
reason: dto.reason.trim(),
requestedBy: userId ?? null,
}),
): Promise<void> {
const trimmed = reason?.trim();
if (!trimmed) {
throw new BadRequestException(
`Give a reason for ${
action === WagonDetachRequestAction.Maintenance
? 'sending this wagon to maintenance'
: 'detaching this wagon'
}`,
);
});
}
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
const repo = manager.getRepository(WagonDetachRequest);
const now = new Date();
await repo.save(
repo.create({
trainId,
wagonId,
wagonNumber: wagon?.wagonNumber ?? wagonId,
action,
reason: trimmed.slice(0, 500),
status: WagonDetachRequestStatus.Approved,
requestedBy: userId ?? null,
decidedBy: userId ?? null,
decidedAt: now,
}),
);
}
/** All detach/maintenance requests of this train, newest first — the approval audit trail. */
/** All detach/maintenance records of this train, newest first — the audit trail. */
async listDetachRequests(trainId: string) {
const rows: Array<{
id: string;
@@ -1012,68 +999,6 @@ export class TrainBuilderService {
return rows;
}
/**
* Decide a pending request. Approve executes the detach (or maintenance
* move) in the same transaction that stamps the decision, so an approved row
* can never exist without its detach having happened. The requester cannot
* approve their own request; a rejection must carry a note.
*/
async decideDetachRequest(
id: string,
requestId: string,
decision: 'APPROVE' | 'REJECT',
userId?: string | null,
note?: string | null,
) {
const pending = await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(WagonDetachRequest);
const request = await repo.findOne({
where: { id: requestId, trainId: id },
lock: { mode: 'pessimistic_write' },
});
if (!request) {
throw new NotFoundException(`Detach request ${requestId} not found on this train`);
}
if (request.status !== WagonDetachRequestStatus.Pending) {
throw new ConflictException(
`This request was already ${request.status.toLowerCase()}`,
);
}
const decisionNote = note?.trim() || null;
if (decision === 'REJECT') {
if (!decisionNote) {
throw new BadRequestException('A note explaining the rejection is required');
}
await repo.update(request.id, {
status: WagonDetachRequestStatus.Rejected,
decidedBy: userId ?? null,
decidedAt: new Date(),
decisionNote,
});
return null;
}
// The 4-eyes point of the gate: requester and approver are different people.
if (request.requestedBy && userId && request.requestedBy === userId) {
throw new ConflictException(
'You filed this request — a different staff member must approve it',
);
}
const pendingCheck =
request.action === WagonDetachRequestAction.Maintenance
? await this.sendWagonToMaintenanceCore(manager, id, request.wagonId, userId, request.reason)
: await this.removeWagonCore(manager, id, request.wagonId, userId);
await repo.update(request.id, {
status: WagonDetachRequestStatus.Approved,
decidedBy: userId ?? null,
decidedAt: new Date(),
decisionNote,
});
return pendingCheck;
});
await this.reconcileWindowAfterConsistChange(pending);
return this.getComposition(id);
}
/**
* Schedule occupancy lives on TrainSetWagon slots (per-schedule snapshot),
* not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/
@@ -1113,6 +1038,9 @@ export class TrainBuilderService {
wagon: Wagon,
opts: { checkOnly?: boolean } = {},
): Promise<void> {
// Only DRAFT/SCHEDULED runs still follow the live consist, so only they can
// pin a wagon. A DISPATCHED/ARRIVED run reads its frozen snapshot and is
// unaffected by what happens to the physical train behind it.
const rows: { id: string; train_set_id: string; status: string; allocs: string }[] =
await manager.query(
`SELECT tsw.id, tsw.train_set_id, ts.status,
@@ -1123,15 +1051,18 @@ export class TrainBuilderService {
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE tsw.physical_wagon_id = $1
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL`,
[wagon.id],
);
if (!rows.length) return;
if (rows.some((r) => Number(r.allocs) > 0 || r.status === 'DISPATCHED')) {
// Cargo already allocated to a live run keeps its wagon: the booking must be
// unassigned from the slot first, so a customer's shipment can never lose its
// wagon as a side effect of editing the train.
if (rows.some((r) => Number(r.allocs) > 0)) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
`Wagon ${wagon.wagonNumber} is carrying cargo on a live schedule — unassign its bookings before removing it`,
);
}
if (opts.checkOnly) return;
@@ -1162,25 +1093,9 @@ export class TrainBuilderService {
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
}
// Only a rolling train is frozen. Pre-dispatch (DRAFT/SCHEDULED) reorder
// is allowed — the pinned schedules' consists are resequenced below so
// they can never desync from the built train's real order.
const dispatched: { exists: boolean }[] = await manager.query(
`SELECT TRUE AS exists
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE tsw.physical_wagon_id = ANY($1::uuid[])
AND ts.status = 'DISPATCHED'
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
LIMIT 1`,
[[...current]],
);
if (dispatched.length > 0) {
throw new ConflictException(
'This train is dispatched — wagons cannot be reordered while it is rolling.',
);
}
// Reorder is allowed at any time, dispatched runs included: a DISPATCHED
// schedule renders the order frozen in its snapshot, and only the
// DRAFT/SCHEDULED consists resequenced below follow the built train.
for (let i = 0; i < dto.wagonIds.length; i++) {
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
}
@@ -1402,6 +1317,7 @@ export class TrainBuilderService {
changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>,
userId: string | null,
yardId: string | null,
reason?: string | null,
): Promise<PendingWindowCheck | null> {
if (!changes.length) return null;
const trainSet = await manager
@@ -1443,6 +1359,7 @@ export class TrainBuilderService {
wagonNumber: c.wagonNumber,
adjustedByUserId: userId,
yardId,
reason: reason?.trim() || null,
occurredAt: now,
}),
),
@@ -1481,17 +1398,21 @@ export class TrainBuilderService {
}
/** Load + freeze the train row for edit; block edits while it is out on a run. */
/**
* The built train is editable at ANY time, including while it is out on a
* dispatched run. A dispatched/arrived schedule froze its own wagon plan into
* `wagonAllocationSnapshot` at the transition and renders from that, so it can
* never be disturbed by later consist edits; only DRAFT/SCHEDULED runs follow
* the live train (see syncLiveScheduleAfterConsistChange). Per-wagon safety
* still applies — assertDetachableAndReleaseStaleSlots refuses to pull a wagon
* whose cargo is allocated to a live 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;
}