import { Freight, WagonMovementKind, WagonStatus } from '@edr/types'; import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { DataSource, EntityManager, ILike, In } from 'typeorm'; /** Schedule whose FULL flag must be re-derived once the consist edit has committed. */ type PendingWindowCheck = { scheduleId: string; wasFull: boolean }; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.util'; import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { WagonStatusLog } from '../wagons/entities/wagon-status-log.entity'; 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 { 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'; import { buildPaginationMeta, normalizePagination, } from '../../common/utils/pagination.util'; const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100; /** Locomotive statuses that block a train from reactivating. */ const UNFIT_FOR_REACTIVATION = new Set(['MAINTENANCE', 'OUT_OF_SERVICE', 'UNAVAILABLE']); /** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */ export interface ActiveScheduleRef { id: string; status: string; reference: string | null; direction: string | null; trainNumber: string | null; } /** * 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 { private readonly logger = new Logger(TrainBuilderService.name); constructor( private readonly dataSource: DataSource, private readonly bookingBatchService: BookingBatchService, ) {} async buildTrain(dto: BuildTrainDto) { const locomotiveIds = [...new Set(dto.locomotiveIds)]; if (locomotiveIds.length < 1) { throw new BadRequestException('A train must be pulled by at least one locomotive'); } const trainId = await this.dataSource.transaction(async (manager) => { const code = await this.generateTrainCode(manager); // Friendly 409 before the partial unique indexes (the race-proof backstop): // the typed pair may not collide with any train's pair or legacy number. const importTrainNumber = dto.importTrainNumber.trim(); const exportTrainNumber = dto.exportTrainNumber.trim(); const numberClash: { code: string }[] = await manager.query( `SELECT code FROM freight.trains WHERE deleted_at IS NULL AND (import_train_number IN ($1, $2) OR export_train_number IN ($1, $2) OR train_number IN ($1, $2)) LIMIT 1`, [importTrainNumber, exportTrainNumber], ); if (numberClash.length) { throw new ConflictException( `Train number ${importTrainNumber}/${exportTrainNumber} is already used by train ${numberClash[0].code}`, ); } 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 = combinedLocomotiveLimits(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, importTrainNumber, exportTrainNumber, }), ); 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, }); const activeByTrain = await this.loadActiveScheduleByTrain(trains.map((t) => t.id)); return { items: trains.map((train) => this.mapSummary(train, activeByTrain.get(train.id) ?? null)), meta: buildPaginationMeta(total, page, pageSize), }; } /** * 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(); const exportTrainNumbers = new Set(); 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 * and in-use train number. */ private async loadActiveScheduleByTrain( trainIds: string[], ): Promise> { if (!trainIds.length) return new Map(); const rows: (ActiveScheduleRef & { trainId: string })[] = await this.dataSource.query( `SELECT DISTINCT ON (tset.train_id) tset.train_id AS "trainId", ts.id, ts.status, ts.reference, ts.direction, ts.train_number AS "trainNumber" FROM freight.train_schedules ts JOIN freight.train_sets tset ON tset.id = ts.train_set_id WHERE tset.train_id = ANY($1) AND ts.deleted_at IS NULL AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') ORDER BY tset.train_id, (ts.status = 'DISPATCHED') DESC, ts.scheduled_departure_date ASC`, [trainIds], ); return new Map(rows.map(({ trainId, ...schedule }) => [trainId, schedule])); } /** * Wagon adjustment history of one built train, newest first: builder * attaches/detaches (no schedule) and trip events (real cuts, couples, * consist adjustments — carrying their schedule reference) alike. */ async getTrainHistory(trainId: string, query: { page?: number; pageSize?: number } = {}) { const { page, pageSize, skip, take } = normalizePagination(query); const [countRows, rows]: [ Array<{ total: string }>, Array<{ id: string; action: string; subject: string; yardLabel: string | null; actor: string | null; scheduleReference: string | null; occurredAt: Date; }>, ] = await Promise.all([ this.dataSource.query( `SELECT count(*) AS total FROM freight.schedule_wagon_adjustment_logs l WHERE l.train_id = $1 AND l.deleted_at IS NULL`, [trainId], ), this.dataSource.query( `SELECT l.id, l.action, l.wagon_number AS "subject", COALESCE(y.label, y.code) AS "yardLabel", COALESCE(u.username, u.email) AS "actor", ts.reference AS "scheduleReference", l.occurred_at AS "occurredAt" FROM freight.schedule_wagon_adjustment_logs l LEFT JOIN freight.yards y ON y.id = l.yard_id LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id LEFT JOIN freight.train_schedules ts ON ts.id = l.train_schedule_id WHERE l.train_id = $1 AND l.deleted_at IS NULL ORDER BY l.occurred_at DESC LIMIT $2 OFFSET $3`, [trainId, take, skip], ), ]); const total = Number(countRows[0]?.total ?? 0); return { items: rows, meta: buildPaginationMeta(total, page, pageSize) }; } /** * Wagons last detached from THIS train that are still loose (no train, * AVAILABLE) — the re-attach shortlist, with when/where/by whom each was * last detached. Derived from the adjustment log, no denormalized column. */ async getDetachedWagons(trainId: string, query: { page?: number; pageSize?: number } = {}) { const { page, pageSize, skip, take } = normalizePagination(query); const lastRemovalSql = ` SELECT DISTINCT ON (l.wagon_id) l.wagon_id AS "wagonId", l.occurred_at AS "detachedAt", COALESCE(y.label, y.code) AS "detachedYardLabel", COALESCE(u.username, u.email) AS "detachedBy" FROM freight.schedule_wagon_adjustment_logs l LEFT JOIN freight.yards y ON y.id = l.yard_id LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id WHERE l.train_id = $1 AND l.action = 'REMOVE' AND l.deleted_at IS NULL ORDER BY l.wagon_id, l.occurred_at DESC`; const stillLoose = `w.deleted_at IS NULL AND w.train_id IS NULL AND w.status = 'AVAILABLE'`; const [countRows, rows]: [ Array<{ total: string }>, Array<{ wagonId: string; wagonNumber: string; wagonTypeCode: string | null; currentYardLabel: string | null; detachedAt: Date; detachedYardLabel: string | null; detachedBy: string | null; }>, ] = await Promise.all([ this.dataSource.query( `SELECT count(*) AS total FROM (${lastRemovalSql}) last_removal JOIN freight.wagons w ON w.id = last_removal."wagonId" WHERE ${stillLoose}`, [trainId], ), this.dataSource.query( `SELECT last_removal."wagonId", w.wagon_number AS "wagonNumber", wt.code AS "wagonTypeCode", COALESCE(cy.label, cy.code) AS "currentYardLabel", last_removal."detachedAt", last_removal."detachedYardLabel", last_removal."detachedBy" FROM (${lastRemovalSql}) last_removal JOIN freight.wagons w ON w.id = last_removal."wagonId" LEFT JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id LEFT JOIN freight.yards cy ON cy.id = w.current_yard_id WHERE ${stillLoose} ORDER BY last_removal."detachedAt" DESC LIMIT $2 OFFSET $3`, [trainId, take, skip], ), ]); const total = Number(countRows[0]?.total ?? 0); return { items: rows, 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: ActiveScheduleRef[] = await this.dataSource.query( `SELECT ts.id, ts.status, ts.reference, ts.direction, ts.train_number AS "trainNumber" 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, currentYardId: wagon.currentYardId ?? null, currentYard: wagon.currentYard ? { id: wagon.currentYard.id, code: wagon.currentYard.code, label: wagon.currentYard.label } : null, 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 = combinedLocomotiveLimits( (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 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, importTrainNumber: train.importTrainNumber ?? null, exportTrainNumber: train.exportTrainNumber ?? null, 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, // Where the consist physically stands. A train built from several yards // only picks a yard's wagons up when it reaches that yard, and a customer // boarding there can only book the wagons standing there — the schedule // route must therefore cover every one of these yards before its // destination. wagonYards: [ ...wagons .reduce((acc, wagon) => { const id = wagon.currentYardId ?? 'UNASSIGNED'; const entry = acc.get(id) ?? { yardId: wagon.currentYardId ?? null, code: wagon.currentYard?.code ?? null, label: wagon.currentYard?.label ?? null, wagonCount: 0, }; entry.wagonCount += 1; acc.set(id, entry); return acc; }, new Map()) .values(), ].sort((a, b) => b.wagonCount - a.wagonCount), totals: { wagonCount: wagons.length, totalTareTons, // Informational only — building never checks against full capacity; // the real gross check (cargo + tare vs haul limit) runs at allocation. totalCapacityTons, totalLengthMeters, maxPullWeightTons, maxTrainLengthMeters, // Cargo the locomotives can still haul once pulling the empty consist. payloadCapacityTons: round(Math.max(0, maxPullWeightTons - totalTareTons)), // Share of the haul limit consumed by the empty wagons alone. tareUtilizationPct: maxPullWeightTons ? round((totalTareTons / 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 (minimum 1, same-yard rule applies). */ async setLocomotives(id: string, dto: UpdateTrainLocomotivesDto) { const locomotiveIds = [...new Set(dto.locomotiveIds)]; if (locomotiveIds.length < 1) { throw new BadRequestException('A train must be pulled by at least one locomotive'); } 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 = combinedLocomotiveLimits(locomotives); await manager .getRepository(Train) .update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) }); }); 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: QueryDeepPartialEntity = {}; 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 locomotives always follow. Of the * wagons, only those standing WITH the train move: on a consist spread * across yards (20 in Dire, 33 waiting in Mojo), moving the train Dire→Mojo * relocates the 20 it is actually pulling and leaves the Mojo wagons where * they stand — the train collects those by arriving, not by this call. * Each moved wagon gets a movement-ledger row. Blocked while the train is * out on a dispatched run. */ async setYard(id: string, currentYardId: string) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); if (train.currentYardId === currentYardId) return; const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } }); if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`); const previousYardId = train.currentYardId ?? null; await manager.getRepository(Train).update(train.id, { currentYardId: yard.id }); const links = await manager .getRepository(TrainLocomotive) .find({ where: { trainId: train.id } }); if (links.length) { await manager .getRepository(Locomotive) .update( { id: In(links.map((link) => link.locomotiveId)) }, { currentYardId: yard.id }, ); } const allWagons = await manager .getRepository(Wagon) .find({ where: { trainId: train.id } }); // Wagons travelling with the train = those at the yard it is leaving. // A yard-less wagon has no standing position of its own, so it follows. const wagons = allWagons.filter( (wagon) => wagon.currentYardId == null || previousYardId == null || wagon.currentYardId === previousYardId, ); const now = new Date(); for (const wagon of wagons) { if (wagon.currentYardId === yard.id) continue; await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id }); // Ledger row keeps the wagon's yard history auditable (mirrors the // manual-relocation path in the wagons service). await manager.getRepository(WagonMovement).save( manager.getRepository(WagonMovement).create({ wagonId: wagon.id, fromYardId: wagon.currentYardId ?? null, toYardId: yard.id, kind: WagonMovementKind.Manual, occurredAt: now, }), ); } }); return this.getComposition(id); } /** * Move ONE coupled wagon to another yard (the train and the rest of the * consist stay put). Refused while any live (DRAFT/SCHEDULED/DISPATCHED) * schedule has the wagon allocated to a slot — its standing yard is part of * that schedule's route validation. Ledger row mirrors `setYard`. */ async setWagonYard(id: string, wagonId: string, currentYardId: string, userId?: string | null) { 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 coupled to train ${train.code}`); } if (wagon.currentYardId === currentYardId) return; const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } }); if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`); if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) { throw new ConflictException( `Wagon ${wagon.wagonNumber} is allocated to a scheduled or dispatched run; its yard cannot be changed`, ); } await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id }); await manager.getRepository(WagonMovement).save( manager.getRepository(WagonMovement).create({ wagonId: wagon.id, fromYardId: wagon.currentYardId ?? null, toYardId: yard.id, kind: WagonMovementKind.Manual, movedByUserId: userId ?? null, occurredAt: new Date(), }), ); }); return this.getComposition(id); } /** * Move SEVERAL coupled wagons to another yard in one transaction (the train * and the rest of the consist stay put). All-or-nothing: if any wagon is not * coupled here, or is pinned to a live schedule, nothing moves — a partial * relocation would leave the consist split across yards silently. Wagons * already in the target yard are skipped, not an error. */ async setWagonsYard( id: string, wagonIds: string[], currentYardId: string, userId?: string | null, ) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } }); if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`); const unique = [...new Set(wagonIds)]; const wagons = await manager.getRepository(Wagon).find({ where: unique.map((wid) => ({ id: wid })) }); const byId = new Map(wagons.map((w) => [w.id, w])); const missing = unique.filter((wid) => byId.get(wid)?.trainId !== train.id); if (missing.length) { throw new NotFoundException( `${missing.length} of ${unique.length} wagons are not coupled to train ${train.code}`, ); } // Check every wagon before moving any — the whole point of the bulk call. const pinned: string[] = []; for (const wagon of wagons) { if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) { pinned.push(wagon.wagonNumber); } } if (pinned.length) { throw new ConflictException( `${pinned.join(', ')} ${pinned.length === 1 ? 'is' : 'are'} allocated to a scheduled or dispatched run; ${ pinned.length === 1 ? 'its' : 'their' } yard cannot be changed`, ); } const moving = wagons.filter((w) => w.currentYardId !== yard.id); if (!moving.length) return; await manager .getRepository(Wagon) .update(moving.map((w) => w.id), { currentYardId: yard.id }); await manager.getRepository(WagonMovement).save( moving.map((w) => manager.getRepository(WagonMovement).create({ wagonId: w.id, fromYardId: w.currentYardId ?? null, toYardId: yard.id, kind: WagonMovementKind.Manual, movedByUserId: userId ?? null, occurredAt: new Date(), }), ), ); }); return this.getComposition(id); } /** Append AVAILABLE, unassigned wagons (any yard) to the consist. */ async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) { const pending = await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); const currentCount = await manager .getRepository(Wagon) .count({ where: { trainId: train.id } }); const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount); return this.syncLiveScheduleAfterConsistChange( manager, train.id, attached.map((w) => ({ action: 'ADD' as const, wagonId: w.id, wagonNumber: w.wagonNumber })), userId ?? null, train.currentYardId ?? null, ); }); await this.reconcileWindowAfterConsistChange(pending); return this.getComposition(id); } /** Detach one wagon and close the sequence gap it leaves. */ async removeWagon(id: string, wagonId: string, userId?: string | null) { const pending = 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`); } await this.assertDetachableAndReleaseStaleSlots(manager, wagon); await manager.getRepository(Wagon).update(wagon.id, { trainId: null, sequenceNumber: null, status: WagonStatus.Available, importTrainNumber: null, exportTrainNumber: null, }); await this.resequenceWagons(manager, train.id); return this.syncLiveScheduleAfterConsistChange( manager, train.id, [{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }], userId ?? null, wagon.currentYardId ?? train.currentYardId ?? null, ); }); await this.reconcileWindowAfterConsistChange(pending); return this.getComposition(id); } /** * Detach one wagon AND flag it for maintenance: it leaves the consist and * moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until * it clears maintenance. The freed sequence gap is closed. */ async sendWagonToMaintenance( id: string, wagonId: string, userId?: string | null, note?: string | null, ) { const pending = 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`); } await this.assertDetachableAndReleaseStaleSlots(manager, wagon); const previousStatus = wagon.status; const notes = buildMaintenanceNotes(formatTrainRunLabel(train), note); await manager.getRepository(Wagon).update(wagon.id, { trainId: null, sequenceNumber: null, status: WagonStatus.Maintenance, importTrainNumber: null, exportTrainNumber: null, }); // Status-history row, same as the fleet desk's "Send to maintenance" — // without it a maintenance detach made here is invisible in the wagon's // status history. The train number is folded into the note so the history // answers "which train did it come off, and why" in one line. if (previousStatus !== WagonStatus.Maintenance) { await manager.getRepository(WagonStatusLog).save( manager.getRepository(WagonStatusLog).create({ wagonId: wagon.id, fromStatus: previousStatus, toStatus: WagonStatus.Maintenance, changedByUserId: userId ?? null, note: notes.statusLogNote, }), ); } // Audit row: which train it came off and when. The wagon does not change // yard here, so from/to are the same — the ledger is the wagon's history // surface, and a maintenance detach has to be in it. const yardId = wagon.currentYardId ?? train.currentYardId ?? null; if (yardId) { await manager.getRepository(WagonMovement).save( manager.getRepository(WagonMovement).create({ wagonId: wagon.id, fromYardId: yardId, toYardId: yardId, kind: WagonMovementKind.Maintenance, note: notes.movementNote, movedByUserId: userId, occurredAt: new Date(), }), ); } else { // to_yard_id is NOT NULL — a yard-less wagon still goes to maintenance, // it just cannot carry a ledger row. this.logger.warn( `Wagon ${wagon.wagonNumber} sent to maintenance with no yard — ledger row skipped`, ); } await this.resequenceWagons(manager, train.id); return this.syncLiveScheduleAfterConsistChange( manager, train.id, [{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }], userId ?? null, yardId, ); }); 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/ * DISPATCHED) schedule has it pinned to one of its slots. */ private async isWagonPinnedToLiveSchedule( manager: EntityManager, wagonId: string, ): Promise { const rows: { 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 = $1 AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') AND ts.deleted_at IS NULL AND tsw.deleted_at IS NULL LIMIT 1`, [wagonId], ); return rows.length > 0; } /** * Detach guard for removeWagon / sendWagonToMaintenance. A wagon is truly * pinned only while a live schedule still NEEDS it: a slot carrying booking * allocations, or any slot on a DISPATCHED run. An empty (allocation-free) * slot on a DRAFT/SCHEDULED schedule is a stale reservation — its load was * moved to another wagon (moveWagonLoad keeps the emptied slot) or its * booking left through a path that didn't clean up — and used to pin the * wagon forever. Release those slots here instead of blocking, with the * same recount removeTrainSetWagonSlot does (wagonCount / totalLengthMeters * feed the schedule capacity math). */ private async assertDetachableAndReleaseStaleSlots( manager: EntityManager, wagon: Wagon, ): Promise { const rows: { id: string; train_set_id: string; status: string; allocs: string }[] = await manager.query( `SELECT tsw.id, tsw.train_set_id, ts.status, (SELECT count(*) FROM freight.wagon_booking_allocations a WHERE a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL) AS allocs 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.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')) { throw new ConflictException( `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, ); } await manager.getRepository(TrainSetWagon).delete(rows.map((r) => r.id)); for (const trainSetId of [...new Set(rows.map((r) => r.train_set_id))]) { const remaining = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId }, select: { id: true, lengthMeters: true }, }); await manager.getRepository(TrainSet).update(trainSetId, { wagonCount: remaining.length, totalLengthMeters: round( remaining.reduce((sum, w) => sum + (Number(w.lengthMeters) || 0), 0), ), }); } } /** 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'); } // 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.', ); } for (let i = 0; i < dto.wagonIds.length; i++) { await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 }); } // Propagate the new order to every live (DRAFT/SCHEDULED) schedule of // this train: slots pinned to a reordered wagon adopt the wagon's new // position, unpinned slots trail in their old relative order. Allocations // ride the slot row (by id), so cargo stays with its physical wagon. const newSeq = new Map(dto.wagonIds.map((wid, i) => [wid, i + 1])); const sets: { train_set_id: string }[] = await manager.query( `SELECT DISTINCT ts.train_set_id 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')`, [id], ); for (const { train_set_id: trainSetId } of sets) { const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId }, order: { sequenceNo: 'ASC' }, }); const sorted = orderSlotsByWagonSequence(slots, newSeq); // (train_set_id, sequence_no) is unique — shift to a temp range first // so the final renumbering can't collide mid-loop. await manager.query( `UPDATE freight.train_set_wagons SET sequence_no = sequence_no + 100000 WHERE train_set_id = $1 AND deleted_at IS NULL`, [trainSetId], ); for (let i = 0; i < sorted.length; i++) { await manager .getRepository(TrainSetWagon) .update(sorted[i].id, { sequenceNo: i + 1 }); } } }); 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. Blocked if any coupled locomotive is unfit for service — a * deactivated train can sit parked for a while and its locomotives may have * since been sent to maintenance independently; reactivating must not wave * a down locomotive back onto the schedule board. */ 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) { const links = await this.dataSource .getRepository(TrainLocomotive) .find({ where: { trainId: id }, relations: { locomotive: true } }); const unfit = links .map((link) => link.locomotive) .filter((loco): loco is Locomotive => Boolean(loco)) .filter((loco) => UNFIT_FOR_REACTIVATION.has(loco.status)); if (unfit.length) { const names = unfit.map((l) => `${l.code} (${l.status})`).join(', '); throw new ConflictException( `Train cannot be reactivated: ${names} ${unfit.length > 1 ? 'are' : 'is'} not fit for service. Detach and replace before reactivating.`, ); } 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 { 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, importTrainNumber: null, exportTrainNumber: null, }, ); await manager.getRepository(TrainLocomotive).delete({ trainId: train.id }); await manager.getRepository(Train).remove(train); }); } // ---------------------------------------------------------------- internals /** * System-assigned train code `TR-NNNNN`. Draws the next number from the * highest existing `TR-` code and probes past any manual collision so the * unique constraint never rejects the build. */ private async generateTrainCode(manager: EntityManager): Promise { const [row]: { max_seq: string | null }[] = await manager.query( `SELECT MAX(CAST(SUBSTRING(code FROM '^TR-([0-9]+)$') AS INTEGER)) AS max_seq FROM freight.trains WHERE code ~ '^TR-[0-9]+$'`, ); let seq = Number(row?.max_seq ?? 0) + 1; for (let attempt = 0; attempt < 50; attempt += 1) { const code = `TR-${String(seq).padStart(5, '0')}`; const exists = await manager .getRepository(Train) .findOne({ where: { code }, withDeleted: true }); if (!exists) return code; seq += 1; } throw new ConflictException('Could not allocate a unique train code'); } private mapSummary(train: Train, activeSchedule: ActiveScheduleRef | null) { 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 totalTareTons = round( wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0), ); return { id: train.id, code: train.code, trainName: train.trainName ?? null, status: train.status, importTrainNumber: train.importTrainNumber ?? null, exportTrainNumber: train.exportTrainNumber ?? null, activeSchedule, 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, totalTareTons, totalLengthMeters: round( wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0), ), // Derived live from the coupled set, NOT from the stored capacity_tons. // That column is written at build/re-couple time, so every train built // before pull weight became additive still holds the old single-locomotive // figure. Computing it here keeps the board honest without a backfill; // the column self-heals the next time the locomotive set is saved. maxPullWeightTons: round( combinedLocomotiveLimits(locomotives)?.maxPullWeightTons ?? Number(train.capacityTons) ?? 0, ), }; } /** * Train Builder edits a train's physical consist directly on `Wagon.trainId` * — it never touches `TrainSchedule.maxWagons` / `TrainSet.wagonCount`, so a * wagon added/removed here (while the train already has a live DRAFT/ * SCHEDULED schedule) used to leave the schedule's capacity, history, and * booking-window status silently stale. This mirrors what * TrainSchedulingService.adjustScheduleConsist does when the SAME edit is * made from the schedule's own consist editor, so both entry points agree. */ private async syncLiveScheduleAfterConsistChange( manager: EntityManager, trainId: string, changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>, userId: string | null, yardId: string | null, ): Promise { if (!changes.length) return null; const trainSet = await manager .getRepository(TrainSet) .findOne({ where: { trainId }, order: { createdAt: 'DESC' } }); const schedule = trainSet ? await manager.getRepository(TrainSchedule).findOne({ where: { trainSetId: trainSet.id, status: In(['DRAFT', 'SCHEDULED']) }, }) : null; const consist = await manager.getRepository(Wagon).find({ where: { trainId }, relations: { wagonType: true }, }); const wagonCount = consist.length; const totalWeightTons = round( consist.reduce((sum, w) => sum + (w.wagonType?.tareWeightTons ? Number(w.wagonType.tareWeightTons) : 0), 0), ); const totalLengthMeters = round( consist.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ? Number(w.wagonType.lengthMeters) : 0), 0), ); if (trainSet) { await manager .getRepository(TrainSet) .update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters }); } // Log the consist change even when the train has no live schedule — the // builder's own detach/attach is the train's history too (who removed // which wagon, when, where), and the detached-wagons tab reads it back. const now = new Date(); await manager.getRepository(ScheduleWagonAdjustmentLog).save( changes.map((c) => manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: schedule?.id ?? null, trainId, action: c.action, wagonId: c.wagonId, wagonNumber: c.wagonNumber, adjustedByUserId: userId, yardId, occurredAt: now, }), ), ); if (!schedule) return null; await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount }); // The FULL/reopen decision must run AFTER the transaction commits — see // reconcileWindowAfterConsistChange. return { scheduleId: schedule.id, wasFull: schedule.bookingWindowStatus === 'FULL' }; } /** * Same full/reopen rule as adjustScheduleConsist: freeing a slot on a FULL * schedule reopens its booking window; filling the last one closes it. * * Runs only once the consist transaction has COMMITTED. BookingBatchService * reads through its own connection, so inside the transaction it still saw * the old consist: a wagon coupled onto an empty (FULL) train counted as 0 * slots, `nowFull` stayed true and the window was never reopened. */ private async reconcileWindowAfterConsistChange( pending: PendingWindowCheck | null, ): Promise { if (!pending) return; const usage = await this.bookingBatchService.scheduleWagonUsage(pending.scheduleId); if (!usage) return; const nowFull = usage.remainingSlots <= 0; if (pending.wasFull && !nowFull) { await this.bookingBatchService.refreshWindowStatus(pending.scheduleId); } else if (!pending.wasFull && nowFull) { await this.bookingBatchService.setWindow(pending.scheduleId, 'FULL'); } } /** Load + freeze the train row for edit; block edits while it is out on a run. */ private async getEditableTrain(manager: EntityManager, id: string): Promise { 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 { 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 { 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 { const uniqueIds = [...new Set(wagonIds)]; const wagonRepo = manager.getRepository(Wagon); // First pass: lock + validate every wagon so the length gate below sees // the full incoming set before any row is written. const toAttach: Wagon[] = []; for (const wagonId of uniqueIds) { const wagon = await wagonRepo.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})`); } // Wagons may sit in any yard — the schedule's route must pass through // every wagon yard before its destination (checked at scheduling time). toAttach.push(wagon); } if (!toAttach.length) return []; await this.assertConsistLengthWithinLimit(manager, train, toAttach); let sequence = startCount; for (const wagon of toAttach) { sequence += 1; await wagonRepo.update(wagon.id, { trainId: train.id, sequenceNumber: sequence, status: WagonStatus.Assigned, // Wagon inherits the train's run numbers on coupling — no per-wagon // number entry, they ride whatever numbers the train was built with. importTrainNumber: train.importTrainNumber, exportTrainNumber: train.exportTrainNumber, }); } return toAttach; } /** * The consist (already-attached wagons + the incoming ones) must fit the * train's locomotive length limit — the weakest locomotive of the set caps * the train, mirroring how scheduling derives capacity. */ private async assertConsistLengthWithinLimit( manager: EntityManager, train: Train, incoming: Wagon[], ): Promise { const links = await manager.getRepository(TrainLocomotive).find({ where: { trainId: train.id }, relations: { locomotive: true }, }); const limits = combinedLocomotiveLimits( links .map((link) => link.locomotive) .filter((loco): loco is Locomotive => Boolean(loco)), ); const maxLengthMeters = Number(limits?.maxTrainLengthMeters ?? 0); if (!Number.isFinite(maxLengthMeters) || maxLengthMeters <= 0) return; const existing = await manager.getRepository(Wagon).find({ where: { trainId: train.id }, relations: { wagonType: true }, }); const currentLength = existing.reduce( (sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0, ); const typeIds = [...new Set(incoming.map((w) => w.wagonTypeId).filter(Boolean))]; const types = typeIds.length ? await manager.getRepository(WagonType).find({ where: { id: In(typeIds) } }) : []; const lengthByType = new Map(types.map((t) => [t.id, Number(t.lengthMeters) || 0])); const addedLength = incoming.reduce( (sum, w) => sum + (lengthByType.get(w.wagonTypeId) ?? 0), 0, ); const totalLength = currentLength + addedLength; if (totalLength > maxLengthMeters) { throw new BadRequestException( `Cannot attach wagons — train length would be ${round(totalLength)} m ` + `(current ${round(currentLength)} m + ${round(addedLength)} m added), ` + `over the locomotive limit of ${round(maxLengthMeters)} m. ` + 'Remove wagons from the consist or use locomotives with a higher length limit.', ); } } /** Compact wagon sequence numbers back to 1..n after a removal. */ private async resequenceWagons(manager: EntityManager, trainId: string): Promise { 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 }); } } } } /** * New consist order for a schedule's slots after a built-train reorder: slots * pinned to a reordered wagon adopt the wagon's new position; unpinned slots * trail behind in their previous relative order. */ /** * How a train is named in a wagon's history. Staff identify a train by its * OPERATIONAL run numbers — the fixed export (odd) and import (even) numbers * typed at build time — not by its internal code (`TRN-LEDGER-PW2`), which is a * ledger key and means nothing on the ground. Both runs are shown when set, * since one built train carries the pair. Falls back to the train number, then * the code, only when no run number exists. */ export function formatTrainRunLabel(train: { exportTrainNumber?: string | null; importTrainNumber?: string | null; trainNumber?: string | null; code?: string | null; }): string { const exportNo = train.exportTrainNumber?.trim(); const importNo = train.importTrainNumber?.trim(); const runs = [ exportNo ? `export ${exportNo}` : null, importNo ? `import ${importNo}` : null, ].filter(Boolean); if (runs.length) return runs.join(' / '); return train.trainNumber?.trim() || train.code?.trim() || 'unknown'; } /** * Notes for a maintenance detach. The train's run numbers are always recorded — * staff need to know which consist a wagon came off — and the operator's reason * is folded in when given, so the wagon's status history answers "which train, * and why" in one line (matching the fleet desk's Send-to-maintenance note). */ export function buildMaintenanceNotes(trainLabel: string, note?: string | null) { const reason = note?.trim(); return { statusLogNote: reason ? `${reason} (detached from train ${trainLabel})` : `Detached from train ${trainLabel}`, movementNote: reason ? `Sent to maintenance from train ${trainLabel}: ${reason}` : `Sent to maintenance from train ${trainLabel}`, }; } export function orderSlotsByWagonSequence< T extends Pick, >(slots: T[], newSeq: Map): T[] { const key = (s: T): number => (s.physicalWagonId ? newSeq.get(s.physicalWagonId) : undefined) ?? Infinity; return [...slots].sort((a, b) => { const sa = key(a); const sb = key(b); return sa !== sb ? sa - sb : a.sequenceNo - b.sequenceNo; }); }