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