mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 12:28:21 +00:00
1212 lines
39 KiB
TypeScript
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;
|
|
});
|
|
}
|
|
}
|