import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { CreateLocomotiveDto } from './dto/create-locomotive.dto'; import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; import { Locomotive, type LocomotiveStatus, type LocomotiveType, } from './entities/locomotive.entity'; import { TrainLocomotive } from '../trains/entities/train-locomotive.entity'; import { LocomotivesRepository } from './locomotives.repository'; @Injectable() export class LocomotivesService { constructor( private readonly locomotivesRepository: LocomotivesRepository, private readonly dataSource: DataSource, ) {} /** The built-train link (if any) coupling this locomotive to a fleet train. */ private findTrainLink(locomotiveId: string): Promise { return this.dataSource.getRepository(TrainLocomotive).findOne({ where: { locomotiveId }, relations: { train: true }, }); } findAll(filter: FilterLocomotivesDto): Promise { // The coupling picker needs a NOT-EXISTS against the train link table, so it // takes the query-builder path; the plain list keeps the simple where. if (filter.excludeCoupled) { return this.locomotivesRepository.findForCoupling({ status: filter.status as LocomotiveStatus | undefined, locomotiveType: filter.locomotiveType as LocomotiveType | undefined, currentYardId: filter.currentYardId, excludeCoupled: true, keepTrainId: filter.excludeTrainId, }); } return this.locomotivesRepository.findAll({ where: { ...(filter.status ? { status: filter.status as LocomotiveStatus } : {}), ...(filter.locomotiveType ? { locomotiveType: filter.locomotiveType as LocomotiveType } : {}), ...(filter.currentYardId ? { currentYardId: filter.currentYardId } : {}), }, relations: { currentYard: true }, order: { code: 'ASC' }, }); } /** Default max pull weight (tons) applied when the caller omits it. */ private static readonly DEFAULT_MAX_PULL_WEIGHT_TONS = 2500; /** * Generate the next sequential locomotive code (LOCO-001, LOCO-002, …) by * scanning the highest existing LOCO-NNN number. Used when the caller does not * supply a code. */ private async generateCode(): Promise { const all = await this.locomotivesRepository.findAll({}); let max = 0; for (const loco of all) { const match = /^LOCO-(\d+)$/.exec(loco.code ?? ''); if (match) max = Math.max(max, Number(match[1])); } return `LOCO-${String(max + 1).padStart(3, '0')}`; } /** * Reject a name already worn by another live locomotive. Compared * case-insensitively on the trimmed value so this matches the DB index * `UQ_locomotives_name_active` — otherwise a clash the guard waved through * would surface as a raw 500 from the index instead of a 409. `excludeId` * lets an update keep its own name. */ private async assertNameAvailable(name: string, excludeId?: string): Promise { const clash = await this.locomotivesRepository.findByName(name, excludeId); if (clash) { throw new ConflictException( `Locomotive name "${name.trim()}" is already used by ${clash.code}`, ); } } async create(dto: CreateLocomotiveDto): Promise { const code = dto.code?.trim() || (await this.generateCode()); const [existing] = await this.locomotivesRepository.findAll({ where: { code } }); if (existing) { throw new ConflictException(`Locomotive code ${code} already exists`); } // Name stays optional; only a non-blank one has to be unique. const name = dto.name?.trim() || null; if (name) { await this.assertNameAvailable(name); } return this.locomotivesRepository.create({ code, name, locomotiveType: dto.locomotiveType as LocomotiveType, status: dto.status as LocomotiveStatus, currentYardId: dto.currentYardId ?? null, maxPullWeightTons: dto.maxPullWeightTons ?? LocomotivesService.DEFAULT_MAX_PULL_WEIGHT_TONS, maxTrainLengthMeters: dto.maxTrainLengthMeters, overageToleranceTons: dto.overageToleranceTons ?? null, overageToleranceMeters: dto.overageToleranceMeters ?? null, powerKw: dto.powerKw ?? null, tractionForceKn: dto.tractionForceKn ?? null, maxSpeedKmh: dto.maxSpeedKmh ?? null, }); } async findById(id: string): Promise { const locomotive = await this.locomotivesRepository.findById(id, { relations: { currentYard: true }, }); if (!locomotive) { throw new NotFoundException(`Locomotive ${id} not found`); } return locomotive; } async update(id: string, dto: UpdateLocomotiveDto): Promise { const locomotive = await this.findById(id); if (dto.code && dto.code !== locomotive.code) { const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } }); if (existing && existing.id !== id) { throw new ConflictException(`Locomotive code ${dto.code} already exists`); } } // Only when the caller actually sends a name — an omitted field keeps the // current one, and clearing it to blank is allowed. if (dto.name !== undefined) { const nextName = dto.name?.trim() || null; if (nextName) { await this.assertNameAvailable(nextName, id); } } // A locomotive coupled to a built train follows the train: its yard and // status are owned by the train-builder flow, not this generic PATCH. const link = await this.findTrainLink(id); if (link) { if (dto.currentYardId !== undefined && dto.currentYardId !== link.train?.currentYardId) { throw new ConflictException( `Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; move the train (train-builder yard change) instead`, ); } if (dto.status !== undefined && dto.status !== locomotive.status) { throw new ConflictException( `Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; detach it before changing its status`, ); } } const updated = await this.locomotivesRepository.update(id, { ...dto, locomotiveType: dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType, status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus, currentYardId: dto.currentYardId === undefined ? locomotive.currentYardId : (dto.currentYardId ?? null), name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null, powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null, tractionForceKn: dto.tractionForceKn === undefined ? locomotive.tractionForceKn : dto.tractionForceKn ?? null, maxSpeedKmh: dto.maxSpeedKmh === undefined ? locomotive.maxSpeedKmh : dto.maxSpeedKmh ?? null, }); if (!updated) { throw new NotFoundException(`Locomotive ${id} not found`); } return updated; } async decommission(id: string): Promise { const locomotive = await this.findById(id); // Can't retire a locomotive that is still coupled to a built train — detach // it in the train-builder first so the train never loses a live loco. const link = await this.findTrainLink(id); if (link) { throw new ConflictException( `Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; detach it before taking it out of service`, ); } const updated = await this.locomotivesRepository.update(id, { status: 'OUT_OF_SERVICE', }); if (!updated) { throw new NotFoundException(`Locomotive ${id} not found`); } return updated; } }