Ticketing seats conflict issue resolution

This commit is contained in:
Stephanos A
2026-07-22 14:28:47 +03:00
parent 2632e2d50c
commit 7ff98bfb81

View File

@@ -210,15 +210,6 @@ export class TicketsService {
}); });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); 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) // Seats held by any active SeatHold (not yet expired)
const heldSeatIds = await this.prisma.seatHold.findMany({ const heldSeatIds = await this.prisma.seatHold.findMany({
where: { expiresAt: { gt: new Date() } }, where: { expiresAt: { gt: new Date() } },
@@ -230,42 +221,62 @@ export class TicketsService {
select: { seatId: true }, select: { seatId: true },
}).then(rows => new Set(rows.map(r => r.seatId))); }).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 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([ const unavailableIds = new Set([
...[...takenByOthers].filter(id => !ownSeatIds.has(id)),
...[...heldSeatIds], ...[...heldSeatIds],
...[...blockedSeatIds], ...[...blockedSeatIds],
]); ]);
const reassigned: { seatNumber: string; newSeatNumber: string }[] = [];
for (const bs of (booking as any).seats) { for (const bs of (booking as any).seats) {
const originalSeatId: string = bs.seatId; 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 // Seats taken by other confirmed/boarded bookings on THIS leg's schedule
if (!takenByOthers.has(originalSeatId) && !heldSeatIds.has(originalSeatId) && !blockedSeatIds.has(originalSeatId)) continue; 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 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({ const candidate = await this.prisma.seat.findFirst({
where: { where: {
status: 'AVAILABLE', status: 'AVAILABLE',
seatNumber: { not: '' }, seatNumber: { not: '' },
NOT: [ NOT: [
{ seatNumber: { startsWith: '-' } }, { seatNumber: { startsWith: '-' } },
{ id: { in: [...unavailableIds] } }, { id: { in: [...allUnavailable] } },
], ],
coach: { coach: {
assignments: { some: { scheduleId: booking.scheduleId } }, assignments: { some: { scheduleId: legScheduleId } },
...(coachTypeId ? { coachTypeId } : {}), ...(coachTypeId ? { coachTypeId } : {}),
}, },
}, },
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], 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) { if (!candidate) {
const className = bs.seat?.coach?.coachType?.name ?? 'the same class'; const className = bs.seat?.coach?.coachType?.name ?? 'the same class';
throw new ConflictException( throw new ConflictException(
@@ -278,10 +289,7 @@ export class TicketsService {
data: { seatId: candidate.id }, data: { seatId: candidate.id },
}); });
// Mark the newly assigned seat as taken so subsequent passengers in the
// same booking don't get assigned the same seat.
unavailableIds.add(candidate.id); unavailableIds.add(candidate.id);
reassigned.push({ seatNumber: bs.seat.seatNumber, newSeatNumber: candidate.seatNumber }); 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 // 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 // on the SAME schedule AND with OVERLAPPING segments are a real conflict.
// generate() run for this booking are NOT a conflict; they are cleaned up above. // Segment overlap: two bookings conflict on a seat when their stop-sequence ranges
const conflictingSeats = await this.prisma.bookingSeat.findMany({ // 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: { where: {
seatId: { in: seatIds }, OR: thisBookingSeats.map(bs => ({
booking: { seatId: bs.seatId,
id: { not: bookingId }, scheduleId: bs.scheduleId ?? booking.scheduleId,
status: { in: ['CONFIRMED', 'BOARDED'] }, 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( 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.`,
); );
} }