mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 00:10:57 +00:00
Merge remote-tracking branch 'origin/dev' into tests
This commit is contained in:
@@ -9,9 +9,10 @@ import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
import { TicketsModule } from '../tickets/tickets.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule],
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, GuestBookingService],
|
||||
exports: [BookingsService, GuestBookingService]
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SeatsService } from '../seats/seats.service';
|
||||
import { TicketsService } from '../tickets/tickets.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
@@ -101,6 +102,7 @@ export class BookingsService {
|
||||
private readonly prisma: PrismaService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly seatsService: SeatsService,
|
||||
private readonly ticketsService: TicketsService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly verifaydaService: VerifaydaService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
@@ -1952,6 +1954,39 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-heal: if booking is CONFIRMED, payment SUCCEEDED, but tickets are missing
|
||||
// (ticket generation failed silently after payment — see finalizePaymentSuccess in
|
||||
// payments.service.ts), attempt to generate them now so the confirmation page
|
||||
// doesn't show "Not yet issued".
|
||||
if (
|
||||
booking.status === 'CONFIRMED' &&
|
||||
(booking as any).tickets?.length === 0 &&
|
||||
(booking as any).paymentIntent?.status === 'SUCCEEDED'
|
||||
) {
|
||||
try {
|
||||
await this.ticketsService.generate(booking.id);
|
||||
} catch (err) {
|
||||
this.logger.warn(`getByRef: generate failed for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}. Trying smart assign.`);
|
||||
try {
|
||||
await this.ticketsService.smartAssignAndGenerate(booking.id);
|
||||
} catch (retryErr) {
|
||||
this.logger.error(`getByRef: smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
|
||||
}
|
||||
}
|
||||
// Re-fetch to include any newly created tickets
|
||||
const refreshed = await this.prisma.booking.findUnique({
|
||||
where: { id: booking.id },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||
paymentIntent: true, tickets: true,
|
||||
priceTier: { select: { priceMinor: true } },
|
||||
},
|
||||
});
|
||||
if (refreshed) Object.assign(booking, refreshed);
|
||||
}
|
||||
|
||||
const outboundSegment = this.resolveSegmentStations(
|
||||
(booking as any).schedule,
|
||||
(booking as any).originStationId,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
} from "@nestjs/common";
|
||||
import { PrismaService } from "../../common/prisma.service";
|
||||
import { SeatsService } from "../seats/seats.service";
|
||||
@@ -826,6 +827,27 @@ export class PaymentsService {
|
||||
});
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
if (intent.status === PaymentIntentStatus.SUCCEEDED) {
|
||||
// Idempotency guard — but still repair missing tickets. They can be absent
|
||||
// when the first finalization threw from generate() after the transaction
|
||||
// committed: the caller got a 500, retried, and now hits this early-return.
|
||||
const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
|
||||
if (ticketCount === 0) {
|
||||
try {
|
||||
await this.ticketsService.generate(intent.bookingId);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
|
||||
);
|
||||
try {
|
||||
await this.ticketsService.smartAssignAndGenerate(intent.bookingId);
|
||||
} catch (retryErr) {
|
||||
this.logger.error(
|
||||
`Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { alreadyFinalized: true };
|
||||
}
|
||||
if (intent.status === PaymentIntentStatus.CANCELLED) {
|
||||
@@ -876,10 +898,25 @@ export class PaymentsService {
|
||||
try {
|
||||
await this.ticketsService.generate(booking.id);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error generating ticket: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
throw err;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Only reassign seats when a *different* booking genuinely holds the seat
|
||||
// (ConflictException). Any other error (transient DB issue, etc.) is logged
|
||||
// and swallowed — the passenger keeps their original seat and the ticket can
|
||||
// be retried via "Generate Missing" in the backoffice.
|
||||
if (err instanceof ConflictException) {
|
||||
this.logger.warn(
|
||||
`Seat conflict for booking ${booking.id}: ${msg}. Attempting smart seat reassignment.`,
|
||||
);
|
||||
try {
|
||||
await this.ticketsService.smartAssignAndGenerate(booking.id);
|
||||
} catch (retryErr) {
|
||||
this.logger.error(
|
||||
`Smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.error(`Error generating ticket for booking ${booking.id}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1005,6 +1042,11 @@ export class PaymentsService {
|
||||
intentId: intent.id,
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
}).catch((err) => {
|
||||
this.logger.error(
|
||||
`finalizePaymentSuccess failed for booking ${event.referenceId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return { alreadyFinalized: false };
|
||||
});
|
||||
return { processed: true, alreadyFinalized };
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@ export class ReportsController {
|
||||
return this.service.generateReport(dto);
|
||||
}
|
||||
|
||||
@Get("schedules")
|
||||
@ApiOperation({ summary: "List schedules for the passengers report picker" })
|
||||
listSchedulesForPicker() {
|
||||
return this.service.listSchedulesForPicker();
|
||||
@Get('schedules')
|
||||
@ApiOperation({ summary: 'List schedules for the passengers report picker' })
|
||||
listSchedulesForPicker(@Query('all') all?: string) {
|
||||
return this.service.listSchedulesForPicker(all === 'true');
|
||||
}
|
||||
|
||||
@Get("passengers/list")
|
||||
@@ -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,141 +263,85 @@ export class ReportsService {
|
||||
},
|
||||
},
|
||||
bookings: {
|
||||
where: { status: { in: ["CONFIRMED", "BOARDED"] } },
|
||||
include: {
|
||||
seats: {
|
||||
where: { leg: 1 },
|
||||
include: {
|
||||
seat: { include: { coach: { include: { coachType: 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 for this schedule — covers:
|
||||
// • outbound seats (leg=1, scheduleId=scheduleId)
|
||||
// • return seats (leg=2, booking.returnScheduleId=scheduleId)
|
||||
// • legacy rows where scheduleId is null but booking.scheduleId matches
|
||||
const allBookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||
{ leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||
{ scheduleId: null, leg: 1, booking: { scheduleId, 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,
|
||||
@@ -411,10 +355,10 @@ export class ReportsService {
|
||||
};
|
||||
}
|
||||
|
||||
async listSchedulesForPicker() {
|
||||
async listSchedulesForPicker(all = false) {
|
||||
const now = new Date();
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: { departureAt: { gte: now } },
|
||||
where: all ? undefined : { departureAt: { gte: now } },
|
||||
select: {
|
||||
id: true,
|
||||
departureAt: true,
|
||||
@@ -423,7 +367,7 @@ export class ReportsService {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
orderBy: { departureAt: 'asc' },
|
||||
orderBy: { departureAt: all ? 'desc' : 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
return schedules.map((s) => ({
|
||||
@@ -439,8 +383,11 @@ export class ReportsService {
|
||||
async getPassengerList(scheduleId: string) {
|
||||
const seats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
leg: 1,
|
||||
booking: { scheduleId, status: { in: ["CONFIRMED", "BOARDED"] } },
|
||||
OR: [
|
||||
{ scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||
{ leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||
{ scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
booking: {
|
||||
@@ -504,11 +451,14 @@ 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: {
|
||||
leg: 1,
|
||||
booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } },
|
||||
OR: [
|
||||
{ scheduleId, booking: { status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
|
||||
{ leg: 2, booking: { returnScheduleId: scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
|
||||
{ scheduleId: null, leg: 1, booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } } },
|
||||
],
|
||||
seat: { coach: { coachType: { type: { not: 'dining' } } } },
|
||||
},
|
||||
include: {
|
||||
@@ -538,11 +488,34 @@ export class ReportsService {
|
||||
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
|
||||
});
|
||||
|
||||
// Manually blocked seats for this schedule — exclude MAINTENANCE entries
|
||||
const blocks = await this.prisma.seatBlock.findMany({
|
||||
// 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,
|
||||
NOT: { reason: { startsWith: 'MAINTENANCE:' } },
|
||||
expiresAt: { lt: new Date(), gte: since24h },
|
||||
},
|
||||
orderBy: { expiresAt: 'desc' },
|
||||
});
|
||||
|
||||
// Manually blocked seats — schedule-scoped blocks for this schedule OR global blocks (scheduleId null)
|
||||
// Exclude MAINTENANCE and booking-system-created blocks
|
||||
const blocks = await this.prisma.seatBlock.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ scheduleId },
|
||||
{ scheduleId: null },
|
||||
],
|
||||
NOT: [
|
||||
{ reason: { startsWith: 'MAINTENANCE:' } },
|
||||
{ reason: { startsWith: 'Booked in tickets' } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
seat: {
|
||||
@@ -569,19 +542,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')
|
||||
|
||||
@@ -1069,6 +1069,7 @@ export class SeatsService {
|
||||
booking: {
|
||||
select: {
|
||||
id: true, bookingRef: true, scheduleId: true,
|
||||
originStationId: true, destinationStationId: true,
|
||||
createdAt: true, contactPhone: true,
|
||||
},
|
||||
},
|
||||
@@ -1086,7 +1087,8 @@ export class SeatsService {
|
||||
});
|
||||
const occupiedIds = new Set(journeySegments.map(js => js.seatId!));
|
||||
|
||||
// Group BookingSeat rows by (seatId::leg) to detect duplicates
|
||||
// Group BookingSeat rows by seatId::leg to find candidate duplicates,
|
||||
// then filter to only those whose booking segments actually overlap.
|
||||
type BS = (typeof bookingSeats)[number];
|
||||
const groups = new Map<string, BS[]>();
|
||||
for (const bs of bookingSeats) {
|
||||
@@ -1095,6 +1097,59 @@ export class SeatsService {
|
||||
groups.get(key)!.push(bs);
|
||||
}
|
||||
|
||||
// Build stop-sequence map for this schedule once
|
||||
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId: schedule.id },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
const seqOf = (stationId: string | null | undefined): number | undefined =>
|
||||
stationId ? stopTimes.find(s => s.stationId === stationId)?.sequence : undefined;
|
||||
|
||||
// Fetch JourneySegment ranges for all booking IDs in candidate groups
|
||||
const candidateBookingIds = [...new Set(
|
||||
[...groups.values()].filter(g => g.length > 1).flatMap(g => g.map(bs => bs.booking.id)),
|
||||
)];
|
||||
const candidateSegments = candidateBookingIds.length > 0
|
||||
? await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId: schedule.id,
|
||||
journey: { bookingId: { in: candidateBookingIds }, status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
select: { departureStationId: true, arrivalStationId: true, journey: { select: { bookingId: true } } },
|
||||
})
|
||||
: [];
|
||||
|
||||
// Collapse per-booking segments into a single [from, to) range
|
||||
const rangeByBookingId = new Map<string, { from: number; to: number }>();
|
||||
for (const seg of candidateSegments) {
|
||||
const bookingId = seg.journey.bookingId;
|
||||
if (!bookingId) continue;
|
||||
const depSeq = seqOf(seg.departureStationId);
|
||||
const arrSeq = seqOf(seg.arrivalStationId);
|
||||
if (depSeq === undefined || arrSeq === undefined) continue;
|
||||
const existing = rangeByBookingId.get(bookingId);
|
||||
rangeByBookingId.set(bookingId, existing
|
||||
? { from: Math.min(existing.from, depSeq), to: Math.max(existing.to, arrSeq) }
|
||||
: { from: depSeq, to: arrSeq });
|
||||
}
|
||||
|
||||
// Fall back to booking-level origin/destination when JourneySegments are missing
|
||||
const rangeForBooking = (bs: BS): { from: number; to: number } | null => {
|
||||
const fromSegments = rangeByBookingId.get(bs.booking.id);
|
||||
if (fromSegments) return fromSegments;
|
||||
// BookingSeat.scheduleId tells us which leg this seat belongs to
|
||||
const bsScheduleId = bs.scheduleId ?? bs.booking.scheduleId;
|
||||
if (bsScheduleId !== schedule.id) return null;
|
||||
const from = seqOf(bs.booking.originStationId);
|
||||
const to = seqOf(bs.booking.destinationStationId);
|
||||
if (from === undefined || to === undefined) return null;
|
||||
return { from, to };
|
||||
};
|
||||
|
||||
// Two bookings are true duplicates only if their segments overlap
|
||||
const segmentsOverlap = (a: { from: number; to: number }, b: { from: number; to: number }) =>
|
||||
a.from < b.to && b.from < a.to;
|
||||
|
||||
// All seats held by any confirmed BookingSeat — union of JourneySegment-based
|
||||
// occupancy AND BookingSeat-based occupancy so that seats whose JourneySegments
|
||||
// are missing (e.g. created via enhanced-seats path without bookingId) are still
|
||||
@@ -1114,13 +1169,30 @@ export class SeatsService {
|
||||
for (const [key, group] of groups) {
|
||||
if (group.length <= 1) continue;
|
||||
if (group[0].seat.coachId !== coach.id) continue;
|
||||
|
||||
// Filter to bookings that actually have overlapping segments
|
||||
const overlapping: BS[] = [];
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
const rangeA = rangeForBooking(group[i]);
|
||||
for (let j = i + 1; j < group.length; j++) {
|
||||
const rangeB = rangeForBooking(group[j]);
|
||||
// If either range is unknown, conservatively treat as overlap
|
||||
const isOverlap = !rangeA || !rangeB || segmentsOverlap(rangeA, rangeB);
|
||||
if (isOverlap) {
|
||||
if (!overlapping.includes(group[i])) overlapping.push(group[i]);
|
||||
if (!overlapping.includes(group[j])) overlapping.push(group[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (overlapping.length <= 1) continue;
|
||||
|
||||
const [seatId] = key.split('::');
|
||||
const seat = coach.seats.find(s => s.id === seatId);
|
||||
duplicates.push({
|
||||
seatId,
|
||||
seatNumber: seat?.seatNumber ?? seatId,
|
||||
leg: group[0].leg,
|
||||
bookings: group.map(bs => ({
|
||||
leg: overlapping[0].leg,
|
||||
bookings: overlapping.map(bs => ({
|
||||
bookingSeatId: bs.id,
|
||||
bookingId: bs.booking.id,
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@@ -10,6 +9,17 @@ import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
export class TicketsController {
|
||||
constructor(private service: TicketsService) {}
|
||||
|
||||
@Post('generate-missing')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Generate tickets for all confirmed bookings that are missing them',
|
||||
description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed counts.',
|
||||
})
|
||||
generateMissing() {
|
||||
return this.service.generateMissing();
|
||||
}
|
||||
|
||||
@Post('smart-assign/:bookingId')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@@ -27,8 +37,8 @@ export class TicketsController {
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Generate ticket for booking (confirmation page)',
|
||||
description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.'
|
||||
summary: 'Generate ticket for booking',
|
||||
description: 'Creates a ticket for a confirmed booking with succeeded payment. Requires payment to be SUCCEEDED and booking to be CONFIRMED.'
|
||||
})
|
||||
generateTicket(@Param('bookingId') bookingId: string) {
|
||||
return this.service.generate(bookingId);
|
||||
@@ -45,8 +55,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'List all tickets with optional filters' })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
@@ -88,8 +98,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get('by-order/:merchantOrderId')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get ticket by merchant order ID',
|
||||
description: 'Looks up the booking ID from the PaymentIntent using merchantOrderId, then returns the full ticket information.'
|
||||
@@ -106,8 +116,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post('scan-board/:qrCodeOrRef')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Scan QR code or booking ref and automatically board ticket',
|
||||
description: 'Scans ticket QR code or booking reference and automatically boards the passenger. Handles errors like expired tickets, already used tickets, etc. Designed for mobile boarding interface.'
|
||||
@@ -131,8 +141,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post(':bookingRef/validate')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Validate ticket at gate with audit logging',
|
||||
description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.'
|
||||
@@ -162,24 +172,24 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get(':ticketId/validation-logs')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Get validation logs for ticket' })
|
||||
getValidationLogs(@Param('ticketId') ticketId: string) {
|
||||
return this.service.getValidationLogs(ticketId);
|
||||
}
|
||||
|
||||
@Get('offline/export')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Export tickets for offline validation' })
|
||||
exportOfflineData(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.exportOfflineData(scheduleId);
|
||||
}
|
||||
|
||||
@Post('validate/offline')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Batch import offline validations',
|
||||
description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.'
|
||||
@@ -221,8 +231,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Patch(':id/restore')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
|
||||
restore(@Param('id') id: string) {
|
||||
return this.service.restore(id);
|
||||
|
||||
@@ -210,15 +210,6 @@ export class TicketsService {
|
||||
});
|
||||
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() } },
|
||||
@@ -230,42 +221,62 @@ export class TicketsService {
|
||||
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 reassigned: { seatNumber: string; newSeatNumber: string }[] = [];
|
||||
|
||||
// Track newly assigned seats so the same seat isn't given to two passengers
|
||||
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;
|
||||
// Use the per-seat scheduleId — for ROUND_TRIP leg 2 this is the return schedule,
|
||||
// not booking.scheduleId (the outbound schedule).
|
||||
const legScheduleId: string = bs.scheduleId ?? booking.scheduleId;
|
||||
|
||||
// Case 1: original seat is still free — nothing to do
|
||||
if (!takenByOthers.has(originalSeatId) && !heldSeatIds.has(originalSeatId) && !blockedSeatIds.has(originalSeatId)) continue;
|
||||
// Seats taken by other confirmed/boarded bookings on THIS leg's schedule
|
||||
const takenByOthersOnLeg = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
seat: { coach: { assignments: { some: { scheduleId: legScheduleId } } } },
|
||||
},
|
||||
select: { seatId: true },
|
||||
}).then(rows => new Set(rows.map(r => r.seatId)));
|
||||
|
||||
// Case 2: original seat is unavailable — find a truly available seat in the same coach type
|
||||
// Case 1: original seat is still free on this leg — nothing to do
|
||||
if (
|
||||
!takenByOthersOnLeg.has(originalSeatId) &&
|
||||
!heldSeatIds.has(originalSeatId) &&
|
||||
!blockedSeatIds.has(originalSeatId)
|
||||
) continue;
|
||||
|
||||
// Case 2: original seat is unavailable — find a free seat of the same coach type on this leg's schedule
|
||||
const coachTypeId: string | undefined = bs.seat?.coach?.coachTypeId;
|
||||
|
||||
const allUnavailable = new Set([
|
||||
...[...takenByOthersOnLeg].filter(id => !ownSeatIds.has(id)),
|
||||
...[...unavailableIds],
|
||||
]);
|
||||
|
||||
const candidate = await this.prisma.seat.findFirst({
|
||||
where: {
|
||||
status: 'AVAILABLE',
|
||||
seatNumber: { not: '' },
|
||||
NOT: [
|
||||
{ seatNumber: { startsWith: '-' } },
|
||||
{ id: { in: [...unavailableIds] } },
|
||||
{ id: { in: [...allUnavailable] } },
|
||||
],
|
||||
coach: {
|
||||
assignments: { some: { scheduleId: booking.scheduleId } },
|
||||
assignments: { some: { scheduleId: legScheduleId } },
|
||||
...(coachTypeId ? { coachTypeId } : {}),
|
||||
},
|
||||
},
|
||||
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
|
||||
});
|
||||
|
||||
// Case 3: no seats left in that class
|
||||
// Case 3: no seats left in that class on this leg
|
||||
if (!candidate) {
|
||||
const className = bs.seat?.coach?.coachType?.name ?? 'the same class';
|
||||
throw new ConflictException(
|
||||
@@ -278,10 +289,7 @@ export class TicketsService {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -352,54 +360,125 @@ export class TicketsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for seat conflicts before deleting existing tickets or issuing new ones
|
||||
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
||||
|
||||
// Remove any SeatBlock rows left over from a previous generate() run for this
|
||||
// booking — they reference the old ticket IDs which are now deleted, and would
|
||||
// otherwise cause the conflict check below to see this booking's own seats as
|
||||
// blocked by another booking.
|
||||
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 },
|
||||
await this.prisma.seatBlock.deleteMany({
|
||||
where: { seatId: { in: seatIds }, blockedBy: 'SYSTEM' },
|
||||
});
|
||||
if (conflictingSeats.length > 0) {
|
||||
const labels = [...new Set(conflictingSeats.map((s: any) => s.seat.seatNumber))].join(', ');
|
||||
|
||||
// Check for seat conflicts — only seats confirmed/boarded by a *different* booking
|
||||
// on the SAME schedule AND with OVERLAPPING segments are a real conflict.
|
||||
// Segment overlap: two bookings conflict on a seat when their stop-sequence ranges
|
||||
// overlap: A.originSeq < B.destSeq AND B.originSeq < A.destSeq.
|
||||
// We resolve sequences via TripStopTime using each booking's originStationId /
|
||||
// destinationStationId. Bookings with no station IDs (full-route) are treated as
|
||||
// seq 0 → ∞ and always overlap.
|
||||
const thisBookingSeats = (booking as any).seats as Array<{ seatId: string; scheduleId: string | null }>;
|
||||
|
||||
// Resolve this booking's stop sequences per leg schedule
|
||||
const thisSeqMap = new Map<string, { originSeq: number; destSeq: number }>();
|
||||
const legScheduleIds = [...new Set(thisBookingSeats.map(bs => bs.scheduleId ?? booking.scheduleId))];
|
||||
for (const schedId of legScheduleIds) {
|
||||
const originId = (booking as any).originStationId;
|
||||
const destId = (booking as any).destinationStationId;
|
||||
if (!originId || !destId) {
|
||||
thisSeqMap.set(schedId, { originSeq: 0, destSeq: Number.MAX_SAFE_INTEGER });
|
||||
continue;
|
||||
}
|
||||
const stops = await this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId: schedId, stationId: { in: [originId, destId] } },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
const oStop = stops.find(s => s.stationId === originId);
|
||||
const dStop = stops.find(s => s.stationId === destId);
|
||||
thisSeqMap.set(schedId, {
|
||||
originSeq: oStop?.sequence ?? 0,
|
||||
destSeq: dStop?.sequence ?? Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
}
|
||||
|
||||
// Find other confirmed/boarded bookings that share any (seatId, scheduleId) pair
|
||||
const candidateConflicts = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
OR: thisBookingSeats.map(bs => ({
|
||||
seatId: bs.seatId,
|
||||
scheduleId: bs.scheduleId ?? booking.scheduleId,
|
||||
booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
})),
|
||||
},
|
||||
include: {
|
||||
seat: true,
|
||||
booking: { select: { id: true, originStationId: true, destinationStationId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const trueConflicts: string[] = [];
|
||||
for (const other of candidateConflicts) {
|
||||
const legScheduleId = other.scheduleId ?? booking.scheduleId;
|
||||
const thisSeq = thisSeqMap.get(legScheduleId) ?? { originSeq: 0, destSeq: Number.MAX_SAFE_INTEGER };
|
||||
|
||||
const otherOriginId = (other.booking as any).originStationId;
|
||||
const otherDestId = (other.booking as any).destinationStationId;
|
||||
let otherOriginSeq = 0;
|
||||
let otherDestSeq = Number.MAX_SAFE_INTEGER;
|
||||
if (otherOriginId && otherDestId) {
|
||||
const stops = await this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId: legScheduleId, stationId: { in: [otherOriginId, otherDestId] } },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
otherOriginSeq = stops.find(s => s.stationId === otherOriginId)?.sequence ?? 0;
|
||||
otherDestSeq = stops.find(s => s.stationId === otherDestId)?.sequence ?? Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
// Segments overlap when: thisOrigin < otherDest AND otherOrigin < thisDest
|
||||
if (thisSeq.originSeq < otherDestSeq && otherOriginSeq < thisSeq.destSeq) {
|
||||
trueConflicts.push((other as any).seat.seatNumber);
|
||||
}
|
||||
}
|
||||
|
||||
if (trueConflicts.length > 0) {
|
||||
const labels = [...new Set(trueConflicts)].join(', ');
|
||||
throw new ConflictException(
|
||||
`Seat(s) ${labels} are already confirmed for another booking.`,
|
||||
`Seat(s) ${labels} are already confirmed for another booking on the same schedule and overlapping segment.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
||||
|
||||
// Generate one ticket per unique passenger (grouped by passengerName)
|
||||
// Generate one ticket per passenger per leg.
|
||||
// Round-trip / transit bookings have seats on multiple legs — each leg needs its own
|
||||
// ticket so the voucher can match by (passengerName, leg) and gate scanners can
|
||||
// validate each leg independently.
|
||||
const tickets = [];
|
||||
|
||||
// Group seats by passenger
|
||||
const passengerSeatsMap = new Map<string, any[]>();
|
||||
|
||||
// Group seats by (passengerName, leg)
|
||||
const passengerLegSeatsMap = new Map<string, any[]>();
|
||||
for (const bookingSeat of (booking as any).seats) {
|
||||
const key = bookingSeat.passengerName;
|
||||
if (!passengerSeatsMap.has(key)) {
|
||||
passengerSeatsMap.set(key, []);
|
||||
const key = `${bookingSeat.passengerName}|${bookingSeat.leg ?? 1}`;
|
||||
if (!passengerLegSeatsMap.has(key)) {
|
||||
passengerLegSeatsMap.set(key, []);
|
||||
}
|
||||
passengerSeatsMap.get(key)!.push(bookingSeat);
|
||||
passengerLegSeatsMap.get(key)!.push(bookingSeat);
|
||||
}
|
||||
|
||||
// Create one ticket per passenger
|
||||
for (const [passengerName, passengerSeats] of passengerSeatsMap.entries()) {
|
||||
// Use first seat for primary data
|
||||
const primarySeat = passengerSeats[0];
|
||||
|
||||
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
|
||||
// Create one ticket per (passenger, leg)
|
||||
for (const [key, legSeats] of passengerLegSeatsMap.entries()) {
|
||||
const [passengerName] = key.split('|');
|
||||
const primarySeat = legSeats[0];
|
||||
const leg = primarySeat.leg ?? 1;
|
||||
|
||||
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}L${leg}`;
|
||||
|
||||
// Re-encode QR with ticketNumber included
|
||||
const qrDataWithTicket = JSON.stringify({
|
||||
ref: booking.bookingRef,
|
||||
ticketNumber: barcodePayload,
|
||||
type: booking.bookingType,
|
||||
passenger: passengerName,
|
||||
seats: passengerSeats.map(ps => ({
|
||||
leg,
|
||||
seats: legSeats.map(ps => ({
|
||||
seat: ps.seat?.seatNumber,
|
||||
coach: ps.seat?.coach?.number,
|
||||
leg: ps.leg || 1,
|
||||
@@ -414,7 +493,7 @@ export class TicketsService {
|
||||
bookingRef: booking.bookingRef,
|
||||
passengerName,
|
||||
seatId: primarySeat.seatId,
|
||||
leg: primarySeat.leg || 1,
|
||||
leg,
|
||||
scheduleId: primarySeat.scheduleId || booking.scheduleId,
|
||||
qrPayload: qrPayloadFinal,
|
||||
barcodePayload,
|
||||
@@ -826,6 +905,34 @@ export class TicketsService {
|
||||
};
|
||||
}
|
||||
|
||||
async generateMissing(): Promise<{ processed: number; generated: number; failed: number; details: any[] }> {
|
||||
const confirmedWithNoTickets = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'CONFIRMED',
|
||||
tickets: { none: {} },
|
||||
paymentIntent: { status: 'SUCCEEDED' },
|
||||
},
|
||||
select: { id: true, bookingRef: true },
|
||||
});
|
||||
|
||||
const details: any[] = [];
|
||||
let generated = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const booking of confirmedWithNoTickets) {
|
||||
try {
|
||||
await this.generate(booking.id);
|
||||
generated++;
|
||||
details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'generated' });
|
||||
} catch (err) {
|
||||
failed++;
|
||||
details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'failed', error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return { processed: confirmedWithNoTickets.length, generated, failed, details };
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { id } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
Reference in New Issue
Block a user