Adding more detail messages for seat blocking

This commit is contained in:
Mulu Mehari
2026-08-03 11:37:16 +03:00
parent f0295f401a
commit 8594d75164
7 changed files with 74 additions and 68 deletions

View File

@@ -5,12 +5,9 @@ import { BlockedSeatRevenueLossStat } from '@edr/types';
import { PrismaService } from '../../common/prisma.service';
import { ReportsService } from '../reports/reports.service';
/** Window the dashboard's blocked-seat loss roll-up covers. Matches the report's default. */
const BLOCKED_SEAT_LOSS_PERIOD_DAYS = 30;
/** Shown when nothing is blocked, or when the loss roll-up could not be computed. */
const EMPTY_BLOCKED_SEAT_LOSS: BlockedSeatRevenueLossStat = {
periodDays: BLOCKED_SEAT_LOSS_PERIOD_DAYS,
periodDays: null,
lossByCurrency: [],
schedulesAffected: 0,
blockedSeatCount: 0,
@@ -85,7 +82,7 @@ export class DashboardService {
}
/**
* Compact roll-up of the Blocked Seat Revenue Loss report over the last 30 days.
* Compact roll-up of the Blocked Seat Revenue Loss report over its full history.
*
* Reuses the report service rather than re-deriving the rule — there is exactly one
* definition of what a blocked seat costs. A failure here degrades to zeroes instead of
@@ -98,7 +95,7 @@ export class DashboardService {
const { summary } = report;
return {
periodDays: BLOCKED_SEAT_LOSS_PERIOD_DAYS,
periodDays: null,
lossByCurrency: summary.lossByCurrency,
schedulesAffected: summary.schedulesAffected,
blockedSeatCount: summary.blockedSeatCount,

View File

@@ -346,6 +346,7 @@ export function assembleReport(
// so a plain sum here never crosses currencies.
const estimatedLossMinor = blocks.reduce((sum, b) => sum + b.estimatedLossMinor, 0);
const currency = blocks.find((b) => b.currency)?.currency ?? 'ETB';
const blockedByNames = [...new Set(blocks.map(blockerDisplayName))];
scheduleRows.push({
scheduleId: schedule.id,
@@ -359,6 +360,7 @@ export function assembleReport(
soldSeats,
loadFactorPercent: +(loadFactor * 100).toFixed(1),
blockedSeatCount: blocks.length,
blockedByNames,
estimatedLossMinor,
adjustedLossMinor: Math.round(estimatedLossMinor * loadFactor),
currency,
@@ -525,6 +527,11 @@ function groupByReasonCategory(
return [...groups.values()].sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor);
}
/** Legacy rows carry no name; 'SYSTEM' blocks are not a person. */
function blockerDisplayName(block: Pick<BlockedSeatLossDetail, 'blockedBy' | 'blockedByName'>): string {
return block.blockedByName ?? (block.blockedBy === 'SYSTEM' ? 'System' : 'Unknown');
}
function groupByBlocker(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByBlocker[] {
const groups = new Map<string, BlockedSeatLossByBlocker>();
for (const row of rows) {
@@ -532,9 +539,7 @@ function groupByBlocker(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByBlock
const key = `${block.blockedBy}|${block.currency}`;
const entry = groups.get(key) ?? {
blockedBy: block.blockedBy,
// Legacy rows carry no name; 'SYSTEM' blocks are not a person.
blockedByName:
block.blockedByName ?? (block.blockedBy === 'SYSTEM' ? 'System' : 'Unknown'),
blockedByName: blockerDisplayName(block),
count: 0,
estimatedLossMinor: 0,
currency: block.currency,

View File

@@ -46,13 +46,13 @@ export enum BlockedSeatsLossSortBy {
export class BlockedSeatsRevenueLossQueryDto {
@ApiPropertyOptional({
example: '2026-07-01',
description: 'Start of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to 30 days ago.',
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 today.',
description: 'End of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to the latest scheduled departure on record.',
})
@IsOptional() @IsDateString() dateTo?: string;

View File

@@ -25,8 +25,6 @@ import {
/** Fares are quoted at the local tariff unless the caller asks otherwise. */
const DEFAULT_LOSS_NATIONALITY = "Ethiopian";
/** Window used when the caller supplies neither `dateFrom` nor `dateTo`. */
const DEFAULT_LOSS_WINDOW_DAYS = 30;
const DEFAULT_LOSS_PAGE_SIZE = 25;
/** How many schedules are priced in parallel. Keeps the DB from being flooded. */
const FARE_QUOTE_CONCURRENCY = 4;
@@ -42,25 +40,6 @@ const EMPTY_LOSS_INPUT: LossCalculatorInput = {
blocks: [],
};
/**
* Resolves the reporting window. Both bounds are inclusive and snap to whole local days,
* matching `generateReport`. Defaults to the last 30 days of departures.
*/
function resolveWindow(
query: Pick<BlockedSeatsRevenueLossQueryDto, "dateFrom" | "dateTo">,
now: Date,
): { dateFrom: Date; dateTo: Date } {
const dateTo = query.dateTo ? new Date(query.dateTo) : new Date(now);
dateTo.setHours(23, 59, 59, 999);
const dateFrom = query.dateFrom
? new Date(query.dateFrom)
: new Date(dateTo.getTime() - DEFAULT_LOSS_WINDOW_DAYS * 24 * 60 * 60 * 1000);
dateFrom.setHours(0, 0, 0, 0);
return { dateFrom, dateTo };
}
/** Mirrors the fare engine's own LOCAL/INTERNATIONAL split. */
function resolveNationalityType(nationality: string): string {
const upper = nationality.toUpperCase();
@@ -1235,6 +1214,37 @@ export class ReportsService {
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
/**
* Resolves the reporting window. Both bounds are inclusive and snap to whole local days,
* matching `generateReport`. When the caller supplies neither bound, defaults to the full
* history of scheduled departures on record — the earliest `TrainSchedule.departureAt` to
* the latest — not a rolling window, so nothing ages out of the report on its own.
*/
private async resolveWindow(
query: Pick<BlockedSeatsRevenueLossQueryDto, "dateFrom" | "dateTo">,
now: Date,
): Promise<{ dateFrom: Date; dateTo: Date }> {
let dateFrom: Date;
let dateTo: Date;
if (query.dateFrom && query.dateTo) {
dateFrom = new Date(query.dateFrom);
dateTo = new Date(query.dateTo);
} else {
const bounds = await this.prisma.trainSchedule.aggregate({
_min: { departureAt: true },
_max: { departureAt: true },
});
dateFrom = query.dateFrom ? new Date(query.dateFrom) : (bounds._min.departureAt ?? new Date(now));
dateTo = query.dateTo ? new Date(query.dateTo) : (bounds._max.departureAt ?? new Date(now));
}
dateTo.setHours(23, 59, 59, 999);
dateFrom.setHours(0, 0, 0, 0);
return { dateFrom, dateTo };
}
/**
* Potential revenue lost to seats that were blocked and therefore never sellable.
*
@@ -1247,7 +1257,7 @@ export class ReportsService {
query: BlockedSeatsRevenueLossQueryDto,
): Promise<BlockedSeatRevenueLossReport> {
const now = new Date();
const { dateFrom, dateTo } = resolveWindow(query, now);
const { dateFrom, dateTo } = await this.resolveWindow(query, now);
const nationalityAssumption = query.nationality?.trim() || DEFAULT_LOSS_NATIONALITY;
const nationalityType = resolveNationalityType(nationalityAssumption);