|
|
|
|
@@ -9,6 +9,30 @@ import { Currency } from '@prisma/client';
|
|
|
|
|
|
|
|
|
|
const POINTS_TO_MINOR = 10;
|
|
|
|
|
|
|
|
|
|
// Shape returned by the heavy schedule include used throughout this service
|
|
|
|
|
type ScheduleWithIncludes = {
|
|
|
|
|
id: string;
|
|
|
|
|
routeId: string | null;
|
|
|
|
|
departureAt: Date;
|
|
|
|
|
arrivalAt: Date;
|
|
|
|
|
status: string;
|
|
|
|
|
train: any;
|
|
|
|
|
originStation: any;
|
|
|
|
|
destinationStation: any;
|
|
|
|
|
stopTimes: Array<{ stationId: string; sequence: number; plannedArrivalAt: Date | null; plannedDepartureAt: Date | null; station: any }>;
|
|
|
|
|
coachAssignments: Array<{ coach: { id: string; seats: any[]; coachType: { id: string; name: string; code: string; seatClasses: any[] } | null } }>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const SCHEDULE_INCLUDE = {
|
|
|
|
|
train: true,
|
|
|
|
|
originStation: true,
|
|
|
|
|
destinationStation: true,
|
|
|
|
|
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
|
|
|
|
coachAssignments: {
|
|
|
|
|
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
|
|
|
|
|
},
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class SearchService {
|
|
|
|
|
constructor(
|
|
|
|
|
@@ -124,7 +148,7 @@ export class SearchService {
|
|
|
|
|
if (windowStart < now) windowStart.setTime(now.getTime());
|
|
|
|
|
|
|
|
|
|
const windowEnd = new Date(requestedDate);
|
|
|
|
|
windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); // exclusive upper bound
|
|
|
|
|
windowEnd.setDate(windowEnd.getDate() + daysAfter + 1);
|
|
|
|
|
|
|
|
|
|
const totalPassengers = adultCount + (childCount ?? 0);
|
|
|
|
|
|
|
|
|
|
@@ -139,30 +163,16 @@ export class SearchService {
|
|
|
|
|
],
|
|
|
|
|
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 } } } } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
include: SCHEDULE_INCLUDE,
|
|
|
|
|
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;
|
|
|
|
|
const results = await Promise.all(
|
|
|
|
|
schedules.map(schedule =>
|
|
|
|
|
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
return results.filter(Boolean);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async searchSchedules(
|
|
|
|
|
@@ -185,29 +195,18 @@ export class SearchService {
|
|
|
|
|
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 } } } } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
include: SCHEDULE_INCLUDE,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
const results = await Promise.all(
|
|
|
|
|
schedules.map(schedule =>
|
|
|
|
|
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
return results.filter(Boolean);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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;
|
|
|
|
|
|
|
|
|
|
@@ -219,82 +218,67 @@ export class SearchService {
|
|
|
|
|
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 leg2WindowEnd = new Date(dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
|
|
|
|
|
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 } } } } },
|
|
|
|
|
// Load leg1 and all potential leg2 candidates in one parallel round-trip
|
|
|
|
|
// instead of firing a separate DB query per transit stop.
|
|
|
|
|
const [leg1Schedules, allCandidates] = await Promise.all([
|
|
|
|
|
this.prisma.trainSchedule.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
status: { in: ['SCHEDULED', 'BOARDING'] },
|
|
|
|
|
departureAt: { gte: dayStart, lt: dayEnd },
|
|
|
|
|
stopTimes: { some: { stationId: originStationId } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
include: SCHEDULE_INCLUDE,
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.trainSchedule.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
status: { in: ['SCHEDULED', 'BOARDING'] },
|
|
|
|
|
departureAt: { gte: dayStart, lt: leg2WindowEnd },
|
|
|
|
|
},
|
|
|
|
|
include: SCHEDULE_INCLUDE,
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const results: any[] = [];
|
|
|
|
|
|
|
|
|
|
for (const leg1 of leg1Schedules) {
|
|
|
|
|
const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId);
|
|
|
|
|
for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
|
|
|
|
|
const originStop = leg1.stopTimes.find(s => 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,
|
|
|
|
|
s => 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 leg1HasDest = leg1.stopTimes.some(s => s.stationId === destinationStationId);
|
|
|
|
|
if (leg1HasDest) continue;
|
|
|
|
|
|
|
|
|
|
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 } } } } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
// Filter from pre-loaded candidates in memory — no extra DB query
|
|
|
|
|
const leg2Schedules = (allCandidates as ScheduleWithIncludes[]).filter(s => {
|
|
|
|
|
const dep = new Date(s.departureAt).getTime();
|
|
|
|
|
return dep >= connWindowStart.getTime()
|
|
|
|
|
&& dep <= connWindowEnd.getTime()
|
|
|
|
|
&& s.stopTimes.some(st => st.stationId === transitStationId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
const leg2TransitStop = leg2.stopTimes.find(s => s.stationId === transitStationId);
|
|
|
|
|
const leg2DestStop = leg2.stopTimes.find(s => 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),
|
|
|
|
|
@@ -326,7 +310,6 @@ export class SearchService {
|
|
|
|
|
displayCurrency,
|
|
|
|
|
combinedMinFareMinor,
|
|
|
|
|
combinedMinFareDisplay,
|
|
|
|
|
// Convenience top-level fields so round-trip filter can read them uniformly
|
|
|
|
|
departureAt: leg1Result.departureAt,
|
|
|
|
|
arrivalAt: leg2Result.arrivalAt,
|
|
|
|
|
totalDurationMinutes:
|
|
|
|
|
@@ -339,19 +322,37 @@ export class SearchService {
|
|
|
|
|
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,
|
|
|
|
|
schedule: ScheduleWithIncludes,
|
|
|
|
|
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);
|
|
|
|
|
const originStop = schedule.stopTimes.find(s => s.stationId === originStationId);
|
|
|
|
|
const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId);
|
|
|
|
|
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null;
|
|
|
|
|
|
|
|
|
|
// Collect all valid seat IDs upfront for a single batch availability check
|
|
|
|
|
const allValidSeatIds = schedule.coachAssignments.flatMap(a =>
|
|
|
|
|
a.coach.seats
|
|
|
|
|
.filter((s: any) => s.status !== 'BLOCKED' && s.seatNumber?.trim())
|
|
|
|
|
.map((s: any) => s.id as string)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Run availability batch and fare calculation in parallel
|
|
|
|
|
const [freeSeats, faresByClass] = await Promise.all([
|
|
|
|
|
this.segmentsService.getFreeSeatIds(
|
|
|
|
|
schedule.id,
|
|
|
|
|
allValidSeatIds,
|
|
|
|
|
schedule.stopTimes,
|
|
|
|
|
originStop.sequence,
|
|
|
|
|
destStop.sequence,
|
|
|
|
|
),
|
|
|
|
|
this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Compute per-class availability using the pre-computed free seat set
|
|
|
|
|
const availabilityByClass: Record<string, number> = {};
|
|
|
|
|
for (const assignment of schedule.coachAssignments) {
|
|
|
|
|
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
|
|
|
|
|
@@ -362,8 +363,7 @@ export class SearchService {
|
|
|
|
|
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 (freeSeats.has(seat.id)) count++;
|
|
|
|
|
}
|
|
|
|
|
if (count > 0) {
|
|
|
|
|
const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition));
|
|
|
|
|
@@ -374,15 +374,13 @@ export class SearchService {
|
|
|
|
|
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++;
|
|
|
|
|
if (freeSeats.has(seat.id)) 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 coachTypes = this.buildCoachTypeDetails(schedule, faresByClass);
|
|
|
|
|
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
|
|
|
|
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
|
|
|
|
|
|
|
|
|
@@ -400,8 +398,8 @@ export class SearchService {
|
|
|
|
|
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 })),
|
|
|
|
|
.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),
|
|
|
|
|
displayCurrency,
|
|
|
|
|
@@ -500,46 +498,31 @@ export class SearchService {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async calculateFaresForSegment(
|
|
|
|
|
schedule: any,
|
|
|
|
|
schedule: ScheduleWithIncludes,
|
|
|
|
|
originStationId: string,
|
|
|
|
|
destinationStationId: string,
|
|
|
|
|
nationality?: string,
|
|
|
|
|
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
|
|
|
|
|
const displayCurrency = resolveCurrencyFromNationality(nationality);
|
|
|
|
|
|
|
|
|
|
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 [];
|
|
|
|
|
// Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany
|
|
|
|
|
const seatClassMap = new Map<string, any>();
|
|
|
|
|
for (const a of schedule.coachAssignments) {
|
|
|
|
|
for (const sc of (a.coach.coachType?.seatClasses ?? [])) {
|
|
|
|
|
if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const seatClasses = Array.from(seatClassMap.values())
|
|
|
|
|
.sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor);
|
|
|
|
|
|
|
|
|
|
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 (seatClasses.length === 0) return [];
|
|
|
|
|
|
|
|
|
|
if (schedule.routeId) {
|
|
|
|
|
const results = await Promise.all(
|
|
|
|
|
seatClasses.map(async (sc) => {
|
|
|
|
|
try {
|
|
|
|
|
const fare = await this.fareEngine.calculate({
|
|
|
|
|
routeId: schedule.routeId,
|
|
|
|
|
routeId: schedule.routeId!,
|
|
|
|
|
originStationId,
|
|
|
|
|
destinationStationId,
|
|
|
|
|
seatClassId: sc.id,
|
|
|
|
|
@@ -552,8 +535,7 @@ export class SearchService {
|
|
|
|
|
displayCurrency: fare.billingCurrency as Currency,
|
|
|
|
|
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message);
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
@@ -562,36 +544,32 @@ export class SearchService {
|
|
|
|
|
const validResults = results.filter(
|
|
|
|
|
(r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null,
|
|
|
|
|
);
|
|
|
|
|
if (validResults.length > 0) {
|
|
|
|
|
return validResults;
|
|
|
|
|
}
|
|
|
|
|
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 } });
|
|
|
|
|
// Fallback: use station codes from already-loaded stopTimes when available
|
|
|
|
|
const originStop = schedule.stopTimes.find(st => st.stationId === originStationId);
|
|
|
|
|
const destStop = schedule.stopTimes.find(st => st.stationId === destinationStationId);
|
|
|
|
|
const originCode = originStop?.station?.code;
|
|
|
|
|
const destCode = destStop?.station?.code;
|
|
|
|
|
|
|
|
|
|
if (originStation && destStation) {
|
|
|
|
|
const segmentRoute = `${originStation.code}-${destStation.code}`;
|
|
|
|
|
if (originCode && destCode) {
|
|
|
|
|
const segmentRoute = `${originCode}-${destCode}`;
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
|
|
|
|
const fareRules = await this.prisma.fareRule.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
route: segmentRoute,
|
|
|
|
|
seatClassId: { in: seatClassIds },
|
|
|
|
|
seatClassId: { in: seatClasses.map((sc: any) => sc.id) },
|
|
|
|
|
validFrom: { lte: now },
|
|
|
|
|
OR: [
|
|
|
|
|
{ validUntil: null },
|
|
|
|
|
{ validUntil: { gte: 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]));
|
|
|
|
|
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
|
|
|
|
|
return fareRules.map(rule => ({
|
|
|
|
|
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
|
|
|
|
|
seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown',
|
|
|
|
|
baseFareMinor: rule.baseFareMinor,
|
|
|
|
|
displayCurrency,
|
|
|
|
|
displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate),
|
|
|
|
|
@@ -599,20 +577,20 @@ export class SearchService {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async buildCoachTypeDetails(
|
|
|
|
|
schedule: any,
|
|
|
|
|
// buildCoachTypeDetails is pure in-memory — no async needed
|
|
|
|
|
private buildCoachTypeDetails(
|
|
|
|
|
schedule: ScheduleWithIncludes,
|
|
|
|
|
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
|
|
|
|
|
): Promise<Array<{
|
|
|
|
|
): Array<{
|
|
|
|
|
coachTypeId: string;
|
|
|
|
|
coachTypeName: string;
|
|
|
|
|
coachTypeCode: string;
|
|
|
|
|
coachId: string;
|
|
|
|
|
classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>;
|
|
|
|
|
}>> {
|
|
|
|
|
}> {
|
|
|
|
|
const coachTypeMap = new Map<
|
|
|
|
|
string,
|
|
|
|
|
{ coachType: any; classNames: Set<string>; coachId: string }
|
|
|
|
|
@@ -682,14 +660,6 @@ export class SearchService {
|
|
|
|
|
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,
|
|
|
|
|
|