Files
edr-platform/apps/edr-freight-api/src/modules/routes/routes.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

273 lines
9.0 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { TrainScheduleStatus } from '@edr/types';
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';
import { UpdateRouteDto } from './dto/update-route.dto';
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(
private readonly dataSource: DataSource,
private readonly routesRepository: RoutesRepository,
) {}
async findAll(filter: FilterRoutesDto): Promise<Route[]> {
const routes = await this.routesRepository.findAll({
where: {
...(filter.status ? { status: filter.status } : {}),
},
relations: {
originYard: true,
destinationYard: true,
milestones: { yard: true },
},
order: {
milestones: { sequenceNo: 'ASC' },
},
});
const sorted = [...routes].sort((a, b) =>
formatRouteLabel(a).localeCompare(formatRouteLabel(b)),
);
const query = filter.search?.trim().toLowerCase();
if (!query) return sorted;
return sorted.filter((route) => {
const haystack = [
formatRouteLabel(route),
route.originYard?.label,
route.originYard?.code,
route.destinationYard?.label,
route.destinationYard?.code,
...(route.milestones ?? []).map(
(m) => m.yard?.label ?? m.yard?.code ?? m.yardId,
),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
}
async findById(id: string): Promise<Route> {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id },
relations: {
originYard: true,
destinationYard: true,
milestones: { yard: true },
},
order: { milestones: { sequenceNo: 'ASC' } },
});
if (!route) {
throw new NotFoundException(`Route ${id} not found`);
}
return route;
}
async create(dto: CreateRouteDto): Promise<Route> {
const validated = await this.validateMilestones(dto.milestones);
const route = await this.dataSource.transaction(async (manager) => {
const savedRoute = await manager.getRepository(Route).save(
manager.getRepository(Route).create({
originYardId: validated.originYardId,
destinationYardId: validated.destinationYardId,
status: dto.status ?? 'AVAILABLE',
direction: validated.direction,
}),
);
await manager.getRepository(RouteMilestone).save(
validated.milestones.map((milestone) =>
manager.getRepository(RouteMilestone).create({
routeId: savedRoute.id,
yardId: milestone.yardId,
sequenceNo: milestone.sequenceNo,
distanceKm: milestone.distanceKm,
}),
),
);
return savedRoute;
});
return this.findById(route.id);
}
async update(id: string, dto: UpdateRouteDto): Promise<Route> {
const existing = await this.findById(id);
const milestoneInput = dto.milestones
? await this.validateMilestones(dto.milestones)
: null;
// Milestones or endpoints are about to be rewritten — reject if any
// non-terminal schedule still references this route, otherwise its stop list
// and distances would silently shift under a live plan. Status-only /
// label-only edits (no milestones supplied) are always allowed.
if (milestoneInput) {
const activeSchedules = await this.dataSource
.getRepository(TrainSchedule)
.count({
where: {
routeId: id,
status: In([
TrainScheduleStatus.Draft,
TrainScheduleStatus.Scheduled,
TrainScheduleStatus.Dispatched,
]),
},
});
if (activeSchedules > 0) {
throw new ConflictException(
'This route is used by active train schedules and its stops cannot be changed. Create a new route instead.',
);
}
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(Route).update(id, {
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
destinationYardId:
milestoneInput?.destinationYardId ?? existing.destinationYardId,
...(milestoneInput ? { direction: milestoneInput.direction } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
});
if (milestoneInput) {
await manager.getRepository(RouteMilestone).delete({ routeId: id });
await manager.getRepository(RouteMilestone).save(
milestoneInput.milestones.map((milestone) =>
manager.getRepository(RouteMilestone).create({
routeId: id,
yardId: milestone.yardId,
sequenceNo: milestone.sequenceNo,
distanceKm: milestone.distanceKm,
}),
),
);
}
});
return this.findById(id);
}
async deactivate(id: string): Promise<Route> {
await this.findById(id);
const updated = await this.routesRepository.update(id, {
status: 'STOP_WORKING',
} as never);
if (!updated) {
throw new NotFoundException(`Route ${id} not found`);
}
return this.findById(id);
}
private async validateMilestones(milestones: Array<{ yardId: string }>) {
if (milestones.length < 2) {
throw new BadRequestException('A route requires at least two yards');
}
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 milestones) {
if (!yardIds.has(milestone.yardId)) {
throw new BadRequestException(`Yard ${milestone.yardId} does not exist`);
}
}
if (milestones[0].yardId === milestones[milestones.length - 1].yardId) {
throw new BadRequestException('Origin and destination yards must be different');
}
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 },
);
return {
originYardId,
destinationYardId,
direction,
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;
}
}