import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { RoutesService } from './routes.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, ApplyDelayDto } from './schedules.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils'; import { AuditService } from '../../common/audit.service'; import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; import { snapshot } from '../../common/audit-snapshot'; import { LiveService } from '../live/live.service'; import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils'; const SCHEDULE_AUDIT_FIELDS = [ 'trainId', 'routeId', 'originStationId', 'destinationStationId', 'departureAt', 'arrivalAt', 'durationMinutes', 'stopsCount', 'status', ] as const; const FARE_RULE_AUDIT_FIELDS = [ 'tripId', 'seatClassId', 'baseFareMinor', 'currency', 'nationality', 'validFrom', 'validUntil', ] as const; const SEGMENT_FARE_AUDIT_FIELDS = [ 'routeId', 'seatClassId', 'originStopSequence', 'destinationStopSequence', 'baseFareMinor', 'currency', 'validFrom', 'validUntil', ] as const; const ROUTE_FARE_AUDIT_FIELDS = [ 'routeId', 'seatClassId', 'passengerCategory', 'baseFareMinor', 'surchargeMinor', 'validFrom', 'validUntil', ] as const; const STOP_TIME_AUDIT_FIELDS = [ 'scheduleId', 'stationId', 'sequence', 'plannedArrivalAt', 'plannedDepartureAt', 'status', ] as const; @Injectable() export class SchedulesService { private readonly logger = new Logger(SchedulesService.name); constructor( private prisma: PrismaService, private routesService: RoutesService, private fareEngine: FareEngineService, private auditService: AuditService, private liveService: LiveService, ) { } /** * Computes each stop's planned arrival/departure time by walking the route in sequence * order and accumulating `RouteStop.travelMinutesToStop` (minutes of travel from the * previous stop). Falls back to distance-proportional interpolation over `distanceKm` for * any stop missing `travelMinutesToStop`. The last stop is always locked to the confirmed * overall `arr` regardless of the accumulated cursor, so schedule.arrivalAt stays * authoritative even if per-stop estimates drift. */ private computePlannedTimes( route: { id: string; stops: { sequence: number; distanceKm: number | null; travelMinutesToStop: number | null }[] }, dep: Date, arr: Date, ) { const totalDuration = arr.getTime() - dep.getTime(); const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; let cursor = dep; return route.stops.map((stop, index) => { if (index === 0) { cursor = dep; } else if (index === route.stops.length - 1) { cursor = arr; } else if (stop.travelMinutesToStop != null) { cursor = new Date(cursor.getTime() + stop.travelMinutesToStop * 60_000); } else { const stopDistance = stop.distanceKm || 0; const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); cursor = new Date(dep.getTime() + totalDuration * progress); this.logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`); } return { sequence: stop.sequence, plannedArrivalAt: index === 0 ? undefined : cursor.toISOString(), plannedDepartureAt: index === route.stops.length - 1 ? undefined : cursor.toISOString(), }; }); } async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) { const startDate = parseEthiopianTime(dto.startDateTime); const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000); const errors: string[] = []; const scheduleIds: string[] = []; const route = await this.prisma.route.findUnique({ where: { id: dto.routeId }, include: { stops: { orderBy: { sequence: 'asc' } } }, }); if (!route) throw new NotFoundException('Route not found'); if (!route.active) throw new BadRequestException('Route is not active'); let currentDate = new Date(startDate); let scheduleCount = 0; while (currentDate < endDate) { try { const departureAt = new Date(currentDate); const arrivalAt = new Date(departureAt.getTime() + dto.durationHours * 60 * 60 * 1000); const createDto: CreateScheduleDto = { trainId: dto.trainId, routeId: dto.routeId, departureAt: departureAt.toISOString(), arrivalAt: arrivalAt.toISOString(), plannedTimes: dto.plannedTimes || [], coachIds: dto.coachIds, }; // createSchedule applies coachIds if given, else auto-applies the route coach template, // and rejects the day outright (caught below) if it would end up with zero coaches. const schedule = await this.createSchedule(createDto); scheduleIds.push(schedule.id); scheduleCount++; } catch (error) { errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`); } currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000); } // One row for the whole sweep, not one per schedule — the operator performed a single // action and the created ids are the interesting part. await this.auditService.log({ action: AUDIT_ACTIONS.BULK_CREATE, entityType: AUDIT_ENTITIES.Schedule, entityId: dto.routeId, newData: { routeId: dto.routeId, trainId: dto.trainId, startDateTime: dto.startDateTime, forNextDays: dto.forNextDays, repeatEveryDays: dto.repeatEveryDays, schedulesCreated: scheduleCount, scheduleIds, errorCount: errors.length, }, }); return { schedulesCreated: scheduleCount, errors, scheduleIds }; } async listSchedules(dto: ListSchedulesDto) { const where: any = {}; if (dto.date) { const date = parseEthiopianTime(dto.date); const nextDay = startOfNextDayEAT(date); where.departureAt = { gte: date, lt: nextDay }; } if (dto.routeId) where.routeId = dto.routeId; if (dto.trainId) where.trainId = dto.trainId; if (dto.status) where.status = dto.status; return this.prisma.trainSchedule.findMany({ where, include: { train: true, route: true, originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, coachAssignments: { include: { coach: true }, orderBy: { positionNumber: 'asc' }, }, liveStatus: { select: { delayMinutes: true } }, _count: { select: { coachAssignments: true, bookings: true } }, }, orderBy: { departureAt: 'asc' }, }); } async createSchedule(dto: CreateScheduleDto) { // Parse dates in local Ethiopian time (EAT - UTC+3) const dep = parseEthiopianTime(dto.departureAt); const arr = parseEthiopianTime(dto.arrivalAt); if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt'); // M-4: a new schedule cannot depart in the past — the backoffice form does not enforce this. if (dep.getTime() < Date.now()) throw new BadRequestException('departureAt must be in the future'); const [train, route] = await Promise.all([ this.prisma.train.findUnique({ where: { id: dto.trainId } }), this.prisma.route.findUnique({ where: { id: dto.routeId }, include: { stops: { orderBy: { sequence: 'asc' } } }, }), ]); if (!train) throw new NotFoundException('Train not found'); if (!train.isActive) throw new BadRequestException('Train is not active'); if (!route) throw new NotFoundException('Route not found'); if (!route.active) throw new BadRequestException('Route is not active'); if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops'); // Check for existing schedule on the same day (local Ethiopian time) const depDate = startOfDayEAT(dep); const nextDay = startOfNextDayEAT(dep); const existingSchedule = await this.prisma.trainSchedule.findFirst({ where: { trainId: dto.trainId, routeId: dto.routeId, departureAt: { gte: depDate, lt: nextDay } }, }); if (existingSchedule) { throw new BadRequestException( `A schedule for this train, route, and date already exists. Departure: ${new Date(existingSchedule.departureAt).toLocaleString()}`, ); } let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { plannedTimes = this.computePlannedTimes(route, dep, arr); } const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence)); const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq)); if (missingSeqs.length > 0) { throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`); } const firstStop = route.stops[0]; const lastStop = route.stops[route.stops.length - 1]; const schedule = await this.prisma.trainSchedule.create({ data: { trainId: dto.trainId, routeId: dto.routeId, originStationId: firstStop.stationId, destinationStationId: lastStop.stationId, departureAt: dep, arrivalAt: arr, durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60_000), stopsCount: Math.max(0, route.stops.length - 2), }, include: { train: true, originStation: true, destinationStation: true }, }); const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap); // Explicit coachIds (from the schedule form's Coaches step) override the route's coach // template; otherwise auto-apply the template if one is defined. if (dto.coachIds && dto.coachIds.length > 0) { await this.assignCoaches( schedule.id, dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })), ); } else { const coachTemplates = await this.prisma.routeCoachTemplate.findMany({ where: { routeId: dto.routeId }, orderBy: { positionNumber: 'asc' }, }); if (coachTemplates.length > 0) { await this.assignCoaches( schedule.id, coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })), ); } } // A schedule with zero coaches has zero seats and is silently invisible to search (and // unbookable) with no indication why — block creation instead of leaving a dead schedule. const assignedCoachCount = await this.prisma.coachAssignment.count({ where: { scheduleId: schedule.id } }); if (assignedCoachCount === 0) { await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: schedule.id } }); await this.prisma.trainSchedule.delete({ where: { id: schedule.id } }); throw new BadRequestException( 'A schedule must have at least one coach assigned to be bookable. Add coaches in the Coaches step, or set a Route Coach Template on this route so new schedules auto-assign coaches.', ); } const result = await this.getSchedule(schedule.id); await this.auditService.log({ action: AUDIT_ACTIONS.CREATE, entityType: AUDIT_ENTITIES.Schedule, entityId: schedule.id, newData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS), }); return result; } async getSchedule(id: string) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id }, include: { train: true, originStation: true, destinationStation: true, coachAssignments: { include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } }, orderBy: { positionNumber: 'asc' }, }, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, liveStatus: { select: { delayMinutes: true } }, }, }); if (!schedule) throw new NotFoundException('Schedule not found'); const allSeatIds = schedule.coachAssignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id)); const effectiveStatuses = await this.resolveEffectiveStatuses(id, allSeatIds); return { ...schedule, coachAssignments: schedule.coachAssignments.map((a: any) => ({ ...a, coach: { ...a.coach, seats: a.coach.seats.map((s: any) => ({ ...s, status: effectiveStatuses.get(s.id) ?? s.status, })), }, })), }; } private async resolveEffectiveStatuses(scheduleId: string, seatIds: string[]): Promise> { const statusMap = new Map(); if (seatIds.length === 0) return statusMap; const [activeHolds, bookedSegments] = await Promise.all([ this.prisma.seatHold.findMany({ where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } }, select: { seatIds: true }, }), this.prisma.journeySegment.findMany({ where: { scheduleId, seatId: { in: seatIds }, journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, }, select: { seatId: true }, }), ]); for (const hold of activeHolds) for (const seatId of hold.seatIds) if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD'); for (const seg of bookedSegments) if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED'); return statusMap; } async updateSchedule(id: string, dto: CreateScheduleDto) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } }); if (!schedule) throw new NotFoundException('Schedule not found'); // Parse dates in local Ethiopian time (EAT - UTC+3) const dep = parseEthiopianTime(dto.departureAt); const arr = parseEthiopianTime(dto.arrivalAt); if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt'); const [train, route] = await Promise.all([ this.prisma.train.findUnique({ where: { id: dto.trainId } }), this.prisma.route.findUnique({ where: { id: dto.routeId }, include: { stops: { orderBy: { sequence: 'asc' } } }, }), ]); if (!train) throw new NotFoundException('Train not found'); if (!train.isActive) throw new BadRequestException('Train is not active'); if (!route) throw new NotFoundException('Route not found'); if (!route.active) throw new BadRequestException('Route is not active'); if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops'); const firstStop = route.stops[0]; const lastStop = route.stops[route.stops.length - 1]; await this.prisma.trainSchedule.update({ where: { id }, data: { trainId: dto.trainId, routeId: dto.routeId, originStationId: firstStop.stationId, destinationStationId: lastStop.stationId, departureAt: dep, arrivalAt: arr, durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60_000), stopsCount: Math.max(0, route.stops.length - 2), }, }); await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } }); let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { plannedTimes = this.computePlannedTimes(route, dep, arr); } const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap); const result = await this.getSchedule(id); await this.auditService.log({ action: AUDIT_ACTIONS.UPDATE, entityType: AUDIT_ENTITIES.Schedule, entityId: id, oldData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS), newData: snapshot(result as any, SCHEDULE_AUDIT_FIELDS), }); return result; } async updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } }); if (!schedule) throw new NotFoundException('Schedule not found'); const updated = await this.prisma.trainSchedule.update({ where: { id }, data: { status: dto.status }, }); // CANCELLED is the one transition an operator is asked to justify after the fact, so it // gets its own verb; everything else is a plain status move. await this.auditService.log({ action: dto.status === 'CANCELLED' ? AUDIT_ACTIONS.CANCEL : AUDIT_ACTIONS.STATUS_CHANGE, entityType: AUDIT_ENTITIES.Schedule, entityId: id, oldData: { status: schedule.status }, newData: { status: updated.status, departureAt: updated.departureAt.toISOString() }, }); return updated; } async deleteSchedule(id: string, cascade = false) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id }, include: { _count: { select: { bookings: true } }, train: true, originStation: true, destinationStation: true }, }); if (!schedule) throw new NotFoundException('Schedule not found'); if (!cascade) { const constraints = []; if ((schedule as any)._count.bookings > 0) { constraints.push({ entityName: 'booking', count: (schedule as any)._count.bookings, action: 'cancel' as const }); } if (constraints.length > 0) { const scheduleName = `${schedule.train.number} (${schedule.originStation.name} → ${schedule.destinationStation.name})`; throw new DeleteOperationException('Schedule', scheduleName, constraints); } } await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } }); await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } }); await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: id } }); await this.prisma.menuItem.deleteMany({ where: { scheduleId: id } }); await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } }); // Delete bookings and all their children const bookings = await this.prisma.booking.findMany({ where: { OR: [{ scheduleId: id }, { returnScheduleId: id }] }, select: { id: true }, }); if (bookings.length > 0) { const bookingIds = bookings.map(b => b.id); // Leaf tables first 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 } } }); } // Delete travel packages that reference this schedule const packagesToDelete = await this.prisma.travelPackage.findMany({ where: { OR: [{ outboundScheduleId: id }, { returnScheduleId: id }] }, select: { id: true }, }); if (packagesToDelete.length > 0) { const packageIds = packagesToDelete.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.delete({ where: { id } }); await this.auditService.log({ action: AUDIT_ACTIONS.DELETE, entityType: AUDIT_ENTITIES.Schedule, entityId: id, oldData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS), newData: { cascade, bookingsAffected: (schedule as any)._count?.bookings ?? 0 }, }); return { deleted: true, id }; } getStops(scheduleId: string) { return this.prisma.tripStopTime.findMany({ where: { scheduleId }, include: { station: true }, orderBy: { sequence: 'asc' }, }); } async updateStop(scheduleId: string, sequence: number, dto: UpdateStopTimeDto) { const stop = await this.prisma.tripStopTime.findUnique({ where: { scheduleId_sequence: { scheduleId, sequence } }, }); if (!stop) throw new NotFoundException(`Stop at sequence ${sequence} not found on schedule`); const updated = await this.prisma.tripStopTime.update({ where: { scheduleId_sequence: { scheduleId, sequence } }, data: { plannedArrivalAt: dto.plannedArrivalAt ? parseEthiopianTime(dto.plannedArrivalAt) : undefined, plannedDepartureAt: dto.plannedDepartureAt ? parseEthiopianTime(dto.plannedDepartureAt) : undefined, status: dto.status, }, include: { station: true }, }); await this.auditService.log({ action: AUDIT_ACTIONS.UPDATE, entityType: AUDIT_ENTITIES.Schedule, entityId: scheduleId, oldData: snapshot(stop, STOP_TIME_AUDIT_FIELDS), newData: snapshot(updated, STOP_TIME_AUDIT_FIELDS), }); return updated; } /** * Shifts stored planned times additively rather than reusing updateSchedulePartial's * recompute-from-route-interpolation path — that path also guards `departureAt must be in the * future`, which a delay report for an already-departed/EN_ROUTE train would legitimately * fail. Check-in cutoffs (resolveCheckinCutoff, SeatsService.holdSeats) are both derived * directly from TripStopTime.plannedArrivalAt/plannedDepartureAt at read time, so shifting the * stored values here is the entire fix — neither of those needs to change. */ async applyDelay(scheduleId: string, dto: ApplyDelayDto) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }); if (!schedule) throw new NotFoundException('Schedule not found'); const stopWhere: any = { scheduleId }; if (dto.fromSequence != null) { stopWhere.sequence = { gte: dto.fromSequence }; } else { // Default: only stops the train hasn't reached yet — a delay report must not retroactively // move a stop that's already BOARDED/COMPLETED. stopWhere.status = { notIn: ['BOARDED', 'COMPLETED'] }; } const stopsToShift = await this.prisma.tripStopTime.findMany({ where: stopWhere }); const shiftMs = dto.delayMinutes * 60_000; const includesOrigin = stopsToShift.some((s) => s.sequence === 1); await this.prisma.$transaction(async (tx) => { for (const stop of stopsToShift) { await tx.tripStopTime.update({ where: { id: stop.id }, data: { plannedArrivalAt: stop.plannedArrivalAt ? new Date(stop.plannedArrivalAt.getTime() + shiftMs) : undefined, plannedDepartureAt: stop.plannedDepartureAt ? new Date(stop.plannedDepartureAt.getTime() + shiftMs) : undefined, }, }); } // Origin stop shifted → the schedule's own departureAt/arrivalAt drive search's day-window // queries and the displayed departure time, so they must move too (both together, so // durationMinutes stays correct). if (includesOrigin) { await tx.trainSchedule.update({ where: { id: scheduleId }, data: { departureAt: new Date(schedule.departureAt.getTime() + shiftMs), arrivalAt: new Date(schedule.arrivalAt.getTime() + shiftMs), }, }); } }); const currentLive = await this.prisma.tripLiveStatus.findUnique({ where: { scheduleId } }); const accumulatedDelayMinutes = Math.max(0, (currentLive?.delayMinutes ?? 0) + dto.delayMinutes); await this.liveService.updateLiveStatus(scheduleId, { delayMinutes: accumulatedDelayMinutes }); await this.auditService.log({ action: AUDIT_ACTIONS.UPDATE, entityType: AUDIT_ENTITIES.Schedule, entityId: scheduleId, oldData: { delayMinutes: currentLive?.delayMinutes ?? 0, departureAt: schedule.departureAt.toISOString(), }, newData: { delayMinutes: dto.delayMinutes, fromSequence: dto.fromSequence, accumulatedDelayMinutes, stopsShifted: stopsToShift.length, }, }); return this.getSchedule(scheduleId); } async upsertScheduleFare( scheduleId: string, seatClassId: string, dto: { baseFareMinor: number; validFrom?: string; validUntil?: string }, ) { const [schedule, seatClass] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }), this.prisma.seatClass.findUnique({ where: { id: seatClassId } }), ]); if (!schedule) throw new NotFoundException('Schedule not found'); if (!seatClass) throw new NotFoundException('Seat class not found'); const now = new Date(); const validFrom = dto.validFrom ? parseEthiopianTime(dto.validFrom) : now; const validUntil = dto.validUntil ? parseEthiopianTime(dto.validUntil) : null; const superseded = await this.prisma.fareRule.findFirst({ where: { tripId: scheduleId, seatClassId, validUntil: null }, orderBy: { validFrom: 'desc' }, }); const created = await this.prisma.$transaction(async (tx) => { await tx.fareRule.updateMany({ where: { tripId: scheduleId, seatClassId, validUntil: null }, data: { validUntil: now }, }); return tx.fareRule.create({ data: { tripId: scheduleId, seatClassId, baseFareMinor: dto.baseFareMinor, currency: 'ETB', validFrom, validUntil }, include: { seatClass: true }, }); }); // A schedule fare is versioned rather than edited, so the audit row pairs the rule that was // closed off with the one that replaced it — otherwise the price change is invisible. await this.auditService.log({ action: AUDIT_ACTIONS.UPDATE, entityType: AUDIT_ENTITIES.ScheduleFare, entityId: created.id, oldData: snapshot(superseded, FARE_RULE_AUDIT_FIELDS), newData: { ...snapshot(created, FARE_RULE_AUDIT_FIELDS), scheduleId, seatClassName: seatClass.name, }, }); return created; } async createFareRule(dto: CreateFareRuleDto) { const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto; const rule = await this.prisma.fareRule.create({ data: { ...rest, tripId: scheduleId, nationality, validFrom: parseEthiopianTime(validFrom), validUntil: validUntil ? parseEthiopianTime(validUntil) : null, }, include: { seatClass: true }, }); // Awaited, not a floating .then(): an unhandled rejection there could outlive the response, // and the row could land after the caller had already moved on. await this.auditService.log({ action: AUDIT_ACTIONS.CREATE, entityType: AUDIT_ENTITIES.FareRule, entityId: rule.id, newData: snapshot(rule, FARE_RULE_AUDIT_FIELDS), }); return rule; } async updateFareRule(id: string, dto: Partial) { const existing = await this.prisma.fareRule.findUnique({ where: { id } }); if (!existing) throw new NotFoundException('Fare rule not found'); const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto; const updated = await this.prisma.fareRule.update({ where: { id }, data: { ...rest, ...(scheduleId !== undefined && { tripId: scheduleId }), ...(nationality !== undefined && { nationality }), ...(validFrom && { validFrom: parseEthiopianTime(validFrom) }), ...(validUntil !== undefined && { validUntil: validUntil ? parseEthiopianTime(validUntil) : null }), }, include: { seatClass: true }, }); await this.auditService.log({ action: AUDIT_ACTIONS.UPDATE, entityType: AUDIT_ENTITIES.FareRule, entityId: id, oldData: snapshot(existing, FARE_RULE_AUDIT_FIELDS), newData: snapshot(updated, FARE_RULE_AUDIT_FIELDS), }); return updated; } async deleteFareRule(id: string) { const existing = await this.prisma.fareRule.findUnique({ where: { id } }); if (!existing) throw new NotFoundException('Fare rule not found'); await this.prisma.fareRule.delete({ where: { id } }); await this.auditService.log({ action: AUDIT_ACTIONS.DELETE, entityType: AUDIT_ENTITIES.FareRule, entityId: id, oldData: snapshot(existing, FARE_RULE_AUDIT_FIELDS), }); return { deleted: true, id }; } async createSegmentFareRule(dto: any) { const { validFrom, validUntil, passengerCategory, ...rest } = dto; const rule = await this.prisma.segmentFareRule.create({ data: { ...rest, validFrom: parseEthiopianTime(validFrom), validUntil: validUntil ? parseEthiopianTime(validUntil) : null, }, include: { seatClass: true, route: true }, }); await this.auditService.log({ action: AUDIT_ACTIONS.CREATE, entityType: AUDIT_ENTITIES.SegmentFareRule, entityId: rule.id, newData: snapshot(rule, SEGMENT_FARE_AUDIT_FIELDS), }); return rule; } getSegmentFares(routeId: string) { return this.prisma.segmentFareRule.findMany({ where: { routeId }, include: { seatClass: true, route: true }, orderBy: [{ originStopSequence: 'asc' }, { destinationStopSequence: 'asc' }], }); } async deleteSegmentFareRule(id: string) { const existing = await this.prisma.segmentFareRule.findUnique({ where: { id } }); if (!existing) throw new NotFoundException('Segment fare rule not found'); const deleted = await this.prisma.segmentFareRule.delete({ where: { id } }); await this.auditService.log({ action: AUDIT_ACTIONS.DELETE, entityType: AUDIT_ENTITIES.SegmentFareRule, entityId: id, oldData: snapshot(existing, SEGMENT_FARE_AUDIT_FIELDS), }); return deleted; } async updateSegmentFareRule(id: string, dto: any) { const existing = await this.prisma.segmentFareRule.findUnique({ where: { id } }); if (!existing) throw new NotFoundException('Segment fare rule not found'); const { validFrom, validUntil, passengerCategory, ...rest } = dto; const updated = await this.prisma.segmentFareRule.update({ where: { id }, data: { ...rest, validFrom: validFrom ? parseEthiopianTime(validFrom) : undefined, validUntil: validUntil ? parseEthiopianTime(validUntil) : null, }, include: { seatClass: true, route: true }, }); await this.auditService.log({ action: AUDIT_ACTIONS.UPDATE, entityType: AUDIT_ENTITIES.SegmentFareRule, entityId: id, oldData: snapshot(existing, SEGMENT_FARE_AUDIT_FIELDS), newData: snapshot(updated, SEGMENT_FARE_AUDIT_FIELDS), }); return updated; } async getFareRules(scheduleId?: string) { const where: any = {}; if (scheduleId) where.tripId = scheduleId; return this.prisma.fareRule.findMany({ where, include: { seatClass: true }, orderBy: { createdAt: 'desc' }, }); } getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) { return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality); } async getAllFaresFromEngine(scheduleId: string, nationality?: string) { try { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, select: { routeId: true, originStationId: true, destinationStationId: true }, }); if (!schedule) throw new NotFoundException('Schedule not found'); if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route'); return await this.fareEngine.calculateAllForSchedule(scheduleId, nationality); } catch (error) { throw new BadRequestException( error instanceof Error ? error.message : 'Failed to calculate fares for schedule', ); } } async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> { const results = await this.fareEngine.calculateAllForSchedule(scheduleId); const errors: string[] = []; let synced = 0; const now = new Date(); for (const fare of results as any[]) { try { const seatClass = await this.prisma.seatClass.findFirst({ where: { name: fare.seatClassName } }); if (!seatClass) { errors.push(`Seat class not found: ${fare.seatClassName}`); continue; } await this.prisma.fareRule.updateMany({ where: { tripId: scheduleId, seatClassId: seatClass.id, validUntil: null }, data: { validUntil: now }, }); await this.prisma.fareRule.create({ data: { tripId: scheduleId, seatClassId: seatClass.id, baseFareMinor: fare.totalMinor, currency: 'ETB', validFrom: now, validUntil: null, }, }); synced++; } catch (err) { errors.push(`${fare.seatClassName}: ${err instanceof Error ? err.message : String(err)}`); } } // One row for the sweep: the operator pressed sync once, and every fare it rewrote is // reconstructable from the FareRule versions it created. await this.auditService.log({ action: AUDIT_ACTIONS.SYNC, entityType: AUDIT_ENTITIES.ScheduleFare, entityId: scheduleId, newData: { scheduleId, synced, errorCount: errors.length }, }); return { synced, errors }; } async recalculateStopTimes(scheduleId: string) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, include: { route: { include: { stops: { orderBy: { sequence: 'asc' } } } } }, }); if (!schedule) throw new NotFoundException('Schedule not found'); if (!schedule.routeId || !schedule.route) throw new BadRequestException('Schedule has no associated route'); const plannedTimes = computePlannedStopTimes( schedule.route, new Date(schedule.departureAt), new Date(schedule.arrivalAt), ); const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(schedule.routeId, scheduleId, plannedTimesMap); await this.auditService.log({ action: AUDIT_ACTIONS.BULK_UPDATE, entityType: AUDIT_ENTITIES.Schedule, entityId: scheduleId, newData: { recalculatedStopTimes: true, routeId: schedule.routeId, stopCount: plannedTimes.length, }, }); return { recalculated: true, scheduleId, stopCount: plannedTimes.length }; } async assignCoaches(scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }); if (!schedule) throw new NotFoundException('Schedule not found'); const coachIds = coaches.map(c => c.coachId); const existingCoaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } }); if (existingCoaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found'); const inactiveCoach = existingCoaches.find(c => c.status !== 'ACTIVE'); if (inactiveCoach) throw new BadRequestException(`Coach ${inactiveCoach.number} is not active`); const previous = await this.prisma.coachAssignment.findMany({ where: { scheduleId }, orderBy: { positionNumber: 'asc' }, select: { coachId: true, positionNumber: true }, }); await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } }); const data = coaches.map((c) => ({ scheduleId, coachId: c.coachId, positionNumber: c.positionNumber, isOperational: true, })); await this.prisma.coachAssignment.createMany({ data }); // Assignment is a wholesale replacement, so both compositions go on one row rather than a // delete row per coach followed by a create row per coach. await this.auditService.log({ action: AUDIT_ACTIONS.ASSIGN, entityType: AUDIT_ENTITIES.CoachAssignment, entityId: scheduleId, oldData: { scheduleId, coaches: previous }, newData: { scheduleId, coaches: coaches.map((c) => ({ coachId: c.coachId, positionNumber: c.positionNumber })), }, }); return { message: 'Coaches assigned successfully', count: coaches.length }; } async getAssignedCoaches(scheduleId: string) { return this.prisma.coachAssignment.findMany({ where: { scheduleId }, include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } }, orderBy: { positionNumber: 'asc' }, }); } async updateSchedulePartial(id: string, dto: UpdateScheduleDto) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } }); if (!schedule) throw new NotFoundException('Schedule not found'); const updateData: any = {}; let dep: Date | undefined; let arr: Date | undefined; if (dto.departureAt || dto.arrivalAt) { dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt); arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt); if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time'); if (dep.getTime() < Date.now()) throw new BadRequestException('departureAt must be in the future'); updateData.departureAt = dep; updateData.arrivalAt = arr; updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000); } if (dto.status) updateData.status = dto.status; if (dto.isPackageOnly !== undefined) updateData.isPackageOnly = dto.isPackageOnly; if (Object.keys(updateData).length > 0) { await this.prisma.trainSchedule.update({ where: { id }, data: updateData }); } // departureAt/arrivalAt changed — the per-stop TripStopTime rows were computed against the // OLD times and are now stale (same interpolation createSchedule/updateSchedule use). Left // unfixed, check-in cutoff enforcement and search silently keep using outdated per-stop // arrival/departure estimates for every intermediate stop. if (dep && arr && schedule.routeId) { const route = await this.prisma.route.findUnique({ where: { id: schedule.routeId }, include: { stops: { orderBy: { sequence: 'asc' } } }, }); if (route && route.stops.length >= 2) { const plannedTimes = this.computePlannedTimes(route, dep, arr); const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap); } } if (dto.coaches !== undefined) { if (dto.coaches.length > 0) { // Logs its own ASSIGN row; this method only audits the schedule's own fields, so the // two rows describe two facts rather than double-reporting one. await this.assignCoaches(id, dto.coaches); } else { const cleared = await this.prisma.coachAssignment.findMany({ where: { scheduleId: id }, select: { coachId: true, positionNumber: true }, }); await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } }); await this.auditService.log({ action: AUDIT_ACTIONS.UNASSIGN, entityType: AUDIT_ENTITIES.CoachAssignment, entityId: id, oldData: { scheduleId: id, coaches: cleared }, newData: { scheduleId: id, coaches: [] }, }); } } const result = await this.getSchedule(id); if (Object.keys(updateData).length > 0) { const statusChanged = dto.status !== undefined && dto.status !== schedule.status; await this.auditService.log({ action: statusChanged ? dto.status === 'CANCELLED' ? AUDIT_ACTIONS.CANCEL : AUDIT_ACTIONS.STATUS_CHANGE : AUDIT_ACTIONS.UPDATE, entityType: AUDIT_ENTITIES.Schedule, entityId: id, oldData: snapshot(schedule, SCHEDULE_AUDIT_FIELDS), newData: snapshot(result as any, SCHEDULE_AUDIT_FIELDS), }); } return result; } async removeCoachAssignment(scheduleId: string, coachId: string) { const assignment = await this.prisma.coachAssignment.findFirst({ where: { scheduleId, coachId } }); if (!assignment) throw new NotFoundException('Coach assignment not found'); await this.prisma.coachAssignment.delete({ where: { id: assignment.id } }); await this.auditService.log({ action: AUDIT_ACTIONS.UNASSIGN, entityType: AUDIT_ENTITIES.CoachAssignment, entityId: assignment.id, oldData: { scheduleId, coachId, positionNumber: assignment.positionNumber }, }); return { message: 'Coach assignment removed' }; } // ── Route Fare Rule Overrides ────────────────────────────────────────────── listRouteFareRules(routeId: string) { return this.prisma.routeFareRule.findMany({ where: { routeId }, include: { seatClass: true, route: true }, orderBy: { createdAt: 'desc' }, }); } async createRouteFareRule(dto: { routeId: string; seatClassId: string; passengerCategory?: string; baseFareMinor: number; validFrom: string; validUntil?: string; }) { const [route, seatClass] = await Promise.all([ this.prisma.route.findUnique({ where: { id: dto.routeId } }), this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } }), ]); if (!route) throw new NotFoundException('Route not found'); if (!seatClass) throw new NotFoundException('Seat class not found'); const rule = await this.prisma.routeFareRule.create({ data: { routeId: dto.routeId, seatClassId: dto.seatClassId, passengerCategory: (dto.passengerCategory as any) ?? 'ADULT', baseFareMinor: dto.baseFareMinor, validFrom: parseEthiopianTime(dto.validFrom), validUntil: dto.validUntil ? parseEthiopianTime(dto.validUntil) : null, }, include: { seatClass: true, route: true }, }); await this.auditService.log({ action: AUDIT_ACTIONS.CREATE, entityType: AUDIT_ENTITIES.RouteFareRule, entityId: rule.id, newData: { ...snapshot(rule, ROUTE_FARE_AUDIT_FIELDS), routeCode: route.code, seatClassName: seatClass.name, }, }); return rule; } async updateRouteFareRule(id: string, dto: { baseFareMinor?: number; surchargeMinor?: number; validFrom?: string; validUntil?: string }) { const rule = await this.prisma.routeFareRule.findUnique({ where: { id } }); if (!rule) throw new NotFoundException('Route fare rule not found'); const updated = await this.prisma.routeFareRule.update({ where: { id }, data: { ...(dto.baseFareMinor !== undefined && { baseFareMinor: dto.baseFareMinor }), ...(dto.surchargeMinor !== undefined && { surchargeMinor: dto.surchargeMinor }), ...(dto.validFrom && { validFrom: parseEthiopianTime(dto.validFrom) }), ...(dto.validUntil !== undefined && { validUntil: dto.validUntil ? parseEthiopianTime(dto.validUntil) : null }), }, include: { seatClass: true, route: true }, }); await this.auditService.log({ action: AUDIT_ACTIONS.UPDATE, entityType: AUDIT_ENTITIES.RouteFareRule, entityId: id, oldData: snapshot(rule, ROUTE_FARE_AUDIT_FIELDS), newData: snapshot(updated, ROUTE_FARE_AUDIT_FIELDS), }); return updated; } async deleteRouteFareRule(id: string) { const rule = await this.prisma.routeFareRule.findUnique({ where: { id } }); if (!rule) throw new NotFoundException('Route fare rule not found'); await this.prisma.routeFareRule.delete({ where: { id } }); await this.auditService.log({ action: AUDIT_ACTIONS.DELETE, entityType: AUDIT_ENTITIES.RouteFareRule, entityId: id, oldData: snapshot(rule, ROUTE_FARE_AUDIT_FIELDS), }); return { deleted: true, id }; } }