import { Freight, PaginatedResponse, 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 { 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); } /** 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, }); } // 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. */ findAll(query: ListWagonsQueryDto = {}): Promise> { return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 }); } 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(); } } }