import { Freight, WagonMovementKind, WagonStatus } from '@edr/types'; import { BadRequestException, Injectable, NotFoundException, ConflictException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm'; 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 { ReorderWagonsDto } from './dto/reorder-wagons.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 { WagonMovement } from './entities/wagon-movement.entity'; import { Train } from '../trains/entities/train.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; @Injectable() export class WagonsService { constructor( @InjectRepository(Wagon) private readonly wagonRepo: Repository, @InjectRepository(Train) private readonly trainRepo: Repository, private readonly dataSource: DataSource, ) {} async create(dto: CreateWagonDto): 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; return this.wagonRepo.save(wagon); } async findAll(query: ListWagonsQueryDto = {}): Promise { const where: FindOptionsWhere[] | FindOptionsWhere = []; const search = query.search?.trim(); const trainId = query.trainId?.trim(); const wagonTypeId = query.wagonTypeId?.trim(); const filters: FindOptionsWhere = { ...(query.status ? { status: query.status } : {}), ...(query.currentYardId ? { currentYardId: query.currentYardId } : {}), ...(trainId ? { trainId } : {}), ...(wagonTypeId ? { wagonTypeId } : {}), }; if (search) { where.push({ wagonNumber: ILike(`%${search}%`), ...filters, }); } // 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'; return this.wagonRepo.find({ where: search ? where : filters, relations: { currentYard: true, wagonType: true }, order: { [sortBy]: sortOrder } as FindOptionsOrder, skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, take: query.limit ? Number(query.limit) : undefined, }); } 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; 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; } 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(), }), ); } // 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 return this.dataSource.getRepository(WagonMovement).find({ where: { wagonId }, relations: { fromYard: true, toYard: true }, order: { occurredAt: 'DESC', createdAt: 'DESC' }, }); } async remove(id: string): 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); } /** * 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): Promise { const wagon = await this.findById(wagonId); // Mirror train-builder attachWagons: only a truly free, available wagon in // the train's own 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`, ); } if (wagon.currentYardId !== train.currentYardId) { throw new BadRequestException( `Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`, ); } 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}`, ); } wagon.trainId = train.id; wagon.sequenceNumber = nextSequence; wagon.status = WagonStatus.Assigned; return this.wagonRepo.save(wagon); } async unassignFromTrain(wagonId: string): 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`, ); } wagon.trainId = null; wagon.sequenceNumber = null; wagon.status = WagonStatus.Available; return this.wagonRepo.save(wagon); } /** * 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; for (const wagon of wagons) { const previousYardId = wagon.currentYardId ?? null; if (previousYardId === toYardId) continue; 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 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): 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.`, ); } for (const wagon of wagons) { wagon.status = status; } await queryRunner.manager.save(Wagon, wagons); await queryRunner.commitTransaction(); return { updated: wagons.length }; } catch (err) { await queryRunner.rollbackTransaction(); throw err; } finally { await queryRunner.release(); } } async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { for (let i = 0; i < dto.wagonIds.length; i++) { await queryRunner.manager.update(Wagon, dto.wagonIds[i], { sequenceNumber: i + 1 }); } await queryRunner.commitTransaction(); } catch (err) { await queryRunner.rollbackTransaction(); throw err; } finally { await queryRunner.release(); } } }