mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
Adding seat blocking revenue loss dashboard
This commit is contained in:
@@ -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 {}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user