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

@@ -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[]>();