diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 7f0a6eee5..6330c8cc6 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -13,6 +13,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service'; import { PaymentsService } from '../payments/payments.service'; import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; +import { JourneyDirection } from '../seats/seats.dto'; import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; @@ -856,6 +857,14 @@ export class BookingsService { const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); + await this.seatsService.assertNoRouteSeatConflict({ + scheduleId: dto.scheduleId, + seatIds: requestedSeatIds, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + journeyDirection: JourneyDirection.ONE_WAY, + }); + const [passengersData, iamContact] = await Promise.all([ this.processPassengers(dto.passengers as any[]), this.resolveIamContact(dto.passengerId), @@ -1052,6 +1061,21 @@ export class BookingsService { throw new NotFoundException('Origin or destination stops not found'); } + await this.seatsService.assertNoRouteSeatConflict({ + scheduleId: dto.scheduleId, + seatIds: holdObSeatIds, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + journeyDirection: JourneyDirection.OUTBOUND, + }); + await this.seatsService.assertNoRouteSeatConflict({ + scheduleId: dto.returnScheduleId, + seatIds: holdRetSeatIds, + originStationId: dto.returnOriginStationId, + destinationStationId: dto.returnDestinationStationId, + journeyDirection: JourneyDirection.RETURN, + }); + const [passengersData, iamContact] = await Promise.all([ this.processRoundTripPassengers(dto.passengers as any[]), this.resolveIamContact(dto.passengerId), @@ -1295,6 +1319,21 @@ export class BookingsService { if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule'); if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination station not found on leg-2 schedule'); + await this.seatsService.assertNoRouteSeatConflict({ + scheduleId: dto.scheduleId, + seatIds: leg1SeatIds, + originStationId: dto.originStationId, + destinationStationId: dto.transitStationId, + journeyDirection: JourneyDirection.ONE_WAY, + }); + await this.seatsService.assertNoRouteSeatConflict({ + scheduleId: dto.leg2ScheduleId, + seatIds: leg2SeatIds, + originStationId: dto.transitStationId, + destinationStationId: dto.leg2DestinationStationId, + journeyDirection: JourneyDirection.ONE_WAY, + }); + const [passengersData, iamContact] = await Promise.all([ this.processPassengers(dto.passengers as any[]), this.resolveIamContact(dto.passengerId), @@ -1490,6 +1529,35 @@ export class BookingsService { if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit station not found'); if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination not found'); + await this.seatsService.assertNoRouteSeatConflict({ + scheduleId: dto.scheduleId, + seatIds: (dto.passengers as any[]).map(p => p.seatId), + originStationId: dto.originStationId, + destinationStationId: dto.transitStationId, + journeyDirection: JourneyDirection.OUTBOUND, + }); + await this.seatsService.assertNoRouteSeatConflict({ + scheduleId: dto.leg2ScheduleId, + seatIds: (dto.passengers as any[]).map(p => p.leg2SeatId ?? p.seatId), + originStationId: dto.transitStationId, + destinationStationId: dto.leg2DestinationStationId, + journeyDirection: JourneyDirection.OUTBOUND, + }); + await this.seatsService.assertNoRouteSeatConflict({ + scheduleId: dto.returnScheduleId, + seatIds: (dto.passengers as any[]).map(p => p.returnSeatId), + originStationId: dto.returnOriginStationId, + destinationStationId: dto.returnTransitStationId, + journeyDirection: JourneyDirection.RETURN, + }); + await this.seatsService.assertNoRouteSeatConflict({ + scheduleId: dto.returnLeg2ScheduleId, + seatIds: (dto.passengers as any[]).map(p => p.returnLeg2SeatId ?? p.returnSeatId), + originStationId: dto.returnTransitStationId, + destinationStationId: dto.returnLeg2DestinationStationId, + journeyDirection: JourneyDirection.RETURN, + }); + const [passengersData, iamContact] = await Promise.all([ this.processRoundTripPassengers(dto.passengers as any[]), this.resolveIamContact(dto.passengerId), diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index c8061184f..4b3a9a53d 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -13,6 +13,7 @@ import { PaymentsService } from '../payments/payments.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { CreateGuestBookingDto, SavedPassengerProfileDto, IssueReservationBookingDto, ReservationBookingKind } from './guest-booking.dto'; import { Currency, PassengerCategory, IdDocumentType, PaymentMethodType, PaymentIntentStatus } from '@prisma/client'; +import { JourneyDirection } from '../seats/seats.dto'; import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils'; import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; import { randomUUID } from 'crypto'; diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts index 2c4215dc8..e80966d6a 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts @@ -2,16 +2,47 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SeatsService } from './seats.service'; import { PrismaService } from '../../common/prisma.service'; import { ConflictException } from '@nestjs/common'; +import { SegmentsService } from '../segments/segments.service'; +import { SystemConfigService } from '../system-config/system-config.service'; +import { AuditService } from '../../common/audit.service'; +import { SmsClientService } from '../notifications/sms-client.service'; +import { JourneyDirection } from './seats.dto'; describe('SeatsService - Auto Assign', () => { let service: SeatsService; let prisma: PrismaService; + let segmentsService: SegmentsService; const mockPrisma = { seat: { findMany: jest.fn(), updateMany: jest.fn(), }, + tripStopTime: { + findMany: jest.fn(), + }, + trainSchedule: { + findUnique: jest.fn(), + }, + seatBlock: { + findMany: jest.fn(), + }, + }; + + const mockSegmentsService = { + getSeatAvailabilityMap: jest.fn(), + }; + + const mockSystemConfigService = { + getNumber: jest.fn().mockResolvedValue(30), + }; + + const mockAuditService = { + log: jest.fn(), + }; + + const mockSmsClientService = { + send: jest.fn(), }; beforeEach(async () => { @@ -19,12 +50,67 @@ describe('SeatsService - Auto Assign', () => { providers: [ SeatsService, { provide: PrismaService, useValue: mockPrisma }, + { provide: SegmentsService, useValue: mockSegmentsService }, + { provide: SystemConfigService, useValue: mockSystemConfigService }, + { provide: AuditService, useValue: mockAuditService }, + { provide: SmsClientService, useValue: mockSmsClientService }, ], }).compile(); service = module.get(SeatsService); prisma = module.get(PrismaService); + segmentsService = module.get(SegmentsService); jest.clearAllMocks(); + + mockPrisma.trainSchedule.findUnique.mockResolvedValue({ + originStationId: 'origin-station', + destinationStationId: 'destination-station', + }); + mockPrisma.tripStopTime.findMany.mockResolvedValue([ + { stationId: 'origin-station', sequence: 0 }, + { stationId: 'destination-station', sequence: 1 }, + ]); + mockPrisma.seatBlock.findMany.mockResolvedValue([]); + mockSegmentsService.getSeatAvailabilityMap.mockResolvedValue(new Map()); + }); + + describe('assertNoRouteSeatConflict', () => { + it('rejects overlapping origin-destination seat reuse on the same schedule', async () => { + mockPrisma.tripStopTime.findMany.mockResolvedValue([ + { stationId: 'seb', sequence: 1 }, + { stationId: 'ada', sequence: 2 }, + { stationId: 'dd', sequence: 3 }, + ]); + mockSegmentsService.getSeatAvailabilityMap.mockResolvedValue(new Map([['seat-1', 'BOOKED']])); + mockPrisma.seat.findMany.mockResolvedValue([ + { id: 'seat-1', seatNumber: '12', coach: { number: 'C1' } }, + ]); + + await expect(service.assertNoRouteSeatConflict({ + scheduleId: 'sched-1', + seatIds: ['seat-1'], + originStationId: 'seb', + destinationStationId: 'dd', + journeyDirection: JourneyDirection.ONE_WAY, + })).rejects.toThrow(ConflictException); + }); + + it('allows the same seat number to be reused on non-overlapping legs', async () => { + mockPrisma.tripStopTime.findMany.mockResolvedValue([ + { stationId: 'seb', sequence: 1 }, + { stationId: 'ada', sequence: 2 }, + { stationId: 'dd', sequence: 3 }, + ]); + mockSegmentsService.getSeatAvailabilityMap.mockResolvedValue(new Map()); + + await expect(service.assertNoRouteSeatConflict({ + scheduleId: 'sched-1', + seatIds: ['seat-1'], + originStationId: 'seb', + destinationStationId: 'ada', + journeyDirection: JourneyDirection.ONE_WAY, + })).resolves.toBeUndefined(); + }); }); describe('autoAssignSeats', () => { diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 383f7d96c..83dc6aea4 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -209,6 +209,57 @@ export class SeatsService { // (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 assertNoRouteSeatConflict(args: { + scheduleId: string; + seatIds: string[]; + originStationId?: string; + destinationStationId?: string; + journeyDirection?: JourneyDirection; + }): Promise { + const { scheduleId, seatIds, originStationId, destinationStationId, journeyDirection = JourneyDirection.ONE_WAY } = args; + if (!seatIds.length) return; + + const stopTimes = await this.prisma.tripStopTime.findMany({ + where: { scheduleId }, + select: { stationId: true, sequence: true }, + }); + + let reqFrom = -Infinity; + let reqTo = Infinity; + if (originStationId && destinationStationId) { + const seqOf = (stationId: string) => stopTimes.find(s => s.stationId === stationId)?.sequence; + const resolvedFrom = seqOf(originStationId); + const resolvedTo = seqOf(destinationStationId); + if (resolvedFrom !== undefined && resolvedTo !== undefined) { + reqFrom = resolvedFrom; + reqTo = resolvedTo; + } + } + + const availability = await this.segmentsService.getSeatAvailabilityMap( + scheduleId, + seatIds, + stopTimes, + reqFrom, + reqTo, + journeyDirection, + ); + + if (availability.size === 0) return; + + const seats = await this.prisma.seat.findMany({ + where: { id: { in: seatIds } }, + select: { id: true, seatNumber: true }, + }); + const byId = new Map(seats.map(seat => [seat.id, seat.seatNumber])); + const conflicts = seatIds.filter(id => availability.has(id)); + + if (conflicts.length > 0) { + const labels = conflicts.map(id => byId.get(id) ?? id).join(', '); + throw new ConflictException(`Seat(s) ${labels} are already assigned for this route on this schedule`); + } + } + async resolveEffectiveStatuses( scheduleId: string, seatIds: string[], @@ -424,6 +475,14 @@ export class SeatsService { throw new BadRequestException('Origin must come before destination'); const currentDirection = dto.journeyDirection || JourneyDirection.ONE_WAY; + await this.assertNoRouteSeatConflict({ + scheduleId: dto.scheduleId, + seatIds, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + journeyDirection: currentDirection, + }); + const activeHolds = await tx.seatHold.findMany({ where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } }, select: { seatIds: true, createdBy: true },