Segment based seat assignement added

This commit is contained in:
Roba Boru
2026-05-27 15:11:23 +03:00
parent 3c6bd724f2
commit 2ceb993c60
10 changed files with 630 additions and 290 deletions

View File

@@ -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,
});
}
}

View File

@@ -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 origindestination 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();
}
}

View File

@@ -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;
}
}