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

357 lines
13 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SearchTripsDto, FareQuoteDto } from './search.dto';
import { CurrencyService } from '../currency/currency.service';
import { Currency } from '@prisma/client';
const POINTS_TO_MINOR = 10;
@Injectable()
export class SearchService {
constructor(
private prisma: PrismaService,
private currencyService: CurrencyService,
) {}
async searchTrips(dto: SearchTripsDto) {
const date = new Date(dto.date);
const nextDay = new Date(date.getTime() + 86_400_000);
const totalPassengers = dto.adultCount + (dto.childCount ?? 0);
// Find all schedules that have BOTH origin and destination as stops
// (not just terminal-to-terminal) and depart on the requested date
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
departureAt: { gte: date, lt: nextDay },
stopTimes: { some: { stationId: dto.originStationId } },
},
include: {
train: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, seatClass: true } } },
},
},
});
const results = [];
for (const schedule of schedules) {
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
// Both stops must exist and origin must come before destination
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
// Compute per-seat availability for the requested segment range
// A seat is available if no active booking/hold overlaps [originSeq, destSeq)
const availabilityByClass: Record<string, number> = {};
for (const assignment of schedule.coachAssignments) {
const className = assignment.coach.seatClass.name;
if (!availabilityByClass[className]) availabilityByClass[className] = 0;
for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED') continue;
const free = await this.isSeatFreeForSegment(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) availabilityByClass[className]++;
}
}
// Departure/arrival times for the requested leg (not the full schedule)
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
results.push({
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,
// Only return stops within the requested leg (origin → destination inclusive)
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),
});
}
return results;
}
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');
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find(s => 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 } });
// Compute route codes for fare lookup
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
const now = new Date();
const nationality = dto.nationality;
// Query fare rules with specificity ordering:
// 1. schedule+segment+nationality
// 2. schedule+segment
// 3. schedule+full-route+nationality
// 4. schedule+full-route
// 5. schedule+global
// 6. segment+nationality
// 7. segment
// 8. full-route+nationality
// 9. full-route
// 10. global
const fareRule = await this.prisma.fareRule.findFirst({
where: {
seatClassId: seatClass?.id,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
orderBy: [
// Prioritize schedule-specific rules
{ tripId: { sort: 'desc', nulls: 'last' } },
// Then prioritize nationality match
{ nationality: { sort: 'desc', nulls: 'last' } },
// Most recent validFrom
{ validFrom: 'desc' },
],
});
// Manual specificity filtering to find best match
const candidates = await this.prisma.fareRule.findMany({
where: {
seatClassId: seatClass?.id,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
});
const bestMatch = this.selectBestFareRule(
candidates,
dto.scheduleId,
segmentRoute,
fullRoute,
nationality,
);
const baseFareMinor = bestMatch?.baseFareMinor ?? this.defaultFare(dto.seatClassName);
const adultCount = dto.adultCount;
const childCount = dto.childCount ?? 0;
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
const childFareMinor = baseFareMinor * paidChildrenCount;
const totalBaseFareMinor = adultFareMinor + childFareMinor;
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(totalBaseFareMinor * promo.percentOff / 100)
: (promo.amountOffMinor ?? 0);
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const displayCurrency = dto.displayCurrency ?? Currency.ETB;
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, childCount,
baseFareMinor, adultFareMinor, childFareMinor,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount, totalBaseFareMinor,
discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor, totalMinor,
currency: 'ETB', displayCurrency, displayTotalMinor,
};
}
/**
* Returns true if the seat has no active hold or confirmed booking
* whose segment range overlaps [fromSeq, toSeq).
* Overlap condition: existingFrom < toSeq AND fromSeq < existingTo
*/
private async isSeatFreeForSegment(
scheduleId: string,
seatId: string,
fromSeq: number,
toSeq: number,
): Promise<boolean> {
// Check active holds that include this seat on this schedule
const holds = await this.prisma.seatHold.findMany({
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
});
for (const hold of holds) {
// Resolve hold segment range from its stored origin/destination via JourneySegment
// For holds we use the stop sequences stored on the hold's origin/destination
// Since SeatHold doesn't store sequences directly, we check JourneySegments
// that reference this seat on this schedule with PENDING_PAYMENT status
const holdSegs = await this.prisma.journeySegment.findMany({
where: { scheduleId, seatId },
include: {
journey: true,
schedule: { include: { stopTimes: true } },
},
});
for (const js of holdSegs) {
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
if (depSeq !== undefined && arrSeq !== undefined) {
if (depSeq < toSeq && fromSeq < arrSeq) return false;
}
}
// If no journey segments yet (hold just created), treat the whole hold as blocking
if (holdSegs.length === 0) return false;
}
// Check confirmed/pending bookings via JourneySegment
const bookedSegments = await this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId,
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
include: {
schedule: { include: { stopTimes: true } },
},
});
for (const js of bookedSegments) {
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
if (depSeq !== undefined && arrSeq !== undefined) {
if (depSeq < toSeq && fromSeq < arrSeq) return false;
}
}
return true;
}
private defaultFare(seatClassName: string): number {
const fares: Record<string, number> = {
'Economy Regular': 45000,
'Economy Bed': 65000,
'VIP Bed': 95000,
};
return fares[seatClassName] ?? 45000;
}
/**
* Select the best matching fare rule based on specificity:
* 1. schedule+segment+nationality
* 2. schedule+segment
* 3. schedule+full-route+nationality
* 4. schedule+full-route
* 5. schedule+global
* 6. segment+nationality
* 7. segment
* 8. full-route+nationality
* 9. full-route
* 10. global
*/
private selectBestFareRule(
candidates: any[],
scheduleId: string,
segmentRoute: string,
fullRoute: string,
nationality?: string,
): any | null {
const priorities = [
// Schedule-specific rules
{ tripId: scheduleId, route: segmentRoute, nationality },
{ tripId: scheduleId, route: segmentRoute, nationality: null },
{ tripId: scheduleId, route: fullRoute, nationality },
{ tripId: scheduleId, route: fullRoute, nationality: null },
{ tripId: scheduleId, route: null, nationality },
{ tripId: scheduleId, route: null, nationality: null },
// Route-specific rules (no schedule)
{ tripId: null, route: segmentRoute, nationality },
{ tripId: null, route: segmentRoute, nationality: null },
{ tripId: null, route: fullRoute, nationality },
{ tripId: null, route: fullRoute, nationality: null },
// Global rules
{ tripId: null, route: null, nationality },
{ tripId: null, route: null, nationality: null },
];
for (const priority of priorities) {
const match = candidates.find(
(c) =>
c.tripId === priority.tripId &&
c.route === priority.route &&
c.nationality === priority.nationality,
);
if (match) return match;
}
return null;
}
}