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

@@ -4,25 +4,22 @@ import {
ArrayMinSize,
IsArray,
IsEnum,
IsNumber,
IsOptional,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
import { RouteStatus } from '../entities/route.entity';
/**
* Segment distances are no longer part of the payload — they are resolved
* from the configured yard_distances table (Configuration → Yard Distances)
* and snapshotted onto route_milestones at create/update.
*/
export class CreateRouteMilestoneDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
@ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' })
@IsOptional()
@IsNumber()
@Min(0)
distanceKm?: number;
}
export class CreateRouteDto {

View File

@@ -9,6 +9,7 @@ import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { YardDistance } from '../rule-engine/entities/yard-distance.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
@@ -17,6 +18,9 @@ import { RouteMilestone } from './entities/route-milestone.entity';
import { formatRouteLabel, Route } from './entities/route.entity';
import { RoutesRepository } from './routes.repository';
/** Order-insensitive key: distances are symmetric. */
const pairKey = (a: string, b: string): string => (a < b ? `${a}|${b}` : `${b}|${a}`);
@Injectable()
export class RoutesService {
constructor(
@@ -183,47 +187,63 @@ export class RoutesService {
return this.findById(id);
}
private async validateMilestones(
milestones: Array<{ yardId: string; distanceKm?: number }>,
) {
private async validateMilestones(milestones: Array<{ yardId: string }>) {
if (milestones.length < 2) {
throw new BadRequestException('A route requires at least two yards');
}
const normalized = milestones.map((milestone, index) => {
const distanceKm =
index === 0 ? 0 : milestone.distanceKm != null ? milestone.distanceKm : null;
if (index > 0 && (distanceKm == null || distanceKm < 0)) {
throw new BadRequestException(
`Enter segment KM for stop ${index + 1} (from previous yard).`,
);
}
return {
yardId: milestone.yardId,
sequenceNo: index + 1,
distanceKm,
};
});
const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))];
const uniqueYardIds = [...new Set(milestones.map((milestone) => milestone.yardId))];
const yards = await this.dataSource
.getRepository(Yard)
.find({ where: uniqueYardIds.map((id) => ({ id })) });
const yardIds = new Set(yards.map((yard) => yard.id));
for (const milestone of normalized) {
for (const milestone of milestones) {
if (!yardIds.has(milestone.yardId)) {
throw new BadRequestException(`Yard ${milestone.yardId} does not exist`);
}
}
if (normalized[0].yardId === normalized[normalized.length - 1].yardId) {
if (milestones[0].yardId === milestones[milestones.length - 1].yardId) {
throw new BadRequestException('Origin and destination yards must be different');
}
const originYardId = normalized[0].yardId;
const destinationYardId = normalized[normalized.length - 1].yardId;
const yardById = new Map(yards.map((yard) => [yard.id, yard]));
const distanceByPair = await this.loadDistanceLookup(uniqueYardIds);
// Segment km come from the configured yard-distance table, not the payload
// — a route can only be built over pairs an admin has entered. Distances
// are symmetric, so an A→B row also serves B→A.
const missingPairs: string[] = [];
const normalized = milestones.map((milestone, index) => {
if (index === 0) {
return { yardId: milestone.yardId, sequenceNo: 1, distanceKm: 0 };
}
const previousYardId = milestones[index - 1].yardId;
const distanceKm = distanceByPair.get(pairKey(previousYardId, milestone.yardId));
if (distanceKm == null) {
const from = yardById.get(previousYardId);
const to = yardById.get(milestone.yardId);
missingPairs.push(
`${from?.label ?? previousYardId}${to?.label ?? milestone.yardId}`,
);
}
return {
yardId: milestone.yardId,
sequenceNo: index + 1,
distanceKm: distanceKm ?? null,
};
});
if (missingPairs.length > 0) {
throw new BadRequestException(
`No distance configured for: ${missingPairs.join(', ')}. ` +
'Add the missing yard distances in Configuration → Yard Distances first.',
);
}
const originYardId = milestones[0].yardId;
const destinationYardId = milestones[milestones.length - 1].yardId;
const direction = deriveTradeDirection(
yardById.get(originYardId) ?? { country: null },
yardById.get(destinationYardId) ?? { country: null },
@@ -236,4 +256,17 @@ export class RoutesService {
milestones: normalized,
};
}
/** Order-insensitive pair → km map over every configured distance touching the yards. */
private async loadDistanceLookup(yardIds: string[]): Promise<Map<string, number>> {
const rows = await this.dataSource
.getRepository(YardDistance)
.find({ where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }] });
const lookup = new Map<string, number>();
for (const row of rows) {
lookup.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm));
}
return lookup;
}
}