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

843 lines
34 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, FareBreakdownPassengerDto } from './search.dto';
import { CurrencyService } from '../currency/currency.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { SegmentsService } from '../segments/segments.service';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { Currency } from '@prisma/client';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
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(
private prisma: PrismaService,
private currencyService: CurrencyService,
private fareEngine: FareEngineService,
private segmentsService: SegmentsService,
private systemConfig: SystemConfigService,
) {}
private async getCutoffHours(): Promise<number> {
return this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE);
}
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 && dto.journeyType !== 'ROUND_TRIP') {
const alternativesOutbound = await this.searchAlternatives(
dto.originStationId,
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
);
return {
journeyType: '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
);
const returnDate = dto.returnDate ?? dto.date;
if (outbound.length === 0 || inbound.length === 0) {
const [alternativeOutbound, alternativeInbound] = await Promise.all([
outbound.length === 0
? this.searchAlternatives(dto.originStationId, dto.destinationStationId, dto.date, dto.adultCount, dto.childCount, dto.nationality)
: Promise.resolve([]),
inbound.length === 0
? this.searchAlternatives(dto.destinationStationId, dto.originStationId, returnDate, dto.adultCount, dto.childCount, dto.nationality)
: Promise.resolve([]),
]);
return {
journeyType: 'ROUND_TRIP',
outbound,
inbound,
alternativeOutbound,
alternativeInbound,
requestedDate: dto.date,
requestedReturnDate: returnDate,
};
}
return { journeyType: 'ROUND_TRIP', outbound, inbound, requestedDate: dto.date, requestedReturnDate: returnDate };
}
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 requestedNextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
const now = new Date();
const totalPassengers = adultCount + (childCount ?? 0);
const NEEDED = 3;
const baseWhere = {
status: 'SCHEDULED',
isPackageOnly: false,
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
} as const;
// Fetch candidates before and after in parallel; take more than needed to
// account for routes that don't serve the destination or have no availability.
const FETCH_LIMIT = NEEDED * 5;
const [beforeCandidates, afterCandidates] = await Promise.all([
this.prisma.trainSchedule.findMany({
where: { ...baseWhere, departureAt: { gte: now < requestedDate ? now : new Date(0), lt: requestedDate } },
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: 'desc' },
take: FETCH_LIMIT,
}),
this.prisma.trainSchedule.findMany({
where: { ...baseWhere, departureAt: { gte: requestedNextDay > now ? requestedNextDay : now } },
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: 'asc' },
take: FETCH_LIMIT,
}),
]);
const pickN = async (candidates: typeof beforeCandidates, limit: number) => {
const out: NonNullable<Awaited<ReturnType<typeof this.buildScheduleResult>>>[] = [];
for (const schedule of candidates) {
if (out.length >= limit) break;
const r = await this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality);
if (r?.hasAvailability) out.push(r);
}
return out;
};
const [before, after] = await Promise.all([
pickN(beforeCandidates, NEEDED),
pickN(afterCandidates, NEEDED),
]);
// before was fetched desc (closest first); reverse so result is chronological
return [...before.reverse(), ...after];
}
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 cutoffHours = await this.getCutoffHours();
const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000);
const isToday = now.getFullYear() === y && now.getMonth() === m - 1 && now.getDate() === d;
const earliest = isToday ? cutoffThreshold : date;
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: 'SCHEDULED',
isPackageOnly: false,
departureAt: { gte: earliest, lt: nextDay },
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
});
const results = await Promise.all(
schedules.map(schedule =>
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
)
);
return results.filter((r): r is NonNullable<typeof r> => !!r && r.hasAvailability);
}
// ── Transit search ─────────────────────────────────────────────────────────
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,
) {
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 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: 'SCHEDULED',
isPackageOnly: false,
departureAt: { gte: dayStart, lt: dayEnd },
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
}),
this.prisma.trainSchedule.findMany({
where: {
status: 'SCHEDULED',
isPackageOnly: false,
departureAt: { gte: dayStart, lt: leg2WindowEnd },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
}),
]);
const cutoffHours = await this.getCutoffHours();
const cutoffThreshold = new Date(Date.now() + cutoffHours * 60 * 60 * 1000);
const results: any[] = [];
for (const leg1 of (leg1Schedules as ScheduleWithIncludes[]).filter(s => new Date(s.departureAt) > cutoffThreshold)) {
const originStop = leg1.stopTimes.find(s => s.stationId === originStationId);
if (!originStop) continue;
const candidateTransitStops = leg1.stopTimes.filter(
s => s.sequence > originStop.sequence,
);
for (const transitStop of candidateTransitStops) {
const leg1HasDest = leg1.stopTimes.some(s => s.stationId === destinationStationId);
if (leg1HasDest) continue;
const transitStationId = transitStop.stationId;
const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt;
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);
// 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 => s.stationId === transitStationId);
const leg2DestStop = leg2.stopTimes.find(s => s.stationId === destinationStationId);
if (!leg2TransitStop || !leg2DestStop) continue;
if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue;
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 leg1MinDisplay = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.displayAmountMinor).filter((n: number) => n > 0), Infinity);
const leg2MinDisplay = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.displayAmountMinor).filter((n: number) => n > 0), Infinity);
const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0);
const combinedMinFareDisplay = (isFinite(leg1MinDisplay) ? leg1MinDisplay : 0) + (isFinite(leg2MinDisplay) ? leg2MinDisplay : 0);
const displayCurrency = leg1Result.displayCurrency ?? leg2Result.displayCurrency ?? Currency.ETB;
results.push({
type: 'TRANSIT',
transitStationId,
transitStationName: transitStop.station.name,
connectionMinutes,
leg1: leg1Result,
leg2: leg2Result,
displayCurrency,
combinedMinFareMinor,
combinedMinFareDisplay,
departureAt: leg1Result.departureAt,
arrivalAt: leg2Result.arrivalAt,
totalDurationMinutes:
leg1Result.durationMinutes + connectionMinutes + leg2Result.durationMinutes,
});
}
}
}
return results;
}
private async buildScheduleResult(
schedule: ScheduleWithIncludes,
originStationId: string,
destinationStationId: string,
totalPassengers: number,
nationality?: string,
) {
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)
);
// Exclude schedules with no seats at all
if (allValidSeatIds.length === 0) return null;
// 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. A coach type
// has separate seat classes per nationality tier (e.g. "VIP Bed Upper (Local)" AND
// "VIP Bed Upper (Intl)" on the same coach) — filter to the searching passenger's own
// nationality first, otherwise a name-based `.find()` across both tiers would credit
// all availability to whichever tier happens to come first in the query result,
// leaving the other tier's class permanently at 0 ("Fully booked") even when seats
// are actually free. Matched via the class's own bedPosition field (case-insensitive:
// Seat.bedPosition is lowercase, SeatClass.bedPosition is uppercase) rather than a
// name substring, since that's an exact, unambiguous signal.
const nationalityUpper = (nationality ?? '').toUpperCase();
const resolvedNationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
? 'LOCAL' : 'INTERNATIONAL';
const availabilityByClass: Record<string, number> = {};
for (const assignment of schedule.coachAssignments) {
const seatClasses = (assignment.coach.coachType?.seatClasses ?? []).filter(
(sc: any) => !sc.nationalityType || sc.nationalityType === resolvedNationalityType,
);
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;
if (freeSeats.has(seat.id)) count++;
}
if (count > 0) {
const matchingClass = seatClasses.find((sc: any) => sc.bedPosition?.toLowerCase() === bedPosition);
if (matchingClass) availabilityByClass[matchingClass.name] = (availabilityByClass[matchingClass.name] ?? 0) + count;
}
}
} else {
let available = 0;
for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
if (freeSeats.has(seat.id)) available++;
}
const names = seatClasses.length > 0 ? seatClasses.map((sc: any) => sc.name) : ['Standard'];
for (const name of names) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available;
}
}
const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass, nationality, availabilityByClass);
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
const displayCurrency = faresByClass[0]?.displayCurrency ?? resolveCurrencyFromNationality(nationality);
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 => 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,
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');
if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
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 } });
if (!seatClass) throw new NotFoundException(`Seat class '${dto.seatClassName}' not found`);
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
seatClassId: seatClass.id,
nationality: dto.nationality,
scheduleId: dto.scheduleId,
adultCount: dto.adultCount,
childCount: dto.childCount ?? 0,
promoCode: dto.promoCode,
});
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor);
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
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: fare.adultCount,
childCount: fare.childCount,
baseFareMinor: fare.baseFarePerPassengerMinor,
adultFareMinor: fare.adultCount * fare.farePerPassengerMinor,
childFareMinor: fare.paidChildrenCount * fare.farePerPassengerMinor,
freeChildrenCount: fare.freeChildrenCount,
paidChildrenCount: fare.paidChildrenCount,
premiumMinor: fare.premiumPerPassenger,
insuranceFeeMinor: fare.insurancePerPassenger,
totalBaseFareMinor: fare.subtotalMinor,
discountMinor: fare.discountMinor,
taxesFeesMinor: 0,
loyaltyRedemptionMinor: loyaltyMinor,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
};
}
async getFareBreakdown(dto: FareBreakdownRequestDto) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
select: { routeId: true, originStationId: true, destinationStationId: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
const now = new Date();
const displayCurrency = dto.displayCurrency ?? Currency.ETB;
let parsedPassengers: FareBreakdownPassengerDto[];
try {
parsedPassengers = JSON.parse(dto.passengers as unknown as string);
} catch {
throw new NotFoundException('passengers must be a valid JSON array');
}
// Categorise passengers by age
const categorised = parsedPassengers.map(p => {
const ageMs = now.getTime() - new Date(p.dateOfBirth).getTime();
const ageYears = ageMs / (1000 * 60 * 60 * 24 * 365.25);
return { ...p, category: (ageYears >= 5 ? 'ADULT' : 'CHILD') as 'ADULT' | 'CHILD', ageYears };
});
const adultCount = categorised.filter(p => p.category === 'ADULT').length;
const childCount = categorised.filter(p => p.category === 'CHILD').length;
// Ask the fare engine for the authoritative free-child count using the full group
// Use the first passenger's seatClassId as a representative — freeChildrenCount
// depends only on adultCount/childCount, not on seat class.
const groupFare = await this.fareEngine.calculate({
routeId: schedule.routeId!,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
seatClassId: categorised[0].seatClassId,
nationality: categorised[0].nationality,
scheduleId: dto.scheduleId,
adultCount,
childCount,
});
const freeChildrenAllowed = groupFare.freeChildrenCount;
// Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup)
let freeChildrenUsed = 0;
const passengerLines = await Promise.all(
categorised.map(async (p) => {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId!,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
seatClassId: p.seatClassId,
nationality: p.nationality,
scheduleId: dto.scheduleId,
adultCount: 1,
childCount: 0,
});
const isFree = p.category === 'CHILD' && freeChildrenUsed < freeChildrenAllowed;
if (isFree) freeChildrenUsed++;
const fareMinor = isFree
? fare.premiumPerPassenger + fare.insurancePerPassenger
: fare.farePerPassengerMinor;
const displayFareMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(fareMinor, Currency.ETB, displayCurrency)
: fareMinor;
return {
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
category: p.category,
ageYears: Math.floor(p.ageYears),
seatClassId: fare.seatClassId,
seatClassName: fare.seatClassName,
nationality: p.nationality ?? null,
baseFareMinor: fare.baseFarePerPassengerMinor,
premiumMinor: fare.premiumPerPassenger,
insuranceFeeMinor: fare.insurancePerPassenger,
fareMinor,
isFree,
displayCurrency,
displayFareMinor,
};
}),
);
let subtotalMinor = passengerLines.reduce((sum, l) => sum + l.fareMinor, 0);
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(subtotalMinor * promo.percentOff / 100)
: (promo.amountOffMinor ?? 0);
}
}
const totalMinor = subtotalMinor - discountMinor;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
return {
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
passengers: passengerLines,
subtotalMinor,
discountMinor,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
};
}
private async calculateFaresForSegment(
schedule: ScheduleWithIncludes,
originStationId: string,
destinationStationId: string,
nationality?: string,
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
const displayCurrency = resolveCurrencyFromNationality(nationality);
const nationalityUpper = (nationality ?? '').toUpperCase();
const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
? 'LOCAL' : 'INTERNATIONAL';
// Collect seat class IDs from the schedule include for the ID set,
// but fetch fresh records from DB so updated baseFareMinor is always current
const seatClassIdSet = new Set<string>();
for (const a of schedule.coachAssignments) {
for (const sc of (a.coach.coachType?.seatClasses ?? [])) {
if (sc.isActive) seatClassIdSet.add(sc.id);
}
}
const freshSeatClasses = await this.prisma.seatClass.findMany({
where: {
id: { in: Array.from(seatClassIdSet) },
isActive: true,
OR: [
{ nationalityType: null },
{ nationalityType: nationalityType },
],
},
});
const seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc]));
const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
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!,
originStationId,
destinationStationId,
seatClassId: sc.id,
nationality,
scheduleId: schedule.id,
});
return {
// Use the input seat class name (sc.name) so it always matches what
// buildCoachTypeDetails looks up via coachType.seatClasses. The fare
// engine may resolve a nationality-specific variant (nationalitySeatClass)
// whose name differs from sc.name, which would cause the class to be
// silently dropped from coachTypes and show N/A on the results page.
seatClassName: sc.name,
baseFareMinor: fare.totalMinor,
displayCurrency: fare.billingCurrency as Currency,
displayAmountMinor: fare.totalInBillingCurrency,
};
} catch {
return null;
}
}),
);
const validResults = results.filter(
(r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null,
);
if (validResults.length > 0) return validResults;
}
// 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 (originCode && destCode) {
const segmentRoute = `${originCode}-${destCode}`;
const now = new Date();
const fareRules = await this.prisma.fareRule.findMany({
where: {
route: segmentRoute,
seatClassId: { in: seatClasses.map((sc: any) => sc.id) },
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
if (fareRules.length > 0) {
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
const TAX_RATE = 0.05;
return fareRules.map(rule => {
const totalMinor = rule.baseFareMinor + Math.round(rule.baseFareMinor * TAX_RATE);
return {
seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown',
baseFareMinor: totalMinor,
displayCurrency,
displayAmountMinor: Math.round(totalMinor * exchangeRate),
};
});
}
}
return [];
}
// buildCoachTypeDetails is pure in-memory — no async needed
private buildCoachTypeDetails(
schedule: ScheduleWithIncludes,
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
nationality?: string,
availabilityByClass: Record<string, number> = {},
): Array<{
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
coachId: string;
classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number; available: 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 nationalityUpper = (nationality ?? '').toUpperCase();
const resolvedNationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
? 'LOCAL' : 'INTERNATIONAL';
const entry = coachTypeMap.get(coachType.id)!;
coachType.seatClasses?.forEach((sc: any) => {
// Exclude classes that belong to the wrong nationality type
if (sc.nationalityType && sc.nationalityType !== resolvedNationalityType) return;
if (faresByClass.some(f => f.seatClassName === sc.name)) 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,
displayCurrency: fareInfo.displayCurrency,
displayAmountMinor: fareInfo.displayAmountMinor,
available: availabilityByClass[className] ?? 0,
};
})
.filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number; available: number } => c !== null)
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
if (classes.length === 0) continue;
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;
});
}
}