Merge pull request #1276 from Tria-plc/alpha

feat: ( reports ) add fleet seat overview to seat status landing page
This commit is contained in:
Abubeker Yasin
2026-08-13 15:34:25 +03:00
committed by GitHub
4 changed files with 872 additions and 7 deletions

View File

@@ -60,6 +60,23 @@ export class ReportsController {
return this.service.getSeatStatusReport(scheduleId);
}
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
@Get("seat-status/overview")
@ApiOperation({
summary: "Fleet-wide seat status across a departure window",
description:
"Landing view for the seat status report, shown before a schedule is picked. Returns the same four " +
"counters as the per-schedule report (paid, unpaid, expired holds, blocked) rolled up over a window of " +
"departures, plus per-day buckets and one row per schedule.\n\n" +
"The window is forward-looking — the next `days` days. If nothing is departing in that window, it falls " +
"back to the most recent `days` of departures on record and says so via `window.direction`.\n\n" +
"Counts apply the same rules as `GET /reports/seat-status`, so a schedule's row here equals what the " +
"drill-down shows after selecting it.",
})
getSeatStatusOverview(@Query('days') days?: string) {
return this.service.getSeatStatusOverview(days ? Number(days) : undefined);
}
@Get("boarding")
@ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" })
getBoardingReport(@Query('scheduleId') scheduleId: string) {

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
import { BookingStatus } from "@prisma/client";
import {
BlockedSeatRevenueLossReport,
UNCATEGORIZED_REASON_CATEGORY,
@@ -37,6 +38,43 @@ const FARE_QUOTE_CONCURRENCY = 4;
/** CSV export is not paginated, but still needs an upper bound. */
const CSV_EXPORT_MAX_SCHEDULES = 5000;
// ── Fleet seat-status overview (landing view of the seat status report) ──────
const MS_PER_DAY_OVERVIEW = 24 * 60 * 60 * 1000;
const OVERVIEW_DEFAULT_DAYS = 7;
const OVERVIEW_MAX_DAYS = 31;
/** Upper bound on schedules charted at once. Signalled back as `window.truncated`. */
const OVERVIEW_MAX_SCHEDULES = 60;
/** The booking statuses that put a seat on a schedule — same set as the drill-down. */
const OVERVIEW_ACTIVE_BOOKING_STATUSES: BookingStatus[] = [
'CONFIRMED',
'BOARDED',
'PENDING_PAYMENT',
];
const EMPTY_OVERVIEW_TOTALS = {
scheduleCount: 0,
sellableSeats: 0,
paidCount: 0,
unpaidCount: 0,
expiredHoldCount: 0,
blockedCount: 0,
availableCount: 0,
loadFactorPercent: 0,
};
function emptyDayBucket(date: string) {
return {
date,
scheduleCount: 0,
sellableSeats: 0,
paid: 0,
unpaid: 0,
expiredHolds: 0,
blocked: 0,
available: 0,
};
}
const EMPTY_LOSS_INPUT: LossCalculatorInput = {
schedules: [],
seatsById: new Map(),
@@ -757,6 +795,349 @@ export class ReportsService {
};
}
/**
* Fleet-wide seat status across a departure window — the landing view for the seat
* status report, shown before a schedule is picked.
*
* Deliberately a separate method from {@link getSeatStatusReport}: that one answers
* "this schedule, row by row" and its response shape is consumed by the drill-down UI.
* This one answers "the whole window, counts only". They share no code path, but they
* *do* share predicates — every filter below is the same rule the drill-down applies
* (the three-branch seat `OR`, the dining-coach exclusion, the counted-block
* resolution), so a schedule's row here always equals what you see after clicking it.
* Change one and the other must change with it.
*/
async getSeatStatusOverview(daysRaw?: number) {
const days = Math.min(
Math.max(Math.trunc(daysRaw || OVERVIEW_DEFAULT_DAYS), 1),
OVERVIEW_MAX_DAYS,
);
const now = new Date();
// Forward-looking by default. But a database whose schedules are all in the past
// would render an empty chart, which reads as a broken page rather than an honest
// "nothing departing" — so fall back to the most recent window that has departures.
let from = now;
let to = new Date(now.getTime() + days * MS_PER_DAY_OVERVIEW);
let direction: 'UPCOMING' | 'RECENT' = 'UPCOMING';
const upcomingCount = await this.prisma.trainSchedule.count({
where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } },
});
if (upcomingCount === 0) {
const latest = await this.prisma.trainSchedule.findFirst({
where: { departureAt: { lt: now }, status: { not: 'CANCELLED' } },
orderBy: { departureAt: 'desc' },
select: { departureAt: true },
});
if (latest) {
direction = 'RECENT';
to = latest.departureAt;
from = new Date(to.getTime() - days * MS_PER_DAY_OVERVIEW);
}
}
const schedules = await this.prisma.trainSchedule.findMany({
where: { departureAt: { gte: from, lte: to }, status: { not: 'CANCELLED' } },
select: {
id: true,
departureAt: true,
isPackageOnly: true,
train: { select: { number: true } },
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
},
orderBy: { departureAt: 'asc' },
take: OVERVIEW_MAX_SCHEDULES,
});
if (schedules.length === 0) {
return {
window: { from, to, days, direction, truncated: false },
totals: EMPTY_OVERVIEW_TOTALS,
byDay: [],
schedules: [],
};
}
const scheduleIds = schedules.map((s) => s.id);
const since24h = new Date(now.getTime() - MS_PER_DAY_OVERVIEW);
const [assignments, bookingSeats, holds, blockRows] = await Promise.all([
this.prisma.coachAssignment.findMany({
where: { scheduleId: { in: scheduleIds } },
select: {
scheduleId: true,
coachId: true,
coach: {
select: {
coachType: { select: { name: true, type: true } },
seats: { select: { seatNumber: true } },
},
},
},
}),
// Same three-branch OR as the drill-down (`getSeatStatusReport`): a seat reaches a
// schedule by its own `scheduleId`, by being leg 2 of a return booking, or — on
// older rows with no `scheduleId` — by its booking's outbound schedule. A plain
// `groupBy scheduleId` would silently drop the last two.
this.prisma.bookingSeat.findMany({
where: {
OR: [
{
scheduleId: { in: scheduleIds },
booking: { status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES } },
},
{
leg: 2,
booking: {
returnScheduleId: { in: scheduleIds },
status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES },
},
},
{
scheduleId: null,
leg: 1,
booking: {
scheduleId: { in: scheduleIds },
status: { in: OVERVIEW_ACTIVE_BOOKING_STATUSES },
},
},
],
},
select: {
scheduleId: true,
leg: true,
booking: {
select: { status: true, scheduleId: true, returnScheduleId: true },
},
seat: {
select: {
coach: { select: { coachType: { select: { name: true, type: true } } } },
},
},
},
}),
this.prisma.seatHold.findMany({
where: {
scheduleId: { in: scheduleIds },
expiresAt: { lt: now, gte: since24h },
},
select: { scheduleId: true },
}),
this.prisma.seatBlock.findMany({
where: {
OR: [{ scheduleId: { in: scheduleIds } }, { scheduleId: null }],
NOT: [
{ reason: { startsWith: 'MAINTENANCE:' } },
{ reason: { startsWith: TICKETING_BLOCK_REASON_PREFIX } },
],
},
select: {
scheduleId: true,
blockedAt: true,
unblockAt: true,
seat: {
select: {
id: true,
coachId: true,
seatNumber: true,
coach: { select: { coachType: { select: { name: true, type: true } } } },
},
},
},
orderBy: { blockedAt: 'desc' },
}),
]);
// ── Sellable seats and assigned coaches, per schedule ───────────────────────
// Sellable = every seat on every assigned coach, minus dining coaches and
// placeholder rows — the same denominator the revenue-loss report uses.
const sellableBySchedule = new Map<string, number>();
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);
const coachType = assignment.coach?.coachType;
if (
isDiningCoach({
coachTypeType: coachType?.type ?? null,
coachTypeName: coachType?.name ?? null,
})
) {
continue;
}
const sellable = (assignment.coach?.seats ?? []).filter(
(seat) => !isPlaceholderSeat(seat),
).length;
sellableBySchedule.set(
assignment.scheduleId,
(sellableBySchedule.get(assignment.scheduleId) ?? 0) + sellable,
);
}
// ── Paid / unpaid, per schedule ────────────────────────────────────────────
const scheduleIdSet = new Set(scheduleIds);
const paidBySchedule = new Map<string, number>();
const unpaidBySchedule = new Map<string, number>();
for (const bs of bookingSeats) {
// Dining seats only — the drill-down does not drop placeholder rows from the
// passenger seat list, so neither does this.
const coachType = bs.seat?.coach?.coachType;
if (
isDiningCoach({
coachTypeType: coachType?.type ?? null,
coachTypeName: coachType?.name ?? null,
})
) {
continue;
}
// Mirrors the OR branches, in the same order: an explicit `scheduleId` inside the
// window wins, then the return leg, then the booking's outbound schedule.
const scheduleId =
bs.scheduleId && scheduleIdSet.has(bs.scheduleId)
? bs.scheduleId
: bs.leg === 2
? bs.booking.returnScheduleId
: bs.booking.scheduleId;
if (!scheduleId || !scheduleIdSet.has(scheduleId)) continue;
const target =
bs.booking.status === 'PENDING_PAYMENT' ? unpaidBySchedule : paidBySchedule;
target.set(scheduleId, (target.get(scheduleId) ?? 0) + 1);
}
// ── Expired holds, per schedule ────────────────────────────────────────────
const holdsBySchedule = new Map<string, number>();
for (const hold of holds) {
holdsBySchedule.set(
hold.scheduleId,
(holdsBySchedule.get(hold.scheduleId) ?? 0) + 1,
);
}
// ── Blocked seats, per schedule ────────────────────────────────────────────
// One counted block per seat, resolved exactly as the drill-down resolves it: a
// schedule-scoped block beats a global one, and rows arrive newest-first so the
// first of a kind seen for a seat is already the most recent.
const blockedBySchedule = new Map<string, number>();
for (const schedule of schedules) {
const assignedCoachIds = coachIdsBySchedule.get(schedule.id) ?? new Set<string>();
const countedSeatIds = new Map<string, string | null>();
for (const block of blockRows) {
const seat = block.seat;
if (!seat || isPlaceholderSeat(seat)) continue;
const coachType = seat.coach?.coachType;
if (
isDiningCoach({
coachTypeType: coachType?.type ?? null,
coachTypeName: coachType?.name ?? null,
})
) {
continue;
}
if (block.scheduleId !== null) {
if (block.scheduleId !== schedule.id) continue;
} else {
if (!assignedCoachIds.has(seat.coachId)) continue;
if (!isGlobalBlockInEffectAt(block, schedule.departureAt)) continue;
}
const existing = countedSeatIds.get(seat.id);
if (existing === undefined || (existing === null && block.scheduleId !== null)) {
countedSeatIds.set(seat.id, block.scheduleId);
}
}
if (countedSeatIds.size > 0) {
blockedBySchedule.set(schedule.id, countedSeatIds.size);
}
}
// ── Assemble ───────────────────────────────────────────────────────────────
const scheduleRows = schedules.map((s) => {
const sellableSeats = sellableBySchedule.get(s.id) ?? 0;
const paid = paidBySchedule.get(s.id) ?? 0;
const unpaid = unpaidBySchedule.get(s.id) ?? 0;
const expiredHolds = holdsBySchedule.get(s.id) ?? 0;
const blocked = blockedBySchedule.get(s.id) ?? 0;
// Floored at zero: a seat can be both sold and blocked, so the parts can
// over-subtract. Never render a negative slice.
const available = Math.max(0, sellableSeats - paid - unpaid - blocked);
return {
scheduleId: s.id,
trainNumber: s.train.number,
originStation: s.originStation.name,
destinationStation: s.destinationStation.name,
departureAt: s.departureAt,
isPackage: s.isPackageOnly,
sellableSeats,
paid,
unpaid,
expiredHolds,
blocked,
available,
loadFactorPercent:
sellableSeats > 0 ? +((paid / sellableSeats) * 100).toFixed(1) : 0,
};
});
// Day buckets keyed on the UTC calendar date of departure, so the chart's axis and
// its bars are derived from one value and cannot disagree with each other.
const byDayMap = new Map<string, ReturnType<typeof emptyDayBucket>>();
for (const row of scheduleRows) {
const date = row.departureAt.toISOString().slice(0, 10);
const bucket = byDayMap.get(date) ?? emptyDayBucket(date);
bucket.scheduleCount += 1;
bucket.sellableSeats += row.sellableSeats;
bucket.paid += row.paid;
bucket.unpaid += row.unpaid;
bucket.expiredHolds += row.expiredHolds;
bucket.blocked += row.blocked;
bucket.available += row.available;
byDayMap.set(date, bucket);
}
const byDay = [...byDayMap.values()].sort((a, b) => a.date.localeCompare(b.date));
const sum = (pick: (r: (typeof scheduleRows)[number]) => number) =>
scheduleRows.reduce((total, row) => total + pick(row), 0);
const totalSellable = sum((r) => r.sellableSeats);
const totalPaid = sum((r) => r.paid);
return {
window: {
from,
to,
days,
direction,
truncated: schedules.length === OVERVIEW_MAX_SCHEDULES,
},
totals: {
scheduleCount: scheduleRows.length,
sellableSeats: totalSellable,
paidCount: totalPaid,
unpaidCount: sum((r) => r.unpaid),
expiredHoldCount: sum((r) => r.expiredHolds),
blockedCount: sum((r) => r.blocked),
availableCount: sum((r) => r.available),
loadFactorPercent:
totalSellable > 0 ? +((totalPaid / totalSellable) * 100).toFixed(1) : 0,
},
byDay,
schedules: scheduleRows,
};
}
async getPaymentDiscrepancyReport(params: {
from?: string;
to?: string;