import { Freight, PaginatedResponse, WagonEventType, WagonMovementKind, WagonStatus, } from '@edr/types'; import { BadRequestException, Injectable, NotFoundException, ConflictException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, In, SelectQueryBuilder } from 'typeorm'; import { paginateQuery } from '../../common/utils/pagination.util'; import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto'; import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto'; import { Wagon } from './entities/wagon.entity'; import { WagonStatusLog } from './entities/wagon-status-log.entity'; import { WagonMovement } from './entities/wagon-movement.entity'; import { Train } from '../trains/entities/train.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service'; /** Wagon columns whose manual edits are diffed into a DETAILS_UPDATED history row. */ const TRACKED_DETAIL_FIELDS = [ 'wagonNumber', 'wagonTypeId', 'exportTrainNumber', 'importTrainNumber', 'notes', ] as const; @Injectable() export class WagonsService { constructor( @InjectRepository(Wagon) private readonly wagonRepo: Repository, @InjectRepository(Train) private readonly trainRepo: Repository, private readonly dataSource: DataSource, private readonly wagonHistory: WagonHistoryService, ) {} async create(dto: CreateWagonDto, userId?: string | null): Promise { const wagon = this.wagonRepo.create({ ...dto, status: dto.status ?? WagonStatus.Available, }); // Convert undefined to null for nullable fields if (dto.trainId === undefined) wagon.trainId = null; if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null; if (dto.currentYardId === undefined) wagon.currentYardId = null; if (dto.exportTrainNumber === undefined) wagon.exportTrainNumber = null; if (dto.importTrainNumber === undefined) wagon.importTrainNumber = null; const saved = await this.wagonRepo.save(wagon); await this.wagonHistory.record(null, { wagonId: saved.id, wagonNumber: saved.wagonNumber, type: WagonEventType.Registered, actorUserId: userId ?? null, toYardId: saved.currentYardId ?? null, trainId: saved.trainId ?? null, toValue: saved.status, metadata: { wagonTypeId: saved.wagonTypeId, exportTrainNumber: saved.exportTrainNumber ?? null, importTrainNumber: saved.importTrainNumber ?? null, }, }); return saved; } /** Shared filter/sort builder behind `findAll` (array) and `findAllPaged` (envelope). */ private buildListQuery(query: ListWagonsQueryDto): SelectQueryBuilder { const search = query.search?.trim(); const trainId = query.trainId?.trim(); const wagonTypeId = query.wagonTypeId?.trim(); const trainNumber = query.trainNumber?.trim(); // QueryBuilder (not find) because both search and the trainNumber filter span // two columns each (export/import run) — an OR that FindOptions cannot express // without cross-producting into conflicting branches. Soft-deleted rows are // still excluded automatically (BaseEntity's @DeleteDateColumn). const qb = this.wagonRepo .createQueryBuilder('w') .leftJoinAndSelect('w.currentYard', 'currentYard') .leftJoinAndSelect('w.wagonType', 'wagonType'); if (query.status) qb.andWhere('w.status = :status', { status: query.status }); if (query.currentYardId) qb.andWhere('w.currentYardId = :currentYardId', { currentYardId: query.currentYardId }); if (trainId) qb.andWhere('w.trainId = :trainId', { trainId }); // Pickers (train-builder, transfer fulfilment) can only take a wagon that is // not already coupled to a built train — never offer one the API will reject. if (query.unassigned) qb.andWhere('w.trainId IS NULL'); if (wagonTypeId) qb.andWhere('w.wagonTypeId = :wagonTypeId', { wagonTypeId }); // Filter by run: the odd export run identifies the pair, so match either // column — a wagon carries export on one, import on the other. if (trainNumber) { qb.andWhere( '(w.exportTrainNumber = :trainNumber OR w.importTrainNumber = :trainNumber)', { trainNumber }, ); } // Registration-day range, both ends inclusive (the UI picks whole days). if (query.createdFrom) { qb.andWhere('w.createdAt >= CAST(:createdFrom AS date)', { createdFrom: query.createdFrom, }); } if (query.createdTo) { qb.andWhere("w.createdAt < CAST(:createdTo AS date) + INTERVAL '1 day'", { createdTo: query.createdTo, }); } // Last-maintenance range, both ends inclusive. There's no column to // compare directly — "last maintenance" is the latest status-log flip to // MAINTENANCE (see attachStatusDates below), so this mirrors that same // MAX(...) FILTER(...) as a correlated subquery against the same table. if (query.maintenanceFrom) { qb.andWhere( `(SELECT MAX(l.created_at) FROM freight.wagon_status_logs l WHERE l.wagon_id = w.id AND l.to_status = '${WagonStatus.Maintenance}') >= CAST(:maintenanceFrom AS date)`, { maintenanceFrom: query.maintenanceFrom }, ); } if (query.maintenanceTo) { qb.andWhere( `(SELECT MAX(l.created_at) FROM freight.wagon_status_logs l WHERE l.wagon_id = w.id AND l.to_status = '${WagonStatus.Maintenance}') < CAST(:maintenanceTo AS date) + INTERVAL '1 day'`, { maintenanceTo: query.maintenanceTo }, ); } // Search matches the wagon number or either run number. if (search) { qb.andWhere( '(w.wagonNumber ILIKE :search OR w.exportTrainNumber ILIKE :search OR w.importTrainNumber ILIKE :search)', { search: `%${search}%` }, ); } // Spec columns (tare, payload) are no longer sortable here — they live on the // wagon type, so sorting by them is sorting by wagonTypeId. const sortable: Array = [ 'wagonNumber', 'status', 'currentYardId', 'sequenceNumber', 'wagonTypeId', ]; const sortBy = sortable.includes((query.sortBy ?? '') as keyof Wagon) ? (query.sortBy as keyof Wagon) : 'wagonNumber'; const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; qb.orderBy(`w.${sortBy}`, sortOrder); return qb; } /** * The wagon list is always a page. Callers that genuinely need every row * (yard workspace, coupling pickers) walk the pages client-side — see * `wagonService.listAll` in the backoffice. */ async findAll(query: ListWagonsQueryDto = {}): Promise> { const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 }); await this.attachStatusDates(page.items); await this.attachMovementStats(page.items, query.statsWindowDays ?? 90); return page; } /** * Latest status-flip dates from the audit log, for the wagons desk columns: * when the wagon last went to MAINTENANCE and when it last became AVAILABLE. * One grouped query per page; null when the log has no such flip. */ private async attachStatusDates(wagons: Wagon[]): Promise { if (!wagons.length) return; const rows: Array<{ wagonId: string; lastMaintenanceAt: Date | null; lastAvailableAt: Date | null; }> = await this.dataSource .getRepository(WagonStatusLog) .createQueryBuilder('l') .select('l.wagon_id', 'wagonId') .addSelect( `MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Maintenance}')`, 'lastMaintenanceAt', ) .addSelect( `MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Available}')`, 'lastAvailableAt', ) .where('l.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) }) .groupBy('l.wagon_id') .getRawMany(); const byId = new Map(rows.map((r) => [r.wagonId, r])); for (const w of wagons) { const r = byId.get(w.id); Object.assign(w, { lastMaintenanceAt: r?.lastMaintenanceAt ?? null, lastAvailableAt: r?.lastAvailableAt ?? null, }); } } /** * Per-wagon movement rollups for the wagon performance report: when the * wagon last arrived anywhere (the idle clock), and how many loaded / total * moves it made inside `windowDays`. One grouped query per page, in the same * shape as `attachStatusDates` above — never one request per row. */ private async attachMovementStats(wagons: Wagon[], windowDays: number): Promise { if (!wagons.length) return; const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000); const rows: Array<{ wagonId: string; lastMovedAt: Date | null; loadsInWindow: string; movesInWindow: string; emptyMovesInWindow: string; }> = await this.dataSource .getRepository(WagonMovement) .createQueryBuilder('m') .select('m.wagon_id', 'wagonId') .addSelect('MAX(m.occurred_at)', 'lastMovedAt') .addSelect( 'COUNT(*) FILTER (WHERE m.occurred_at >= :since AND m.kind = :loaded)', 'loadsInWindow', ) .addSelect( 'COUNT(*) FILTER (WHERE m.occurred_at >= :since AND m.kind = :empty)', 'emptyMovesInWindow', ) .addSelect('COUNT(*) FILTER (WHERE m.occurred_at >= :since)', 'movesInWindow') .where('m.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) }) .setParameters({ since, loaded: WagonMovementKind.Loaded, empty: WagonMovementKind.EmptyReposition, }) .groupBy('m.wagon_id') .getRawMany(); const byId = new Map(rows.map((r) => [r.wagonId, r])); for (const w of wagons) { const r = byId.get(w.id); Object.assign(w, { lastMovedAt: r?.lastMovedAt ?? null, loadsInWindow: Number(r?.loadsInWindow ?? 0), movesInWindow: Number(r?.movesInWindow ?? 0), emptyMovesInWindow: Number(r?.emptyMovesInWindow ?? 0), }); } } async findById(id: string): Promise { const wagon = await this.wagonRepo.findOne({ where: { id }, relations: { currentYard: true, wagonType: true }, }); if (!wagon) throw new NotFoundException(`Wagon ${id} not found`); return wagon; } async update(id: string, dto: UpdateWagonDto, userId?: string | null): Promise { const wagon = await this.findById(id); // A wagon coupled to a built train follows the train: its yard and status // are managed through the train-builder flow, not this generic PATCH. if (wagon.trainId != null) { if (dto.currentYardId !== undefined && dto.currentYardId !== wagon.currentYardId) { throw new ConflictException( `Wagon ${wagon.wagonNumber} is coupled to a built train; relocate the train (train-builder) instead of moving the wagon`, ); } if (dto.status !== undefined && dto.status !== wagon.status) { throw new ConflictException( `Wagon ${wagon.wagonNumber} is coupled to a built train; detach it via train-builder before changing its status`, ); } } const previousYardId = wagon.currentYardId ?? null; const previousStatus = wagon.status; const before = Object.fromEntries( TRACKED_DETAIL_FIELDS.map((f) => [f, (wagon as unknown as Record)[f] ?? null]), ); Object.assign(wagon, dto); // `findById` eager-loads `currentYard`; when the DTO changes the scalar FK // TypeORM otherwise re-derives `current_yard_id` from the STALE relation // object (the old yard) on save and silently reverts the change. Drop the // relation so the scalar `currentYardId` wins. if (dto.currentYardId !== undefined) { wagon.currentYard = null; } // Same trap for `wagonType`: the stale eager-loaded relation would win over // the new `wagonTypeId` and the type change would silently not persist. if (dto.wagonTypeId !== undefined) { wagon.wagonType = undefined; } await this.wagonRepo.save(wagon); // Staff manually relocated the wagon — write the movement ledger row so the // wagon's yard history stays auditable (who moved it, from where, when). if ( dto.currentYardId !== undefined && dto.currentYardId !== null && dto.currentYardId !== previousYardId ) { const movementRepo = this.dataSource.getRepository(WagonMovement); await movementRepo.save( movementRepo.create({ wagonId: id, fromYardId: previousYardId, toYardId: dto.currentYardId, kind: WagonMovementKind.Manual, movedByUserId: userId ?? null, occurredAt: new Date(), }), ); } // History: one row per kind of change — a yard move, a status flip, and // the remaining field edits as a single diff. const events: WagonEventInput[] = []; const changes: Record = {}; for (const f of TRACKED_DETAIL_FIELDS) { if (dto[f] === undefined) continue; const to = (wagon as unknown as Record)[f] ?? null; if (before[f] !== to) changes[f] = { from: before[f], to }; } if (Object.keys(changes).length) { events.push({ wagonId: id, wagonNumber: wagon.wagonNumber, type: WagonEventType.DetailsUpdated, actorUserId: userId ?? null, metadata: { changes }, }); } if (dto.currentYardId !== undefined && dto.currentYardId !== previousYardId) { events.push({ wagonId: id, wagonNumber: wagon.wagonNumber, type: WagonEventType.MovedManually, actorUserId: userId ?? null, fromYardId: previousYardId, toYardId: dto.currentYardId ?? null, reason: 'Wagon record edited', }); } if (dto.status !== undefined && dto.status !== previousStatus) { events.push({ wagonId: id, wagonNumber: wagon.wagonNumber, type: WagonEventType.StatusChanged, actorUserId: userId ?? null, fromValue: previousStatus, toValue: dto.status, reason: 'Wagon record edited', }); } await this.wagonHistory.record(null, events); // Re-read with the relation so the response reflects the new yard label // instead of the stale relation object loaded before the assign. return this.findById(id); } /** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */ async listMovements(wagonId: string): Promise { await this.findById(wagonId); // 404 on unknown wagon const movements = await this.dataSource.getRepository(WagonMovement).find({ where: { wagonId }, relations: { fromYard: true, toYard: true }, order: { occurredAt: 'DESC', createdAt: 'DESC' }, }); await this.attachBookingReferences(movements); return movements; } /** * Resolve each loaded move's booking to its human reference, so the UI can * show (and link to) "BKG-11284" rather than a raw uuid. One query for the * whole ledger; `wagon_movements` deliberately has no FK to bookings, so * this is a read-time join on primary keys, exactly like the labels in * `wagon-history.service`. */ private async attachBookingReferences(movements: WagonMovement[]): Promise { const ids = [...new Set(movements.map((m) => m.bookingId).filter((v): v is string => !!v))]; if (!ids.length) return; const rows: Array<{ id: string; reference: string }> = await this.dataSource.query( `SELECT id, reference FROM freight.bookings WHERE id = ANY($1::uuid[])`, [ids], ); const byId = new Map(rows.map((r) => [r.id, r.reference])); for (const m of movements) { Object.assign(m, { bookingReference: m.bookingId ? (byId.get(m.bookingId) ?? null) : null, }); } } async remove(id: string, userId?: string | null): Promise { const wagon = await this.findById(id); // A coupled wagon must be detached via train-builder before it can be // removed, so a built train never silently loses a wagon. if (wagon.trainId != null) { throw new ConflictException( `Wagon ${wagon.wagonNumber} is coupled to a built train; detach it via train-builder before deleting it`, ); } if (await this.isWagonPinnedToLiveSchedule(id)) { throw new ConflictException( `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be deleted`, ); } // Soft delete (deleted_at) — hard-deleting would strand ledger/schedule // history that references this wagon. await this.wagonRepo.softRemove(wagon); await this.wagonHistory.record(null, { wagonId: id, wagonNumber: wagon.wagonNumber, type: WagonEventType.Deleted, actorUserId: userId ?? null, fromYardId: wagon.currentYardId ?? null, fromValue: wagon.status, }); } /** * Permanently purge a wagon — irreversible, and only for rows that carry no * history: a mistyped or duplicated entry someone wants gone for good. * * `wagon_movements` cascades on delete, so a wagon with movements would take * its ledger history down with it. Rather than allow that, every reference is * checked first and the purge is refused if any exist — soft delete (`remove`) * stays the answer for a wagon that has actually been used. * * Soft-deleted wagons are purgeable, so `withDeleted` is used to find them. */ async purge(id: string, userId?: string | null): Promise { const wagon = await this.wagonRepo.findOne({ where: { id }, withDeleted: true, }); if (!wagon) { throw new NotFoundException(`Wagon ${id} not found`); } if (wagon.trainId != null) { throw new ConflictException( `Wagon ${wagon.wagonNumber} is coupled to a train; detach it via train-builder before deleting it permanently`, ); } if (await this.isWagonPinnedToLiveSchedule(id)) { throw new ConflictException( `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be deleted permanently`, ); } // Each of these would either lose history (movements cascade) or silently // blank a live reference (containers / train-set slots are SET NULL). const blockers: string[] = []; const [movements, containers, trainSetSlots] = await Promise.all([ this.dataSource.query( `SELECT count(*)::int AS count FROM freight.wagon_movements WHERE wagon_id = $1`, [id], ), this.dataSource.query( `SELECT count(*)::int AS count FROM freight.containers WHERE wagon_id = $1`, [id], ), this.dataSource.query( `SELECT count(*)::int AS count FROM freight.train_set_wagons WHERE physical_wagon_id = $1`, [id], ), ]); if (movements[0]?.count > 0) { blockers.push(`${movements[0].count} movement record(s)`); } if (containers[0]?.count > 0) { blockers.push(`${containers[0].count} container(s)`); } if (trainSetSlots[0]?.count > 0) { blockers.push(`${trainSetSlots[0].count} train-set slot(s)`); } if (blockers.length > 0) { throw new ConflictException( `Wagon ${wagon.wagonNumber} cannot be permanently deleted — it still has ${blockers.join( ', ', )}. Delete it normally instead, which keeps the history intact.`, ); } // Recorded BEFORE the row goes: wagon_events has no FK, so the history of // a purged wagon survives under its id and number snapshot. await this.wagonHistory.record(null, { wagonId: id, wagonNumber: wagon.wagonNumber, type: WagonEventType.Purged, actorUserId: userId ?? null, fromYardId: wagon.currentYardId ?? null, fromValue: wagon.status, }); await this.wagonRepo.remove(wagon); } /** * A wagon is busy when any live (DRAFT/SCHEDULED/DISPATCHED) schedule pins it * to one of its slots — schedule occupancy lives on TrainSetWagon rows, not * on the Wagon entity. Mirrors TrainBuilderService.isWagonPinnedToLiveSchedule. */ private async isWagonPinnedToLiveSchedule(wagonId: string): Promise { const rows: { exists: boolean }[] = await this.dataSource.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; } async assignToTrain( wagonId: string, dto: AssignWagonToTrainDto, userId?: string | null, ): Promise { const wagon = await this.findById(wagonId); // Mirror train-builder attachWagons: only a truly free, available wagon // (any yard) can be coupled, and never onto a dispatched train. if (wagon.trainId != null) { 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})`); } const train = await this.trainRepo.findOne({ where: { id: dto.trainId } }); if (!train) throw new NotFoundException('Train 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`, ); } const maxSeq = await this.wagonRepo .createQueryBuilder('w') .select('MAX(w.sequenceNumber)', 'max') .where('w.trainId = :trainId', { trainId: train.id }) .getRawOne(); const nextSequence = Number(maxSeq?.max ?? 0) + 1; // An explicit sequence is only honoured when it is the next free slot; // anything else would duplicate a slot or leave a gap. if (dto.sequenceNumber != null && dto.sequenceNumber !== nextSequence) { throw new BadRequestException( `Sequence ${dto.sequenceNumber} is not the next free slot (${nextSequence}) for train ${train.code}`, ); } const previousStatus = wagon.status; wagon.trainId = train.id; wagon.sequenceNumber = nextSequence; wagon.status = WagonStatus.Assigned; const saved = await this.wagonRepo.save(wagon); await this.wagonHistory.record(null, { wagonId: wagon.id, wagonNumber: wagon.wagonNumber, type: WagonEventType.CoupledToTrain, actorUserId: userId ?? null, trainId: train.id, fromYardId: wagon.currentYardId ?? null, toValue: nextSequence, metadata: { status: { from: previousStatus, to: WagonStatus.Assigned }, trainCode: train.code }, }); return saved; } async unassignFromTrain(wagonId: string, userId?: string | null): Promise { const wagon = await this.findById(wagonId); // A wagon pinned to a live schedule is still operationally committed even // if the fleet train is being edited — don't free it out from under it. if (await this.isWagonPinnedToLiveSchedule(wagonId)) { throw new ConflictException( `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be detached`, ); } const previousTrainId = wagon.trainId; const previousSequence = wagon.sequenceNumber; const previousStatus = wagon.status; wagon.trainId = null; wagon.sequenceNumber = null; wagon.status = WagonStatus.Available; const saved = await this.wagonRepo.save(wagon); await this.wagonHistory.record(null, { wagonId: wagon.id, wagonNumber: wagon.wagonNumber, type: WagonEventType.UncoupledFromTrain, actorUserId: userId ?? null, trainId: previousTrainId, fromYardId: wagon.currentYardId ?? null, fromValue: previousSequence, metadata: { status: { from: previousStatus, to: WagonStatus.Available } }, }); return saved; } /** * Relocate many wagons to one destination yard in a single transaction. Each * wagon whose yard actually changes gets a `wagon_movements` ledger row (kind * `Manual`) so the yard history stays auditable — mirrors the single-wagon * `update` path. Wagons already in the destination yard are skipped. */ async bulkTransfer( dto: BulkTransferWagonsDto, userId?: string | null, opts?: { transferRequestId?: string | null }, ): Promise<{ moved: number }> { const { wagonIds, toYardId } = dto; if (!wagonIds.length) return { moved: 0 }; const yard = await this.dataSource .getRepository(Yard) .findOne({ where: { id: toYardId } }); if (!yard) throw new NotFoundException('Destination yard not found'); const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { const wagons = await queryRunner.manager.find(Wagon, { where: { id: In(wagonIds) }, }); if (wagons.length !== wagonIds.length) { throw new NotFoundException('One or more wagons not found'); } // Only free, available wagons can be bulk-relocated; a coupled wagon // moves with its train (train-builder), never on its own here. const blocked = wagons.filter( (w) => w.trainId != null || w.status !== WagonStatus.Available, ); if (blocked.length) { throw new ConflictException( `Cannot transfer wagons coupled to a train or not available: ${blocked .map((w) => w.wagonNumber) .join(', ')}`, ); } let moved = 0; const events: WagonEventInput[] = []; for (const wagon of wagons) { const previousYardId = wagon.currentYardId ?? null; if (previousYardId === toYardId) continue; events.push({ wagonId: wagon.id, wagonNumber: wagon.wagonNumber, type: WagonEventType.MovedManually, actorUserId: userId ?? null, fromYardId: previousYardId, toYardId, reason: opts?.transferRequestId ? 'Transfer request fulfilled' : 'Bulk transfer', metadata: opts?.transferRequestId ? { transferRequestId: opts.transferRequestId } : null, }); wagon.currentYardId = toYardId; // Drop the eager relation so the scalar FK wins on save (see `update`). wagon.currentYard = null; await queryRunner.manager.save(Wagon, wagon); await queryRunner.manager.save( queryRunner.manager.create(WagonMovement, { wagonId: wagon.id, fromYardId: previousYardId, toYardId, kind: WagonMovementKind.Manual, movedByUserId: userId ?? null, transferRequestId: opts?.transferRequestId ?? null, occurredAt: new Date(), }), ); moved++; } await this.wagonHistory.record(queryRunner.manager, events); await queryRunner.commitTransaction(); return { moved }; } catch (err) { await queryRunner.rollbackTransaction(); throw err; } finally { await queryRunner.release(); } } /** * Set the same status on many wagons in one transaction (e.g. flip a batch * from Available to Assigned in the yard workspace). Only the `status` column * is touched — train assignment is managed through the assign/unassign flow. */ async bulkSetStatus( dto: BulkSetWagonStatusDto, changedByUserId?: string, ): Promise<{ updated: number }> { const { wagonIds, status } = dto; if (!wagonIds.length) return { updated: 0 }; const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { const wagons = await queryRunner.manager.find(Wagon, { where: { id: In(wagonIds) }, }); if (wagons.length !== wagonIds.length) { throw new NotFoundException('One or more wagons not found'); } // A coupled wagon's status is owned by the train-builder flow — refuse to // flip status on any wagon that is currently on a built train. const coupled = wagons.filter((w) => w.trainId != null); if (coupled.length) { throw new ConflictException( `Cannot change status of wagons coupled to a built train: ${coupled .map((w) => w.wagonNumber) .join(', ')}. Detach them via train-builder first.`, ); } // Audit trail rides the same transaction — a status flip without its // history row can't happen. No-change wagons write no log row. const logs = wagons .filter((w) => w.status !== status) .map((w) => queryRunner.manager.create(WagonStatusLog, { wagonId: w.id, fromStatus: w.status, toStatus: status, changedByUserId: changedByUserId ?? null, note: dto.note ?? null, }), ); for (const wagon of wagons) { wagon.status = status; } await queryRunner.manager.save(Wagon, wagons); if (logs.length) await queryRunner.manager.save(WagonStatusLog, logs); await this.wagonHistory.record( queryRunner.manager, logs.map((l) => ({ wagonId: l.wagonId, wagonNumber: wagons.find((w) => w.id === l.wagonId)?.wagonNumber ?? null, type: WagonEventType.StatusChanged, actorUserId: changedByUserId ?? null, fromValue: l.fromStatus, toValue: l.toStatus, reason: dto.note ?? null, })), ); await queryRunner.commitTransaction(); return { updated: wagons.length }; } catch (err) { await queryRunner.rollbackTransaction(); throw err; } finally { await queryRunner.release(); } } /** Status-flip history of one wagon, newest first (maintenance/availability audit). */ async statusHistory(wagonId: string): Promise { return this.dataSource.getRepository(WagonStatusLog).find({ where: { wagonId }, order: { createdAt: 'DESC' }, take: 100, }); } }