This commit is contained in:
Roba Boru
2026-07-18 21:57:05 +03:00
9 changed files with 1130 additions and 501 deletions

View File

@@ -13,8 +13,8 @@ export class DashboardService {
async getBackofficeStats() {
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] =
await Promise.all([
this.prisma.booking.count(),
this.prisma.booking.count({ where: { packageId: { not: null } } }),
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }),
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }),
this.prisma.ticket.count(),
this.prisma.passenger.count(),
this.prisma.seat.count({ where: { status: 'BLOCKED' } }),

View File

@@ -1,38 +1,50 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ReportsService } from './reports.service';
import { GenerateReportDto } from './reports.dto';
import { PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
import { ReportsService } from "./reports.service";
import { GenerateReportDto } from "./reports.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@ApiTags('Reports')
@Controller('reports')
@ApiTags("Reports")
@Controller("reports")
@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiBearerAuth("IAM-auth")
export class ReportsController {
constructor(private service: ReportsService) {}
@Post('generate')
@ApiOperation({ summary: 'Generate operational report' })
@Post("generate")
@ApiOperation({ summary: "Generate operational report" })
generateReport(@Body() dto: GenerateReportDto) {
return this.service.generateReport(dto);
}
@Get('passengers')
@ApiOperation({ summary: 'Passengers report for a specific schedule' })
getOccupancyReport(@Query('scheduleId') scheduleId: string) {
@Get("schedules")
@ApiOperation({ summary: "List schedules for the passengers report picker" })
listSchedulesForPicker() {
return this.service.listSchedulesForPicker();
}
@Get("passengers/list")
@ApiOperation({ summary: "Flat passenger list for a specific schedule" })
getPassengerList(@Query("scheduleId") scheduleId: string) {
return this.service.getPassengerList(scheduleId);
}
@Get("passengers")
@ApiOperation({ summary: "Passengers report for a specific schedule" })
getOccupancyReport(@Query("scheduleId") scheduleId: string) {
return this.service.getOccupancyBySchedule(scheduleId);
}
@Get(':reportId')
@ApiOperation({ summary: 'Get report by ID' })
getReport(@Param('reportId') reportId: string) {
@Get(":reportId")
@ApiOperation({ summary: "Get report by ID" })
getReport(@Param("reportId") reportId: string) {
return this.service.getReport(reportId);
}
@Get()
@ApiOperation({ summary: 'List reports' })
listReports(@Query('type') type?: string) {
@ApiOperation({ summary: "List reports" })
listReports(@Query("type") type?: string) {
return this.service.listReports(type);
}
}

View File

@@ -1,8 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { GenerateReportDto, ReportType } from './reports.dto';
import { Injectable, Logger } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
import { PrismaService } from "../../common/prisma.service";
import { GenerateReportDto, ReportType } from "./reports.dto";
@Injectable()
export class ReportsService {
@@ -15,7 +15,7 @@ export class ReportsService {
async generateReport(dto: GenerateReportDto) {
const dateFrom = new Date(dto.dateFrom);
dateFrom.setHours(0, 0, 0, 0);
const dateTo = new Date(dto.dateTo);
dateTo.setHours(23, 59, 59, 999);
@@ -28,7 +28,11 @@ export class ReportsService {
data = await this.generateOccupancyReport(dateFrom, dateTo);
break;
case ReportType.AGENT_SALES:
data = await this.generateAgentSalesReport(dateFrom, dateTo, dto.agentId);
data = await this.generateAgentSalesReport(
dateFrom,
dateTo,
dto.agentId,
);
break;
case ReportType.CANCELLATIONS:
data = await this.generateCancellationsReport(dateFrom, dateTo);
@@ -45,8 +49,8 @@ export class ReportsService {
reportType: dto.reportType,
dateFrom,
dateTo,
data
}
data,
},
});
return { reportId: report.id, reportType: dto.reportType, data };
@@ -56,37 +60,43 @@ export class ReportsService {
// Fetch all bookings in date range, regardless of status
const bookings = await this.prisma.booking.findMany({
where: {
createdAt: { gte: dateFrom, lte: dateTo }
createdAt: { gte: dateFrom, lte: dateTo },
},
include: { paymentIntent: true }
include: { paymentIntent: true },
});
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
const byPaymentMethod = bookings.reduce((acc, b) => {
const method = b.paymentIntent?.method ?? 'UNKNOWN';
acc[method] = (acc[method] || 0) + b.totalMinor;
return acc;
}, {} as Record<string, number>);
const byPaymentMethod = bookings.reduce(
(acc, b) => {
const method = b.paymentIntent?.method ?? "UNKNOWN";
acc[method] = (acc[method] || 0) + b.totalMinor;
return acc;
},
{} as Record<string, number>,
);
// Group by date for charts
const byDate = bookings.reduce((acc, b) => {
const date = b.createdAt.toISOString().split('T')[0];
if (!acc[date]) {
acc[date] = { totalMinor: 0, count: 0 };
}
acc[date].totalMinor += b.totalMinor;
acc[date].count += 1;
return acc;
}, {} as Record<string, any>);
const byDate = bookings.reduce(
(acc, b) => {
const date = b.createdAt.toISOString().split("T")[0];
if (!acc[date]) {
acc[date] = { totalMinor: 0, count: 0 };
}
acc[date].totalMinor += b.totalMinor;
acc[date].count += 1;
return acc;
},
{} as Record<string, any>,
);
return {
totalBookings: bookings.length,
totalRevenueMinor: totalRevenue,
totalRevenue: totalRevenue / 100,
currency: 'ETB',
currency: "ETB",
byPaymentMethod,
byDate,
cancellationRate: 0
cancellationRate: 0,
};
}
@@ -95,75 +105,116 @@ export class ReportsService {
where: { departureAt: { gte: dateFrom, lte: dateTo } },
include: {
coachAssignments: { include: { coach: { include: { seats: true } } } },
bookings: { include: { seats: true } },
bookings: {
where: { status: { in: ["CONFIRMED", "BOARDED"] } },
include: { seats: true },
},
},
});
const tripData = schedules.map(schedule => {
const totalSeats = schedule.coachAssignments.reduce((sum, a) => sum + a.coach.seats.length, 0);
const bookedSeats = schedule.bookings.reduce((sum, b) => sum + b.seats.length, 0);
const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
return { scheduleId: schedule.id, departureAt: schedule.departureAt, totalSeats, bookedSeats, occupancyRate: +occupancyRate.toFixed(2) };
const tripData = schedules.map((schedule) => {
const totalSeats = schedule.coachAssignments.reduce(
(sum, a) => sum + a.coach.seats.length,
0,
);
const bookedSeats = schedule.bookings.reduce(
(sum, b) =>
sum + b.seats.filter((s: any) => s.scheduleId === schedule.id).length,
0,
);
const occupancyRate =
totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
return {
scheduleId: schedule.id,
departureAt: schedule.departureAt,
totalSeats,
bookedSeats,
occupancyRate: +occupancyRate.toFixed(2),
};
});
const avgOccupancy = tripData.length > 0 ? tripData.reduce((sum, t) => sum + t.occupancyRate, 0) / tripData.length : 0;
return { totalSchedules: schedules.length, averageOccupancyRate: +avgOccupancy.toFixed(2), schedules: tripData };
const avgOccupancy =
tripData.length > 0
? tripData.reduce((sum, t) => sum + t.occupancyRate, 0) /
tripData.length
: 0;
return {
totalSchedules: schedules.length,
averageOccupancyRate: +avgOccupancy.toFixed(2),
schedules: tripData,
};
}
private async generateAgentSalesReport(dateFrom: Date, dateTo: Date, agentId?: string) {
private async generateAgentSalesReport(
dateFrom: Date,
dateTo: Date,
agentId?: string,
) {
const agentBookings = await this.prisma.agentBooking.findMany({
where: {
createdAt: { gte: dateFrom, lte: dateTo },
...(agentId ? { agentId } : {})
...(agentId ? { agentId } : {}),
},
include: {
agent: { select: { id: true, iamUserId: true, agentCode: true } },
booking: true
}
booking: true,
},
});
const iamUserIds = [...new Set(
agentBookings.map(ab => ab.agent.iamUserId).filter(Boolean) as string[]
)];
const iamRows = iamUserIds.length > 0
? await this.dataSource.query<{ id: string; name: { en?: string; am?: string } | null }[]>(
`SELECT id, name FROM iam.users WHERE id = ANY($1)`,
[iamUserIds],
)
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
const iamUserIds = [
...new Set(
agentBookings
.map((ab) => ab.agent.iamUserId)
.filter(Boolean) as string[],
),
];
const iamRows =
iamUserIds.length > 0
? await this.dataSource.query<
{ id: string; name: { en?: string; am?: string } | null }[]
>(`SELECT id, name FROM iam.users WHERE id = ANY($1)`, [iamUserIds])
: [];
const iamMap = new Map(iamRows.map((r) => [r.id, r]));
const byAgent = agentBookings.reduce((acc, ab) => {
const iam = ab.agent.iamUserId ? iamMap.get(ab.agent.iamUserId) : undefined;
const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode;
if (!acc[agentName]) {
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
}
acc[agentName].bookings += 1;
acc[agentName].revenueMinor += ab.booking.totalMinor;
acc[agentName].cashCollected += ab.cashReceived ?? 0;
return acc;
}, {} as Record<string, any>);
const byAgent = agentBookings.reduce(
(acc, ab) => {
const iam = ab.agent.iamUserId
? iamMap.get(ab.agent.iamUserId)
: undefined;
const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode;
if (!acc[agentName]) {
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
}
acc[agentName].bookings += 1;
acc[agentName].revenueMinor += ab.booking.totalMinor;
acc[agentName].cashCollected += ab.cashReceived ?? 0;
return acc;
},
{} as Record<string, any>,
);
return {
totalAgentBookings: agentBookings.length,
byAgent
byAgent,
};
}
private async generateCancellationsReport(dateFrom: Date, dateTo: Date) {
const cancellations = await this.prisma.bookingCancellation.findMany({
where: { createdAt: { gte: dateFrom, lte: dateTo } },
include: { booking: true }
include: { booking: true },
});
const totalRefunded = cancellations.reduce((sum, c) => sum + c.refundAmount, 0);
const totalRefunded = cancellations.reduce(
(sum, c) => sum + c.refundAmount,
0,
);
return {
totalCancellations: cancellations.length,
totalRefundedMinor: totalRefunded,
totalRefunded: totalRefunded / 100,
currency: 'ETB'
currency: "ETB",
};
}
@@ -171,23 +222,26 @@ export class ReportsService {
const payments = await this.prisma.paymentIntent.findMany({
where: {
createdAt: { gte: dateFrom, lte: dateTo },
status: 'SUCCEEDED'
}
status: "SUCCEEDED",
},
});
const byMethod = payments.reduce((acc, p) => {
const method = p.method;
if (!acc[method]) {
acc[method] = { count: 0, totalMinor: 0 };
}
acc[method].count += 1;
acc[method].totalMinor += p.amountMinor;
return acc;
}, {} as Record<string, any>);
const byMethod = payments.reduce(
(acc, p) => {
const method = p.method;
if (!acc[method]) {
acc[method] = { count: 0, totalMinor: 0 };
}
acc[method].count += 1;
acc[method].totalMinor += p.amountMinor;
return acc;
},
{} as Record<string, any>,
);
return {
totalPayments: payments.length,
byMethod
byMethod,
};
}
@@ -209,33 +263,48 @@ export class ReportsService {
},
},
bookings: {
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
where: { status: { in: ["CONFIRMED", "BOARDED"] } },
include: {
seats: {
where: { scheduleId },
include: {
seat: { include: { coach: { include: { coachType: 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);
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,
);
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 }>();
const coachMap = new Map<
string,
{
coachNumber: string;
coachType: string;
totalSeats: number;
booked: number;
}
>();
for (const assignment of (schedule as any).coachAssignments) {
const c = assignment.coach;
coachMap.set(c.id, {
coachNumber: c.number,
coachType: (c as any).coachType?.name ?? 'Unknown',
coachType: (c as any).coachType?.name ?? "Unknown",
totalSeats: c.seats.length,
booked: 0,
});
@@ -244,56 +313,91 @@ export class ReportsService {
const coachId = bs.seat?.coachId;
if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++;
}
const byCoach = [...coachMap.values()].map(c => ({
const byCoach = [...coachMap.values()].map((c) => ({
...c,
occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
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 }>();
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 });
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);
const byOrigin = [...originMap.values()].sort(
(a, b) => b.passengers - a.passengers,
);
// Per-destination station breakdown
const destMap = new Map<string, { stationName: string; passengers: number }>();
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 });
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 byDestination = [...destMap.values()].sort(
(a, b) => b.passengers - a.passengers,
);
// Per-class breakdown
const classMap = new Map<string, { className: string; totalSeats: number; booked: number }>();
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 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 => ({
const byClass = [...classMap.values()].map((c) => ({
...c,
occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
occupancyRate:
c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
}));
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,
@@ -307,15 +411,68 @@ export class ReportsService {
};
}
async listSchedulesForPicker() {
const schedules = await this.prisma.trainSchedule.findMany({
select: {
id: true,
departureAt: true,
train: { select: { number: true } },
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
},
orderBy: { departureAt: "desc" },
take: 200,
});
return schedules.map((s) => ({
id: s.id,
label: `${s.train.number} · ${s.originStation.name}${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" })}`,
}));
}
async getPassengerList(scheduleId: string) {
const seats = await this.prisma.bookingSeat.findMany({
where: {
scheduleId,
booking: { status: { in: ["CONFIRMED", "BOARDED"] } },
},
include: {
booking: { select: { bookingRef: true, status: true } },
seat: {
include: {
coach: {
select: { number: true, coachType: { select: { name: true } } },
},
},
},
},
orderBy: [{ seat: { coach: { number: "asc" } } }],
});
return seats.map((bs) => ({
bookingRef: bs.booking.bookingRef,
bookingStatus: bs.booking.status,
passengerName: bs.passengerName,
passengerCategory: bs.passengerCategory,
idDocumentType: bs.idDocumentType,
idDocumentNumber: bs.idDocumentNumber,
passportNumber: bs.passportNumber,
passportCountry: bs.passportCountry,
seatLabel: bs.seatLabelSnapshot,
coachNumber: bs.seat?.coach?.number ?? null,
coachType: (bs.seat?.coach as any)?.coachType?.name ?? null,
}));
}
async getReport(reportId: string) {
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
return this.prisma.operationalReport.findUnique({
where: { id: reportId },
});
}
async listReports(reportType?: string) {
return this.prisma.operationalReport.findMany({
where: reportType ? { reportType } : {},
orderBy: { createdAt: 'desc' },
take: 50
orderBy: { createdAt: "desc" },
take: 50,
});
}
}

View File

@@ -31,6 +31,16 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
export class SeatsController {
constructor(private service: SeatsService) {}
// ── Blocked Seats ─────────────────────────────────────────────────────────
@Get('blocks')
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all blocked seats with reason and coach info' })
@ApiResponse({ status: 200, description: 'Blocked seat records' })
getBlockedSeats() {
return this.service.getBlockedSeats();
}
// ── Coach Availability ────────────────────────────────────────────────────
@Get('coaches/:scheduleId')
@SetMetadata('isPublic', true)

View File

@@ -615,6 +615,26 @@ export class SeatsService {
await this.prisma.journey.deleteMany({ where: { bookingId } as any });
}
async getBlockedSeats() {
const blocks = await this.prisma.seatBlock.findMany({
include: {
seat: { include: { coach: { select: { number: true } } } },
},
orderBy: { blockedAt: 'desc' },
});
return blocks.map(b => ({
id: b.id,
seatId: b.seatId,
seatNumber: b.seat.seatNumber,
coachNumber: b.seat.coach.number,
scheduleId: b.scheduleId,
reason: b.reason,
blockedBy: b.blockedBy,
blockedAt: b.blockedAt,
unblockAt: b.unblockAt,
}));
}
async getCoachesWithAvailability(scheduleId: string, originStationId?: string, destinationStationId?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },