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

@@ -0,0 +1,23 @@
-- Blocked Seat Revenue Loss report (GET /reports/blocked-seats-revenue-loss) needs to answer
-- "who blocked this seat and why". Purely additive: every column is nullable and every index is
-- new, so rows written before this migration keep working and simply report as
-- System / Unknown (blockedByName) and Uncategorized (reasonCategory).
-- CreateEnum
DO $$
BEGIN
CREATE TYPE "passenger"."SeatBlockReasonCategory" AS ENUM ('MAINTENANCE', 'VIP_RESERVED', 'SAFETY', 'OPERATIONAL', 'OTHER');
EXCEPTION
WHEN duplicate_object THEN NULL;
END
$$;
-- AlterTable
ALTER TABLE "passenger"."SeatBlock"
ADD COLUMN IF NOT EXISTS "reasonCategory" "passenger"."SeatBlockReasonCategory",
ADD COLUMN IF NOT EXISTS "blockedByName" TEXT;
-- CreateIndex: the report filters SeatBlock by blockedAt window, and joins
-- schedule-scoped blocks to a (schedule, seat) pair.
CREATE INDEX IF NOT EXISTS "SeatBlock_blockedAt_idx" ON "passenger"."SeatBlock"("blockedAt");
CREATE INDEX IF NOT EXISTS "SeatBlock_scheduleId_seatId_idx" ON "passenger"."SeatBlock"("scheduleId", "seatId");

View File

@@ -1349,19 +1349,38 @@ model NotificationTemplate {
@@schema("passenger") @@schema("passenger")
} }
/// Why a seat was pulled out of sale. Coarse bucket for reporting; the free-text
/// `reason` stays as the operator's detail. Nullable — rows written before this
/// column existed have no category and report as "Uncategorized".
enum SeatBlockReasonCategory {
MAINTENANCE
VIP_RESERVED
SAFETY
OPERATIONAL
OTHER
@@schema("passenger")
}
model SeatBlock { model SeatBlock {
id String @id @default(uuid()) id String @id @default(uuid())
seatId String seatId String
scheduleId String? scheduleId String?
reason String reason String
blockedBy String reasonCategory SeatBlockReasonCategory?
approvedBy String? blockedBy String
blockedAt DateTime @default(now()) /// Display name of the blocking staff member, denormalized at write time so the
unblockAt DateTime? /// revenue-loss report needs no cross-service IAM lookup. Null on legacy rows.
seat Seat @relation(fields: [seatId], references: [id]) blockedByName String?
approvedBy String?
blockedAt DateTime @default(now())
unblockAt DateTime?
seat Seat @relation(fields: [seatId], references: [id])
@@index([seatId]) @@index([seatId])
@@index([scheduleId]) @@index([scheduleId])
@@index([blockedAt])
@@index([scheduleId, seatId])
@@schema("passenger") @@schema("passenger")
} }

View File

@@ -0,0 +1,63 @@
/**
* Reading the authenticated staff member off the request.
*
* `JwtGuard` (from `@tria-plc/api-common`) puts the decoded IAM user on `request.user`.
* Sibling controllers reach for `req.user?.id ?? req.user?.sub`, because the shape differs
* slightly between token versions. This module centralises that so callers get a small,
* typed value object instead of an `any` bag.
*/
/** Bilingual name as IAM stores it. */
interface ActingUserName {
en?: string;
am?: string;
}
/** The slice of `request.user` this app actually reads. */
export interface ActingUserClaims {
id?: string;
/** Older tokens carry the subject as `sub` rather than `id`. */
sub?: string;
name?: ActingUserName | string | null;
username?: string;
email?: string;
}
/** The minimal Express request shape needed to reach the authenticated user. */
export interface RequestWithActingUser {
user?: ActingUserClaims;
}
/** Who performed an action, resolved once at write time so readers need no IAM lookup. */
export interface ActingUser {
/** IAM user id. */
id: string;
/** Human-readable name, denormalized alongside the id. */
name: string;
}
/**
* Resolves the acting staff member from a guarded request.
*
* Returns `null` when no user is attached — callers decide what that means. Endpoints behind
* `@PassengerStaff(...)` always have one, since the guard rejects anonymous requests; system
* paths (ticketing, cleanup jobs) legitimately have none and record themselves explicitly.
*/
export function resolveActingUser(req: RequestWithActingUser): ActingUser | null {
const claims = req.user;
const id = claims?.id ?? claims?.sub;
if (!id) return null;
return { id, name: resolveActingUserName(claims) };
}
function resolveActingUserName(claims: ActingUserClaims | undefined): string {
if (!claims) return 'Unknown';
const { name } = claims;
if (typeof name === 'string' && name.trim()) return name.trim();
if (name && typeof name === 'object') {
const localized = name.en?.trim() || name.am?.trim();
if (localized) return localized;
}
return claims.username?.trim() || claims.email?.trim() || 'Unknown';
}

View File

@@ -1,6 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller'; import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
import { ReportsModule } from '../reports/reports.module';
@Module({ controllers: [DashboardController], providers: [DashboardService] }) @Module({
// ReportsModule owns the blocked-seat revenue loss rule; the dashboard's roll-up
// reads it from there instead of keeping a second copy of the definition.
imports: [ReportsModule],
controllers: [DashboardController],
providers: [DashboardService],
})
export class DashboardModule {} export class DashboardModule {}

View File

@@ -1,17 +1,34 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { BlockedSeatRevenueLossStat } from '@edr/types';
import { PrismaService } from '../../common/prisma.service'; 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,
lossByCurrency: [],
schedulesAffected: 0,
blockedSeatCount: 0,
topReasonCategory: null,
};
@Injectable() @Injectable()
export class DashboardService { export class DashboardService {
private readonly logger = new Logger(DashboardService.name);
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
@InjectDataSource() private dataSource: DataSource, @InjectDataSource() private dataSource: DataSource,
private reports: ReportsService,
) {} ) {}
async getBackofficeStats() { async getBackofficeStats() {
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] = const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows, blockedSeatRevenueLoss] =
await Promise.all([ await Promise.all([
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }), this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }),
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }), this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }),
@@ -38,6 +55,9 @@ export class DashboardService {
AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED') AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED')
GROUP BY COALESCE("displayCurrency"::text, "currency"::text) GROUP BY COALESCE("displayCurrency"::text, "currency"::text)
`, `,
// Joined into this same call on purpose: the dashboard's request count stays
// exactly where it was, and the card renders from the payload it already fetches.
this.getBlockedSeatRevenueLossStat(),
]); ]);
const totalPackageTickets = await this.prisma.ticket.count({ const totalPackageTickets = await this.prisma.ticket.count({
@@ -58,11 +78,43 @@ export class DashboardService {
totalNormalTickets: totalTickets - totalPackageTickets, totalNormalTickets: totalTickets - totalPackageTickets,
totalPassengers, totalPassengers,
blockedSeatsCount, blockedSeatsCount,
blockedSeatRevenueLoss,
revenueByCurrency: toMap(revenueRows), revenueByCurrency: toMap(revenueRows),
packageRevenueByCurrency: toMap(packageRevenueRows), packageRevenueByCurrency: toMap(packageRevenueRows),
}; };
} }
/**
* Compact roll-up of the Blocked Seat Revenue Loss report over the last 30 days.
*
* 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
* taking the whole dashboard down with it.
*/
private async getBlockedSeatRevenueLossStat(): Promise<BlockedSeatRevenueLossStat> {
try {
// pageSize 1: only the summary is read, and paging does not change what it covers.
const report = await this.reports.getBlockedSeatsRevenueLoss({ page: 1, pageSize: 1 });
const { summary } = report;
return {
periodDays: BLOCKED_SEAT_LOSS_PERIOD_DAYS,
lossByCurrency: summary.lossByCurrency,
schedulesAffected: summary.schedulesAffected,
blockedSeatCount: summary.blockedSeatCount,
// topReasonCategories is already sorted by estimated loss, descending.
topReasonCategory: summary.topReasonCategories[0]?.reasonCategory ?? null,
};
} catch (err) {
this.logger.warn(
`Blocked-seat revenue loss roll-up unavailable — ${
err instanceof Error ? err.message : String(err)
}`,
);
return EMPTY_BLOCKED_SEAT_LOSS;
}
}
async getHomeDashboard(passengerId: string) { async getHomeDashboard(passengerId: string) {
const now = new Date(); const now = new Date();
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([ const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([

View File

@@ -0,0 +1,641 @@
import {
assembleReport,
AssembleOptions,
countSellableSeats,
LossBlock,
LossCalculatorInput,
LossCoach,
LossFare,
LossSchedule,
LossSeat,
resolveSeatClass,
selectCountedBlocks,
soldKey,
} from './blocked-seats-loss.calculator';
// ── Fixtures ─────────────────────────────────────────────────────────────────
const DEPARTURE = new Date('2026-07-15T08:00:00.000Z');
const NOW = new Date('2026-07-20T00:00:00.000Z');
const ECONOMY_LOCAL = {
id: 'sc-econ-local',
name: 'Economy',
bedPosition: null,
nationalityType: 'LOCAL',
};
const ECONOMY_INTL = {
id: 'sc-econ-intl',
name: 'Economy (International)',
bedPosition: null,
nationalityType: 'INTERNATIONAL',
};
function coach(overrides: Partial<LossCoach> = {}): LossCoach {
return {
id: 'coach-1',
number: 'C1',
coachTypeType: 'passenger',
coachTypeName: 'Economy Coach',
seatClasses: [ECONOMY_LOCAL, ECONOMY_INTL],
...overrides,
};
}
function seat(overrides: Partial<LossSeat> = {}): LossSeat {
return {
id: 'seat-1',
coachId: 'coach-1',
seatNumber: '1',
bedPosition: null,
premiumFeeMinor: 0,
...overrides,
};
}
function schedule(overrides: Partial<LossSchedule> = {}): LossSchedule {
return {
id: 'sched-1',
trainNumber: 'ET-101',
routeName: 'Addis Ababa — Dire Dawa',
originStation: 'Addis Ababa',
destinationStation: 'Dire Dawa',
departureAt: DEPARTURE,
status: 'SCHEDULED',
...overrides,
};
}
function block(overrides: Partial<LossBlock> = {}): LossBlock {
return {
id: 'block-1',
seatId: 'seat-1',
scheduleId: null,
reason: 'Torn upholstery',
reasonCategory: 'MAINTENANCE',
blockedBy: 'user-1',
blockedByName: 'Abebe Bekele',
approvedBy: null,
blockedAt: new Date('2026-07-10T00:00:00.000Z'),
unblockAt: null,
...overrides,
};
}
function fare(overrides: Partial<LossFare> = {}): LossFare {
return {
seatClassId: ECONOMY_LOCAL.id,
seatClassName: 'Economy',
farePerPassengerMinor: 50_000, // ETB 500.00
exchangeRate: 1,
currency: 'ETB',
...overrides,
};
}
/** Builds a calculator input from loose parts, wiring up the id→entity maps. */
function makeInput(parts: {
schedules?: LossSchedule[];
seats?: LossSeat[];
coaches?: LossCoach[];
/** scheduleId → coachIds assigned to it. */
assignments?: Record<string, string[]>;
sold?: [string, string][];
blocks?: LossBlock[];
}): LossCalculatorInput {
const seats = parts.seats ?? [seat()];
const coaches = parts.coaches ?? [coach()];
const schedules = parts.schedules ?? [schedule()];
const assignments = parts.assignments ?? { 'sched-1': ['coach-1'] };
return {
schedules,
seatsById: new Map(seats.map((s) => [s.id, s])),
coachesById: new Map(coaches.map((c) => [c.id, c])),
coachIdsBySchedule: new Map(
Object.entries(assignments).map(([sid, cids]) => [sid, new Set(cids)]),
),
soldSeatKeys: new Set((parts.sold ?? []).map(([sid, seatId]) => soldKey(sid, seatId))),
blocks: parts.blocks ?? [block()],
};
}
function makeOptions(overrides: Partial<AssembleOptions> = {}): AssembleOptions {
return {
faresBySchedule: new Map([['sched-1', new Map([[ECONOMY_LOCAL.id, fare()]])]]),
schedulesWithoutFare: new Set<string>(),
nationalityType: 'LOCAL',
nationalityAssumption: 'Ethiopian',
now: NOW,
dateFrom: new Date('2026-07-01T00:00:00.000Z'),
dateTo: new Date('2026-07-31T23:59:59.999Z'),
page: 1,
pageSize: 25,
sortBy: 'lossMinor',
...overrides,
};
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('blocked-seats-loss calculator', () => {
describe('schedule attribution', () => {
it('counts a schedule-scoped block against exactly that schedule', () => {
const other = schedule({ id: 'sched-2' });
const counted = selectCountedBlocks(
makeInput({
schedules: [schedule(), other],
assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-1'] },
blocks: [block({ scheduleId: 'sched-1' })],
}),
);
expect(counted.get('sched-1')).toHaveLength(1);
expect(counted.get('sched-1')?.[0].blockType).toBe('SCHEDULE');
// Same coach runs on sched-2, but the block named sched-1 only.
expect(counted.has('sched-2')).toBe(false);
});
it('counts a global block against every schedule its window covers', () => {
const counted = selectCountedBlocks(
makeInput({
schedules: [schedule(), schedule({ id: 'sched-2' })],
assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-1'] },
blocks: [block({ scheduleId: null })],
}),
);
expect(counted.get('sched-1')?.[0].blockType).toBe('GLOBAL');
expect(counted.get('sched-2')?.[0].blockType).toBe('GLOBAL');
});
it('ignores a global block that started after departure', () => {
const counted = selectCountedBlocks(
makeInput({
blocks: [block({ blockedAt: new Date('2026-07-16T00:00:00.000Z') })],
}),
);
expect(counted.size).toBe(0);
});
it('ignores a global block that was lifted before departure', () => {
const counted = selectCountedBlocks(
makeInput({
blocks: [
block({
blockedAt: new Date('2026-07-01T00:00:00.000Z'),
unblockAt: new Date('2026-07-10T00:00:00.000Z'),
}),
],
}),
);
expect(counted.size).toBe(0);
});
it('counts a global block still open at departure', () => {
const counted = selectCountedBlocks(
makeInput({
blocks: [
block({
blockedAt: new Date('2026-07-01T00:00:00.000Z'),
unblockAt: new Date('2026-07-20T00:00:00.000Z'),
}),
],
}),
);
expect(counted.get('sched-1')).toHaveLength(1);
});
it('counts a seat blocked twice for one schedule only once, at the newer block', () => {
const counted = selectCountedBlocks(
makeInput({
blocks: [
block({ id: 'old', blockedAt: new Date('2026-07-01T00:00:00.000Z') }),
block({ id: 'new', blockedAt: new Date('2026-07-09T00:00:00.000Z') }),
],
}),
);
expect(counted.get('sched-1')).toHaveLength(1);
expect(counted.get('sched-1')?.[0].block.id).toBe('new');
});
it('prefers a schedule-scoped block over a global one for the same seat', () => {
const counted = selectCountedBlocks(
makeInput({
blocks: [
block({ id: 'global', scheduleId: null }),
block({ id: 'scoped', scheduleId: 'sched-1' }),
],
}),
);
expect(counted.get('sched-1')).toHaveLength(1);
expect(counted.get('sched-1')?.[0].block.id).toBe('scoped');
});
it('skips CANCELLED schedules entirely', () => {
const counted = selectCountedBlocks(
makeInput({ schedules: [schedule({ status: 'CANCELLED' })] }),
);
expect(counted.size).toBe(0);
});
});
describe('coach-assignment gating', () => {
it('ignores a global block when the seat\'s coach was not on that train', () => {
const counted = selectCountedBlocks(
makeInput({ assignments: { 'sched-1': ['coach-other'] } }),
);
expect(counted.size).toBe(0);
});
it('counts a global block when the coach was assigned', () => {
const counted = selectCountedBlocks(
makeInput({ assignments: { 'sched-1': ['coach-1'] } }),
);
expect(counted.get('sched-1')).toHaveLength(1);
});
it('gates each schedule independently on its own assignments', () => {
const counted = selectCountedBlocks(
makeInput({
schedules: [schedule(), schedule({ id: 'sched-2' })],
assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-other'] },
}),
);
expect(counted.has('sched-1')).toBe(true);
expect(counted.has('sched-2')).toBe(false);
});
});
describe('dining and placeholder exclusion', () => {
it('excludes seats in a dining coach', () => {
const counted = selectCountedBlocks(
makeInput({ coaches: [coach({ coachTypeType: 'dining' })] }),
);
expect(counted.size).toBe(0);
});
it('excludes placeholder seats whose number starts with "-"', () => {
const counted = selectCountedBlocks(
makeInput({ seats: [seat({ seatNumber: '-1' })] }),
);
expect(counted.size).toBe(0);
});
// Regression: real EDR data puts a display name in CoachType.type — 'Dining Coach '
// with a trailing space — rather than the documented 'dining' slug. An exact match
// let dining seats into the report and inflated the blocked-seat count.
it.each([
['dining'],
['Dining Coach '],
['DINING'],
[' dining '],
])('excludes a dining coach whose type is %p', (coachTypeType) => {
const counted = selectCountedBlocks(
makeInput({ coaches: [coach({ coachTypeType, coachTypeName: 'Dining Coach ' })] }),
);
expect(counted.size).toBe(0);
});
it('excludes a dining coach identified only by its coachType name', () => {
const counted = selectCountedBlocks(
makeInput({
coaches: [coach({ coachTypeType: 'Regular Seat', coachTypeName: 'Dining Coach ' })],
}),
);
expect(counted.size).toBe(0);
});
it('does not mistake a normal coach for a dining one', () => {
const counted = selectCountedBlocks(
makeInput({
coaches: [coach({ coachTypeType: 'Regular Seat', coachTypeName: 'Hard Seat Coach' })],
}),
);
expect(counted.get('sched-1')).toHaveLength(1);
});
it('keeps a real seat in a sleeper coach', () => {
const counted = selectCountedBlocks(
makeInput({ coaches: [coach({ coachTypeType: 'sleeper' })] }),
);
expect(counted.get('sched-1')).toHaveLength(1);
});
it('leaves dining and placeholder seats out of the sellable-seat denominator', () => {
const input = makeInput({
seats: [
seat({ id: 'real-1', coachId: 'coach-1', seatNumber: '1' }),
seat({ id: 'real-2', coachId: 'coach-1', seatNumber: '2' }),
seat({ id: 'spacer', coachId: 'coach-1', seatNumber: '-1' }),
seat({ id: 'diner', coachId: 'coach-dining', seatNumber: '1' }),
],
coaches: [coach(), coach({ id: 'coach-dining', coachTypeType: 'dining' })],
assignments: { 'sched-1': ['coach-1', 'coach-dining'] },
});
expect(countSellableSeats('sched-1', input)).toBe(2);
});
});
describe('blocked-after-sale exclusion', () => {
it('excludes a blocked seat that was nonetheless sold on that schedule', () => {
const counted = selectCountedBlocks(
makeInput({ sold: [['sched-1', 'seat-1']] }),
);
expect(counted.size).toBe(0);
});
it('still counts the block on a schedule where the seat was not sold', () => {
const counted = selectCountedBlocks(
makeInput({
schedules: [schedule(), schedule({ id: 'sched-2' })],
assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-1'] },
sold: [['sched-1', 'seat-1']],
}),
);
expect(counted.has('sched-1')).toBe(false);
expect(counted.has('sched-2')).toBe(true);
});
it("excludes ticketing's own bookkeeping blocks", () => {
const counted = selectCountedBlocks(
makeInput({ blocks: [block({ reason: 'Booked in tickets t-1, t-2' })] }),
);
expect(counted.size).toBe(0);
});
});
describe('seat-class resolution and per-seat loss', () => {
it('picks the seat class variant matching the nationality assumption', () => {
expect(resolveSeatClass(seat(), coach(), 'LOCAL')?.id).toBe(ECONOMY_LOCAL.id);
expect(resolveSeatClass(seat(), coach(), 'INTERNATIONAL')?.id).toBe(ECONOMY_INTL.id);
});
it('narrows by bed position before nationality in a sleeper coach', () => {
const upper = { id: 'sc-upper', name: 'Upper Berth', bedPosition: 'upper', nationalityType: 'LOCAL' };
const lower = { id: 'sc-lower', name: 'Lower Berth', bedPosition: 'lower', nationalityType: 'LOCAL' };
const sleeper = coach({ seatClasses: [upper, lower] });
expect(resolveSeatClass(seat({ bedPosition: 'LOWER' }), sleeper, 'LOCAL')?.id).toBe('sc-lower');
});
it("adds the seat's own premium fee to the class fare", () => {
const report = assembleReport(
makeInput({ seats: [seat({ premiumFeeMinor: 2_500 })] }),
selectCountedBlocks(makeInput({ seats: [seat({ premiumFeeMinor: 2_500 })] })),
makeOptions(),
);
// 50_000 class fare + 2_500 seat premium
expect(report.schedules[0].blocks[0].estimatedLossMinor).toBe(52_500);
});
it('counts the seat but claims no money when no fare could be quoted', () => {
const input = makeInput({});
const report = assembleReport(input, selectCountedBlocks(input), makeOptions({
faresBySchedule: new Map(),
schedulesWithoutFare: new Set(['sched-1']),
}));
expect(report.summary.blockedSeatCount).toBe(1);
expect(report.schedules[0].estimatedLossMinor).toBe(0);
expect(report.meta.schedulesWithoutFare).toBe(1);
});
});
describe('load-factor adjustment', () => {
it('scales estimated loss by sold ÷ sellable', () => {
const seats = [
seat({ id: 'seat-1', seatNumber: '1' }),
seat({ id: 'seat-2', seatNumber: '2' }),
seat({ id: 'seat-3', seatNumber: '3' }),
seat({ id: 'seat-4', seatNumber: '4' }),
];
// 4 sellable seats, 2 sold ⇒ load factor 0.5.
const parts = { seats, sold: [['sched-1', 'seat-2'], ['sched-1', 'seat-3']] as [string, string][] };
const input = makeInput(parts);
const report = assembleReport(input, selectCountedBlocks(input), makeOptions());
const row = report.schedules[0];
expect(row.sellableSeats).toBe(4);
expect(row.soldSeats).toBe(2);
expect(row.loadFactorPercent).toBe(50);
expect(row.estimatedLossMinor).toBe(50_000);
expect(row.adjustedLossMinor).toBe(25_000);
});
it('adjusts to zero on a train that sold nothing', () => {
const input = makeInput({});
const report = assembleReport(input, selectCountedBlocks(input), makeOptions());
expect(report.schedules[0].loadFactorPercent).toBe(0);
expect(report.schedules[0].estimatedLossMinor).toBe(50_000);
expect(report.schedules[0].adjustedLossMinor).toBe(0);
});
});
describe('multi-currency grouping', () => {
it('groups totals per currency and never sums across them', () => {
const schedules = [schedule(), schedule({ id: 'sched-2' })];
const seats = [
seat({ id: 'seat-1', coachId: 'coach-1', seatNumber: '1' }),
seat({ id: 'seat-2', coachId: 'coach-2', seatNumber: '1' }),
];
const coaches = [coach(), coach({ id: 'coach-2', number: 'C2' })];
const blocks = [
block({ id: 'b1', seatId: 'seat-1', scheduleId: 'sched-1' }),
block({ id: 'b2', seatId: 'seat-2', scheduleId: 'sched-2' }),
];
const input = makeInput({
schedules,
seats,
coaches,
blocks,
assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-2'] },
});
const report = assembleReport(
input,
selectCountedBlocks(input),
makeOptions({
faresBySchedule: new Map([
['sched-1', new Map([[ECONOMY_LOCAL.id, fare({ currency: 'ETB' })]])],
[
'sched-2',
new Map([
[ECONOMY_LOCAL.id, fare({ currency: 'DJF', exchangeRate: 2, farePerPassengerMinor: 50_000 })],
]),
],
]),
}),
);
expect(report.summary.lossByCurrency).toEqual(
expect.arrayContaining([
{ currency: 'ETB', estimatedLossMinor: 50_000, adjustedLossMinor: 0 },
{ currency: 'DJF', estimatedLossMinor: 100_000, adjustedLossMinor: 0 },
]),
);
expect(report.summary.lossByCurrency).toHaveLength(2);
});
it('keeps reason-category and blocker breakdowns split by currency', () => {
const input = makeInput({
schedules: [schedule(), schedule({ id: 'sched-2' })],
seats: [
seat({ id: 'seat-1', coachId: 'coach-1', seatNumber: '1' }),
seat({ id: 'seat-2', coachId: 'coach-2', seatNumber: '1' }),
],
coaches: [coach(), coach({ id: 'coach-2', number: 'C2' })],
blocks: [
block({ id: 'b1', seatId: 'seat-1', scheduleId: 'sched-1' }),
block({ id: 'b2', seatId: 'seat-2', scheduleId: 'sched-2' }),
],
assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-2'] },
});
const report = assembleReport(
input,
selectCountedBlocks(input),
makeOptions({
faresBySchedule: new Map([
['sched-1', new Map([[ECONOMY_LOCAL.id, fare({ currency: 'ETB' })]])],
['sched-2', new Map([[ECONOMY_LOCAL.id, fare({ currency: 'USD' })]])],
]),
}),
);
// Same category, same blocker — but two currencies, so two rows each.
expect(report.summary.topReasonCategories).toHaveLength(2);
expect(report.summary.topReasonCategories.map((r) => r.currency).sort()).toEqual(['ETB', 'USD']);
expect(report.summary.topBlockers).toHaveLength(2);
});
});
describe('legacy rows', () => {
it('reports an uncategorized legacy block under UNCATEGORIZED with an Unknown blocker', () => {
const input = makeInput({
blocks: [block({ reasonCategory: null, blockedByName: null, blockedBy: 'legacy-id' })],
});
const report = assembleReport(input, selectCountedBlocks(input), makeOptions());
expect(report.schedules[0].blocks[0].reasonCategory).toBeNull();
expect(report.summary.topReasonCategories[0].reasonCategory).toBe('UNCATEGORIZED');
expect(report.summary.topBlockers[0].blockedByName).toBe('Unknown');
});
it('names a SYSTEM blocker "System"', () => {
const input = makeInput({
blocks: [block({ blockedBy: 'SYSTEM', blockedByName: null })],
});
const report = assembleReport(input, selectCountedBlocks(input), makeOptions());
expect(report.summary.topBlockers[0].blockedByName).toBe('System');
});
});
describe('zero-blocks schedule', () => {
it('returns an empty report when nothing is blocked', () => {
const input = makeInput({ blocks: [] });
const report = assembleReport(input, selectCountedBlocks(input), makeOptions());
expect(report.schedules).toHaveLength(0);
expect(report.summary.schedulesAffected).toBe(0);
expect(report.summary.blockedSeatCount).toBe(0);
expect(report.summary.lossByCurrency).toEqual([]);
expect(report.meta.total).toBe(0);
// The methodology and exclusions still travel with the (empty) answer.
expect(report.meta.exclusions.length).toBeGreaterThan(0);
expect(report.meta.methodology).toContain('counterfactual');
});
it('omits unaffected schedules from a report that has other affected ones', () => {
const input = makeInput({
schedules: [schedule(), schedule({ id: 'sched-empty' })],
assignments: { 'sched-1': ['coach-1'], 'sched-empty': ['coach-1'] },
blocks: [block({ scheduleId: 'sched-1' })],
});
const report = assembleReport(input, selectCountedBlocks(input), makeOptions());
expect(report.schedules.map((s) => s.scheduleId)).toEqual(['sched-1']);
expect(report.meta.total).toBe(1);
});
});
describe('meta and drill-down detail', () => {
it('states the nationality assumption it priced at', () => {
const input = makeInput({});
const report = assembleReport(
input,
selectCountedBlocks(input),
makeOptions({ nationalityAssumption: 'German', nationalityType: 'INTERNATIONAL' }),
);
expect(report.meta.nationalityAssumption).toBe('German');
expect(report.meta.methodology).toContain('German');
expect(report.meta.methodology).toContain('INTERNATIONAL');
});
it('reports days blocked against now while a block is still open', () => {
const input = makeInput({
blocks: [block({ blockedAt: new Date('2026-07-10T00:00:00.000Z'), unblockAt: null })],
});
const report = assembleReport(input, selectCountedBlocks(input), makeOptions());
const detail = report.schedules[0].blocks[0];
expect(detail.stillBlocked).toBe(true);
expect(detail.daysBlocked).toBe(10); // 10 Jul → 20 Jul (NOW)
});
it('reports days blocked against unblockAt once a block has ended', () => {
const input = makeInput({
blocks: [
block({
blockedAt: new Date('2026-07-10T00:00:00.000Z'),
unblockAt: new Date('2026-07-16T00:00:00.000Z'),
}),
],
});
const report = assembleReport(input, selectCountedBlocks(input), makeOptions());
const detail = report.schedules[0].blocks[0];
expect(detail.stillBlocked).toBe(false);
expect(detail.daysBlocked).toBe(6);
});
it('paginates schedules and reports the unpaginated total', () => {
const schedules = [1, 2, 3].map((n) => schedule({ id: `sched-${n}` }));
const seats = [1, 2, 3].map((n) => seat({ id: `seat-${n}`, seatNumber: String(n) }));
const blocks = [1, 2, 3].map((n) =>
block({ id: `b-${n}`, seatId: `seat-${n}`, scheduleId: `sched-${n}` }),
);
const input = makeInput({
schedules,
seats,
blocks,
assignments: { 'sched-1': ['coach-1'], 'sched-2': ['coach-1'], 'sched-3': ['coach-1'] },
});
const report = assembleReport(
input,
selectCountedBlocks(input),
makeOptions({
page: 2,
pageSize: 2,
faresBySchedule: new Map(
schedules.map((s) => [s.id, new Map([[ECONOMY_LOCAL.id, fare()]])]),
),
}),
);
expect(report.meta.total).toBe(3);
expect(report.schedules).toHaveLength(1);
expect(report.summary.blockedSeatCount).toBe(3); // summary covers all, not the page
});
});
});

View File

@@ -0,0 +1,558 @@
/**
* 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();
}
function isGlobalBlockInEffectAt(block: LossBlock, 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: Pick<LossCoach, 'coachTypeType' | 'coachTypeName'>,
): 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';
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,
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);
}
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,
// Legacy rows carry no name; 'SYSTEM' blocks are not a person.
blockedByName:
block.blockedByName ?? (block.blockedBy === 'SYSTEM' ? 'System' : 'Unknown'),
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(' ');
}

View File

@@ -1,7 +1,14 @@
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common"; import { Body, Controller, Get, Param, Post, Query, Res } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger"; import type { Response } from "express";
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiOkResponse,
ApiProduces,
} from "@nestjs/swagger";
import { ReportsService } from "./reports.service"; import { ReportsService } from "./reports.service";
import { GenerateReportDto } from "./reports.dto"; import { BlockedSeatsRevenueLossQueryDto, GenerateReportDto } from "./reports.dto";
import { PassengerStaff } from "../../common/passenger-guards"; import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@@ -76,6 +83,54 @@ export class ReportsController {
return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort }); return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort });
} }
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
@Get("blocked-seats-revenue-loss")
@ApiOperation({
summary: "Potential revenue lost to blocked seats, per schedule",
description:
"For every train schedule in the window, the fare revenue that could never be earned because seats were " +
"blocked out of sale — with per-seat drill-down showing who blocked each seat and why.\n\n" +
"**A blocked seat counts against a schedule when** a `SeatBlock` row names 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 assigned to that schedule " +
"via `CoachAssignment`.\n\n" +
"**Excluded** (echoed in `meta.exclusions`): dining-coach seats, placeholder seats, CANCELLED schedules, " +
"seats that were sold anyway, and ticketing's own bookkeeping blocks.\n\n" +
"**This is a counterfactual.** `estimatedLossMinor` assumes every blocked seat would have sold; " +
"`adjustedLossMinor` scales it by the schedule's load factor. The real figure sits between the two — " +
"`meta.methodology` states the formula and the nationality assumption in full.\n\n" +
"All amounts are integer minor units, grouped per currency and never summed across currencies.",
})
@ApiOkResponse({ description: "Blocked-seat revenue loss report" })
getBlockedSeatsRevenueLoss(@Query() query: BlockedSeatsRevenueLossQueryDto) {
return this.service.getBlockedSeatsRevenueLoss(query);
}
@Get("blocked-seats-revenue-loss/export")
@ApiOperation({
summary: "Blocked-seat revenue loss as CSV",
description:
"Same filters as the JSON report, flattened to one row per blocked seat. Not paginated — the whole " +
"filtered result is returned.",
})
@ApiProduces("text/csv")
@ApiOkResponse({ description: "CSV export", schema: { type: "string" } })
// `@Res()` without passthrough so the global ResponseTransformInterceptor does not wrap
// the CSV in a `{ success, data }` envelope — same approach as the attachment stream.
async exportBlockedSeatsRevenueLoss(
@Query() query: BlockedSeatsRevenueLossQueryDto,
@Res() res: Response,
): Promise<void> {
const csv = await this.service.exportBlockedSeatsRevenueLossCsv(query);
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader(
"Content-Disposition",
`attachment; filename="blocked-seats-revenue-loss-${new Date().toISOString().split("T")[0]}.csv"`,
);
res.send(csv);
}
@Get(":reportId") @Get(":reportId")
@ApiOperation({ summary: "Get report by ID" }) @ApiOperation({ summary: "Get report by ID" })
getReport(@Param("reportId") reportId: string) { getReport(@Param("reportId") reportId: string) {

View File

@@ -1,5 +1,7 @@
import { IsString, IsDateString, IsOptional, IsEnum } from 'class-validator'; import { IsString, IsDateString, IsOptional, IsEnum, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { SeatBlockReasonCategory } from '../seats/seats.dto';
export enum ReportType { export enum ReportType {
REVENUE = 'REVENUE', REVENUE = 'REVENUE',
@@ -27,3 +29,77 @@ export class ExportReportDto {
@ApiProperty() @IsString() reportId: string; @ApiProperty() @IsString() reportId: string;
@ApiProperty({ enum: ExportFormat }) @IsEnum(ExportFormat) format: ExportFormat; @ApiProperty({ enum: ExportFormat }) @IsEnum(ExportFormat) format: ExportFormat;
} }
// ── Blocked Seat Revenue Loss ────────────────────────────────────────────────
export enum BlockedSeatsLossSortBy {
/** Largest estimated loss first (default). */
LOSS_DESC = 'lossMinor',
/** Smallest estimated loss first. */
LOSS_ASC = 'lossMinorAsc',
/** Most blocked seats first. */
BLOCKED_SEATS = 'blockedSeatCount',
/** Soonest departure first. */
DEPARTURE = 'departureAt',
}
export class BlockedSeatsRevenueLossQueryDto {
@ApiPropertyOptional({
example: '2026-07-01',
description: 'Start of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to 30 days ago.',
})
@IsOptional() @IsDateString() dateFrom?: string;
@ApiPropertyOptional({
example: '2026-07-31',
description: 'End of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to today.',
})
@IsOptional() @IsDateString() dateTo?: string;
@ApiPropertyOptional({ description: 'Restrict to a single TrainSchedule.' })
@IsOptional() @IsString() scheduleId?: string;
@ApiPropertyOptional({ description: 'Restrict to schedules running this route.' })
@IsOptional() @IsString() routeId?: string;
@ApiPropertyOptional({ description: 'Restrict to schedules operated by this train.' })
@IsOptional() @IsString() trainId?: string;
@ApiPropertyOptional({
description:
'Restrict to blocks on seats in this coach. Load factor still reflects the whole train, so the percentage stays comparable.',
})
@IsOptional() @IsString() coachId?: string;
@ApiPropertyOptional({
enum: SeatBlockReasonCategory,
description: 'Restrict to blocks in this reporting bucket. Legacy uncategorized blocks are excluded when set.',
})
@IsOptional() @IsEnum(SeatBlockReasonCategory) reasonCategory?: SeatBlockReasonCategory;
@ApiPropertyOptional({
description: "Blocker filter — matches the IAM user id exactly, or the recorded name case-insensitively.",
})
@IsOptional() @IsString() blockedBy?: string;
@ApiPropertyOptional({
example: 'Ethiopian',
default: 'Ethiopian',
description:
'Nationality the counterfactual fares are priced at. Drives both the seat-class tariff variant (LOCAL vs INTERNATIONAL) and the billing currency. Defaults to Ethiopian — the local tariff in ETB.',
})
@IsOptional() @IsString() nationality?: string;
@ApiPropertyOptional({ default: 1, minimum: 1, description: 'Page of schedules, 1-based.' })
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
@ApiPropertyOptional({ default: 25, minimum: 1, maximum: 200, description: 'Schedules per page.' })
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize?: number;
@ApiPropertyOptional({
enum: BlockedSeatsLossSortBy,
default: BlockedSeatsLossSortBy.LOSS_DESC,
description: 'Schedule ordering. Defaults to largest estimated loss first.',
})
@IsOptional() @IsEnum(BlockedSeatsLossSortBy) sortBy?: BlockedSeatsLossSortBy;
}

View File

@@ -2,9 +2,12 @@ import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios'; import { HttpModule } from '@nestjs/axios';
import { ReportsController } from './reports.controller'; import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service'; import { ReportsService } from './reports.service';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
@Module({ @Module({
imports: [HttpModule], // FareEngineModule supplies the counterfactual fares the blocked-seat revenue
// loss report prices blocked seats against — never re-implemented here.
imports: [HttpModule, FareEngineModule],
controllers: [ReportsController], controllers: [ReportsController],
providers: [ReportsService], providers: [ReportsService],
exports: [ReportsService] exports: [ReportsService]

View File

@@ -1,8 +1,105 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm"; import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm"; import { DataSource } from "typeorm";
import {
BlockedSeatRevenueLossReport,
UNCATEGORIZED_REASON_CATEGORY,
} from "@edr/types";
import { PrismaService } from "../../common/prisma.service"; import { PrismaService } from "../../common/prisma.service";
import { GenerateReportDto, ReportType } from "./reports.dto"; import { FareEngineService } from "../fare-engine/fare-engine.service";
import {
BlockedSeatsLossSortBy,
BlockedSeatsRevenueLossQueryDto,
GenerateReportDto,
ReportType,
} from "./reports.dto";
import {
assembleReport,
LossCalculatorInput,
LossCoach,
LossFare,
LossSeat,
selectCountedBlocks,
soldKey,
} from "./blocked-seats-loss.calculator";
/** 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;
/** CSV export is not paginated, but still needs an upper bound. */
const CSV_EXPORT_MAX_SCHEDULES = 5000;
const EMPTY_LOSS_INPUT: LossCalculatorInput = {
schedules: [],
seatsById: new Map(),
coachesById: new Map(),
coachIdsBySchedule: new Map(),
soldSeatKeys: new Set(),
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();
return upper === "ETHIOPIAN" || upper === "DJIBOUTIAN" ? "LOCAL" : "INTERNATIONAL";
}
/**
* The fare engine returns two shapes: a full distance-based calculation, and a thinner
* FareRule fallback for schedules with no route. Both are reduced to the fields the loss
* calculator needs, or dropped if neither shape is present.
*/
function normalizeFareQuote(quote: unknown): LossFare | null {
if (typeof quote !== "object" || quote === null) return null;
const q = quote as Record<string, unknown>;
const seatClassId = q.seatClassId;
if (typeof seatClassId !== "string") return null;
const fareMinor =
typeof q.farePerPassengerMinor === "number"
? q.farePerPassengerMinor
: typeof q.totalMinor === "number"
? q.totalMinor
: null;
if (fareMinor === null) return null;
return {
seatClassId,
seatClassName: typeof q.seatClassName === "string" ? q.seatClassName : "Unknown",
farePerPassengerMinor: fareMinor,
exchangeRate: typeof q.exchangeRate === "number" ? q.exchangeRate : 1,
currency: typeof q.billingCurrency === "string" ? q.billingCurrency : "ETB",
};
}
/** RFC 4180 cell: always quoted, embedded quotes doubled. */
function toCsvCell(value: string | number): string {
return `"${String(value).replace(/"/g, '""')}"`;
}
@Injectable() @Injectable()
export class ReportsService { export class ReportsService {
@@ -10,6 +107,7 @@ export class ReportsService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
@InjectDataSource() private dataSource: DataSource, @InjectDataSource() private dataSource: DataSource,
private fareEngine: FareEngineService,
) {} ) {}
async generateReport(dto: GenerateReportDto) { async generateReport(dto: GenerateReportDto) {
@@ -1135,6 +1233,318 @@ export class ReportsService {
}; };
} }
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
/**
* Potential revenue lost to seats that were blocked and therefore never sellable.
*
* The counting rule and the money live in `blocked-seats-loss.calculator.ts`; this method
* is the fetch plan. Query count is bounded and independent of the number of schedules:
* schedules → coach assignments → seats → booking seats → seat blocks, plus one fare
* calculation per *affected* schedule (schedules with no blocked seat need no fare).
*/
async getBlockedSeatsRevenueLoss(
query: BlockedSeatsRevenueLossQueryDto,
): Promise<BlockedSeatRevenueLossReport> {
const now = new Date();
const { dateFrom, dateTo } = resolveWindow(query, now);
const nationalityAssumption = query.nationality?.trim() || DEFAULT_LOSS_NATIONALITY;
const nationalityType = resolveNationalityType(nationalityAssumption);
// 1 — schedules in the window. CANCELLED trains never ran, so nothing was lost on them.
const schedules = await this.prisma.trainSchedule.findMany({
where: {
departureAt: { gte: dateFrom, lte: dateTo },
status: { not: 'CANCELLED' },
...(query.scheduleId ? { id: query.scheduleId } : {}),
...(query.routeId ? { routeId: query.routeId } : {}),
...(query.trainId ? { trainId: query.trainId } : {}),
},
select: {
id: true,
departureAt: true,
status: true,
train: { select: { number: true } },
route: { select: { name: true } },
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
},
orderBy: { departureAt: 'desc' },
});
const emptyOptions = {
faresBySchedule: new Map<string, Map<string, LossFare>>(),
schedulesWithoutFare: new Set<string>(),
nationalityType,
nationalityAssumption,
now,
dateFrom,
dateTo,
page: query.page ?? 1,
pageSize: query.pageSize ?? DEFAULT_LOSS_PAGE_SIZE,
sortBy: query.sortBy ?? BlockedSeatsLossSortBy.LOSS_DESC,
};
if (schedules.length === 0) {
return assembleReport(EMPTY_LOSS_INPUT, new Map(), emptyOptions);
}
const scheduleIds = schedules.map((s) => s.id);
const departures = schedules.map((s) => s.departureAt.getTime());
const earliestDeparture = new Date(Math.min(...departures));
const latestDeparture = new Date(Math.max(...departures));
// 2 — coach assignments. Unfiltered by `coachId` on purpose: the load factor must
// describe the whole train even when the block list is narrowed to one coach.
const assignments = await this.prisma.coachAssignment.findMany({
where: { scheduleId: { in: scheduleIds } },
select: {
scheduleId: true,
coachId: true,
coach: {
select: {
id: true,
number: true,
coachType: {
select: {
name: true,
type: true,
seatClasses: {
select: { id: true, name: true, bedPosition: true, nationalityType: true },
},
},
},
},
},
},
});
const coachesById = new Map<string, LossCoach>();
const coachIdsBySchedule = new Map<string, Set<string>>();
for (const assignment of assignments) {
const coachIds = coachIdsBySchedule.get(assignment.scheduleId) ?? new Set<string>();
coachIds.add(assignment.coachId);
coachIdsBySchedule.set(assignment.scheduleId, coachIds);
if (!coachesById.has(assignment.coachId)) {
coachesById.set(assignment.coachId, {
id: assignment.coach.id,
number: assignment.coach.number,
coachTypeType: assignment.coach.coachType?.type ?? 'passenger',
coachTypeName: assignment.coach.coachType?.name ?? 'Unknown',
seatClasses: assignment.coach.coachType?.seatClasses ?? [],
});
}
}
// 3 — seats on those coaches. Bounded by fleet size, not by schedule count.
const coachIds = [...coachesById.keys()];
const seatRows = coachIds.length
? await this.prisma.seat.findMany({
where: { coachId: { in: coachIds } },
select: {
id: true,
coachId: true,
seatNumber: true,
bedPosition: true,
premiumFeeMinor: true,
},
})
: [];
const seatsById = new Map<string, LossSeat>(seatRows.map((s) => [s.id, s]));
// 4 — seats actually sold on these schedules. Same tri-branch shape the other
// schedule reports use: outbound leg, return leg, and legacy rows with a null
// scheduleId that inherit the booking's schedule.
const bookingSeats = await this.prisma.bookingSeat.findMany({
where: {
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
OR: [
{ scheduleId: { in: scheduleIds } },
{ leg: 2, booking: { returnScheduleId: { in: scheduleIds } } },
{ scheduleId: null, leg: 1, booking: { scheduleId: { in: scheduleIds } } },
],
},
select: {
seatId: true,
scheduleId: true,
leg: true,
booking: { select: { scheduleId: true, returnScheduleId: true } },
},
});
const scheduleIdSet = new Set(scheduleIds);
const soldSeatKeys = new Set<string>();
for (const bs of bookingSeats) {
const effectiveScheduleId =
bs.scheduleId ?? (bs.leg === 2 ? bs.booking.returnScheduleId : bs.booking.scheduleId);
if (!effectiveScheduleId || !scheduleIdSet.has(effectiveScheduleId)) continue;
soldSeatKeys.add(soldKey(effectiveScheduleId, bs.seatId));
}
// 5 — candidate blocks: schedule-scoped ones for these schedules, plus global ones
// whose active window overlaps the departure range at all. Per-schedule precision
// is applied in the calculator against each schedule's own departureAt.
const blockRows = await this.prisma.seatBlock.findMany({
where: {
AND: [
{
OR: [
{ scheduleId: { in: scheduleIds } },
{
scheduleId: null,
blockedAt: { lte: latestDeparture },
OR: [{ unblockAt: null }, { unblockAt: { gte: earliestDeparture } }],
},
],
},
...(query.reasonCategory ? [{ reasonCategory: query.reasonCategory }] : []),
...(query.coachId ? [{ seat: { coachId: query.coachId } }] : []),
...(query.blockedBy
? [
{
OR: [
{ blockedBy: query.blockedBy },
{
blockedByName: {
contains: query.blockedBy,
mode: 'insensitive' as const,
},
},
],
},
]
: []),
],
},
select: {
id: true,
seatId: true,
scheduleId: true,
reason: true,
reasonCategory: true,
blockedBy: true,
blockedByName: true,
approvedBy: true,
blockedAt: true,
unblockAt: true,
},
orderBy: { blockedAt: 'desc' },
});
const input: LossCalculatorInput = {
schedules: schedules.map((s) => ({
id: s.id,
trainNumber: s.train?.number ?? '—',
routeName: s.route?.name ?? null,
originStation: s.originStation?.name ?? '—',
destinationStation: s.destinationStation?.name ?? '—',
departureAt: s.departureAt,
status: s.status,
})),
seatsById,
coachesById,
coachIdsBySchedule,
soldSeatKeys,
blocks: blockRows,
};
const countedBySchedule = selectCountedBlocks(input);
// 6 — one fare calculation per affected schedule, never per seat.
const { faresBySchedule, schedulesWithoutFare } = await this.quoteFaresForSchedules(
[...countedBySchedule.keys()],
nationalityAssumption,
);
return assembleReport(input, countedBySchedule, {
...emptyOptions,
faresBySchedule,
schedulesWithoutFare,
});
}
/**
* Quotes every active seat class on each affected schedule, in small concurrent batches
* so a wide date range does not open hundreds of simultaneous fare calculations.
*/
private async quoteFaresForSchedules(
scheduleIds: string[],
nationality: string,
): Promise<{
faresBySchedule: Map<string, Map<string, LossFare>>;
schedulesWithoutFare: Set<string>;
}> {
const faresBySchedule = new Map<string, Map<string, LossFare>>();
const schedulesWithoutFare = new Set<string>();
for (let i = 0; i < scheduleIds.length; i += FARE_QUOTE_CONCURRENCY) {
const batch = scheduleIds.slice(i, i + FARE_QUOTE_CONCURRENCY);
await Promise.all(
batch.map(async (scheduleId) => {
try {
const quotes = await this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
const bySeatClass = new Map<string, LossFare>();
for (const quote of quotes) {
const fare = normalizeFareQuote(quote);
if (fare) bySeatClass.set(fare.seatClassId, fare);
}
if (bySeatClass.size === 0) {
schedulesWithoutFare.add(scheduleId);
return;
}
faresBySchedule.set(scheduleId, bySeatClass);
} catch (err) {
// A schedule with no route and no fare rules cannot be priced. Its blocked
// seats still show up in the report; they just carry no monetary claim.
this.logger.warn(
`Blocked-seat loss: no fare for schedule ${scheduleId}${
err instanceof Error ? err.message : String(err)
}`,
);
schedulesWithoutFare.add(scheduleId);
}
}),
);
}
return { faresBySchedule, schedulesWithoutFare };
}
/** CSV of the same report, one row per blocked seat, honouring the same filters. */
async exportBlockedSeatsRevenueLossCsv(
query: BlockedSeatsRevenueLossQueryDto,
): Promise<string> {
// Export is the whole filtered result, not the caller's page.
const report = await this.getBlockedSeatsRevenueLoss({
...query,
page: 1,
pageSize: CSV_EXPORT_MAX_SCHEDULES,
});
const headers = [
'Train', 'Route', 'Origin', 'Destination', 'Departure', 'Schedule Status',
'Sellable Seats', 'Sold Seats', 'Load Factor %', 'Coach', 'Seat', 'Seat Class',
'Block Type', 'Reason Category', 'Reason', 'Blocked By', 'Blocked By Name',
'Approved By', 'Blocked At', 'Unblock At', 'Still Blocked', 'Days Blocked',
'Estimated Loss (minor)', 'Currency',
];
const rows = report.schedules.flatMap((s) =>
s.blocks.map((b) => [
s.trainNumber, s.routeName ?? '', s.originStation, s.destinationStation,
s.departureAt, s.status, s.sellableSeats, s.soldSeats, s.loadFactorPercent,
b.coachNumber ?? '', b.seatNumber ?? '', b.seatClassName ?? '',
b.blockType, b.reasonCategory ?? UNCATEGORIZED_REASON_CATEGORY, b.reason,
b.blockedBy, b.blockedByName ?? '', b.approvedBy ?? '',
b.blockedAt, b.unblockAt ?? '', b.stillBlocked ? 'YES' : 'NO', b.daysBlocked,
b.estimatedLossMinor, b.currency,
]),
);
return [headers, ...rows].map((row) => row.map(toCsvCell).join(',')).join('\n');
}
async getReport(reportId: string) { async getReport(reportId: string) {
return this.prisma.operationalReport.findUnique({ return this.prisma.operationalReport.findUnique({
where: { id: reportId }, where: { id: reportId },

View File

@@ -442,13 +442,47 @@ export class SearchService {
where: { active: true, stops: { some: { stationId: originStationId } } }, where: { active: true, stops: { some: { stationId: originStationId } } },
select: { stops: { select: { stationId: true, sequence: true } } }, select: { stops: { select: { stationId: true, sequence: true } } },
}); });
return candidateRoutes.some((r) => { const onRouteDefinition = candidateRoutes.some((r) => {
const o = r.stops.find((s) => s.stationId === originStationId); const o = r.stops.find((s) => s.stationId === originStationId);
const d = r.stops.find((s) => s.stationId === destinationStationId); const d = r.stops.find((s) => s.stationId === destinationStationId);
return !!o && !!d && o.sequence < d.sequence; return !!o && !!d && o.sequence < d.sequence;
}); });
if (onRouteDefinition) return true;
// Fallback: a schedule whose own stop times connect the pair in order.
//
// A return leg is modelled by reusing the outbound Route while laying its TripStopTimes
// in the opposite order (see test/fixtures/seed-ui.ts). The RouteStop check above cannot
// see that — it only knows A→B→C — so it reports "no route" for C→A even though
// searchSchedules finds and sells that trip, because searchSchedules resolves
// connectivity from TripStopTime.sequence, exactly like the availability loop below.
// Without this fallback the endpoint contradicts the search it is meant to preview, and
// the portal would disable the date picker for a pair that is genuinely bookable.
const schedules = await this.prisma.trainSchedule.findMany({
where: {
AND: [
{ stopTimes: { some: { stationId: originStationId } } },
{ stopTimes: { some: { stationId: destinationStationId } } },
],
},
select: {
stopTimes: {
where: { stationId: { in: [originStationId, destinationStationId] } },
select: { stationId: true, sequence: true },
},
},
take: this.ROUTE_EXISTS_SCHEDULE_SCAN_LIMIT,
});
return schedules.some((s) => {
const o = s.stopTimes.find((st) => st.stationId === originStationId);
const d = s.stopTimes.find((st) => st.stationId === destinationStationId);
return !!o && !!d && o.sequence < d.sequence;
});
} }
/** Bounds the stop-time fallback scan — connectivity is a yes/no, not a survey. */
private readonly ROUTE_EXISTS_SCHEDULE_SCAN_LIMIT = 200;
/** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */ /** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */
private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean { private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean {
return ( return (

View File

@@ -7,6 +7,7 @@ import {
Post, Post,
Patch, Patch,
Query, Query,
Req,
SetMetadata, SetMetadata,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
@@ -20,7 +21,8 @@ import {
ApiBody, ApiBody,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { SeatsService } from "./seats.service"; import { SeatsService } from "./seats.service";
import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto"; import { BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaintenanceDto } from "./seats.dto";
import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user";
import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto"; import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
import { JwtGuard } from "../../common/jwt.guard"; import { JwtGuard } from "../../common/jwt.guard";
import { PassengerStaff } from "../../common/passenger-guards"; import { PassengerStaff } from "../../common/passenger-guards";
@@ -220,11 +222,22 @@ This makes it clear which segment of the route each seat is held for, enabling s
@Post(":seatId/block") @Post(":seatId/block")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin]) @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" }) @ApiOperation({
summary: "Block a seat (e.g., maintenance, damage)",
description:
"The authenticated staff member is recorded as the blocker — their IAM id in `blockedBy` and their " +
"display name in `blockedByName` — so the Blocked Seat Revenue Loss report can attribute the block " +
"without a cross-service lookup.",
})
@ApiParam({ name: "seatId", description: "Seat UUID" }) @ApiParam({ name: "seatId", description: "Seat UUID" })
@ApiBody({ type: BlockSeatDto })
@ApiResponse({ status: 200, description: "Seat blocked" }) @ApiResponse({ status: 200, description: "Seat blocked" })
blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string; scheduleId?: string }) { blockSeat(
return this.service.blockSeat(seatId, body.reason, body.scheduleId); @Param("seatId") seatId: string,
@Body() body: BlockSeatDto,
@Req() req: RequestWithActingUser,
) {
return this.service.blockSeat(seatId, body, resolveActingUser(req));
} }
@Delete(":seatId/block") @Delete(":seatId/block")
@@ -243,9 +256,14 @@ This makes it clear which segment of the route each seat is held for, enabling s
@ApiBearerAuth("IAM-auth") @ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Set seat status to Under Maintenance" }) @ApiOperation({ summary: "Set seat status to Under Maintenance" })
@ApiParam({ name: "seatId", description: "Seat UUID" }) @ApiParam({ name: "seatId", description: "Seat UUID" })
@ApiBody({ type: SetMaintenanceDto })
@ApiResponse({ status: 200, description: "Seat set to under maintenance" }) @ApiResponse({ status: 200, description: "Seat set to under maintenance" })
setMaintenance(@Param("seatId") seatId: string, @Body() body: { reason: string }) { setMaintenance(
return this.service.setMaintenance(seatId, body.reason); @Param("seatId") seatId: string,
@Body() body: SetMaintenanceDto,
@Req() req: RequestWithActingUser,
) {
return this.service.setMaintenance(seatId, body.reason, resolveActingUser(req));
} }
@Delete(":seatId/maintenance") @Delete(":seatId/maintenance")

View File

@@ -53,3 +53,43 @@ export class ReleaseHoldDto {
@ApiProperty({ example: 'hold-uuid', description: 'SeatHold UUID to release' }) @ApiProperty({ example: 'hold-uuid', description: 'SeatHold UUID to release' })
@IsString() holdId: string; @IsString() holdId: string;
} }
/** Coarse bucket for *why* a seat was pulled out of sale — mirrors the Prisma
* `SeatBlockReasonCategory` enum. The free-text `reason` stays the detail. */
export enum SeatBlockReasonCategory {
MAINTENANCE = 'MAINTENANCE',
VIP_RESERVED = 'VIP_RESERVED',
SAFETY = 'SAFETY',
OPERATIONAL = 'OPERATIONAL',
OTHER = 'OTHER',
}
export class BlockSeatDto {
@ApiProperty({
example: 'Torn upholstery — awaiting replacement',
description: 'Free-text detail explaining the block. Shown verbatim in the revenue-loss report.',
})
@IsString() reason: string;
@ApiPropertyOptional({
example: 'schedule-uuid',
description:
'When set, the block applies only to this schedule. Omit for a global block that pulls the seat out of sale on every schedule its coach runs on.',
})
@IsOptional() @IsString() scheduleId?: string;
@ApiPropertyOptional({
enum: SeatBlockReasonCategory,
default: SeatBlockReasonCategory.OTHER,
description:
'Reporting bucket for this block. Defaults to OTHER. Drives the reason-category breakdown in the Blocked Seat Revenue Loss report.',
})
@IsOptional()
@IsEnum(SeatBlockReasonCategory)
reasonCategory?: SeatBlockReasonCategory;
}
export class SetMaintenanceDto {
@ApiProperty({ example: 'Seat recline mechanism jammed' })
@IsString() reason: string;
}

View File

@@ -1,6 +1,7 @@
import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { HoldSeatsDto, JourneyDirection } from './seats.dto'; import { BlockSeatDto, HoldSeatsDto, JourneyDirection, SeatBlockReasonCategory } from './seats.dto';
import { ActingUser } from '../../common/acting-user';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service'; import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
@@ -696,7 +697,10 @@ export class SeatsService {
coachNumber: b.seat.coach.number, coachNumber: b.seat.coach.number,
scheduleId: b.scheduleId, scheduleId: b.scheduleId,
reason: b.reason, reason: b.reason,
// Blocks written before the reason-category column existed report as uncategorized.
reasonCategory: b.reasonCategory,
blockedBy: b.blockedBy, blockedBy: b.blockedBy,
blockedByName: b.blockedByName,
blockedAt: b.blockedAt, blockedAt: b.blockedAt,
unblockAt: b.unblockAt, unblockAt: b.unblockAt,
})); }));
@@ -898,20 +902,42 @@ export class SeatsService {
return { imported, errors: errors.slice(0, 10) }; return { imported, errors: errors.slice(0, 10) };
} }
async blockSeat(seatId: string, reason: string, scheduleId?: string) { /**
* Pulls a seat out of sale.
*
* `actor` is the authenticated staff member from the request. Their IAM id lands in
* `blockedBy` and their display name is denormalized into `blockedByName`, so the
* Blocked Seat Revenue Loss report can attribute the block without a cross-service
* lookup. System-initiated blocks (no authenticated user) fall back to `SYSTEM`.
*/
async blockSeat(seatId: string, dto: BlockSeatDto, actor: ActingUser | null) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found'); if (!seat) throw new NotFoundException('Seat not found');
const { reason, scheduleId } = dto;
const reasonCategory = dto.reasonCategory ?? SeatBlockReasonCategory.OTHER;
const blockedBy = actor?.id ?? 'SYSTEM';
const blockedByName = actor?.name ?? 'System';
// Schedule-scoped block: only affects this schedule, not all schedules // Schedule-scoped block: only affects this schedule, not all schedules
// Global block (no scheduleId): sets Seat.status = BLOCKED for all schedules // Global block (no scheduleId): sets Seat.status = BLOCKED for all schedules
if (scheduleId) { if (scheduleId) {
await this.prisma.seatBlock.create({ data: { seatId, scheduleId, reason, blockedBy: 'system' } }); await this.prisma.seatBlock.create({
data: { seatId, scheduleId, reason, reasonCategory, blockedBy, blockedByName },
});
} else { } else {
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } }); await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } });
await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } }); await this.prisma.seatBlock.create({
data: { seatId, reason, reasonCategory, blockedBy, blockedByName },
});
} }
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason, scheduleId } }); await this.auditService.log({
return { blocked: true, seatId, reason, scheduleId }; action: 'UPDATE',
entityType: 'Seat',
entityId: seatId,
newData: { status: 'BLOCKED', reason, reasonCategory, scheduleId, blockedBy },
});
return { blocked: true, seatId, reason, reasonCategory, scheduleId, blockedBy, blockedByName };
} }
async unblockSeat(seatId: string, scheduleId?: string) { async unblockSeat(seatId: string, scheduleId?: string) {
@@ -928,12 +954,20 @@ export class SeatsService {
return { unblocked: true, seatId, scheduleId }; return { unblocked: true, seatId, scheduleId };
} }
async setMaintenance(seatId: string, reason: string) { async setMaintenance(seatId: string, reason: string, actor: ActingUser | null) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } }); const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found'); if (!seat) throw new NotFoundException('Seat not found');
if (seat.status === 'BOOKED') throw new BadRequestException('Cannot set a booked seat to maintenance'); if (seat.status === 'BOOKED') throw new BadRequestException('Cannot set a booked seat to maintenance');
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'UNDER_MAINTENANCE' as any } }); await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'UNDER_MAINTENANCE' as any } });
await this.prisma.seatBlock.create({ data: { seatId, reason: `MAINTENANCE: ${reason}`, blockedBy: 'system' } }); await this.prisma.seatBlock.create({
data: {
seatId,
reason: `MAINTENANCE: ${reason}`,
reasonCategory: SeatBlockReasonCategory.MAINTENANCE,
blockedBy: actor?.id ?? 'SYSTEM',
blockedByName: actor?.name ?? 'System',
},
});
return { maintenance: true, seatId, reason }; return { maintenance: true, seatId, reason };
} }

View File

@@ -0,0 +1,308 @@
/**
* `/search/available-dates` — the data behind the search form's date picker.
*
* The portal disables the date control entirely when `routeExists` is false, and grays out
* individual days that report `available: false`. Both behaviours are only as correct as this
* endpoint, so this suite pins:
*
* 1. routeExists=false (with an empty `dates` array) for a station pair no active route
* connects in that direction — the case that disables the whole control.
* 2. routeExists=true plus a per-date availability list when a route does connect them.
* 3. Direction matters: seed-core's route runs A→B→C, so C→A is NOT a route even though
* both stations sit on it. This is the exact regression the UI relies on — a reverse
* pair must not be treated as bookable.
* 4. A date only counts as available when a bookable schedule actually departs that day.
* 5. The range is clamped server-side and never reports dates in the past.
*
* Uses the slim harness (real Nest DI) for schedule creation so the real interpolation runs,
* then instantiates SearchService directly with a real Prisma — SearchModule is not in the
* slim harness's DOMAIN_MODULES (it pulls in NotificationsModule → RabbitMQ), mirroring the
* Tier-2 pattern in stop-based-booking-segment.e2e-spec.ts.
*/
import { SchedulesService } from "../src/modules/schedules/schedules.service";
import { SearchService } from "../src/modules/search/search.service";
import { SegmentsService } from "../src/modules/segments/segments.service";
import { CurrencyService } from "../src/modules/currency/currency.service";
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
import { IDS, resetAndSeedCore } from "./fixtures/seed-core";
const ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000;
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
/** Calendar date in Africa/Addis_Ababa (fixed UTC+3) — matches the service's own conversion. */
function addisDateStr(d: Date): string {
return new Date(d.getTime() + ADDIS_OFFSET_MS).toISOString().slice(0, 10);
}
function daysFromNow(days: number): Date {
return new Date(Date.now() + days * ONE_DAY_MS);
}
describe("GET /search/available-dates", () => {
let harness: ServiceHarness;
let searchService: SearchService;
let schedulesService: SchedulesService;
beforeAll(async () => {
harness = await createServiceHarness();
schedulesService = await harness.moduleRef.resolve(SchedulesService);
const currencyService = harness.moduleRef.get(CurrencyService);
const fareEngine = harness.moduleRef.get(FareEngineService);
const segmentsService = new SegmentsService(harness.prisma as any);
searchService = new SearchService(
harness.prisma as any,
currencyService,
fareEngine,
segmentsService,
);
});
afterAll(async () => {
await harness.close();
});
beforeEach(async () => {
await resetAndSeedCore(harness.prisma);
});
/** Creates a bookable schedule departing `days` from now on the seeded A→B→C route. */
async function createBookableSchedule(days: number, trainNumber: string) {
const departureAt = daysFromNow(days);
departureAt.setUTCHours(6, 0, 0, 0);
const arrivalAt = new Date(departureAt.getTime() + 6 * 60 * 60 * 1000);
const train = await harness.prisma.train.create({
data: { number: trainNumber, name: `Test ${trainNumber}` },
});
const coach = await harness.prisma.coach.create({
data: {
coachTypeId: IDS.coachType,
number: `${trainNumber}-C1`,
capacity: 2,
sequence: 1,
status: "ACTIVE",
},
});
await Promise.all(
["1A", "1B"].map((seatNumber, i) =>
harness.prisma.seat.create({
data: { coachId: coach.id, seatNumber, row: 1, col: String.fromCharCode(65 + i) },
}),
),
);
const schedule = await schedulesService.createSchedule({
trainId: train.id,
routeId: IDS.route,
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
departureAt: departureAt.toISOString(),
arrivalAt: arrivalAt.toISOString(),
coachIds: [coach.id],
} as any);
return { schedule, departureDate: addisDateStr(departureAt) };
}
function range(days = 30) {
return { from: addisDateStr(new Date()), to: addisDateStr(daysFromNow(days)) };
}
it("reports routeExists=false and no dates when no route connects the pair", async () => {
// seed-core's only route runs A→B→C and no schedule exists yet, so nothing connects C→A.
// Every date is unbookable, and the portal disables the date control outright rather than
// graying out each day individually.
const result = await searchService.getAvailableDates({
originStationId: IDS.stationC,
destinationStationId: IDS.stationA,
...range(),
} as any);
expect(result.routeExists).toBe(false);
expect(result.dates).toEqual([]);
});
/**
* Regression: `routeExists` must agree with what the search can actually sell.
*
* A return leg reuses the outbound Route but lays its TripStopTimes in the opposite order.
* routeExistsForPair originally consulted only RouteStop ordering, so it answered "no route"
* for C→A while searchTrips happily returned a bookable trip for that same pair. The portal
* disables its date picker on this flag, so the stale answer would have blocked a real,
* sellable journey.
*/
it("reports routeExists=true for a reverse pair a real schedule connects", async () => {
const departureAt = daysFromNow(3);
departureAt.setUTCHours(6, 0, 0, 0);
const train = await harness.prisma.train.create({
data: { number: "AD-REV", name: "Reverse leg" },
});
const coach = await harness.prisma.coach.create({
data: { coachTypeId: IDS.coachType, number: "AD-REV-C1", capacity: 1, sequence: 1, status: "ACTIVE" },
});
await harness.prisma.seat.create({
data: { coachId: coach.id, seatNumber: "1A", row: 1, col: "A" },
});
// Return leg: same Route, but stop times run C → A.
const schedule = await harness.prisma.trainSchedule.create({
data: {
trainId: train.id,
routeId: IDS.route,
originStationId: IDS.stationC,
destinationStationId: IDS.stationA,
departureAt,
arrivalAt: new Date(departureAt.getTime() + 6 * 60 * 60 * 1000),
durationMinutes: 360,
status: "SCHEDULED",
},
});
await harness.prisma.tripStopTime.createMany({
data: [
{ scheduleId: schedule.id, stationId: IDS.stationC, sequence: 1, plannedDepartureAt: departureAt, status: "OPEN" },
{ scheduleId: schedule.id, stationId: IDS.stationA, sequence: 2, plannedArrivalAt: new Date(departureAt.getTime() + 6 * 3600_000), status: "OPEN" },
],
});
await harness.prisma.coachAssignment.create({
data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1, isOperational: true },
});
const result = await searchService.getAvailableDates({
originStationId: IDS.stationC,
destinationStationId: IDS.stationA,
...range(),
} as any);
expect(result.routeExists).toBe(true);
expect(result.dates.filter((d) => d.available).map((d) => d.date)).toContain(
addisDateStr(departureAt),
);
});
it("reports routeExists=false for a station pair with no route at all", async () => {
const orphan = await harness.prisma.station.create({
data: { code: "ZZZ", name: "Orphan", city: "Nowhere" },
});
const result = await searchService.getAvailableDates({
originStationId: IDS.stationA,
destinationStationId: orphan.id,
...range(),
} as any);
expect(result.routeExists).toBe(false);
expect(result.dates).toEqual([]);
});
it("reports routeExists=true with a per-date list for a connected pair", async () => {
const result = await searchService.getAvailableDates({
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
...range(),
} as any);
expect(result.routeExists).toBe(true);
expect(result.dates.length).toBeGreaterThan(0);
for (const d of result.dates) {
expect(d).toEqual({ date: expect.any(String), available: expect.any(Boolean) });
}
});
it("marks only the days a bookable schedule departs as available", async () => {
const { departureDate } = await createBookableSchedule(3, "AD-1");
const result = await searchService.getAvailableDates({
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
...range(),
} as any);
expect(result.routeExists).toBe(true);
const available = result.dates.filter((d) => d.available).map((d) => d.date);
expect(available).toContain(departureDate);
// Every other day in the window has no schedule, so it must be reported unavailable —
// this is what grays out individual days on the picker.
expect(available).toEqual([departureDate]);
});
it("treats a mid-route segment as its own pair (A→B available, C→B never)", async () => {
const { departureDate } = await createBookableSchedule(4, "AD-2");
const forward = await searchService.getAvailableDates({
originStationId: IDS.stationA,
destinationStationId: IDS.stationB,
...range(),
} as any);
expect(forward.routeExists).toBe(true);
expect(forward.dates.filter((d) => d.available).map((d) => d.date)).toContain(departureDate);
// C sits after B on the route, so C→B is backwards — no route, whatever schedules exist.
const backward = await searchService.getAvailableDates({
originStationId: IDS.stationC,
destinationStationId: IDS.stationB,
...range(),
} as any);
expect(backward.routeExists).toBe(false);
expect(backward.dates).toEqual([]);
});
it("never reports dates before today, even when asked for a past range", async () => {
const result = await searchService.getAvailableDates({
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
from: addisDateStr(daysFromNow(-30)),
to: addisDateStr(daysFromNow(5)),
} as any);
const today = addisDateStr(new Date());
expect(result.routeExists).toBe(true);
for (const d of result.dates) expect(d.date >= today).toBe(true);
});
it("clamps an over-long range to the 90-day server maximum", async () => {
const result = await searchService.getAvailableDates({
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
from: addisDateStr(new Date()),
to: addisDateStr(daysFromNow(400)),
} as any);
expect(result.routeExists).toBe(true);
// Inclusive of both ends, 90 days spans at most 91 calendar dates.
expect(result.dates.length).toBeLessThanOrEqual(91);
expect(result.to <= addisDateStr(daysFromNow(91))).toBe(true);
});
it("does not mark a package-only schedule's day as available", async () => {
const { schedule, departureDate } = await createBookableSchedule(5, "AD-3");
await harness.prisma.trainSchedule.update({
where: { id: schedule.id },
data: { isPackageOnly: true },
});
const result = await searchService.getAvailableDates({
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
...range(),
} as any);
const available = result.dates.filter((d) => d.available).map((d) => d.date);
expect(available).not.toContain(departureDate);
});
it("does not mark a cancelled schedule's day as available", async () => {
const { schedule, departureDate } = await createBookableSchedule(6, "AD-4");
await harness.prisma.trainSchedule.update({
where: { id: schedule.id },
data: { status: "CANCELLED" },
});
const result = await searchService.getAvailableDates({
originStationId: IDS.stationA,
destinationStationId: IDS.stationC,
...range(),
} as any);
const available = result.dates.filter((d) => d.available).map((d) => d.date);
expect(available).not.toContain(departureDate);
});
});

View File

@@ -10,7 +10,9 @@ import {
Banknote, Banknote,
ArrowRight, ArrowRight,
ScanLine, ScanLine,
Ban,
} from "lucide-react"; } from "lucide-react";
import { SEAT_BLOCK_REASON_CATEGORY_LABELS } from "@edr/types";
import { dashboardApi } from "@/lib/api/dashboard"; import { dashboardApi } from "@/lib/api/dashboard";
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import { formatCurrency } from "@/lib/utils"; import { formatCurrency } from "@/lib/utils";
@@ -161,6 +163,10 @@ function DashboardPageContent() {
return rate !== null ? sum + Math.round(totalMinor * rate) : sum; return rate !== null ? sum + Math.round(totalMinor * rate) : sum;
}, 0); }, 0);
const blockedLoss = stats?.blockedSeatRevenueLoss;
// Never summed across currencies — each is shown on its own line, largest first.
const blockedLossRows = blockedLoss?.lossByCurrency ?? [];
const normalRows = stats?.revenueByCurrency ?? []; const normalRows = stats?.revenueByCurrency ?? [];
const packageRows = stats?.packageRevenueByCurrency ?? []; const packageRows = stats?.packageRevenueByCurrency ?? [];
const normalGrand = calcGrand(normalRows); const normalGrand = calcGrand(normalRows);
@@ -320,6 +326,73 @@ function DashboardPageContent() {
</> </>
)} )}
</div> </div>
{/* Blocked-seat revenue loss — rides the same backoffice-stats payload, so the
dashboard makes no extra request for it. */}
<div className="card flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-slate-100 dark:bg-slate-800 p-1.5">
<Ban className="h-4 w-4 text-slate-600 dark:text-slate-400" />
</div>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Blocked Seats
</span>
<span className="ml-auto text-[11px] text-muted-foreground">
Last {blockedLoss?.periodDays ?? 30}d
</span>
</div>
{statsLoading ? (
<p className="text-muted-foreground text-sm">Loading</p>
) : (
<>
{blockedLossRows.length === 0 ? (
<p className="text-3xl font-bold text-foreground tabular-nums">
{formatCurrency(0, "ETB")}
</p>
) : (
blockedLossRows.map((row, i) => (
<p
key={row.currency}
className={
i === 0
? "text-3xl font-bold text-foreground tabular-nums"
: "text-lg font-semibold text-foreground tabular-nums"
}
>
{formatCurrency(row.estimatedLossMinor, row.currency)}
</p>
))
)}
<p className="text-xs text-muted-foreground -mt-1">
Estimated potential revenue never earned
</p>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Seats blocked</span>
<span className="text-sm font-semibold text-foreground tabular-nums">
{(blockedLoss?.blockedSeatCount ?? 0).toLocaleString()} across{" "}
{(blockedLoss?.schedulesAffected ?? 0).toLocaleString()} schedules
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Top reason</span>
<span className="text-sm font-semibold text-foreground">
{blockedLoss?.topReasonCategory
? (SEAT_BLOCK_REASON_CATEGORY_LABELS[blockedLoss.topReasonCategory] ??
blockedLoss.topReasonCategory)
: "—"}
</span>
</div>
</div>
<Link
href="/reports/blocked-seats"
className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1"
>
View full report <ArrowRight className="h-3 w-3" />
</Link>
</>
)}
</div>
</div> </div>
{/* Revenue breakdown */} {/* Revenue breakdown */}

View File

@@ -0,0 +1,3 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,928 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Ban,
ChevronDown,
ChevronRight,
Download,
Info,
Layers,
TrendingDown,
Train,
} from "lucide-react";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip as RechartsTooltip,
XAxis,
YAxis,
} from "recharts";
import type {
BlockedSeatLossDetail,
BlockedSeatLossSchedule,
BlockedSeatRevenueLossReport,
} from "@edr/types";
import {
SEAT_BLOCK_REASON_CATEGORIES,
SEAT_BLOCK_REASON_CATEGORY_LABELS,
UNCATEGORIZED_REASON_CATEGORY,
} from "@edr/types";
import { apiClient } from "@/lib/api-client";
import {
blockedSeatsLossApi,
type BlockedSeatsLossFilters,
type ScheduleOption,
} from "@/lib/api/blocked-seats-loss";
import Badge from "@/components/ui/Badge";
import ActionButton from "@/components/ui/ActionButton";
import Pagination from "@/components/ui/Pagination";
import { usePagination } from "@/lib/use-pagination";
import { formatCurrency, formatDateTime } from "@/lib/utils";
import { categoricalColor, getChartPalette } from "@/lib/chart-palette";
import { useTheme } from "@/lib/theme-store";
interface RouteOption {
id: string;
name: string;
code: string;
}
interface TrainOption {
id: string;
number: string;
name: string;
}
/** Fixed domain order for reason categories, so a filter never repaints the survivors. */
const REASON_CATEGORY_ORDER: readonly string[] = [
...SEAT_BLOCK_REASON_CATEGORIES,
UNCATEGORIZED_REASON_CATEGORY,
];
function reasonLabel(category: string | null): string {
const key = category ?? UNCATEGORIZED_REASON_CATEGORY;
return SEAT_BLOCK_REASON_CATEGORY_LABELS[key] ?? key;
}
function isoDaysAgo(days: number): string {
const d = new Date();
d.setDate(d.getDate() - days);
return d.toISOString().split("T")[0];
}
const TABLE_PAGE_SIZE = 25;
export default function BlockedSeatRevenueLossPage() {
const isDark = useTheme((s) => s.isDark);
const palette = getChartPalette(isDark);
// ── Filters ───────────────────────────────────────────────────────────────
const [dateFrom, setDateFrom] = useState(isoDaysAgo(30));
const [dateTo, setDateTo] = useState(() => new Date().toISOString().split("T")[0]);
const [scheduleId, setScheduleId] = useState("");
const [routeId, setRouteId] = useState("");
const [trainId, setTrainId] = useState("");
const [reasonCategory, setReasonCategory] = useState("");
const [blockedBy, setBlockedBy] = useState("");
const [blockedByInput, setBlockedByInput] = useState("");
const [sortBy, setSortBy] = useState("lossMinor");
const [page, setPage] = useState(1);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [showMethodology, setShowMethodology] = useState(false);
const [exporting, setExporting] = useState(false);
const filters: BlockedSeatsLossFilters = useMemo(
() => ({
dateFrom,
dateTo,
scheduleId,
routeId,
trainId,
reasonCategory,
blockedBy,
sortBy,
}),
[dateFrom, dateTo, scheduleId, routeId, trainId, reasonCategory, blockedBy, sortBy],
);
const { data: schedules = [], isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
queryKey: ["report-schedules-all"],
queryFn: blockedSeatsLossApi.getSchedules,
});
const { data: routes = [] } = useQuery<RouteOption[]>({
queryKey: ["routes"],
queryFn: () => apiClient.get<RouteOption[]>("/routes"),
});
const { data: trains = [] } = useQuery<TrainOption[]>({
queryKey: ["fleet-trains"],
queryFn: async () => {
const res = await apiClient.get<TrainOption[] | { items: TrainOption[] }>(
"/fleet/trains",
);
return Array.isArray(res) ? res : (res?.items ?? []);
},
});
const { data, isLoading, isError, isFetching } = useQuery<BlockedSeatRevenueLossReport>({
queryKey: ["blocked-seats-revenue-loss", filters, page],
// Hold the previous render while refetching rather than flashing a skeleton.
placeholderData: (previous) => previous,
queryFn: () =>
blockedSeatsLossApi.getReport({ ...filters, page, pageSize: TABLE_PAGE_SIZE }),
});
const summary = data?.summary;
const scheduleRows = data?.schedules ?? [];
const totalPages = Math.max(1, Math.ceil((data?.meta.total ?? 0) / TABLE_PAGE_SIZE));
const resetFilters = () => {
setDateFrom(isoDaysAgo(30));
setDateTo(new Date().toISOString().split("T")[0]);
setScheduleId("");
setRouteId("");
setTrainId("");
setReasonCategory("");
setBlockedBy("");
setBlockedByInput("");
setSortBy("lossMinor");
setPage(1);
};
const onFilterChange = (apply: () => void) => {
apply();
setPage(1);
setExpanded(new Set());
};
const toggleExpanded = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const doExport = async () => {
setExporting(true);
try {
const csv = await blockedSeatsLossApi.exportCsv(filters);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `blocked-seats-revenue-loss-${new Date().toISOString().split("T")[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
} finally {
setExporting(false);
}
};
// ── Chart data ────────────────────────────────────────────────────────────
// Money is only comparable within one currency, so both charts are scoped to the
// dominant currency on this page and say so.
const chartCurrency = summary?.lossByCurrency[0]?.currency ?? "ETB";
const otherCurrencies = (summary?.lossByCurrency ?? [])
.slice(1)
.map((c) => c.currency);
const topSchedules = useMemo(
() =>
scheduleRows
.filter((s) => s.currency === chartCurrency)
.slice()
.sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor)
.slice(0, 10)
.map((s) => ({
label: `${s.trainNumber} · ${new Date(s.departureAt).toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
})}`,
estimatedLossMinor: s.estimatedLossMinor,
adjustedLossMinor: s.adjustedLossMinor,
blockedSeatCount: s.blockedSeatCount,
scheduleId: s.scheduleId,
})),
[scheduleRows, chartCurrency],
);
const reasonBreakdown = useMemo(() => {
const rows = (summary?.topReasonCategories ?? []).filter(
(r) => r.currency === chartCurrency,
);
const total = rows.reduce((sum, r) => sum + r.estimatedLossMinor, 0);
return rows.map((r) => ({
...r,
// Colour by fixed domain position, not by rank in this filtered view.
color: categoricalColor(palette, REASON_CATEGORY_ORDER.indexOf(r.reasonCategory)),
sharePercent: total > 0 ? (r.estimatedLossMinor / total) * 100 : 0,
}));
}, [summary, chartCurrency, palette]);
const hasData = (summary?.blockedSeatCount ?? 0) > 0;
return (
<div className="space-y-6 p-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-3xl font-bold text-foreground">Blocked Seat Revenue Loss</h1>
<p className="text-muted-foreground mt-1">
Potential fare revenue that could never be earned because seats were blocked
out of sale with per-seat detail on who blocked each one and why.
</p>
</div>
<ActionButton
icon={Download}
variant="secondary"
onClick={doExport}
loading={exporting}
disabled={!hasData}
>
Download CSV
</ActionButton>
</div>
{/* ── Filter bar: one row above everything it scopes ───────────────── */}
<div className="card">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<label className="label" htmlFor="bsl-from">
Departing from
</label>
<input
id="bsl-from"
type="date"
className="input"
value={dateFrom}
onChange={(e) => onFilterChange(() => setDateFrom(e.target.value))}
/>
</div>
<div>
<label className="label" htmlFor="bsl-to">
Departing to
</label>
<input
id="bsl-to"
type="date"
className="input"
value={dateTo}
onChange={(e) => onFilterChange(() => setDateTo(e.target.value))}
/>
</div>
<div className="lg:col-span-2">
<label className="label" htmlFor="bsl-schedule">
Schedule
</label>
<select
id="bsl-schedule"
className="input"
value={scheduleId}
disabled={loadingSchedules}
onChange={(e) => onFilterChange(() => setScheduleId(e.target.value))}
>
<option value="">
{loadingSchedules ? "Loading schedules…" : "All schedules"}
</option>
{schedules.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor="bsl-route">
Route
</label>
<select
id="bsl-route"
className="input"
value={routeId}
onChange={(e) => onFilterChange(() => setRouteId(e.target.value))}
>
<option value="">All routes</option>
{routes.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor="bsl-train">
Train
</label>
<select
id="bsl-train"
className="input"
value={trainId}
onChange={(e) => onFilterChange(() => setTrainId(e.target.value))}
>
<option value="">All trains</option>
{trains.map((t) => (
<option key={t.id} value={t.id}>
{t.number} {t.name}
</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor="bsl-reason">
Reason category
</label>
<select
id="bsl-reason"
className="input"
value={reasonCategory}
onChange={(e) => onFilterChange(() => setReasonCategory(e.target.value))}
>
<option value="">All reasons</option>
{SEAT_BLOCK_REASON_CATEGORIES.map((c) => (
<option key={c} value={c}>
{reasonLabel(c)}
</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor="bsl-blocker">
Blocked by
</label>
<form
onSubmit={(e) => {
e.preventDefault();
onFilterChange(() => setBlockedBy(blockedByInput.trim()));
}}
>
<input
id="bsl-blocker"
type="search"
className="input"
placeholder="Name or user id — press Enter"
value={blockedByInput}
onChange={(e) => setBlockedByInput(e.target.value)}
onBlur={() => onFilterChange(() => setBlockedBy(blockedByInput.trim()))}
/>
</form>
</div>
</div>
<div className="mt-4 flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<label className="text-xs text-muted-foreground" htmlFor="bsl-sort">
Sort by
</label>
<select
id="bsl-sort"
className="input w-56"
value={sortBy}
onChange={(e) => onFilterChange(() => setSortBy(e.target.value))}
>
<option value="lossMinor">Largest estimated loss</option>
<option value="lossMinorAsc">Smallest estimated loss</option>
<option value="blockedSeatCount">Most blocked seats</option>
<option value="departureAt">Earliest departure</option>
</select>
</div>
<button
type="button"
onClick={resetFilters}
className="text-xs text-primary hover:underline"
>
Reset filters
</button>
</div>
{isError && (
<p className="text-xs text-red-500 mt-3">
Failed to load the report. Check the filters and try again.
</p>
)}
</div>
{isLoading && !data ? (
<div className="card py-16 text-center text-muted-foreground">Loading report</div>
) : !hasData ? (
<div className="card py-16 text-center text-muted-foreground">
<Ban className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>No blocked seats cost revenue in this window.</p>
<p className="text-xs mt-1">
Widen the date range, or clear the route/train/reason filters.
</p>
</div>
) : (
<div className={isFetching ? "opacity-60 transition-opacity space-y-6" : "space-y-6"}>
{/* ── Summary tiles ─────────────────────────────────────────────── */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="card">
<div className="flex items-start justify-between">
<div className="min-w-0">
<p className="text-muted-foreground text-sm font-medium">
Estimated loss
</p>
{summary?.lossByCurrency.map((c, i) => (
<p
key={c.currency}
className={
i === 0
? "text-2xl font-bold mt-2 text-foreground"
: "text-base font-semibold text-foreground"
}
>
{formatCurrency(c.estimatedLossMinor, c.currency)}
</p>
))}
<p className="text-xs text-muted-foreground mt-1">At full occupancy</p>
</div>
<TrendingDown className="h-8 w-8 text-slate-500 opacity-30 shrink-0" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div className="min-w-0">
<p className="text-muted-foreground text-sm font-medium">Adjusted loss</p>
{summary?.lossByCurrency.map((c, i) => (
<p
key={c.currency}
className={
i === 0
? "text-2xl font-bold mt-2 text-foreground"
: "text-base font-semibold text-foreground"
}
>
{formatCurrency(c.adjustedLossMinor, c.currency)}
</p>
))}
<p className="text-xs text-muted-foreground mt-1">
Scaled by each train&apos;s load factor
</p>
</div>
<Layers className="h-8 w-8 text-slate-500 opacity-30 shrink-0" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Blocked seats</p>
<p className="text-2xl font-bold mt-2 text-foreground tabular-nums">
{summary?.blockedSeatCount.toLocaleString()}
</p>
<p className="text-xs text-muted-foreground mt-1">
Across {summary?.schedulesAffected.toLocaleString()} schedules
</p>
</div>
<Ban className="h-8 w-8 text-slate-500 opacity-30 shrink-0" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">
Seat-days blocked
</p>
<p className="text-2xl font-bold mt-2 text-foreground tabular-nums">
{scheduleRows
.reduce(
(sum, s) => sum + s.blocks.reduce((n, b) => n + b.daysBlocked, 0),
0,
)
.toLocaleString()}
</p>
<p className="text-xs text-muted-foreground mt-1">On this page</p>
</div>
<Train className="h-8 w-8 text-slate-500 opacity-30 shrink-0" />
</div>
</div>
</div>
{/* ── Charts ────────────────────────────────────────────────────── */}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<div className="card">
<h3 className="text-base font-semibold text-foreground">
Top schedules by estimated loss
</h3>
<p className="text-xs text-muted-foreground mt-1 mb-4">
Estimated loss in {chartCurrency}, largest first
{otherCurrencies.length > 0 && (
<> · {otherCurrencies.join(", ")} shown in the table below</>
)}
</p>
{topSchedules.length === 0 ? (
<div className="h-[320px] flex items-center justify-center text-muted-foreground text-sm">
No schedules in {chartCurrency} on this page
</div>
) : (
<ResponsiveContainer width="100%" height={40 * topSchedules.length + 48}>
<BarChart
data={topSchedules}
layout="vertical"
margin={{ top: 4, right: 72, bottom: 8, left: 8 }}
barCategoryGap="28%"
>
<CartesianGrid
horizontal={false}
stroke={palette.grid}
strokeWidth={1}
/>
<XAxis
type="number"
tick={{ fontSize: 11, fill: palette.textMuted }}
tickFormatter={(v: number) => (v / 100).toLocaleString()}
axisLine={{ stroke: palette.axis }}
tickLine={false}
/>
<YAxis
type="category"
dataKey="label"
width={128}
tick={{ fontSize: 11, fill: palette.textMuted }}
axisLine={false}
tickLine={false}
/>
<RechartsTooltip
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
contentStyle={{
background: palette.tooltipBg,
border: `1px solid ${palette.tooltipBorder}`,
borderRadius: 8,
fontSize: 12,
}}
formatter={(value: number, name: string) => [
formatCurrency(value, chartCurrency),
name === "estimatedLossMinor" ? "Estimated" : "Adjusted",
]}
/>
{/* One series, one hue — bar length already encodes magnitude. */}
<Bar
dataKey="estimatedLossMinor"
fill={palette.sequential}
radius={[0, 4, 4, 0]}
maxBarSize={24}
isAnimationActive={false}
label={{
position: "right",
fontSize: 11,
fill: palette.textMuted,
formatter: (v: number) => formatCurrency(v, chartCurrency),
}}
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className="card">
<h3 className="text-base font-semibold text-foreground">
Where the loss comes from
</h3>
<p className="text-xs text-muted-foreground mt-1 mb-4">
Share of estimated loss by reason category, in {chartCurrency}
</p>
{/* Part-to-whole: one horizontal stacked bar, 2px surface gaps between
segments (no borders), with a legend carrying identity in text. */}
<div
className="flex w-full h-7 rounded-md overflow-hidden"
role="img"
aria-label={`Estimated loss by reason category: ${reasonBreakdown
.map((r) => `${reasonLabel(r.reasonCategory)} ${r.sharePercent.toFixed(0)}%`)
.join(", ")}`}
>
{reasonBreakdown.map((r, i) => (
<div
key={r.reasonCategory}
className="h-full"
style={{
width: `${r.sharePercent}%`,
background: r.color,
marginRight: i < reasonBreakdown.length - 1 ? 2 : 0,
}}
title={`${reasonLabel(r.reasonCategory)}${formatCurrency(
r.estimatedLossMinor,
r.currency,
)}`}
/>
))}
</div>
{/* Legend doubles as the table view — the numbers are never tooltip-gated. */}
<table className="w-full text-sm mt-4">
<thead>
<tr className="text-xs uppercase tracking-wider text-muted-foreground">
<th className="text-left font-medium py-2">Reason</th>
<th className="text-right font-medium py-2">Seats</th>
<th className="text-right font-medium py-2">Share</th>
<th className="text-right font-medium py-2">Estimated loss</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{reasonBreakdown.map((r) => (
<tr key={r.reasonCategory}>
<td className="py-2">
<span className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 rounded-sm shrink-0"
style={{ background: r.color }}
aria-hidden="true"
/>
<span className="text-foreground">
{reasonLabel(r.reasonCategory)}
</span>
</span>
</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">
{r.count.toLocaleString()}
</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">
{r.sharePercent.toFixed(1)}%
</td>
<td className="py-2 text-right tabular-nums text-foreground font-medium">
{formatCurrency(r.estimatedLossMinor, r.currency)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* ── Schedule table with per-seat drill-down ───────────────────── */}
<div className="card p-0">
<div className="px-4 pt-4 pb-3 flex items-center justify-between gap-4 flex-wrap">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Affected schedules
</h3>
<span className="text-xs text-muted-foreground">
Expand a row to see every blocked seat
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{[
"Schedule",
"Route",
"Departure",
"Blocked",
"Load factor",
"Estimated loss",
"Adjusted loss",
].map((h) => (
<th
key={h}
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{scheduleRows.map((row) => (
<ScheduleRow
key={row.scheduleId}
row={row}
expanded={expanded.has(row.scheduleId)}
onToggle={() => toggleExpanded(row.scheduleId)}
/>
))}
{scheduleRows.length === 0 && (
<tr>
<td
colSpan={7}
className="py-8 text-center text-sm text-muted-foreground"
>
No schedules on this page
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={(p) => {
setPage(p);
setExpanded(new Set());
}}
/>
</div>
</div>
)}
{/* ── Methodology, verbatim from the API ──────────────────────────── */}
{data && (
<div className="card">
<button
type="button"
onClick={() => setShowMethodology((v) => !v)}
className="flex w-full items-center gap-2 text-left"
aria-expanded={showMethodology}
>
{showMethodology ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<Info className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold text-foreground">
How this is calculated
</span>
</button>
{showMethodology && (
<div className="mt-4 space-y-4 text-sm text-muted-foreground">
<p className="leading-relaxed">{data.meta.methodology}</p>
<div>
<p className="font-medium text-foreground mb-2">What is excluded</p>
<ul className="list-disc pl-5 space-y-1">
{data.meta.exclusions.map((e) => (
<li key={e}>{e}</li>
))}
</ul>
</div>
<dl className="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2">
<div className="flex justify-between gap-4">
<dt>Fares priced at nationality</dt>
<dd className="text-foreground">{data.meta.nationalityAssumption}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Departure window</dt>
<dd className="text-foreground">
{formatDateTime(data.meta.dateFrom)} {formatDateTime(data.meta.dateTo)}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Schedules affected</dt>
<dd className="text-foreground tabular-nums">{data.meta.total}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Schedules with no fare on file</dt>
<dd className="text-foreground tabular-nums">
{data.meta.schedulesWithoutFare}
</dd>
</div>
</dl>
{data.meta.schedulesWithoutFare > 0 && (
<p className="text-xs">
{data.meta.schedulesWithoutFare} schedule
{data.meta.schedulesWithoutFare === 1 ? "" : "s"} could not be priced (no
route and no fare rules). Their blocked seats are counted, but carry no
monetary claim.
</p>
)}
</div>
)}
</div>
)}
</div>
);
}
// ── Schedule row + drill-down ───────────────────────────────────────────────
function ScheduleRow({
row,
expanded,
onToggle,
}: {
row: BlockedSeatLossSchedule;
expanded: boolean;
onToggle: () => void;
}) {
return (
<>
<tr
className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
onClick={onToggle}
>
<td className="px-4 py-3 whitespace-nowrap">
<span className="flex items-center gap-2">
{expanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<span className="font-semibold text-foreground">{row.trainNumber}</span>
<Badge variant="status" status={row.status}>
{row.status}
</Badge>
</span>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{row.originStation} {row.destinationStation}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{formatDateTime(row.departureAt)}
</td>
<td className="px-4 py-3 tabular-nums whitespace-nowrap">{row.blockedSeatCount}</td>
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground tabular-nums">
{row.loadFactorPercent}%{" "}
<span className="opacity-70">
({row.soldSeats}/{row.sellableSeats})
</span>
</td>
<td className="px-4 py-3 tabular-nums font-medium whitespace-nowrap">
{formatCurrency(row.estimatedLossMinor, row.currency)}
</td>
<td className="px-4 py-3 tabular-nums whitespace-nowrap text-muted-foreground">
{formatCurrency(row.adjustedLossMinor, row.currency)}
</td>
</tr>
{expanded && (
<tr>
<td colSpan={7} className="bg-gray-50/60 dark:bg-gray-800/40 px-4 py-4">
<BlockDetailTable blocks={row.blocks} />
</td>
</tr>
)}
</>
);
}
function BlockDetailTable({ blocks }: { blocks: BlockedSeatLossDetail[] }) {
const { paged, page, totalPages, setPage } = usePagination(blocks, 50);
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-[11px] uppercase tracking-wider text-muted-foreground">
{[
"Coach · Seat",
"Class",
"Scope",
"Reason",
"Blocked by",
"Approved by",
"Blocked at",
"Until",
"Days",
"Estimated loss",
].map((h) => (
<th key={h} className="px-3 py-2 text-left font-medium whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{paged.map((b) => (
<tr key={b.blockId}>
<td className="px-3 py-2 whitespace-nowrap font-medium text-foreground">
{b.coachNumber ?? "—"} · #{b.seatNumber ?? "—"}
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{b.seatClassName ?? "—"}
</td>
<td className="px-3 py-2 whitespace-nowrap">
<Badge>{b.blockType === "SCHEDULE" ? "Schedule" : "Global"}</Badge>
</td>
<td className="px-3 py-2 max-w-sm">
<span className="flex flex-col gap-1">
<Badge className="w-fit">{reasonLabel(b.reasonCategory)}</Badge>
<span className="text-muted-foreground break-words">{b.reason}</span>
</span>
</td>
<td className="px-3 py-2 whitespace-nowrap text-foreground">
{b.blockedByName ?? (b.blockedBy === "SYSTEM" ? "System" : "Unknown")}
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{b.approvedBy ?? "—"}
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{formatDateTime(b.blockedAt)}
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{b.stillBlocked ? (
<span className="text-amber-600 dark:text-amber-400">Still blocked</span>
) : (
formatDateTime(b.unblockAt)
)}
</td>
<td className="px-3 py-2 whitespace-nowrap tabular-nums text-muted-foreground">
{b.daysBlocked}
</td>
<td className="px-3 py-2 whitespace-nowrap tabular-nums font-medium text-foreground">
{formatCurrency(b.estimatedLossMinor, b.currency)}
</td>
</tr>
))}
</tbody>
</table>
{totalPages > 1 && (
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
)}
</div>
);
}

View File

@@ -9,6 +9,11 @@ import { PERMS } from '@/lib/permissions';
import Modal from '@/components/ui/Modal'; import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton' import ActionButton from '@/components/ui/ActionButton'
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench, Ticket as TicketIcon } from 'lucide-react'; import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench, Ticket as TicketIcon } from 'lucide-react';
import {
SEAT_BLOCK_REASON_CATEGORIES,
SEAT_BLOCK_REASON_CATEGORY_LABELS,
SeatBlockReasonCategory,
} from '@edr/types';
export default function SeatsPage() { export default function SeatsPage() {
const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route'); const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route');
@@ -19,9 +24,17 @@ export default function SeatsPage() {
const [showRemoveModal, setShowRemoveModal] = useState(false); const [showRemoveModal, setShowRemoveModal] = useState(false);
const [selectedSeat, setSelectedSeat] = useState<any>(null); const [selectedSeat, setSelectedSeat] = useState<any>(null);
const [blockReason, setBlockReason] = useState(''); const [blockReason, setBlockReason] = useState('');
// Reporting bucket for the block — drives the reason breakdown in the Blocked Seat
// Revenue Loss report. Free-text `reason` stays the operator's detail.
const [blockCategory, setBlockCategory] = useState<SeatBlockReasonCategory>(
SeatBlockReasonCategory.Other,
);
const [showBlockCoachModal, setShowBlockCoachModal] = useState(false); const [showBlockCoachModal, setShowBlockCoachModal] = useState(false);
const [selectedCoach, setSelectedCoach] = useState<any>(null); const [selectedCoach, setSelectedCoach] = useState<any>(null);
const [blockCoachReason, setBlockCoachReason] = useState(''); const [blockCoachReason, setBlockCoachReason] = useState('');
const [blockCoachCategory, setBlockCoachCategory] = useState<SeatBlockReasonCategory>(
SeatBlockReasonCategory.Other,
);
const [showUnblockCoachModal, setShowUnblockCoachModal] = useState(false); const [showUnblockCoachModal, setShowUnblockCoachModal] = useState(false);
const [coachToUnblock, setCoachToUnblock] = useState<any>(null); const [coachToUnblock, setCoachToUnblock] = useState<any>(null);
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false); const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
@@ -110,13 +123,14 @@ export default function SeatsPage() {
}; };
const blockMutation = useMutation({ const blockMutation = useMutation({
mutationFn: ({ seatId, reason }: any) => mutationFn: ({ seatId, reason, reasonCategory }: any) =>
seatsApi.block(seatId, { reason, ...(activeTab === 'schedule' && selectedSchedule ? { scheduleId: selectedSchedule } : {}) }), seatsApi.block(seatId, { reason, reasonCategory, ...(activeTab === 'schedule' && selectedSchedule ? { scheduleId: selectedSchedule } : {}) }),
onSuccess: () => { onSuccess: () => {
invalidateSeatData(); invalidateSeatData();
setShowBlockModal(false); setShowBlockModal(false);
setSelectedSeat(null); setSelectedSeat(null);
setBlockReason(''); setBlockReason('');
setBlockCategory(SeatBlockReasonCategory.Other);
}, },
}); });
@@ -186,17 +200,18 @@ export default function SeatsPage() {
const coaches = activeTab === 'schedule' ? (seatMapData?.coaches || []) : (Array.isArray(routeCoachesData) ? routeCoachesData : []); const coaches = activeTab === 'schedule' ? (seatMapData?.coaches || []) : (Array.isArray(routeCoachesData) ? routeCoachesData : []);
const blockCoachMutation = useMutation({ const blockCoachMutation = useMutation({
mutationFn: async ({ coachId, reason }: any) => { mutationFn: async ({ coachId, reason, reasonCategory }: any) => {
const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || []; const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || [];
const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id); const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id);
const scheduleId = activeTab === 'schedule' ? selectedSchedule : undefined; const scheduleId = activeTab === 'schedule' ? selectedSchedule : undefined;
return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason, ...(scheduleId ? { scheduleId } : {}) }))); return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason, reasonCategory, ...(scheduleId ? { scheduleId } : {}) })));
}, },
onSuccess: () => { onSuccess: () => {
invalidateSeatData(); invalidateSeatData();
setShowBlockCoachModal(false); setShowBlockCoachModal(false);
setSelectedCoach(null); setSelectedCoach(null);
setBlockCoachReason(''); setBlockCoachReason('');
setBlockCoachCategory(SeatBlockReasonCategory.Other);
}, },
}); });
@@ -356,7 +371,7 @@ export default function SeatsPage() {
alert('Please provide a reason for blocking'); alert('Please provide a reason for blocking');
return; return;
} }
await blockCoachMutation.mutateAsync({ coachId: selectedCoach.id, reason: blockCoachReason }); await blockCoachMutation.mutateAsync({ coachId: selectedCoach.id, reason: blockCoachReason, reasonCategory: blockCoachCategory });
}; };
const submitBlock = async () => { const submitBlock = async () => {
@@ -364,7 +379,7 @@ export default function SeatsPage() {
alert('Please provide a reason for the reservation'); alert('Please provide a reason for the reservation');
return; return;
} }
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason }); await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason, reasonCategory: blockCategory });
}; };
const submitRemoveSeat = async () => { const submitRemoveSeat = async () => {
@@ -874,6 +889,23 @@ export default function SeatsPage() {
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Reserve seat <strong>{selectedSeat?.seatNumber}</strong> in Coach <strong>{selectedSeat?.coach?.coachNumber}</strong> Reserve seat <strong>{selectedSeat?.seatNumber}</strong> in Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
</p> </p>
<div>
<label className="label">Reason Category</label>
<select
className="input"
value={blockCategory}
onChange={(e) => setBlockCategory(e.target.value as SeatBlockReasonCategory)}
>
{SEAT_BLOCK_REASON_CATEGORIES.map((c) => (
<option key={c} value={c}>
{SEAT_BLOCK_REASON_CATEGORY_LABELS[c]}
</option>
))}
</select>
<p className="text-xs text-muted-foreground mt-1">
Groups this block in the Blocked Seat Revenue Loss report.
</p>
</div>
<div> <div>
<label className="label">Reason for Reservation *</label> <label className="label">Reason for Reservation *</label>
<textarea <textarea
@@ -1150,6 +1182,20 @@ export default function SeatsPage() {
This will block all {selectedCoach?.seats?.length || 0} seats in this coach. This will block all {selectedCoach?.seats?.length || 0} seats in this coach.
</p> </p>
</div> </div>
<div>
<label className="label">Reason Category</label>
<select
className="input"
value={blockCoachCategory}
onChange={(e) => setBlockCoachCategory(e.target.value as SeatBlockReasonCategory)}
>
{SEAT_BLOCK_REASON_CATEGORIES.map((c) => (
<option key={c} value={c}>
{SEAT_BLOCK_REASON_CATEGORY_LABELS[c]}
</option>
))}
</select>
</div>
<div> <div>
<label className="label">Reason for Blocking *</label> <label className="label">Reason for Blocking *</label>
<textarea <textarea

View File

@@ -33,6 +33,7 @@ import {
Moon, Moon,
Sun, Sun,
Armchair, Armchair,
Ban,
Grid3x3, Grid3x3,
Banknote, Banknote,
Activity, Activity,
@@ -123,6 +124,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
items: [ items: [
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view }, { name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view }, { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view },
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view }, { name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
{ name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: PERMS.reports.view }, { name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: PERMS.reports.view },
{ name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view }, { name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view },

View File

@@ -0,0 +1,54 @@
import { apiClient } from '@/lib/api-client';
import type { BlockedSeatRevenueLossReport } from '@edr/types';
/** One entry of `GET /reports/schedules`. */
export interface ScheduleOption {
id: string;
label: string;
departureAt: string;
isPackage: boolean;
}
/** Every filter the report accepts. Empty strings are dropped before the request. */
export interface BlockedSeatsLossFilters {
dateFrom?: string;
dateTo?: string;
scheduleId?: string;
routeId?: string;
trainId?: string;
coachId?: string;
reasonCategory?: string;
blockedBy?: string;
nationality?: string;
page?: number;
pageSize?: number;
sortBy?: string;
}
/** Serializes filters, omitting blanks so the API applies its own defaults. */
export function toQueryString(filters: BlockedSeatsLossFilters): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (value === undefined || value === null || value === '') continue;
params.set(key, String(value));
}
return params.toString();
}
export const blockedSeatsLossApi = {
getReport: (filters: BlockedSeatsLossFilters) =>
apiClient.get<BlockedSeatRevenueLossReport>(
`/reports/blocked-seats-revenue-loss?${toQueryString(filters)}`,
),
getSchedules: () => apiClient.get<ScheduleOption[]>('/reports/schedules?all=true'),
/**
* CSV export. `getRaw` because the endpoint streams a bare CSV body with no
* `{ success, data }` envelope for `get` to unwrap.
*/
exportCsv: (filters: BlockedSeatsLossFilters) =>
apiClient.getRaw<string>(
`/reports/blocked-seats-revenue-loss/export?${toQueryString(filters)}`,
),
};

View File

@@ -1,4 +1,5 @@
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import type { BlockedSeatRevenueLossStat } from '@edr/types';
import { DashboardStats, RevenueData } from '@/types'; import { DashboardStats, RevenueData } from '@/types';
export const dashboardApi = { export const dashboardApi = {
@@ -12,6 +13,7 @@ export const dashboardApi = {
totalPackageTickets: number; totalPackageTickets: number;
totalPassengers: number; totalPassengers: number;
blockedSeatsCount: number; blockedSeatsCount: number;
blockedSeatRevenueLoss: BlockedSeatRevenueLossStat;
revenueByCurrency: { currency: string; totalMinor: number }[]; revenueByCurrency: { currency: string; totalMinor: number }[];
packageRevenueByCurrency: { currency: string; totalMinor: number }[]; packageRevenueByCurrency: { currency: string; totalMinor: number }[];
}>('/dashboard/backoffice-stats'); }>('/dashboard/backoffice-stats');

View File

@@ -0,0 +1,66 @@
/**
* Chart palette.
*
* Categorical slots are assigned in fixed order and never cycled — a series keeps its
* hue when a filter removes its neighbours. Both modes are separately stepped for their
* own surface, not an automatic flip of the light values.
*
* Validated against this app's card surfaces (light `#ffffff`, dark `#0f1729`) with the
* dataviz six-check validator, six slots, adjacent pairlist:
* light — CVD ΔE 9.1, normal-vision ΔE 19.6, contrast WARN on aqua/yellow/magenta
* dark — CVD ΔE 8.4, normal-vision ΔE 19.3, contrast all ≥ 3:1
* The light-mode contrast WARN obliges *relief*: every chart using these slots ships a
* legend with visible text labels and a table view of the same numbers.
*/
export interface ChartPalette {
/** Categorical slots, in fixed assignment order. */
categorical: readonly string[];
/** Single hue for magnitude — one colour for every bar in a one-series chart. */
sequential: string;
/** Recessive chrome. */
grid: string;
axis: string;
/** Text tokens — labels never wear the data colour. */
textMuted: string;
/** Surface, for the 2px gaps and rings that separate marks. */
surface: string;
tooltipBg: string;
tooltipBorder: string;
}
const LIGHT: ChartPalette = {
categorical: ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300'],
sequential: '#2a78d6',
grid: '#e1e0d9',
axis: '#c3c2b7',
textMuted: '#898781',
surface: '#ffffff',
tooltipBg: '#ffffff',
tooltipBorder: 'rgba(11,11,11,0.10)',
};
const DARK: ChartPalette = {
categorical: ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300'],
sequential: '#3987e5',
grid: '#2c2c2a',
axis: '#383835',
textMuted: '#898781',
surface: '#0f1729',
tooltipBg: '#0f1729',
tooltipBorder: 'rgba(255,255,255,0.10)',
};
export function getChartPalette(isDark: boolean): ChartPalette {
return isDark ? DARK : LIGHT;
}
/**
* Colour for a categorical member, keyed by its position in a **stable** ordering of the
* whole domain — never by its rank in the current filtered view, so filtering does not
* repaint the survivors. Past the last slot everything folds into one neutral bucket
* rather than inventing a hue no CVD check would pass.
*/
export function categoricalColor(palette: ChartPalette, index: number): string {
return palette.categorical[index] ?? palette.textMuted;
}

View File

@@ -727,11 +727,21 @@ export default function SearchPage() {
const disabledDates = useMemo(() => { const disabledDates = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
// routeExists === false is handled by disabling the date control outright
// (noRouteForPair below), not by enumerating dates — the server returns an
// empty `dates` array in that case anyway.
if (!availableDates?.routeExists) return set; if (!availableDates?.routeExists) return set;
for (const d of availableDates.dates) if (!d.available) set.add(d.date); for (const d of availableDates.dates) if (!d.available) set.add(d.date);
return set; return set;
}, [availableDates]); }, [availableDates]);
// No route connects the chosen From/To, so no date could ever produce a trip. The date
// picker is disabled outright rather than left selectable: graying out individual days
// would imply the date is the problem when the station pair is, and letting someone pick
// a date only to fail at submit wastes the interaction.
const noRouteForPair =
!!originId && !!destId && availableDates?.routeExists === false;
// /search/available-dates only ever reports on a bounded window (server-clamped to 90 days — // /search/available-dates only ever reports on a bounded window (server-clamped to 90 days —
// see AVAILABLE_DATES_RANGE_DAYS). disabledDates alone can't gray out anything past that // see AVAILABLE_DATES_RANGE_DAYS). disabledDates alone can't gray out anything past that
// window (it was simply never fetched, not confirmed available), so once a route is picked // window (it was simply never fetched, not confirmed available), so once a route is picked
@@ -758,6 +768,15 @@ export default function SearchPage() {
} }
}, [departureDate, disabledDates, setValue, setError]); }, [departureDate, disabledDates, setValue, setError]);
// Losing the route invalidates any date already chosen — clear both legs so a stale value
// cannot be submitted from behind a now-disabled control.
useEffect(() => {
if (noRouteForPair && departureDate) {
setValue("departureDate", "");
clearErrors("departureDate");
}
}, [noRouteForPair, departureDate, setValue, clearErrors]);
// Same idea as the departure-date availability above, but for the return leg — which travels // Same idea as the departure-date availability above, but for the return leg — which travels
// destination -> origin, the reverse pair. Only fetched for round trips once both stations are // destination -> origin, the reverse pair. Only fetched for round trips once both stations are
// picked; deliberately unaware of which specific outbound schedule will end up chosen (that's // picked; deliberately unaware of which specific outbound schedule will end up chosen (that's
@@ -789,6 +808,14 @@ export default function SearchPage() {
return set; return set;
}, [returnAvailableDates]); }, [returnAvailableDates]);
// The return leg travels destination → origin, so it has its own route-existence answer:
// a one-way route (A→C with no C→A) leaves the outbound date pickable but the return not.
const noReturnRouteForPair =
tripType === "ROUND_TRIP" &&
!!originId &&
!!destId &&
returnAvailableDates?.routeExists === false;
const returnMaxDate = originId && destId && tripType === "ROUND_TRIP" ? maxSearchDate : undefined; const returnMaxDate = originId && destId && tripType === "ROUND_TRIP" ? maxSearchDate : undefined;
// If the currently selected return date becomes unavailable (From/To changed, or the // If the currently selected return date becomes unavailable (From/To changed, or the
@@ -805,6 +832,13 @@ export default function SearchPage() {
} }
}, [returnDate, returnDisabledDates, setValue, setError]); }, [returnDate, returnDisabledDates, setValue, setError]);
useEffect(() => {
if (noReturnRouteForPair && returnDate) {
setValue("returnDate", "");
clearErrors("returnDate");
}
}, [noReturnRouteForPair, returnDate, setValue, clearErrors]);
const saveRecent = useCallback((id: string) => { const saveRecent = useCallback((id: string) => {
setRecentStationIds((prev) => { setRecentStationIds((prev) => {
const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5); const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5);
@@ -1242,10 +1276,19 @@ export default function SearchPage() {
minDate={new Date()} minDate={new Date()}
maxDate={departureMaxDate} maxDate={departureMaxDate}
disabledDates={disabledDates} disabledDates={disabledDates}
disabled={noRouteForPair}
placeholder="Departure date" placeholder="Departure date"
error={!!errors.departureDate} error={!!errors.departureDate}
/> />
</div> </div>
{noRouteForPair && (
<p
data-testid="no-route-notice"
className="text-xs text-amber-600 dark:text-amber-400"
>
No route connects these stations pick a different destination.
</p>
)}
{errors.departureDate && ( {errors.departureDate && (
<p className="text-xs text-red-500"> <p className="text-xs text-red-500">
{errors.departureDate.message} {errors.departureDate.message}
@@ -1278,11 +1321,20 @@ export default function SearchPage() {
} }
maxDate={returnMaxDate} maxDate={returnMaxDate}
disabledDates={returnDisabledDates} disabledDates={returnDisabledDates}
disabled={noReturnRouteForPair}
placeholder="Return date" placeholder="Return date"
error={!!errors.returnDate} error={!!errors.returnDate}
/> />
</div> </div>
{errors.returnDate && ( {noReturnRouteForPair && (
<p
data-testid="no-return-route-notice"
className="text-xs text-amber-600 dark:text-amber-400"
>
No return route from this destination.
</p>
)}
{errors.returnDate && (
<p className="text-xs text-red-500"> <p className="text-xs text-red-500">
{errors.returnDate.message} {errors.returnDate.message}
</p> </p>
@@ -1436,11 +1488,20 @@ export default function SearchPage() {
minDate={new Date()} minDate={new Date()}
maxDate={departureMaxDate} maxDate={departureMaxDate}
disabledDates={disabledDates} disabledDates={disabledDates}
disabled={noRouteForPair}
placeholder="Departure" placeholder="Departure"
error={!!errors.departureDate} error={!!errors.departureDate}
/> />
</div> </div>
{errors.departureDate && ( {noRouteForPair && (
<p
data-testid="no-route-notice"
className="text-xs text-amber-600 dark:text-amber-400"
>
No route connects these stations pick a different destination.
</p>
)}
{errors.departureDate && (
<p className="text-xs text-red-500"> <p className="text-xs text-red-500">
{errors.departureDate.message} {errors.departureDate.message}
</p> </p>
@@ -1592,9 +1653,18 @@ export default function SearchPage() {
minDate={new Date()} minDate={new Date()}
maxDate={departureMaxDate} maxDate={departureMaxDate}
disabledDates={disabledDates} disabledDates={disabledDates}
disabled={noRouteForPair}
placeholder="Departure date" placeholder="Departure date"
/> />
{errors.departureDate && ( {noRouteForPair && (
<p
data-testid="no-route-notice"
className="text-xs text-amber-600 dark:text-amber-400"
>
No route connects these stations pick a different destination.
</p>
)}
{errors.departureDate && (
<p className="text-xs text-red-500"> <p className="text-xs text-red-500">
{errors.departureDate.message} {errors.departureDate.message}
</p> </p>
@@ -1625,9 +1695,18 @@ export default function SearchPage() {
} }
maxDate={returnMaxDate} maxDate={returnMaxDate}
disabledDates={returnDisabledDates} disabledDates={returnDisabledDates}
disabled={noReturnRouteForPair}
placeholder="Return date" placeholder="Return date"
/> />
{errors.returnDate && ( {noReturnRouteForPair && (
<p
data-testid="no-return-route-notice"
className="text-xs text-amber-600 dark:text-amber-400"
>
No return route from this destination.
</p>
)}
{errors.returnDate && (
<p className="text-xs text-red-500"> <p className="text-xs text-red-500">
{errors.returnDate.message} {errors.returnDate.message}
</p> </p>

View File

@@ -21,6 +21,12 @@ interface ModernDatePickerProps {
// Dates with no bookable schedule for the selected route (YYYY-MM-DD keys) — disabled // Dates with no bookable schedule for the selected route (YYYY-MM-DD keys) — disabled
// alongside the minDate/maxDate range, not just clamping it. // alongside the minDate/maxDate range, not just clamping it.
disabledDates?: Set<string>; disabledDates?: Set<string>;
/**
* Disables the whole control — the calendar cannot be opened at all. Used when no route
* exists between the selected stations, where every date is unselectable and graying out
* individual days would be misleading (the problem is the route, not the date).
*/
disabled?: boolean;
placeholder?: string; placeholder?: string;
error?: boolean; error?: boolean;
} }
@@ -38,6 +44,7 @@ export default function ModernDatePicker({
minDate, minDate,
maxDate, maxDate,
disabledDates, disabledDates,
disabled = false,
placeholder = 'Select date', placeholder = 'Select date',
error = false, error = false,
}: ModernDatePickerProps) { }: ModernDatePickerProps) {
@@ -52,6 +59,13 @@ export default function ModernDatePicker({
const [ethViewYear, setEthViewYear] = useState(initialEthDate.year); const [ethViewYear, setEthViewYear] = useState(initialEthDate.year);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// If the control becomes disabled while the calendar is open (e.g. the user changes
// the destination to one with no route), close it rather than leaving a live calendar
// floating over a disabled field.
useEffect(() => {
if (disabled) setIsOpen(false);
}, [disabled]);
useEffect(() => { useEffect(() => {
const check = () => setIsMobileView(window.innerWidth < 768); const check = () => setIsMobileView(window.innerWidth < 768);
check(); check();
@@ -297,8 +311,10 @@ export default function ModernDatePicker({
{/* Trigger button */} {/* Trigger button */}
<button <button
type="button" type="button"
onClick={() => setIsOpen(true)} onClick={() => !disabled && setIsOpen(true)}
className={`w-full min-w-0 px-2.5 sm:px-3.5 py-3.5 border-2 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-left flex items-center justify-between gap-1.5 bg-white dark:bg-gray-800 transition-all group ${ disabled={disabled}
aria-disabled={disabled}
className={`w-full min-w-0 px-2.5 sm:px-3.5 py-3.5 border-2 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-left flex items-center justify-between gap-1.5 bg-white dark:bg-gray-800 transition-all group disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:border-gray-200 dark:disabled:hover:border-gray-700 ${
error error
? 'border-red-400 hover:border-red-400' ? 'border-red-400 hover:border-red-400'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'

View File

@@ -195,11 +195,20 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
const disabledDates = useMemo(() => { const disabledDates = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
if (!availableDates) return set; if (!availableDates) return set;
if (!availableDates.routeExists) return set; // no route → don't blanket-disable every date, the submit-time error already covers this // routeExists === false is handled by disabling the whole control (noRouteForPair below)
// rather than by enumerating every date as unavailable — the server returns an empty
// `dates` array in that case anyway.
if (!availableDates.routeExists) return set;
for (const d of availableDates.dates) if (!d.available) set.add(d.date); for (const d of availableDates.dates) if (!d.available) set.add(d.date);
return set; return set;
}, [availableDates]); }, [availableDates]);
// No route connects the chosen stations, so no date could ever yield a trip. The date
// field is disabled outright: letting someone pick a date and only telling them after
// they submit wastes the interaction, and graying out individual days would imply the
// date is the problem when the station pair is.
const noRouteForPair = !!originId && !!destinationId && availableDates?.routeExists === false;
// /search/available-dates only ever reports on a bounded window (server-clamped to 90 days — // /search/available-dates only ever reports on a bounded window (server-clamped to 90 days —
// see AVAILABLE_DATES_RANGE_DAYS). disabledDates alone can't gray out anything past that // see AVAILABLE_DATES_RANGE_DAYS). disabledDates alone can't gray out anything past that
// window (it was simply never fetched, not confirmed available), so once a route is picked // window (it was simply never fetched, not confirmed available), so once a route is picked
@@ -225,6 +234,15 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
} }
}, [departureDate, disabledDates, setValue, setError]); }, [departureDate, disabledDates, setValue, setError]);
// Losing the route invalidates any date already chosen — clear it so a stale value can't
// be submitted from behind the now-disabled control.
useEffect(() => {
if (noRouteForPair && departureDate) {
setValue('departureDate', '');
clearErrors('departureDate');
}
}, [noRouteForPair, departureDate, setValue, clearErrors]);
const onSubmit = (data: SearchForm) => { const onSubmit = (data: SearchForm) => {
setSearchCriteria({ ...data }); setSearchCriteria({ ...data });
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -283,18 +301,29 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
{/* Date */} {/* Date */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Date</label> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Date</label>
<ModernDatePicker <div data-testid="departure-date-field">
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined} <ModernDatePicker
onChange={(date) => { value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
setValue('departureDate', toDateStr(date)); onChange={(date) => {
clearErrors('departureDate'); setValue('departureDate', toDateStr(date));
}} clearErrors('departureDate');
minDate={new Date()} }}
maxDate={departureMaxDate} minDate={new Date()}
disabledDates={disabledDates} maxDate={departureMaxDate}
placeholder="Select date" disabledDates={disabledDates}
/> disabled={noRouteForPair}
{errors.departureDate && ( placeholder={noRouteForPair ? 'No route available' : 'Select date'}
/>
</div>
{noRouteForPair && (
<p
data-testid="no-route-notice"
className="text-amber-600 dark:text-amber-400 text-sm"
>
No route connects these stations pick a different destination.
</p>
)}
{!noRouteForPair && errors.departureDate && (
<p className="text-red-600 dark:text-red-400 text-sm">{errors.departureDate.message}</p> <p className="text-red-600 dark:text-red-400 text-sm">{errors.departureDate.message}</p>
)} )}
</div> </div>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,136 @@
import { test, expect, type Page } from "@playwright/test";
import { API_URL, STATIONS, staffToken } from "../../fixtures/data";
/**
* Search form — date selectability is driven by whether a route actually exists.
*
* Two behaviours, both backed by GET /search/available-dates:
*
* 1. No route between the selected From/To → the Date control is DISABLED outright and an
* explanation is shown. Letting someone pick a date and only failing at submit wastes the
* interaction, and graying out individual days would imply the date is the problem when
* the station pair is.
* 2. A route exists → the control is enabled, and only the days with no bookable schedule
* are individually disabled inside the calendar.
*
* The form prefills From/To from the URL query (see booking/search/page.tsx), so these specs set
* the pair deterministically instead of driving the station modal across three layout variants.
*
* Runs in the `guest` project — the search form needs no authentication.
*/
/** A station on no route at all — created per-run so the shared seed stays untouched. */
async function createOrphanStation(request: Page["request"]): Promise<string> {
const res = await request.post(`${API_URL}/stations`, {
headers: { Authorization: `Bearer ${staffToken()}` },
data: {
code: `ORP${Date.now().toString().slice(-4)}`,
name: `Orphan ${Date.now()}`,
city: "Nowhere",
},
});
expect(res.ok(), `station create failed: ${res.status()} ${await res.text()}`).toBeTruthy();
const body = await res.json();
return body?.data?.id ?? body?.id;
}
function searchUrl(origin: string, destination: string): string {
return `/booking/search?origin=${origin}&destination=${destination}`;
}
/** The departure-date trigger. Several layout variants exist; only one is visible at a time. */
function dateTrigger(page: Page) {
return page
.locator("button")
.filter({ hasText: /^(Departure date|Departure|Select date|Date)$/i })
.first();
}
test.describe("search date availability", () => {
test("no route between the stations disables the date picker", async ({ page, request }) => {
const orphanId = await createOrphanStation(request);
await page.goto(searchUrl(STATIONS.A, orphanId), { waitUntil: "domcontentloaded" });
// The notice only renders once /search/available-dates has answered routeExists:false.
await expect(page.getByTestId("no-route-notice").first()).toBeVisible({ timeout: 20_000 });
await expect(dateTrigger(page)).toBeDisabled();
});
test("a disabled date picker cannot be opened", async ({ page, request }) => {
const orphanId = await createOrphanStation(request);
await page.goto(searchUrl(STATIONS.A, orphanId), { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("no-route-notice").first()).toBeVisible({ timeout: 20_000 });
// force:true bypasses Playwright's own actionability guard, so this asserts the app
// really refuses to open — not merely that the button looks unclickable.
await dateTrigger(page).click({ force: true }).catch(() => {});
await page.waitForTimeout(500);
await expect(page.getByRole("button", { name: /^\d{1,2}$/ })).toHaveCount(0);
});
test("a route that exists leaves the date picker enabled and openable", async ({ page }) => {
await page.goto(searchUrl(STATIONS.A, STATIONS.C), { waitUntil: "domcontentloaded" });
// Give the availability query the same chance to resolve as the no-route case gets.
await page.waitForTimeout(3_000);
await expect(page.getByTestId("no-route-notice")).toHaveCount(0);
const trigger = dateTrigger(page);
await expect(trigger).toBeEnabled();
await trigger.click();
// Calendar day cells confirm it actually opened.
await expect(page.getByRole("button", { name: /^\d{1,2}$/ }).first()).toBeVisible({
timeout: 10_000,
});
});
test("dates with no bookable schedule are individually disabled", async ({ page }) => {
await page.goto(searchUrl(STATIONS.A, STATIONS.C), { waitUntil: "domcontentloaded" });
await page.waitForTimeout(3_000);
await dateTrigger(page).click();
const dayCells = page.getByRole("button", { name: /^\d{1,2}$/ });
await expect(dayCells.first()).toBeVisible({ timeout: 10_000 });
// The seed creates exactly one bookable departure in the window, so the calendar must
// contain both enabled and disabled days — never all-enabled (which would mean the
// availability data was ignored) and never all-disabled (which would mean it was
// misapplied to a route that does have a trip).
const total = await dayCells.count();
let disabled = 0;
for (let i = 0; i < total; i++) {
if (await dayCells.nth(i).isDisabled()) disabled++;
}
expect(total).toBeGreaterThan(0);
expect(disabled).toBeGreaterThan(0);
expect(disabled).toBeLessThan(total);
});
test("switching to a routeless destination clears an already-chosen date", async ({
page,
request,
}) => {
const orphanId = await createOrphanStation(request);
// Start on a valid pair with a date already in the URL, so a value is definitely set.
const withDate = new Date();
withDate.setDate(withDate.getDate() + 2);
const dateStr = withDate.toISOString().slice(0, 10);
await page.goto(`${searchUrl(STATIONS.A, STATIONS.C)}&date=${dateStr}`, {
waitUntil: "domcontentloaded",
});
await page.waitForTimeout(2_500);
// Now navigate to the routeless pair — the stale date must not survive behind the
// disabled control, or a doomed search could still be submitted.
await page.goto(searchUrl(STATIONS.A, orphanId), { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("no-route-notice").first()).toBeVisible({ timeout: 20_000 });
const trigger = dateTrigger(page);
await expect(trigger).toBeDisabled();
// Placeholder text (not a formatted date) proves the value was cleared.
await expect(trigger).not.toContainText(/\d{4}/);
});
});

View File

@@ -2,5 +2,9 @@ export * from "./common/index";
export * from "./freight/index"; export * from "./freight/index";
export * as Freight from "./freight/index"; export * as Freight from "./freight/index";
export * as Passenger from "./passenger/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 type { PaymentEvent, PaymentEventType, PaymentFailedEvent, PaymentSucceededEvent, PaymentIntentSnapshot, InitiatePaymentRequest } from "./common/payments";
export { PaymentReferenceType, PaymentService } 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"; import type { BaseEntity } from "../common";
export * from "./support-chat"; export * from "./support-chat";
export * from "./blocked-seat-revenue-loss";
export enum TicketStatus { export enum TicketStatus {
Reserved = "RESERVED", Reserved = "RESERVED",