import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "../../common/prisma.service"; import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, FareBreakdownPassengerDto, AvailableDatesQueryDto, } 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 { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils"; import { Currency, Prisma } from "@prisma/client"; import { Passenger } from "@edr/types"; 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; route: { checkinMinutesBefore: number; stops: Array<{ stationId: string; checkinMinutesBefore: number | null }>; } | null; 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, route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: 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, ) {} 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, outboundReason] = await Promise.all([ this.searchAlternatives( dto.originStationId, dto.destinationStationId, dto.date, dto.adultCount, dto.childCount, dto.nationality, ), this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date), ]); return { journeyType: "ONE_WAY", outbound: [], alternativeOutbound: alternativesOutbound, requestedDate: dto.date, outboundReason, }; } 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, outboundReason, inboundReason] = 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([]), outbound.length === 0 ? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date) : Promise.resolve(undefined), inbound.length === 0 ? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate) : Promise.resolve(undefined), ]); return { journeyType: "ROUND_TRIP", outbound, inbound, alternativeOutbound, alternativeInbound, requestedDate: dto.date, requestedReturnDate: returnDate, outboundReason, inboundReason, }; } 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, ) { // Real millisecond arithmetic, not string-padded day-of-month increment — the latter // produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month. const requestedDate = new Date(`${dateStr}T00:00:00+03:00`); const requestedNextDay = new Date(requestedDate.getTime() + 24 * 60 * 60 * 1000); const now = new Date(); const totalPassengers = adultCount + (childCount ?? 0); const NEEDED = 3; // Include BOARDING alongside SCHEDULED: BOARDING is just an operational display status the // schedule-level cron sets on a fixed 30-min-before-departure timer (see tasks.service.ts) — // it does NOT mean booking is closed. The actual booking cutoff is per-stop and configurable // (RouteStop/Route.checkinMinutesBefore), enforced below by buildScheduleResult's own live // check against each stop's estimated arrival/departure. Excluding BOARDING here would // silently impose a hidden, non-configurable 30-minute cutoff on top of that. const baseWhere: Prisma.TrainScheduleWhereInput = { status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, }; // 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< Awaited> >[] = []; 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, ) { // Real millisecond arithmetic, not string-padded day-of-month increment — the latter // produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month. const date = new Date(`${dateStr}T00:00:00+03:00`); const nextDay = new Date(date.getTime() + 24 * 60 * 60 * 1000); const totalPassengers = adultCount + (childCount ?? 0); // Match on the schedule's own departure DATE only — do NOT use `now` as a lower bound here. // A schedule whose origin has already departed (EN_ROUTE) can still have a later stop (e.g. // Lebu, Adama) whose own cutoff hasn't passed; using the overall departureAt as a floor would // wrongly exclude the whole schedule for those still-bookable downstream segments. The // per-segment cutoff check in buildScheduleResult is the sole authority for whether THIS // specific origin stop is still bookable, using each stop's own estimated arrival/departure. const schedules = await this.prisma.trainSchedule.findMany({ where: { // EN_ROUTE/BOARDING included alongside SCHEDULED — these are operational display // statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere). status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, departureAt: { gte: date, 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, ); } /** * Only called when searchSchedules/searchTransitOptions found zero bookable results for a * leg — classifies WHY, cheaply, by re-querying without the filters that already excluded * everything. Priority order (most specific/actionable first): a station pair EDR never * connects at all beats "nothing on this exact date", which beats "something exists but * every option is cancelled/package-only/past cutoff/full" — see SearchEmptyReasonCode. */ private async classifyEmptySearch( originStationId: string, destinationStationId: string, dateStr: string, ): Promise { const [origin, destination] = await Promise.all([ this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }), this.prisma.station.findUnique({ where: { id: destinationStationId }, select: { name: true } }), ]); const originStationName = origin?.name ?? "the origin station"; const destinationStationName = destination?.name ?? "the destination station"; const withCode = (code: Passenger.SearchEmptyReasonCode) => ({ code, originStationName, destinationStationName, }); // 1. Does any active route connect these two stations, in this direction, at all — // ignoring date entirely? if (!(await this.routeExistsForPair(originStationId, destinationStationId))) { return withCode(Passenger.SearchEmptyReasonCode.NoRoute); } // 2. A route exists — is there any schedule at all on the requested date for this pair // (regardless of status/package/coach/cutoff — those are checked next)? // Real millisecond arithmetic, not string-padded day-of-month increment — the latter // produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month. const date = new Date(`${dateStr}T00:00:00+03:00`); const nextDay = new Date(date.getTime() + 24 * 60 * 60 * 1000); const dayCandidates = await this.prisma.trainSchedule.findMany({ where: { departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } } }, select: { status: true, isPackageOnly: true, departureAt: true, route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } }, }, stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } }, coachAssignments: { select: { id: true } }, }, }); const sameDayForPair = dayCandidates.filter((s) => { const o = s.stopTimes.find((st) => st.stationId === originStationId); const dst = s.stopTimes.find((st) => st.stationId === destinationStationId); return !!o && !!dst && o.sequence < dst.sequence; }); if (sameDayForPair.length === 0) return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate); // 3. Schedules exist that date — narrow to ones that would otherwise be bookable // (right status, not package-only, has at least one coach assigned). const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s)); if (bookable.length === 0) { if (sameDayForPair.every((s) => s.status === "CANCELLED")) return withCode(Passenger.SearchEmptyReasonCode.Cancelled); if (sameDayForPair.every((s) => s.isPackageOnly)) return withCode(Passenger.SearchEmptyReasonCode.PackageOnly); return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate); } // 4. Bookable schedules exist — did every one of them already pass its check-in cutoff // for this origin stop? const allCutoffPassed = bookable.every((s) => { const originStop = s.stopTimes.find((st) => st.stationId === originStationId) ?? null; return Date.now() >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime(); }); if (allCutoffPassed) return withCode(Passenger.SearchEmptyReasonCode.CheckinClosed); // 5. A bookable, still-open schedule exists for this pair/date — the only remaining reason // searchSchedules dropped it is zero/insufficient seat availability. return withCode(Passenger.SearchEmptyReasonCode.FullyBooked); } /** * Whether any active route connects originStationId → destinationStationId in this * direction, ignoring date/schedule state entirely. Shared by classifyEmptySearch and * getAvailableDates. */ private async routeExistsForPair(originStationId: string, destinationStationId: string): Promise { const candidateRoutes = await this.prisma.route.findMany({ where: { active: true, stops: { some: { stationId: originStationId } } }, select: { stops: { select: { stationId: true, sequence: true } } }, }); return candidateRoutes.some((r) => { const o = r.stops.find((s) => s.stationId === originStationId); const d = r.stops.find((s) => s.stationId === destinationStationId); return !!o && !!d && o.sequence < d.sequence; }); } /** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */ private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean { return ( (["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) && !s.isPackageOnly && s.coachAssignments.length > 0 ); } private readonly MAX_AVAILABLE_DATES_SPAN_DAYS = 90; private readonly ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000; private readonly ONE_DAY_MS = 24 * 60 * 60 * 1000; /** Converts an absolute instant to its calendar date string in Africa/Addis_Ababa (fixed UTC+3, no DST). */ private toAddisDateStr(d: Date): string { return new Date(d.getTime() + this.ADDIS_OFFSET_MS).toISOString().slice(0, 10); } /** * For each date in the (server-clamped) range, whether at least one bookable schedule exists * for originStationId → destinationStationId — used to disable schedule-less dates on the * search date picker before the user submits a search. Reuses the same route-existence and * bookability checks as classifyEmptySearch, plus the same check-in cutoff resolution used * throughout this service, but does not compute seat-level availability (see buildScheduleResult) * — a date can be marked available and still turn out fully booked when actually searched. */ async getAvailableDates(dto: AvailableDatesQueryDto) { const { originStationId, destinationStationId } = dto; const todayStr = this.toAddisDateStr(new Date()); const from = dto.from > todayStr ? dto.from : todayStr; const fromDate = new Date(`${from}T00:00:00+03:00`); const maxToDate = new Date(fromDate.getTime() + this.MAX_AVAILABLE_DATES_SPAN_DAYS * this.ONE_DAY_MS); const requestedToDate = new Date(`${dto.to}T00:00:00+03:00`); const toDate = requestedToDate < maxToDate ? requestedToDate : maxToDate; const to = this.toAddisDateStr(toDate); if (!(await this.routeExistsForPair(originStationId, destinationStationId))) { return { originStationId, destinationStationId, from, to, routeExists: false, dates: [] as { date: string; available: boolean }[], }; } const rangeEnd = new Date(toDate.getTime() + this.ONE_DAY_MS); const schedules = await this.prisma.trainSchedule.findMany({ where: { departureAt: { gte: fromDate, lt: rangeEnd }, stopTimes: { some: { stationId: originStationId } }, }, select: { departureAt: true, status: true, isPackageOnly: true, route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } }, }, stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } }, coachAssignments: { select: { id: true } }, }, }); const now = Date.now(); const availableDays = new Set(); for (const s of schedules) { const originStop = s.stopTimes.find((st) => st.stationId === originStationId); const destinationStop = s.stopTimes.find((st) => st.stationId === destinationStationId); if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) continue; if (!this.isBookableSchedule(s)) continue; if (now >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime()) continue; availableDays.add(this.toAddisDateStr(s.departureAt)); } const dates: { date: string; available: boolean }[] = []; for (let cursor = fromDate; cursor <= toDate; cursor = new Date(cursor.getTime() + this.ONE_DAY_MS)) { const dateStr = this.toAddisDateStr(cursor); dates.push({ date: dateStr, available: availableDays.has(dateStr) }); } return { originStationId, destinationStationId, from, to, routeExists: true, dates }; } // ── 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, ) { // Real millisecond arithmetic, not string-padded day-of-month increment — the latter // produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month. const dayStart = new Date(`${dateStr}T00:00:00+03:00`); const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); 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: { // BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere. status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, departureAt: { gte: dayStart, lt: dayEnd }, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, }, include: SCHEDULE_INCLUDE, }), this.prisma.trainSchedule.findMany({ where: { status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, departureAt: { gte: dayStart, lt: leg2WindowEnd }, coachAssignments: { some: {} }, }, include: SCHEDULE_INCLUDE, }), ]); const results: any[] = []; for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) { 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; // Segment-level cutoff: use the origin stop's own estimated arrival time, not the // schedule's overall departureAt (which is station A's time). This lets // B→D remain bookable even after A→D closes. Stop-level checkinMinutesBefore override → // route default → 30 min fallback — same resolution GuestBookingService applies at // booking-creation time, so a segment shown as bookable here stays bookable through // checkout instead of being rejected against a different, hardcoded cutoff. if (Date.now() >= resolveCheckinCutoff(schedule, originStop, originStationId).cutoffAt.getTime()) 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, fare calculation, and schedule-scoped seat blocks in parallel const [freeSeatsRaw, faresByClass, scheduleBlocks] = await Promise.all([ this.segmentsService.getFreeSeatIds( schedule.id, allValidSeatIds, schedule.stopTimes, originStop.sequence, destStop.sequence, ), this.calculateFaresForSegment( schedule, originStationId, destinationStationId, nationality, ), this.prisma.seatBlock.findMany({ where: { scheduleId: schedule.id, seatId: { in: allValidSeatIds }, }, select: { seatId: true }, }), ]); const scheduleBlockedIds = new Set( scheduleBlocks.map((b: any) => b.seatId), ); // Seats that are free from holds/bookings AND not schedule-blocked const freeSeats = new Set( [...freeSeatsRaw].filter((id) => !scheduleBlockedIds.has(id)), ); // 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, ); // Use the selected stop's own planned time, not the schedule's full-route span — // for stop-based (mid-route) boarding/alighting these differ from the train's // overall origin departure / final destination arrival. 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: displayTotalMinor, currency: displayCurrency, 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; // Pre-compute isFree per passenger index synchronously so the race-free // counter assignment isn't corrupted by concurrent Promise.all resolution. let freeChildrenUsed = 0; const isFreeByIndex = categorised.map((p) => { if (p.category === "CHILD" && freeChildrenUsed < freeChildrenAllowed) { freeChildrenUsed++; return true; } return false; }); // Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup) const passengerLines = await Promise.all( categorised.map(async (p, idx) => { 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 = isFreeByIndex[idx]; 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: displayTotalMinor, currency: displayCurrency, displayCurrency, displayTotalMinor, }; } private async calculateFaresForSegment( schedule: ScheduleWithIncludes, originStationId: string, destinationStationId: string, nationality?: string, ): Promise< Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number; }> > { 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; }); } }