import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, FareBreakdownPassengerDto } from './search.dto'; import { CurrencyService } from '../currency/currency.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; import { SegmentsService } from '../segments/segments.service'; import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { Currency } from '@prisma/client'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; const POINTS_TO_MINOR = 10; // Shape returned by the heavy schedule include used throughout this service type ScheduleWithIncludes = { id: string; routeId: string | null; departureAt: Date; arrivalAt: Date; status: string; train: any; originStation: any; destinationStation: any; stopTimes: Array<{ stationId: string; sequence: number; plannedArrivalAt: Date | null; plannedDepartureAt: Date | null; station: any }>; coachAssignments: Array<{ coach: { id: string; seats: any[]; coachType: { id: string; name: string; code: string; seatClasses: any[] } | null } }>; }; const SCHEDULE_INCLUDE = { train: true, originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, coachAssignments: { include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, }, } as const; @Injectable() export class SearchService { constructor( private prisma: PrismaService, private currencyService: CurrencyService, private fareEngine: FareEngineService, private segmentsService: SegmentsService, private systemConfig: SystemConfigService, ) {} private async getCutoffHours(): Promise { return this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE); } 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 && dto.journeyType !== 'ROUND_TRIP') { const alternativesOutbound = await this.searchAlternatives( dto.originStationId, dto.destinationStationId, dto.date, dto.adultCount, dto.childCount, dto.nationality, ); return { journeyType: '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 ); const returnDate = dto.returnDate ?? dto.date; if (outbound.length === 0 || inbound.length === 0) { const [alternativeOutbound, alternativeInbound] = await Promise.all([ outbound.length === 0 ? this.searchAlternatives(dto.originStationId, dto.destinationStationId, dto.date, dto.adultCount, dto.childCount, dto.nationality) : Promise.resolve([]), inbound.length === 0 ? this.searchAlternatives(dto.destinationStationId, dto.originStationId, returnDate, dto.adultCount, dto.childCount, dto.nationality) : Promise.resolve([]), ]); return { journeyType: 'ROUND_TRIP', outbound, inbound, alternativeOutbound, alternativeInbound, requestedDate: dto.date, requestedReturnDate: returnDate, }; } return { journeyType: 'ROUND_TRIP', outbound, inbound, requestedDate: dto.date, requestedReturnDate: returnDate }; } 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 requestedNextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0); const now = new Date(); const totalPassengers = adultCount + (childCount ?? 0); const NEEDED = 3; const baseWhere = { status: 'SCHEDULED', isPackageOnly: false, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, } as const; // Fetch candidates before and after in parallel; take more than needed to // account for routes that don't serve the destination or have no availability. const FETCH_LIMIT = NEEDED * 5; const [beforeCandidates, afterCandidates] = await Promise.all([ this.prisma.trainSchedule.findMany({ where: { ...baseWhere, departureAt: { gte: now < requestedDate ? now : new Date(0), lt: requestedDate } }, include: SCHEDULE_INCLUDE, orderBy: { departureAt: 'desc' }, take: FETCH_LIMIT, }), this.prisma.trainSchedule.findMany({ where: { ...baseWhere, departureAt: { gte: requestedNextDay > now ? requestedNextDay : now } }, include: SCHEDULE_INCLUDE, orderBy: { departureAt: 'asc' }, take: FETCH_LIMIT, }), ]); const pickN = async (candidates: typeof beforeCandidates, limit: number) => { const out: NonNullable>>[] = []; for (const schedule of candidates) { if (out.length >= limit) break; const r = await this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality); if (r?.hasAvailability) out.push(r); } return out; }; const [before, after] = await Promise.all([ pickN(beforeCandidates, NEEDED), pickN(afterCandidates, NEEDED), ]); // before was fetched desc (closest first); reverse so result is chronological return [...before.reverse(), ...after]; } 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 cutoffHours = await this.getCutoffHours(); const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000); const isToday = now.getFullYear() === y && now.getMonth() === m - 1 && now.getDate() === d; const earliest = isToday ? cutoffThreshold : date; const schedules = await this.prisma.trainSchedule.findMany({ where: { status: 'SCHEDULED', isPackageOnly: false, departureAt: { gte: earliest, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, }, include: SCHEDULE_INCLUDE, }); const results = await Promise.all( schedules.map(schedule => this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) ) ); return results.filter((r): r is NonNullable => !!r && r.hasAvailability); } // ── Transit search ───────────────────────────────────────────────────────── 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, ) { 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 leg2WindowEnd = new Date(dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000); const totalPassengers = adultCount + (childCount ?? 0); // Load leg1 and all potential leg2 candidates in one parallel round-trip // instead of firing a separate DB query per transit stop. const [leg1Schedules, allCandidates] = await Promise.all([ this.prisma.trainSchedule.findMany({ where: { status: 'SCHEDULED', isPackageOnly: false, departureAt: { gte: dayStart, lt: dayEnd }, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, }, include: SCHEDULE_INCLUDE, }), this.prisma.trainSchedule.findMany({ where: { status: 'SCHEDULED', isPackageOnly: false, departureAt: { gte: dayStart, lt: leg2WindowEnd }, coachAssignments: { some: {} }, }, include: SCHEDULE_INCLUDE, }), ]); const cutoffHours = await this.getCutoffHours(); const cutoffThreshold = new Date(Date.now() + cutoffHours * 60 * 60 * 1000); const results: any[] = []; for (const leg1 of (leg1Schedules as ScheduleWithIncludes[]).filter(s => new Date(s.departureAt) > cutoffThreshold)) { const originStop = leg1.stopTimes.find(s => s.stationId === originStationId); if (!originStop) continue; const candidateTransitStops = leg1.stopTimes.filter( s => s.sequence > originStop.sequence, ); for (const transitStop of candidateTransitStops) { const leg1HasDest = leg1.stopTimes.some(s => s.stationId === destinationStationId); if (leg1HasDest) continue; const transitStationId = transitStop.stationId; const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt; 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); // Filter from pre-loaded candidates in memory — no extra DB query const leg2Schedules = (allCandidates as ScheduleWithIncludes[]).filter(s => { const dep = new Date(s.departureAt).getTime(); return dep >= connWindowStart.getTime() && dep <= connWindowEnd.getTime() && s.stopTimes.some(st => st.stationId === transitStationId); }); for (const leg2 of leg2Schedules) { const leg2TransitStop = leg2.stopTimes.find(s => s.stationId === transitStationId); const leg2DestStop = leg2.stopTimes.find(s => s.stationId === destinationStationId); if (!leg2TransitStop || !leg2DestStop) continue; if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue; 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 leg1MinDisplay = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.displayAmountMinor).filter((n: number) => n > 0), Infinity); const leg2MinDisplay = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.displayAmountMinor).filter((n: number) => n > 0), Infinity); const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0); const combinedMinFareDisplay = (isFinite(leg1MinDisplay) ? leg1MinDisplay : 0) + (isFinite(leg2MinDisplay) ? leg2MinDisplay : 0); const displayCurrency = leg1Result.displayCurrency ?? leg2Result.displayCurrency ?? Currency.ETB; results.push({ type: 'TRANSIT', transitStationId, transitStationName: transitStop.station.name, connectionMinutes, leg1: leg1Result, leg2: leg2Result, displayCurrency, combinedMinFareMinor, combinedMinFareDisplay, departureAt: leg1Result.departureAt, arrivalAt: leg2Result.arrivalAt, totalDurationMinutes: leg1Result.durationMinutes + connectionMinutes + leg2Result.durationMinutes, }); } } } return results; } private async buildScheduleResult( schedule: ScheduleWithIncludes, originStationId: string, destinationStationId: string, totalPassengers: number, nationality?: string, ) { const originStop = schedule.stopTimes.find(s => s.stationId === originStationId); const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId); if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; // Collect all valid seat IDs upfront for a single batch availability check const allValidSeatIds = schedule.coachAssignments.flatMap(a => a.coach.seats .filter((s: any) => s.status !== 'BLOCKED' && s.seatNumber?.trim()) .map((s: any) => s.id as string) ); // Exclude schedules with no seats at all if (allValidSeatIds.length === 0) return null; // Run availability batch and fare calculation in parallel const [freeSeats, faresByClass] = await Promise.all([ this.segmentsService.getFreeSeatIds( schedule.id, allValidSeatIds, schedule.stopTimes, originStop.sequence, destStop.sequence, ), this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality), ]); // Compute per-class availability using the pre-computed free seat set. A coach type // has separate seat classes per nationality tier (e.g. "VIP Bed Upper (Local)" AND // "VIP Bed Upper (Intl)" on the same coach) — filter to the searching passenger's own // nationality first, otherwise a name-based `.find()` across both tiers would credit // all availability to whichever tier happens to come first in the query result, // leaving the other tier's class permanently at 0 ("Fully booked") even when seats // are actually free. Matched via the class's own bedPosition field (case-insensitive: // Seat.bedPosition is lowercase, SeatClass.bedPosition is uppercase) rather than a // name substring, since that's an exact, unambiguous signal. const nationalityUpper = (nationality ?? '').toUpperCase(); const resolvedNationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') ? 'LOCAL' : 'INTERNATIONAL'; const availabilityByClass: Record = {}; for (const assignment of schedule.coachAssignments) { const seatClasses = (assignment.coach.coachType?.seatClasses ?? []).filter( (sc: any) => !sc.nationalityType || sc.nationalityType === resolvedNationalityType, ); 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; if (freeSeats.has(seat.id)) count++; } if (count > 0) { const matchingClass = seatClasses.find((sc: any) => sc.bedPosition?.toLowerCase() === bedPosition); if (matchingClass) availabilityByClass[matchingClass.name] = (availabilityByClass[matchingClass.name] ?? 0) + count; } } } else { let available = 0; for (const seat of assignment.coach.seats) { if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; if (freeSeats.has(seat.id)) available++; } const names = seatClasses.length > 0 ? seatClasses.map((sc: any) => sc.name) : ['Standard']; for (const name of names) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available; } } const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass, nationality, availabilityByClass); const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; const displayCurrency = faresByClass[0]?.displayCurrency ?? resolveCurrencyFromNationality(nationality); 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 => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) .map(st => ({ 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), displayCurrency, 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'); if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation'); 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 } }); if (!seatClass) throw new NotFoundException(`Seat class '${dto.seatClassName}' not found`); const fare = await this.fareEngine.calculate({ routeId: schedule.routeId, originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, seatClassId: seatClass.id, nationality: dto.nationality, scheduleId: dto.scheduleId, adultCount: dto.adultCount, childCount: dto.childCount ?? 0, promoCode: dto.promoCode, }); const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR; const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor); const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality); 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: fare.adultCount, childCount: fare.childCount, baseFareMinor: fare.baseFarePerPassengerMinor, adultFareMinor: fare.adultCount * fare.farePerPassengerMinor, childFareMinor: fare.paidChildrenCount * fare.farePerPassengerMinor, freeChildrenCount: fare.freeChildrenCount, paidChildrenCount: fare.paidChildrenCount, premiumMinor: fare.premiumPerPassenger, insuranceFeeMinor: fare.insurancePerPassenger, totalBaseFareMinor: fare.subtotalMinor, discountMinor: fare.discountMinor, taxesFeesMinor: 0, loyaltyRedemptionMinor: loyaltyMinor, totalMinor, currency: 'ETB', displayCurrency, displayTotalMinor, }; } async getFareBreakdown(dto: FareBreakdownRequestDto) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, select: { routeId: true, originStationId: true, destinationStationId: true }, }); if (!schedule) throw new NotFoundException('Schedule not found'); if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation'); const now = new Date(); const displayCurrency = dto.displayCurrency ?? Currency.ETB; let parsedPassengers: FareBreakdownPassengerDto[]; try { parsedPassengers = JSON.parse(dto.passengers as unknown as string); } catch { throw new NotFoundException('passengers must be a valid JSON array'); } // Categorise passengers by age const categorised = parsedPassengers.map(p => { const ageMs = now.getTime() - new Date(p.dateOfBirth).getTime(); const ageYears = ageMs / (1000 * 60 * 60 * 24 * 365.25); return { ...p, category: (ageYears >= 5 ? 'ADULT' : 'CHILD') as 'ADULT' | 'CHILD', ageYears }; }); const adultCount = categorised.filter(p => p.category === 'ADULT').length; const childCount = categorised.filter(p => p.category === 'CHILD').length; // Ask the fare engine for the authoritative free-child count using the full group // Use the first passenger's seatClassId as a representative — freeChildrenCount // depends only on adultCount/childCount, not on seat class. const groupFare = await this.fareEngine.calculate({ routeId: schedule.routeId!, originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, seatClassId: categorised[0].seatClassId, nationality: categorised[0].nationality, scheduleId: dto.scheduleId, adultCount, childCount, }); const freeChildrenAllowed = groupFare.freeChildrenCount; // Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup) let freeChildrenUsed = 0; const passengerLines = await Promise.all( categorised.map(async (p) => { const fare = await this.fareEngine.calculate({ routeId: schedule.routeId!, originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, seatClassId: p.seatClassId, nationality: p.nationality, scheduleId: dto.scheduleId, adultCount: 1, childCount: 0, }); const isFree = p.category === 'CHILD' && freeChildrenUsed < freeChildrenAllowed; if (isFree) freeChildrenUsed++; const fareMinor = isFree ? fare.premiumPerPassenger + fare.insurancePerPassenger : fare.farePerPassengerMinor; const displayFareMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(fareMinor, Currency.ETB, displayCurrency) : fareMinor; return { passengerName: p.passengerName, dateOfBirth: p.dateOfBirth, category: p.category, ageYears: Math.floor(p.ageYears), seatClassId: fare.seatClassId, seatClassName: fare.seatClassName, nationality: p.nationality ?? null, baseFareMinor: fare.baseFarePerPassengerMinor, premiumMinor: fare.premiumPerPassenger, insuranceFeeMinor: fare.insurancePerPassenger, fareMinor, isFree, displayCurrency, displayFareMinor, }; }), ); let subtotalMinor = passengerLines.reduce((sum, l) => sum + l.fareMinor, 0); 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(subtotalMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); } } const totalMinor = subtotalMinor - discountMinor; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; return { scheduleId: dto.scheduleId, originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, passengers: passengerLines, subtotalMinor, discountMinor, totalMinor, currency: 'ETB', displayCurrency, displayTotalMinor, }; } private async calculateFaresForSegment( schedule: ScheduleWithIncludes, originStationId: string, destinationStationId: string, nationality?: string, ): Promise> { const displayCurrency = resolveCurrencyFromNationality(nationality); const nationalityUpper = (nationality ?? '').toUpperCase(); const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') ? 'LOCAL' : 'INTERNATIONAL'; // Collect seat class IDs from the schedule include for the ID set, // but fetch fresh records from DB so updated baseFareMinor is always current const seatClassIdSet = new Set(); for (const a of schedule.coachAssignments) { for (const sc of (a.coach.coachType?.seatClasses ?? [])) { if (sc.isActive) seatClassIdSet.add(sc.id); } } const freshSeatClasses = await this.prisma.seatClass.findMany({ where: { id: { in: Array.from(seatClassIdSet) }, isActive: true, OR: [ { nationalityType: null }, { nationalityType: nationalityType }, ], }, }); const seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc])); const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor); if (seatClasses.length === 0) 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 { // Use the input seat class name (sc.name) so it always matches what // buildCoachTypeDetails looks up via coachType.seatClasses. The fare // engine may resolve a nationality-specific variant (nationalitySeatClass) // whose name differs from sc.name, which would cause the class to be // silently dropped from coachTypes and show N/A on the results page. seatClassName: sc.name, baseFareMinor: fare.totalMinor, displayCurrency: fare.billingCurrency as Currency, displayAmountMinor: fare.totalInBillingCurrency, }; } catch { return null; } }), ); const validResults = results.filter( (r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null, ); if (validResults.length > 0) return validResults; } // Fallback: use station codes from already-loaded stopTimes when available const originStop = schedule.stopTimes.find(st => st.stationId === originStationId); const destStop = schedule.stopTimes.find(st => st.stationId === destinationStationId); const originCode = originStop?.station?.code; const destCode = destStop?.station?.code; if (originCode && destCode) { const segmentRoute = `${originCode}-${destCode}`; const now = new Date(); const fareRules = await this.prisma.fareRule.findMany({ where: { route: segmentRoute, seatClassId: { in: seatClasses.map((sc: any) => sc.id) }, validFrom: { lte: now }, OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, }); if (fareRules.length > 0) { const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency); const TAX_RATE = 0.05; return fareRules.map(rule => { const totalMinor = rule.baseFareMinor + Math.round(rule.baseFareMinor * TAX_RATE); return { seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown', baseFareMinor: totalMinor, displayCurrency, displayAmountMinor: Math.round(totalMinor * exchangeRate), }; }); } } return []; } // buildCoachTypeDetails is pure in-memory — no async needed private buildCoachTypeDetails( schedule: ScheduleWithIncludes, faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, nationality?: string, availabilityByClass: Record = {}, ): Array<{ coachTypeId: string; coachTypeName: string; coachTypeCode: string; coachId: string; classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number; available: number }>; }> { 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 nationalityUpper = (nationality ?? '').toUpperCase(); const resolvedNationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') ? 'LOCAL' : 'INTERNATIONAL'; const entry = coachTypeMap.get(coachType.id)!; coachType.seatClasses?.forEach((sc: any) => { // Exclude classes that belong to the wrong nationality type if (sc.nationalityType && sc.nationalityType !== resolvedNationalityType) return; if (faresByClass.some(f => f.seatClassName === sc.name)) 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, displayCurrency: fareInfo.displayCurrency, displayAmountMinor: fareInfo.displayAmountMinor, available: availabilityByClass[className] ?? 0, }; }) .filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number; available: number } => c !== null) .sort((a, b) => a.baseFareMinor - b.baseFareMinor); if (classes.length === 0) continue; 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; }); } }