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

@@ -1,6 +1,13 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
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 {}

View File

@@ -1,17 +1,34 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { BlockedSeatRevenueLossStat } from '@edr/types';
import { PrismaService } from '../../common/prisma.service';
import { ReportsService } from '../reports/reports.service';
/** Window the dashboard's blocked-seat loss roll-up covers. Matches the report's default. */
const BLOCKED_SEAT_LOSS_PERIOD_DAYS = 30;
/** Shown when nothing is blocked, or when the loss roll-up could not be computed. */
const EMPTY_BLOCKED_SEAT_LOSS: BlockedSeatRevenueLossStat = {
periodDays: BLOCKED_SEAT_LOSS_PERIOD_DAYS,
lossByCurrency: [],
schedulesAffected: 0,
blockedSeatCount: 0,
topReasonCategory: null,
};
@Injectable()
export class DashboardService {
private readonly logger = new Logger(DashboardService.name);
constructor(
private prisma: PrismaService,
@InjectDataSource() private dataSource: DataSource,
private reports: ReportsService,
) {}
async getBackofficeStats() {
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] =
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows, blockedSeatRevenueLoss] =
await Promise.all([
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }),
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')
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({
@@ -58,11 +78,43 @@ export class DashboardService {
totalNormalTickets: totalTickets - totalPackageTickets,
totalPassengers,
blockedSeatsCount,
blockedSeatRevenueLoss,
revenueByCurrency: toMap(revenueRows),
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) {
const now = new Date();
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 { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
import { Body, Controller, Get, Param, Post, Query, Res } from "@nestjs/common";
import type { Response } from "express";
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiOkResponse,
ApiProduces,
} from "@nestjs/swagger";
import { ReportsService } from "./reports.service";
import { GenerateReportDto } from "./reports.dto";
import { BlockedSeatsRevenueLossQueryDto, GenerateReportDto } from "./reports.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@@ -76,6 +83,54 @@ export class ReportsController {
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")
@ApiOperation({ summary: "Get report by ID" })
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 { SeatBlockReasonCategory } from '../seats/seats.dto';
export enum ReportType {
REVENUE = 'REVENUE',
@@ -27,3 +29,77 @@ export class ExportReportDto {
@ApiProperty() @IsString() reportId: string;
@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 { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
import { FareEngineModule } from '../fare-engine/fare-engine.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],
providers: [ReportsService],
exports: [ReportsService]

View File

@@ -1,8 +1,105 @@
import { Injectable, Logger } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
import {
BlockedSeatRevenueLossReport,
UNCATEGORIZED_REASON_CATEGORY,
} from "@edr/types";
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()
export class ReportsService {
@@ -10,6 +107,7 @@ export class ReportsService {
constructor(
private prisma: PrismaService,
@InjectDataSource() private dataSource: DataSource,
private fareEngine: FareEngineService,
) {}
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) {
return this.prisma.operationalReport.findUnique({
where: { id: reportId },

View File

@@ -442,13 +442,47 @@ export class SearchService {
where: { active: true, stops: { some: { stationId: originStationId } } },
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 d = r.stops.find((s) => s.stationId === destinationStationId);
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. */
private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean {
return (

View File

@@ -7,6 +7,7 @@ import {
Post,
Patch,
Query,
Req,
SetMetadata,
UseGuards,
} from "@nestjs/common";
@@ -20,7 +21,8 @@ import {
ApiBody,
} from "@nestjs/swagger";
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 { JwtGuard } from "../../common/jwt.guard";
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")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@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" })
@ApiBody({ type: BlockSeatDto })
@ApiResponse({ status: 200, description: "Seat blocked" })
blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string; scheduleId?: string }) {
return this.service.blockSeat(seatId, body.reason, body.scheduleId);
blockSeat(
@Param("seatId") seatId: string,
@Body() body: BlockSeatDto,
@Req() req: RequestWithActingUser,
) {
return this.service.blockSeat(seatId, body, resolveActingUser(req));
}
@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")
@ApiOperation({ summary: "Set seat status to Under Maintenance" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@ApiBody({ type: SetMaintenanceDto })
@ApiResponse({ status: 200, description: "Seat set to under maintenance" })
setMaintenance(@Param("seatId") seatId: string, @Body() body: { reason: string }) {
return this.service.setMaintenance(seatId, body.reason);
setMaintenance(
@Param("seatId") seatId: string,
@Body() body: SetMaintenanceDto,
@Req() req: RequestWithActingUser,
) {
return this.service.setMaintenance(seatId, body.reason, resolveActingUser(req));
}
@Delete(":seatId/maintenance")

View File

@@ -53,3 +53,43 @@ export class ReleaseHoldDto {
@ApiProperty({ example: 'hold-uuid', description: 'SeatHold UUID to release' })
@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 { 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 { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
@@ -696,7 +697,10 @@ export class SeatsService {
coachNumber: b.seat.coach.number,
scheduleId: b.scheduleId,
reason: b.reason,
// Blocks written before the reason-category column existed report as uncategorized.
reasonCategory: b.reasonCategory,
blockedBy: b.blockedBy,
blockedByName: b.blockedByName,
blockedAt: b.blockedAt,
unblockAt: b.unblockAt,
}));
@@ -898,20 +902,42 @@ export class SeatsService {
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 } });
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
// Global block (no scheduleId): sets Seat.status = BLOCKED for all schedules
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 {
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 } });
return { blocked: true, seatId, reason, scheduleId };
await this.auditService.log({
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) {
@@ -928,12 +954,20 @@ export class SeatsService {
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 } });
if (!seat) throw new NotFoundException('Seat not found');
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.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 };
}