import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { SearchTripsDto, FareQuoteDto } from './search.dto'; import { CurrencyService } from '../currency/currency.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; import { SegmentsService } from '../segments/segments.service'; import { Currency } from '@prisma/client'; const POINTS_TO_MINOR = 10; @Injectable() export class SearchService { constructor( private prisma: PrismaService, private currencyService: CurrencyService, private fareEngine: FareEngineService, private segmentsService: SegmentsService, ) {} async searchTrips(dto: SearchTripsDto) { const [direct, transit] = await Promise.all([ this.searchSchedules( dto.originStationId, dto.destinationStationId, dto.date, dto.adultCount, dto.childCount, dto.nationality, ), this.searchTransitOptions( dto.originStationId, dto.destinationStationId, dto.date, dto.adultCount, dto.childCount, dto.nationality, ), ]); const outbound = [...direct, ...transit]; if (outbound.length === 0) { const alternativesOutbound = await this.searchAlternatives( dto.originStationId, dto.destinationStationId, dto.date, dto.adultCount, dto.childCount, dto.nationality, ); return { journeyType: dto.journeyType === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY', outbound: [], alternativeOutbound: alternativesOutbound, requestedDate: dto.date, }; } if (dto.journeyType === 'ROUND_TRIP') { const [returnDirect, returnTransit] = await Promise.all([ this.searchSchedules( dto.destinationStationId, dto.originStationId, dto.returnDate ?? dto.date, dto.adultCount, dto.childCount, dto.nationality, ), this.searchTransitOptions( dto.destinationStationId, dto.originStationId, dto.returnDate ?? dto.date, dto.adultCount, dto.childCount, dto.nationality, ), ]); const allReturn = [...returnDirect, ...returnTransit]; const latestOutboundArrival = outbound.length > 0 ? Math.max(...outbound.map((s: any) => new Date(s.arrivalAt ?? s.leg2?.arrivalAt).getTime())) : Date.now(); const inbound = allReturn.filter((s: any) => new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival ); if (inbound.length === 0) { const alternativeInbound = await this.searchAlternatives( dto.destinationStationId, dto.originStationId, dto.returnDate ?? dto.date, dto.adultCount, dto.childCount, dto.nationality, ); return { journeyType: 'ROUND_TRIP', outbound, inbound: [], alternativeInbound }; } return { journeyType: 'ROUND_TRIP', outbound, inbound }; } return { journeyType: 'ONE_WAY', outbound }; } private async searchAlternatives( originStationId: string, destinationStationId: string, dateStr: string, adultCount: number, childCount?: number, nationality?: string, ) { const [y, m, d] = dateStr.split('-').map(Number); const requestedDate = new Date(y, m - 1, d, 0, 0, 0, 0); const now = new Date(); const daysBefore = Math.min(7, Math.floor(requestedDate.getTime() / 86_400_000)); const daysAfter = 14 - daysBefore; const windowStart = new Date(requestedDate); windowStart.setDate(windowStart.getDate() - daysBefore); if (windowStart < now) windowStart.setTime(now.getTime()); const windowEnd = new Date(requestedDate); windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); // exclusive upper bound const totalPassengers = adultCount + (childCount ?? 0); const requestedNextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0); const schedules = await this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, OR: [ { departureAt: { gte: windowStart, lt: requestedDate } }, { departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } }, ], stopTimes: { some: { stationId: originStationId } }, }, include: { train: true, originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, coachAssignments: { include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, }, }, orderBy: { departureAt: 'asc' }, }); const results: any[] = []; for (const schedule of schedules) { const result = await this.buildScheduleResult( schedule, originStationId, destinationStationId, totalPassengers, nationality, ); if (result) results.push(result); } return results; } private async searchSchedules( originStationId: string, destinationStationId: string, dateStr: string, adultCount: number, childCount?: number, nationality?: string, ) { const [y, m, d] = dateStr.split('-').map(Number); const date = new Date(y, m - 1, d, 0, 0, 0, 0); const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0); const now = new Date(); const totalPassengers = adultCount + (childCount ?? 0); const schedules = await this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, departureAt: { gte: date < now ? now : date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, }, include: { train: true, originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, coachAssignments: { include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, }, }, }); const results: any[] = []; for (const schedule of schedules) { const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality); if (result) results.push(result); } return results; } // ── Transit search ───────────────────────────────────────────────────────── // Finds pairs of schedules (leg1: origin→transit, leg2: transit→destination) // where the passenger has between MIN_CONNECTION and MAX_CONNECTION minutes // to change trains at the transit station. private readonly MIN_CONNECTION_MINUTES = 30; private readonly MAX_CONNECTION_MINUTES = 360; private async searchTransitOptions( originStationId: string, destinationStationId: string, dateStr: string, adultCount: number, childCount?: number, nationality?: string, ) { // Find all stations that can serve as transit points: // they must be a stop after origin on some schedule AND // a stop before destination on another schedule on the same day. const [y, m, d] = dateStr.split('-').map(Number); const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0); const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0); const totalPassengers = adultCount + (childCount ?? 0); // Load all schedules on this date that pass through origin const leg1Schedules = await this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, departureAt: { gte: dayStart, lt: dayEnd }, stopTimes: { some: { stationId: originStationId } }, }, include: { train: true, originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, coachAssignments: { include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, }, }, }); const results: any[] = []; for (const leg1 of leg1Schedules) { const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId); if (!originStop) continue; // Every stop after origin on leg1 is a candidate transit station const candidateTransitStops = leg1.stopTimes.filter( (s: any) => s.sequence > originStop.sequence, ); for (const transitStop of candidateTransitStops) { // leg1 must NOT already contain the final destination const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId); if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules const transitStationId = transitStop.stationId; const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt; // Find leg2 schedules departing from the transit station within the connection window, // and reaching the final destination. Search up to the next calendar day to handle // overnight connections. const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000); const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000); const leg2Schedules = await this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, departureAt: { gte: connWindowStart, lte: connWindowEnd }, stopTimes: { some: { stationId: transitStationId } }, }, include: { train: true, originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, coachAssignments: { include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, }, }, }); for (const leg2 of leg2Schedules) { const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId); const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId); if (!leg2TransitStop || !leg2DestStop) continue; if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue; // Build individual leg result objects (reuse existing per-schedule logic) const [leg1Result, leg2Result] = await Promise.all([ this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality), this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality), ]); if (!leg1Result || !leg2Result) continue; if (!leg1Result.hasAvailability || !leg2Result.hasAvailability) continue; const leg2DepartureAt = leg2TransitStop.plannedDepartureAt ?? leg2.departureAt; const connectionMinutes = Math.round( (new Date(leg2DepartureAt).getTime() - new Date(leg1ArrivalAt).getTime()) / 60_000, ); const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity); const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity); const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0); results.push({ type: 'TRANSIT', transitStationId, transitStationName: transitStop.station.name, connectionMinutes, leg1: leg1Result, leg2: leg2Result, combinedMinFareMinor, // Convenience top-level fields so round-trip filter can read them uniformly departureAt: leg1Result.departureAt, arrivalAt: leg2Result.arrivalAt, totalDurationMinutes: leg1Result.durationMinutes + connectionMinutes + leg2Result.durationMinutes, }); } } } return results; } // Builds the same result shape as searchSchedules for a single schedule+leg, // extracted so both direct and transit paths share identical output. private async buildScheduleResult( schedule: any, originStationId: string, destinationStationId: string, totalPassengers: number, nationality?: string, ) { const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId); const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId); if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; const availabilityByClass: Record = {}; for (const assignment of schedule.coachAssignments) { const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard']; const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition); if (isBedCoach) { for (const bedPosition of ['upper', 'middle', 'lower']) { let count = 0; for (const seat of assignment.coach.seats) { if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); if (free) count++; } if (count > 0) { const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition)); if (matchingClass) availabilityByClass[matchingClass] = (availabilityByClass[matchingClass] ?? 0) + count; } } } else { let available = 0; for (const seat of assignment.coach.seats) { if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); if (free) available++; } for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available; } } const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality); const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass); const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; return { type: 'DIRECT', scheduleId: schedule.id, trainNumber: schedule.train.number, trainName: schedule.train.name, origin: { id: originStop.stationId, code: originStop.station.code, name: originStop.station.name, city: originStop.station.city, sequence: originStop.sequence }, destination: { id: destStop.stationId, code: destStop.station.code, name: destStop.station.name, city: destStop.station.city, sequence: destStop.sequence }, departureAt: legDepartureAt, arrivalAt: legArrivalAt, durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000), status: schedule.status, stops: schedule.stopTimes .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) .map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), availabilityByClass, hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), faresByClass, coachTypes, }; } async getFareQuote(dto: FareQuoteDto) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, }, }); if (!schedule) throw new NotFoundException('Schedule not found'); const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId); const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId); if (!originStop || !destStop || originStop.sequence >= destStop.sequence) { throw new NotFoundException('Origin or destination not found on this schedule'); } const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } }); const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; const now = new Date(); const nationality = dto.nationality; const candidates = await this.prisma.fareRule.findMany({ where: { seatClassId: seatClass?.id, validFrom: { lte: now }, OR: [ { validUntil: null }, { validUntil: { gte: now } }, ], }, }); const bestMatch = this.selectBestFareRule( candidates, dto.scheduleId, segmentRoute, fullRoute, nationality, ); const baseFareMinor = bestMatch?.baseFareMinor ?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName); const adultCount = dto.adultCount; const childCount = dto.childCount ?? 0; const adultFareMinor = baseFareMinor * adultCount; const paidChildrenCount = Math.max(0, childCount - 1); const childFareMinor = baseFareMinor * paidChildrenCount; const totalBaseFareMinor = adultFareMinor + childFareMinor; let discountMinor = 0; if (dto.promoCode) { const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); if (promo?.active && promo.validUntil > now) { discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); } } const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR; const taxesMinor = Math.round(totalBaseFareMinor * 0.05); const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); const displayCurrency = dto.displayCurrency ?? Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; return { scheduleId: dto.scheduleId, originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, segmentRoute, seatClassName: dto.seatClassName, nationality: dto.nationality, adultCount, childCount, baseFareMinor, adultFareMinor, childFareMinor, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount, totalBaseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: 'ETB', displayCurrency, displayTotalMinor, }; } private async calculateFaresForSegment( schedule: any, originStationId: string, destinationStationId: string, nationality?: string, ): Promise> { const seatClassIds: string[] = Array.from( new Set( schedule.coachAssignments .flatMap((a: any) => a.coach.coachType?.seatClasses || []) .map((sc: any) => sc.id) .filter((id: any) => id) ) ); if (seatClassIds.length === 0) { console.log(`No seat classes assigned to schedule ${schedule.id}`); return []; } const seatClasses = await this.prisma.seatClass.findMany({ where: { isActive: true, id: { in: seatClassIds } }, orderBy: { baseFareMinor: 'asc' }, }); if (seatClasses.length === 0) { console.log(`No active seat classes for schedule ${schedule.id}`); return []; } if (schedule.routeId) { const results = await Promise.all( seatClasses.map(async (sc) => { try { const fare = await this.fareEngine.calculate({ routeId: schedule.routeId, originStationId, destinationStationId, seatClassId: sc.id, nationality, scheduleId: schedule.id, }); return { seatClassName: fare.seatClassName, baseFareMinor: fare.baseFarePerPassengerMinor, }; } catch (error) { console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message); return null; } }), ); const validResults = results.filter((r): r is { seatClassName: string; baseFareMinor: number } => r !== null); if (validResults.length > 0) { return validResults; } } const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } }); const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } }); if (originStation && destStation) { const segmentRoute = `${originStation.code}-${destStation.code}`; const now = new Date(); const fareRules = await this.prisma.fareRule.findMany({ where: { route: segmentRoute, seatClassId: { in: seatClassIds }, validFrom: { lte: now }, OR: [ { validUntil: null }, { validUntil: { gte: now } }, ], }, }); if (fareRules.length > 0) { console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`); const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name])); return fareRules.map(rule => ({ seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', baseFareMinor: rule.baseFareMinor, })); } } console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`); return []; } private async buildCoachTypeDetails( schedule: any, faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>, ): Promise; }>> { const coachTypeMap = new Map< string, { coachType: any; classNames: Set; coachId: string } >(); for (const assignment of schedule.coachAssignments) { const coachType = assignment.coach.coachType; if (!coachType) continue; if (!coachTypeMap.has(coachType.id)) { coachTypeMap.set(coachType.id, { coachType, classNames: new Set(), coachId: assignment.coach.id, }); } const entry = coachTypeMap.get(coachType.id)!; coachType.seatClasses?.forEach((sc: any) => entry.classNames.add(sc.name)); } const result = []; for (const [, { coachType, classNames, coachId }] of coachTypeMap) { const classes = Array.from(classNames) .map((className) => { const fareInfo = faresByClass.find((f) => f.seatClassName === className); if (!fareInfo) return null; return { name: className, baseFareMinor: fareInfo.baseFareMinor }; }) .filter((c): c is { name: string; baseFareMinor: number } => c !== null) .sort((a, b) => a.baseFareMinor - b.baseFareMinor); result.push({ coachTypeId: coachType.id, coachTypeName: coachType.name, coachTypeCode: coachType.code, coachId, classes, }); } return result.sort((a, b) => { const minPriceA = Math.min(...a.classes.map((c) => c.baseFareMinor)); const minPriceB = Math.min(...b.classes.map((c) => c.baseFareMinor)); return minPriceA - minPriceB; }); } private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise { if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`); const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, select: { routeId: true, originStationId: true, destinationStationId: true }, }); if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation'); const fare = await this.fareEngine.calculate({ routeId: schedule.routeId, originStationId: schedule.originStationId, destinationStationId: schedule.destinationStationId, seatClassId, }); return fare.baseFarePerPassengerMinor; } private getDefaultFareForClass(_className: string): never { throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead'); } private defaultFare(_seatClassName: string): never { throw new Error('defaultFare should not be called — use resolveScheduleFare instead'); } private selectBestFareRule( candidates: any[], scheduleId: string, segmentRoute: string, fullRoute: string, nationality?: string, ): any | null { const priorities = [ { tripId: scheduleId, route: segmentRoute, nationality }, { tripId: scheduleId, route: segmentRoute, nationality: null }, { tripId: scheduleId, route: fullRoute, nationality }, { tripId: scheduleId, route: fullRoute, nationality: null }, { tripId: scheduleId, route: null, nationality }, { tripId: scheduleId, route: null, nationality: null }, { tripId: null, route: segmentRoute, nationality }, { tripId: null, route: segmentRoute, nationality: null }, { tripId: null, route: fullRoute, nationality }, { tripId: null, route: fullRoute, nationality: null }, { tripId: null, route: null, nationality }, { tripId: null, route: null, nationality: null }, ]; for (const priority of priorities) { const match = candidates.find( (c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality, ); if (match) return match; } return null; } }