mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +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.
273 lines
9.0 KiB
TypeScript
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;
|
|
}
|
|
}
|