mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 12:30:58 +00:00
498 lines
17 KiB
TypeScript
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;
|
|
}
|
|
}
|