Files
edr-platform/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.ts
2026-08-07 10:40:05 +03:00

568 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Blocked Seat Revenue Loss — the counting rule and the money.
*
* Deliberately free of Prisma and Nest: `ReportsService` does the fetching, this module
* decides which blocks count against which schedule and what each one cost. That split is
* what makes the rule testable — every exclusion below has a unit test in
* `blocked-seats-loss.calculator.spec.ts`.
*
* All amounts are integer minor units, always carried with their currency.
*/
import {
BlockedSeatBlockType,
BlockedSeatLossByBlocker,
BlockedSeatLossByCurrency,
BlockedSeatLossByReasonCategory,
BlockedSeatLossDetail,
BlockedSeatLossSchedule,
BlockedSeatRevenueLossReport,
SeatBlockReasonCategory,
UNCATEGORIZED_REASON_CATEGORY,
} from '@edr/types';
// ── Inputs ───────────────────────────────────────────────────────────────────
export interface LossSeatClass {
id: string;
name: string;
bedPosition: string | null;
nationalityType: string | null;
}
export interface LossCoach {
id: string;
number: string;
/** CoachType.type — 'passenger' | 'sleeper' | 'dining' | 'baggage'. */
coachTypeType: string;
coachTypeName: string;
seatClasses: LossSeatClass[];
}
export interface LossSeat {
id: string;
coachId: string;
seatNumber: string;
bedPosition: string | null;
premiumFeeMinor: number;
}
export interface LossSchedule {
id: string;
trainNumber: string;
routeName: string | null;
originStation: string;
destinationStation: string;
departureAt: Date;
status: string;
}
export interface LossBlock {
id: string;
seatId: string;
/** Null for a global block — one that applies wherever the seat's coach runs. */
scheduleId: string | null;
reason: string;
reasonCategory: SeatBlockReasonCategory | null;
blockedBy: string;
blockedByName: string | null;
approvedBy: string | null;
blockedAt: Date;
unblockAt: Date | null;
}
/** One fare quote from the fare engine, per seat class, per schedule. */
export interface LossFare {
seatClassId: string;
seatClassName: string;
/** Base + class premium + insurance, in ETB minor units. */
farePerPassengerMinor: number;
/** ETB → billing currency. 1 when billing in ETB. */
exchangeRate: number;
currency: string;
}
export interface LossCalculatorInput {
schedules: LossSchedule[];
/** Every seat on every coach involved, keyed by seat id. */
seatsById: Map<string, LossSeat>;
/** Every coach involved, keyed by coach id. */
coachesById: Map<string, LossCoach>;
/** Coach ids assigned to each schedule, keyed by schedule id. */
coachIdsBySchedule: Map<string, Set<string>>;
/** `${scheduleId}|${seatId}` for every seat with a CONFIRMED/BOARDED booking. */
soldSeatKeys: Set<string>;
/** Candidate blocks — schedule-scoped for these schedules, plus overlapping global ones. */
blocks: LossBlock[];
}
/** A block that survived every gate, bound to the schedule it cost revenue on. */
export interface CountedBlock {
block: LossBlock;
seat: LossSeat;
coach: LossCoach;
blockType: BlockedSeatBlockType;
}
// ── Exclusions, stated once so the API can echo them verbatim ────────────────
export const BLOCKED_SEAT_LOSS_EXCLUSIONS: readonly string[] = [
'Dining-coach seats — never sold as passenger seats, so blocking one costs no fare revenue.',
'Placeholder seats (seat number starting with "-") — layout spacers, not real seats.',
'CANCELLED schedules — the train did not run, so no fare was lost to the block.',
'Seats that were nonetheless sold on that schedule (a CONFIRMED or BOARDED booking exists) — blocked after sale, so no revenue was lost.',
'System blocks created by ticket issuance ("Booked in tickets …") — bookkeeping for seats that were sold, not withheld inventory.',
'A seat blocked more than once for the same schedule is counted once, at its most recent block.',
];
/** Prefix ticket issuance writes into `SeatBlock.reason` for already-sold seats. */
export const TICKETING_BLOCK_REASON_PREFIX = 'Booked in tickets';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
// ── Step 1: which blocks count against which schedule ────────────────────────
/**
* Applies the counting rule.
*
* A blocked seat counts against a schedule when either:
* - a `SeatBlock` row targets 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 actually assigned to that schedule.
*
* …minus every exclusion in {@link BLOCKED_SEAT_LOSS_EXCLUSIONS}.
*
* Returns counted blocks keyed by schedule id. Schedules with no counted block are absent.
*/
export function selectCountedBlocks(
input: LossCalculatorInput,
): Map<string, CountedBlock[]> {
const { schedules, seatsById, coachesById, coachIdsBySchedule, soldSeatKeys, blocks } = input;
// Per schedule, at most one counted block per seat. A schedule-scoped block beats a
// global one (it is the more specific statement); between two of the same kind, the
// most recently created wins.
const bySchedule = new Map<string, Map<string, CountedBlock>>();
for (const schedule of schedules) {
if (schedule.status === 'CANCELLED') continue;
const assignedCoachIds = coachIdsBySchedule.get(schedule.id) ?? new Set<string>();
for (const block of blocks) {
if (block.reason.startsWith(TICKETING_BLOCK_REASON_PREFIX)) continue;
const seat = seatsById.get(block.seatId);
if (!seat) continue;
if (isPlaceholderSeat(seat)) continue;
const coach = coachesById.get(seat.coachId);
if (!coach || isDiningCoach(coach)) continue;
let blockType: BlockedSeatBlockType;
if (block.scheduleId !== null) {
if (block.scheduleId !== schedule.id) continue;
blockType = 'SCHEDULE';
} else {
if (!assignedCoachIds.has(seat.coachId)) continue;
if (!isGlobalBlockInEffectAt(block, schedule.departureAt)) continue;
blockType = 'GLOBAL';
}
// Blocked but sold anyway ⇒ the fare was collected, nothing was lost.
if (soldSeatKeys.has(soldKey(schedule.id, seat.id))) continue;
const candidate: CountedBlock = { block, seat, coach, blockType };
const seatMap = bySchedule.get(schedule.id) ?? new Map<string, CountedBlock>();
const existing = seatMap.get(seat.id);
if (!existing || supersedes(candidate, existing)) seatMap.set(seat.id, candidate);
bySchedule.set(schedule.id, seatMap);
}
}
const result = new Map<string, CountedBlock[]>();
for (const [scheduleId, seatMap] of bySchedule) {
if (seatMap.size === 0) continue;
result.set(scheduleId, [...seatMap.values()]);
}
return result;
}
function supersedes(candidate: CountedBlock, existing: CountedBlock): boolean {
if (candidate.blockType !== existing.blockType) return candidate.blockType === 'SCHEDULE';
return candidate.block.blockedAt.getTime() > existing.block.blockedAt.getTime();
}
export function isGlobalBlockInEffectAt(
block: Pick<LossBlock, 'blockedAt' | 'unblockAt'>,
departureAt: Date,
): boolean {
if (block.blockedAt.getTime() > departureAt.getTime()) return false;
if (block.unblockAt === null) return true;
return block.unblockAt.getTime() >= departureAt.getTime();
}
export function isPlaceholderSeat(seat: Pick<LossSeat, 'seatNumber'>): boolean {
return !seat.seatNumber || seat.seatNumber.startsWith('-');
}
/**
* `CoachType.type` is documented as a slug ('passenger' | 'sleeper' | 'dining' | 'baggage'),
* but real EDR data stores display names there instead — e.g. `'Dining Coach '`, trailing
* space included. An exact `=== 'dining'` match therefore lets dining seats through and
* inflates the blocked-seat count. Match on a substring of type *or* name so both the
* documented convention and the data as it actually exists are covered.
*/
export function isDiningCoach(coach: {
coachTypeType?: string | null;
coachTypeName?: string | null;
}): boolean {
const haystack = `${coach.coachTypeType ?? ''} ${coach.coachTypeName ?? ''}`.toLowerCase();
return haystack.includes('dining');
}
export function soldKey(scheduleId: string, seatId: string): string {
return `${scheduleId}|${seatId}`;
}
// ── Step 2: seats that could have been sold ──────────────────────────────────
/**
* Sellable seats on a schedule: every seat on every assigned coach, minus dining coaches
* and placeholder rows. This is the denominator of the load factor, and it deliberately
* ignores the `coachId` filter so the percentage stays comparable across filtered views.
*/
export function countSellableSeats(
scheduleId: string,
input: Pick<LossCalculatorInput, 'coachIdsBySchedule' | 'coachesById' | 'seatsById'>,
): number {
const coachIds = input.coachIdsBySchedule.get(scheduleId);
if (!coachIds || coachIds.size === 0) return 0;
let total = 0;
for (const seat of input.seatsById.values()) {
if (!coachIds.has(seat.coachId)) continue;
if (isPlaceholderSeat(seat)) continue;
const coach = input.coachesById.get(seat.coachId);
if (!coach || isDiningCoach(coach)) continue;
total++;
}
return total;
}
// ── Step 3: the money ────────────────────────────────────────────────────────
/**
* Picks the seat class a seat is priced under.
*
* Bed position selects the tier in a sleeper coach; `nationalityType` then picks the
* LOCAL or INTERNATIONAL variant of that tier, matching how the fare engine resolves it.
*/
export function resolveSeatClass(
seat: LossSeat,
coach: LossCoach,
nationalityType: string,
): LossSeatClass | null {
const classes = coach.seatClasses;
if (classes.length === 0) return null;
const bed = seat.bedPosition?.toLowerCase();
const byBed = bed
? classes.filter((sc) => sc.bedPosition?.toLowerCase() === bed)
: classes.filter((sc) => !sc.bedPosition);
const pool = byBed.length > 0 ? byBed : classes;
return pool.find((sc) => sc.nationalityType === nationalityType) ?? pool[0] ?? null;
}
/**
* What one blocked seat would have sold for:
*
* base fare + class premium + insurance (the fare engine's per-passenger fare)
* + the seat's own premium (window/berth surcharge)
*
* converted into the billing currency implied by the nationality assumption.
*
* Returns `null` when no fare could be quoted for the seat's class — the seat still
* counts as blocked, it just carries no monetary claim.
*/
export function estimateSeatLoss(
seat: LossSeat,
fare: LossFare | null,
): { estimatedLossMinor: number; currency: string } | null {
if (!fare) return null;
const etbMinor = fare.farePerPassengerMinor + (seat.premiumFeeMinor ?? 0);
return {
estimatedLossMinor: Math.round(etbMinor * fare.exchangeRate),
currency: fare.currency,
};
}
// ── Step 4: assemble ─────────────────────────────────────────────────────────
export interface AssembleOptions {
/** Seat-class fares per schedule, keyed by schedule id then seat class id. */
faresBySchedule: Map<string, Map<string, LossFare>>;
/** Schedule ids whose fare calculation failed outright. */
schedulesWithoutFare: Set<string>;
/** 'LOCAL' or 'INTERNATIONAL' — how seat classes were resolved. */
nationalityType: string;
/** The nationality string the fares were priced at, for `meta`. */
nationalityAssumption: string;
/** Reference time for "days blocked" on still-blocked seats. Injected for determinism. */
now: Date;
dateFrom: Date;
dateTo: Date;
page: number;
pageSize: number;
sortBy: string;
}
/**
* Turns counted blocks + fares into the wire response.
*
* Schedules with no counted block are omitted: they carry no loss and no drill-down, and
* `meta.total` counts the schedules actually paginated so the two never disagree.
*/
export function assembleReport(
input: LossCalculatorInput,
countedBySchedule: Map<string, CountedBlock[]>,
options: AssembleOptions,
): BlockedSeatRevenueLossReport {
const soldCountBySchedule = countSoldSeatsPerSchedule(input.soldSeatKeys);
const scheduleRows: BlockedSeatLossSchedule[] = [];
for (const schedule of input.schedules) {
const counted = countedBySchedule.get(schedule.id);
if (!counted || counted.length === 0) continue;
const fares = options.faresBySchedule.get(schedule.id) ?? new Map<string, LossFare>();
const sellableSeats = countSellableSeats(schedule.id, input);
const soldSeats = soldCountBySchedule.get(schedule.id) ?? 0;
const loadFactor = sellableSeats > 0 ? Math.min(1, soldSeats / sellableSeats) : 0;
const blocks: BlockedSeatLossDetail[] = counted
.map((c) => toDetail(c, fares, options))
.sort(byCoachThenSeat);
// One schedule prices in exactly one currency (the nationality assumption fixes it),
// 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,
trainNumber: schedule.trainNumber,
routeName: schedule.routeName,
originStation: schedule.originStation,
destinationStation: schedule.destinationStation,
departureAt: schedule.departureAt.toISOString(),
status: schedule.status,
sellableSeats,
soldSeats,
loadFactorPercent: +(loadFactor * 100).toFixed(1),
blockedSeatCount: blocks.length,
blockedByNames,
estimatedLossMinor,
adjustedLossMinor: Math.round(estimatedLossMinor * loadFactor),
currency,
blocks,
});
}
sortSchedules(scheduleRows, options.sortBy);
const summary = {
schedulesAffected: scheduleRows.length,
blockedSeatCount: scheduleRows.reduce((sum, s) => sum + s.blockedSeatCount, 0),
lossByCurrency: groupLossByCurrency(scheduleRows),
topReasonCategories: groupByReasonCategory(scheduleRows),
topBlockers: groupByBlocker(scheduleRows),
};
const page = Math.max(1, options.page);
const pageSize = Math.max(1, options.pageSize);
const paged = scheduleRows.slice((page - 1) * pageSize, page * pageSize);
return {
summary,
schedules: paged,
meta: {
total: scheduleRows.length,
page,
pageSize,
dateFrom: options.dateFrom.toISOString(),
dateTo: options.dateTo.toISOString(),
nationalityAssumption: options.nationalityAssumption,
methodology: buildMethodology(options),
exclusions: [...BLOCKED_SEAT_LOSS_EXCLUSIONS],
schedulesWithoutFare: options.schedulesWithoutFare.size,
},
};
}
function toDetail(
counted: CountedBlock,
fares: Map<string, LossFare>,
options: AssembleOptions,
): BlockedSeatLossDetail {
const { block, seat, coach, blockType } = counted;
const seatClass = resolveSeatClass(seat, coach, options.nationalityType);
const fare = lookupFare(seatClass, coach, fares);
const loss = estimateSeatLoss(seat, fare);
const endedAt = block.unblockAt ?? options.now;
return {
blockId: block.id,
seatId: seat.id,
coachNumber: coach.number,
seatNumber: seat.seatNumber,
seatClassName: seatClass?.name ?? coach.coachTypeName ?? null,
reason: block.reason,
reasonCategory: block.reasonCategory,
blockType,
blockedBy: block.blockedBy,
blockedByName: block.blockedByName,
approvedBy: block.approvedBy,
blockedAt: block.blockedAt.toISOString(),
unblockAt: block.unblockAt ? block.unblockAt.toISOString() : null,
stillBlocked: block.unblockAt === null,
daysBlocked: Math.max(
0,
Math.floor((endedAt.getTime() - block.blockedAt.getTime()) / MS_PER_DAY),
),
estimatedLossMinor: loss?.estimatedLossMinor ?? 0,
currency: loss?.currency ?? 'ETB',
};
}
/**
* The fare engine keys its quotes by the *nationality-resolved* seat class, which may not
* be the class the seat nominally belongs to. Try the exact class, then any sibling class
* on the same coach type that was quoted.
*/
function lookupFare(
seatClass: LossSeatClass | null,
coach: LossCoach,
fares: Map<string, LossFare>,
): LossFare | null {
if (fares.size === 0) return null;
if (seatClass) {
const exact = fares.get(seatClass.id);
if (exact) return exact;
const sibling = coach.seatClasses.find(
(sc) => sc.bedPosition === seatClass.bedPosition && fares.has(sc.id),
);
if (sibling) return fares.get(sibling.id) ?? null;
}
const anyOnCoach = coach.seatClasses.find((sc) => fares.has(sc.id));
return anyOnCoach ? (fares.get(anyOnCoach.id) ?? null) : null;
}
function countSoldSeatsPerSchedule(soldSeatKeys: Set<string>): Map<string, number> {
const counts = new Map<string, number>();
for (const key of soldSeatKeys) {
const scheduleId = key.slice(0, key.indexOf('|'));
counts.set(scheduleId, (counts.get(scheduleId) ?? 0) + 1);
}
return counts;
}
function byCoachThenSeat(a: BlockedSeatLossDetail, b: BlockedSeatLossDetail): number {
const coach = (a.coachNumber ?? '').localeCompare(b.coachNumber ?? '', undefined, {
numeric: true,
});
if (coach !== 0) return coach;
return (a.seatNumber ?? '').localeCompare(b.seatNumber ?? '', undefined, { numeric: true });
}
function sortSchedules(rows: BlockedSeatLossSchedule[], sortBy: string): void {
switch (sortBy) {
case 'lossMinorAsc':
rows.sort((a, b) => a.estimatedLossMinor - b.estimatedLossMinor);
break;
case 'blockedSeatCount':
rows.sort((a, b) => b.blockedSeatCount - a.blockedSeatCount);
break;
case 'departureAt':
rows.sort((a, b) => a.departureAt.localeCompare(b.departureAt));
break;
default:
rows.sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor);
}
}
function groupLossByCurrency(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByCurrency[] {
const byCurrency = new Map<string, BlockedSeatLossByCurrency>();
for (const row of rows) {
const entry = byCurrency.get(row.currency) ?? {
currency: row.currency,
estimatedLossMinor: 0,
adjustedLossMinor: 0,
};
entry.estimatedLossMinor += row.estimatedLossMinor;
entry.adjustedLossMinor += row.adjustedLossMinor;
byCurrency.set(row.currency, entry);
}
return [...byCurrency.values()].sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor);
}
function groupByReasonCategory(
rows: BlockedSeatLossSchedule[],
): BlockedSeatLossByReasonCategory[] {
const groups = new Map<string, BlockedSeatLossByReasonCategory>();
for (const row of rows) {
for (const block of row.blocks) {
const category = block.reasonCategory ?? UNCATEGORIZED_REASON_CATEGORY;
const key = `${category}|${block.currency}`;
const entry = groups.get(key) ?? {
reasonCategory: category,
count: 0,
estimatedLossMinor: 0,
currency: block.currency,
};
entry.count++;
entry.estimatedLossMinor += block.estimatedLossMinor;
groups.set(key, entry);
}
}
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) {
for (const block of row.blocks) {
const key = `${block.blockedBy}|${block.currency}`;
const entry = groups.get(key) ?? {
blockedBy: block.blockedBy,
blockedByName: blockerDisplayName(block),
count: 0,
estimatedLossMinor: 0,
currency: block.currency,
};
entry.count++;
entry.estimatedLossMinor += block.estimatedLossMinor;
groups.set(key, entry);
}
}
return [...groups.values()].sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor);
}
function buildMethodology(options: AssembleOptions): string {
return [
'Estimated loss is a counterfactual: it is the fare each blocked seat would have sold for, not money that left the business.',
'Per blocked seat: estimatedLoss = base fare (distance × seat-class per-km tariff × insurance factor) + seat-class premium + insurance fee + the seat\'s own premium fee, priced for the schedule\'s full origin→destination journey.',
`Fares are priced at nationality "${options.nationalityAssumption}" (${options.nationalityType} tariff), which also fixes the billing currency. Totals are grouped per currency and never summed across them.`,
'estimatedLossAtFullOccupancy assumes every blocked seat would have sold. adjustedLoss = estimatedLoss × load factor (sold ÷ sellable seats on that schedule), because a blocked seat on a half-empty train did not really cost a full fare. The true figure sits between the two.',
'A blocked seat counts against a schedule when a SeatBlock names that schedule directly, or when a global block was in effect at departure and the seat\'s coach was assigned to that schedule.',
].join(' ');
}