mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Ticketing seats conflict issue resolution
This commit is contained in:
@@ -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.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user