Files
edr-platform/apps/edr-passenger-api/src/modules/schedules/routes.service.ts
2026-07-23 20:11:11 +03:00

399 lines
18 KiB
TypeScript

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<number, { plannedArrivalAt?: string; plannedDepartureAt?: string }>,
) {
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' },
});
}
}