Files
edr-platform/apps/edr-passenger-api/src/modules/segments/segments.service.ts
2026-05-27 15:11:23 +03:00

170 lines
6.2 KiB
TypeScript

import { Injectable, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
export interface Segment {
fromStationId: string;
toStationId: string;
fromSequence: number;
toSequence: number;
fromName: string;
toName: string;
}
@Injectable()
export class SegmentsService {
constructor(private prisma: PrismaService) {}
async getJourneySegments(
scheduleId: string,
originStationId: string,
destinationStationId: string,
): Promise<Segment[]> {
const stopTimes = await this.prisma.tripStopTime.findMany({
where: { scheduleId },
include: { station: true },
orderBy: { sequence: 'asc' },
});
const originStop = stopTimes.find(st => st.stationId === originStationId);
const destStop = stopTimes.find(st => st.stationId === destinationStationId);
if (!originStop || !destStop) {
throw new BadRequestException('Origin or destination station not found on this schedule');
}
if (originStop.sequence >= destStop.sequence) {
throw new BadRequestException('Origin must come before destination');
}
const segments: Segment[] = [];
for (let i = originStop.sequence; i < destStop.sequence; i++) {
const fromStop = stopTimes.find(st => st.sequence === i);
const toStop = stopTimes.find(st => st.sequence === i + 1);
if (fromStop && toStop) {
segments.push({
fromStationId: fromStop.stationId,
toStationId: toStop.stationId,
fromSequence: fromStop.sequence,
toSequence: toStop.sequence,
fromName: fromStop.station.name,
toName: toStop.station.name,
});
}
}
return segments;
}
/**
* Checks whether a seat is free for the requested leg [reqFrom, reqTo).
*
* Overlap rule (strict): existingFrom < reqTo AND reqFrom < existingTo
*
* This means two journeys that TOUCH at a boundary do NOT conflict:
* P1: A(1) → B(2) reqFrom=1, reqTo=2
* P2: B(2) → D(4) reqFrom=2, reqTo=4
* Check P1 vs P2: 1 < 4 AND 2 < 2 → true AND false → NO conflict ✓
*
* P3: A(1) → D(4) reqFrom=1, reqTo=4
* Check P3 vs P2: 1 < 4 AND 2 < 4 → true AND true → CONFLICT ✓
*
* Sources checked:
* 1. Active SeatHolds — leg decoded from createdBy JSON ({ originStationId, destinationStationId })
* 2. Active JourneySegments — per-leg rows for CONFIRMED / PENDING_PAYMENT journeys
*/
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;
}
/** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */
async getOverlappingReservations(
scheduleId: string,
seatId: string,
requestedSegments: Segment[],
): Promise<{ type: string; id: string }[]> {
const reqFrom = Math.min(...requestedSegments.map(s => s.fromSequence));
const reqTo = Math.max(...requestedSegments.map(s => s.toSequence));
const free = await this.isSeatFreeForLeg(scheduleId, seatId, reqFrom, reqTo);
return free ? [] : [{ type: 'conflict', id: seatId }];
}
segmentsOverlap(segments1: Segment[], segments2: Segment[]): boolean {
for (const s1 of segments1) {
for (const s2 of segments2) {
if (s1.fromSequence < s2.toSequence && s2.fromSequence < s1.toSequence) return true;
}
}
return false;
}
}