mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Passengers and seats report updates
This commit is contained in:
@@ -47,6 +47,12 @@ export class ReportsController {
|
||||
return this.service.getPaymentDiscrepancyReport({ from, to, sortBy, search });
|
||||
}
|
||||
|
||||
@Get("seat-status")
|
||||
@ApiOperation({ summary: "Seat status breakdown for a schedule (paid, unpaid, expired holds, blocked)" })
|
||||
getSeatStatusReport(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.getSeatStatusReport(scheduleId);
|
||||
}
|
||||
|
||||
@Get("payments")
|
||||
@ApiOperation({ summary: "Payments collected for a schedule" })
|
||||
getPaymentsReport(@Query('scheduleId') scheduleId: string) {
|
||||
|
||||
@@ -263,138 +263,81 @@ export class ReportsService {
|
||||
},
|
||||
},
|
||||
bookings: {
|
||||
where: { status: { in: ["CONFIRMED", "BOARDED"] } },
|
||||
select: {
|
||||
id: true,
|
||||
originStationId: true,
|
||||
destinationStationId: true,
|
||||
},
|
||||
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
select: { id: true, originStationId: true, destinationStationId: true },
|
||||
},
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!schedule) return null;
|
||||
|
||||
const totalSeats = (schedule as any).coachAssignments.reduce(
|
||||
(s: number, a: any) => s + a.coach.seats.length,
|
||||
0,
|
||||
);
|
||||
const allBookingSeats = (schedule as any).bookings.flatMap(
|
||||
(b: any) => b.seats,
|
||||
);
|
||||
// Fetch booking seats directly by scheduleId — avoids deserializing legacy
|
||||
// BookingSeat rows with null scheduleId that crash Prisma when loaded via relation.
|
||||
const allBookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
leg: 1,
|
||||
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
},
|
||||
select: {
|
||||
bookingId: true,
|
||||
seat: { select: { coachId: true, coach: { select: { coachType: { select: { name: true } } } } } },
|
||||
},
|
||||
});
|
||||
|
||||
const totalSeats = schedule.coachAssignments.reduce((s, a) => s + a.coach.seats.length, 0);
|
||||
const totalPassengers = allBookingSeats.length;
|
||||
const occupancyRate =
|
||||
totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0;
|
||||
const occupancyRate = totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0;
|
||||
|
||||
// Per-coach breakdown
|
||||
const coachMap = new Map<
|
||||
string,
|
||||
{
|
||||
coachNumber: string;
|
||||
coachType: string;
|
||||
totalSeats: number;
|
||||
booked: number;
|
||||
}
|
||||
>();
|
||||
for (const assignment of (schedule as any).coachAssignments) {
|
||||
const coachMap = new Map<string, { coachNumber: string; coachType: string; totalSeats: number; booked: number }>();
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
const c = assignment.coach;
|
||||
coachMap.set(c.id, {
|
||||
coachNumber: c.number,
|
||||
coachType: (c as any).coachType?.name ?? "Unknown",
|
||||
totalSeats: c.seats.length,
|
||||
booked: 0,
|
||||
});
|
||||
coachMap.set(c.id, { coachNumber: c.number, coachType: (c as any).coachType?.name ?? 'Unknown', totalSeats: c.seats.length, booked: 0 });
|
||||
}
|
||||
for (const bs of allBookingSeats) {
|
||||
const coachId = bs.seat?.coachId;
|
||||
if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++;
|
||||
}
|
||||
const byCoach = [...coachMap.values()].map((c) => ({
|
||||
...c,
|
||||
occupancyRate:
|
||||
c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
|
||||
}));
|
||||
|
||||
// Per-origin station breakdown (using booking's originStationId)
|
||||
const originMap = new Map<
|
||||
string,
|
||||
{ stationName: string; passengers: number }
|
||||
>();
|
||||
for (const booking of (schedule as any).bookings) {
|
||||
const stationId = booking.originStationId ?? schedule.originStationId;
|
||||
const stationName =
|
||||
(schedule as any).stopTimes.find(
|
||||
(st: any) => st.stationId === stationId,
|
||||
)?.station?.name ??
|
||||
(schedule as any).originStation?.name ??
|
||||
stationId;
|
||||
if (!originMap.has(stationId))
|
||||
originMap.set(stationId, { stationName, passengers: 0 });
|
||||
originMap.get(stationId)!.passengers += booking.seats.length;
|
||||
}
|
||||
const byOrigin = [...originMap.values()].sort(
|
||||
(a, b) => b.passengers - a.passengers,
|
||||
);
|
||||
|
||||
// Per-destination station breakdown
|
||||
const destMap = new Map<
|
||||
string,
|
||||
{ stationName: string; passengers: number }
|
||||
>();
|
||||
for (const booking of (schedule as any).bookings) {
|
||||
const stationId =
|
||||
booking.destinationStationId ?? schedule.destinationStationId;
|
||||
const stationName =
|
||||
(schedule as any).stopTimes.find(
|
||||
(st: any) => st.stationId === stationId,
|
||||
)?.station?.name ??
|
||||
(schedule as any).destinationStation?.name ??
|
||||
stationId;
|
||||
if (!destMap.has(stationId))
|
||||
destMap.set(stationId, { stationName, passengers: 0 });
|
||||
destMap.get(stationId)!.passengers += booking.seats.length;
|
||||
}
|
||||
const byDestination = [...destMap.values()].sort(
|
||||
(a, b) => b.passengers - a.passengers,
|
||||
);
|
||||
const byCoach = [...coachMap.values()].map(c => ({ ...c, occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0 }));
|
||||
|
||||
// Per-class breakdown
|
||||
const classMap = new Map<
|
||||
string,
|
||||
{ className: string; totalSeats: number; booked: number }
|
||||
>();
|
||||
for (const assignment of (schedule as any).coachAssignments) {
|
||||
const typeName = (assignment.coach as any).coachType?.name ?? "Unknown";
|
||||
if (!classMap.has(typeName))
|
||||
classMap.set(typeName, {
|
||||
className: typeName,
|
||||
totalSeats: 0,
|
||||
booked: 0,
|
||||
});
|
||||
const classMap = new Map<string, { className: string; totalSeats: number; booked: number }>();
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
const typeName = (assignment.coach as any).coachType?.name ?? 'Unknown';
|
||||
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
|
||||
classMap.get(typeName)!.totalSeats += assignment.coach.seats.length;
|
||||
}
|
||||
for (const bs of allBookingSeats) {
|
||||
const typeName = bs.seat?.coach?.coachType?.name ?? "Unknown";
|
||||
if (!classMap.has(typeName))
|
||||
classMap.set(typeName, {
|
||||
className: typeName,
|
||||
totalSeats: 0,
|
||||
booked: 0,
|
||||
});
|
||||
const typeName = bs.seat?.coach?.coachType?.name ?? 'Unknown';
|
||||
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
|
||||
classMap.get(typeName)!.booked++;
|
||||
}
|
||||
const byClass = [...classMap.values()].map((c) => ({
|
||||
...c,
|
||||
occupancyRate:
|
||||
c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
|
||||
}));
|
||||
const byClass = [...classMap.values()].map(c => ({ ...c, occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0 }));
|
||||
|
||||
// Per-origin / per-destination — count actual seats per booking from allBookingSeats
|
||||
const seatCountByBooking = allBookingSeats.reduce((acc, bs) => { acc[bs.bookingId] = (acc[bs.bookingId] ?? 0) + 1; return acc; }, {} as Record<string, number>);
|
||||
const originMap = new Map<string, { stationName: string; passengers: number }>();
|
||||
const destMap = new Map<string, { stationName: string; passengers: number }>();
|
||||
for (const booking of schedule.bookings) {
|
||||
const count = seatCountByBooking[booking.id] ?? 0;
|
||||
const oId = booking.originStationId ?? schedule.originStationId;
|
||||
const dId = booking.destinationStationId ?? schedule.destinationStationId;
|
||||
const oName = schedule.stopTimes.find(st => st.stationId === oId)?.station?.name ?? (schedule as any).originStation?.name ?? oId;
|
||||
const dName = schedule.stopTimes.find(st => st.stationId === dId)?.station?.name ?? (schedule as any).destinationStation?.name ?? dId;
|
||||
if (!originMap.has(oId)) originMap.set(oId, { stationName: oName, passengers: 0 });
|
||||
originMap.get(oId)!.passengers += count;
|
||||
if (!destMap.has(dId)) destMap.set(dId, { stationName: dName, passengers: 0 });
|
||||
destMap.get(dId)!.passengers += count;
|
||||
}
|
||||
const byOrigin = [...originMap.values()].sort((a, b) => b.passengers - a.passengers);
|
||||
const byDestination = [...destMap.values()].sort((a, b) => b.passengers - a.passengers);
|
||||
|
||||
return {
|
||||
schedule: {
|
||||
id: schedule.id,
|
||||
trainName:
|
||||
(schedule as any).train?.name ?? (schedule as any).train?.number,
|
||||
trainName: (schedule as any).train?.name ?? (schedule as any).train?.number,
|
||||
origin: (schedule as any).originStation?.name,
|
||||
destination: (schedule as any).destinationStation?.name,
|
||||
departureAt: schedule.departureAt,
|
||||
@@ -502,7 +445,7 @@ export class ReportsService {
|
||||
}
|
||||
|
||||
async getSeatStatusReport(scheduleId: string) {
|
||||
// Booked seats — exclude dining coaches
|
||||
// Confirmed/boarded seats — exclude dining coaches
|
||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
@@ -537,11 +480,30 @@ export class ReportsService {
|
||||
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
|
||||
});
|
||||
|
||||
// Manually blocked seats for this schedule — exclude MAINTENANCE entries
|
||||
// Active seat holds for this schedule
|
||||
const activeHolds = await this.prisma.seatHold.findMany({
|
||||
where: { scheduleId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// Expired holds (last 24h) — held but never converted to a booking
|
||||
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const expiredHolds = await this.prisma.seatHold.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
expiresAt: { lt: new Date(), gte: since24h },
|
||||
},
|
||||
orderBy: { expiresAt: 'desc' },
|
||||
});
|
||||
|
||||
// Manually blocked seats from back office — exclude MAINTENANCE and system-created blocks
|
||||
const blocks = await this.prisma.seatBlock.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
NOT: { reason: { startsWith: 'MAINTENANCE:' } },
|
||||
NOT: [
|
||||
{ reason: { startsWith: 'MAINTENANCE:' } },
|
||||
{ blockedBy: 'system' },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
seat: {
|
||||
@@ -568,19 +530,41 @@ export class ReportsService {
|
||||
return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? null;
|
||||
};
|
||||
|
||||
const paidSeats = bookingSeats.filter(bs =>
|
||||
bs.booking.status === 'CONFIRMED' || bs.booking.status === 'BOARDED'
|
||||
);
|
||||
const unpaidSeats = bookingSeats.filter(bs =>
|
||||
bs.booking.status === 'PENDING_PAYMENT'
|
||||
);
|
||||
|
||||
const mapSeat = (bs: any) => ({
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
passengerName: bs.passengerName,
|
||||
passengerCategory: bs.passengerCategory,
|
||||
coachNumber: bs.seat?.coach?.number ?? null,
|
||||
seatNumber: bs.seat?.seatNumber ?? null,
|
||||
seatClassName: resolveSeatClass(bs.seat),
|
||||
fareMinor: bs.fareMinor,
|
||||
currency: bs.booking.currency ?? 'ETB',
|
||||
bookingStatus: bs.booking.status,
|
||||
paymentStatus: bs.booking.paymentIntent?.status ?? 'PENDING',
|
||||
bookedAt: bs.booking.createdAt,
|
||||
});
|
||||
|
||||
return {
|
||||
bookedSeats: bookingSeats.map(bs => ({
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
passengerName: bs.passengerName,
|
||||
passengerCategory: bs.passengerCategory,
|
||||
coachNumber: bs.seat?.coach?.number ?? null,
|
||||
seatNumber: bs.seat?.seatNumber ?? null,
|
||||
seatClassName: resolveSeatClass(bs.seat),
|
||||
fareMinor: bs.fareMinor,
|
||||
currency: bs.booking.currency ?? 'ETB',
|
||||
bookingStatus: bs.booking.status,
|
||||
paymentStatus: bs.booking.paymentIntent?.status ?? 'PENDING',
|
||||
bookedAt: bs.booking.createdAt,
|
||||
summary: {
|
||||
paidCount: paidSeats.length,
|
||||
unpaidCount: unpaidSeats.length,
|
||||
expiredHoldCount: expiredHolds.length,
|
||||
blockedCount: blocks.filter(b => b.seat?.coach?.coachType?.type !== 'dining').length,
|
||||
},
|
||||
paidSeats: paidSeats.map(mapSeat),
|
||||
unpaidSeats: unpaidSeats.map(mapSeat),
|
||||
expiredHolds: expiredHolds.map(h => ({
|
||||
holdId: h.id,
|
||||
seatIds: h.seatIds,
|
||||
expiresAt: h.expiresAt,
|
||||
createdAt: h.createdAt,
|
||||
})),
|
||||
blockedSeats: blocks
|
||||
.filter(b => b.seat?.coach?.coachType?.type !== 'dining')
|
||||
|
||||
Reference in New Issue
Block a user