mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
454 lines
16 KiB
TypeScript
454 lines
16 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 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<string, number> = {};
|
|
|
|
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<Array<{ seatClassName: string; baseFareMinor: number }>> {
|
|
// 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<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;
|
|
}
|
|
|
|
/**
|
|
* Fallback method to get fares from FareRule table when fare engine fails
|
|
*/
|
|
private async getFallbackFares(
|
|
scheduleId: string,
|
|
originCode: string,
|
|
destCode: string,
|
|
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
|
|
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;
|
|
}
|
|
}
|