mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #909 from Tria-plc/alpha
Ticketing seats conflict issue resolution
This commit is contained in:
@@ -34,10 +34,11 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post('generate/:bookingId')
|
||||
@SetMetadata('isPublic', true)
|
||||
@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. Requires payment to be SUCCEEDED and booking to be CONFIRMED.'
|
||||
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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -364,22 +372,79 @@ export class TicketsService {
|
||||
});
|
||||
|
||||
// Check for seat conflicts — only seats confirmed/boarded by a *different* booking
|
||||
// on the same schedule are a real conflict. SeatBlock rows created by a previous
|
||||
// generate() run for this booking are NOT a conflict; they are cleaned up above.
|
||||
const conflictingSeats = await this.prisma.bookingSeat.findMany({
|
||||
// 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: {
|
||||
seatId: { in: seatIds },
|
||||
booking: {
|
||||
id: { not: bookingId },
|
||||
status: { in: ['CONFIRMED', 'BOARDED'] },
|
||||
},
|
||||
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 } },
|
||||
},
|
||||
include: { seat: true },
|
||||
});
|
||||
if (conflictingSeats.length > 0) {
|
||||
const labels = [...new Set(conflictingSeats.map((s: any) => s.seat.seatNumber))].join(', ');
|
||||
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user