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 outbound = await this.searchSchedules( dto.originStationId, dto.destinationStationId, dto.date, dto.adultCount, dto.childCount, dto.nationality, ); if (dto.journeyType === 'ROUND_TRIP') { const allInbound = await this.searchSchedules( dto.destinationStationId, dto.originStationId, dto.returnDate ?? dto.date, dto.adultCount, dto.childCount, dto.nationality, ); const latestOutboundArrival = outbound.length > 0 ? Math.max(...outbound.map((s) => new Date(s.arrivalAt).getTime())) : Date.now(); const inbound = allInbound.filter((schedule) => new Date(schedule.departureAt).getTime() > latestOutboundArrival ); return { journeyType: 'ROUND_TRIP', outbound, inbound }; } return { journeyType: 'ONE_WAY', outbound }; } 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 totalPassengers = adultCount + (childCount ?? 0); const schedules = await this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, departureAt: { gte: 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 = []; for (const schedule of schedules) { 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) continue; 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) { const bedPositions = ['upper', 'middle', 'lower']; for (const bedPosition of bedPositions) { let count = 0; for (const seat of assignment.coach.seats) { if (seat.bedPosition !== bedPosition) continue; if (seat.status === 'BLOCKED') continue; if (!seat.seatNumber || !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((className: string) => { const classNameLower = className.toLowerCase(); return ( (bedPosition === 'upper' && classNameLower.includes('upper')) || (bedPosition === 'middle' && classNameLower.includes('middle')) || (bedPosition === 'lower' && classNameLower.includes('lower')) ); }); if (matchingClass) { if (!availabilityByClass[matchingClass]) availabilityByClass[matchingClass] = 0; availabilityByClass[matchingClass] += count; } } } } else { let availableSeatsInCoach = 0; for (const seat of assignment.coach.seats) { if (seat.status === 'BLOCKED') continue; if (!seat.seatNumber || !seat.seatNumber.trim()) continue; const free = await this.segmentsService.isSeatFreeForLeg( schedule.id, seat.id, originStop.sequence, destStop.sequence, ); if (free) availableSeatsInCoach++; } for (const seatClassName of seatClassNames) { if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0; availabilityByClass[seatClassName] += availableSeatsInCoach; } } } const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; const faresByClass = await this.calculateFaresForSegment( schedule, originStationId, destinationStationId, nationality, ); const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass); 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: 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, }); } 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: 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 ?? 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, }; } 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, }); 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, using defaults for ${originStationId} to ${destinationStationId}`); return seatClasses.map(sc => ({ seatClassName: sc.name, baseFareMinor: this.getDefaultFareForClass(sc.name), })); } private async buildCoachTypeDetails( schedule: any, faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>, ): Promise; }>> { const coachTypeMap = new Map< string, { coachType: any; classNames: Set } >(); 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(), }); } const entry = coachTypeMap.get(coachType.id)!; coachType.seatClasses?.forEach((sc: any) => entry.classNames.add(sc.name)); } const result = []; for (const [, { coachType, classNames }] of coachTypeMap) { const classes = Array.from(classNames) .map((className) => { const fareInfo = faresByClass.find((f) => f.seatClassName === className); return { name: className, baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className), }; }) .sort((a, b) => a.baseFareMinor - b.baseFareMinor); result.push({ coachTypeId: coachType.id, coachTypeName: coachType.name, coachTypeCode: coachType.code, 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 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; } 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; } }