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"] } }, include: { seats: { where: { scheduleId }, include: { seat: { include: { coach: { include: { coachType: true } } } }, }, }, }, }, stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } }, }, }); if (!schedule) return null; const totalSeats = (schedule as any).coachAssignments.reduce( (s: number, a: any) => s + a.coach.seats.length, 0, ); const allBookingSeats = (schedule as any).bookings.flatMap( (b: any) => b.seats, ); const totalPassengers = allBookingSeats.length; const occupancyRate = totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0; // Per-coach breakdown const coachMap = new Map< string, { coachNumber: string; coachType: string; totalSeats: number; booked: number; } >(); for (const assignment of (schedule as any).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-origin station breakdown (using booking's originStationId) const originMap = new Map< string, { stationName: string; passengers: number } >(); for (const booking of (schedule as any).bookings) { const stationId = booking.originStationId ?? schedule.originStationId; const stationName = (schedule as any).stopTimes.find( (st: any) => st.stationId === stationId, )?.station?.name ?? (schedule as any).originStation?.name ?? stationId; if (!originMap.has(stationId)) originMap.set(stationId, { stationName, passengers: 0 }); originMap.get(stationId)!.passengers += booking.seats.length; } const byOrigin = [...originMap.values()].sort( (a, b) => b.passengers - a.passengers, ); // Per-destination station breakdown const destMap = new Map< string, { stationName: string; passengers: number } >(); for (const booking of (schedule as any).bookings) { const stationId = booking.destinationStationId ?? schedule.destinationStationId; const stationName = (schedule as any).stopTimes.find( (st: any) => st.stationId === stationId, )?.station?.name ?? (schedule as any).destinationStation?.name ?? stationId; if (!destMap.has(stationId)) destMap.set(stationId, { stationName, passengers: 0 }); destMap.get(stationId)!.passengers += booking.seats.length; } const byDestination = [...destMap.values()].sort( (a, b) => b.passengers - a.passengers, ); // Per-class breakdown const classMap = new Map< string, { className: string; totalSeats: number; booked: number } >(); for (const assignment of (schedule as any).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, })); 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() { const schedules = await this.prisma.trainSchedule.findMany({ select: { id: true, departureAt: true, train: { select: { number: true } }, originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, }, orderBy: { departureAt: "desc" }, take: 200, }); return schedules.map((s) => ({ id: s.id, label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" })}`, })); } async getPassengerList(scheduleId: string) { const seats = await this.prisma.bookingSeat.findMany({ where: { scheduleId, booking: { status: { in: ["CONFIRMED", "BOARDED"] } }, }, include: { booking: { select: { bookingRef: true, status: true } }, seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } }, }, }, }, }, orderBy: [{ seat: { coach: { number: "asc" } } }], }); return seats.map((bs) => ({ bookingRef: bs.booking.bookingRef, bookingStatus: bs.booking.status, passengerName: bs.passengerName, passengerCategory: bs.passengerCategory, idDocumentType: bs.idDocumentType, idDocumentNumber: bs.idDocumentNumber, passportNumber: bs.passportNumber, passportCountry: bs.passportCountry, seatLabel: bs.seatLabelSnapshot, coachNumber: bs.seat?.coach?.number ?? null, coachType: (bs.seat?.coach as any)?.coachType?.name ?? null, })); } 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, }); } }