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