Files
edr-platform/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts
Marshal 603537a20b add yard distances management to rule engine
- Introduced new yard distances resource with CRUD operations.
- Created migration for yard distances table with necessary constraints.
- Implemented service and repository for yard distances handling.
- Added controller for API endpoints to manage yard distances.
- Updated rule engine configuration to include yard distances.
- Enhanced rule engine resource page to support yard distance selection.
- Updated contracts and train builder pages to handle new yard distance logic.
- Added error handling utility for better error message extraction.
2026-07-21 08:50:50 +00:00

216 lines
7.9 KiB
TypeScript

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<TrainLocomotive | null> {
return this.dataSource.getRepository(TrainLocomotive).findOne({
where: { locomotiveId },
relations: { train: true },
});
}
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
// 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<string> {
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<void> {
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<Locomotive> {
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<Locomotive> {
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<Locomotive> {
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<Locomotive> {
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;
}
}