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

214 lines
10 KiB
TypeScript

import { Body, Controller, Get, Param, Post, Query, Res } from "@nestjs/common";
import type { Response } from "express";
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiOkResponse,
ApiProduces,
} from "@nestjs/swagger";
import { ReportsService } from "./reports.service";
import { BlockedSeatsRevenueLossQueryDto, FinanceSummaryQueryDto, GenerateReportDto } from "./reports.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@ApiTags("Reports")
@Controller("reports")
@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
export class ReportsController {
constructor(private service: ReportsService) {}
@Post("generate")
@ApiOperation({ summary: "Generate operational report" })
generateReport(@Body() dto: GenerateReportDto) {
return this.service.generateReport(dto);
}
@Get('schedules')
@ApiOperation({ summary: 'List schedules for the passengers report picker' })
listSchedulesForPicker(@Query('all') all?: string) {
return this.service.listSchedulesForPicker(all === 'true');
}
@Get("passengers/list")
@ApiOperation({ summary: "Flat passenger list for a specific schedule" })
getPassengerList(@Query("scheduleId") scheduleId: string) {
return this.service.getPassengerList(scheduleId);
}
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
@Get("passengers/overview")
@ApiOperation({
summary: "Fleet-wide passenger mix across a departure window",
description:
"Landing view for the passengers report, shown before a schedule is picked. Returns passenger volume per " +
"departure day, nationality split, passenger-category mix and the busiest origin→destination pairs across " +
"the window, plus one row per schedule.\n\n" +
"The window is forward-looking — the next `days` days. If nothing is departing in that window, it falls " +
"back to the most recent `days` of departures on record and says so via `window.direction`.\n\n" +
"Counts CONFIRMED and BOARDED seats only, matching `GET /reports/passengers`. Carries no occupancy figure " +
"by design: this report and the seat status report measure capacity differently, so a shared occupancy " +
"number would contradict one of them.",
})
getPassengerOverview(@Query('days') days?: string) {
return this.service.getPassengerOverview(days ? Number(days) : undefined);
}
@Get("passengers")
@ApiOperation({ summary: "Passengers report for a specific schedule" })
getOccupancyReport(@Query("scheduleId") scheduleId: string) {
return this.service.getOccupancyBySchedule(scheduleId);
}
@Get("payment-discrepancy")
@ApiOperation({ summary: "Payment discrepancy report — bookings where paid amount is less than the fare. Pass `search` to look up a specific PNR or ticket number." })
getPaymentDiscrepancy(
@Query('from') from?: string,
@Query('to') to?: string,
@Query('sortBy') sortBy?: string,
@Query('search') search?: string,
) {
return this.service.getPaymentDiscrepancyReport({ from, to, sortBy, search });
}
@Get("seat-status")
@ApiOperation({ summary: "Seat status breakdown for a schedule (paid, unpaid, expired holds, blocked)" })
getSeatStatusReport(@Query('scheduleId') scheduleId: string) {
return this.service.getSeatStatusReport(scheduleId);
}
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
@Get("seat-status/overview")
@ApiOperation({
summary: "Fleet-wide seat status across a departure window",
description:
"Landing view for the seat status report, shown before a schedule is picked. Returns the same four " +
"counters as the per-schedule report (paid, unpaid, expired holds, blocked) rolled up over a window of " +
"departures, plus per-day buckets and one row per schedule.\n\n" +
"The window is forward-looking — the next `days` days. If nothing is departing in that window, it falls " +
"back to the most recent `days` of departures on record and says so via `window.direction`.\n\n" +
"Counts apply the same rules as `GET /reports/seat-status`, so a schedule's row here equals what the " +
"drill-down shows after selecting it.",
})
getSeatStatusOverview(@Query('days') days?: string) {
return this.service.getSeatStatusOverview(days ? Number(days) : undefined);
}
@Get("boarding")
@ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" })
getBoardingReport(@Query('scheduleId') scheduleId: string) {
return this.service.getBoardingReport(scheduleId);
}
@Get("payments")
@ApiOperation({ summary: "Payments collected for a schedule" })
getPaymentsReport(@Query('scheduleId') scheduleId: string) {
return this.service.getPaymentsReport(scheduleId);
}
@Get("payments/discrepancy")
@ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" })
getPaymentDiscrepancyBySchedule(
@Query('scheduleId') scheduleId: string,
@Query('search') search?: string,
@Query('seatClass') seatClass?: string,
@Query('sort') sort?: string,
) {
return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort });
}
// ── Finance Summary ──────────────────────────────────────────────────────
@Get("finance")
@ApiOperation({
summary: "Finance summary — revenue by period, origin/destination segment, payment method, and currency",
description:
"Revenue collected in the window (PaymentIntent.paidAt), grouped by day/week/month, origin → " +
"destination station pair, payment method, and currency. Amounts are never converted to ETB — a " +
"Waafi payment is reported in whatever currency Waafi actually charged, and with no method filter " +
"every currency present is listed separately rather than summed. Filter by originStationId and/or " +
"destinationStationId independently to query any station-pair segment (A→B, A→D, B→C), not just a " +
"whole predefined route. Only counts CONFIRMED/BOARDED bookings with a SUCCEEDED payment — the same " +
"revenue definition as the dashboard and /payments confirmed-revenue filter. Returns per-bucket rows " +
"plus roll-ups by period, segment, and method for charting.",
})
getFinanceSummary(@Query() query: FinanceSummaryQueryDto) {
return this.service.getFinanceSummary(query);
}
@Get("finance/export")
@ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" })
@ApiProduces("text/csv")
@ApiOkResponse({ description: "CSV export", schema: { type: "string" } })
async exportFinanceSummary(@Query() query: FinanceSummaryQueryDto, @Res() res: Response): Promise<void> {
const csv = await this.service.exportFinanceSummaryCsv(query);
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader(
"Content-Disposition",
`attachment; filename="finance-summary-${new Date().toISOString().split("T")[0]}.csv"`,
);
res.send(csv);
}
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
@Get("blocked-seats-revenue-loss")
@ApiOperation({
summary: "Potential revenue lost to blocked seats, per schedule",
description:
"For every train schedule in the window, the fare revenue that could never be earned because seats were " +
"blocked out of sale — with per-seat drill-down showing who blocked each seat and why.\n\n" +
"**A blocked seat counts against a schedule when** a `SeatBlock` row names that `scheduleId` directly, " +
"**or** a global block (no `scheduleId`) was in effect at departure — `blockedAt <= departureAt` and " +
"(`unblockAt IS NULL` or `unblockAt >= departureAt`) — and the seat's coach was assigned to that schedule " +
"via `CoachAssignment`.\n\n" +
"**Excluded** (echoed in `meta.exclusions`): dining-coach seats, placeholder seats, CANCELLED schedules, " +
"seats that were sold anyway, and ticketing's own bookkeeping blocks.\n\n" +
"**This is a counterfactual.** `estimatedLossMinor` assumes every blocked seat would have sold; " +
"`adjustedLossMinor` scales it by the schedule's load factor. The real figure sits between the two — " +
"`meta.methodology` states the formula and the nationality assumption in full.\n\n" +
"All amounts are integer minor units, grouped per currency and never summed across currencies.",
})
@ApiOkResponse({ description: "Blocked-seat revenue loss report" })
getBlockedSeatsRevenueLoss(@Query() query: BlockedSeatsRevenueLossQueryDto) {
return this.service.getBlockedSeatsRevenueLoss(query);
}
@Get("blocked-seats-revenue-loss/export")
@ApiOperation({
summary: "Blocked-seat revenue loss as CSV",
description:
"Same filters as the JSON report, flattened to one row per blocked seat. Not paginated — the whole " +
"filtered result is returned.",
})
@ApiProduces("text/csv")
@ApiOkResponse({ description: "CSV export", schema: { type: "string" } })
// `@Res()` without passthrough so the global ResponseTransformInterceptor does not wrap
// the CSV in a `{ success, data }` envelope — same approach as the attachment stream.
async exportBlockedSeatsRevenueLoss(
@Query() query: BlockedSeatsRevenueLossQueryDto,
@Res() res: Response,
): Promise<void> {
const csv = await this.service.exportBlockedSeatsRevenueLossCsv(query);
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader(
"Content-Disposition",
`attachment; filename="blocked-seats-revenue-loss-${new Date().toISOString().split("T")[0]}.csv"`,
);
res.send(csv);
}
@Get(":reportId")
@ApiOperation({ summary: "Get report by ID" })
getReport(@Param("reportId") reportId: string) {
return this.service.getReport(reportId);
}
@Get()
@ApiOperation({ summary: "List reports" })
listReports(@Query("type") type?: string) {
return this.service.listReports(type);
}
}