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

1212 lines
39 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";
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;
route: {
checkinMinutesBefore: number;
stops: Array<{ stationId: string; checkinMinutesBefore: number | null }>;
} | null;
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,
route: {
select: {
checkinMinutesBefore: true,
stops: { select: { stationId: true, checkinMinutesBefore: 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,
) {}
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(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
);
const requestedNextDay = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
);
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(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
);
const nextDay = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
);
const now = new Date();
const totalPassengers = adultCount + (childCount ?? 0);
// Use now as the lower bound for today so we don't fetch schedules that have
// already fully departed. The per-segment cutoff check in buildScheduleResult
// handles the exact check using each stop's own plannedDepartureAt.
const isToday =
now.getFullYear() === y &&
now.getMonth() === m - 1 &&
now.getDate() === d;
const earliest = isToday ? now : 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(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
);
const dayEnd = new Date(
`${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
);
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 results: any[] = [];
for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
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;
// Segment-level cutoff: use the origin stop's planned departure, not the
// schedule's overall departureAt (which is station A's time). This lets
// B→D remain bookable even after A→D closes.
// Cutoff resolution: stop-level override → route default → 30 min fallback.
const now = new Date();
const segmentDepartureAt =
originStop.plannedDepartureAt ?? schedule.departureAt;
const routeStop = schedule.route?.stops?.find(
(s) => s.stationId === originStationId,
);
const checkinMinutes =
routeStop?.checkinMinutesBefore ??
schedule.route?.checkinMinutesBefore ??
30;
if (
segmentDepartureAt.getTime() - now.getTime() <=
checkinMinutes * 60 * 1000
)
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, fare calculation, and schedule-scoped seat blocks in parallel
const [freeSeatsRaw, faresByClass, scheduleBlocks] = await Promise.all([
this.segmentsService.getFreeSeatIds(
schedule.id,
allValidSeatIds,
schedule.stopTimes,
originStop.sequence,
destStop.sequence,
),
this.calculateFaresForSegment(
schedule,
originStationId,
destinationStationId,
nationality,
),
this.prisma.seatBlock.findMany({
where: {
scheduleId: schedule.id,
seatId: { in: allValidSeatIds },
},
select: { seatId: true },
}),
]);
const scheduleBlockedIds = new Set(
scheduleBlocks.map((b: any) => b.seatId),
);
// Seats that are free from holds/bookings AND not schedule-blocked
const freeSeats = new Set(
[...freeSeatsRaw].filter((id) => !scheduleBlockedIds.has(id)),
);
// 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 = schedule.departureAt;
const legArrivalAt = 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: displayTotalMinor,
currency: displayCurrency,
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;
// Pre-compute isFree per passenger index synchronously so the race-free
// counter assignment isn't corrupted by concurrent Promise.all resolution.
let freeChildrenUsed = 0;
const isFreeByIndex = categorised.map((p) => {
if (p.category === "CHILD" && freeChildrenUsed < freeChildrenAllowed) {
freeChildrenUsed++;
return true;
}
return false;
});
// Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup)
const passengerLines = await Promise.all(
categorised.map(async (p, idx) => {
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 = isFreeByIndex[idx];
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: displayTotalMinor,
currency: displayCurrency,
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;
});
}
}