import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; import { parseEthiopianTime } from '../../common/utils/timezone.utils'; @Injectable() export class RoutesService { constructor(private prisma: PrismaService, private auditService: AuditService) {} // ── Route CRUD ───────────────────────────────────────────────────────────── /** * distanceKm is CUMULATIVE distance from the route origin, not distance from the previous * stop (that's what travelMinutesToStop is for) — fare pricing computes a segment's distance * as destStop.distanceKm - originStop.distanceKm, so a route with equal or decreasing values * across stops silently produces zero/negative segment distances, which the fare engine * rejects (caught and swallowed by search into a bare "N/A" instead of a visible error). Catch * the mistake here instead, with a message that names the exact stops involved. */ private validateStopDistances(stops: { sequence: number; stationId: string; distanceKm?: number | null }[]): void { const sorted = [...stops].sort((a, b) => a.sequence - b.sequence); let prevDistance = sorted[0]?.distanceKm ?? 0; for (let i = 1; i < sorted.length; i++) { const stop = sorted[i]; if (stop.distanceKm == null) { throw new BadRequestException( `Stop ${stop.sequence} is missing distanceKm (cumulative distance in km from the route origin). This is required for fare pricing.`, ); } if (stop.distanceKm <= prevDistance) { throw new BadRequestException( `Stop ${stop.sequence}'s distanceKm (${stop.distanceKm}) must be greater than stop ${sorted[i - 1].sequence}'s distanceKm (${prevDistance}) — distanceKm is cumulative distance from the route origin, not distance from the previous stop. Equal or decreasing values make fare pricing between these stops fail silently.`, ); } prevDistance = stop.distanceKm; } } async createRoute(dto: CreateRouteDto) { const existing = await this.prisma.route.findUnique({ where: { code: dto.code } }); if (existing) throw new ConflictException(`Route code "${dto.code}" already exists`); if (dto.stops.length < 2) throw new BadRequestException('A route must have at least 2 stops'); const seqs = dto.stops.map(s => s.sequence); if (new Set(seqs).size !== seqs.length) throw new ConflictException('Duplicate sequence numbers in stop list'); this.validateStopDistances(dto.stops); const stationIds = [...new Set(dto.stops.map(s => s.stationId))]; const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } }); if (stations.length !== stationIds.length) throw new BadRequestException('One or more station IDs not found'); const route = await this.prisma.route.create({ data: { code: dto.code, name: dto.name, description: dto.description, active: dto.active ?? true, ...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}), effectiveFrom: parseEthiopianTime(dto.effectiveFrom), effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null, stops: { create: dto.stops.map(s => ({ stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, travelMinutesToStop: s.travelMinutesToStop ?? null, })), }, }, include: { stops: { include: { route: false }, orderBy: { sequence: 'asc' } } }, }); await this.auditService.log({ action: 'CREATE', entityType: 'Route', entityId: route.id, newData: { code: route.code, name: route.name } }); return route; } async listRoutes(activeOnly = false) { return this.prisma.route.findMany({ where: activeOnly ? { active: true } : undefined, include: { stops: { orderBy: { sequence: 'asc' } }, _count: { select: { stops: true } }, }, orderBy: { code: 'asc' }, }); } async getRoute(id: string) { const route = await this.prisma.route.findUnique({ where: { id }, include: { stops: { orderBy: { sequence: 'asc' }, include: { route: false, }, }, }, }); if (!route) throw new NotFoundException('Route not found'); // Enrich stops with station details const stationIds = route.stops.map(s => s.stationId); const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } }); const stationMap = Object.fromEntries(stations.map(s => [s.id, s])); return { ...route, stops: route.stops.map(s => ({ ...s, station: stationMap[s.stationId] })), }; } async updateRoute(id: string, dto: UpdateRouteDto) { const route = await this.prisma.route.findUnique({ where: { id } }); if (!route) throw new NotFoundException('Route not found'); if (dto.stops && dto.stops.length >= 2) this.validateStopDistances(dto.stops); await this.prisma.route.update({ where: { id }, data: { name: dto.name, description: dto.description, active: dto.active, ...(dto.effectiveFrom ? { effectiveFrom: parseEthiopianTime(dto.effectiveFrom) } : {}), // effectiveUntil is nullable (open-ended route) — distinguish "field not sent" (leave // untouched) from "explicitly cleared" (null → set to null), not just truthy/falsy. ...(dto.effectiveUntil !== undefined ? { effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null } : {}), ...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}), }, }); if (dto.stops && dto.stops.length >= 2) { await this.prisma.routeStop.deleteMany({ where: { routeId: id } }); await this.prisma.routeStop.createMany({ data: dto.stops.map(s => ({ routeId: id, stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, travelMinutesToStop: s.travelMinutesToStop ?? null, })), }); } await this.auditService.log({ action: 'UPDATE', entityType: 'Route', entityId: id, newData: { name: dto.name, active: dto.active } }); return this.prisma.route.findUnique({ where: { id }, include: { stops: { orderBy: { sequence: 'asc' } } }, }); } async deleteRoute(id: string, cascade = false) { const route = await this.prisma.route.findUnique({ where: { id }, include: { schedules: true, stops: true } }); if (!route) throw new NotFoundException('Route not found'); if (!cascade) { const constraints = []; if (route.schedules.length > 0) { constraints.push({ entityName: 'schedule', count: route.schedules.length, action: 'delete' as const }); } if (constraints.length > 0) { throw new DeleteOperationException('Route', `${route.code} (${route.name})`, constraints); } } if (cascade) { const scheduleIds = route.schedules.map(s => s.id); if (scheduleIds.length > 0) { await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); await this.prisma.menuItem.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); await this.prisma.journeySegment.deleteMany({ where: { scheduleId: { in: scheduleIds } } }); const bookings = await this.prisma.booking.findMany({ where: { OR: [{ scheduleId: { in: scheduleIds } }, { returnScheduleId: { in: scheduleIds } }] }, select: { id: true }, }); if (bookings.length > 0) { const bookingIds = bookings.map(b => b.id); const tickets = await this.prisma.ticket.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); if (tickets.length > 0) { await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: { in: tickets.map(t => t.id) } } }); } await this.prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } }); const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); if (foodOrders.length > 0) { await this.prisma.foodOrderItem.deleteMany({ where: { orderId: { in: foodOrders.map(o => o.id) } } }); } await this.prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } }); const paymentIntents = await this.prisma.paymentIntent.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } }); if (paymentIntents.length > 0) { await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: { in: paymentIntents.map(p => p.id) } } }); } await this.prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } }); await this.prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } }); await this.prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } }); await this.prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } }); await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } }); await this.prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } }); await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: { in: bookingIds } } }); await this.prisma.journey.deleteMany({ where: { bookingId: { in: bookingIds } } }); await this.prisma.booking.deleteMany({ where: { id: { in: bookingIds } } }); } const packages = await this.prisma.travelPackage.findMany({ where: { OR: [{ outboundScheduleId: { in: scheduleIds } }, { returnScheduleId: { in: scheduleIds } }] }, select: { id: true }, }); if (packages.length > 0) { const packageIds = packages.map(p => p.id); await this.prisma.packagePriceTier.deleteMany({ where: { packageId: { in: packageIds } } }); await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } }); } await this.prisma.trainSchedule.deleteMany({ where: { id: { in: scheduleIds } } }); } await this.prisma.routeStop.deleteMany({ where: { routeId: id } }); await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId: id } }); } await this.prisma.route.delete({ where: { id } }); await this.auditService.log({ action: 'DELETE', entityType: 'Route', entityId: id, oldData: { code: route.code, name: route.name } }); return { deleted: true, id }; } // ── Route Stops ──────────────────────────────────────────────────────────── async addStop(routeId: string, dto: AddRouteStopDto) { const route = await this.prisma.route.findUnique({ where: { id: routeId } }); if (!route) throw new NotFoundException('Route not found'); const station = await this.prisma.station.findUnique({ where: { id: dto.stationId } }); if (!station) throw new NotFoundException(`Station ${dto.stationId} not found`); if (!station.isOperational) throw new BadRequestException(`Station ${dto.stationId} is not operational`); const existing = await this.prisma.routeStop.findUnique({ where: { routeId_sequence: { routeId, sequence: dto.sequence } }, }); if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`); const otherStops = await this.prisma.routeStop.findMany({ where: { routeId } }); this.validateStopDistances([...otherStops, { sequence: dto.sequence, stationId: dto.stationId, distanceKm: dto.distanceKm }]); return this.prisma.routeStop.create({ data: { routeId, stationId: dto.stationId, sequence: dto.sequence, distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null, checkinMinutesBefore: dto.checkinMinutesBefore ?? null, travelMinutesToStop: dto.travelMinutesToStop ?? null, }, }); } async removeStop(routeId: string, sequence: number) { const stop = await this.prisma.routeStop.findUnique({ where: { routeId_sequence: { routeId, sequence } }, }); if (!stop) throw new NotFoundException(`Stop at sequence ${sequence} not found on route`); const total = await this.prisma.routeStop.count({ where: { routeId } }); if (total <= 2) throw new BadRequestException('A route must retain at least 2 stops'); await this.prisma.routeStop.delete({ where: { routeId_sequence: { routeId, sequence } } }); return { deleted: true, sequence }; } async getStops(routeId: string) { const route = await this.prisma.route.findUnique({ where: { id: routeId } }); if (!route) throw new NotFoundException('Route not found'); const stops = await this.prisma.routeStop.findMany({ where: { routeId }, orderBy: { sequence: 'asc' }, }); const stationIds = stops.map(s => s.stationId); const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } }); const stationMap = Object.fromEntries(stations.map(s => [s.id, s])); return stops.map(s => ({ ...s, station: stationMap[s.stationId] })); } async getSchedulesForRoute(routeId: string) { const route = await this.prisma.route.findUnique({ where: { id: routeId } }); if (!route) throw new NotFoundException('Route not found'); return this.prisma.trainSchedule.findMany({ where: { routeId }, include: { train: true, originStation: true, destinationStation: true }, orderBy: { departureAt: 'asc' }, }); } async getRouteCoachTemplate(routeId: string) { const route = await this.prisma.route.findUnique({ where: { id: routeId } }); if (!route) throw new NotFoundException('Route not found'); return this.prisma.routeCoachTemplate.findMany({ where: { routeId }, include: { coach: { include: { coachType: true } } }, orderBy: { positionNumber: 'asc' }, }); } async setRouteCoachTemplate(routeId: string, dto: SetRouteCoachTemplateDto) { const route = await this.prisma.route.findUnique({ where: { id: routeId } }); if (!route) throw new NotFoundException('Route not found'); const coachIds = dto.coaches.map(c => c.coachId); const coaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } }); if (coaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found'); const inactive = coaches.find(c => c.status !== 'ACTIVE'); if (inactive) throw new BadRequestException(`Coach ${inactive.number} is not active`); const positions = dto.coaches.map(c => c.positionNumber); if (new Set(positions).size !== positions.length) throw new BadRequestException('Duplicate positionNumber values'); await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } }); await this.prisma.routeCoachTemplate.createMany({ data: dto.coaches.map(c => ({ routeId, coachId: c.coachId, positionNumber: c.positionNumber })), }); return this.getRouteCoachTemplate(routeId); } async removeRouteCoachTemplate(routeId: string) { const route = await this.prisma.route.findUnique({ where: { id: routeId } }); if (!route) throw new NotFoundException('Route not found'); await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } }); return { deleted: true, routeId }; } // ── Used by SchedulesService ─────────────────────────────────────────────── /** * Copies RouteStop definitions into TripStopTime rows for a schedule. * plannedTimes maps sequence → { arrivalAt?, departureAt? } for actual timing. */ async applyRouteToSchedule( routeId: string, scheduleId: string, plannedTimes: Record, ) { const stops = await this.prisma.routeStop.findMany({ where: { routeId }, orderBy: { sequence: 'asc' }, }); if (stops.length === 0) throw new BadRequestException('Route has no stops defined'); await this.prisma.tripStopTime.deleteMany({ where: { scheduleId } }); await this.prisma.tripStopTime.createMany({ data: stops.map(s => { const times = plannedTimes[s.sequence] ?? {}; return { scheduleId, stationId: s.stationId, sequence: s.sequence, plannedArrivalAt: times.plannedArrivalAt ? new Date(times.plannedArrivalAt) : null, plannedDepartureAt: times.plannedDepartureAt ? new Date(times.plannedDepartureAt) : null, }; }), }); const intermediateCount = Math.max(0, stops.length - 2); await this.prisma.trainSchedule.update({ where: { id: scheduleId }, data: { stopsCount: intermediateCount }, }); return this.prisma.tripStopTime.findMany({ where: { scheduleId }, include: { station: true }, orderBy: { sequence: 'asc' }, }); } }