import { Injectable, Logger } from "@nestjs/common"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource } from "typeorm"; import { PrismaService } from "../../common/prisma.service"; import { GenerateReportDto, ReportType } from "./reports.dto"; @Injectable() export class ReportsService { private readonly logger = new Logger(ReportsService.name); constructor( private prisma: PrismaService, @InjectDataSource() private dataSource: DataSource, ) {} 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 directly by scheduleId — avoids deserializing legacy // BookingSeat rows with null scheduleId that crash Prisma when loaded via relation. const allBookingSeats = await this.prisma.bookingSeat.findMany({ where: { scheduleId, leg: 1, booking: { 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: { scheduleId, leg: 1, booking: { 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 — exclude dining coaches const bookingSeats = await this.prisma.bookingSeat.findMany({ where: { scheduleId, leg: 1, booking: { status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } }, seat: { coach: { coachType: { type: { not: 'dining' } } } }, }, 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' } }], }); // Active seat holds for this schedule const activeHolds = await this.prisma.seatHold.findMany({ where: { scheduleId }, orderBy: { createdAt: 'desc' }, }); // 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 — schedule-scoped blocks for this schedule OR global blocks (scheduleId null) // Exclude MAINTENANCE and booking-system-created blocks const blocks = await this.prisma.seatBlock.findMany({ where: { OR: [ { scheduleId }, { scheduleId: null }, ], NOT: [ { reason: { startsWith: 'MAINTENANCE:' } }, { reason: { startsWith: 'Booked in tickets' } }, { blockedBy: 'system' }, ], }, include: { seat: { select: { seatNumber: true, bedPosition: true, coach: { select: { number: true, coachType: { select: { name: true, type: true, seatClasses: { select: { name: true, bedPosition: true } } } }, }, }, }, }, }, orderBy: { blockedAt: 'desc' }, }); 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 paidSeats = bookingSeats.filter(bs => bs.booking.status === 'CONFIRMED' || bs.booking.status === 'BOARDED' ); const unpaidSeats = bookingSeats.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.filter(b => b.seat?.coach?.coachType?.type !== 'dining').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 .filter(b => b.seat?.coach?.coachType?.type !== 'dining') .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, })), }; } async getPaymentDiscrepancyReport(params: { from?: string; to?: string; sortBy?: string; search?: string; }) { // Load exchange rates once — we need DJF→ETB (and any other non-ETB currencies). // Keep only the most-recent rate per pair (rates are ordered desc by effectiveDate). 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)); } } // Convert any minor amount to its ETB equivalent using stored exchange rates. // b.totalMinor is the booking's canonical ETB amount (always stored in ETB), // so callers should pass that directly rather than converting displayTotalMinor. const toEtbMinor = (minor: number, currency: string): number => { if (currency === 'ETB') return minor; const rate = rateToEtb.get(currency); // If no rate is on file fall back to the raw value (avoids silently hiding // cross-currency bookings, at the cost of an approximate comparison). return rate ? Math.round(minor * rate) : minor; }; if (params.search?.trim()) { return this.getDiscrepancyForRef(params.search.trim(), toEtbMinor); } 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). const actualMinor = b.displayTotalMinor ?? b.totalMinor; const actualCurrency = (b.displayCurrency as string | null) ?? b.currency; const paidMinor = pi.amountMinor; const paidCurrency = pi.currency; // b.totalMinor is always in ETB minor. pi.amountMinor is the charge MAJOR amount // (the gateway receives major units — displayMinorToChargeMajor divides by 100 before // sending). Multiply by 100 to convert back to minor before the ETB comparison. const owedEtb = b.totalMinor; const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency); const balanceMinor = owedEtb - paidEtb; const balanceCurrency = 'ETB'; 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, toEtbMinor: (minor: number, currency: 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 owedEtb = b.totalMinor; const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency); const balanceMinor = owedEtb - paidEtb; const balanceCurrency = 'ETB'; 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 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, 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 actualMinor = b.seats.reduce((s, seat) => s + (seat.fareMinor ?? 0), 0); const paidMinor = Math.round(b.paymentIntent!.amountMinor); return { bookingRef: b.bookingRef, passengerName: b.seats[0]?.passengerName ?? b.passenger?.user?.fullName ?? '—', phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—', method: b.paymentIntent!.method, paidAt: b.paymentIntent!.paidAt, actualMinor, paidMinor, currency: 'ETB', passengerCount: b.seats.length, }; }); const totalActualMinor = rows.reduce((s, r) => s + r.actualMinor, 0); const totalPaidMinor = rows.reduce((s, r) => s + r.paidMinor, 0); const byMethod = rows.reduce((acc, r) => { acc[r.method] = (acc[r.method] ?? 0) + r.paidMinor; return acc; }, {} as Record); return { totalActualMinor, totalPaidMinor, 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: { include: { originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, }, }, package: { 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 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).package; 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.schedule.originStation.name, destination: b.schedule.destinationStation.name, 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(b => b.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 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, }); } }