Adding seat blocking revenue loss dashboard

This commit is contained in:
Mulu Mehari
2026-08-02 23:08:15 +03:00
parent ec4bf8a5ab
commit f0295f401a
32 changed files with 4008 additions and 59 deletions

View File

@@ -2,5 +2,9 @@ export * from "./common/index";
export * from "./freight/index";
export * as Freight from "./freight/index";
export * as Passenger from "./passenger/index";
// Flat re-export: the Blocked Seat Revenue Loss shapes are shared verbatim between the
// passenger API and the backoffice, and reading them through the `Passenger.` namespace
// on every line buys nothing.
export * from "./passenger/blocked-seat-revenue-loss";
export type { PaymentEvent, PaymentEventType, PaymentFailedEvent, PaymentSucceededEvent, PaymentIntentSnapshot, InitiatePaymentRequest } from "./common/payments";
export { PaymentReferenceType, PaymentService } from "./common/payments";

View File

@@ -0,0 +1,169 @@
/**
* Blocked Seat Revenue Loss report — shared shapes for
* `GET /reports/blocked-seats-revenue-loss`.
*
* Every monetary field is an **integer count of minor units** (ETB cents, DJF centimes …)
* and is always paired with its `currency`. Never sum across currencies.
*/
/**
* Why a seat was pulled out of sale. Mirrors the Prisma `SeatBlockReasonCategory` enum.
*
* Declared as a const object + union type rather than a TS `enum` so that Prisma's own
* generated string-literal union assigns to it directly, with no cast at the boundary.
*/
export const SeatBlockReasonCategory = {
Maintenance: "MAINTENANCE",
VipReserved: "VIP_RESERVED",
Safety: "SAFETY",
Operational: "OPERATIONAL",
Other: "OTHER",
} as const;
export type SeatBlockReasonCategory =
(typeof SeatBlockReasonCategory)[keyof typeof SeatBlockReasonCategory];
/** Every category value, in the order they should appear in a picker. */
export const SEAT_BLOCK_REASON_CATEGORIES: readonly SeatBlockReasonCategory[] =
Object.values(SeatBlockReasonCategory);
/** Human labels for {@link SeatBlockReasonCategory}, plus the legacy null bucket. */
export const SEAT_BLOCK_REASON_CATEGORY_LABELS: Record<string, string> = {
MAINTENANCE: "Maintenance",
VIP_RESERVED: "VIP Reserved",
SAFETY: "Safety",
OPERATIONAL: "Operational",
OTHER: "Other",
UNCATEGORIZED: "Uncategorized",
};
/** Bucket label used for blocks written before `reasonCategory` existed. */
export const UNCATEGORIZED_REASON_CATEGORY = "UNCATEGORIZED";
/**
* How the block reached this schedule.
* - `SCHEDULE` — a `SeatBlock` row naming this `scheduleId` directly.
* - `GLOBAL` — a `SeatBlock` row with no `scheduleId`, in effect at departure, whose
* seat's coach was assigned to this schedule.
*/
export type BlockedSeatBlockType = "SCHEDULE" | "GLOBAL";
/** One blocked seat on one schedule — the drill-down row. */
export interface BlockedSeatLossDetail {
blockId: string;
seatId: string;
coachNumber: string | null;
seatNumber: string | null;
seatClassName: string | null;
/** Operator's free-text detail, verbatim. */
reason: string;
/** `null` on rows written before the column existed — render as "Uncategorized". */
reasonCategory: SeatBlockReasonCategory | null;
blockType: BlockedSeatBlockType;
/** IAM user id, or `SYSTEM` for system-initiated blocks. */
blockedBy: string;
/** `null` on legacy rows — render as "Unknown". */
blockedByName: string | null;
approvedBy: string | null;
blockedAt: string;
unblockAt: string | null;
/** True when the block has no scheduled end. */
stillBlocked: boolean;
/** Whole days from `blockedAt` to `unblockAt`, or to now while still blocked. */
daysBlocked: number;
estimatedLossMinor: number;
currency: string;
}
/** One schedule with at least one blocked seat counted against it. */
export interface BlockedSeatLossSchedule {
scheduleId: string;
trainNumber: string;
routeName: string | null;
originStation: string;
destinationStation: string;
departureAt: string;
status: string;
/** Non-dining, non-placeholder seats on the coaches assigned to this schedule. */
sellableSeats: number;
/** Seats with a CONFIRMED/BOARDED booking on this schedule. */
soldSeats: number;
/** `soldSeats / sellableSeats`, as a percentage rounded to one decimal. */
loadFactorPercent: number;
blockedSeatCount: number;
/** Loss at full occupancy — the sum of the fares these seats would have sold for. */
estimatedLossMinor: number;
/** `estimatedLossMinor × loadFactor` — what the train's actual demand supports. */
adjustedLossMinor: number;
currency: string;
blocks: BlockedSeatLossDetail[];
}
/** Loss totals for one currency. */
export interface BlockedSeatLossByCurrency {
currency: string;
estimatedLossMinor: number;
adjustedLossMinor: number;
}
/** Loss grouped by reason category, per currency. */
export interface BlockedSeatLossByReasonCategory {
/** A {@link SeatBlockReasonCategory} value, or {@link UNCATEGORIZED_REASON_CATEGORY}. */
reasonCategory: string;
count: number;
estimatedLossMinor: number;
currency: string;
}
/** Loss grouped by the staff member who blocked the seat, per currency. */
export interface BlockedSeatLossByBlocker {
blockedBy: string;
blockedByName: string;
count: number;
estimatedLossMinor: number;
currency: string;
}
export interface BlockedSeatLossSummary {
schedulesAffected: number;
blockedSeatCount: number;
lossByCurrency: BlockedSeatLossByCurrency[];
topReasonCategories: BlockedSeatLossByReasonCategory[];
topBlockers: BlockedSeatLossByBlocker[];
}
/**
* Provenance for the numbers above. `methodology` and `exclusions` are meant to be
* rendered verbatim in the UI — this report is a counterfactual, and the assumptions
* behind it have to travel with it.
*/
export interface BlockedSeatLossMeta {
/** Total schedules matching the filters, before pagination. */
total: number;
page: number;
pageSize: number;
dateFrom: string;
dateTo: string;
/** Nationality the fares were priced at (drives tariff variant and currency). */
nationalityAssumption: string;
methodology: string;
exclusions: string[];
/** Schedules whose fare could not be computed — their seats count but carry no loss. */
schedulesWithoutFare: number;
}
export interface BlockedSeatRevenueLossReport {
summary: BlockedSeatLossSummary;
schedules: BlockedSeatLossSchedule[];
meta: BlockedSeatLossMeta;
}
/** Compact roll-up embedded in `GET /dashboard/backoffice-stats`. */
export interface BlockedSeatRevenueLossStat {
periodDays: number;
lossByCurrency: BlockedSeatLossByCurrency[];
schedulesAffected: number;
blockedSeatCount: number;
/** Largest category by estimated loss, or `null` when nothing is blocked. */
topReasonCategory: string | null;
}

View File

@@ -1,6 +1,7 @@
import type { BaseEntity } from "../common";
export * from "./support-chat";
export * from "./blocked-seat-revenue-loss";
export enum TicketStatus {
Reserved = "RESERVED",