import { Injectable, Logger } from "@nestjs/common"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource } from "typeorm"; import { BookingStatus } from "@prisma/client"; import { BlockedSeatRevenueLossReport, UNCATEGORIZED_REASON_CATEGORY, } from "@edr/types"; import { PrismaService } from "../../common/prisma.service"; import { FareEngineService } from "../fare-engine/fare-engine.service"; import { BlockedSeatsLossSortBy, BlockedSeatsRevenueLossQueryDto, GenerateReportDto, ReportType, } from "./reports.dto"; import { assembleReport, isDiningCoach, isGlobalBlockInEffectAt, isPlaceholderSeat, LossCalculatorInput, LossCoach, LossFare, LossSeat, selectCountedBlocks, soldKey, TICKETING_BLOCK_REASON_PREFIX, } from "./blocked-seats-loss.calculator"; /** Fares are quoted at the local tariff unless the caller asks otherwise. */ const DEFAULT_LOSS_NATIONALITY = "Ethiopian"; const DEFAULT_LOSS_PAGE_SIZE = 25; /** How many schedules are priced in parallel. Keeps the DB from being flooded. */ const FARE_QUOTE_CONCURRENCY = 4; /** CSV export is not paginated, but still needs an upper bound. */ const CSV_EXPORT_MAX_SCHEDULES = 5000; // ── Fleet seat-status overview (landing view of the seat status report) ────── const MS_PER_DAY_OVERVIEW = 24 * 60 * 60 * 1000; const OVERVIEW_DEFAULT_DAYS = 7; const OVERVIEW_MAX_DAYS = 31; /** Upper bound on schedules charted at once. Signalled back as `window.truncated`. */ const OVERVIEW_MAX_SCHEDULES = 60; /** The booking statuses that put a seat on a schedule — same set as the drill-down. */ const OVERVIEW_ACTIVE_BOOKING_STATUSES: BookingStatus[] = [ 'CONFIRMED', 'BOARDED', 'PENDING_PAYMENT', ]; /** The passengers report counts people, so a seat awaiting payment does not qualify. */ const PASSENGER_ACTIVE_BOOKING_STATUSES: BookingStatus[] = ['CONFIRMED', 'BOARDED']; /** Route pairs are long-tailed; only the busiest are legible in a chart. */ const TOP_ROUTES_LIMIT = 8; const EMPTY_OVERVIEW_TOTALS = { scheduleCount: 0, sellableSeats: 0, paidCount: 0, unpaidCount: 0, expiredHoldCount: 0, blockedCount: 0, availableCount: 0, loadFactorPercent: 0, }; function emptyDayBucket(date: string) { return { date, scheduleCount: 0, sellableSeats: 0, paid: 0, unpaid: 0, expiredHolds: 0, blocked: 0, available: 0, }; } const EMPTY_LOSS_INPUT: LossCalculatorInput = { schedules: [], seatsById: new Map(), coachesById: new Map(), coachIdsBySchedule: new Map(), soldSeatKeys: new Set(), blocks: [], }; /** Mirrors the fare engine's own LOCAL/INTERNATIONAL split. */ function resolveNationalityType(nationality: string): string { const upper = nationality.toUpperCase(); return upper === "ETHIOPIAN" || upper === "DJIBOUTIAN" ? "LOCAL" : "INTERNATIONAL"; } /** * The fare engine returns two shapes: a full distance-based calculation, and a thinner * FareRule fallback for schedules with no route. Both are reduced to the fields the loss * calculator needs, or dropped if neither shape is present. */ function normalizeFareQuote(quote: unknown): LossFare | null { if (typeof quote !== "object" || quote === null) return null; const q = quote as Record; const seatClassId = q.seatClassId; if (typeof seatClassId !== "string") return null; const fareMinor = typeof q.farePerPassengerMinor === "number" ? q.farePerPassengerMinor : typeof q.totalMinor === "number" ? q.totalMinor : null; if (fareMinor === null) return null; return { seatClassId, seatClassName: typeof q.seatClassName === "string" ? q.seatClassName : "Unknown", farePerPassengerMinor: fareMinor, exchangeRate: typeof q.exchangeRate === "number" ? q.exchangeRate : 1, currency: typeof q.billingCurrency === "string" ? q.billingCurrency : "ETB", }; } /** RFC 4180 cell: always quoted, embedded quotes doubled. */ function toCsvCell(value: string | number): string { return `"${String(value).replace(/"/g, '""')}"`; } @Injectable() export class ReportsService { private readonly logger = new Logger(ReportsService.name); constructor( private prisma: PrismaService, @InjectDataSource() private dataSource: DataSource, private fareEngine: FareEngineService, ) {} async generateReport(dto: GenerateReportDto) { const dateFrom = new Date(dto.dateFrom); dateFrom.setHours(0, 0, 0, 0); const dateTo = new Date(dto.dateTo); dateTo.setHours(23, 59, 59, 999); let data: any; switch (dto.reportType) { case ReportType.REVENUE: data = await this.generateRevenueReport(dateFrom, dateTo); break; case ReportType.OCCUPANCY: data = await this.generateOccupancyReport(dateFrom, dateTo); break; case ReportType.AGENT_SALES: data = await this.generateAgentSalesReport( dateFrom, dateTo, dto.agentId, ); break; case ReportType.CANCELLATIONS: data = await this.generateCancellationsReport(dateFrom, dateTo); break; case ReportType.PAYMENT_METHODS: data = await this.generatePaymentMethodsReport(dateFrom, dateTo); break; default: data = {}; } const report = await this.prisma.operationalReport.create({ data: { reportType: dto.reportType, dateFrom, dateTo, data, }, }); return { reportId: report.id, reportType: dto.reportType, data }; } private async generateRevenueReport(dateFrom: Date, dateTo: Date) { // Fetch all bookings in date range, regardless of status const bookings = await this.prisma.booking.findMany({ where: { createdAt: { gte: dateFrom, lte: dateTo }, }, include: { paymentIntent: true }, }); const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0); const byPaymentMethod = bookings.reduce( (acc, b) => { const method = b.paymentIntent?.method ?? "UNKNOWN"; acc[method] = (acc[method] || 0) + b.totalMinor; return acc; }, {} as Record, ); // Group by date for charts const byDate = bookings.reduce( (acc, b) => { const date = b.createdAt.toISOString().split("T")[0]; if (!acc[date]) { acc[date] = { totalMinor: 0, count: 0 }; } acc[date].totalMinor += b.totalMinor; acc[date].count += 1; return acc; }, {} as Record, ); return { totalBookings: bookings.length, totalRevenueMinor: totalRevenue, totalRevenue: totalRevenue / 100, currency: "ETB", byPaymentMethod, byDate, cancellationRate: 0, }; } private async generateOccupancyReport(dateFrom: Date, dateTo: Date) { const schedules = await this.prisma.trainSchedule.findMany({ where: { departureAt: { gte: dateFrom, lte: dateTo } }, include: { coachAssignments: { include: { coach: { include: { seats: true } } } }, bookings: { where: { status: { in: ["CONFIRMED", "BOARDED"] } }, include: { seats: true }, }, }, }); const tripData = schedules.map((schedule) => { const totalSeats = schedule.coachAssignments.reduce( (sum, a) => sum + a.coach.seats.length, 0, ); const bookedSeats = schedule.bookings.reduce( (sum, b) => sum + b.seats.filter((s: any) => s.scheduleId === schedule.id).length, 0, ); const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0; return { scheduleId: schedule.id, departureAt: schedule.departureAt, totalSeats, bookedSeats, occupancyRate: +occupancyRate.toFixed(2), }; }); const avgOccupancy = tripData.length > 0 ? tripData.reduce((sum, t) => sum + t.occupancyRate, 0) / tripData.length : 0; return { totalSchedules: schedules.length, averageOccupancyRate: +avgOccupancy.toFixed(2), schedules: tripData, }; } private async generateAgentSalesReport( dateFrom: Date, dateTo: Date, agentId?: string, ) { const agentBookings = await this.prisma.agentBooking.findMany({ where: { createdAt: { gte: dateFrom, lte: dateTo }, ...(agentId ? { agentId } : {}), }, include: { agent: { select: { id: true, iamUserId: true, agentCode: true } }, booking: true, }, }); const iamUserIds = [ ...new Set( agentBookings .map((ab) => ab.agent.iamUserId) .filter(Boolean) as string[], ), ]; const iamRows = iamUserIds.length > 0 ? await this.dataSource.query< { id: string; name: { en?: string; am?: string } | null }[] >(`SELECT id, name FROM iam.users WHERE id = ANY($1)`, [iamUserIds]) : []; const iamMap = new Map(iamRows.map((r) => [r.id, r])); const byAgent = agentBookings.reduce( (acc, ab) => { const iam = ab.agent.iamUserId ? iamMap.get(ab.agent.iamUserId) : undefined; const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode; if (!acc[agentName]) { acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 }; } acc[agentName].bookings += 1; acc[agentName].revenueMinor += ab.booking.totalMinor; acc[agentName].cashCollected += ab.cashReceived ?? 0; return acc; }, {} as Record, ); return { totalAgentBookings: agentBookings.length, byAgent, }; } private async generateCancellationsReport(dateFrom: Date, dateTo: Date) { const cancellations = await this.prisma.bookingCancellation.findMany({ where: { createdAt: { gte: dateFrom, lte: dateTo } }, include: { booking: true }, }); const totalRefunded = cancellations.reduce( (sum, c) => sum + c.refundAmount, 0, ); return { totalCancellations: cancellations.length, totalRefundedMinor: totalRefunded, totalRefunded: totalRefunded / 100, currency: "ETB", }; } private async generatePaymentMethodsReport(dateFrom: Date, dateTo: Date) { const payments = await this.prisma.paymentIntent.findMany({ where: { createdAt: { gte: dateFrom, lte: dateTo }, status: "SUCCEEDED", }, }); const byMethod = payments.reduce( (acc, p) => { const method = p.method; if (!acc[method]) { acc[method] = { count: 0, totalMinor: 0 }; } acc[method].count += 1; acc[method].totalMinor += p.amountMinor; return acc; }, {} as Record, ); return { totalPayments: payments.length, byMethod, }; } async getOccupancyBySchedule(scheduleId: string) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, include: { originStation: true, destinationStation: true, train: true, coachAssignments: { include: { coach: { include: { coachType: true, seats: { select: { id: true } }, }, }, }, }, bookings: { where: { status: { in: ['CONFIRMED', 'BOARDED'] } }, select: { id: true, originStationId: true, destinationStationId: true }, }, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, }, }); if (!schedule) return null; // Fetch booking seats for this schedule — covers: // • outbound seats (leg=1, scheduleId=scheduleId) // • return seats (leg=2, booking.returnScheduleId=scheduleId) // • legacy rows where scheduleId is null but booking.scheduleId matches const allBookingSeats = await this.prisma.bookingSeat.findMany({ where: { OR: [ { scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } }, { leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } }, { scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } }, ], }, select: { bookingId: true, seat: { select: { coachId: true, coach: { select: { coachType: { select: { name: true } } } } } }, }, }); const totalSeats = schedule.coachAssignments.reduce((s, a) => s + a.coach.seats.length, 0); const totalPassengers = allBookingSeats.length; const occupancyRate = totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0; // Per-coach breakdown const coachMap = new Map(); for (const assignment of schedule.coachAssignments) { const c = assignment.coach; coachMap.set(c.id, { coachNumber: c.number, coachType: (c as any).coachType?.name ?? 'Unknown', totalSeats: c.seats.length, booked: 0 }); } for (const bs of allBookingSeats) { const coachId = bs.seat?.coachId; if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++; } const byCoach = [...coachMap.values()].map(c => ({ ...c, occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0 })); // Per-class breakdown const classMap = new Map(); for (const assignment of schedule.coachAssignments) { const typeName = (assignment.coach as any).coachType?.name ?? 'Unknown'; if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 }); classMap.get(typeName)!.totalSeats += assignment.coach.seats.length; } for (const bs of allBookingSeats) { const typeName = bs.seat?.coach?.coachType?.name ?? 'Unknown'; if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 }); classMap.get(typeName)!.booked++; } const byClass = [...classMap.values()].map(c => ({ ...c, occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0 })); // Per-origin / per-destination — count actual seats per booking from allBookingSeats const seatCountByBooking = allBookingSeats.reduce((acc, bs) => { acc[bs.bookingId] = (acc[bs.bookingId] ?? 0) + 1; return acc; }, {} as Record); const originMap = new Map(); const destMap = new Map(); for (const booking of schedule.bookings) { const count = seatCountByBooking[booking.id] ?? 0; const oId = booking.originStationId ?? schedule.originStationId; const dId = booking.destinationStationId ?? schedule.destinationStationId; const oName = schedule.stopTimes.find(st => st.stationId === oId)?.station?.name ?? (schedule as any).originStation?.name ?? oId; const dName = schedule.stopTimes.find(st => st.stationId === dId)?.station?.name ?? (schedule as any).destinationStation?.name ?? dId; if (!originMap.has(oId)) originMap.set(oId, { stationName: oName, passengers: 0 }); originMap.get(oId)!.passengers += count; if (!destMap.has(dId)) destMap.set(dId, { stationName: dName, passengers: 0 }); destMap.get(dId)!.passengers += count; } const byOrigin = [...originMap.values()].sort((a, b) => b.passengers - a.passengers); const byDestination = [...destMap.values()].sort((a, b) => b.passengers - a.passengers); return { schedule: { id: schedule.id, trainName: (schedule as any).train?.name ?? (schedule as any).train?.number, origin: (schedule as any).originStation?.name, destination: (schedule as any).destinationStation?.name, departureAt: schedule.departureAt, arrivalAt: schedule.arrivalAt, }, summary: { totalSeats, totalPassengers, occupancyRate }, byCoach, byClass, byOrigin, byDestination, }; } async listSchedulesForPicker(all = false) { const now = new Date(); const schedules = await this.prisma.trainSchedule.findMany({ where: all ? undefined : { departureAt: { gte: now } }, select: { id: true, departureAt: true, isPackageOnly: true, train: { select: { number: true } }, originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, }, orderBy: { departureAt: all ? 'desc' : 'asc' }, take: 200, }); return schedules.map((s) => ({ id: s.id, departureAt: s.departureAt, isPackage: s.isPackageOnly, label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}${ s.isPackageOnly ? ' (package)' : '' }`, })); } async getPassengerList(scheduleId: string) { const seats = await this.prisma.bookingSeat.findMany({ where: { OR: [ { scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } }, { leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } }, { scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } }, ], }, include: { booking: { select: { bookingRef: true, status: true, originStationId: true, destinationStationId: true, totalMinor: true, currency: true, _count: { select: { seats: 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" } }], }); const stationIds = [...new Set( seats.flatMap(bs => [bs.booking.originStationId, bs.booking.destinationStationId]).filter(Boolean) as string[], )]; const stations = stationIds.length > 0 ? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } }) : []; const stationName = new Map(stations.map(s => [s.id, s.name])); const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, select: { departureAt: true }, }); return seats.map((bs) => ({ bookingRef: bs.booking.bookingRef, passengerName: bs.passengerName, passengerCategory: bs.passengerCategory, idDocumentType: bs.idDocumentType, idDocumentNumber: bs.idDocumentNumber, 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?.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, })); } async getSeatStatusReport(scheduleId: string) { // Confirmed/boarded seats. Dining coaches are dropped in JS below — `CoachType.type` // holds display names in real data, so an exact match here would not catch them. const bookingSeats = await this.prisma.bookingSeat.findMany({ where: { OR: [ { scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } }, { leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } }, { scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } }, ], }, include: { booking: { select: { bookingRef: true, status: true, totalMinor: true, currency: true, createdAt: true, paymentIntent: { select: { status: true } }, }, }, seat: { select: { seatNumber: true, bedPosition: true, coach: { select: { number: true, coachType: { select: { name: true, type: true, seatClasses: { select: { name: true, bedPosition: true } } } }, }, }, }, }, }, orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }], }); // Expired holds (last 24h) — held but never converted to a booking const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000); const expiredHolds = await this.prisma.seatHold.findMany({ where: { scheduleId, expiresAt: { lt: new Date(), gte: since24h }, }, orderBy: { expiresAt: 'desc' }, }); // Manually blocked seats. Counted the same way the blocked-seat revenue loss report // counts them (see `selectCountedBlocks`), so the two reports never disagree: // - a schedule-scoped block naming this schedule, or // - a global block (scheduleId null) that was in effect at departure AND sits on a // coach actually assigned to this train. // A global block on a coach that never joined this consist is not a blocked seat here. // Excluded: dining coaches, placeholder seats, ticket-issuance bookkeeping blocks, and // MAINTENANCE (a seat out of service, not one withheld by hand). const [schedule, assignments, blockRows] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, select: { departureAt: true }, }), this.prisma.coachAssignment.findMany({ where: { scheduleId }, select: { coachId: true }, }), this.prisma.seatBlock.findMany({ where: { OR: [ { scheduleId }, { scheduleId: null }, ], NOT: [ { reason: { startsWith: 'MAINTENANCE:' } }, { reason: { startsWith: TICKETING_BLOCK_REASON_PREFIX } }, ], }, include: { seat: { select: { id: true, coachId: true, seatNumber: true, bedPosition: true, coach: { select: { number: true, coachType: { select: { name: true, type: true, seatClasses: { select: { name: true, bedPosition: true } } } }, }, }, }, }, }, orderBy: { blockedAt: 'desc' }, }), ]); const assignedCoachIds = new Set(assignments.map((a) => a.coachId)); const departureAt = schedule?.departureAt ?? null; // One counted block per seat: a schedule-scoped block beats a global one, and between // two of the same kind the most recent wins — the rows arrive newest-first, so the // first of a kind seen for a seat is already the most recent. const countedBySeat = new Map(); for (const block of blockRows) { const seat = block.seat; if (!seat || isPlaceholderSeat(seat)) continue; const coachType = seat.coach?.coachType; if ( isDiningCoach({ coachTypeType: coachType?.type ?? null, coachTypeName: coachType?.name ?? null, }) ) { continue; } if (block.scheduleId === null) { if (!assignedCoachIds.has(seat.coachId)) continue; if (!departureAt || !isGlobalBlockInEffectAt(block, departureAt)) continue; } const existing = countedBySeat.get(seat.id); if (!existing || (existing.scheduleId === null && block.scheduleId !== null)) { countedBySeat.set(seat.id, block); } } const blocks = [...countedBySeat.values()].sort( (a, b) => b.blockedAt.getTime() - a.blockedAt.getTime(), ); const resolveSeatClass = (seat: any): string | null => { const classes = seat?.coach?.coachType?.seatClasses ?? []; const matched = seat?.bedPosition ? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition.toLowerCase()) : null; return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? null; }; const passengerSeats = bookingSeats.filter( bs => !isDiningCoach({ coachTypeType: bs.seat?.coach?.coachType?.type ?? null, coachTypeName: bs.seat?.coach?.coachType?.name ?? null, }), ); const paidSeats = passengerSeats.filter(bs => bs.booking.status === 'CONFIRMED' || bs.booking.status === 'BOARDED' ); const unpaidSeats = passengerSeats.filter(bs => bs.booking.status === 'PENDING_PAYMENT' ); const mapSeat = (bs: any) => ({ bookingRef: bs.booking.bookingRef, passengerName: bs.passengerName, passengerCategory: bs.passengerCategory, coachNumber: bs.seat?.coach?.number ?? null, seatNumber: bs.seat?.seatNumber ?? null, seatClassName: resolveSeatClass(bs.seat), fareMinor: bs.fareMinor, currency: bs.booking.currency ?? 'ETB', bookingStatus: bs.booking.status, paymentStatus: bs.booking.paymentIntent?.status ?? 'PENDING', bookedAt: bs.booking.createdAt, }); return { summary: { paidCount: paidSeats.length, unpaidCount: unpaidSeats.length, expiredHoldCount: expiredHolds.length, blockedCount: blocks.length, }, paidSeats: paidSeats.map(mapSeat), unpaidSeats: unpaidSeats.map(mapSeat), expiredHolds: expiredHolds.map(h => ({ holdId: h.id, seatIds: h.seatIds, expiresAt: h.expiresAt, createdAt: h.createdAt, })), blockedSeats: blocks.map(b => ({ id: b.id, coachNumber: b.seat?.coach?.number ?? null, seatNumber: b.seat?.seatNumber ?? null, seatClassName: resolveSeatClass(b.seat), reason: b.reason, blockedBy: b.blockedBy, blockedAt: b.blockedAt, unblockAt: b.unblockAt, })), }; } /** * Fleet-wide seat status across a departure window — the landing view for the seat * status report, shown before a schedule is picked. * * Deliberately a separate method from {@link getSeatStatusReport}: that one answers * "this schedule, row by row" and its response shape is consumed by the drill-down UI. * This one answers "the whole window, counts only". They share no code path, but they * *do* share predicates — every filter below is the same rule the drill-down applies * (the three-branch seat `OR`, the dining-coach exclusion, the counted-block * resolution), so a schedule's row here always equals what you see after clicking it. * Change one and the other must change with it. */ async getSeatStatusOverview(daysRaw?: number) { const days = Math.min( Math.max(Math.trunc(daysRaw || OVERVIEW_DEFAULT_DAYS), 1), OVERVIEW_MAX_DAYS, ); const now = new Date(); // Forward-looking by default. But a database whose schedules are all in the past // would render an empty chart, which reads as a broken page rather than an honest // "nothing departing" — so fall back to the most recent window that has departures. let from = now; let to = new Date(now.getTime() + days * MS_PER_DAY_OVERVIEW); let direction: 'UPCOMING' | 'RECENT' = 'UPCOMING'; const upcomingCount = await this.prisma.trainSchedule.count({ where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } }, }); if (upcomingCount === 0) { const latest = await this.prisma.trainSchedule.findFirst({ where: { departureAt: { lt: now }, status: { not: 'CANCELLED' } }, orderBy: { departureAt: 'desc' }, select: { departureAt: true }, }); if (latest) { direction = 'RECENT'; to = latest.departureAt; from = new Date(to.getTime() - days * MS_PER_DAY_OVERVIEW); } } const schedules = await this.prisma.trainSchedule.findMany({ where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } }, select: { id: true, departureAt: true, isPackageOnly: true, train: { select: { number: true } }, originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, }, orderBy: { departureAt: 'asc' }, take: OVERVIEW_MAX_SCHEDULES, }); if (schedules.length === 0) { return { window: { from, to, days, direction, truncated: false }, totals: EMPTY_OVERVIEW_TOTALS, byDay: [], schedules: [], }; } const scheduleIds = schedules.map((s) => s.id); const since24h = new Date(now.getTime() - MS_PER_DAY_OVERVIEW); const [assignments, bookingSeats, holds, blockRows] = await Promise.all([ this.prisma.coachAssignment.findMany({ where: { scheduleId: { in: scheduleIds } }, select: { scheduleId: true, coachId: true, coach: { select: { coachType: { select: { name: true, type: true } }, seats: { select: { seatNumber: true } }, }, }, }, }), // Same three-branch OR as the drill-down (`getSeatStatusReport`): a seat reaches a // schedule by its own `scheduleId`, by being leg 2 of a return booking, or — on // older rows with no `scheduleId` — by its booking's outbound schedule. A plain // `groupBy scheduleId` would silently drop the last two. this.prisma.bookingSeat.findMany({ where: { OR: [ { scheduleId: { in: scheduleIds }, booking: { status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES } }, }, { leg: 2, booking: { returnScheduleId: { in: scheduleIds }, status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES }, }, }, { scheduleId: null, leg: 1, booking: { scheduleId: { in: scheduleIds }, status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES }, }, }, ], }, select: { scheduleId: true, leg: true, booking: { select: { status: true, scheduleId: true, returnScheduleId: true }, }, seat: { select: { coach: { select: { coachType: { select: { name: true, type: true } } } }, }, }, }, }), this.prisma.seatHold.findMany({ where: { scheduleId: { in: scheduleIds }, expiresAt: { lt: now, gte: since24h }, }, select: { scheduleId: true }, }), this.prisma.seatBlock.findMany({ where: { OR: [{ scheduleId: { in: scheduleIds } }, { scheduleId: null }], NOT: [ { reason: { startsWith: 'MAINTENANCE:' } }, { reason: { startsWith: TICKETING_BLOCK_REASON_PREFIX } }, ], }, select: { scheduleId: true, blockedAt: true, unblockAt: true, seat: { select: { id: true, coachId: true, seatNumber: true, coach: { select: { coachType: { select: { name: true, type: true } } } }, }, }, }, orderBy: { blockedAt: 'desc' }, }), ]); // ── Sellable seats and assigned coaches, per schedule ─────────────────────── // Sellable = every seat on every assigned coach, minus dining coaches and // placeholder rows — the same denominator the revenue-loss report uses. const sellableBySchedule = new Map(); const coachIdsBySchedule = new Map>(); for (const assignment of assignments) { const coachIds = coachIdsBySchedule.get(assignment.scheduleId) ?? new Set(); coachIds.add(assignment.coachId); coachIdsBySchedule.set(assignment.scheduleId, coachIds); const coachType = assignment.coach?.coachType; if ( isDiningCoach({ coachTypeType: coachType?.type ?? null, coachTypeName: coachType?.name ?? null, }) ) { continue; } const sellable = (assignment.coach?.seats ?? []).filter( (seat) => !isPlaceholderSeat(seat), ).length; sellableBySchedule.set( assignment.scheduleId, (sellableBySchedule.get(assignment.scheduleId) ?? 0) + sellable, ); } // ── Paid / unpaid, per schedule ──────────────────────────────────────────── const scheduleIdSet = new Set(scheduleIds); const paidBySchedule = new Map(); const unpaidBySchedule = new Map(); for (const bs of bookingSeats) { // Dining seats only — the drill-down does not drop placeholder rows from the // passenger seat list, so neither does this. const coachType = bs.seat?.coach?.coachType; if ( isDiningCoach({ coachTypeType: coachType?.type ?? null, coachTypeName: coachType?.name ?? null, }) ) { continue; } // Mirrors the OR branches, in the same order: an explicit `scheduleId` inside the // window wins, then the return leg, then the booking's outbound schedule. const scheduleId = bs.scheduleId && scheduleIdSet.has(bs.scheduleId) ? bs.scheduleId : bs.leg === 2 ? bs.booking.returnScheduleId : bs.booking.scheduleId; if (!scheduleId || !scheduleIdSet.has(scheduleId)) continue; const target = bs.booking.status === 'PENDING_PAYMENT' ? unpaidBySchedule : paidBySchedule; target.set(scheduleId, (target.get(scheduleId) ?? 0) + 1); } // ── Expired holds, per schedule ──────────────────────────────────────────── const holdsBySchedule = new Map(); for (const hold of holds) { holdsBySchedule.set( hold.scheduleId, (holdsBySchedule.get(hold.scheduleId) ?? 0) + 1, ); } // ── Blocked seats, per schedule ──────────────────────────────────────────── // One counted block per seat, resolved exactly as the drill-down resolves it: a // schedule-scoped block beats a global one, and rows arrive newest-first so the // first of a kind seen for a seat is already the most recent. const blockedBySchedule = new Map(); for (const schedule of schedules) { const assignedCoachIds = coachIdsBySchedule.get(schedule.id) ?? new Set(); const countedSeatIds = new Map(); for (const block of blockRows) { const seat = block.seat; if (!seat || isPlaceholderSeat(seat)) continue; const coachType = seat.coach?.coachType; if ( isDiningCoach({ coachTypeType: coachType?.type ?? null, coachTypeName: coachType?.name ?? null, }) ) { continue; } if (block.scheduleId !== null) { if (block.scheduleId !== schedule.id) continue; } else { if (!assignedCoachIds.has(seat.coachId)) continue; if (!isGlobalBlockInEffectAt(block, schedule.departureAt)) continue; } const existing = countedSeatIds.get(seat.id); if (existing === undefined || (existing === null && block.scheduleId !== null)) { countedSeatIds.set(seat.id, block.scheduleId); } } if (countedSeatIds.size > 0) { blockedBySchedule.set(schedule.id, countedSeatIds.size); } } // ── Assemble ─────────────────────────────────────────────────────────────── const scheduleRows = schedules.map((s) => { const sellableSeats = sellableBySchedule.get(s.id) ?? 0; const paid = paidBySchedule.get(s.id) ?? 0; const unpaid = unpaidBySchedule.get(s.id) ?? 0; const expiredHolds = holdsBySchedule.get(s.id) ?? 0; const blocked = blockedBySchedule.get(s.id) ?? 0; // Floored at zero: a seat can be both sold and blocked, so the parts can // over-subtract. Never render a negative slice. const available = Math.max(0, sellableSeats - paid - unpaid - blocked); return { scheduleId: s.id, trainNumber: s.train.number, originStation: s.originStation.name, destinationStation: s.destinationStation.name, departureAt: s.departureAt, isPackage: s.isPackageOnly, sellableSeats, paid, unpaid, expiredHolds, blocked, available, loadFactorPercent: sellableSeats > 0 ? +((paid / sellableSeats) * 100).toFixed(1) : 0, }; }); // Day buckets keyed on the UTC calendar date of departure, so the chart's axis and // its bars are derived from one value and cannot disagree with each other. const byDayMap = new Map>(); for (const row of scheduleRows) { const date = row.departureAt.toISOString().slice(0, 10); const bucket = byDayMap.get(date) ?? emptyDayBucket(date); bucket.scheduleCount += 1; bucket.sellableSeats += row.sellableSeats; bucket.paid += row.paid; bucket.unpaid += row.unpaid; bucket.expiredHolds += row.expiredHolds; bucket.blocked += row.blocked; bucket.available += row.available; byDayMap.set(date, bucket); } const byDay = [...byDayMap.values()].sort((a, b) => a.date.localeCompare(b.date)); const sum = (pick: (r: (typeof scheduleRows)[number]) => number) => scheduleRows.reduce((total, row) => total + pick(row), 0); const totalSellable = sum((r) => r.sellableSeats); const totalPaid = sum((r) => r.paid); return { window: { from, to, days, direction, truncated: schedules.length === OVERVIEW_MAX_SCHEDULES, }, totals: { scheduleCount: scheduleRows.length, sellableSeats: totalSellable, paidCount: totalPaid, unpaidCount: sum((r) => r.unpaid), expiredHoldCount: sum((r) => r.expiredHolds), blockedCount: sum((r) => r.blocked), availableCount: sum((r) => r.available), loadFactorPercent: totalSellable > 0 ? +((totalPaid / totalSellable) * 100).toFixed(1) : 0, }, byDay, schedules: scheduleRows, }; } /** * Fleet-wide passenger mix across a departure window — the landing view for the * passengers report, shown before a schedule is picked. * * Answers "who travelled", not "how full were the trains". Occupancy is deliberately * absent: this report and the seat status report count capacity differently (this one * includes dining and placeholder seats in `totalSeats`, the other does not), so an * occupancy figure here would either contradict the table below it or the seats page. * That pre-existing difference is left alone rather than silently reconciled. * * Counts CONFIRMED and BOARDED only, matching {@link getOccupancyBySchedule} — a seat * awaiting payment has no passenger on it yet. */ async getPassengerOverview(daysRaw?: number) { const days = Math.min( Math.max(Math.trunc(daysRaw || OVERVIEW_DEFAULT_DAYS), 1), OVERVIEW_MAX_DAYS, ); const now = new Date(); // Window resolution is intentionally a copy of the one in getSeatStatusOverview // rather than a shared helper: the two reports are free to diverge on what window // makes sense for them, and a shared helper would couple them for ~20 lines. let from = now; let to = new Date(now.getTime() + days * MS_PER_DAY_OVERVIEW); let direction: 'UPCOMING' | 'RECENT' = 'UPCOMING'; const upcomingCount = await this.prisma.trainSchedule.count({ where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } }, }); if (upcomingCount === 0) { const latest = await this.prisma.trainSchedule.findFirst({ where: { departureAt: { lt: now }, status: { not: 'CANCELLED' } }, orderBy: { departureAt: 'desc' }, select: { departureAt: true }, }); if (latest) { direction = 'RECENT'; to = latest.departureAt; from = new Date(to.getTime() - days * MS_PER_DAY_OVERVIEW); } } const schedules = await this.prisma.trainSchedule.findMany({ where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } }, select: { id: true, departureAt: true, isPackageOnly: true, originStationId: true, destinationStationId: true, train: { select: { number: true } }, originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, }, orderBy: { departureAt: 'asc' }, take: OVERVIEW_MAX_SCHEDULES, }); if (schedules.length === 0) { return { window: { from, to, days, direction, truncated: false }, totals: { scheduleCount: 0, totalPassengers: 0, groupPassengers: 0 }, byDay: [], byNationality: [], byCategory: [], topRoutes: [], schedules: [], }; } const scheduleIds = schedules.map((s) => s.id); // Same three-branch OR as the per-schedule report: own scheduleId, return leg, or a // legacy null-scheduleId row reached through the booking's outbound schedule. const bookingSeats = await this.prisma.bookingSeat.findMany({ where: { OR: [ { scheduleId: { in: scheduleIds }, booking: { status: { in: PASSENGER_ACTIVE_BOOKING_STATUSES } }, }, { leg: 2, booking: { returnScheduleId: { in: scheduleIds }, status: { in: PASSENGER_ACTIVE_BOOKING_STATUSES }, }, }, { scheduleId: null, leg: 1, booking: { scheduleId: { in: scheduleIds }, status: { in: PASSENGER_ACTIVE_BOOKING_STATUSES }, }, }, ], }, select: { scheduleId: true, leg: true, bookingId: true, passengerCategory: true, passportCountry: true, idDocumentType: true, booking: { select: { scheduleId: true, returnScheduleId: true, originStationId: true, destinationStationId: true, }, }, }, }); // Station names for the route pairs. Bookings that never recorded a station fall back // to the schedule's own endpoints, the same fallback getOccupancyBySchedule applies. const stationIds = [ ...new Set( [ ...bookingSeats.flatMap((bs) => [ bs.booking.originStationId, bs.booking.destinationStationId, ]), ...schedules.flatMap((s) => [s.originStationId, s.destinationStationId]), ].filter((id): id is string => Boolean(id)), ), ]; const stations = stationIds.length ? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true }, }) : []; const stationName = new Map(stations.map((s) => [s.id, s.name])); const scheduleById = new Map(schedules.map((s) => [s.id, s])); const scheduleIdSet = new Set(scheduleIds); const passengersBySchedule = new Map(); const nationalityCounts = new Map(); const categoryCounts = new Map(); const routeCounts = new Map(); // A booking contributing more than one seat to the window is a group booking. const seatsPerBooking = new Map(); for (const bs of bookingSeats) { const scheduleId = bs.scheduleId && scheduleIdSet.has(bs.scheduleId) ? bs.scheduleId : bs.leg === 2 ? bs.booking.returnScheduleId : bs.booking.scheduleId; if (!scheduleId || !scheduleIdSet.has(scheduleId)) continue; const schedule = scheduleById.get(scheduleId); passengersBySchedule.set( scheduleId, (passengersBySchedule.get(scheduleId) ?? 0) + 1, ); seatsPerBooking.set(bs.bookingId, (seatsPerBooking.get(bs.bookingId) ?? 0) + 1); // Same derivation as getPassengerList, so the chart and the drill-down list agree // on what a passenger's nationality is. const nationality = bs.passportCountry ? bs.passportCountry === 'Djibouti' ? 'Djiboutian' : bs.passportCountry : bs.idDocumentType === 'NATIONAL_ID' ? 'Ethiopian' : 'Unknown'; nationalityCounts.set(nationality, (nationalityCounts.get(nationality) ?? 0) + 1); const category = bs.passengerCategory ?? 'ADULT'; categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1); const originId = bs.booking.originStationId ?? schedule?.originStationId ?? null; const destinationId = bs.booking.destinationStationId ?? schedule?.destinationStationId ?? null; if (originId && destinationId) { const key = `${originId}|${destinationId}`; const existing = routeCounts.get(key); if (existing) { existing.passengers += 1; } else { routeCounts.set(key, { origin: stationName.get(originId) ?? originId, destination: stationName.get(destinationId) ?? destinationId, passengers: 1, }); } } } const groupPassengers = [...seatsPerBooking.values()] .filter((count) => count > 1) .reduce((sum, count) => sum + count, 0); const scheduleRows = schedules.map((s) => ({ scheduleId: s.id, trainNumber: s.train.number, originStation: s.originStation.name, destinationStation: s.destinationStation.name, departureAt: s.departureAt, isPackage: s.isPackageOnly, passengers: passengersBySchedule.get(s.id) ?? 0, })); const byDayMap = new Map(); for (const row of scheduleRows) { const date = row.departureAt.toISOString().slice(0, 10); const bucket = byDayMap.get(date) ?? { date, scheduleCount: 0, passengers: 0 }; bucket.scheduleCount += 1; bucket.passengers += row.passengers; byDayMap.set(date, bucket); } const rank = (rows: T[]) => rows.sort((a, b) => b.passengers - a.passengers); return { window: { from, to, days, direction, truncated: schedules.length === OVERVIEW_MAX_SCHEDULES, }, totals: { scheduleCount: scheduleRows.length, totalPassengers: scheduleRows.reduce((sum, r) => sum + r.passengers, 0), groupPassengers, }, byDay: [...byDayMap.values()].sort((a, b) => a.date.localeCompare(b.date)), byNationality: rank( [...nationalityCounts.entries()].map(([nationality, passengers]) => ({ nationality, passengers, })), ), byCategory: rank( [...categoryCounts.entries()].map(([category, passengers]) => ({ category, passengers, })), ), topRoutes: rank([...routeCounts.values()]).slice(0, TOP_ROUTES_LIMIT), schedules: scheduleRows, }; } async getPaymentDiscrepancyReport(params: { from?: string; to?: string; sortBy?: string; search?: string; }) { // Load all exchange rates once — we need conversions in both directions. const rateRows = await this.prisma.currencyExchangeRate.findMany({ orderBy: { effectiveDate: 'desc' }, }); // Most-recent rate for each fromCurrency→toCurrency pair const rateMap = new Map(); for (const r of rateRows) { const key = `${r.fromCurrency}→${r.toCurrency}`; if (!rateMap.has(key)) rateMap.set(key, Number(r.rate)); } // Convert minor amount from one currency to another. const convertMinor = (minor: number, from: string, to: string): number => { if (from === to) return minor; const direct = rateMap.get(`${from}→${to}`); if (direct) return Math.round(minor * direct); // Try via ETB as pivot const toEtb = rateMap.get(`${from}→ETB`); const fromEtb = rateMap.get(`ETB→${to}`); if (toEtb && fromEtb) return Math.round(minor * toEtb * fromEtb); return minor; // fallback: no rate on file }; if (params.search?.trim()) { return this.getDiscrepancyForRef(params.search.trim(), convertMinor); } const dateFilter: Record = {}; if (params.from) dateFilter.gte = new Date(params.from + 'T00:00:00.000Z'); if (params.to) dateFilter.lte = new Date(params.to + 'T23:59:59.999Z'); const seatSelect = { where: { leg: 1 }, orderBy: [ { seat: { coach: { number: 'asc' as const } } }, { seat: { seatNumber: 'asc' as const } }, ], select: { passengerName: true, passengerCategory: true, seatLabelSnapshot: true, fareMinor: true, displayFareMinor: true, displayCurrency: true, seat: { select: { seatNumber: true, coach: { select: { number: true, coachType: { select: { name: true } } } }, }, }, }, }; const bookings = await this.prisma.booking.findMany({ where: { status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any }, paymentIntent: { status: 'SUCCEEDED' }, ...(Object.keys(dateFilter).length > 0 && { createdAt: dateFilter }), }, include: { paymentIntent: { select: { amountMinor: true, currency: true, paidAt: true } }, schedule: { include: { originStation: { select: { name: true, code: true, city: true } }, destinationStation: { select: { name: true, code: true, city: true } }, }, }, seats: seatSelect, passenger: { select: { user: { select: { phone: true, fullName: true } } }, }, }, orderBy: { createdAt: 'desc' }, }); const rows = bookings .map(b => { const pi = b.paymentIntent!; // Display amounts shown to the passenger (may be in DJF/USD). const actualMinor = b.displayTotalMinor ?? b.totalMinor; const actualCurrency = (b.displayCurrency as string | null) ?? b.currency; const paidMinor = pi.amountMinor; const paidCurrency = pi.currency; // Balance in the booking's display currency: // convert paid (major units from gateway) to display currency minor, then subtract. const paidInDisplayMinor = convertMinor(paidMinor * 100, paidCurrency, actualCurrency); const balanceMinor = actualMinor - paidInDisplayMinor; const balanceCurrency = actualCurrency; const firstSeat = b.seats[0]; const passengers = this.buildSeatPassengers(b.seats, actualCurrency); return { pnr: b.bookingRef, passengerName: firstSeat?.passengerName ?? b.passenger?.user?.fullName ?? '—', phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—', bookingDate: b.createdAt, origin: b.schedule.originStation, destination: b.schedule.destinationStation, departureAt: b.schedule.departureAt, seatType: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—', coachNumber: firstSeat?.seat?.coach?.number ?? null, actualMinor, actualCurrency, paidMinor, paidCurrency, balanceMinor, balanceCurrency, passengerCount: passengers.length, passengers, }; }) .filter(r => r.balanceMinor > 0); if (params.sortBy === 'departure') { rows.sort((a, b) => new Date(a.departureAt).getTime() - new Date(b.departureAt).getTime()); } else { rows.sort((a, b) => b.balanceMinor - a.balanceMinor); } const totalBalanceEtbMinor = rows.reduce((sum, r) => sum + r.balanceMinor, 0); return { total: rows.length, totalBalanceEtbMinor, rows }; } private async getDiscrepancyForRef( search: string, convertMinor: (minor: number, from: string, to: string) => number, ) { let bookingId: string | null = null; const byPnr = await this.prisma.booking.findUnique({ where: { bookingRef: search.toUpperCase() }, select: { id: true }, }); if (byPnr) { bookingId = byPnr.id; } else { const ticket = await this.prisma.ticket.findFirst({ where: { barcodePayload: search }, select: { bookingId: true }, }); bookingId = ticket?.bookingId ?? null; } if (!bookingId) { return { total: 0, totalBalanceEtbMinor: 0, rows: [], notFound: true }; } const b = await this.prisma.booking.findUnique({ where: { id: bookingId }, include: { paymentIntent: { select: { amountMinor: true, currency: true, paidAt: true, status: true } }, schedule: { include: { originStation: { select: { name: true, code: true, city: true } }, destinationStation: { select: { name: true, code: true, city: true } }, }, }, seats: { where: { leg: 1 }, orderBy: [ { seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }, ], select: { passengerName: true, passengerCategory: true, seatLabelSnapshot: true, fareMinor: true, displayFareMinor: true, displayCurrency: true, seat: { select: { seatNumber: true, coach: { select: { number: true, coachType: { select: { name: true } } } }, }, }, }, }, passenger: { select: { user: { select: { phone: true, fullName: true } } }, }, }, }); if (!b) return { total: 0, totalBalanceEtbMinor: 0, rows: [], notFound: true }; const pi = b.paymentIntent; const actualMinor = b.displayTotalMinor ?? b.totalMinor; const actualCurrency = (b.displayCurrency as string | null) ?? b.currency; const paidMinor = pi?.amountMinor ?? 0; const paidCurrency = pi?.currency ?? b.currency; const paidInDisplayMinor = convertMinor(paidMinor * 100, paidCurrency, actualCurrency); const balanceMinor = actualMinor - paidInDisplayMinor; const balanceCurrency = actualCurrency; const firstSeat = b.seats[0]; const passengers = this.buildSeatPassengers(b.seats, actualCurrency); const row = { pnr: b.bookingRef, passengerName: firstSeat?.passengerName ?? b.passenger?.user?.fullName ?? '—', phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—', bookingDate: b.createdAt, origin: b.schedule.originStation, destination: b.schedule.destinationStation, departureAt: b.schedule.departureAt, seatType: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—', coachNumber: firstSeat?.seat?.coach?.number ?? null, actualMinor, actualCurrency, paidMinor, paidCurrency, balanceMinor, balanceCurrency, bookingStatus: b.status, paymentStatus: pi?.status ?? null, passengerCount: passengers.length, passengers, }; return { total: balanceMinor > 0 ? 1 : 0, totalBalanceEtbMinor: balanceMinor > 0 ? balanceMinor : 0, rows: [row], notFound: false, }; } private buildSeatPassengers(seats: any[], fallbackCurrency: string) { return seats.map(s => ({ name: s.passengerName as string, category: s.passengerCategory as string, seatNumber: (s.seat?.seatNumber ?? null) as string | null, coachNumber: (s.seat?.coach?.number ?? null) as string | null, seatType: (s.seatLabelSnapshot ?? s.seat?.coach?.coachType?.name ?? null) as string | null, fareMinor: (s.displayFareMinor ?? s.fareMinor ?? null) as number | null, fareCurrency: ((s.displayCurrency as string | null) ?? fallbackCurrency), })); } async getPaymentsReport(scheduleId: string) { const rateRows = await this.prisma.currencyExchangeRate.findMany({ where: { toCurrency: 'ETB' as any }, orderBy: { effectiveDate: 'desc' }, }); const rateToEtb = new Map(); for (const r of rateRows) { if (!rateToEtb.has(r.fromCurrency)) rateToEtb.set(r.fromCurrency, Number(r.rate)); } const toEtbMinor = (minor: number, currency: string): number => { if (currency === 'ETB') return minor; const rate = rateToEtb.get(currency); return rate ? Math.round(minor * rate) : minor; }; const bookings = await this.prisma.booking.findMany({ where: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any }, paymentIntent: { status: 'SUCCEEDED' }, }, include: { paymentIntent: { select: { amountMinor: true, currency: true, method: true, paidAt: true } }, seats: { where: { leg: 1 }, select: { passengerName: true, fareMinor: true, displayFareMinor: true, displayCurrency: true, passengerCategory: true, seatLabelSnapshot: true, seat: { select: { coach: { select: { number: true, coachType: { select: { name: true } } } } } }, }, }, passenger: { select: { user: { select: { phone: true, fullName: true } } } }, }, }); const rows = bookings.map(b => { const pi = b.paymentIntent!; const actualMinor = b.displayTotalMinor ?? b.totalMinor; const actualCurrency = (b.displayCurrency as string | null) ?? b.currency; // pi.amountMinor is stored in major units — convert to minor const paidMinor = Math.round(pi.amountMinor * 100); const paidCurrency = pi.currency; const varianceMinor = toEtbMinor(actualMinor, actualCurrency) - toEtbMinor(paidMinor, paidCurrency); return { bookingRef: b.bookingRef, passengerName: b.seats[0]?.passengerName ?? b.passenger?.user?.fullName ?? '—', phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—', method: pi.method, paidAt: pi.paidAt, actualMinor, actualCurrency, paidMinor, paidCurrency, varianceMinor, passengerCount: b.seats.length, }; }); const totalActualEtbMinor = rows.reduce((s, r) => s + toEtbMinor(r.actualMinor, r.actualCurrency), 0); const totalPaidEtbMinor = rows.reduce((s, r) => s + toEtbMinor(r.paidMinor, r.paidCurrency), 0); const byMethod = rows.reduce((acc, r) => { if (!acc[r.method]) acc[r.method] = { totalPaidEtbMinor: 0, currency: 'ETB' }; acc[r.method].totalPaidEtbMinor += toEtbMinor(r.paidMinor, r.paidCurrency); return acc; }, {} as Record); return { totalActualEtbMinor, totalPaidEtbMinor, byMethod, rows }; } async getPaymentDiscrepancyBySchedule(scheduleId: string, params: { search?: string; seatClass?: string; sort?: string; }) { const bookings = await this.prisma.booking.findMany({ where: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any }, paymentIntent: { status: 'SUCCEEDED' }, }, include: { paymentIntent: { select: { amountMinor: true, currency: true } }, schedule: { select: { id: true } }, seats: { where: { leg: 1 }, orderBy: [ { seat: { coach: { number: 'asc' as const } } }, { seat: { seatNumber: 'asc' as const } }, ], select: { passengerName: true, passengerCategory: true, seatLabelSnapshot: true, fareMinor: true, seat: { select: { seatNumber: true, bedPosition: true, coach: { select: { number: true, coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } } } }, }, }, }, }, passenger: { select: { user: { select: { phone: true, fullName: true } } } }, }, }); const stationIds = [...new Set( bookings.flatMap(b => [b.originStationId, b.destinationStationId]).filter(Boolean) as string[], )]; const stations = stationIds.length > 0 ? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } }) : []; const stationName = new Map(stations.map(s => [s.id, s.name])); const resolveSeatClass = (seat: any): string => { const classes = seat?.coach?.coachType?.seatClasses ?? []; const matched = seat?.bedPosition ? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition.toLowerCase()) : null; return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? 'Unknown'; }; let rows = bookings.map(b => { const pi = b.paymentIntent!; const actualMinor = b.seats.reduce((s, seat) => s + (seat.fareMinor ?? 0), 0); // pi.amountMinor is a Float in full currency units — convert to cents once const paidMinorCents = Math.round(pi.amountMinor * 100); const isPackage = !!(b as any).packageId; const effectiveActualMinor = isPackage ? actualMinor * 2 : actualMinor; const effectiveVarianceMinor = effectiveActualMinor - paidMinorCents; const breakdown = b.seats.map(s => ({ passengerName: s.passengerName ?? '—', seatClass: resolveSeatClass(s.seat), coachNumber: s.seat?.coach?.number ?? null, seatNumber: s.seat?.seatNumber ?? null, fareMinor: isPackage ? (s.fareMinor ?? 0) * 2 : (s.fareMinor ?? 0), })); const firstSeat = b.seats[0]; return { bookingRef: b.bookingRef, isPackage, seatClass: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—', coachNumber: firstSeat?.seat?.coach?.number ?? null, seatNumber: firstSeat?.seat?.seatNumber ?? null, origin: b.originStationId ? (stationName.get(b.originStationId) ?? '—') : '—', destination: b.destinationStationId ? (stationName.get(b.destinationStationId) ?? '—') : '—', phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—', actualMinor: effectiveActualMinor, paidMinor: paidMinorCents, varianceMinor: effectiveVarianceMinor, breakdown, }; }).filter(r => r.varianceMinor > 0); if (params.search?.trim()) { const q = params.search.trim().toUpperCase(); rows = rows.filter(r => r.bookingRef.toUpperCase().includes(q)); } if (params.seatClass?.trim()) { const sc = params.seatClass.trim().toLowerCase(); rows = rows.filter(r => r.breakdown.some(bd => bd.seatClass.toLowerCase().includes(sc))); } if (params.sort === 'asc') { rows.sort((a, b) => a.varianceMinor - b.varianceMinor); } else { rows.sort((a, b) => b.varianceMinor - a.varianceMinor); } return { total: rows.length, rows }; } async getBoardingReport(scheduleId: string) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId }, select: { id: true, departureAt: true, arrivalAt: true, train: { select: { number: true, name: true } }, originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, }, }); if (!schedule) return null; const tickets = await this.prisma.ticket.findMany({ where: { OR: [ { scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } }, { leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } }, { scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } }, ], }, select: { id: true, bookingRef: true, passengerName: true, boardedAt: true, validatorId: true, status: true, booking: { select: { status: true, originStationId: true, destinationStationId: 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' } }], }); const stationIds = [...new Set( tickets.flatMap(t => [t.booking.originStationId, t.booking.destinationStationId]).filter(Boolean) as string[], )]; const stations = stationIds.length > 0 ? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } }) : []; const stationName = new Map(stations.map(s => [s.id, s.name])); const resolveSeatClass = (seat: any): string | null => { const classes = seat?.coach?.coachType?.seatClasses ?? []; const matched = seat?.bedPosition ? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition.toLowerCase()) : null; return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? null; }; const rows = tickets.map(t => ({ bookingRef: t.bookingRef, passengerName: t.passengerName, coachNumber: t.seat?.coach?.number ?? null, seatNumber: t.seat?.seatNumber ?? null, seatClassName: resolveSeatClass(t.seat), origin: t.booking.originStationId ? (stationName.get(t.booking.originStationId) ?? null) : null, destination: t.booking.destinationStationId ? (stationName.get(t.booking.destinationStationId) ?? null) : null, boarded: !!t.boardedAt, boardedAt: t.boardedAt ?? null, validatorId: t.validatorId ?? null, bookingStatus: t.booking.status, })); const boardedCount = rows.filter(r => r.boarded).length; const notBoardedCount = rows.length - boardedCount; const byCoach = new Map(); for (const r of rows) { const key = r.coachNumber ?? 'Unknown'; if (!byCoach.has(key)) byCoach.set(key, { coachNumber: key, total: 0, boarded: 0 }); byCoach.get(key)!.total++; if (r.boarded) byCoach.get(key)!.boarded++; } return { schedule: { id: schedule.id, trainName: (schedule.train as any)?.name ?? (schedule.train as any)?.number, origin: (schedule.originStation as any)?.name, destination: (schedule.destinationStation as any)?.name, departureAt: schedule.departureAt, arrivalAt: schedule.arrivalAt, }, summary: { total: rows.length, boardedCount, notBoardedCount, boardingRate: rows.length > 0 ? +((boardedCount / rows.length) * 100).toFixed(1) : 0, }, byCoach: [...byCoach.values()].sort((a, b) => a.coachNumber.localeCompare(b.coachNumber)), rows, }; } // ── Blocked Seat Revenue Loss ────────────────────────────────────────────── /** * Resolves the reporting window. Both bounds are inclusive and snap to whole local days, * matching `generateReport`. When the caller supplies neither bound, defaults to the full * history of scheduled departures on record — the earliest `TrainSchedule.departureAt` to * the latest — not a rolling window, so nothing ages out of the report on its own. */ private async resolveWindow( query: Pick, now: Date, ): Promise<{ dateFrom: Date; dateTo: Date }> { let dateFrom: Date; let dateTo: Date; if (query.dateFrom && query.dateTo) { dateFrom = new Date(query.dateFrom); dateTo = new Date(query.dateTo); } else { const bounds = await this.prisma.trainSchedule.aggregate({ _min: { departureAt: true }, _max: { departureAt: true }, }); dateFrom = query.dateFrom ? new Date(query.dateFrom) : (bounds._min.departureAt ?? new Date(now)); dateTo = query.dateTo ? new Date(query.dateTo) : (bounds._max.departureAt ?? new Date(now)); } dateTo.setHours(23, 59, 59, 999); dateFrom.setHours(0, 0, 0, 0); return { dateFrom, dateTo }; } /** * Potential revenue lost to seats that were blocked and therefore never sellable. * * The counting rule and the money live in `blocked-seats-loss.calculator.ts`; this method * is the fetch plan. Query count is bounded and independent of the number of schedules: * schedules → coach assignments → seats → booking seats → seat blocks, plus one fare * calculation per *affected* schedule (schedules with no blocked seat need no fare). */ async getBlockedSeatsRevenueLoss( query: BlockedSeatsRevenueLossQueryDto, ): Promise { const now = new Date(); const { dateFrom, dateTo } = await this.resolveWindow(query, now); const nationalityAssumption = query.nationality?.trim() || DEFAULT_LOSS_NATIONALITY; const nationalityType = resolveNationalityType(nationalityAssumption); // 1 — schedules in the window. CANCELLED trains never ran, so nothing was lost on them. const schedules = await this.prisma.trainSchedule.findMany({ where: { departureAt: { gte: dateFrom, lte: dateTo }, status: { not: 'CANCELLED' }, ...(query.scheduleId ? { id: query.scheduleId } : {}), ...(query.routeId ? { routeId: query.routeId } : {}), ...(query.trainId ? { trainId: query.trainId } : {}), }, select: { id: true, departureAt: true, status: true, train: { select: { number: true } }, route: { select: { name: true } }, originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, }, orderBy: { departureAt: 'desc' }, }); const emptyOptions = { faresBySchedule: new Map>(), schedulesWithoutFare: new Set(), nationalityType, nationalityAssumption, now, dateFrom, dateTo, page: query.page ?? 1, pageSize: query.pageSize ?? DEFAULT_LOSS_PAGE_SIZE, sortBy: query.sortBy ?? BlockedSeatsLossSortBy.LOSS_DESC, }; if (schedules.length === 0) { return assembleReport(EMPTY_LOSS_INPUT, new Map(), emptyOptions); } const scheduleIds = schedules.map((s) => s.id); const departures = schedules.map((s) => s.departureAt.getTime()); const earliestDeparture = new Date(Math.min(...departures)); const latestDeparture = new Date(Math.max(...departures)); // 2 — coach assignments. Unfiltered by `coachId` on purpose: the load factor must // describe the whole train even when the block list is narrowed to one coach. const assignments = await this.prisma.coachAssignment.findMany({ where: { scheduleId: { in: scheduleIds } }, select: { scheduleId: true, coachId: true, coach: { select: { id: true, number: true, coachType: { select: { name: true, type: true, seatClasses: { select: { id: true, name: true, bedPosition: true, nationalityType: true }, }, }, }, }, }, }, }); const coachesById = new Map(); const coachIdsBySchedule = new Map>(); for (const assignment of assignments) { const coachIds = coachIdsBySchedule.get(assignment.scheduleId) ?? new Set(); coachIds.add(assignment.coachId); coachIdsBySchedule.set(assignment.scheduleId, coachIds); if (!coachesById.has(assignment.coachId)) { coachesById.set(assignment.coachId, { id: assignment.coach.id, number: assignment.coach.number, coachTypeType: assignment.coach.coachType?.type ?? 'passenger', coachTypeName: assignment.coach.coachType?.name ?? 'Unknown', seatClasses: assignment.coach.coachType?.seatClasses ?? [], }); } } // 3 — seats on those coaches. Bounded by fleet size, not by schedule count. const coachIds = [...coachesById.keys()]; const seatRows = coachIds.length ? await this.prisma.seat.findMany({ where: { coachId: { in: coachIds } }, select: { id: true, coachId: true, seatNumber: true, bedPosition: true, premiumFeeMinor: true, }, }) : []; const seatsById = new Map(seatRows.map((s) => [s.id, s])); // 4 — seats actually sold on these schedules. Same tri-branch shape the other // schedule reports use: outbound leg, return leg, and legacy rows with a null // scheduleId that inherit the booking's schedule. const bookingSeats = await this.prisma.bookingSeat.findMany({ where: { booking: { status: { in: ['CONFIRMED', 'BOARDED'] } }, OR: [ { scheduleId: { in: scheduleIds } }, { leg: 2, booking: { returnScheduleId: { in: scheduleIds } } }, { scheduleId: null, leg: 1, booking: { scheduleId: { in: scheduleIds } } }, ], }, select: { seatId: true, scheduleId: true, leg: true, booking: { select: { scheduleId: true, returnScheduleId: true } }, }, }); const scheduleIdSet = new Set(scheduleIds); const soldSeatKeys = new Set(); for (const bs of bookingSeats) { const effectiveScheduleId = bs.scheduleId ?? (bs.leg === 2 ? bs.booking.returnScheduleId : bs.booking.scheduleId); if (!effectiveScheduleId || !scheduleIdSet.has(effectiveScheduleId)) continue; soldSeatKeys.add(soldKey(effectiveScheduleId, bs.seatId)); } // 5 — candidate blocks: schedule-scoped ones for these schedules, plus global ones // whose active window overlaps the departure range at all. Per-schedule precision // is applied in the calculator against each schedule's own departureAt. const blockRows = await this.prisma.seatBlock.findMany({ where: { AND: [ { OR: [ { scheduleId: { in: scheduleIds } }, { scheduleId: null, blockedAt: { lte: latestDeparture }, OR: [{ unblockAt: null }, { unblockAt: { gte: earliestDeparture } }], }, ], }, ...(query.reasonCategory ? [{ reasonCategory: query.reasonCategory }] : []), ...(query.coachId ? [{ seat: { coachId: query.coachId } }] : []), ...(query.blockedBy ? [ { OR: [ { blockedBy: query.blockedBy }, { blockedByName: { contains: query.blockedBy, mode: 'insensitive' as const, }, }, ], }, ] : []), ], }, select: { id: true, seatId: true, scheduleId: true, reason: true, reasonCategory: true, blockedBy: true, blockedByName: true, approvedBy: true, blockedAt: true, unblockAt: true, }, orderBy: { blockedAt: 'desc' }, }); const input: LossCalculatorInput = { schedules: schedules.map((s) => ({ id: s.id, trainNumber: s.train?.number ?? '—', routeName: s.route?.name ?? null, originStation: s.originStation?.name ?? '—', destinationStation: s.destinationStation?.name ?? '—', departureAt: s.departureAt, status: s.status, })), seatsById, coachesById, coachIdsBySchedule, soldSeatKeys, blocks: blockRows, }; const countedBySchedule = selectCountedBlocks(input); // 6 — one fare calculation per affected schedule, never per seat. const { faresBySchedule, schedulesWithoutFare } = await this.quoteFaresForSchedules( [...countedBySchedule.keys()], nationalityAssumption, ); return assembleReport(input, countedBySchedule, { ...emptyOptions, faresBySchedule, schedulesWithoutFare, }); } /** * Quotes every active seat class on each affected schedule, in small concurrent batches * so a wide date range does not open hundreds of simultaneous fare calculations. */ private async quoteFaresForSchedules( scheduleIds: string[], nationality: string, ): Promise<{ faresBySchedule: Map>; schedulesWithoutFare: Set; }> { const faresBySchedule = new Map>(); const schedulesWithoutFare = new Set(); for (let i = 0; i < scheduleIds.length; i += FARE_QUOTE_CONCURRENCY) { const batch = scheduleIds.slice(i, i + FARE_QUOTE_CONCURRENCY); await Promise.all( batch.map(async (scheduleId) => { try { const quotes = await this.fareEngine.calculateAllForSchedule(scheduleId, nationality); const bySeatClass = new Map(); for (const quote of quotes) { const fare = normalizeFareQuote(quote); if (fare) bySeatClass.set(fare.seatClassId, fare); } if (bySeatClass.size === 0) { schedulesWithoutFare.add(scheduleId); return; } faresBySchedule.set(scheduleId, bySeatClass); } catch (err) { // A schedule with no route and no fare rules cannot be priced. Its blocked // seats still show up in the report; they just carry no monetary claim. this.logger.warn( `Blocked-seat loss: no fare for schedule ${scheduleId} — ${ err instanceof Error ? err.message : String(err) }`, ); schedulesWithoutFare.add(scheduleId); } }), ); } return { faresBySchedule, schedulesWithoutFare }; } /** CSV of the same report, one row per blocked seat, honouring the same filters. */ async exportBlockedSeatsRevenueLossCsv( query: BlockedSeatsRevenueLossQueryDto, ): Promise { // Export is the whole filtered result, not the caller's page. const report = await this.getBlockedSeatsRevenueLoss({ ...query, page: 1, pageSize: CSV_EXPORT_MAX_SCHEDULES, }); const headers = [ 'Train', 'Route', 'Origin', 'Destination', 'Departure', 'Schedule Status', 'Sellable Seats', 'Sold Seats', 'Load Factor %', 'Coach', 'Seat', 'Seat Class', 'Block Type', 'Reason Category', 'Reason', 'Blocked By', 'Blocked By Name', 'Approved By', 'Blocked At', 'Unblock At', 'Still Blocked', 'Days Blocked', 'Estimated Loss (minor)', 'Currency', ]; const rows = report.schedules.flatMap((s) => s.blocks.map((b) => [ s.trainNumber, s.routeName ?? '', s.originStation, s.destinationStation, s.departureAt, s.status, s.sellableSeats, s.soldSeats, s.loadFactorPercent, b.coachNumber ?? '', b.seatNumber ?? '', b.seatClassName ?? '', b.blockType, b.reasonCategory ?? UNCATEGORIZED_REASON_CATEGORY, b.reason, b.blockedBy, b.blockedByName ?? '', b.approvedBy ?? '', b.blockedAt, b.unblockAt ?? '', b.stillBlocked ? 'YES' : 'NO', b.daysBlocked, b.estimatedLossMinor, b.currency, ]), ); return [headers, ...rows].map((row) => row.map(toCsvCell).join(',')).join('\n'); } async getReport(reportId: string) { return this.prisma.operationalReport.findUnique({ where: { id: reportId }, }); } async listReports(reportType?: string) { return this.prisma.operationalReport.findMany({ where: reportType ? { reportType } : {}, orderBy: { createdAt: "desc" }, take: 50, }); } }