import { IsString, IsDateString, IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { PaymentMethodType } from '@prisma/client'; import { SeatBlockReasonCategory } from '../seats/seats.dto'; export enum ReportType { REVENUE = 'REVENUE', OCCUPANCY = 'OCCUPANCY', AGENT_SALES = 'AGENT_SALES', CANCELLATIONS = 'CANCELLATIONS', PAYMENT_METHODS = 'PAYMENT_METHODS' } export enum ExportFormat { JSON = 'JSON', CSV = 'CSV', PDF = 'PDF' } export class GenerateReportDto { @ApiProperty({ enum: ReportType }) @IsEnum(ReportType) reportType: ReportType; @ApiProperty({ example: '2026-01-01' }) @IsDateString() dateFrom: string; @ApiProperty({ example: '2026-01-31' }) @IsDateString() dateTo: string; @ApiPropertyOptional() @IsOptional() @IsString() routeId?: string; @ApiPropertyOptional() @IsOptional() @IsString() agentId?: string; } export class ExportReportDto { @ApiProperty() @IsString() reportId: string; @ApiProperty({ enum: ExportFormat }) @IsEnum(ExportFormat) format: ExportFormat; } // ── Blocked Seat Revenue Loss ──────────────────────────────────────────────── export enum BlockedSeatsLossSortBy { /** Largest estimated loss first (default). */ LOSS_DESC = 'lossMinor', /** Smallest estimated loss first. */ LOSS_ASC = 'lossMinorAsc', /** Most blocked seats first. */ BLOCKED_SEATS = 'blockedSeatCount', /** Soonest departure first. */ DEPARTURE = 'departureAt', } export class BlockedSeatsRevenueLossQueryDto { @ApiPropertyOptional({ example: '2026-07-01', description: 'Start of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to the earliest scheduled departure on record.', }) @IsOptional() @IsDateString() dateFrom?: string; @ApiPropertyOptional({ example: '2026-07-31', description: 'End of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to the latest scheduled departure on record.', }) @IsOptional() @IsDateString() dateTo?: string; @ApiPropertyOptional({ description: 'Restrict to a single TrainSchedule.' }) @IsOptional() @IsString() scheduleId?: string; @ApiPropertyOptional({ description: 'Restrict to schedules running this route.' }) @IsOptional() @IsString() routeId?: string; @ApiPropertyOptional({ description: 'Restrict to schedules operated by this train.' }) @IsOptional() @IsString() trainId?: string; @ApiPropertyOptional({ description: 'Restrict to blocks on seats in this coach. Load factor still reflects the whole train, so the percentage stays comparable.', }) @IsOptional() @IsString() coachId?: string; @ApiPropertyOptional({ enum: SeatBlockReasonCategory, description: 'Restrict to blocks in this reporting bucket. Legacy uncategorized blocks are excluded when set.', }) @IsOptional() @IsEnum(SeatBlockReasonCategory) reasonCategory?: SeatBlockReasonCategory; @ApiPropertyOptional({ description: "Blocker filter — matches the IAM user id exactly, or the recorded name case-insensitively.", }) @IsOptional() @IsString() blockedBy?: string; @ApiPropertyOptional({ example: 'Ethiopian', default: 'Ethiopian', description: 'Nationality the counterfactual fares are priced at. Drives both the seat-class tariff variant (LOCAL vs INTERNATIONAL) and the billing currency. Defaults to Ethiopian — the local tariff in ETB.', }) @IsOptional() @IsString() nationality?: string; @ApiPropertyOptional({ default: 1, minimum: 1, description: 'Page of schedules, 1-based.' }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number; @ApiPropertyOptional({ default: 25, minimum: 1, maximum: 200, description: 'Schedules per page.' }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize?: number; @ApiPropertyOptional({ enum: BlockedSeatsLossSortBy, default: BlockedSeatsLossSortBy.LOSS_DESC, description: 'Schedule ordering. Defaults to largest estimated loss first.', }) @IsOptional() @IsEnum(BlockedSeatsLossSortBy) sortBy?: BlockedSeatsLossSortBy; } // ── Finance Summary ────────────────────────────────────────────────────────── export enum FinanceGranularity { DAILY = 'daily', WEEKLY = 'weekly', MONTHLY = 'monthly', } /** * Domestic vs cross-border traffic, derived from the endpoints' `Station.countryCode`: * a trip is INTERCITY only when both endpoints sit in Ethiopia, and INTERNATIONAL as soon * as either endpoint is outside it — which on this line means Djibouti (Alisabieh, Holhol, * Nagad). A trip wholly inside Djibouti counts as INTERNATIONAL too. */ export enum FinanceTripType { INTERCITY = 'intercity', INTERNATIONAL = 'international', } /** * Travel-package revenue vs ordinary ticket sales, derived from `Booking.packageId`. * NOT the `Booking.bookingType` column, which holds ONE_WAY / ROUND_TRIP — every package * booking happens to be ROUND_TRIP, but that is a different question from this one. */ export enum FinanceBookingType { REGULAR = 'regular', PACKAGE = 'package', } /** * Which revenue stream a row came from. `ticket` is the booking fare — all this report used to * count. The rest are fees collected after the fare: `excess_baggage` from ExcessBaggageCharge, * `outstanding` from a PAID SupplementaryCharge with reason UNDERPAYMENT, and `other` from every * remaining supplementary reason (UPGRADE, RESCHEDULE, and anything added later — `reason` is a * free-text column, so this bucket is deliberately open-ended). */ export enum FinanceRevenueType { TICKET = 'ticket', EXCESS_BAGGAGE = 'excess_baggage', OUTSTANDING = 'outstanding', OTHER = 'other', } export class FinanceSummaryQueryDto { @ApiProperty({ example: '2026-07-01', description: 'Start of the window, inclusive, matched on PaymentIntent.paidAt.' }) @IsDateString() dateFrom: string; @ApiProperty({ example: '2026-07-31', description: 'End of the window, inclusive, matched on PaymentIntent.paidAt.' }) @IsDateString() dateTo: string; @ApiPropertyOptional({ enum: FinanceGranularity, default: FinanceGranularity.DAILY }) @IsOptional() @IsEnum(FinanceGranularity) granularity?: FinanceGranularity; @ApiPropertyOptional({ description: 'Restrict to bookings departing from this station.' }) @IsOptional() @IsString() originStationId?: string; @ApiPropertyOptional({ description: 'Restrict to bookings arriving at this station.' }) @IsOptional() @IsString() destinationStationId?: string; @ApiPropertyOptional({ enum: PaymentMethodType, description: 'Restrict to payments made with this method.' }) @IsOptional() @IsEnum(PaymentMethodType) method?: PaymentMethodType; @ApiPropertyOptional({ enum: FinanceTripType, description: 'Restrict to trips wholly inside Ethiopia (intercity) or trips touching a station outside ' + 'Ethiopia (international — in practice Djibouti). Omit for all trips.', }) @IsOptional() @IsEnum(FinanceTripType) tripType?: FinanceTripType; @ApiPropertyOptional({ enum: FinanceBookingType, description: 'Restrict to ordinary ticket sales (regular) or travel-package bookings (package). ' + 'Omit for all bookings. Unrelated to the ONE_WAY/ROUND_TRIP booking type.', }) @IsOptional() @IsEnum(FinanceBookingType) bookingType?: FinanceBookingType; @ApiPropertyOptional({ enum: FinanceRevenueType, description: 'Restrict to one revenue stream: the booking fare (ticket), excess-baggage fees ' + '(excess_baggage), recovered underpayments (outstanding), or every other supplementary ' + 'charge such as upgrades and reschedules (other). Omit for all revenue.', }) @IsOptional() @IsEnum(FinanceRevenueType) revenueType?: FinanceRevenueType; }