Merge pull request #1277 from Tria-plc/alpha

feat: ( reports ) add fleet passenger overview to passengers landing …
This commit is contained in:
Abubeker Yasin
2026-08-13 16:21:47 +03:00
committed by GitHub
4 changed files with 780 additions and 6 deletions

View File

@@ -37,6 +37,24 @@ export class ReportsController {
return this.service.getPassengerList(scheduleId);
}
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
@Get("passengers/overview")
@ApiOperation({
summary: "Fleet-wide passenger mix across a departure window",
description:
"Landing view for the passengers report, shown before a schedule is picked. Returns passenger volume per " +
"departure day, nationality split, passenger-category mix and the busiest origin→destination pairs across " +
"the window, plus 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 CONFIRMED and BOARDED seats only, matching `GET /reports/passengers`. Carries no occupancy figure " +
"by design: this report and the seat status report measure capacity differently, so a shared occupancy " +
"number would contradict one of them.",
})
getPassengerOverview(@Query('days') days?: string) {
return this.service.getPassengerOverview(days ? Number(days) : undefined);
}
@Get("passengers")
@ApiOperation({ summary: "Passengers report for a specific schedule" })
getOccupancyReport(@Query("scheduleId") scheduleId: string) {

View File

@@ -51,6 +51,11 @@ const OVERVIEW_ACTIVE_BOOKING_STATUSES: BookingStatus[] = [
'PENDING_PAYMENT',
];
/** The passengers report counts people, so a seat awaiting payment does not qualify. */
const PASSENGER_ACTIVE_BOOKING_STATUSES: BookingStatus[] = ['CONFIRMED', 'BOARDED'];
/** Route pairs are long-tailed; only the busiest are legible in a chart. */
const TOP_ROUTES_LIMIT = 8;
const EMPTY_OVERVIEW_TOTALS = {
scheduleCount: 0,
sellableSeats: 0,
@@ -1138,6 +1143,260 @@ export class ReportsService {
};
}
/**
* Fleet-wide passenger mix across a departure window — the landing view for the
* passengers report, shown before a schedule is picked.
*
* Answers "who travelled", not "how full were the trains". Occupancy is deliberately
* absent: this report and the seat status report count capacity differently (this one
* includes dining and placeholder seats in `totalSeats`, the other does not), so an
* occupancy figure here would either contradict the table below it or the seats page.
* That pre-existing difference is left alone rather than silently reconciled.
*
* Counts CONFIRMED and BOARDED only, matching {@link getOccupancyBySchedule} — a seat
* awaiting payment has no passenger on it yet.
*/
async getPassengerOverview(daysRaw?: number) {
const days = Math.min(
Math.max(Math.trunc(daysRaw || OVERVIEW_DEFAULT_DAYS), 1),
OVERVIEW_MAX_DAYS,
);
const now = new Date();
// Window resolution is intentionally a copy of the one in getSeatStatusOverview
// rather than a shared helper: the two reports are free to diverge on what window
// makes sense for them, and a shared helper would couple them for ~20 lines.
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,
originStationId: true,
destinationStationId: 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: { scheduleCount: 0, totalPassengers: 0, groupPassengers: 0 },
byDay: [],
byNationality: [],
byCategory: [],
topRoutes: [],
schedules: [],
};
}
const scheduleIds = schedules.map((s) => s.id);
// Same three-branch OR as the per-schedule report: own scheduleId, return leg, or a
// legacy null-scheduleId row reached through the booking's outbound schedule.
const bookingSeats = await this.prisma.bookingSeat.findMany({
where: {
OR: [
{
scheduleId: { in: scheduleIds },
booking: { status: { in: PASSENGER_ACTIVE_BOOKING_STATUSES } },
},
{
leg: 2,
booking: {
returnScheduleId: { in: scheduleIds },
status: { in: PASSENGER_ACTIVE_BOOKING_STATUSES },
},
},
{
scheduleId: null,
leg: 1,
booking: {
scheduleId: { in: scheduleIds },
status: { in: PASSENGER_ACTIVE_BOOKING_STATUSES },
},
},
],
},
select: {
scheduleId: true,
leg: true,
bookingId: true,
passengerCategory: true,
passportCountry: true,
idDocumentType: true,
booking: {
select: {
scheduleId: true,
returnScheduleId: true,
originStationId: true,
destinationStationId: true,
},
},
},
});
// Station names for the route pairs. Bookings that never recorded a station fall back
// to the schedule's own endpoints, the same fallback getOccupancyBySchedule applies.
const stationIds = [
...new Set(
[
...bookingSeats.flatMap((bs) => [
bs.booking.originStationId,
bs.booking.destinationStationId,
]),
...schedules.flatMap((s) => [s.originStationId, s.destinationStationId]),
].filter((id): id is string => Boolean(id)),
),
];
const stations = stationIds.length
? await this.prisma.station.findMany({
where: { id: { in: stationIds } },
select: { id: true, name: true },
})
: [];
const stationName = new Map(stations.map((s) => [s.id, s.name]));
const scheduleById = new Map(schedules.map((s) => [s.id, s]));
const scheduleIdSet = new Set(scheduleIds);
const passengersBySchedule = new Map<string, number>();
const nationalityCounts = new Map<string, number>();
const categoryCounts = new Map<string, number>();
const routeCounts = new Map<string, { origin: string; destination: string; passengers: number }>();
// A booking contributing more than one seat to the window is a group booking.
const seatsPerBooking = new Map<string, number>();
for (const bs of bookingSeats) {
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 schedule = scheduleById.get(scheduleId);
passengersBySchedule.set(
scheduleId,
(passengersBySchedule.get(scheduleId) ?? 0) + 1,
);
seatsPerBooking.set(bs.bookingId, (seatsPerBooking.get(bs.bookingId) ?? 0) + 1);
// Same derivation as getPassengerList, so the chart and the drill-down list agree
// on what a passenger's nationality is.
const nationality = bs.passportCountry
? bs.passportCountry === 'Djibouti'
? 'Djiboutian'
: bs.passportCountry
: bs.idDocumentType === 'NATIONAL_ID'
? 'Ethiopian'
: 'Unknown';
nationalityCounts.set(nationality, (nationalityCounts.get(nationality) ?? 0) + 1);
const category = bs.passengerCategory ?? 'ADULT';
categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
const originId = bs.booking.originStationId ?? schedule?.originStationId ?? null;
const destinationId =
bs.booking.destinationStationId ?? schedule?.destinationStationId ?? null;
if (originId && destinationId) {
const key = `${originId}|${destinationId}`;
const existing = routeCounts.get(key);
if (existing) {
existing.passengers += 1;
} else {
routeCounts.set(key, {
origin: stationName.get(originId) ?? originId,
destination: stationName.get(destinationId) ?? destinationId,
passengers: 1,
});
}
}
}
const groupPassengers = [...seatsPerBooking.values()]
.filter((count) => count > 1)
.reduce((sum, count) => sum + count, 0);
const scheduleRows = schedules.map((s) => ({
scheduleId: s.id,
trainNumber: s.train.number,
originStation: s.originStation.name,
destinationStation: s.destinationStation.name,
departureAt: s.departureAt,
isPackage: s.isPackageOnly,
passengers: passengersBySchedule.get(s.id) ?? 0,
}));
const byDayMap = new Map<string, { date: string; scheduleCount: number; passengers: number }>();
for (const row of scheduleRows) {
const date = row.departureAt.toISOString().slice(0, 10);
const bucket = byDayMap.get(date) ?? { date, scheduleCount: 0, passengers: 0 };
bucket.scheduleCount += 1;
bucket.passengers += row.passengers;
byDayMap.set(date, bucket);
}
const rank = <T extends { passengers: number }>(rows: T[]) =>
rows.sort((a, b) => b.passengers - a.passengers);
return {
window: {
from,
to,
days,
direction,
truncated: schedules.length === OVERVIEW_MAX_SCHEDULES,
},
totals: {
scheduleCount: scheduleRows.length,
totalPassengers: scheduleRows.reduce((sum, r) => sum + r.passengers, 0),
groupPassengers,
},
byDay: [...byDayMap.values()].sort((a, b) => a.date.localeCompare(b.date)),
byNationality: rank(
[...nationalityCounts.entries()].map(([nationality, passengers]) => ({
nationality,
passengers,
})),
),
byCategory: rank(
[...categoryCounts.entries()].map(([category, passengers]) => ({
category,
passengers,
})),
),
topRoutes: rank([...routeCounts.values()]).slice(0, TOP_ROUTES_LIMIT),
schedules: scheduleRows,
};
}
async getPaymentDiscrepancyReport(params: {
from?: string;
to?: string;