mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 12:25:02 +00:00
feat: ( reports ) add intercity/international trip-type filter to finance summary
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { tripTypeFor } from './finance-trip-type';
|
||||
import { FinanceTripType } from './reports.dto';
|
||||
|
||||
describe('tripTypeFor', () => {
|
||||
it('counts a trip between two Ethiopian stations as intercity', () => {
|
||||
// Sebeta → Dire Dawa
|
||||
expect(tripTypeFor('ET', 'ET')).toBe(FinanceTripType.INTERCITY);
|
||||
});
|
||||
|
||||
it('counts a trip leaving Ethiopia as international', () => {
|
||||
// Sebeta → Nagad
|
||||
expect(tripTypeFor('ET', 'DJ')).toBe(FinanceTripType.INTERNATIONAL);
|
||||
});
|
||||
|
||||
it('counts a trip arriving in Ethiopia as international', () => {
|
||||
// Nagad → Sebeta
|
||||
expect(tripTypeFor('DJ', 'ET')).toBe(FinanceTripType.INTERNATIONAL);
|
||||
});
|
||||
|
||||
it('counts a trip wholly inside Djibouti as international', () => {
|
||||
// Alisabieh → Nagad — international because neither endpoint is Ethiopian, not because
|
||||
// the trip crosses a border.
|
||||
expect(tripTypeFor('DJ', 'DJ')).toBe(FinanceTripType.INTERNATIONAL);
|
||||
});
|
||||
|
||||
it('treats a station with no country code as Ethiopian', () => {
|
||||
expect(tripTypeFor(null, null)).toBe(FinanceTripType.INTERCITY);
|
||||
expect(tripTypeFor(undefined, 'ET')).toBe(FinanceTripType.INTERCITY);
|
||||
});
|
||||
|
||||
it('still reports international when only the known endpoint is outside Ethiopia', () => {
|
||||
expect(tripTypeFor(null, 'DJ')).toBe(FinanceTripType.INTERNATIONAL);
|
||||
expect(tripTypeFor('DJ', undefined)).toBe(FinanceTripType.INTERNATIONAL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Finance trip type — the domestic / cross-border rule.
|
||||
*
|
||||
* Deliberately free of Prisma and Nest, in the same spirit as
|
||||
* `blocked-seats-loss.calculator.ts`: `ReportsService` fetches the stations, this module
|
||||
* decides what the station pair means. Every case below has a unit test in
|
||||
* `finance-trip-type.spec.ts`.
|
||||
*/
|
||||
|
||||
import { FinanceTripType } from './reports.dto';
|
||||
|
||||
/** The country a station has to sit in for a trip to count as domestic Ethiopian traffic. */
|
||||
export const DOMESTIC_COUNTRY = 'ET';
|
||||
|
||||
/**
|
||||
* Classifies a trip from its endpoints' `Station.countryCode`: intercity when both endpoints
|
||||
* are Ethiopian, international as soon as either is not — so Sebeta → Nagad, Nagad → Sebeta
|
||||
* and Alisabieh → Nagad are all international.
|
||||
*
|
||||
* A station with no `countryCode` is treated as Ethiopian. The column is nullable and the
|
||||
* back-office station form defaults it to `'ET'`, which is the same assumption the portal
|
||||
* makes when deciding whether a passenger needs a Fayda ID. Defaulting rather than carrying
|
||||
* an "unknown" bucket keeps the invariant that intercity + international equals the
|
||||
* unfiltered total.
|
||||
*/
|
||||
export function tripTypeFor(
|
||||
originCountry: string | null | undefined,
|
||||
destinationCountry: string | null | undefined,
|
||||
): FinanceTripType {
|
||||
const bothDomestic =
|
||||
(originCountry ?? DOMESTIC_COUNTRY) === DOMESTIC_COUNTRY &&
|
||||
(destinationCountry ?? DOMESTIC_COUNTRY) === DOMESTIC_COUNTRY;
|
||||
return bothDomestic ? FinanceTripType.INTERCITY : FinanceTripType.INTERNATIONAL;
|
||||
}
|
||||
@@ -122,16 +122,19 @@ export class ReportsController {
|
||||
|
||||
@Get("finance")
|
||||
@ApiOperation({
|
||||
summary: "Finance summary — revenue by period, origin/destination segment, payment method, and currency",
|
||||
summary: "Finance summary — revenue by period, origin/destination segment, trip 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 " +
|
||||
"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.",
|
||||
"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 " +
|
||||
"dashboard and /payments confirmed-revenue filter. Returns per-bucket rows plus roll-ups by period, " +
|
||||
"segment, trip type, and method for charting.",
|
||||
})
|
||||
getFinanceSummary(@Query() query: FinanceSummaryQueryDto) {
|
||||
return this.service.getFinanceSummary(query);
|
||||
|
||||
@@ -113,6 +113,17 @@ export enum FinanceGranularity {
|
||||
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',
|
||||
}
|
||||
|
||||
export class FinanceSummaryQueryDto {
|
||||
@ApiProperty({ example: '2026-07-01', description: 'Start of the window, inclusive, matched on PaymentIntent.paidAt.' })
|
||||
@IsDateString() dateFrom: string;
|
||||
@@ -131,4 +142,12 @@ export class FinanceSummaryQueryDto {
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
BlockedSeatsRevenueLossQueryDto,
|
||||
FinanceGranularity,
|
||||
FinanceSummaryQueryDto,
|
||||
FinanceTripType,
|
||||
GenerateReportDto,
|
||||
ReportType,
|
||||
} from "./reports.dto";
|
||||
import { tripTypeFor } from "./finance-trip-type";
|
||||
import {
|
||||
assembleReport,
|
||||
isDiningCoach,
|
||||
@@ -151,6 +153,7 @@ export interface FinanceBucket {
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
segmentLabel: string;
|
||||
tripType: FinanceTripType;
|
||||
method: string;
|
||||
currency: string;
|
||||
bookingCount: number;
|
||||
@@ -1804,23 +1807,31 @@ export class ReportsService {
|
||||
if (destination) stationIds.add(destination);
|
||||
}
|
||||
const stations = stationIds.size > 0
|
||||
? await this.prisma.station.findMany({ where: { id: { in: [...stationIds] } }, select: { id: true, name: true } })
|
||||
? await this.prisma.station.findMany({
|
||||
where: { id: { in: [...stationIds] } },
|
||||
select: { id: true, name: true, countryCode: true },
|
||||
})
|
||||
: [];
|
||||
const stationName = new Map(stations.map((s) => [s.id, s.name]));
|
||||
const stationCountry = new Map(stations.map((s) => [s.id, s.countryCode]));
|
||||
|
||||
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.
|
||||
const bucketFor = (
|
||||
period: string,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
segmentLabel: string,
|
||||
tripType: FinanceTripType,
|
||||
method: string,
|
||||
currency: string,
|
||||
): FinanceBucket => {
|
||||
const key = `${period}|${originStationId}|${destinationStationId}|${method}|${currency}`;
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { period, originStationId, destinationStationId, segmentLabel, method, currency, bookingCount: 0, revenueMinor: 0 };
|
||||
bucket = { period, originStationId, destinationStationId, segmentLabel, tripType, method, currency, bookingCount: 0, revenueMinor: 0 };
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
return bucket;
|
||||
@@ -1832,9 +1843,17 @@ export class ReportsService {
|
||||
const originStationId = b.originStationId ?? b.schedule.originStationId ?? "UNKNOWN";
|
||||
const destinationStationId = b.destinationStationId ?? b.schedule.destinationStationId ?? "UNKNOWN";
|
||||
const segmentLabel = `${stationName.get(originStationId) ?? "Unknown"} → ${stationName.get(destinationStationId) ?? "Unknown"}`;
|
||||
|
||||
// Filtered here rather than pushed into the Prisma `where`: the endpoints that decide
|
||||
// the trip type are `booking.originStationId ?? schedule.originStationId`, and a
|
||||
// `{ in: ethiopianStationIds }` clause would mis-bucket any legacy row whose
|
||||
// booking-level station columns are null.
|
||||
const tripType = tripTypeFor(stationCountry.get(originStationId), stationCountry.get(destinationStationId));
|
||||
if (query.tripType && tripType !== query.tripType) continue;
|
||||
|
||||
const currency = (b.displayCurrency as string | null) ?? b.currency;
|
||||
const amountMinor = b.displayTotalMinor ?? b.totalMinor;
|
||||
const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, pi.method, currency);
|
||||
const bucket = bucketFor(period, originStationId, destinationStationId, segmentLabel, tripType, pi.method, currency);
|
||||
bucket.bookingCount += 1;
|
||||
bucket.revenueMinor += amountMinor;
|
||||
}
|
||||
@@ -1868,10 +1887,12 @@ export class ReportsService {
|
||||
granularity,
|
||||
dateFrom: query.dateFrom,
|
||||
dateTo: query.dateTo,
|
||||
tripType: query.tripType ?? 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),
|
||||
rows,
|
||||
};
|
||||
}
|
||||
@@ -1880,10 +1901,11 @@ export class ReportsService {
|
||||
async exportFinanceSummaryCsv(query: FinanceSummaryQueryDto): Promise<string> {
|
||||
const report = await this.getFinanceSummary(query);
|
||||
|
||||
const headers = ["Period", "Origin → Destination", "Payment Method", "Currency", "Bookings", "Revenue"];
|
||||
const headers = ["Period", "Origin → Destination", "Trip Type", "Payment Method", "Currency", "Bookings", "Revenue"];
|
||||
const rows = report.rows.map((r) => [
|
||||
r.period,
|
||||
r.segmentLabel,
|
||||
r.tripType,
|
||||
r.method,
|
||||
r.currency,
|
||||
r.bookingCount,
|
||||
|
||||
Reference in New Issue
Block a user