Files
edr-platform/apps/edr-passenger-api/src/modules/search/search.service.ts

704 lines
26 KiB
TypeScript

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