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 { 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 { 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); } }