Generate ticket, dashboard, excess luggage rate updates

This commit is contained in:
Stephanos A
2026-07-16 08:41:35 +03:00
parent 2d2fcfbda9
commit b2b31f30bb
16 changed files with 585 additions and 315 deletions

View File

@@ -612,7 +612,7 @@ export class BookingsService {
passenger: { select: { id: true, iamUserId: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
seats: { include: { seat: true } },
seats: { include: { seat: { include: { coach: true } } } },
package: { select: { id: true, name: true, code: true } },
priceTier: { select: { id: true, label: true, priceMinor: true } },
},
@@ -671,7 +671,7 @@ export class BookingsService {
const mappedRegular = regularItems.map((booking: any) => {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory, seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: s.seat.coach?.number ?? null } : null }));
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
// Resolve contact: DB row → IAM → TravelerProfile notes → seat name fallback
@@ -704,6 +704,15 @@ export class BookingsService {
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers,
seats: booking.seats.map((s: any) => ({
passengerName: s.passengerName,
passengerCategory: s.passengerCategory,
leg: s.leg ?? 1,
fareMinor: s.fareMinor,
idDocumentType: s.idDocumentType,
verifaydaVerified: s.verifaydaVerified,
seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: { number: s.seat.coach?.number ?? null } } : null,
})),
schedule: {
train: booking.schedule.train,
originStation: (booking as any).originStationId

View File

@@ -1,14 +1,23 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, SetMetadata, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { DashboardService } from './dashboard.service';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
@ApiTags('Dashboard')
@Controller('dashboard')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class DashboardController {
constructor(private service: DashboardService) {}
@Get(':passengerId') @ApiOperation({ summary: 'Get home dashboard aggregate for passenger' })
@Get('backoffice-stats')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' })
getBackofficeStats() { return this.service.getBackofficeStats(); }
@Get(':passengerId')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get home dashboard aggregate for passenger' })
getHomeDashboard(@Param('passengerId') id: string) { return this.service.getHomeDashboard(id); }
}

View File

@@ -10,6 +10,57 @@ export class DashboardService {
@InjectDataSource() private dataSource: DataSource,
) {}
async getBackofficeStats() {
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, revenueRows, packageRevenueRows] =
await Promise.all([
this.prisma.booking.count(),
this.prisma.booking.count({ where: { packageId: { not: null } } }),
this.prisma.ticket.count(),
this.prisma.passenger.count(),
this.prisma.$queryRaw<{ currency: string; total: bigint }[]>`
SELECT
COALESCE("displayCurrency"::text, "currency"::text) AS currency,
SUM(COALESCE("displayTotalMinor", "totalMinor")) AS total
FROM passenger."Booking"
WHERE status IN ('CONFIRMED', 'BOARDED')
AND "packageId" IS NULL
AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED')
GROUP BY COALESCE("displayCurrency"::text, "currency"::text)
`,
this.prisma.$queryRaw<{ currency: string; total: bigint }[]>`
SELECT
COALESCE("displayCurrency"::text, "currency"::text) AS currency,
SUM(COALESCE("displayTotalMinor", "totalMinor")) AS total
FROM passenger."Booking"
WHERE status IN ('CONFIRMED', 'BOARDED')
AND "packageId" IS NOT NULL
AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED')
GROUP BY COALESCE("displayCurrency"::text, "currency"::text)
`,
]);
const totalPackageTickets = await this.prisma.ticket.count({
where: { booking: { packageId: { not: null } } },
});
const toMap = (rows: { currency: string; total: bigint }[]) =>
Object.entries(
rows.reduce((m, r) => { m[r.currency] = Number(r.total); return m; }, {} as Record<string, number>),
).map(([currency, totalMinor]) => ({ currency, totalMinor }));
return {
totalBookings,
totalPackageBookings,
totalNormalBookings: totalBookings - totalPackageBookings,
totalTickets,
totalPackageTickets,
totalNormalTickets: totalTickets - totalPackageTickets,
totalPassengers,
revenueByCurrency: toMap(revenueRows),
packageRevenueByCurrency: toMap(packageRevenueRows),
};
}
async getHomeDashboard(passengerId: string) {
const now = new Date();
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { IsInt, IsPositive, IsString } from 'class-validator';
import { IsInt, IsOptional, IsPositive, IsString } from 'class-validator';
import { ExcessBaggageService } from './excess-baggage.service';
import {
LogExcessBaggageDto,
@@ -12,8 +12,8 @@ import { PassengerAdmin } from '../../common/passenger-guards';
class UpsertBaggageAllowanceDto {
@IsString() seatClassId: string;
@IsInt() @IsPositive() maxWeightKg: number;
@IsInt() @IsPositive() maxPiecesCount: number;
@IsOptional() @IsInt() maxWeightKg?: number;
@IsOptional() @IsInt() maxPiecesCount?: number;
@IsInt() @IsPositive() excessFeePerKg: number;
}

View File

@@ -42,7 +42,6 @@ export class ExcessBaggageService {
const booking = await this.prisma.booking.findUnique({
where: { id: dto.bookingId },
include: {
seats: { take: 1, include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } },
},
});
@@ -51,20 +50,9 @@ export class ExcessBaggageService {
throw new BadRequestException('Booking must be CONFIRMED or BOARDED to log excess baggage');
}
// Resolve fee per kg from BaggageAllowance via seat class
const coachTypeId = booking.seats[0]?.seat?.coach?.coachTypeId;
let feePerKgMinor = 5000; // 50 ETB default fallback (in minor)
if (coachTypeId) {
const seatClass = await this.prisma.seatClass.findFirst({
where: { coachTypeId },
});
if (seatClass) {
const allowance = await this.prisma.baggageAllowance.findFirst({
where: { seatClassId: seatClass.id },
});
if (allowance) feePerKgMinor = allowance.excessFeePerKg;
}
}
const allowance = await this.prisma.baggageAllowance.findFirst({ orderBy: { createdAt: 'asc' } });
if (!allowance) throw new BadRequestException('No excess baggage rate configured. Please set a rate in Tariff Rates.');
const feePerKgMinor = allowance.excessFeePerKg;
const totalMinor = feePerKgMinor * dto.excessWeightKg;
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
@@ -289,11 +277,16 @@ export class ExcessBaggageService {
return allowances.map(a => ({ ...a, seatClass: scMap.get(a.seatClassId) ?? null }));
}
async upsertAllowance(dto: { seatClassId: string; maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }) {
return this.prisma.baggageAllowance.upsert({
where: { seatClassId: dto.seatClassId } as any,
update: { maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg },
create: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg },
async upsertAllowance(dto: { seatClassId: string; maxWeightKg?: number; maxPiecesCount?: number; excessFeePerKg: number }) {
const existing = await this.prisma.baggageAllowance.findFirst({ where: { seatClassId: dto.seatClassId } });
if (existing) {
return this.prisma.baggageAllowance.update({
where: { id: existing.id },
data: { maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg },
});
}
return this.prisma.baggageAllowance.create({
data: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg },
});
}
@@ -302,7 +295,7 @@ export class ExcessBaggageService {
}
async deleteAllowance(id: string) {
await this.prisma.baggageAllowance.delete({ where: { id } });
await this.prisma.baggageAllowance.deleteMany({ where: { id } });
return { deleted: true };
}

View File

@@ -9,6 +9,19 @@ import { PassengerAdmin } from '../../common/passenger-guards';
export class TicketsController {
constructor(private service: TicketsService) {}
@Post('smart-assign/:bookingId')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Smart seat assignment + ticket generation',
description:
'Keeps original seats if still free, auto-reassigns to an available seat of the same coach type if taken, ' +
'or throws 409 if the schedule is fully booked in that class.',
})
smartAssignAndGenerate(@Param('bookingId') bookingId: string) {
return this.service.smartAssignAndGenerate(bookingId);
}
@Post('generate/:bookingId')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException, ConflictException, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
@@ -174,6 +174,109 @@ export class TicketsService {
};
}
// Smart seat assignment for conflict resolution:
// 1. If the original seat is still free → keep it and generate
// 2. If the original seat is taken → find a truly available seat in the same coach type
// (excludes: confirmed/boarded bookings, active holds, seat blocks, BOOKED/HELD/REMOVED status)
// 3. If no seats of that class remain → throw so the agent is notified
async smartAssignAndGenerate(bookingId: string) {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: {
seats: {
include: {
seat: { include: { coach: { include: { coachType: true } } } },
},
},
},
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
// Seats taken by other confirmed/boarded bookings on this schedule
const takenByOthers = await this.prisma.bookingSeat.findMany({
where: {
booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } },
seat: { coach: { assignments: { some: { scheduleId: booking.scheduleId } } } },
},
select: { seatId: true },
}).then(rows => new Set(rows.map(r => r.seatId)));
// Seats held by any active SeatHold (not yet expired)
const heldSeatIds = await this.prisma.seatHold.findMany({
where: { expiresAt: { gt: new Date() } },
select: { seatIds: true },
}).then(rows => new Set(rows.flatMap(r => r.seatIds)));
// Seats with an active SeatBlock
const blockedSeatIds = await this.prisma.seatBlock.findMany({
select: { seatId: true },
}).then(rows => new Set(rows.map(r => r.seatId)));
// Union of all unavailable seat IDs (excluding the booking's own seats)
const ownSeatIds = new Set((booking as any).seats.map((bs: any) => bs.seatId as string));
const unavailableIds = new Set([
...[...takenByOthers].filter(id => !ownSeatIds.has(id)),
...[...heldSeatIds],
...[...blockedSeatIds],
]);
const reassigned: { seatNumber: string; newSeatNumber: string }[] = [];
for (const bs of (booking as any).seats) {
const originalSeatId: string = bs.seatId;
// Case 1: original seat is still free — nothing to do
if (!takenByOthers.has(originalSeatId) && !heldSeatIds.has(originalSeatId) && !blockedSeatIds.has(originalSeatId)) continue;
// Case 2: original seat is unavailable — find a truly available seat in the same coach type
const coachTypeId: string | undefined = bs.seat?.coach?.coachTypeId;
const candidate = await this.prisma.seat.findFirst({
where: {
status: 'AVAILABLE',
seatNumber: { not: '' },
NOT: [
{ seatNumber: { startsWith: '-' } },
{ id: { in: [...unavailableIds] } },
],
coach: {
assignments: { some: { scheduleId: booking.scheduleId } },
...(coachTypeId ? { coachTypeId } : {}),
},
},
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
});
// Case 3: no seats left in that class
if (!candidate) {
const className = bs.seat?.coach?.coachType?.name ?? 'the same class';
throw new ConflictException(
`No available seats remaining in ${className} on this schedule. Please contact the passenger to arrange an alternative.`,
);
}
await this.prisma.bookingSeat.update({
where: { id: bs.id },
data: { seatId: candidate.id },
});
// Mark the newly assigned seat as taken so subsequent passengers in the
// same booking don't get assigned the same seat.
unavailableIds.add(candidate.id);
reassigned.push({ seatNumber: bs.seat.seatNumber, newSeatNumber: candidate.seatNumber });
}
await this.auditService.log({
action: 'UPDATE',
entityType: 'Booking',
entityId: bookingId,
newData: { smartReassigned: true, changes: reassigned },
});
return this.generate(bookingId);
}
async generate(bookingId: string) {
if (!bookingId) throw new BadRequestException('Booking ID is required');
@@ -231,11 +334,29 @@ export class TicketsService {
}
}
// Check for seat conflicts before deleting existing tickets or issuing new ones
const seatIds = (booking as any).seats.map((bs: any) => bs.seatId);
const conflictingSeats = await this.prisma.bookingSeat.findMany({
where: {
seatId: { in: seatIds },
booking: {
id: { not: bookingId },
status: { in: ['CONFIRMED', 'BOARDED'] },
},
},
include: { seat: true },
});
if (conflictingSeats.length > 0) {
const labels = [...new Set(conflictingSeats.map((s: any) => s.seat.seatNumber))].join(', ');
throw new ConflictException(
`Seat(s) ${labels} are already confirmed for another booking.`,
);
}
await this.prisma.ticket.deleteMany({ where: { bookingId } });
// Generate one ticket per unique passenger (grouped by passengerName)
const tickets = [];
const seatIds = (booking as any).seats.map((bs: any) => bs.seatId);
// Group seats by passenger
const passengerSeatsMap = new Map<string, any[]>();