Files
edr-platform/apps/edr-passenger-api/src/modules/reports/blocked-seats-loss.calculator.spec.ts
2026-08-02 23:08:15 +03:00

642 lines
22 KiB
TypeScript

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
});
});
});