mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 13:35:03 +00:00
feat: ( reports ) add regular/package booking-type filter to finance summary
This commit is contained in:
@@ -122,7 +122,7 @@ export class ReportsController {
|
||||
|
||||
@Get("finance")
|
||||
@ApiOperation({
|
||||
summary: "Finance summary — revenue by period, origin/destination segment, trip type, payment method, and currency",
|
||||
summary: "Finance summary — revenue by period, origin/destination segment, trip type, booking type, 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 " +
|
||||
@@ -131,10 +131,13 @@ export class ReportsController {
|
||||
"destinationStationId independently to query any station-pair segment (A→B, A→D, B→C), not just a " +
|
||||
"whole predefined route. Pass `tripType` to split domestic from cross-border traffic: a trip is " +
|
||||
"`intercity` only when both endpoints sit in Ethiopia, and `international` as soon as either endpoint " +
|
||||
"is outside it — so Sebeta → Nagad, Nagad → Sebeta and Alisabieh → Nagad are all international. Only " +
|
||||
"counts CONFIRMED/BOARDED bookings with a SUCCEEDED payment — the same revenue definition as the " +
|
||||
"is outside it — so Sebeta → Nagad, Nagad → Sebeta and Alisabieh → Nagad are all international. Pass " +
|
||||
"`bookingType` to separate travel-package revenue from ordinary ticket sales: `package` is a booking " +
|
||||
"carrying a `packageId`, `regular` is one without — unrelated to the ONE_WAY/ROUND_TRIP booking type, " +
|
||||
"and not counting the legacy standalone `PackageBooking` table, which this report has never included. " +
|
||||
"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, trip type, and method for charting.",
|
||||
"segment, trip type, booking type, and method for charting.",
|
||||
})
|
||||
getFinanceSummary(@Query() query: FinanceSummaryQueryDto) {
|
||||
return this.service.getFinanceSummary(query);
|
||||
|
||||
@@ -124,6 +124,16 @@ export enum FinanceTripType {
|
||||
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',
|
||||
}
|
||||
|
||||
export class FinanceSummaryQueryDto {
|
||||
@ApiProperty({ example: '2026-07-01', description: 'Start of the window, inclusive, matched on PaymentIntent.paidAt.' })
|
||||
@IsDateString() dateFrom: string;
|
||||
@@ -150,4 +160,12 @@ export class FinanceSummaryQueryDto {
|
||||
'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;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { FareEngineService } from "../fare-engine/fare-engine.service";
|
||||
import {
|
||||
BlockedSeatsLossSortBy,
|
||||
BlockedSeatsRevenueLossQueryDto,
|
||||
FinanceBookingType,
|
||||
FinanceGranularity,
|
||||
FinanceSummaryQueryDto,
|
||||
FinanceTripType,
|
||||
@@ -154,6 +155,8 @@ export interface FinanceBucket {
|
||||
destinationStationId: string;
|
||||
segmentLabel: string;
|
||||
tripType: FinanceTripType;
|
||||
/** regular vs package — from `Booking.packageId`, not the ONE_WAY/ROUND_TRIP column. */
|
||||
bookingType: FinanceBookingType;
|
||||
method: string;
|
||||
currency: string;
|
||||
bookingCount: number;
|
||||
@@ -1783,9 +1786,15 @@ export class ReportsService {
|
||||
},
|
||||
...(query.originStationId ? { originStationId: query.originStationId } : {}),
|
||||
...(query.destinationStationId ? { destinationStationId: query.destinationStationId } : {}),
|
||||
// Unlike tripType, this one pushes down: `packageId` sits on the booking row itself,
|
||||
// so there is no schedule fallback to preserve and the database can do the filtering.
|
||||
...(query.bookingType
|
||||
? { packageId: query.bookingType === FinanceBookingType.PACKAGE ? { not: null } : null }
|
||||
: {}),
|
||||
},
|
||||
select: {
|
||||
totalMinor: true,
|
||||
packageId: true,
|
||||
currency: true,
|
||||
displayTotalMinor: true,
|
||||
displayCurrency: true,
|
||||
@@ -1818,20 +1827,23 @@ export class ReportsService {
|
||||
const buckets = new Map<string, FinanceBucket>();
|
||||
// `tripType` is a pure function of the station pair, so it never splits a bucket that the
|
||||
// origin/destination part of the key hasn't split already — it rides along on the bucket
|
||||
// rather than joining the key.
|
||||
// rather than joining the key. `bookingType` is not: a package and a regular booking can
|
||||
// share the same period, segment, method and currency, so it has to be part of the key or
|
||||
// a mixed bucket would take whichever label happened to land first.
|
||||
const bucketFor = (
|
||||
period: string,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
segmentLabel: string,
|
||||
tripType: FinanceTripType,
|
||||
bookingType: FinanceBookingType,
|
||||
method: string,
|
||||
currency: string,
|
||||
): FinanceBucket => {
|
||||
const key = `${period}|${originStationId}|${destinationStationId}|${method}|${currency}`;
|
||||
const key = `${period}|${originStationId}|${destinationStationId}|${bookingType}|${method}|${currency}`;
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { period, originStationId, destinationStationId, segmentLabel, tripType, method, currency, bookingCount: 0, revenueMinor: 0 };
|
||||
bucket = { period, originStationId, destinationStationId, segmentLabel, tripType, bookingType, method, currency, bookingCount: 0, revenueMinor: 0 };
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
return bucket;
|
||||
@@ -1851,9 +1863,10 @@ export class ReportsService {
|
||||
const tripType = tripTypeFor(stationCountry.get(originStationId), stationCountry.get(destinationStationId));
|
||||
if (query.tripType && tripType !== query.tripType) continue;
|
||||
|
||||
const bookingType = b.packageId ? FinanceBookingType.PACKAGE : FinanceBookingType.REGULAR;
|
||||
const currency = (b.displayCurrency as string | null) ?? b.currency;
|
||||
const amountMinor = b.displayTotalMinor ?? b.totalMinor;
|
||||
const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, tripType, pi.method, currency);
|
||||
const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, tripType, bookingType, pi.method, currency);
|
||||
bucket.bookingCount += 1;
|
||||
bucket.revenueMinor += amountMinor;
|
||||
}
|
||||
@@ -1888,11 +1901,13 @@ export class ReportsService {
|
||||
dateFrom: query.dateFrom,
|
||||
dateTo: query.dateTo,
|
||||
tripType: query.tripType ?? null,
|
||||
bookingType: query.bookingType ?? null,
|
||||
totals,
|
||||
byPeriod: rollUp((r) => `${r.period}|${r.currency}`, (r) => r.period),
|
||||
bySegment: rollUp((r) => `${r.originStationId}|${r.destinationStationId}|${r.currency}`, (r) => r.segmentLabel),
|
||||
byMethod: rollUp((r) => `${r.method}|${r.currency}`, (r) => r.method),
|
||||
byTripType: rollUp((r) => `${r.tripType}|${r.currency}`, (r) => r.tripType),
|
||||
byBookingType: rollUp((r) => `${r.bookingType}|${r.currency}`, (r) => r.bookingType),
|
||||
rows,
|
||||
};
|
||||
}
|
||||
@@ -1901,11 +1916,12 @@ export class ReportsService {
|
||||
async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise<string> {
|
||||
const report = await this.getFinanceSummary(query);
|
||||
|
||||
const headers = ["Period", "Origin → Destination", "Trip Type", "Payment Method", "Currency", "Bookings", "Revenue"];
|
||||
const headers = ["Period", "Origin → Destination", "Trip Type", "Booking Type", "Payment Method", "Currency", "Bookings", "Revenue"];
|
||||
const rows = report.rows.map((r) => [
|
||||
r.period,
|
||||
r.segmentLabel,
|
||||
r.tripType,
|
||||
r.bookingType,
|
||||
r.method,
|
||||
r.currency,
|
||||
r.bookingCount,
|
||||
|
||||
Reference in New Issue
Block a user