From 8b713d56ebe266d2058251a299a7b25d413d4286 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 21 Jul 2026 16:38:02 +0300 Subject: [PATCH] Passengers and seats report updates --- .../src/modules/reports/reports.controller.ts | 6 + .../src/modules/reports/reports.service.ts | 226 ++++++++---------- 2 files changed, 111 insertions(+), 121 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 90c8f51b8..6f91de06f 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -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) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 2435ac91e..1ae02ba54 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -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(); + 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(); + 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); + const originMap = new Map(); + const destMap = new Map(); + 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')