mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Segment based seat assignement added
This commit is contained in:
@@ -102,7 +102,63 @@ export class SchedulesService {
|
||||
},
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
return schedule;
|
||||
|
||||
// Compute effective seat statuses from SeatHold + JourneySegment
|
||||
// (seat.status DB column is no longer written during booking)
|
||||
const allSeatIds = schedule.coachAssignments.flatMap(a => a.coach.seats.map(s => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(id, allSeatIds);
|
||||
|
||||
return {
|
||||
...schedule,
|
||||
coachAssignments: schedule.coachAssignments.map(a => ({
|
||||
...a,
|
||||
coach: {
|
||||
...a.coach,
|
||||
seats: a.coach.seats.map(s => ({
|
||||
...s,
|
||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes effective seat status for a schedule by checking active SeatHolds
|
||||
* and confirmed JourneySegments. The DB seat.status column is not written
|
||||
* during segment-based booking, so this overlay is required.
|
||||
* Priority: BLOCKED (physical) > BOOKED (confirmed) > HELD (active hold) > AVAILABLE
|
||||
*/
|
||||
private async resolveEffectiveStatuses(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
const statusMap = new Map<string, string>();
|
||||
if (seatIds.length === 0) return statusMap;
|
||||
|
||||
const [activeHolds, bookedSegments] = await Promise.all([
|
||||
this.prisma.seatHold.findMany({
|
||||
where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } },
|
||||
select: { seatIds: true },
|
||||
}),
|
||||
this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
seatId: { in: seatIds },
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
select: { seatId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
for (const hold of activeHolds)
|
||||
for (const seatId of hold.seatIds)
|
||||
if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD');
|
||||
|
||||
for (const seg of bookedSegments)
|
||||
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
|
||||
|
||||
return statusMap;
|
||||
}
|
||||
|
||||
updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
|
||||
|
||||
@@ -3,9 +3,10 @@ import { SearchController } from './search.controller';
|
||||
import { SearchService } from './search.service';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||
import { SegmentsModule } from '../segments/segments.module';
|
||||
|
||||
@Module({
|
||||
imports: [CurrencyModule, FareEngineModule],
|
||||
imports: [CurrencyModule, FareEngineModule, SegmentsModule],
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
exports: [SearchService],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
const POINTS_TO_MINOR = 10;
|
||||
@@ -13,6 +14,7 @@ export class SearchService {
|
||||
private prisma: PrismaService,
|
||||
private currencyService: CurrencyService,
|
||||
private fareEngine: FareEngineService,
|
||||
private segmentsService: SegmentsService,
|
||||
) {}
|
||||
|
||||
async searchTrips(dto: SearchTripsDto) {
|
||||
@@ -58,7 +60,8 @@ export class SearchService {
|
||||
|
||||
for (const seat of assignment.coach.seats) {
|
||||
if (seat.status === 'BLOCKED') continue;
|
||||
const free = await this.isSeatFreeForSegment(
|
||||
// Use segment-aware check — a seat booked A→B is still free for B→D
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||
schedule.id, seat.id,
|
||||
originStop.sequence, destStop.sequence,
|
||||
);
|
||||
@@ -237,70 +240,6 @@ export class SearchService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the seat has no active hold or confirmed booking
|
||||
* whose segment range overlaps [fromSeq, toSeq).
|
||||
* Overlap condition: existingFrom < toSeq AND fromSeq < existingTo
|
||||
*/
|
||||
private async isSeatFreeForSegment(
|
||||
scheduleId: string,
|
||||
seatId: string,
|
||||
fromSeq: number,
|
||||
toSeq: number,
|
||||
): Promise<boolean> {
|
||||
// Check active holds that include this seat on this schedule
|
||||
const holds = await this.prisma.seatHold.findMany({
|
||||
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
|
||||
});
|
||||
|
||||
for (const hold of holds) {
|
||||
// Resolve hold segment range from its stored origin/destination via JourneySegment
|
||||
// For holds we use the stop sequences stored on the hold's origin/destination
|
||||
// Since SeatHold doesn't store sequences directly, we check JourneySegments
|
||||
// that reference this seat on this schedule with PENDING_PAYMENT status
|
||||
const holdSegs = await this.prisma.journeySegment.findMany({
|
||||
where: { scheduleId, seatId },
|
||||
include: {
|
||||
journey: true,
|
||||
schedule: { include: { stopTimes: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const js of holdSegs) {
|
||||
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
|
||||
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
|
||||
if (depSeq !== undefined && arrSeq !== undefined) {
|
||||
if (depSeq < toSeq && fromSeq < arrSeq) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If no journey segments yet (hold just created), treat the whole hold as blocking
|
||||
if (holdSegs.length === 0) return false;
|
||||
}
|
||||
|
||||
// Check confirmed/pending bookings via JourneySegment
|
||||
const bookedSegments = await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
seatId,
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
include: {
|
||||
schedule: { include: { stopTimes: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const js of bookedSegments) {
|
||||
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
|
||||
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
|
||||
if (depSeq !== undefined && arrSeq !== undefined) {
|
||||
if (depSeq < toSeq && fromSeq < arrSeq) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private defaultFare(seatClassName: string): number {
|
||||
const fares: Record<string, number> = {
|
||||
'Economy Regular': 45000,
|
||||
|
||||
@@ -26,6 +26,34 @@ Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED`
|
||||
getSeatMap(@Param('scheduleId') scheduleId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(scheduleId, coachId); }
|
||||
|
||||
// ── Hold / Release ────────────────────────────────────────────────────────
|
||||
@Get('holds')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'List active seat holds with full leg context',
|
||||
description: `Returns all non-expired holds enriched with:
|
||||
- **schedule**: train number, departure/arrival, full route origin→destination
|
||||
- **leg**: the specific origin→destination this hold covers (station name, code, stop sequence)
|
||||
- **seats**: seat label, coach, seat class, row, col
|
||||
- **ttlSeconds**: seconds remaining before the hold expires
|
||||
|
||||
This makes it clear which segment of the route each seat is held for, enabling segment-based reuse of the same seat on non-overlapping legs.`,
|
||||
})
|
||||
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter by TrainSchedule UUID' })
|
||||
@ApiQuery({ name: 'passengerId', required: false, description: 'Filter by Passenger UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Active holds with schedule, leg, and seat details' })
|
||||
getHolds(
|
||||
@Query('scheduleId') scheduleId?: string,
|
||||
@Query('passengerId') passengerId?: string,
|
||||
) { return this.service.getHolds(scheduleId, passengerId); }
|
||||
|
||||
@Get('holds/:holdId')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Get a single hold with full leg context' })
|
||||
@ApiParam({ name: 'holdId', description: 'SeatHold UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Hold with schedule, leg, and seat details' })
|
||||
@ApiResponse({ status: 404, description: 'Hold not found' })
|
||||
getHold(@Param('holdId') holdId: string) { return this.service.getHold(holdId); }
|
||||
|
||||
@Post('hold')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import { IsString, IsArray, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsString, IsArray, ValidateNested } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class PassengerSeatDto {
|
||||
@ApiProperty({ example: 'passenger-uuid', description: 'Passenger UUID' })
|
||||
@IsString() passengerId: string;
|
||||
|
||||
@ApiProperty({ example: 'seat-uuid', description: 'Seat UUID assigned to this passenger' })
|
||||
@IsString() seatId: string;
|
||||
}
|
||||
|
||||
export class HoldSeatsDto {
|
||||
@ApiProperty({ example: 'schedule-uuid' }) @IsString() scheduleId: string;
|
||||
@ApiProperty({ example: 'passenger-uuid' }) @IsString() passengerId: string;
|
||||
@ApiProperty({ type: [String], example: ['seat-uuid-1', 'seat-uuid-2'] }) @IsArray() seatIds: string[];
|
||||
@ApiPropertyOptional({ example: 'fare-quote-uuid' }) @IsOptional() @IsString() fareQuoteId?: string;
|
||||
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' })
|
||||
@IsString() scheduleId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID — scopes the hold to a leg so the seat can be reused on non-overlapping legs' })
|
||||
@IsString() originStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [PassengerSeatDto],
|
||||
description: 'One entry per passenger. Each passenger is assigned exactly one seat. Duplicate passengerId or seatId within the same request is rejected.',
|
||||
example: [
|
||||
{ passengerId: 'passenger-uuid-1', seatId: 'seat-uuid-1' },
|
||||
{ passengerId: 'passenger-uuid-2', seatId: 'seat-uuid-2' },
|
||||
],
|
||||
})
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PassengerSeatDto)
|
||||
passengers: PassengerSeatDto[];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SeatsController } from './seats.controller';
|
||||
import { SeatsService } from './seats.service';
|
||||
import { SegmentsModule } from '../segments/segments.module';
|
||||
|
||||
@Module({ controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService] })
|
||||
@Module({
|
||||
imports: [SegmentsModule],
|
||||
controllers: [SeatsController],
|
||||
providers: [SeatsService],
|
||||
exports: [SeatsService],
|
||||
})
|
||||
export class SeatsModule {}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { HoldSeatsDto } from './seats.dto';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
|
||||
@Injectable()
|
||||
export class SeatsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private segmentsService: SegmentsService,
|
||||
) {}
|
||||
|
||||
// ── Seat Map ──────────────────────────────────────────────────────────────
|
||||
async getSeatMap(scheduleId: string, coachId?: string) {
|
||||
@@ -14,6 +18,10 @@ export class SeatsService {
|
||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
});
|
||||
|
||||
const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds);
|
||||
|
||||
return {
|
||||
coaches: assignments.map((a) => ({
|
||||
id: a.coach.id,
|
||||
@@ -21,34 +29,334 @@ export class SeatsService {
|
||||
name: `Coach ${a.coach.label}`,
|
||||
seatClass: a.coach.seatClass.name,
|
||||
positionNumber: a.positionNumber,
|
||||
seats: a.coach.seats.map((s) => ({ id: s.id, number: s.label, status: s.status, kind: s.kind })),
|
||||
seats: a.coach.seats.map((s) => ({
|
||||
id: s.id,
|
||||
number: s.label,
|
||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||
kind: s.kind,
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
isWindow: s.isWindow,
|
||||
isAisle: s.isAisle,
|
||||
bedPosition: s.bedPosition,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the effective seat status for a set of seats on a specific schedule
|
||||
* by checking active SeatHolds and confirmed JourneySegments.
|
||||
*
|
||||
* Priority: BLOCKED (physical) > BOOKED (confirmed journey) > HELD (active hold) > AVAILABLE
|
||||
*
|
||||
* This is needed because seat.status is no longer written during booking —
|
||||
* availability is segment-scoped, so the DB column stays AVAILABLE even when held.
|
||||
*/
|
||||
async resolveEffectiveStatuses(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
const statusMap = new Map<string, string>();
|
||||
|
||||
if (seatIds.length === 0) return statusMap;
|
||||
|
||||
// 1. Active holds — any seat in an unexpired SeatHold for this schedule is HELD
|
||||
const activeHolds = await this.prisma.seatHold.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
expiresAt: { gt: new Date() },
|
||||
seatIds: { hasSome: seatIds },
|
||||
},
|
||||
select: { seatIds: true },
|
||||
});
|
||||
for (const hold of activeHolds) {
|
||||
for (const seatId of hold.seatIds) {
|
||||
if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Active bookings via JourneySegment — CONFIRMED or PENDING_PAYMENT → BOOKED
|
||||
// (overwrites HELD if the same seat has a confirmed booking)
|
||||
const bookedSegments = await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
seatId: { in: seatIds },
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
select: { seatId: true },
|
||||
});
|
||||
for (const seg of bookedSegments) {
|
||||
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
|
||||
}
|
||||
|
||||
return statusMap;
|
||||
}
|
||||
|
||||
// ── Hold / Release ────────────────────────────────────────────────────────
|
||||
async holdSeats(dto: HoldSeatsDto) {
|
||||
// ── Validate request integrity ───────────────────────────────────────────
|
||||
const passengerIds = dto.passengers.map(p => p.passengerId);
|
||||
const seatIds = dto.passengers.map(p => p.seatId);
|
||||
|
||||
if (new Set(passengerIds).size !== passengerIds.length)
|
||||
throw new BadRequestException('Duplicate passengerId in passengers list — each passenger must appear once');
|
||||
if (new Set(seatIds).size !== seatIds.length)
|
||||
throw new BadRequestException('Duplicate seatId in passengers list — each seat can only be assigned to one passenger');
|
||||
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
||||
|
||||
const hold = await this.prisma.$transaction(async (tx) => {
|
||||
const seats = await tx.seat.findMany({ where: { id: { in: dto.seatIds } }, select: { id: true, status: true, heldUntil: true } });
|
||||
const unavailable = seats.filter((s) => s.status === 'BOOKED' || s.status === 'BLOCKED' || (s.status === 'HELD' && s.heldUntil && s.heldUntil > new Date()));
|
||||
if (unavailable.length > 0) throw new ConflictException('One or more seats unavailable');
|
||||
await tx.seat.updateMany({ where: { id: { in: dto.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } });
|
||||
return tx.seatHold.create({ data: { scheduleId: dto.scheduleId, passengerId: dto.passengerId, seatIds: dto.seatIds, fareQuoteId: dto.fareQuoteId, expiresAt } });
|
||||
// ── 1. Validate seats exist and none are BLOCKED ─────────────────────
|
||||
const seats = await tx.seat.findMany({
|
||||
where: { id: { in: seatIds } },
|
||||
select: { id: true, status: true, label: true },
|
||||
});
|
||||
|
||||
if (seats.length !== seatIds.length) {
|
||||
const found = new Set(seats.map(s => s.id));
|
||||
const missing = seatIds.filter(id => !found.has(id));
|
||||
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const blocked = seats.filter(s => s.status === 'BLOCKED');
|
||||
if (blocked.length > 0)
|
||||
throw new ConflictException(`Seat(s) ${blocked.map(s => s.label).join(', ')} are blocked`);
|
||||
|
||||
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.label]));
|
||||
|
||||
// ── 2. Resolve requested leg sequences ──────────────────────────────
|
||||
const stopTimes = await tx.tripStopTime.findMany({
|
||||
where: { scheduleId: dto.scheduleId },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
const seqOf = (stationId: string) =>
|
||||
stopTimes.find(s => s.stationId === stationId)?.sequence;
|
||||
|
||||
const reqFrom = seqOf(dto.originStationId);
|
||||
const reqTo = seqOf(dto.destinationStationId);
|
||||
|
||||
if (reqFrom === undefined || reqTo === undefined)
|
||||
throw new BadRequestException('Origin or destination station not found on this schedule');
|
||||
if (reqFrom >= reqTo)
|
||||
throw new BadRequestException('Origin must come before destination');
|
||||
|
||||
// ── 3. Load active holds for this schedule ───────────────────────────
|
||||
const activeHolds = await tx.seatHold.findMany({
|
||||
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
|
||||
select: { seatIds: true, createdBy: true },
|
||||
});
|
||||
|
||||
// Parse each hold's leg range and passenger list
|
||||
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = [];
|
||||
for (const h of activeHolds) {
|
||||
try {
|
||||
if (h.createdBy?.trimStart().startsWith('{')) {
|
||||
const meta = JSON.parse(h.createdBy);
|
||||
const holdFrom = seqOf(meta.originStationId);
|
||||
const holdTo = seqOf(meta.destinationStationId);
|
||||
if (holdFrom !== undefined && holdTo !== undefined) {
|
||||
parsedHolds.push({
|
||||
seatIds: h.seatIds,
|
||||
from: holdFrom,
|
||||
to: holdTo,
|
||||
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { /* ignore malformed */ }
|
||||
}
|
||||
|
||||
// ── 4. Per-passenger validation with overlap check ───────────────────
|
||||
for (const { passengerId, seatId } of dto.passengers) {
|
||||
for (const hold of parsedHolds) {
|
||||
const legsOverlap = hold.from < reqTo && reqFrom < hold.to;
|
||||
if (!legsOverlap) continue; // non-overlapping leg — no conflict
|
||||
|
||||
// Rule A: seat is held on an overlapping leg
|
||||
if (hold.seatIds.includes(seatId)) {
|
||||
throw new ConflictException(
|
||||
`Seat ${seatLabelById[seatId]} is already held for this leg. Please choose a different seat.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Rule B: passenger already holds a seat on an overlapping leg
|
||||
if (hold.passengerIds.includes(passengerId)) {
|
||||
throw new ConflictException(
|
||||
`Passenger already holds a seat on this journey leg. You can only hold one seat per journey.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store passenger→seat mapping AND leg in createdBy as JSON
|
||||
const holdMeta = {
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
|
||||
};
|
||||
|
||||
return tx.seatHold.create({
|
||||
data: {
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerId: dto.passengers[0].passengerId,
|
||||
seatIds,
|
||||
createdBy: JSON.stringify(holdMeta),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
return { id: hold.id, scheduleId: dto.scheduleId, seatIds: dto.seatIds, expiresAt };
|
||||
|
||||
return this.enrichHold(hold);
|
||||
}
|
||||
|
||||
async getHolds(scheduleId?: string, passengerId?: string) {
|
||||
const holds = await this.prisma.seatHold.findMany({
|
||||
where: {
|
||||
expiresAt: { gt: new Date() },
|
||||
...(scheduleId ? { scheduleId } : {}),
|
||||
...(passengerId ? { passengerId } : {}),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return Promise.all(holds.map(h => this.enrichHold(h)));
|
||||
}
|
||||
|
||||
async getHold(holdId: string) {
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
|
||||
if (!hold) throw new NotFoundException('Hold not found');
|
||||
return this.enrichHold(hold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the opaque fareQuoteId leg encoding into human-readable station
|
||||
* names and enriches the hold with schedule, seat, and leg details.
|
||||
*/
|
||||
private async enrichHold(hold: any) {
|
||||
// Decode leg and passenger→seat mapping from createdBy JSON
|
||||
let originStationId: string | null = null;
|
||||
let destinationStationId: string | null = null;
|
||||
let passengerSeatMap: { passengerId: string; seatId: string }[] = [];
|
||||
|
||||
try {
|
||||
if (hold.createdBy) {
|
||||
const raw = hold.createdBy;
|
||||
// Guard: only parse if it looks like a JSON object, not a plain number/string
|
||||
if (typeof raw === 'string' && raw.trimStart().startsWith('{')) {
|
||||
const meta = JSON.parse(raw);
|
||||
originStationId = meta.originStationId ?? null;
|
||||
destinationStationId = meta.destinationStationId ?? null;
|
||||
passengerSeatMap = Array.isArray(meta.passengers) ? meta.passengers : [];
|
||||
}
|
||||
}
|
||||
} catch { /* ignore malformed createdBy */ }
|
||||
|
||||
const seatIds = hold.seatIds as string[];
|
||||
|
||||
const [schedule, originStation, destinationStation, seats] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({
|
||||
where: { id: hold.scheduleId },
|
||||
include: { train: true, originStation: true, destinationStation: true },
|
||||
}),
|
||||
originStationId ? this.prisma.station.findUnique({ where: { id: originStationId } }) : null,
|
||||
destinationStationId ? this.prisma.station.findUnique({ where: { id: destinationStationId } }) : null,
|
||||
this.prisma.seat.findMany({
|
||||
where: { id: { in: seatIds } },
|
||||
include: { coach: { include: { seatClass: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
let originSequence: number | null = null;
|
||||
let destinationSequence: number | null = null;
|
||||
if (originStationId && destinationStationId) {
|
||||
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId: hold.scheduleId, stationId: { in: [originStationId, destinationStationId] } },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
originSequence = stopTimes.find(s => s.stationId === originStationId)?.sequence ?? null;
|
||||
destinationSequence = stopTimes.find(s => s.stationId === destinationStationId)?.sequence ?? null;
|
||||
}
|
||||
|
||||
// Build seat map keyed by seatId for quick lookup
|
||||
const seatById = Object.fromEntries(seats.map(s => [s.id, s]));
|
||||
|
||||
// Merge passenger→seat mapping with seat details
|
||||
const passengers = passengerSeatMap.length > 0
|
||||
? passengerSeatMap.map(({ passengerId, seatId }) => {
|
||||
const s = seatById[seatId];
|
||||
return {
|
||||
passengerId,
|
||||
seat: s ? {
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
seatNumber: s.seatNumber,
|
||||
coach: s.coach.label,
|
||||
seatClass: s.coach.seatClass.name,
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
} : { id: seatId },
|
||||
};
|
||||
})
|
||||
// Fallback for holds created before this change
|
||||
: seatIds.map(seatId => {
|
||||
const s = seatById[seatId];
|
||||
return {
|
||||
passengerId: hold.passengerId,
|
||||
seat: s ? {
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
seatNumber: s.seatNumber,
|
||||
coach: s.coach.label,
|
||||
seatClass: s.coach.seatClass.name,
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
} : { id: seatId },
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
holdId: hold.id,
|
||||
expiresAt: hold.expiresAt,
|
||||
createdAt: hold.createdAt,
|
||||
ttlSeconds: Math.max(0, Math.floor((hold.expiresAt.getTime() - Date.now()) / 1000)),
|
||||
schedule: schedule ? {
|
||||
id: schedule.id,
|
||||
trainNumber: schedule.train.number,
|
||||
trainName: schedule.train.name,
|
||||
departureAt: schedule.departureAt,
|
||||
arrivalAt: schedule.arrivalAt,
|
||||
fullRouteOrigin: schedule.originStation.name,
|
||||
fullRouteDestination: schedule.destinationStation.name,
|
||||
} : null,
|
||||
leg: {
|
||||
originStationId,
|
||||
originStationName: originStation?.name ?? null,
|
||||
originStationCode: originStation?.code ?? null,
|
||||
originSequence,
|
||||
destinationStationId,
|
||||
destinationStationName: destinationStation?.name ?? null,
|
||||
destinationStationCode: destinationStation?.code ?? null,
|
||||
destinationSequence,
|
||||
},
|
||||
passengers,
|
||||
};
|
||||
}
|
||||
|
||||
async releaseHold(holdId: string) {
|
||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
|
||||
if (!hold) throw new NotFoundException('Hold not found');
|
||||
await this.prisma.seat.updateMany({ where: { id: { in: hold.seatIds }, status: 'HELD' }, data: { status: 'AVAILABLE', heldUntil: null } });
|
||||
await this.prisma.seatHold.delete({ where: { id: holdId } });
|
||||
return { released: true };
|
||||
return { released: true, holdId };
|
||||
}
|
||||
|
||||
async confirmSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'BOOKED', heldUntil: null } }); }
|
||||
async releaseSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'AVAILABLE', heldUntil: null } }); }
|
||||
async confirmSeats(seatIds: string[]) {
|
||||
// No-op for status — availability is segment-scoped via JourneySegment
|
||||
// seat.status = BLOCKED is the only hard gate; BOOKED is not used as a booking flag
|
||||
}
|
||||
async releaseSeats(seatIds: string[]) {
|
||||
// Only reset seats that are physically BLOCKED back to AVAILABLE if needed
|
||||
// For segment-based bookings, releasing is handled by JourneySegment deletion
|
||||
}
|
||||
|
||||
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise<string[]> {
|
||||
const seats = await this.prisma.seat.findMany({
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface SeatHoldRequest {
|
||||
passengerId: string;
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
fareQuoteId?: string;
|
||||
}
|
||||
|
||||
export interface BookingConfirmRequest {
|
||||
@@ -27,28 +26,42 @@ export class EnhancedSeatsService {
|
||||
|
||||
async holdSeats(request: SeatHoldRequest) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const segments = await this.segmentsService.getJourneySegments(request.scheduleId, request.originStationId, request.destinationStationId);
|
||||
const segments = await this.segmentsService.getJourneySegments(
|
||||
request.scheduleId, request.originStationId, request.destinationStationId,
|
||||
);
|
||||
const reqFrom = Math.min(...segments.map(s => s.fromSequence));
|
||||
const reqTo = Math.max(...segments.map(s => s.toSequence));
|
||||
|
||||
for (const seatId of request.seatIds) {
|
||||
const seat = await tx.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new BadRequestException(`Seat ${seatId} not found`);
|
||||
// Only BLOCKED seats are hard-rejected — BOOKED/HELD are fine if the
|
||||
// segment does not overlap (another passenger may occupy a different leg)
|
||||
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.label} is blocked`);
|
||||
const overlaps = await this.segmentsService.getOverlappingReservations(request.scheduleId, seatId, segments);
|
||||
if (overlaps.length > 0) throw new ConflictException(`Seat ${seat.label} is not available for the requested segments`);
|
||||
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||
request.scheduleId, seatId, reqFrom, reqTo,
|
||||
);
|
||||
if (!free) throw new ConflictException(`Seat ${seat.label} is not available for the requested leg`);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
||||
// Encode origin/destination into fareQuoteId so confirmBooking can resolve the leg range
|
||||
// Format: "leg:{originStationId}:{destinationStationId}" (or preserve actual fareQuoteId)
|
||||
const legKey = request.fareQuoteId ?? `leg:${request.originStationId}:${request.destinationStationId}`;
|
||||
const seatHold = await tx.seatHold.create({
|
||||
data: { scheduleId: request.scheduleId, seatIds: request.seatIds, passengerId: request.passengerId, fareQuoteId: legKey, expiresAt },
|
||||
data: {
|
||||
scheduleId: request.scheduleId,
|
||||
seatIds: request.seatIds,
|
||||
passengerId: request.passengerId,
|
||||
// Store leg in createdBy JSON — no fareQuoteId needed
|
||||
createdBy: JSON.stringify({
|
||||
originStationId: request.originStationId,
|
||||
destinationStationId: request.destinationStationId,
|
||||
}),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.seat.updateMany({ where: { id: { in: request.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } });
|
||||
|
||||
// Do NOT set seat.status = HELD globally — status is segment-scoped
|
||||
this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments });
|
||||
|
||||
return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds };
|
||||
});
|
||||
}
|
||||
@@ -68,19 +81,16 @@ export class EnhancedSeatsService {
|
||||
});
|
||||
if (!schedule) throw new BadRequestException('Schedule not found');
|
||||
|
||||
// Resolve the passenger's leg range from the hold's fareQuoteId (encoded as "leg:originId:destId")
|
||||
const legKey = hold.fareQuoteId ?? '';
|
||||
// Resolve the passenger's leg from createdBy JSON
|
||||
let originStationId: string | undefined;
|
||||
let destinationStationId: string | undefined;
|
||||
if (legKey.startsWith('leg:')) {
|
||||
const parts = legKey.split(':');
|
||||
originStationId = parts[1];
|
||||
destinationStationId = parts[2];
|
||||
} else {
|
||||
// Fall back to booking's own origin/destination if available
|
||||
originStationId = (booking as any).originStationId;
|
||||
destinationStationId = (booking as any).destinationStationId;
|
||||
}
|
||||
try {
|
||||
if (hold.createdBy) {
|
||||
const meta = JSON.parse(hold.createdBy);
|
||||
originStationId = meta.originStationId;
|
||||
destinationStationId = meta.destinationStationId;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const originStop = originStationId ? schedule.stopTimes.find(s => s.stationId === originStationId) : undefined;
|
||||
const destStop = destinationStationId ? schedule.stopTimes.find(s => s.stationId === destinationStationId) : undefined;
|
||||
@@ -122,11 +132,10 @@ export class EnhancedSeatsService {
|
||||
}
|
||||
}
|
||||
|
||||
await tx.seat.updateMany({ where: { id: { in: hold.seatIds } }, data: { status: 'BOOKED', heldUntil: null } });
|
||||
// Do NOT set seat.status = BOOKED globally — availability is segment-scoped
|
||||
await tx.seatHold.delete({ where: { id: request.holdId } });
|
||||
|
||||
this.eventEmitter.emit('booking.confirmed', { bookingId: request.bookingId, scheduleId: hold.scheduleId, seatIds: hold.seatIds, segments });
|
||||
|
||||
return { bookingId: request.bookingId, confirmedSeats: hold.seatIds, segments };
|
||||
});
|
||||
}
|
||||
@@ -171,6 +180,8 @@ export class EnhancedSeatsService {
|
||||
|
||||
async getSeatAvailability(scheduleId: string, originStationId: string, destinationStationId: string) {
|
||||
const segments = await this.segmentsService.getJourneySegments(scheduleId, originStationId, destinationStationId);
|
||||
const reqFrom = Math.min(...segments.map(s => s.fromSequence));
|
||||
const reqTo = Math.max(...segments.map(s => s.toSequence));
|
||||
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
@@ -181,13 +192,20 @@ export class EnhancedSeatsService {
|
||||
const availableSeats = [];
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
for (const seat of assignment.coach.seats) {
|
||||
const overlaps = await this.segmentsService.getOverlappingReservations(scheduleId, seat.id, segments);
|
||||
if (overlaps.length === 0 && seat.status === 'AVAILABLE') {
|
||||
// Hard-blocked seats are never available
|
||||
if (seat.status === 'BLOCKED') continue;
|
||||
// Availability is determined purely by segment overlap — not global seat.status
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(scheduleId, seat.id, reqFrom, reqTo);
|
||||
if (free) {
|
||||
availableSeats.push({
|
||||
id: seat.id, label: seat.label,
|
||||
coach: assignment.coach.label,
|
||||
seatClass: assignment.coach.seatClass.name,
|
||||
row: seat.row, col: seat.col,
|
||||
kind: seat.kind,
|
||||
isWindow: seat.isWindow,
|
||||
isAisle: seat.isAisle,
|
||||
bedPosition: seat.bedPosition,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,132 +1,51 @@
|
||||
import { Controller, Post, Get, Body, Query, Param } from '@nestjs/common';
|
||||
import { Controller, Post, Get, Body, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { EnhancedSeatsService } from './enhanced-seats.service';
|
||||
import { HoldSeatsDto, ConfirmBookingDto, SeatAvailabilityDto, ReleaseSeatsDto } from './segments.dto';
|
||||
import { ConfirmBookingDto, SeatAvailabilityDto, ReleaseSeatsDto } from './segments.dto';
|
||||
|
||||
@ApiTags('Segment-based Seats')
|
||||
@Controller('segments/seats')
|
||||
export class SegmentSeatsController {
|
||||
constructor(private enhancedSeatsService: EnhancedSeatsService) {}
|
||||
|
||||
@Post('hold')
|
||||
@ApiOperation({
|
||||
summary: 'Hold seats for specific journey segments',
|
||||
description: 'Reserve seats for a partial journey (e.g., Addis Ababa → Dire Dawa) with 10-minute expiry'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Seats held successfully',
|
||||
schema: {
|
||||
example: {
|
||||
holdId: 'hold_123',
|
||||
expiresAt: '2024-01-15T10:10:00Z',
|
||||
segments: [
|
||||
{ fromName: 'Addis Ababa', toName: 'Adama', fromSequence: 0, toSequence: 1 },
|
||||
{ fromName: 'Adama', toName: 'Awash', fromSequence: 1, toSequence: 2 },
|
||||
{ fromName: 'Awash', toName: 'Dire Dawa', fromSequence: 2, toSequence: 3 }
|
||||
],
|
||||
seats: ['seat_1', 'seat_2']
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({ status: 409, description: 'Seats not available for requested segments' })
|
||||
async holdSeats(@Body() dto: HoldSeatsDto) {
|
||||
return this.enhancedSeatsService.holdSeats({
|
||||
scheduleId: dto.scheduleId,
|
||||
seatIds: dto.seatIds,
|
||||
passengerId: dto.passengerId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
fareQuoteId: dto.fareQuoteId,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('confirm')
|
||||
@ApiOperation({
|
||||
summary: 'Confirm booking and convert hold to reservation',
|
||||
description: 'Convert seat hold to confirmed booking after payment success'
|
||||
@ApiOperation({
|
||||
summary: 'Confirm booking — convert hold to reservation',
|
||||
description: 'Call after payment succeeds. Converts the SeatHold (created via POST /seats/hold) into JourneySegment records scoped to the passenger\'s leg.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Booking confirmed successfully',
|
||||
schema: {
|
||||
example: {
|
||||
bookingId: 'booking_123',
|
||||
confirmedSeats: ['seat_1', 'seat_2'],
|
||||
segments: [
|
||||
{ fromName: 'Addis Ababa', toName: 'Adama' },
|
||||
{ fromName: 'Adama', toName: 'Awash' },
|
||||
{ fromName: 'Awash', toName: 'Dire Dawa' }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Hold expired or not found' })
|
||||
async confirmBooking(@Body() dto: ConfirmBookingDto) {
|
||||
@ApiResponse({ status: 200, description: 'Booking confirmed, JourneySegments created for the held leg' })
|
||||
@ApiResponse({ status: 400, description: 'Hold expired or booking not found' })
|
||||
confirmBooking(@Body() dto: ConfirmBookingDto) {
|
||||
return this.enhancedSeatsService.confirmBooking(dto);
|
||||
}
|
||||
|
||||
@Post('release')
|
||||
@ApiOperation({
|
||||
summary: 'Release seats when train reaches station',
|
||||
description: 'Automatically release seats for passengers who have reached their destination'
|
||||
@ApiOperation({
|
||||
summary: 'Release seats when train reaches a station',
|
||||
description: 'Called by the live tracking system when the train departs a station. Frees seats for passengers whose journey ended at that station.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Seats released successfully',
|
||||
schema: {
|
||||
example: {
|
||||
releasedSeats: ['seat_1', 'seat_2'],
|
||||
stationId: 'st_DRE'
|
||||
}
|
||||
}
|
||||
})
|
||||
async releaseSeats(@Body() dto: ReleaseSeatsDto) {
|
||||
@ApiResponse({ status: 200, description: 'Seats released for passengers who reached their destination' })
|
||||
releaseSeats(@Body() dto: ReleaseSeatsDto) {
|
||||
return this.enhancedSeatsService.releaseSeats(dto.scheduleId, dto.currentStationId);
|
||||
}
|
||||
|
||||
@Get('availability')
|
||||
@ApiOperation({
|
||||
summary: 'Check seat availability for journey segments',
|
||||
description: 'Get available seats for a specific origin-destination pair'
|
||||
@ApiOperation({
|
||||
summary: 'Get available seats for a specific leg',
|
||||
description: 'Returns seats that have no overlapping reservation for the requested origin→destination leg. A seat booked A→B is shown as available for B→D.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Seat availability retrieved',
|
||||
schema: {
|
||||
example: {
|
||||
segments: [
|
||||
{ fromName: 'Addis Ababa', toName: 'Adama', fromSequence: 0, toSequence: 1 },
|
||||
{ fromName: 'Adama', toName: 'Awash', fromSequence: 1, toSequence: 2 }
|
||||
],
|
||||
availableSeats: [
|
||||
{ id: 'seat_1', label: '1A', coach: 'A', seatClass: 'Economy Regular', row: 1, col: 'A' },
|
||||
{ id: 'seat_2', label: '1B', coach: 'A', seatClass: 'Economy Regular', row: 1, col: 'B' }
|
||||
],
|
||||
totalAvailable: 2
|
||||
}
|
||||
}
|
||||
})
|
||||
async getSeatAvailability(@Query() dto: SeatAvailabilityDto) {
|
||||
@ApiResponse({ status: 200, description: 'Available seats with coach, seat class, row, col, window/aisle/bed flags' })
|
||||
getSeatAvailability(@Query() dto: SeatAvailabilityDto) {
|
||||
return this.enhancedSeatsService.getSeatAvailability(dto.scheduleId, dto.originStationId, dto.destinationStationId);
|
||||
}
|
||||
|
||||
@Post('expire-holds')
|
||||
@ApiOperation({
|
||||
summary: 'Expire old seat holds (background job)',
|
||||
description: 'Release seats from expired holds and make them available'
|
||||
@ApiOperation({
|
||||
summary: 'Expire stale seat holds (background job)',
|
||||
description: 'Removes holds past their expiry time. Called by the scheduler every minute.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Expired holds processed',
|
||||
schema: {
|
||||
example: {
|
||||
expiredHolds: 5,
|
||||
releasedSeats: ['seat_1', 'seat_2', 'seat_3']
|
||||
}
|
||||
}
|
||||
})
|
||||
async expireHolds() {
|
||||
@ApiResponse({ status: 200, description: 'Expired holds removed' })
|
||||
expireHolds() {
|
||||
return this.enhancedSeatsService.expireHolds();
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export class SegmentsService {
|
||||
});
|
||||
|
||||
const originStop = stopTimes.find(st => st.stationId === originStationId);
|
||||
const destStop = stopTimes.find(st => st.stationId === destinationStationId);
|
||||
const destStop = stopTimes.find(st => st.stationId === destinationStationId);
|
||||
|
||||
if (!originStop || !destStop) {
|
||||
throw new BadRequestException('Origin or destination station not found on this schedule');
|
||||
@@ -38,22 +38,126 @@ export class SegmentsService {
|
||||
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);
|
||||
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,
|
||||
toStationId: toStop.stationId,
|
||||
fromSequence: fromStop.sequence,
|
||||
toSequence: toStop.sequence,
|
||||
fromName: fromStop.station.name,
|
||||
toName: toStop.station.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** True if two segment ranges overlap: [a.from, a.to) ∩ [b.from, b.to) ≠ ∅ */
|
||||
/**
|
||||
* 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) {
|
||||
@@ -62,69 +166,4 @@ export class SegmentsService {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns conflicts for a seat on a schedule for the requested segment range.
|
||||
* Checks:
|
||||
* 1. Active SeatHolds — resolved to sequence range via JourneySegment if available,
|
||||
* otherwise treated as full-schedule block.
|
||||
* 2. Active BookingSeats — resolved via JourneySegment sequence ranges.
|
||||
*/
|
||||
async getOverlappingReservations(
|
||||
scheduleId: string,
|
||||
seatId: string,
|
||||
requestedSegments: Segment[],
|
||||
) {
|
||||
const overlaps: { type: string; id: string }[] = [];
|
||||
const reqFrom = Math.min(...requestedSegments.map(s => s.fromSequence));
|
||||
const reqTo = Math.max(...requestedSegments.map(s => s.toSequence));
|
||||
|
||||
// ── 1. Active holds ──────────────────────────────────────────────────────
|
||||
const activeHolds = await this.prisma.seatHold.findMany({
|
||||
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
|
||||
});
|
||||
|
||||
for (const hold of activeHolds) {
|
||||
// Resolve hold range from JourneySegments created at hold time
|
||||
const holdSegs = await this.prisma.journeySegment.findMany({
|
||||
where: { scheduleId, seatId },
|
||||
include: { schedule: { include: { stopTimes: true } } },
|
||||
});
|
||||
|
||||
if (holdSegs.length === 0) {
|
||||
// No journey segments yet — conservative: treat as full-schedule conflict
|
||||
overlaps.push({ type: 'hold', id: hold.id });
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const js of holdSegs) {
|
||||
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
|
||||
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
|
||||
if (depSeq !== undefined && arrSeq !== undefined && depSeq < reqTo && reqFrom < arrSeq) {
|
||||
overlaps.push({ type: 'hold', id: hold.id });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Active bookings via JourneySegment ────────────────────────────────
|
||||
const bookedSegments = await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
seatId,
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
include: { schedule: { include: { stopTimes: true } } },
|
||||
});
|
||||
|
||||
for (const js of bookedSegments) {
|
||||
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
|
||||
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
|
||||
if (depSeq !== undefined && arrSeq !== undefined && depSeq < reqTo && reqFrom < arrSeq) {
|
||||
overlaps.push({ type: 'booking', id: js.journeyId });
|
||||
}
|
||||
}
|
||||
|
||||
return overlaps;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user