mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Update seat availability
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
import { JourneyDirection } from '../../modules/seats/seats.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared by SeatsService (seatmap display, hold-creation conflict checks) and
|
||||||
|
* SegmentsService (search results' availability counts, EnhancedSeatsService) — the
|
||||||
|
* single source of truth for whether two journey directions on the same schedule
|
||||||
|
* should be treated as conflicting. Without this, a round-trip's OUTBOUND and RETURN
|
||||||
|
* legs on the same schedule would wrongly block each other's seats.
|
||||||
|
*
|
||||||
|
* Check if two journey directions conflict (should not be allowed simultaneously).
|
||||||
|
* For round-trip bookings: OUTBOUND and RETURN should NOT conflict on the same schedule.
|
||||||
|
*/
|
||||||
|
export function checkDirectionConflict(current: JourneyDirection, existing: JourneyDirection): boolean {
|
||||||
|
// OUTBOUND and RETURN are allowed simultaneously (round-trip on the same schedule)
|
||||||
|
if ((current === JourneyDirection.OUTBOUND && existing === JourneyDirection.RETURN) ||
|
||||||
|
(current === JourneyDirection.RETURN && existing === JourneyDirection.OUTBOUND)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same directions conflict (e.g., two OUTBOUND or two RETURN bookings)
|
||||||
|
if (current === existing) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ONE_WAY conflicts with other ONE_WAY bookings only
|
||||||
|
if (current === JourneyDirection.ONE_WAY && existing === JourneyDirection.ONE_WAY) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ONE_WAY with OUTBOUND/RETURN: conflict (to maintain safety for legacy bookings)
|
||||||
|
if (current === JourneyDirection.ONE_WAY || existing === JourneyDirection.ONE_WAY) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: no conflict
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { SegmentsService } from '../segments/segments.service';
|
|||||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||||
import { AuditService } from '../../common/audit.service';
|
import { AuditService } from '../../common/audit.service';
|
||||||
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||||
|
import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SeatsService {
|
export class SeatsService {
|
||||||
@@ -137,10 +138,10 @@ export class SeatsService {
|
|||||||
|
|
||||||
private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' {
|
private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' {
|
||||||
const n = coachTypeName.toLowerCase();
|
const n = coachTypeName.toLowerCase();
|
||||||
// Explicit VIP name check first
|
// Name-based: VIP / Soft Berth Coach → VIP_BED
|
||||||
if (n.includes('vip')) return 'VIP_BED';
|
if (n.includes('vip') || n.includes('soft')) return 'VIP_BED';
|
||||||
// Fall back to actual beds-per-room count: 4 = VIP, 6 = Economy
|
// Beds-per-room fallback: 2 or 4 beds per room = VIP, more = Economy
|
||||||
if (bedsPerRoom === 4) return 'VIP_BED';
|
if (bedsPerRoom != null && bedsPerRoom <= 4) return 'VIP_BED';
|
||||||
return 'ECONOMY_BED';
|
return 'ECONOMY_BED';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +188,11 @@ export class SeatsService {
|
|||||||
return legacyMap[col?.toUpperCase()] ?? null;
|
return legacyMap[col?.toUpperCase()] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delegates the actual "is this seat held/booked for this leg" determination to
|
||||||
|
// SegmentsService.getSeatAvailabilityMap — the same canonical check search results
|
||||||
|
// (availabilityByClass) use — so the seatmap and search results can never disagree
|
||||||
|
// about seat availability again. Previously this method carried its own
|
||||||
|
// separately-written copy of the same hold/JourneySegment-overlap logic.
|
||||||
async resolveEffectiveStatuses(
|
async resolveEffectiveStatuses(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
seatIds: string[],
|
seatIds: string[],
|
||||||
@@ -197,138 +203,42 @@ export class SeatsService {
|
|||||||
const statusMap = new Map<string, string>();
|
const statusMap = new Map<string, string>();
|
||||||
if (seatIds.length === 0) return statusMap;
|
if (seatIds.length === 0) return statusMap;
|
||||||
|
|
||||||
// Resolve the requested leg's sequence range once
|
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||||||
let reqFrom: number | undefined;
|
where: { scheduleId },
|
||||||
let reqTo: number | undefined;
|
select: { stationId: true, sequence: true },
|
||||||
let allStopTimes: { stationId: string; sequence: number }[] | null = null;
|
});
|
||||||
|
|
||||||
const getStopTimes = async () => {
|
|
||||||
if (!allStopTimes) {
|
|
||||||
allStopTimes = await this.prisma.tripStopTime.findMany({
|
|
||||||
where: { scheduleId },
|
|
||||||
select: { stationId: true, sequence: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return allStopTimes;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// No specific leg requested (or it doesn't resolve to real stops on this
|
||||||
|
// schedule) — conservatively treat the whole schedule as one big leg, so any
|
||||||
|
// resolvable hold/booking anywhere on it blocks these seats. Matches this
|
||||||
|
// method's previous behavior when called without origin/destination.
|
||||||
|
let reqFrom = -Infinity;
|
||||||
|
let reqTo = Infinity;
|
||||||
if (originStationId && destinationStationId) {
|
if (originStationId && destinationStationId) {
|
||||||
const stops = await getStopTimes();
|
const seqOf = (id: string) => stopTimes.find(s => s.stationId === id)?.sequence;
|
||||||
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
const resolvedFrom = seqOf(originStationId);
|
||||||
reqFrom = seqOf(originStationId);
|
const resolvedTo = seqOf(destinationStationId);
|
||||||
reqTo = seqOf(destinationStationId);
|
if (resolvedFrom !== undefined && resolvedTo !== undefined) {
|
||||||
}
|
reqFrom = resolvedFrom;
|
||||||
|
reqTo = resolvedTo;
|
||||||
// ── Active holds ──────────────────────────────────────────────────────────
|
|
||||||
const activeHolds = await this.prisma.seatHold.findMany({
|
|
||||||
where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } },
|
|
||||||
select: { seatIds: true, createdBy: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const reqDirection = journeyDirection || JourneyDirection.ONE_WAY;
|
|
||||||
|
|
||||||
for (const hold of activeHolds) {
|
|
||||||
let holdFrom: number | undefined;
|
|
||||||
let holdTo: number | undefined;
|
|
||||||
let holdDirection = JourneyDirection.ONE_WAY;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (hold.createdBy?.trimStart().startsWith('{')) {
|
|
||||||
const meta = JSON.parse(hold.createdBy);
|
|
||||||
const stops = await getStopTimes();
|
|
||||||
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
|
||||||
holdFrom = seqOf(meta.originStationId);
|
|
||||||
holdTo = seqOf(meta.destinationStationId);
|
|
||||||
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
for (const seatId of hold.seatIds) {
|
|
||||||
if (!seatIds.includes(seatId)) continue;
|
|
||||||
|
|
||||||
// Check leg overlap
|
|
||||||
const legsOverlap =
|
|
||||||
reqFrom === undefined || reqTo === undefined ||
|
|
||||||
holdFrom === undefined || holdTo === undefined ||
|
|
||||||
(holdFrom < reqTo && reqFrom < holdTo);
|
|
||||||
|
|
||||||
// Check direction conflict
|
|
||||||
const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection);
|
|
||||||
|
|
||||||
if (!legsOverlap || !directionsConflict) {
|
|
||||||
// This hold does not conflict with the requested leg/direction.
|
|
||||||
// Explicitly mark AVAILABLE so the DB's HELD status (set by the
|
|
||||||
// opposing-direction hold) does not bleed through via the fallback.
|
|
||||||
if (!statusMap.has(seatId)) statusMap.set(seatId, 'AVAILABLE');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
statusMap.set(seatId, 'HELD');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Confirmed bookings via JourneySegment ─────────────────────────────────
|
const availability = await this.segmentsService.getSeatAvailabilityMap(
|
||||||
const bookedSegments = await this.prisma.journeySegment.findMany({
|
scheduleId, seatIds, stopTimes, reqFrom, reqTo, journeyDirection || JourneyDirection.ONE_WAY,
|
||||||
where: {
|
);
|
||||||
scheduleId,
|
|
||||||
seatId: { in: seatIds },
|
|
||||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
|
||||||
},
|
|
||||||
select: { seatId: true, departureStationId: true, arrivalStationId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (reqFrom !== undefined && reqTo !== undefined) {
|
// Every requested seat defaults to AVAILABLE — this also guards against a stale
|
||||||
const stops = await getStopTimes();
|
// persisted Seat.status column (e.g. a leftover 'BOOKED'/'BLOCKED' value) bleeding
|
||||||
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
// through getSeatMap's own fallback, since that fallback only triggers when this
|
||||||
for (const seg of bookedSegments) {
|
// map has no entry at all for a given seat.
|
||||||
if (!seg.seatId) continue;
|
for (const seatId of seatIds) {
|
||||||
const segFrom = seqOf(seg.departureStationId);
|
statusMap.set(seatId, availability.get(seatId) ?? 'AVAILABLE');
|
||||||
const segTo = seqOf(seg.arrivalStationId);
|
|
||||||
if (segFrom !== undefined && segTo !== undefined) {
|
|
||||||
if (segFrom < reqTo && reqFrom < segTo) statusMap.set(seg.seatId, 'BOOKED');
|
|
||||||
} else {
|
|
||||||
statusMap.set(seg.seatId, 'BOOKED');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const seg of bookedSegments) {
|
|
||||||
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return statusMap;
|
return statusMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if two journey directions conflict (should not be allowed simultaneously)
|
|
||||||
* For round-trip bookings: OUTBOUND and RETURN should NOT conflict on same schedule
|
|
||||||
*/
|
|
||||||
private checkDirectionConflict(current: JourneyDirection, existing: JourneyDirection): boolean {
|
|
||||||
// OUTBOUND and RETURN are allowed simultaneously (round-trip on different schedules)
|
|
||||||
if ((current === JourneyDirection.OUTBOUND && existing === JourneyDirection.RETURN) ||
|
|
||||||
(current === JourneyDirection.RETURN && existing === JourneyDirection.OUTBOUND)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same directions conflict (e.g., two OUTBOUND or two RETURN bookings)
|
|
||||||
if (current === existing) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ONE_WAY conflicts with other ONE_WAY bookings only
|
|
||||||
if (current === JourneyDirection.ONE_WAY && existing === JourneyDirection.ONE_WAY) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ONE_WAY with OUTBOUND/RETURN: conflict (to maintain safety for legacy bookings)
|
|
||||||
if (current === JourneyDirection.ONE_WAY || existing === JourneyDirection.ONE_WAY) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: no conflict
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async holdSeats(dto: HoldSeatsDto) {
|
async holdSeats(dto: HoldSeatsDto) {
|
||||||
const passengerIds = dto.passengers.map(p => p.passengerId);
|
const passengerIds = dto.passengers.map(p => p.passengerId);
|
||||||
const seatIds = dto.passengers.map(p => p.seatId);
|
const seatIds = dto.passengers.map(p => p.seatId);
|
||||||
@@ -432,7 +342,7 @@ export class SeatsService {
|
|||||||
const legsOverlap = legUnknown || (holdFrom < reqTo && reqFrom < holdTo);
|
const legsOverlap = legUnknown || (holdFrom < reqTo && reqFrom < holdTo);
|
||||||
if (!legsOverlap) continue;
|
if (!legsOverlap) continue;
|
||||||
|
|
||||||
const directionsConflict = this.checkDirectionConflict(currentDirection, holdDirection);
|
const directionsConflict = checkDirectionConflict(currentDirection, holdDirection);
|
||||||
if (!directionsConflict) continue;
|
if (!directionsConflict) continue;
|
||||||
|
|
||||||
for (const { passengerId, seatId } of dto.passengers) {
|
for (const { passengerId, seatId } of dto.passengers) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
|
import { JourneyDirection } from '../seats/seats.dto';
|
||||||
|
import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
|
||||||
|
|
||||||
export interface Segment {
|
export interface Segment {
|
||||||
fromStationId: string;
|
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
|
* Overlap rule (strict): existingFrom < reqTo AND reqFrom < existingTo
|
||||||
*
|
*
|
||||||
@@ -66,99 +73,30 @@ export class SegmentsService {
|
|||||||
* P3: A(1) → D(4) reqFrom=1, reqTo=4
|
* P3: A(1) → D(4) reqFrom=1, reqTo=4
|
||||||
* Check P3 vs P2: 1 < 4 AND 2 < 4 → true AND true → CONFLICT ✓
|
* 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:
|
* 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
|
* 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(
|
async getSeatAvailabilityMap(
|
||||||
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(
|
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
seatIds: string[],
|
seatIds: string[],
|
||||||
stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>,
|
stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>,
|
||||||
reqFrom: number,
|
reqFrom: number,
|
||||||
reqTo: number,
|
reqTo: number,
|
||||||
): Promise<Set<string>> {
|
journeyDirection: JourneyDirection = JourneyDirection.ONE_WAY,
|
||||||
if (seatIds.length === 0) return new Set();
|
): Promise<Map<string, 'HELD' | 'BOOKED'>> {
|
||||||
|
const result = new Map<string, 'HELD' | 'BOOKED'>();
|
||||||
|
if (seatIds.length === 0) return result;
|
||||||
|
|
||||||
const seqOf = (stationId: string) =>
|
const seqOf = (stationId: string) =>
|
||||||
stopTimesForSeqLookup.find(s => s.stationId === stationId)?.sequence;
|
stopTimesForSeqLookup.find(s => s.stationId === stationId)?.sequence;
|
||||||
@@ -181,29 +119,31 @@ export class SegmentsService {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Determine which seats are blocked by active holds
|
// ── 1. Active holds ────────────────────────────────────────────────────────
|
||||||
const holdBlockedSeats = new Set<string>();
|
|
||||||
for (const hold of allHolds) {
|
for (const hold of allHolds) {
|
||||||
let holdFrom: number | undefined;
|
let holdFrom: number | undefined;
|
||||||
let holdTo: number | undefined;
|
let holdTo: number | undefined;
|
||||||
|
let holdDirection = JourneyDirection.ONE_WAY;
|
||||||
try {
|
try {
|
||||||
if (hold.createdBy) {
|
if (hold.createdBy) {
|
||||||
const meta = JSON.parse(hold.createdBy as string);
|
const meta = JSON.parse(hold.createdBy as string);
|
||||||
holdFrom = seqOf(meta.originStationId);
|
holdFrom = seqOf(meta.originStationId);
|
||||||
holdTo = seqOf(meta.destinationStationId);
|
holdTo = seqOf(meta.destinationStationId);
|
||||||
|
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
for (const sid of hold.seatIds) {
|
for (const sid of hold.seatIds) {
|
||||||
if (!seatIdSet.has(sid)) continue;
|
if (!seatIdSet.has(sid)) continue;
|
||||||
// Conservative block if leg can't be resolved; otherwise check overlap
|
// Conservative block if leg can't be resolved; otherwise check overlap.
|
||||||
if (holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo)) {
|
const legsOverlap = holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo);
|
||||||
holdBlockedSeats.add(sid);
|
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 }>>();
|
const journeyRangesBySeat = new Map<string, Map<string, { from: number; to: number }>>();
|
||||||
for (const leg of bookedLegs) {
|
for (const leg of bookedLegs) {
|
||||||
if (!leg.seatId || !leg.journeyId || !leg.departureStationId || !leg.arrivalStationId) continue;
|
if (!leg.seatId || !leg.journeyId || !leg.departureStationId || !leg.arrivalStationId) continue;
|
||||||
@@ -220,20 +160,52 @@ export class SegmentsService {
|
|||||||
: { from: depSeq, to: arrSeq });
|
: { from: depSeq, to: arrSeq });
|
||||||
}
|
}
|
||||||
|
|
||||||
const freeSeats = new Set<string>();
|
|
||||||
for (const seatId of seatIds) {
|
for (const seatId of seatIds) {
|
||||||
if (holdBlockedSeats.has(seatId)) continue;
|
|
||||||
let blocked = false;
|
|
||||||
const rangeMap = journeyRangesBySeat.get(seatId);
|
const rangeMap = journeyRangesBySeat.get(seatId);
|
||||||
if (rangeMap) {
|
if (!rangeMap) continue;
|
||||||
for (const { from, to } of rangeMap.values()) {
|
for (const { from, to } of rangeMap.values()) {
|
||||||
if (from < reqTo && reqFrom < to) { blocked = true; break; }
|
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 */
|
/** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */
|
||||||
|
|||||||
Reference in New Issue
Block a user