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

498 lines
17 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 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<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) {
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<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,
});
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<Array<{
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
classes: Array<{ name: string; baseFareMinor: number }>;
}>> {
const coachTypeMap = new Map<
string,
{ coachType: any; classNames: Set<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(),
});
}
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<string, number> = {
'Economy Regular': 35000,
'Economy Bed': 49000,
'VIP Bed': 63000,
};
return defaults[className] ?? 35000;
}
private defaultFare(seatClassName: string): number {
const fares: Record<string, number> = {
'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;
}
}