mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
479 lines
14 KiB
TypeScript
479 lines
14 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: { 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,
|
|
});
|
|
}
|
|
}
|