mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
568 lines
21 KiB
TypeScript
568 lines
21 KiB
TypeScript
/**
|
||
* 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(' ');
|
||
}
|