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.
This commit is contained in:
Marshal
2026-07-21 08:50:50 +00:00
parent 1647681840
commit 603537a20b
45 changed files with 1275 additions and 227 deletions

View File

@@ -1,5 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsBoolean, IsIn, IsOptional, IsUUID } from 'class-validator';
import {
LOCOMOTIVE_STATUSES,
@@ -21,4 +22,29 @@ export class FilterLocomotivesDto {
@IsOptional()
@IsUUID()
currentYardId?: string;
/**
* Drop locomotives already coupled to a built train — the train-builder
* "change locomotives" picker uses this so a loco that belongs to another
* train is never offered (the backend would 409 on save anyway). Combine with
* `excludeTrainId` to keep the CURRENT train's own locos in the list.
*/
@ApiPropertyOptional({
description: 'Exclude locomotives already coupled to any built train',
})
@IsOptional()
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
excludeCoupled?: boolean;
/**
* When `excludeCoupled` is set, locos coupled to THIS train are still kept
* (they are valid picks — you are editing that train's consist).
*/
@ApiPropertyOptional({
description: 'Train id whose own coupled locomotives are NOT excluded',
})
@IsOptional()
@IsUUID()
excludeTrainId?: string;
}

View File

@@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Locomotive } from './entities/locomotive.entity';
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
import { TrainLocomotive } from '../trains/entities/train-locomotive.entity';
@Injectable()
export class LocomotivesRepository extends BaseRepository<Locomotive> {
@@ -14,6 +15,47 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
super(repository);
}
/**
* List locomotives for the train-builder coupling picker: the usual
* status/type/yard filters, plus optional exclusion of any loco already
* coupled to a built train. `keepTrainId` spares that one train's own locos
* from the exclusion so they stay selectable while editing its consist.
*/
findForCoupling(opts: {
status?: LocomotiveStatus;
locomotiveType?: LocomotiveType;
currentYardId?: string;
excludeCoupled?: boolean;
keepTrainId?: string;
}): Promise<Locomotive[]> {
const qb = this.repository
.createQueryBuilder('locomotive')
.leftJoinAndSelect('locomotive.currentYard', 'currentYard')
.orderBy('locomotive.code', 'ASC');
if (opts.status) qb.andWhere('locomotive.status = :status', { status: opts.status });
if (opts.locomotiveType)
qb.andWhere('locomotive.locomotiveType = :type', { type: opts.locomotiveType });
if (opts.currentYardId)
qb.andWhere('locomotive.currentYardId = :yardId', { yardId: opts.currentYardId });
if (opts.excludeCoupled) {
// NOT EXISTS a link to a DIFFERENT train. Own-train links are kept so the
// consist being edited still lists its current locomotives.
const sub = this.repository.manager
.getRepository(TrainLocomotive)
.createQueryBuilder('tl')
.select('1')
.where('tl.locomotiveId = locomotive.id');
if (opts.keepTrainId) {
sub.andWhere('tl.trainId != :keepTrainId', { keepTrainId: opts.keepTrainId });
}
qb.andWhere(`NOT EXISTS (${sub.getQuery()})`).setParameters(sub.getParameters());
}
return qb.getMany();
}
/**
* A live locomotive already holding this name, compared the same way the
* `UQ_locomotives_name_active` index compares: case- and whitespace-

View File

@@ -29,6 +29,17 @@ export class LocomotivesService {
}
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 } : {}),