Files
edr-platform/apps/edr-passenger-api/src/modules/reports/reports.service.ts
2026-07-21 14:06:46 +03:00

1024 lines
34 KiB
TypeScript

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<string, number>,
);
// 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<string, any>,
);
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<string, any>,
);
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<string, any>,
);
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: { leg: 1 },
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(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) {
// Booked 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' } }],
});
// Manually blocked seats for this schedule — exclude MAINTENANCE entries
const blocks = await this.prisma.seatBlock.findMany({
where: {
scheduleId,
NOT: { reason: { startsWith: 'MAINTENANCE:' } },
},
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;
};
return {
bookedSeats: bookingSeats.map(bs => ({
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,
})),
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<string, number>();
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<string, Date> = {};
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<string, number>);
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,
});
}
}