mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
1562 lines
56 KiB
TypeScript
1562 lines
56 KiB
TypeScript
import { Injectable, Logger } from "@nestjs/common";
|
|
import { InjectDataSource } from "@nestjs/typeorm";
|
|
import { DataSource } from "typeorm";
|
|
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,
|
|
LossCalculatorInput,
|
|
LossCoach,
|
|
LossFare,
|
|
LossSeat,
|
|
selectCountedBlocks,
|
|
soldKey,
|
|
} from "./blocked-seats-loss.calculator";
|
|
|
|
/** Fares are quoted at the local tariff unless the caller asks otherwise. */
|
|
const DEFAULT_LOSS_NATIONALITY = "Ethiopian";
|
|
/** Window used when the caller supplies neither `dateFrom` nor `dateTo`. */
|
|
const DEFAULT_LOSS_WINDOW_DAYS = 30;
|
|
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;
|
|
|
|
const EMPTY_LOSS_INPUT: LossCalculatorInput = {
|
|
schedules: [],
|
|
seatsById: new Map(),
|
|
coachesById: new Map(),
|
|
coachIdsBySchedule: new Map(),
|
|
soldSeatKeys: new Set(),
|
|
blocks: [],
|
|
};
|
|
|
|
/**
|
|
* Resolves the reporting window. Both bounds are inclusive and snap to whole local days,
|
|
* matching `generateReport`. Defaults to the last 30 days of departures.
|
|
*/
|
|
function resolveWindow(
|
|
query: Pick<BlockedSeatsRevenueLossQueryDto, "dateFrom" | "dateTo">,
|
|
now: Date,
|
|
): { dateFrom: Date; dateTo: Date } {
|
|
const dateTo = query.dateTo ? new Date(query.dateTo) : new Date(now);
|
|
dateTo.setHours(23, 59, 59, 999);
|
|
|
|
const dateFrom = query.dateFrom
|
|
? new Date(query.dateFrom)
|
|
: new Date(dateTo.getTime() - DEFAULT_LOSS_WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
|
dateFrom.setHours(0, 0, 0, 0);
|
|
|
|
return { dateFrom, dateTo };
|
|
}
|
|
|
|
/** 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<string, unknown>;
|
|
|
|
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<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'] } },
|
|
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<string, { coachNumber: string; coachType: string; totalSeats: number; booked: number }>();
|
|
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<string, { className: string; totalSeats: number; booked: number }>();
|
|
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<string, number>);
|
|
const originMap = new Map<string, { stationName: string; passengers: number }>();
|
|
const destMap = new Map<string, { stationName: string; passengers: number }>();
|
|
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 — exclude dining coaches
|
|
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'] } } },
|
|
],
|
|
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' } },
|
|
],
|
|
},
|
|
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 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<string, number>();
|
|
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<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/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<string, number>();
|
|
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<string, { totalPaidEtbMinor: number; currency: string }>);
|
|
|
|
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<string, { coachNumber: string; total: number; boarded: number }>();
|
|
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 ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* 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<BlockedSeatRevenueLossReport> {
|
|
const now = new Date();
|
|
const { dateFrom, dateTo } = 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<string, Map<string, LossFare>>(),
|
|
schedulesWithoutFare: new Set<string>(),
|
|
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<string, LossCoach>();
|
|
const coachIdsBySchedule = new Map<string, Set<string>>();
|
|
for (const assignment of assignments) {
|
|
const coachIds = coachIdsBySchedule.get(assignment.scheduleId) ?? new Set<string>();
|
|
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<string, LossSeat>(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<string>();
|
|
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<string, Map<string, LossFare>>;
|
|
schedulesWithoutFare: Set<string>;
|
|
}> {
|
|
const faresBySchedule = new Map<string, Map<string, LossFare>>();
|
|
const schedulesWithoutFare = new Set<string>();
|
|
|
|
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<string, LossFare>();
|
|
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<string> {
|
|
// 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,
|
|
});
|
|
}
|
|
}
|