mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 20:40:55 +00:00
Update seat availability
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { JourneyDirection } from '../seats/seats.dto';
|
||||
import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
|
||||
|
||||
export interface Segment {
|
||||
fromStationId: string;
|
||||
@@ -54,7 +56,12 @@ export class SegmentsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a seat is free for the requested leg [reqFrom, reqTo).
|
||||
* Canonical per-seat availability check for a leg [reqFrom, reqTo) — the single
|
||||
* source of truth used by search results (availabilityByClass), the interactive
|
||||
* seatmap (SeatsService.resolveEffectiveStatuses), and hold-conflict checking, so
|
||||
* they can never disagree about whether a given seat is free. Previously
|
||||
* SeatsService maintained its own separately-written copy of this same
|
||||
* hold/booking-overlap logic, which could (and did) drift out of sync with this one.
|
||||
*
|
||||
* Overlap rule (strict): existingFrom < reqTo AND reqFrom < existingTo
|
||||
*
|
||||
@@ -66,99 +73,30 @@ export class SegmentsService {
|
||||
* P3: A(1) → D(4) reqFrom=1, reqTo=4
|
||||
* Check P3 vs P2: 1 < 4 AND 2 < 4 → true AND true → CONFLICT ✓
|
||||
*
|
||||
* journeyDirection lets a round-trip's OUTBOUND and RETURN holds coexist on the
|
||||
* same schedule without blocking each other (see checkDirectionConflict) — omit it
|
||||
* for one-way contexts, where it defaults to ONE_WAY (conflicts with anything).
|
||||
*
|
||||
* Sources checked:
|
||||
* 1. Active SeatHolds — leg decoded from createdBy JSON ({ originStationId, destinationStationId })
|
||||
* 1. Active SeatHolds — leg + direction decoded from createdBy JSON
|
||||
* ({ originStationId, destinationStationId, journeyDirection })
|
||||
* 2. Active JourneySegments — per-leg rows for CONFIRMED / PENDING_PAYMENT journeys
|
||||
* (JourneySegment carries no direction — a confirmed booking always blocks,
|
||||
* regardless of the requester's own direction)
|
||||
*
|
||||
* Returns a map from seatId to 'HELD' | 'BOOKED' — seats with no entry are free.
|
||||
* BOOKED takes priority when a seat is somehow reported as both.
|
||||
*/
|
||||
async isSeatFreeForLeg(
|
||||
scheduleId: string,
|
||||
seatId: string,
|
||||
reqFrom: number,
|
||||
reqTo: number,
|
||||
): Promise<boolean> {
|
||||
// ── Load stop-time sequences once ────────────────────────────────────────
|
||||
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
const seqOf = (stationId: string) =>
|
||||
stopTimes.find(s => s.stationId === stationId)?.sequence;
|
||||
|
||||
// ── 1. Active holds ───────────────────────────────────────────────────────
|
||||
const activeHolds = await this.prisma.seatHold.findMany({
|
||||
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
|
||||
});
|
||||
|
||||
for (const hold of activeHolds) {
|
||||
// Decode leg from createdBy JSON: { originStationId, destinationStationId, passengers }
|
||||
let holdFrom: number | undefined;
|
||||
let holdTo: number | undefined;
|
||||
try {
|
||||
if (hold.createdBy) {
|
||||
const meta = JSON.parse(hold.createdBy);
|
||||
holdFrom = seqOf(meta.originStationId);
|
||||
holdTo = seqOf(meta.destinationStationId);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (holdFrom !== undefined && holdTo !== undefined) {
|
||||
if (holdFrom < reqTo && reqFrom < holdTo) return false;
|
||||
} else {
|
||||
// Cannot resolve leg — conservative block
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Active JourneySegments ─────────────────────────────────────────────
|
||||
// Each row is one leg (e.g. A→B, B→C). We group by journeyId to get the
|
||||
// full range [min(depSeq), max(arrSeq)] per journey for this seat.
|
||||
const bookedLegs = await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
seatId,
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
});
|
||||
|
||||
// Group legs by journeyId → find the full range each journey occupies
|
||||
const journeyRanges = new Map<string, { from: number; to: number }>();
|
||||
for (const leg of bookedLegs) {
|
||||
const depSeq = seqOf(leg.departureStationId);
|
||||
const arrSeq = seqOf(leg.arrivalStationId);
|
||||
if (depSeq === undefined || arrSeq === undefined) continue;
|
||||
|
||||
const existing = journeyRanges.get(leg.journeyId);
|
||||
if (!existing) {
|
||||
journeyRanges.set(leg.journeyId, { from: depSeq, to: arrSeq });
|
||||
} else {
|
||||
journeyRanges.set(leg.journeyId, {
|
||||
from: Math.min(existing.from, depSeq),
|
||||
to: Math.max(existing.to, arrSeq),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { from, to } of journeyRanges.values()) {
|
||||
// Strict overlap: existingFrom < reqTo AND reqFrom < existingTo
|
||||
if (from < reqTo && reqFrom < to) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch availability check for multiple seats on a single schedule.
|
||||
* Replaces N×isSeatFreeForLeg calls with 2 queries total.
|
||||
* Returns a Set of seat IDs that are free for [reqFrom, reqTo).
|
||||
*/
|
||||
async getFreeSeatIds(
|
||||
async getSeatAvailabilityMap(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>,
|
||||
reqFrom: number,
|
||||
reqTo: number,
|
||||
): Promise<Set<string>> {
|
||||
if (seatIds.length === 0) return new Set();
|
||||
journeyDirection: JourneyDirection = JourneyDirection.ONE_WAY,
|
||||
): Promise<Map<string, 'HELD' | 'BOOKED'>> {
|
||||
const result = new Map<string, 'HELD' | 'BOOKED'>();
|
||||
if (seatIds.length === 0) return result;
|
||||
|
||||
const seqOf = (stationId: string) =>
|
||||
stopTimesForSeqLookup.find(s => s.stationId === stationId)?.sequence;
|
||||
@@ -181,29 +119,31 @@ export class SegmentsService {
|
||||
}),
|
||||
]);
|
||||
|
||||
// Determine which seats are blocked by active holds
|
||||
const holdBlockedSeats = new Set<string>();
|
||||
// ── 1. Active holds ────────────────────────────────────────────────────────
|
||||
for (const hold of allHolds) {
|
||||
let holdFrom: number | undefined;
|
||||
let holdTo: number | undefined;
|
||||
let holdDirection = JourneyDirection.ONE_WAY;
|
||||
try {
|
||||
if (hold.createdBy) {
|
||||
const meta = JSON.parse(hold.createdBy as string);
|
||||
holdFrom = seqOf(meta.originStationId);
|
||||
holdTo = seqOf(meta.destinationStationId);
|
||||
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
for (const sid of hold.seatIds) {
|
||||
if (!seatIdSet.has(sid)) continue;
|
||||
// Conservative block if leg can't be resolved; otherwise check overlap
|
||||
if (holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo)) {
|
||||
holdBlockedSeats.add(sid);
|
||||
}
|
||||
// Conservative block if leg can't be resolved; otherwise check overlap.
|
||||
const legsOverlap = holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo);
|
||||
if (!legsOverlap) continue;
|
||||
if (!checkDirectionConflict(journeyDirection, holdDirection)) continue;
|
||||
result.set(sid, 'HELD');
|
||||
}
|
||||
}
|
||||
|
||||
// Build full journey ranges per seat (group multi-leg journeys)
|
||||
// ── 2. Active JourneySegments — per-seat, per-journey leg ranges ──────────
|
||||
const journeyRangesBySeat = new Map<string, Map<string, { from: number; to: number }>>();
|
||||
for (const leg of bookedLegs) {
|
||||
if (!leg.seatId || !leg.journeyId || !leg.departureStationId || !leg.arrivalStationId) continue;
|
||||
@@ -220,20 +160,52 @@ export class SegmentsService {
|
||||
: { from: depSeq, to: arrSeq });
|
||||
}
|
||||
|
||||
const freeSeats = new Set<string>();
|
||||
for (const seatId of seatIds) {
|
||||
if (holdBlockedSeats.has(seatId)) continue;
|
||||
let blocked = false;
|
||||
const rangeMap = journeyRangesBySeat.get(seatId);
|
||||
if (rangeMap) {
|
||||
for (const { from, to } of rangeMap.values()) {
|
||||
if (from < reqTo && reqFrom < to) { blocked = true; break; }
|
||||
}
|
||||
if (!rangeMap) continue;
|
||||
for (const { from, to } of rangeMap.values()) {
|
||||
if (from < reqTo && reqFrom < to) { result.set(seatId, 'BOOKED'); break; }
|
||||
}
|
||||
if (!blocked) freeSeats.add(seatId);
|
||||
}
|
||||
|
||||
return freeSeats;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch availability check for multiple seats on a single schedule.
|
||||
* Thin wrapper around getSeatAvailabilityMap — returns just the free-seat set.
|
||||
*/
|
||||
async getFreeSeatIds(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>,
|
||||
reqFrom: number,
|
||||
reqTo: number,
|
||||
journeyDirection: JourneyDirection = JourneyDirection.ONE_WAY,
|
||||
): Promise<Set<string>> {
|
||||
if (seatIds.length === 0) return new Set();
|
||||
const statusMap = await this.getSeatAvailabilityMap(
|
||||
scheduleId, seatIds, stopTimesForSeqLookup, reqFrom, reqTo, journeyDirection,
|
||||
);
|
||||
return new Set(seatIds.filter(id => !statusMap.has(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-seat convenience wrapper around getSeatAvailabilityMap.
|
||||
*/
|
||||
async isSeatFreeForLeg(
|
||||
scheduleId: string,
|
||||
seatId: string,
|
||||
reqFrom: number,
|
||||
reqTo: number,
|
||||
journeyDirection: JourneyDirection = JourneyDirection.ONE_WAY,
|
||||
): Promise<boolean> {
|
||||
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
const freeSeats = await this.getFreeSeatIds(scheduleId, [seatId], stopTimes, reqFrom, reqTo, journeyDirection);
|
||||
return freeSeats.has(seatId);
|
||||
}
|
||||
|
||||
/** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */
|
||||
|
||||
Reference in New Issue
Block a user