diff --git a/apps/edr-passenger-api/src/common/utils/timezone.utils.ts b/apps/edr-passenger-api/src/common/utils/timezone.utils.ts index d21a8a7dc..325a14d1f 100644 --- a/apps/edr-passenger-api/src/common/utils/timezone.utils.ts +++ b/apps/edr-passenger-api/src/common/utils/timezone.utils.ts @@ -18,12 +18,11 @@ * parseEthiopianTime('2026-06-15') // Treats as midnight EAT */ export function parseEthiopianTime(dateInput: string | Date): Date { - if (dateInput instanceof Date) { - return dateInput; - } - - // Parse as local time (EAT) since TZ is set to Africa/Addis_Ababa - return new Date(dateInput); + if (dateInput instanceof Date) return dateInput; + // Already has timezone info (Z or +HH:MM) — parse directly as UTC + if (/Z$|[+-]\d{2}:\d{2}$/.test(dateInput)) return new Date(dateInput); + // Bare local string (e.g. "2026-07-23T21:00") — treat explicitly as EAT (UTC+3) + return new Date(dateInput + '+03:00'); } /** diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 6ecd04834..477fba83a 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -447,7 +447,7 @@ export class ReportsService { _count: { select: { seats: true } }, }, }, - seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } } } } }, + seat: { select: { seatNumber: true, bedPosition: true, coach: { select: { number: true, coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } } } } } }, }, orderBy: [{ seat: { coach: { number: "asc" } } }, { seat: { seatNumber: "asc" } }], }); @@ -474,13 +474,25 @@ export class ReportsService { passportNumber: bs.passportNumber, passportCountry: bs.passportCountry, seatLabel: bs.seatLabelSnapshot, + seatNumber: bs.seat?.seatNumber ?? null, + seatClassName: (() => { + const classes = bs.seat?.coach?.coachType?.seatClasses ?? []; + const matched = bs.seat?.bedPosition + ? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === bs.seat!.bedPosition!.toLowerCase()) + : null; + return (matched ?? classes[0])?.name ?? bs.seat?.coach?.coachType?.name ?? null; + })(), coachNumber: bs.seat?.coach?.number ?? null, - coachType: (bs.seat?.coach as any)?.coachType?.name ?? null, + coachType: bs.seat?.coach?.coachType?.name ?? null, + nationality: bs.passportCountry + ? (bs.passportCountry === 'Djibouti' ? 'Djiboutian' : bs.passportCountry) + : bs.idDocumentType === 'NATIONAL_ID' ? 'Ethiopian' : null, origin: bs.booking.originStationId ? (stationName.get(bs.booking.originStationId) ?? null) : null, destination: bs.booking.destinationStationId ? (stationName.get(bs.booking.destinationStationId) ?? null) : null, amountPaidMinor: bs.booking.totalMinor, currency: bs.booking.currency ?? 'ETB', isGroupBooking: (bs.booking._count?.seats ?? 0) > 1, + bookingStatus: bs.booking.status, })); } diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 18bd9ce09..6ed657975 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -1,11 +1,16 @@ -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 { 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; @@ -19,19 +24,48 @@ type ScheduleWithIncludes = { 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 } }>; + 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' } }, + 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 } } } } }, + include: { + coach: { + include: { seats: true, coachType: { include: { seatClasses: true } } }, + }, + }, }, } as const; @@ -66,7 +100,7 @@ export class SearchService { const outbound = [...direct, ...transit]; - if (outbound.length === 0 && dto.journeyType !== 'ROUND_TRIP') { + if (outbound.length === 0 && dto.journeyType !== "ROUND_TRIP") { const alternativesOutbound = await this.searchAlternatives( dto.originStationId, dto.destinationStationId, @@ -76,14 +110,14 @@ export class SearchService { dto.nationality, ); return { - journeyType: 'ONE_WAY', + journeyType: "ONE_WAY", outbound: [], alternativeOutbound: alternativesOutbound, requestedDate: dto.date, }; } - if (dto.journeyType === 'ROUND_TRIP') { + if (dto.journeyType === "ROUND_TRIP") { const [returnDirect, returnTransit] = await Promise.all([ this.searchSchedules( dto.destinationStationId, @@ -104,12 +138,19 @@ export class SearchService { ]); 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 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 inbound = allReturn.filter( + (s: any) => + new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > + latestOutboundArrival, ); const returnDate = dto.returnDate ?? dto.date; @@ -117,14 +158,28 @@ export class SearchService { 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) + ? 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) + ? this.searchAlternatives( + dto.destinationStationId, + dto.originStationId, + returnDate, + dto.adultCount, + dto.childCount, + dto.nationality, + ) : Promise.resolve([]), ]); return { - journeyType: 'ROUND_TRIP', + journeyType: "ROUND_TRIP", outbound, inbound, alternativeOutbound, @@ -134,10 +189,16 @@ export class SearchService { }; } - return { journeyType: 'ROUND_TRIP', outbound, inbound, requestedDate: dto.date, requestedReturnDate: returnDate }; + return { + journeyType: "ROUND_TRIP", + outbound, + inbound, + requestedDate: dto.date, + requestedReturnDate: returnDate, + }; } - return { journeyType: 'ONE_WAY', outbound }; + return { journeyType: "ONE_WAY", outbound }; } private async searchAlternatives( @@ -148,15 +209,19 @@ export class SearchService { 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 [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', + status: "SCHEDULED", isPackageOnly: false, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, @@ -168,24 +233,44 @@ export class SearchService { const [beforeCandidates, afterCandidates] = await Promise.all([ this.prisma.trainSchedule.findMany({ - where: { ...baseWhere, departureAt: { gte: now < requestedDate ? now : new Date(0), lt: requestedDate } }, + where: { + ...baseWhere, + departureAt: { + gte: now < requestedDate ? now : new Date(0), + lt: requestedDate, + }, + }, include: SCHEDULE_INCLUDE, - orderBy: { departureAt: 'desc' }, + orderBy: { departureAt: "desc" }, take: FETCH_LIMIT, }), this.prisma.trainSchedule.findMany({ - where: { ...baseWhere, departureAt: { gte: requestedNextDay > now ? requestedNextDay : now } }, + where: { + ...baseWhere, + departureAt: { gte: requestedNextDay > now ? requestedNextDay : now }, + }, include: SCHEDULE_INCLUDE, - orderBy: { departureAt: 'asc' }, + orderBy: { departureAt: "asc" }, take: FETCH_LIMIT, }), ]); - const pickN = async (candidates: typeof beforeCandidates, limit: number) => { - const out: NonNullable>>[] = []; + const pickN = async ( + candidates: typeof beforeCandidates, + limit: number, + ) => { + const out: NonNullable< + Awaited> + >[] = []; for (const schedule of candidates) { if (out.length >= limit) break; - const r = await this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality); + const r = await this.buildScheduleResult( + schedule as any, + originStationId, + destinationStationId, + totalPassengers, + nationality, + ); if (r?.hasAvailability) out.push(r); } return out; @@ -208,21 +293,28 @@ export class SearchService { 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 [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 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', + status: "SCHEDULED", isPackageOnly: false, departureAt: { gte: earliest, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, @@ -232,11 +324,19 @@ export class SearchService { }); const results = await Promise.all( - schedules.map(schedule => - this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) - ) + schedules.map((schedule) => + this.buildScheduleResult( + schedule as any, + originStationId, + destinationStationId, + totalPassengers, + nationality, + ), + ), + ); + return results.filter( + (r): r is NonNullable => !!r && r.hasAvailability, ); - return results.filter((r): r is NonNullable => !!r && r.hasAvailability); } // ── Transit search ───────────────────────────────────────────────────────── @@ -251,10 +351,16 @@ export class SearchService { 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 [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 @@ -262,7 +368,7 @@ export class SearchService { const [leg1Schedules, allCandidates] = await Promise.all([ this.prisma.trainSchedule.findMany({ where: { - status: 'SCHEDULED', + status: "SCHEDULED", isPackageOnly: false, departureAt: { gte: dayStart, lt: dayEnd }, stopTimes: { some: { stationId: originStationId } }, @@ -272,7 +378,7 @@ export class SearchService { }), this.prisma.trainSchedule.findMany({ where: { - status: 'SCHEDULED', + status: "SCHEDULED", isPackageOnly: false, departureAt: { gte: dayStart, lt: leg2WindowEnd }, coachAssignments: { some: {} }, @@ -284,61 +390,125 @@ export class SearchService { const results: any[] = []; for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) { - const originStop = leg1.stopTimes.find(s => s.stationId === originStationId); + const originStop = leg1.stopTimes.find( + (s) => s.stationId === originStationId, + ); if (!originStop) continue; const candidateTransitStops = leg1.stopTimes.filter( - s => s.sequence > originStop.sequence, + (s) => s.sequence > originStop.sequence, ); for (const transitStop of candidateTransitStops) { - const leg1HasDest = leg1.stopTimes.some(s => s.stationId === destinationStationId); + 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 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); + 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); - }); + 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); + 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), + this.buildScheduleResult( + leg1, + originStationId, + transitStationId, + totalPassengers, + nationality, + ), + this.buildScheduleResult( + leg2, + transitStationId, + destinationStationId, + totalPassengers, + nationality, + ), ]); if (!leg1Result || !leg2Result) continue; - if (!leg1Result.hasAvailability || !leg2Result.hasAvailability) continue; + if (!leg1Result.hasAvailability || !leg2Result.hasAvailability) + continue; - const leg2DepartureAt = leg2TransitStop.plannedDepartureAt ?? leg2.departureAt; + const leg2DepartureAt = + leg2TransitStop.plannedDepartureAt ?? leg2.departureAt; const connectionMinutes = Math.round( - (new Date(leg2DepartureAt).getTime() - new Date(leg1ArrivalAt).getTime()) / 60_000, + (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; + 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', + type: "TRANSIT", transitStationId, transitStationName: transitStop.station.name, connectionMinutes, @@ -348,9 +518,11 @@ export class SearchService { combinedMinFareMinor, combinedMinFareDisplay, departureAt: leg1Result.departureAt, - arrivalAt: leg2Result.arrivalAt, + arrivalAt: leg2Result.arrivalAt, totalDurationMinutes: - leg1Result.durationMinutes + connectionMinutes + leg2Result.durationMinutes, + leg1Result.durationMinutes + + connectionMinutes + + leg2Result.durationMinutes, }); } } @@ -366,25 +538,40 @@ export class SearchService { 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; + 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; + 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 => + 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) + .filter((s: any) => s.status !== "BLOCKED" && s.seatNumber?.trim()) + .map((s: any) => s.id as string), ); // Exclude schedules with no seats at all @@ -399,7 +586,12 @@ export class SearchService { originStop.sequence, destStop.sequence, ), - this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality), + this.calculateFaresForSegment( + schedule, + originStationId, + destinationStationId, + nationality, + ), this.prisma.seatBlock.findMany({ where: { scheduleId: schedule.id, @@ -408,9 +600,13 @@ export class SearchService { select: { seatId: true }, }), ]); - const scheduleBlockedIds = new Set(scheduleBlocks.map((b: any) => b.seatId)); + 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))); + 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 @@ -421,62 +617,116 @@ export class SearchService { // 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 nationalityUpper = (nationality ?? "").toUpperCase(); + const resolvedNationalityType = + nationalityUpper === "ETHIOPIAN" || nationalityUpper === "DJIBOUTIAN" + ? "LOCAL" + : "INTERNATIONAL"; const availabilityByClass: Record = {}; for (const assignment of schedule.coachAssignments) { - const seatClasses = (assignment.coach.coachType?.seatClasses ?? []).filter( - (sc: any) => !sc.nationalityType || sc.nationalityType === resolvedNationalityType, + 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']) { + 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 ( + 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; + 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 (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 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 coachTypes = this.buildCoachTypeDetails( + schedule, + faresByClass, + nationality, + availabilityByClass, + ); + const legDepartureAt = schedule.departureAt; + const legArrivalAt = schedule.arrivalAt; - const displayCurrency = faresByClass[0]?.displayCurrency ?? resolveCurrencyFromNationality(nationality); + const displayCurrency = + faresByClass[0]?.displayCurrency ?? + resolveCurrencyFromNationality(nationality); return { - type: 'DIRECT', + 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 }, + 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), + 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 })), + .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), + hasAvailability: Object.values(availabilityByClass).some( + (n) => n >= totalPassengers, + ), displayCurrency, faresByClass, coachTypes, @@ -489,20 +739,34 @@ export class SearchService { include: { originStation: true, destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + 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'); + 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); + 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'); + 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 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, @@ -520,10 +784,16 @@ export class SearchService { 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; + 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, @@ -555,10 +825,17 @@ export class SearchService { async getFareBreakdown(dto: FareBreakdownRequestDto) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, - select: { routeId: true, originStationId: true, destinationStationId: true }, + 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'); + 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; @@ -567,18 +844,22 @@ export class SearchService { try { parsedPassengers = JSON.parse(dto.passengers as unknown as string); } catch { - throw new NotFoundException('passengers must be a valid JSON array'); + throw new NotFoundException("passengers must be a valid JSON array"); } // Categorise passengers by age - const categorised = parsedPassengers.map(p => { + 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 }; + 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; + 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 @@ -598,8 +879,8 @@ export class SearchService { // 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) { + const isFreeByIndex = categorised.map((p) => { + if (p.category === "CHILD" && freeChildrenUsed < freeChildrenAllowed) { freeChildrenUsed++; return true; } @@ -625,9 +906,14 @@ export class SearchService { const fareMinor = isFree ? fare.premiumPerPassenger + fare.insurancePerPassenger : fare.farePerPassengerMinor; - const displayFareMinor = displayCurrency !== Currency.ETB - ? await this.currencyService.convertAmount(fareMinor, Currency.ETB, displayCurrency) - : fareMinor; + const displayFareMinor = + displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount( + fareMinor, + Currency.ETB, + displayCurrency, + ) + : fareMinor; return { passengerName: p.passengerName, @@ -652,18 +938,25 @@ export class SearchService { let discountMinor = 0; if (dto.promoCode) { - const promo = await this.prisma.promotion.findUnique({ where: { code: 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) + ? 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; + const displayTotalMinor = + displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount( + totalMinor, + Currency.ETB, + displayCurrency, + ) + : totalMinor; return { scheduleId: dto.scheduleId, @@ -684,18 +977,27 @@ export class SearchService { originStationId: string, destinationStationId: string, nationality?: string, - ): Promise> { + ): 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'; + 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(); for (const a of schedule.coachAssignments) { - for (const sc of (a.coach.coachType?.seatClasses ?? [])) { + for (const sc of a.coach.coachType?.seatClasses ?? []) { if (sc.isActive) seatClassIdSet.add(sc.id); } } @@ -703,14 +1005,13 @@ export class SearchService { where: { id: { in: Array.from(seatClassIdSet) }, isActive: true, - OR: [ - { nationalityType: null }, - { nationalityType: nationalityType }, - ], + 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); + 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 []; @@ -732,9 +1033,9 @@ export class SearchService { // 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, + seatClassName: sc.name, + baseFareMinor: fare.totalMinor, + displayCurrency: fare.billingCurrency as Currency, displayAmountMinor: fare.totalInBillingCurrency, }; } catch { @@ -744,16 +1045,27 @@ export class SearchService { ); const validResults = results.filter( - (r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null, + ( + 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 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; + const destCode = destStop?.station?.code; if (originCode && destCode) { const segmentRoute = `${originCode}-${destCode}`; @@ -769,13 +1081,18 @@ export class SearchService { }); if (fareRules.length > 0) { - const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency); + 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 fareRules.map((rule) => { + const totalMinor = + rule.baseFareMinor + Math.round(rule.baseFareMinor * TAX_RATE); return { - seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown', - baseFareMinor: totalMinor, + seatClassName: + seatClassMap.get(rule.seatClassId)?.name ?? "Unknown", + baseFareMinor: totalMinor, displayCurrency, displayAmountMinor: Math.round(totalMinor * exchangeRate), }; @@ -789,7 +1106,12 @@ export class SearchService { // buildCoachTypeDetails is pure in-memory — no async needed private buildCoachTypeDetails( schedule: ScheduleWithIncludes, - faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, + faresByClass: Array<{ + seatClassName: string; + baseFareMinor: number; + displayCurrency: Currency; + displayAmountMinor: number; + }>, nationality?: string, availabilityByClass: Record = {}, ): Array<{ @@ -797,7 +1119,13 @@ export class SearchService { coachTypeName: string; coachTypeCode: string; coachId: string; - classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number; available: number }>; + classes: Array<{ + name: string; + baseFareMinor: number; + displayCurrency: Currency; + displayAmountMinor: number; + available: number; + }>; }> { const coachTypeMap = new Map< string, @@ -816,15 +1144,22 @@ export class SearchService { }); } - const nationalityUpper = (nationality ?? '').toUpperCase(); - const resolvedNationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') - ? 'LOCAL' : 'INTERNATIONAL'; + 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); + if ( + sc.nationalityType && + sc.nationalityType !== resolvedNationalityType + ) + return; + if (faresByClass.some((f) => f.seatClassName === sc.name)) + entry.classNames.add(sc.name); }); } @@ -832,17 +1167,29 @@ export class SearchService { for (const [, { coachType, classNames, coachId }] of coachTypeMap) { const classes = Array.from(classNames) .map((className) => { - const fareInfo = faresByClass.find((f) => f.seatClassName === className); + const fareInfo = faresByClass.find( + (f) => f.seatClassName === className, + ); if (!fareInfo) return null; return { - name: className, - baseFareMinor: fareInfo.baseFareMinor, - displayCurrency: fareInfo.displayCurrency, + name: className, + baseFareMinor: fareInfo.baseFareMinor, + displayCurrency: fareInfo.displayCurrency, displayAmountMinor: fareInfo.displayAmountMinor, - available: availabilityByClass[className] ?? 0, + available: availabilityByClass[className] ?? 0, }; }) - .filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number; available: number } => c !== null) + .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; @@ -861,5 +1208,4 @@ export class SearchService { return minPriceA - minPriceB; }); } - } diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index 7f0eede85..0292a1f89 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -52,13 +52,17 @@ interface PassengerRow { passportNumber: string | null; passportCountry: string | null; seatLabel: string | null; + seatNumber: string | null; + seatClassName: string | null; coachNumber: string | null; coachType: string | null; + nationality: string | null; origin: string | null; destination: string | null; amountPaidMinor: number; currency: string; isGroupBooking: boolean; + bookingStatus: string; } type Tab = "occupancy" | "list"; @@ -105,6 +109,7 @@ export default function PassengersReportPage() { .filter((p) => { if (filterCoach && p.coachNumber !== filterCoach) return false; if (filterOrigin && p.origin !== filterOrigin) return false; + if (filterSeatClass && p.seatClassName !== filterSeatClass) return false; if (listSearch.trim()) { const q = listSearch.toLowerCase(); return ( @@ -454,6 +459,30 @@ export default function PassengersReportPage() { value={listSearch} onChange={(e) => setListSearch(e.target.value)} /> + + {passengerList.length > 0 && (