mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
Update seat availability
This commit is contained in:
@@ -6,6 +6,7 @@ import { SegmentsService } from '../segments/segments.service';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
|
||||
|
||||
@Injectable()
|
||||
export class SeatsService {
|
||||
@@ -137,10 +138,10 @@ export class SeatsService {
|
||||
|
||||
private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' {
|
||||
const n = coachTypeName.toLowerCase();
|
||||
// Explicit VIP name check first
|
||||
if (n.includes('vip')) return 'VIP_BED';
|
||||
// Fall back to actual beds-per-room count: 4 = VIP, 6 = Economy
|
||||
if (bedsPerRoom === 4) return 'VIP_BED';
|
||||
// Name-based: VIP / Soft Berth Coach → VIP_BED
|
||||
if (n.includes('vip') || n.includes('soft')) return 'VIP_BED';
|
||||
// Beds-per-room fallback: 2 or 4 beds per room = VIP, more = Economy
|
||||
if (bedsPerRoom != null && bedsPerRoom <= 4) return 'VIP_BED';
|
||||
return 'ECONOMY_BED';
|
||||
}
|
||||
|
||||
@@ -187,6 +188,11 @@ export class SeatsService {
|
||||
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(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
@@ -197,138 +203,42 @@ export class SeatsService {
|
||||
const statusMap = new Map<string, string>();
|
||||
if (seatIds.length === 0) return statusMap;
|
||||
|
||||
// Resolve the requested leg's sequence range once
|
||||
let reqFrom: number | undefined;
|
||||
let reqTo: number | undefined;
|
||||
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;
|
||||
};
|
||||
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
|
||||
// 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) {
|
||||
const stops = await getStopTimes();
|
||||
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
||||
reqFrom = seqOf(originStationId);
|
||||
reqTo = seqOf(destinationStationId);
|
||||
}
|
||||
|
||||
// ── 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');
|
||||
const seqOf = (id: string) => stopTimes.find(s => s.stationId === id)?.sequence;
|
||||
const resolvedFrom = seqOf(originStationId);
|
||||
const resolvedTo = seqOf(destinationStationId);
|
||||
if (resolvedFrom !== undefined && resolvedTo !== undefined) {
|
||||
reqFrom = resolvedFrom;
|
||||
reqTo = resolvedTo;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Confirmed bookings via JourneySegment ─────────────────────────────────
|
||||
const bookedSegments = await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
seatId: { in: seatIds },
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
select: { seatId: true, departureStationId: true, arrivalStationId: true },
|
||||
});
|
||||
const availability = await this.segmentsService.getSeatAvailabilityMap(
|
||||
scheduleId, seatIds, stopTimes, reqFrom, reqTo, journeyDirection || JourneyDirection.ONE_WAY,
|
||||
);
|
||||
|
||||
if (reqFrom !== undefined && reqTo !== undefined) {
|
||||
const stops = await getStopTimes();
|
||||
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
||||
for (const seg of bookedSegments) {
|
||||
if (!seg.seatId) continue;
|
||||
const segFrom = seqOf(seg.departureStationId);
|
||||
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');
|
||||
}
|
||||
// Every requested seat defaults to AVAILABLE — this also guards against a stale
|
||||
// persisted Seat.status column (e.g. a leftover 'BOOKED'/'BLOCKED' value) bleeding
|
||||
// through getSeatMap's own fallback, since that fallback only triggers when this
|
||||
// map has no entry at all for a given seat.
|
||||
for (const seatId of seatIds) {
|
||||
statusMap.set(seatId, availability.get(seatId) ?? 'AVAILABLE');
|
||||
}
|
||||
|
||||
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) {
|
||||
const passengerIds = dto.passengers.map(p => p.passengerId);
|
||||
const seatIds = dto.passengers.map(p => p.seatId);
|
||||
@@ -432,7 +342,7 @@ export class SeatsService {
|
||||
const legsOverlap = legUnknown || (holdFrom < reqTo && reqFrom < holdTo);
|
||||
if (!legsOverlap) continue;
|
||||
|
||||
const directionsConflict = this.checkDirectionConflict(currentDirection, holdDirection);
|
||||
const directionsConflict = checkDirectionConflict(currentDirection, holdDirection);
|
||||
if (!directionsConflict) continue;
|
||||
|
||||
for (const { passengerId, seatId } of dto.passengers) {
|
||||
|
||||
Reference in New Issue
Block a user