Merge remote-tracking branch 'origin/dev' into tests

This commit is contained in:
Muluhabt
2026-07-22 16:33:07 +03:00
212 changed files with 11497 additions and 3447 deletions

View File

@@ -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]

View File

@@ -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,

View File

@@ -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 };
}

View File

@@ -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) {

View File

@@ -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')

View File

@@ -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,

View File

@@ -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);

View File

@@ -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');