mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
- 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.
216 lines
7.9 KiB
TypeScript
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;
|
|
}
|
|
}
|