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 date = new Date(dto.date); const nextDay = new Date(date.getTime() + 86_400_000); const totalPassengers = dto.adultCount + (dto.childCount ?? 0); // Find all schedules that have BOTH origin and destination as stops // (not just terminal-to-terminal) and depart on the requested date const schedules = await this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: dto.originStationId } }, }, include: { train: true, originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, coachAssignments: { include: { coach: { include: { seats: true, seatClass: true } } }, }, }, }); const results = []; for (const schedule of schedules) { const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId); const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); // Both stops must exist and origin must come before destination if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue; // Compute per-seat availability for the requested segment range // A seat is available if no active booking/hold overlaps [originSeq, destSeq) const availabilityByClass: Record = {}; for (const assignment of schedule.coachAssignments) { const className = assignment.coach.seatClass.name; if (!availabilityByClass[className]) availabilityByClass[className] = 0; for (const seat of assignment.coach.seats) { if (seat.status === 'BLOCKED') continue; // Use segment-aware check — a seat booked A→B is still free for B→D const free = await this.segmentsService.isSeatFreeForLeg( schedule.id, seat.id, originStop.sequence, destStop.sequence, ); if (free) availabilityByClass[className]++; } } // Departure/arrival times for the requested leg (not the full schedule) const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; // Fetch fares for all seat classes - need to pass the SEARCH origin/destination, not schedule terminals const faresByClass = await this.calculateFaresForSegment( schedule, dto.originStationId, dto.destinationStationId, dto.nationality, ); results.push({ 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), faresByClass, }); } return results; } 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 => s.stationId === dto.originStationId); const destStop = schedule.stopTimes.find(s => 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 } }); // Compute route codes for fare lookup const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; const now = new Date(); const nationality = dto.nationality; // Query fare rules with specificity ordering: // 1. schedule+segment+nationality // 2. schedule+segment // 3. schedule+full-route+nationality // 4. schedule+full-route // 5. schedule+global // 6. segment+nationality // 7. segment // 8. full-route+nationality // 9. full-route // 10. global const fareRule = await this.prisma.fareRule.findFirst({ where: { seatClassId: seatClass?.id, validFrom: { lte: now }, OR: [ { validUntil: null }, { validUntil: { gte: now } }, ], }, orderBy: [ // Prioritize schedule-specific rules { tripId: { sort: 'desc', nulls: 'last' } }, // Then prioritize nationality match { nationality: { sort: 'desc', nulls: 'last' } }, // Most recent validFrom { validFrom: 'desc' }, ], }); // Manual specificity filtering to find best match 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 ?? this.defaultFare(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, }; } /** * Calculate fares for a specific segment of a schedule */ private async calculateFaresForSegment( schedule: any, originStationId: string, destinationStationId: string, nationality?: string, ): Promise> { // Get seat classes that are actually assigned to this schedule via coaches const assignedSeatClassIds: string[] = Array.from( new Set( schedule.coachAssignments.map((a: any) => a.coach.seatClass.id as string) ) ); // Get only the seat classes that are assigned to this schedule const seatClasses = await this.prisma.seatClass.findMany({ where: { isActive: true, id: { in: assignedSeatClassIds } }, orderBy: { basePrice: 'asc' }, }); // If no coaches assigned, return empty array if (seatClasses.length === 0) { console.log(`No seat classes assigned to schedule ${schedule.id}`); return []; } // If schedule has a route, use route-based calculation 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, }); 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; } } // Fallback: Try to get fares from FareRule table 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: assignedSeatClassIds }, validFrom: { lte: now }, OR: [ { validUntil: null }, { validUntil: { gte: now } }, ], }, include: { seatClass: true }, }); if (fareRules.length > 0) { console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`); return fareRules.map(rule => ({ seatClassName: rule.seatClass.name, baseFareMinor: rule.baseFareMinor, })); } } // Last resort: Return default fares only for assigned seat classes console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`); return seatClasses.map(sc => ({ seatClassName: sc.name, baseFareMinor: this.getDefaultFareForClass(sc.name), })); } private getDefaultFareForClass(className: string): number { const defaults: Record = { 'Economy Regular': 35000, 'Economy Bed': 49000, 'VIP Bed': 63000, }; return defaults[className] ?? 35000; } private defaultFare(seatClassName: string): number { const fares: Record = { 'Economy Regular': 45000, 'Economy Bed': 65000, 'VIP Bed': 95000, }; return fares[seatClassName] ?? 45000; } /** * Fallback method to get fares from FareRule table when fare engine fails */ private async getFallbackFares( scheduleId: string, originCode: string, destCode: string, ): Promise> { const segmentRoute = `${originCode}-${destCode}`; const now = new Date(); // Try to find fare rules for this segment const fareRules = await this.prisma.fareRule.findMany({ where: { route: segmentRoute, validFrom: { lte: now }, OR: [ { validUntil: null }, { validUntil: { gte: now } }, ], }, include: { seatClass: true }, }); if (fareRules.length > 0) { console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`); return fareRules.map(rule => ({ seatClassName: rule.seatClass.name, baseFareMinor: rule.baseFareMinor, })); } // If no segment-specific rules, return default fares console.log(`No fare rules found for ${segmentRoute}, using defaults`); return [ { seatClassName: 'Economy Regular', baseFareMinor: 35000 }, { seatClassName: 'Economy Bed', baseFareMinor: 49000 }, { seatClassName: 'VIP Bed', baseFareMinor: 63000 }, ]; } /** * Select the best matching fare rule based on specificity: * 1. schedule+segment+nationality * 2. schedule+segment * 3. schedule+full-route+nationality * 4. schedule+full-route * 5. schedule+global * 6. segment+nationality * 7. segment * 8. full-route+nationality * 9. full-route * 10. global */ private selectBestFareRule( candidates: any[], scheduleId: string, segmentRoute: string, fullRoute: string, nationality?: string, ): any | null { const priorities = [ // Schedule-specific rules { 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 }, // Route-specific rules (no schedule) { tripId: null, route: segmentRoute, nationality }, { tripId: null, route: segmentRoute, nationality: null }, { tripId: null, route: fullRoute, nationality }, { tripId: null, route: fullRoute, nationality: null }, // Global rules { 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; } }