import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { PaginatedResponse, TrainScheduleStatus } from '@edr/types'; import { DataSource, In, Not } from 'typeorm'; import { paginateArray } from '../../common/utils/pagination.util'; 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, type RouteStatus } 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 { 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); }); } /** * `findAll` on the shared `{items, meta}` envelope. * * ponytail: slices in memory — the corridor table is small (tens of rows) and * both the ordering (formatted "A → B → C" label) and the search span the * milestone collection, which a single SQL page window cannot express. Move to * a query builder if routes ever grow past a few hundred. */ async findAllPaged(filter: FilterRoutesDto): Promise> { return paginateArray(await this.findAll(filter), filter); } async findById(id: string): Promise { 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 { const validated = await this.validateMilestones(dto.milestones); await this.assertNotDuplicate(validated.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 { const existing = await this.findById(id); const milestoneInput = dto.milestones ? await this.validateMilestones(dto.milestones) : null; // An edit can collide with another route just as easily as a create can. if (milestoneInput) { await this.assertNotDuplicate(milestoneInput.milestones, id); } // 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 { 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); } /** * A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and * "Addis → Dire Dawa" share endpoints but are different corridors. So the * duplicate test compares the full yard sequence, not just origin/destination. * * Decommissioned routes (STOP_WORKING) are ignored: replacing a retired * corridor with a fresh one is exactly what an admin does after deactivating, * and there is no reactivate action to fall back on. */ private async assertNotDuplicate( milestones: Array<{ yardId: string }>, excludeRouteId?: string, ): Promise { const signature = milestones.map((m) => m.yardId).join('>'); const candidates = await this.dataSource.getRepository(Route).find({ where: { originYardId: milestones[0].yardId, destinationYardId: milestones[milestones.length - 1].yardId, status: Not('STOP_WORKING'), }, relations: { originYard: true, destinationYard: true, milestones: { yard: true }, }, }); const duplicate = candidates.find((route) => { if (route.id === excludeRouteId) return false; const stops = [...(route.milestones ?? [])] .sort((a, b) => a.sequenceNo - b.sequenceNo) .map((m) => m.yardId) .join('>'); return stops === signature; }); if (duplicate) { throw new ConflictException( `This route already exists: ${formatRouteLabel(duplicate)}. ` + 'Edit the existing route instead of creating a duplicate.', ); } } 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> { const rows = await this.dataSource .getRepository(YardDistance) .find({ where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }] }); const lookup = new Map(); for (const row of rows) { lookup.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm)); } return lookup; } }