Files
edr-platform/apps/edr-passenger-api/src/modules/reports/reports.service.ts

2270 lines
81 KiB
TypeScript

import { Injectable, Logger } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
import { BookingStatus } from "@prisma/client";
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,
isDiningCoach,
isGlobalBlockInEffectAt,
isPlaceholderSeat,
LossCalculatorInput,
LossCoach,
LossFare,
LossSeat,
selectCountedBlocks,
soldKey,
TICKETING_BLOCK_REASON_PREFIX,
} from "./blocked-seats-loss.calculator";
/** Fares are quoted at the local tariff unless the caller asks otherwise. */
const DEFAULT_LOSS_NATIONALITY = "Ethiopian";
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;
// ── Fleet seat-status overview (landing view of the seat status report) ──────
const MS_PER_DAY_OVERVIEW = 24 * 60 * 60 * 1000;
const OVERVIEW_DEFAULT_DAYS = 7;
const OVERVIEW_MAX_DAYS = 31;
/** Upper bound on schedules charted at once. Signalled back as `window.truncated`. */
const OVERVIEW_MAX_SCHEDULES = 60;
/** The booking statuses that put a seat on a schedule — same set as the drill-down. */
const OVERVIEW_ACTIVE_BOOKING_STATUSES: BookingStatus[] = [
'CONFIRMED',
'BOARDED',
'PENDING_PAYMENT',
];
/** The passengers report counts people, so a seat awaiting payment does not qualify. */
const PASSENGER_ACTIVE_BOOKING_STATUSES: BookingStatus[] = ['CONFIRMED', 'BOARDED'];
/** Route pairs are long-tailed; only the busiest are legible in a chart. */
const TOP_ROUTES_LIMIT = 8;
const EMPTY_OVERVIEW_TOTALS = {
scheduleCount: 0,
sellableSeats: 0,
paidCount: 0,
unpaidCount: 0,
expiredHoldCount: 0,
blockedCount: 0,
availableCount: 0,
loadFactorPercent: 0,
};
function emptyDayBucket(date: string) {
return {
date,
scheduleCount: 0,
sellableSeats: 0,
paid: 0,
unpaid: 0,
expiredHolds: 0,
blocked: 0,
available: 0,
};
}
const EMPTY_LOSS_INPUT: LossCalculatorInput = {
schedules: [],
seatsById: new Map(),
coachesById: new Map(),
coachIdsBySchedule: new Map(),
soldSeatKeys: new Set(),
blocks: [],
};
/** 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. Dining coaches are dropped in JS below — `CoachType.type`
// holds display names in real data, so an exact match here would not catch them.
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'] } } },
],
},
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' } }],
});
// 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. Counted the same way the blocked-seat revenue loss report
// counts them (see `selectCountedBlocks`), so the two reports never disagree:
// - a schedule-scoped block naming this schedule, or
// - a global block (scheduleId null) that was in effect at departure AND sits on a
// coach actually assigned to this train.
// A global block on a coach that never joined this consist is not a blocked seat here.
// Excluded: dining coaches, placeholder seats, ticket-issuance bookkeeping blocks, and
// MAINTENANCE (a seat out of service, not one withheld by hand).
const [schedule, assignments, blockRows] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { departureAt: true },
}),
this.prisma.coachAssignment.findMany({
where: { scheduleId },
select: { coachId: true },
}),
this.prisma.seatBlock.findMany({
where: {
OR: [
{ scheduleId },
{ scheduleId: null },
],
NOT: [
{ reason: { startsWith: 'MAINTENANCE:' } },
{ reason: { startsWith: TICKETING_BLOCK_REASON_PREFIX } },
],
},
include: {
seat: {
select: {
id: true,
coachId: true,
seatNumber: true,
bedPosition: true,
coach: {
select: {
number: true,
coachType: { select: { name: true, type: true, seatClasses: { select: { name: true, bedPosition: true } } } },
},
},
},
},
},
orderBy: { blockedAt: 'desc' },
}),
]);
const assignedCoachIds = new Set(assignments.map((a) => a.coachId));
const departureAt = schedule?.departureAt ?? null;
// One counted block per seat: a schedule-scoped block beats a global one, and between
// two of the same kind the most recent wins — the rows arrive newest-first, so the
// first of a kind seen for a seat is already the most recent.
const countedBySeat = new Map<string, (typeof blockRows)[number]>();
for (const block of blockRows) {
const seat = block.seat;
if (!seat || isPlaceholderSeat(seat)) continue;
const coachType = seat.coach?.coachType;
if (
isDiningCoach({
coachTypeType: coachType?.type ?? null,
coachTypeName: coachType?.name ?? null,
})
) {
continue;
}
if (block.scheduleId === null) {
if (!assignedCoachIds.has(seat.coachId)) continue;
if (!departureAt || !isGlobalBlockInEffectAt(block, departureAt)) continue;
}
const existing = countedBySeat.get(seat.id);
if (!existing || (existing.scheduleId === null && block.scheduleId !== null)) {
countedBySeat.set(seat.id, block);
}
}
const blocks = [...countedBySeat.values()].sort(
(a, b) => b.blockedAt.getTime() - a.blockedAt.getTime(),
);
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 passengerSeats = bookingSeats.filter(
bs =>
!isDiningCoach({
coachTypeType: bs.seat?.coach?.coachType?.type ?? null,
coachTypeName: bs.seat?.coach?.coachType?.name ?? null,
}),
);
const paidSeats = passengerSeats.filter(bs =>
bs.booking.status === 'CONFIRMED' || bs.booking.status === 'BOARDED'
);
const unpaidSeats = passengerSeats.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.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.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,
})),
};
}
/**
* Fleet-wide seat status across a departure window — the landing view for the seat
* status report, shown before a schedule is picked.
*
* Deliberately a separate method from {@link getSeatStatusReport}: that one answers
* "this schedule, row by row" and its response shape is consumed by the drill-down UI.
* This one answers "the whole window, counts only". They share no code path, but they
* *do* share predicates — every filter below is the same rule the drill-down applies
* (the three-branch seat `OR`, the dining-coach exclusion, the counted-block
* resolution), so a schedule's row here always equals what you see after clicking it.
* Change one and the other must change with it.
*/
async getSeatStatusOverview(daysRaw?: number) {
const days = Math.min(
Math.max(Math.trunc(daysRaw || OVERVIEW_DEFAULT_DAYS), 1),
OVERVIEW_MAX_DAYS,
);
const now = new Date();
// Forward-looking by default. But a database whose schedules are all in the past
// would render an empty chart, which reads as a broken page rather than an honest
// "nothing departing" — so fall back to the most recent window that has departures.
let from = now;
let to = new Date(now.getTime() + days * MS_PER_DAY_OVERVIEW);
let direction: 'UPCOMING' | 'RECENT' = 'UPCOMING';
const upcomingCount = await this.prisma.trainSchedule.count({
where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } },
});
if (upcomingCount === 0) {
const latest = await this.prisma.trainSchedule.findFirst({
where: { departureAt: { lt: now }, status: { not: 'CANCELLED' } },
orderBy: { departureAt: 'desc' },
select: { departureAt: true },
});
if (latest) {
direction = 'RECENT';
to = latest.departureAt;
from = new Date(to.getTime() - days * MS_PER_DAY_OVERVIEW);
}
}
const schedules = await this.prisma.trainSchedule.findMany({
where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } },
select: {
id: true,
departureAt: true,
isPackageOnly: true,
train: { select: { number: true } },
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
},
orderBy: { departureAt: 'asc' },
take: OVERVIEW_MAX_SCHEDULES,
});
if (schedules.length === 0) {
return {
window: { from, to, days, direction, truncated: false },
totals: EMPTY_OVERVIEW_TOTALS,
byDay: [],
schedules: [],
};
}
const scheduleIds = schedules.map((s) => s.id);
const since24h = new Date(now.getTime() - MS_PER_DAY_OVERVIEW);
const [assignments, bookingSeats, holds, blockRows] = await Promise.all([
this.prisma.coachAssignment.findMany({
where: { scheduleId: { in: scheduleIds } },
select: {
scheduleId: true,
coachId: true,
coach: {
select: {
coachType: { select: { name: true, type: true } },
seats: { select: { seatNumber: true } },
},
},
},
}),
// Same three-branch OR as the drill-down (`getSeatStatusReport`): a seat reaches a
// schedule by its own `scheduleId`, by being leg 2 of a return booking, or — on
// older rows with no `scheduleId` — by its booking's outbound schedule. A plain
// `groupBy scheduleId` would silently drop the last two.
this.prisma.bookingSeat.findMany({
where: {
OR: [
{
scheduleId: { in: scheduleIds },
booking: { status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES } },
},
{
leg: 2,
booking: {
returnScheduleId: { in: scheduleIds },
status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES },
},
},
{
scheduleId: null,
leg: 1,
booking: {
scheduleId: { in: scheduleIds },
status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES },
},
},
],
},
select: {
scheduleId: true,
leg: true,
booking: {
select: { status: true, scheduleId: true, returnScheduleId: true },
},
seat: {
select: {
coach: { select: { coachType: { select: { name: true, type: true } } } },
},
},
},
}),
this.prisma.seatHold.findMany({
where: {
scheduleId: { in: scheduleIds },
expiresAt: { lt: now, gte: since24h },
},
select: { scheduleId: true },
}),
this.prisma.seatBlock.findMany({
where: {
OR: [{ scheduleId: { in: scheduleIds } }, { scheduleId: null }],
NOT: [
{ reason: { startsWith: 'MAINTENANCE:' } },
{ reason: { startsWith: TICKETING_BLOCK_REASON_PREFIX } },
],
},
select: {
scheduleId: true,
blockedAt: true,
unblockAt: true,
seat: {
select: {
id: true,
coachId: true,
seatNumber: true,
coach: { select: { coachType: { select: { name: true, type: true } } } },
},
},
},
orderBy: { blockedAt: 'desc' },
}),
]);
// ── Sellable seats and assigned coaches, per schedule ───────────────────────
// Sellable = every seat on every assigned coach, minus dining coaches and
// placeholder rows — the same denominator the revenue-loss report uses.
const sellableBySchedule = new Map<string, number>();
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);
const coachType = assignment.coach?.coachType;
if (
isDiningCoach({
coachTypeType: coachType?.type ?? null,
coachTypeName: coachType?.name ?? null,
})
) {
continue;
}
const sellable = (assignment.coach?.seats ?? []).filter(
(seat) => !isPlaceholderSeat(seat),
).length;
sellableBySchedule.set(
assignment.scheduleId,
(sellableBySchedule.get(assignment.scheduleId) ?? 0) + sellable,
);
}
// ── Paid / unpaid, per schedule ────────────────────────────────────────────
const scheduleIdSet = new Set(scheduleIds);
const paidBySchedule = new Map<string, number>();
const unpaidBySchedule = new Map<string, number>();
for (const bs of bookingSeats) {
// Dining seats only — the drill-down does not drop placeholder rows from the
// passenger seat list, so neither does this.
const coachType = bs.seat?.coach?.coachType;
if (
isDiningCoach({
coachTypeType: coachType?.type ?? null,
coachTypeName: coachType?.name ?? null,
})
) {
continue;
}
// Mirrors the OR branches, in the same order: an explicit `scheduleId` inside the
// window wins, then the return leg, then the booking's outbound schedule.
const scheduleId =
bs.scheduleId && scheduleIdSet.has(bs.scheduleId)
? bs.scheduleId
: bs.leg === 2
? bs.booking.returnScheduleId
: bs.booking.scheduleId;
if (!scheduleId || !scheduleIdSet.has(scheduleId)) continue;
const target =
bs.booking.status === 'PENDING_PAYMENT' ? unpaidBySchedule : paidBySchedule;
target.set(scheduleId, (target.get(scheduleId) ?? 0) + 1);
}
// ── Expired holds, per schedule ────────────────────────────────────────────
const holdsBySchedule = new Map<string, number>();
for (const hold of holds) {
holdsBySchedule.set(
hold.scheduleId,
(holdsBySchedule.get(hold.scheduleId) ?? 0) + 1,
);
}
// ── Blocked seats, per schedule ────────────────────────────────────────────
// One counted block per seat, resolved exactly as the drill-down resolves it: a
// schedule-scoped block beats a global one, and rows arrive newest-first so the
// first of a kind seen for a seat is already the most recent.
const blockedBySchedule = new Map<string, number>();
for (const schedule of schedules) {
const assignedCoachIds = coachIdsBySchedule.get(schedule.id) ?? new Set<string>();
const countedSeatIds = new Map<string, string | null>();
for (const block of blockRows) {
const seat = block.seat;
if (!seat || isPlaceholderSeat(seat)) continue;
const coachType = seat.coach?.coachType;
if (
isDiningCoach({
coachTypeType: coachType?.type ?? null,
coachTypeName: coachType?.name ?? null,
})
) {
continue;
}
if (block.scheduleId !== null) {
if (block.scheduleId !== schedule.id) continue;
} else {
if (!assignedCoachIds.has(seat.coachId)) continue;
if (!isGlobalBlockInEffectAt(block, schedule.departureAt)) continue;
}
const existing = countedSeatIds.get(seat.id);
if (existing === undefined || (existing === null && block.scheduleId !== null)) {
countedSeatIds.set(seat.id, block.scheduleId);
}
}
if (countedSeatIds.size > 0) {
blockedBySchedule.set(schedule.id, countedSeatIds.size);
}
}
// ── Assemble ───────────────────────────────────────────────────────────────
const scheduleRows = schedules.map((s) => {
const sellableSeats = sellableBySchedule.get(s.id) ?? 0;
const paid = paidBySchedule.get(s.id) ?? 0;
const unpaid = unpaidBySchedule.get(s.id) ?? 0;
const expiredHolds = holdsBySchedule.get(s.id) ?? 0;
const blocked = blockedBySchedule.get(s.id) ?? 0;
// Floored at zero: a seat can be both sold and blocked, so the parts can
// over-subtract. Never render a negative slice.
const available = Math.max(0, sellableSeats - paid - unpaid - blocked);
return {
scheduleId: s.id,
trainNumber: s.train.number,
originStation: s.originStation.name,
destinationStation: s.destinationStation.name,
departureAt: s.departureAt,
isPackage: s.isPackageOnly,
sellableSeats,
paid,
unpaid,
expiredHolds,
blocked,
available,
loadFactorPercent:
sellableSeats > 0 ? +((paid / sellableSeats) * 100).toFixed(1) : 0,
};
});
// Day buckets keyed on the UTC calendar date of departure, so the chart's axis and
// its bars are derived from one value and cannot disagree with each other.
const byDayMap = new Map<string, ReturnType<typeof emptyDayBucket>>();
for (const row of scheduleRows) {
const date = row.departureAt.toISOString().slice(0, 10);
const bucket = byDayMap.get(date) ?? emptyDayBucket(date);
bucket.scheduleCount += 1;
bucket.sellableSeats += row.sellableSeats;
bucket.paid += row.paid;
bucket.unpaid += row.unpaid;
bucket.expiredHolds += row.expiredHolds;
bucket.blocked += row.blocked;
bucket.available += row.available;
byDayMap.set(date, bucket);
}
const byDay = [...byDayMap.values()].sort((a, b) => a.date.localeCompare(b.date));
const sum = (pick: (r: (typeof scheduleRows)[number]) => number) =>
scheduleRows.reduce((total, row) => total + pick(row), 0);
const totalSellable = sum((r) => r.sellableSeats);
const totalPaid = sum((r) => r.paid);
return {
window: {
from,
to,
days,
direction,
truncated: schedules.length === OVERVIEW_MAX_SCHEDULES,
},
totals: {
scheduleCount: scheduleRows.length,
sellableSeats: totalSellable,
paidCount: totalPaid,
unpaidCount: sum((r) => r.unpaid),
expiredHoldCount: sum((r) => r.expiredHolds),
blockedCount: sum((r) => r.blocked),
availableCount: sum((r) => r.available),
loadFactorPercent:
totalSellable > 0 ? +((totalPaid / totalSellable) * 100).toFixed(1) : 0,
},
byDay,
schedules: scheduleRows,
};
}
/**
* Fleet-wide passenger mix across a departure window — the landing view for the
* passengers report, shown before a schedule is picked.
*
* Answers "who travelled", not "how full were the trains". Occupancy is deliberately
* absent: this report and the seat status report count capacity differently (this one
* includes dining and placeholder seats in `totalSeats`, the other does not), so an
* occupancy figure here would either contradict the table below it or the seats page.
* That pre-existing difference is left alone rather than silently reconciled.
*
* Counts CONFIRMED and BOARDED only, matching {@link getOccupancyBySchedule} — a seat
* awaiting payment has no passenger on it yet.
*/
async getPassengerOverview(daysRaw?: number) {
const days = Math.min(
Math.max(Math.trunc(daysRaw || OVERVIEW_DEFAULT_DAYS), 1),
OVERVIEW_MAX_DAYS,
);
const now = new Date();
// Window resolution is intentionally a copy of the one in getSeatStatusOverview
// rather than a shared helper: the two reports are free to diverge on what window
// makes sense for them, and a shared helper would couple them for ~20 lines.
let from = now;
let to = new Date(now.getTime() + days * MS_PER_DAY_OVERVIEW);
let direction: 'UPCOMING' | 'RECENT' = 'UPCOMING';
const upcomingCount = await this.prisma.trainSchedule.count({
where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } },
});
if (upcomingCount === 0) {
const latest = await this.prisma.trainSchedule.findFirst({
where: { departureAt: { lt: now }, status: { not: 'CANCELLED' } },
orderBy: { departureAt: 'desc' },
select: { departureAt: true },
});
if (latest) {
direction = 'RECENT';
to = latest.departureAt;
from = new Date(to.getTime() - days * MS_PER_DAY_OVERVIEW);
}
}
const schedules = await this.prisma.trainSchedule.findMany({
where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } },
select: {
id: true,
departureAt: true,
isPackageOnly: true,
originStationId: true,
destinationStationId: true,
train: { select: { number: true } },
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
},
orderBy: { departureAt: 'asc' },
take: OVERVIEW_MAX_SCHEDULES,
});
if (schedules.length === 0) {
return {
window: { from, to, days, direction, truncated: false },
totals: { scheduleCount: 0, totalPassengers: 0, groupPassengers: 0 },
byDay: [],
byNationality: [],
byCategory: [],
topRoutes: [],
schedules: [],
};
}
const scheduleIds = schedules.map((s) => s.id);
// Same three-branch OR as the per-schedule report: own scheduleId, return leg, or a
// legacy null-scheduleId row reached through the booking's outbound schedule.
const bookingSeats = await this.prisma.bookingSeat.findMany({
where: {
OR: [
{
scheduleId: { in: scheduleIds },
booking: { status: { in: PASSENGER_ACTIVE_BOOKING_STATUSES } },
},
{
leg: 2,
booking: {
returnScheduleId: { in: scheduleIds },
status: { in: PASSENGER_ACTIVE_BOOKING_STATUSES },
},
},
{
scheduleId: null,
leg: 1,
booking: {
scheduleId: { in: scheduleIds },
status: { in: PASSENGER_ACTIVE_BOOKING_STATUSES },
},
},
],
},
select: {
scheduleId: true,
leg: true,
bookingId: true,
passengerCategory: true,
passportCountry: true,
idDocumentType: true,
booking: {
select: {
scheduleId: true,
returnScheduleId: true,
originStationId: true,
destinationStationId: true,
},
},
},
});
// Station names for the route pairs. Bookings that never recorded a station fall back
// to the schedule's own endpoints, the same fallback getOccupancyBySchedule applies.
const stationIds = [
...new Set(
[
...bookingSeats.flatMap((bs) => [
bs.booking.originStationId,
bs.booking.destinationStationId,
]),
...schedules.flatMap((s) => [s.originStationId, s.destinationStationId]),
].filter((id): id is string => Boolean(id)),
),
];
const stations = stationIds.length
? 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 scheduleById = new Map(schedules.map((s) => [s.id, s]));
const scheduleIdSet = new Set(scheduleIds);
const passengersBySchedule = new Map<string, number>();
const nationalityCounts = new Map<string, number>();
const categoryCounts = new Map<string, number>();
const routeCounts = new Map<string, { origin: string; destination: string; passengers: number }>();
// A booking contributing more than one seat to the window is a group booking.
const seatsPerBooking = new Map<string, number>();
for (const bs of bookingSeats) {
const scheduleId =
bs.scheduleId && scheduleIdSet.has(bs.scheduleId)
? bs.scheduleId
: bs.leg === 2
? bs.booking.returnScheduleId
: bs.booking.scheduleId;
if (!scheduleId || !scheduleIdSet.has(scheduleId)) continue;
const schedule = scheduleById.get(scheduleId);
passengersBySchedule.set(
scheduleId,
(passengersBySchedule.get(scheduleId) ?? 0) + 1,
);
seatsPerBooking.set(bs.bookingId, (seatsPerBooking.get(bs.bookingId) ?? 0) + 1);
// Same derivation as getPassengerList, so the chart and the drill-down list agree
// on what a passenger's nationality is.
const nationality = bs.passportCountry
? bs.passportCountry === 'Djibouti'
? 'Djiboutian'
: bs.passportCountry
: bs.idDocumentType === 'NATIONAL_ID'
? 'Ethiopian'
: 'Unknown';
nationalityCounts.set(nationality, (nationalityCounts.get(nationality) ?? 0) + 1);
const category = bs.passengerCategory ?? 'ADULT';
categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
const originId = bs.booking.originStationId ?? schedule?.originStationId ?? null;
const destinationId =
bs.booking.destinationStationId ?? schedule?.destinationStationId ?? null;
if (originId && destinationId) {
const key = `${originId}|${destinationId}`;
const existing = routeCounts.get(key);
if (existing) {
existing.passengers += 1;
} else {
routeCounts.set(key, {
origin: stationName.get(originId) ?? originId,
destination: stationName.get(destinationId) ?? destinationId,
passengers: 1,
});
}
}
}
const groupPassengers = [...seatsPerBooking.values()]
.filter((count) => count > 1)
.reduce((sum, count) => sum + count, 0);
const scheduleRows = schedules.map((s) => ({
scheduleId: s.id,
trainNumber: s.train.number,
originStation: s.originStation.name,
destinationStation: s.destinationStation.name,
departureAt: s.departureAt,
isPackage: s.isPackageOnly,
passengers: passengersBySchedule.get(s.id) ?? 0,
}));
const byDayMap = new Map<string, { date: string; scheduleCount: number; passengers: number }>();
for (const row of scheduleRows) {
const date = row.departureAt.toISOString().slice(0, 10);
const bucket = byDayMap.get(date) ?? { date, scheduleCount: 0, passengers: 0 };
bucket.scheduleCount += 1;
bucket.passengers += row.passengers;
byDayMap.set(date, bucket);
}
const rank = <T extends { passengers: number }>(rows: T[]) =>
rows.sort((a, b) => b.passengers - a.passengers);
return {
window: {
from,
to,
days,
direction,
truncated: schedules.length === OVERVIEW_MAX_SCHEDULES,
},
totals: {
scheduleCount: scheduleRows.length,
totalPassengers: scheduleRows.reduce((sum, r) => sum + r.passengers, 0),
groupPassengers,
},
byDay: [...byDayMap.values()].sort((a, b) => a.date.localeCompare(b.date)),
byNationality: rank(
[...nationalityCounts.entries()].map(([nationality, passengers]) => ({
nationality,
passengers,
})),
),
byCategory: rank(
[...categoryCounts.entries()].map(([category, passengers]) => ({
category,
passengers,
})),
),
topRoutes: rank([...routeCounts.values()]).slice(0, TOP_ROUTES_LIMIT),
schedules: scheduleRows,
};
}
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 ──────────────────────────────────────────────
/**
* Resolves the reporting window. Both bounds are inclusive and snap to whole local days,
* matching `generateReport`. When the caller supplies neither bound, defaults to the full
* history of scheduled departures on record — the earliest `TrainSchedule.departureAt` to
* the latest — not a rolling window, so nothing ages out of the report on its own.
*/
private async resolveWindow(
query: Pick<BlockedSeatsRevenueLossQueryDto, "dateFrom" | "dateTo">,
now: Date,
): Promise<{ dateFrom: Date; dateTo: Date }> {
let dateFrom: Date;
let dateTo: Date;
if (query.dateFrom && query.dateTo) {
dateFrom = new Date(query.dateFrom);
dateTo = new Date(query.dateTo);
} else {
const bounds = await this.prisma.trainSchedule.aggregate({
_min: { departureAt: true },
_max: { departureAt: true },
});
dateFrom = query.dateFrom ? new Date(query.dateFrom) : (bounds._min.departureAt ?? new Date(now));
dateTo = query.dateTo ? new Date(query.dateTo) : (bounds._max.departureAt ?? new Date(now));
}
dateTo.setHours(23, 59, 59, 999);
dateFrom.setHours(0, 0, 0, 0);
return { dateFrom, dateTo };
}
/**
* 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 } = await this.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,
});
}
}