mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 15:30:56 +00:00
Searching on the last day of a month (e.g. 2026-07-31) built an invalid "next day" string like "2026-07-32" for the Prisma date-range filter, crashing POST /search with a PrismaClientValidationError in production. Affected searchSchedules, searchAlternatives, classifyEmptySearch, and searchTransitOptions — all four built the bound the same way. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1399 lines
49 KiB
TypeScript
1399 lines
49 KiB
TypeScript
import { Injectable, NotFoundException } from "@nestjs/common";
|
|
import { PrismaService } from "../../common/prisma.service";
|
|
import {
|
|
SearchTripsDto,
|
|
FareQuoteDto,
|
|
FareBreakdownRequestDto,
|
|
FareBreakdownPassengerDto,
|
|
AvailableDatesQueryDto,
|
|
} 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 { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils";
|
|
import { Currency, Prisma } from "@prisma/client";
|
|
import { Passenger } from "@edr/types";
|
|
|
|
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, outboundReason] = await Promise.all([
|
|
this.searchAlternatives(
|
|
dto.originStationId,
|
|
dto.destinationStationId,
|
|
dto.date,
|
|
dto.adultCount,
|
|
dto.childCount,
|
|
dto.nationality,
|
|
),
|
|
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date),
|
|
]);
|
|
return {
|
|
journeyType: "ONE_WAY",
|
|
outbound: [],
|
|
alternativeOutbound: alternativesOutbound,
|
|
requestedDate: dto.date,
|
|
outboundReason,
|
|
};
|
|
}
|
|
|
|
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, outboundReason, inboundReason] = 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([]),
|
|
outbound.length === 0
|
|
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date)
|
|
: Promise.resolve(undefined),
|
|
inbound.length === 0
|
|
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate)
|
|
: Promise.resolve(undefined),
|
|
]);
|
|
return {
|
|
journeyType: "ROUND_TRIP",
|
|
outbound,
|
|
inbound,
|
|
alternativeOutbound,
|
|
alternativeInbound,
|
|
requestedDate: dto.date,
|
|
requestedReturnDate: returnDate,
|
|
outboundReason,
|
|
inboundReason,
|
|
};
|
|
}
|
|
|
|
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,
|
|
) {
|
|
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
|
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
|
const requestedDate = new Date(`${dateStr}T00:00:00+03:00`);
|
|
const requestedNextDay = new Date(requestedDate.getTime() + 24 * 60 * 60 * 1000);
|
|
const now = new Date();
|
|
const totalPassengers = adultCount + (childCount ?? 0);
|
|
const NEEDED = 3;
|
|
|
|
// Include BOARDING alongside SCHEDULED: BOARDING is just an operational display status the
|
|
// schedule-level cron sets on a fixed 30-min-before-departure timer (see tasks.service.ts) —
|
|
// it does NOT mean booking is closed. The actual booking cutoff is per-stop and configurable
|
|
// (RouteStop/Route.checkinMinutesBefore), enforced below by buildScheduleResult's own live
|
|
// check against each stop's estimated arrival/departure. Excluding BOARDING here would
|
|
// silently impose a hidden, non-configurable 30-minute cutoff on top of that.
|
|
const baseWhere: Prisma.TrainScheduleWhereInput = {
|
|
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
|
isPackageOnly: false,
|
|
stopTimes: { some: { stationId: originStationId } },
|
|
coachAssignments: { some: {} },
|
|
};
|
|
|
|
// 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,
|
|
) {
|
|
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
|
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
|
const date = new Date(`${dateStr}T00:00:00+03:00`);
|
|
const nextDay = new Date(date.getTime() + 24 * 60 * 60 * 1000);
|
|
const totalPassengers = adultCount + (childCount ?? 0);
|
|
|
|
// Match on the schedule's own departure DATE only — do NOT use `now` as a lower bound here.
|
|
// A schedule whose origin has already departed (EN_ROUTE) can still have a later stop (e.g.
|
|
// Lebu, Adama) whose own cutoff hasn't passed; using the overall departureAt as a floor would
|
|
// wrongly exclude the whole schedule for those still-bookable downstream segments. The
|
|
// per-segment cutoff check in buildScheduleResult is the sole authority for whether THIS
|
|
// specific origin stop is still bookable, using each stop's own estimated arrival/departure.
|
|
const schedules = await this.prisma.trainSchedule.findMany({
|
|
where: {
|
|
// EN_ROUTE/BOARDING included alongside SCHEDULED — these are operational display
|
|
// statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere).
|
|
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
|
isPackageOnly: false,
|
|
departureAt: { gte: date, 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,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Only called when searchSchedules/searchTransitOptions found zero bookable results for a
|
|
* leg — classifies WHY, cheaply, by re-querying without the filters that already excluded
|
|
* everything. Priority order (most specific/actionable first): a station pair EDR never
|
|
* connects at all beats "nothing on this exact date", which beats "something exists but
|
|
* every option is cancelled/package-only/past cutoff/full" — see SearchEmptyReasonCode.
|
|
*/
|
|
private async classifyEmptySearch(
|
|
originStationId: string,
|
|
destinationStationId: string,
|
|
dateStr: string,
|
|
): Promise<Passenger.ISearchEmptyReason> {
|
|
const [origin, destination] = await Promise.all([
|
|
this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }),
|
|
this.prisma.station.findUnique({ where: { id: destinationStationId }, select: { name: true } }),
|
|
]);
|
|
const originStationName = origin?.name ?? "the origin station";
|
|
const destinationStationName = destination?.name ?? "the destination station";
|
|
const withCode = (code: Passenger.SearchEmptyReasonCode) => ({
|
|
code,
|
|
originStationName,
|
|
destinationStationName,
|
|
});
|
|
|
|
// 1. Does any active route connect these two stations, in this direction, at all —
|
|
// ignoring date entirely?
|
|
if (!(await this.routeExistsForPair(originStationId, destinationStationId))) {
|
|
return withCode(Passenger.SearchEmptyReasonCode.NoRoute);
|
|
}
|
|
|
|
// 2. A route exists — is there any schedule at all on the requested date for this pair
|
|
// (regardless of status/package/coach/cutoff — those are checked next)?
|
|
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
|
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
|
const date = new Date(`${dateStr}T00:00:00+03:00`);
|
|
const nextDay = new Date(date.getTime() + 24 * 60 * 60 * 1000);
|
|
const dayCandidates = await this.prisma.trainSchedule.findMany({
|
|
where: { departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } } },
|
|
select: {
|
|
status: true,
|
|
isPackageOnly: true,
|
|
departureAt: true,
|
|
route: {
|
|
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
|
|
},
|
|
stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } },
|
|
coachAssignments: { select: { id: true } },
|
|
},
|
|
});
|
|
const sameDayForPair = dayCandidates.filter((s) => {
|
|
const o = s.stopTimes.find((st) => st.stationId === originStationId);
|
|
const dst = s.stopTimes.find((st) => st.stationId === destinationStationId);
|
|
return !!o && !!dst && o.sequence < dst.sequence;
|
|
});
|
|
if (sameDayForPair.length === 0) return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
|
|
|
|
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
|
|
// (right status, not package-only, has at least one coach assigned).
|
|
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s));
|
|
if (bookable.length === 0) {
|
|
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
|
|
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
|
|
if (sameDayForPair.every((s) => s.isPackageOnly))
|
|
return withCode(Passenger.SearchEmptyReasonCode.PackageOnly);
|
|
return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
|
|
}
|
|
|
|
// 4. Bookable schedules exist — did every one of them already pass its check-in cutoff
|
|
// for this origin stop?
|
|
const allCutoffPassed = bookable.every((s) => {
|
|
const originStop = s.stopTimes.find((st) => st.stationId === originStationId) ?? null;
|
|
return Date.now() >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime();
|
|
});
|
|
if (allCutoffPassed) return withCode(Passenger.SearchEmptyReasonCode.CheckinClosed);
|
|
|
|
// 5. A bookable, still-open schedule exists for this pair/date — the only remaining reason
|
|
// searchSchedules dropped it is zero/insufficient seat availability.
|
|
return withCode(Passenger.SearchEmptyReasonCode.FullyBooked);
|
|
}
|
|
|
|
/**
|
|
* Whether any active route connects originStationId → destinationStationId in this
|
|
* direction, ignoring date/schedule state entirely. Shared by classifyEmptySearch and
|
|
* getAvailableDates.
|
|
*/
|
|
private async routeExistsForPair(originStationId: string, destinationStationId: string): Promise<boolean> {
|
|
const candidateRoutes = await this.prisma.route.findMany({
|
|
where: { active: true, stops: { some: { stationId: originStationId } } },
|
|
select: { stops: { select: { stationId: true, sequence: true } } },
|
|
});
|
|
return candidateRoutes.some((r) => {
|
|
const o = r.stops.find((s) => s.stationId === originStationId);
|
|
const d = r.stops.find((s) => s.stationId === destinationStationId);
|
|
return !!o && !!d && o.sequence < d.sequence;
|
|
});
|
|
}
|
|
|
|
/** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */
|
|
private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean {
|
|
return (
|
|
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
|
|
!s.isPackageOnly &&
|
|
s.coachAssignments.length > 0
|
|
);
|
|
}
|
|
|
|
private readonly MAX_AVAILABLE_DATES_SPAN_DAYS = 90;
|
|
private readonly ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000;
|
|
private readonly ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
/** Converts an absolute instant to its calendar date string in Africa/Addis_Ababa (fixed UTC+3, no DST). */
|
|
private toAddisDateStr(d: Date): string {
|
|
return new Date(d.getTime() + this.ADDIS_OFFSET_MS).toISOString().slice(0, 10);
|
|
}
|
|
|
|
/**
|
|
* For each date in the (server-clamped) range, whether at least one bookable schedule exists
|
|
* for originStationId → destinationStationId — used to disable schedule-less dates on the
|
|
* search date picker before the user submits a search. Reuses the same route-existence and
|
|
* bookability checks as classifyEmptySearch, plus the same check-in cutoff resolution used
|
|
* throughout this service, but does not compute seat-level availability (see buildScheduleResult)
|
|
* — a date can be marked available and still turn out fully booked when actually searched.
|
|
*/
|
|
async getAvailableDates(dto: AvailableDatesQueryDto) {
|
|
const { originStationId, destinationStationId } = dto;
|
|
|
|
const todayStr = this.toAddisDateStr(new Date());
|
|
const from = dto.from > todayStr ? dto.from : todayStr;
|
|
const fromDate = new Date(`${from}T00:00:00+03:00`);
|
|
|
|
const maxToDate = new Date(fromDate.getTime() + this.MAX_AVAILABLE_DATES_SPAN_DAYS * this.ONE_DAY_MS);
|
|
const requestedToDate = new Date(`${dto.to}T00:00:00+03:00`);
|
|
const toDate = requestedToDate < maxToDate ? requestedToDate : maxToDate;
|
|
const to = this.toAddisDateStr(toDate);
|
|
|
|
if (!(await this.routeExistsForPair(originStationId, destinationStationId))) {
|
|
return {
|
|
originStationId,
|
|
destinationStationId,
|
|
from,
|
|
to,
|
|
routeExists: false,
|
|
dates: [] as { date: string; available: boolean }[],
|
|
};
|
|
}
|
|
|
|
const rangeEnd = new Date(toDate.getTime() + this.ONE_DAY_MS);
|
|
const schedules = await this.prisma.trainSchedule.findMany({
|
|
where: {
|
|
departureAt: { gte: fromDate, lt: rangeEnd },
|
|
stopTimes: { some: { stationId: originStationId } },
|
|
},
|
|
select: {
|
|
departureAt: true,
|
|
status: true,
|
|
isPackageOnly: true,
|
|
route: {
|
|
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
|
|
},
|
|
stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } },
|
|
coachAssignments: { select: { id: true } },
|
|
},
|
|
});
|
|
|
|
const now = Date.now();
|
|
const availableDays = new Set<string>();
|
|
for (const s of schedules) {
|
|
const originStop = s.stopTimes.find((st) => st.stationId === originStationId);
|
|
const destinationStop = s.stopTimes.find((st) => st.stationId === destinationStationId);
|
|
if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) continue;
|
|
if (!this.isBookableSchedule(s)) continue;
|
|
if (now >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime()) continue;
|
|
availableDays.add(this.toAddisDateStr(s.departureAt));
|
|
}
|
|
|
|
const dates: { date: string; available: boolean }[] = [];
|
|
for (let cursor = fromDate; cursor <= toDate; cursor = new Date(cursor.getTime() + this.ONE_DAY_MS)) {
|
|
const dateStr = this.toAddisDateStr(cursor);
|
|
dates.push({ date: dateStr, available: availableDays.has(dateStr) });
|
|
}
|
|
|
|
return { originStationId, destinationStationId, from, to, routeExists: true, dates };
|
|
}
|
|
|
|
// ── 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,
|
|
) {
|
|
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
|
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
|
const dayStart = new Date(`${dateStr}T00:00:00+03:00`);
|
|
const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
|
|
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: {
|
|
// BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere.
|
|
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
|
isPackageOnly: false,
|
|
departureAt: { gte: dayStart, lt: dayEnd },
|
|
stopTimes: { some: { stationId: originStationId } },
|
|
coachAssignments: { some: {} },
|
|
},
|
|
include: SCHEDULE_INCLUDE,
|
|
}),
|
|
this.prisma.trainSchedule.findMany({
|
|
where: {
|
|
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
|
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 own estimated arrival time, not the
|
|
// schedule's overall departureAt (which is station A's time). This lets
|
|
// B→D remain bookable even after A→D closes. Stop-level checkinMinutesBefore override →
|
|
// route default → 30 min fallback — same resolution GuestBookingService applies at
|
|
// booking-creation time, so a segment shown as bookable here stays bookable through
|
|
// checkout instead of being rejected against a different, hardcoded cutoff.
|
|
if (Date.now() >= resolveCheckinCutoff(schedule, originStop, originStationId).cutoffAt.getTime())
|
|
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,
|
|
);
|
|
// Use the selected stop's own planned time, not the schedule's full-route span —
|
|
// for stop-based (mid-route) boarding/alighting these differ from the train's
|
|
// overall origin departure / final destination arrival.
|
|
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: 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;
|
|
});
|
|
}
|
|
}
|