From a82538d9db5c15cdd439ff707dc8bf525f3416a6 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 17 Jun 2026 18:12:49 +0300 Subject: [PATCH] feat: enhance search and ticketing for multi-leg (round trip + transit) journeys --- apps/edr-passenger-api/prisma/schema.prisma | 14 +- .../src/modules/bookings/bookings.dto.ts | 82 ++-- .../src/modules/bookings/bookings.service.ts | 349 +++++++++++++- .../src/modules/bookings/guest-booking.dto.ts | 56 ++- .../modules/bookings/guest-booking.service.ts | 427 +++++++++++++++++- .../src/modules/search/search.dto.ts | 51 ++- .../src/modules/search/search.service.ts | 354 ++++++++++----- .../src/modules/tickets/tickets.controller.ts | 16 +- .../src/modules/tickets/tickets.service.ts | 247 ++++++---- .../backoffice/tailwind.config.js | 1 + 10 files changed, 1282 insertions(+), 315 deletions(-) diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 3c5d18f3a..1b98bd3e1 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -523,7 +523,17 @@ model Booking { returnHoldId String? returnSeatClassId String? returnLegStatus ReturnLegStatus @default(NOT_APPLICABLE) - outboundBoardedAt DateTime? + // Transit leg-2 fields (single-booking transit) + leg2ScheduleId String? + leg2OriginStationId String? + leg2DestinationStationId String? + leg2SeatClassId String? + // Round-trip transit: return journey transit fields + returnLeg2ScheduleId String? + returnLeg2OriginStationId String? + returnLeg2DestStationId String? + returnLeg2SeatClassId String? + outboundBoardedAt DateTime? returnBoardedAt DateTime? contactEmail String? contactPhone String? @@ -554,6 +564,8 @@ model BookingSeat { id String @id @default(uuid()) bookingId String seatId String + leg Int @default(1) // 1=outbound/leg-1, 2=return/leg-2 + scheduleId String? // which schedule this seat belongs to passengerName String dateOfBirth DateTime? passengerCategory PassengerCategory @default(ADULT) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 875bf40a2..a5ebf717c 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -5,6 +5,7 @@ import { Currency, IdDocumentType } from '@prisma/client'; export class PassengerInputDto { @ApiProperty() @IsString() seatId: string; + @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Seat ID on leg-2 schedule' }) @IsOptional() @IsString() leg2SeatId?: string; @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first free), Age ≥5 = ADULT (full fare)' }) @IsDateString() dateOfBirth: string; @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; @@ -15,17 +16,17 @@ export class PassengerInputDto { } export class RoundTripPassengerDto { - @ApiProperty({ - description: 'Outbound journey seat ID', - example: 'seat-uuid-outbound' - }) + @ApiProperty({ description: 'Outbound journey seat ID', example: 'seat-uuid-outbound' }) @IsString() outboundSeatId: string; - @ApiProperty({ - description: 'Return journey seat ID', - example: 'seat-uuid-return' - }) + @ApiProperty({ description: 'Return journey seat ID', example: 'seat-uuid-return' }) @IsString() returnSeatId: string; + + @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Outbound leg-2 seat ID' }) + @IsOptional() @IsString() outboundLeg2SeatId?: string; + + @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) + @IsOptional() @IsString() returnLeg2SeatId?: string; @ApiProperty({ example: 'Abebe Kebede', @@ -92,8 +93,8 @@ export class CreateBookingDto { @ApiProperty({ example: 'ONE_WAY', - enum: ['ONE_WAY', 'ROUND_TRIP'], - description: `Booking type:\n\n**ONE_WAY:**\n- Single journey from origin to destination\n- Uses: scheduleId, holdId, originStationId, destinationStationId, seatClassId\n- passengers: PassengerInputDto[] with seatId\n\n**ROUND_TRIP:**\n- Outbound + return journey with single PNR\n- Uses all outbound fields PLUS return fields\n- passengers: RoundTripPassengerDto[] with outboundSeatId and returnSeatId\n- Combined fare calculation with single payment`, + enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'], + description: `Booking type:\n\n**ONE_WAY:** Single journey\n\n**ROUND_TRIP:** Outbound + return, single PNR\n\n**TRANSIT:** Single journey via connecting train, single PNR, single ticket\n\n**ROUND_TRIP_TRANSIT:** Round trip where one or both directions use a connecting train`, default: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string; @@ -114,31 +115,52 @@ export class CreateBookingDto { @ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; - // Round-trip specific fields - @ApiPropertyOptional({ - description: '**ROUND_TRIP ONLY:** Return schedule ID (required when bookingType=ROUND_TRIP)' - }) + // Transit-specific fields + @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 schedule ID' }) + @IsOptional() @IsString() leg2ScheduleId?: string; + + @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 seat hold ID' }) + @IsOptional() @IsString() leg2HoldId?: string; + + @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Transit (connecting) station UUID' }) + @IsOptional() @IsString() transitStationId?: string; + + @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 destination station UUID' }) + @IsOptional() @IsString() leg2DestinationStationId?: string; + + @ApiPropertyOptional({ description: '**TRANSIT / ROUND_TRIP_TRANSIT:** Leg-2 seat class ID (defaults to outbound seatClassId)' }) + @IsOptional() @IsString() leg2SeatClassId?: string; + + // Round-trip transit: return direction transit fields + @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-1 schedule ID' }) @IsOptional() @IsString() returnScheduleId?: string; - - @ApiPropertyOptional({ - description: '**ROUND_TRIP ONLY:** Return origin station ID (usually same as outbound destination)' - }) + + @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return origin station ID' }) @IsOptional() @IsString() returnOriginStationId?: string; - - @ApiPropertyOptional({ - description: '**ROUND_TRIP ONLY:** Return destination station ID (usually same as outbound origin)' - }) + + @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return destination station ID' }) @IsOptional() @IsString() returnDestinationStationId?: string; - - @ApiPropertyOptional({ - description: '**ROUND_TRIP ONLY:** Return seat hold ID (required when bookingType=ROUND_TRIP)' - }) + + @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return seat hold ID' }) @IsOptional() @IsString() returnHoldId?: string; - - @ApiPropertyOptional({ - description: '**ROUND_TRIP ONLY:** Return seat class ID (optional, defaults to outbound seatClassId if not provided)' - }) + + @ApiPropertyOptional({ description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return seat class ID' }) @IsOptional() @IsString() returnSeatClassId?: string; + + @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 schedule ID' }) + @IsOptional() @IsString() returnLeg2ScheduleId?: string; + + @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat hold ID' }) + @IsOptional() @IsString() returnLeg2HoldId?: string; + + @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return transit (connecting) station UUID' }) + @IsOptional() @IsString() returnTransitStationId?: string; + + @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 destination station UUID' }) + @IsOptional() @IsString() returnLeg2DestinationStationId?: string; + + @ApiPropertyOptional({ description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat class ID' }) + @IsOptional() @IsString() returnLeg2SeatClassId?: string; } export class ModifyBookingDto { 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 9b7cd085a..3e0d6a61a 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -254,9 +254,9 @@ export class BookingsService { } async create(dto: CreateBookingDto) { - if (dto.bookingType === 'ROUND_TRIP') { - return this.createRoundTripBooking(dto); - } + if (dto.bookingType === 'ROUND_TRIP') return this.createRoundTripBooking(dto); + if (dto.bookingType === 'TRANSIT') return this.createTransitBooking(dto); + if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createRoundTripTransitBooking(dto); return this.createOneWayBooking(dto); } @@ -401,20 +401,39 @@ export class BookingsService { returnSeatClassId: dto.returnSeatClassId, returnLegStatus: 'NEITHER_USED', seats: { - create: passengersData.map(p => ({ - seat: { connect: { id: p.outboundSeatId } }, - passengerName: p.passengerName, - dateOfBirth: p.dateOfBirth, - passengerCategory: p.category, - idDocumentType: p.idDocumentType, - passportNumber: p.passportNumber, - passportCountry: p.passportCountry, - verifaydaVerified: p.verifaydaVerified, - verifaydaData: p.verifaydaData, - fareMinor: p.category === PassengerCategory.ADULT ? (outboundFare.baseFareMinor + returnFare.baseFareMinor) : 0, - displayCurrency - })) - } + create: [ + ...passengersData.map(p => ({ + seat: { connect: { id: p.outboundSeatId } }, + leg: 1, + scheduleId: dto.scheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData, + fareMinor: p.category === PassengerCategory.ADULT ? outboundFare.baseFareMinor : (outboundFare.paidChildrenCount > 0 ? outboundFare.baseFareMinor : 0), + displayCurrency, + })), + ...passengersData.map(p => ({ + seat: { connect: { id: p.returnSeatId } }, + leg: 2, + scheduleId: dto.returnScheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData, + fareMinor: p.category === PassengerCategory.ADULT ? returnFare.baseFareMinor : (returnFare.paidChildrenCount > 0 ? returnFare.baseFareMinor : 0), + displayCurrency, + })), + ], + }, } as any, include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } } }); @@ -445,6 +464,302 @@ export class BookingsService { }; } + private async createTransitBooking(dto: CreateBookingDto) { + if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) { + throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings'); + } + + const [leg1Hold, leg2Hold] = await Promise.all([ + this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }), + this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }), + ]); + if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired'); + if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired'); + + const [leg1Schedule, leg2Schedule] = await Promise.all([ + this.prisma.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + }), + this.prisma.trainSchedule.findUnique({ + where: { id: dto.leg2ScheduleId }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + }), + ]); + if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found'); + if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found'); + + const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId); + const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); + const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); + const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId); + 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'); + + const passengersData = await this.processPassengers(dto.passengers as any[]); + const { adultCount, childCount } = this.countPassengers(passengersData); + + const leg2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId; + const [leg1Fare, leg2Fare] = await Promise.all([ + this.calculateFare(dto.scheduleId, dto.seatClassId, leg1OriginStop, leg1DestStop, passengersData[0]?.nationality, adultCount, childCount), + this.calculateFare(dto.leg2ScheduleId, leg2SeatClassId, leg2OriginStop, leg2DestStop, passengersData[0]?.nationality, adultCount, childCount), + ]); + + const combinedBase = leg1Fare.totalBaseFareMinor + leg2Fare.totalBaseFareMinor; + let discountMinor = 0; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > new Date()) { + discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); + } + } + const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; + const taxesMinor = Math.round(combinedBase * 0.05); + const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor); + const displayCurrency = dto.displayCurrency || Currency.ETB; + const displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + // Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2 + const booking = await this.prisma.booking.create({ + data: { + bookingRef: generateRef(), + passengerId: dto.passengerId, + scheduleId: dto.scheduleId, + status: 'PENDING_PAYMENT', + bookingType: 'TRANSIT', + totalMinor, + adultCount, + childCount, + displayCurrency, + displayTotalMinor, + leg2ScheduleId: dto.leg2ScheduleId, + leg2OriginStationId: dto.transitStationId, + leg2DestinationStationId: dto.leg2DestinationStationId, + leg2SeatClassId, + seats: { + create: [ + ...passengersData.map(p => ({ + seat: { connect: { id: p.seatId } }, + leg: 1, + scheduleId: dto.scheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData, + fareMinor: p.category === PassengerCategory.ADULT ? leg1Fare.baseFareMinor : (leg1Fare.paidChildrenCount > 0 ? leg1Fare.baseFareMinor : 0), + displayCurrency, + })), + ...passengersData.map(p => ({ + seat: { connect: { id: p.leg2SeatId ?? p.seatId } }, + leg: 2, + scheduleId: dto.leg2ScheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData, + fareMinor: p.category === PassengerCategory.ADULT ? leg2Fare.baseFareMinor : (leg2Fare.paidChildrenCount > 0 ? leg2Fare.baseFareMinor : 0), + displayCurrency, + })), + ], + }, + } as any, + include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }, + }); + + await Promise.all([ + this.seatsService.confirmSeats(passengersData.map(p => p.seatId)), + this.seatsService.confirmSeats(passengersData.map(p => p.leg2SeatId ?? p.seatId)), + ]); + this.eventEmitter.emit('booking.created', { booking }); + + return { + ...booking, + fareBreakdown: { + leg1BaseFareMinor: leg1Fare.baseFareMinor, + leg2BaseFareMinor: leg2Fare.baseFareMinor, + adultCount, childCount, + freeChildrenCount: Math.min(childCount, 1), + paidChildrenCount: leg1Fare.paidChildrenCount, + combinedBaseFareMinor: combinedBase, + discountMinor, loyaltyRedemptionMinor: loyaltyMinor, + taxesFeesMinor: taxesMinor, totalMinor, + currency: 'ETB', displayCurrency, displayTotalMinor, + }, + }; + } + + private async createRoundTripTransitBooking(dto: CreateBookingDto) { + if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId || + !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId || + !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) { + throw new BadRequestException( + 'ROUND_TRIP_TRANSIT requires outbound transit fields (leg2ScheduleId, leg2HoldId, transitStationId, leg2DestinationStationId) ' + + 'AND return transit fields (returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, ' + + 'returnLeg2ScheduleId, returnLeg2HoldId, returnTransitStationId, returnLeg2DestinationStationId)', + ); + } + + // Validate all 4 holds + const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([ + this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }), + this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }), + this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }), + this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }), + ]); + const now = new Date(); + if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 seat hold expired'); + if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 seat hold expired'); + if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 seat hold expired'); + if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 seat hold expired'); + + // Load all 4 schedules + const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([ + this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + ]); + if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found'); + if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found'); + if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found'); + if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found'); + + const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId); + const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId); + const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId); + const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId); + const retL1Origin = retL1Sched.stopTimes.find(s => s.stationId === dto.returnOriginStationId); + const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId); + const retL2Origin = retL2Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId); + const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId); + if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit station not found'); + if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination not found'); + 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'); + + const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]); + const { adultCount, childCount } = this.countPassengers(passengersData); + const nat = passengersData[0]?.nationality; + + const obL2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId; + const retL1SeatClassId = dto.returnSeatClassId ?? dto.seatClassId; + const retL2SeatClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId; + + const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([ + this.calculateFare(dto.scheduleId, dto.seatClassId, obL1Origin, obL1Dest, nat, adultCount, childCount), + this.calculateFare(dto.leg2ScheduleId, obL2SeatClassId, obL2Origin, obL2Dest, nat, adultCount, childCount), + this.calculateFare(dto.returnScheduleId, retL1SeatClassId, retL1Origin, retL1Dest, nat, adultCount, childCount), + this.calculateFare(dto.returnLeg2ScheduleId, retL2SeatClassId, retL2Origin, retL2Dest, nat, adultCount, childCount), + ]); + + const combinedBase = obL1Fare.totalBaseFareMinor + obL2Fare.totalBaseFareMinor + + retL1Fare.totalBaseFareMinor + retL2Fare.totalBaseFareMinor; + let discountMinor = 0; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > new Date()) { + discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); + } + } + const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; + const taxesMinor = Math.round(combinedBase * 0.05); + const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor); + const displayCurrency = dto.displayCurrency || Currency.ETB; + const displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: Awaited>) => ({ + seat: { connect: { id: seatId } }, + leg, + scheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData, + fareMinor: p.category === PassengerCategory.ADULT ? fare.baseFareMinor : (fare.paidChildrenCount > 0 ? fare.baseFareMinor : 0), + displayCurrency, + }); + + const booking = await this.prisma.booking.create({ + data: { + bookingRef: generateRef(), + passengerId: dto.passengerId, + scheduleId: dto.scheduleId, + status: 'PENDING_PAYMENT', + bookingType: 'ROUND_TRIP_TRANSIT', + totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor, + // Outbound transit leg-2 + leg2ScheduleId: dto.leg2ScheduleId, + leg2OriginStationId: dto.transitStationId, + leg2DestinationStationId: dto.leg2DestinationStationId, + leg2SeatClassId: obL2SeatClassId, + // Return transit + returnScheduleId: dto.returnScheduleId, + returnOriginStationId: dto.returnOriginStationId, + returnDestinationStationId: dto.returnDestinationStationId, + returnSeatClassId: retL1SeatClassId, + returnLeg2ScheduleId: dto.returnLeg2ScheduleId, + returnLeg2OriginStationId: dto.returnTransitStationId, + returnLeg2DestStationId: dto.returnLeg2DestinationStationId, + returnLeg2SeatClassId: retL2SeatClassId, + returnLegStatus: 'NEITHER_USED', + seats: { + create: [ + // Outbound leg-1 (sequence 1) + ...passengersData.map(p => makeSeat(p, p.outboundSeatId, 1, dto.scheduleId, obL1Fare)), + // Outbound leg-2 (sequence 2) + ...passengersData.map(p => makeSeat(p, p.outboundLeg2SeatId ?? p.outboundSeatId, 2, dto.leg2ScheduleId!, obL2Fare)), + // Return leg-1 (sequence 3) + ...passengersData.map(p => makeSeat(p, p.returnSeatId, 3, dto.returnScheduleId!, retL1Fare)), + // Return leg-2 (sequence 4) + ...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId ?? p.returnSeatId, 4, dto.returnLeg2ScheduleId!, retL2Fare)), + ], + }, + } as any, + include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }, + }); + + await Promise.all([ + this.seatsService.confirmSeats(passengersData.map(p => p.outboundSeatId)), + this.seatsService.confirmSeats(passengersData.map(p => p.outboundLeg2SeatId ?? p.outboundSeatId)), + this.seatsService.confirmSeats(passengersData.map(p => p.returnSeatId)), + this.seatsService.confirmSeats(passengersData.map(p => p.returnLeg2SeatId ?? p.returnSeatId)), + ]); + this.eventEmitter.emit('booking.created', { booking }); + + return { + ...booking, + fareBreakdown: { + outboundLeg1FareMinor: obL1Fare.baseFareMinor, + outboundLeg2FareMinor: obL2Fare.baseFareMinor, + returnLeg1FareMinor: retL1Fare.baseFareMinor, + returnLeg2FareMinor: retL2Fare.baseFareMinor, + adultCount, childCount, + freeChildrenCount: Math.min(childCount, 1), + paidChildrenCount: obL1Fare.paidChildrenCount, + combinedBaseFareMinor: combinedBase, + discountMinor, loyaltyRedemptionMinor: loyaltyMinor, + taxesFeesMinor: taxesMinor, totalMinor, + currency: 'ETB', displayCurrency, displayTotalMinor, + }, + }; + } + private async processPassengers(passengers: any[]) { const processedPassengers = []; for (const passenger of passengers) { diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts index ea7d1132b..ba4af04b4 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts @@ -7,9 +7,15 @@ export class GuestPassengerDto { @ApiProperty({ example: 'seat-id-uuid', description: 'Outbound seat ID (or only seat for ONE_WAY)' }) @IsString() seatId: string; - @ApiPropertyOptional({ example: 'seat-id-uuid', description: 'Return seat ID (ROUND_TRIP only)' }) + @ApiPropertyOptional({ example: 'seat-id-uuid', description: 'Return seat ID (ROUND_TRIP / ROUND_TRIP_TRANSIT outbound leg-1)' }) @IsOptional() @IsString() returnSeatId?: string; + @ApiPropertyOptional({ example: 'seat-id-uuid', description: 'Leg-2 seat ID (TRANSIT / ROUND_TRIP_TRANSIT outbound leg-2)' }) + @IsOptional() @IsString() leg2SeatId?: string; + + @ApiPropertyOptional({ example: 'seat-id-uuid', description: 'ROUND_TRIP_TRANSIT: return journey leg-2 seat ID' }) + @IsOptional() @IsString() returnLeg2SeatId?: string; + @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; @@ -39,10 +45,10 @@ export class GuestPassengerDto { } export class CreateGuestBookingDto { - @ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP'], default: 'ONE_WAY' }) - @IsOptional() @IsString() bookingType?: 'ONE_WAY' | 'ROUND_TRIP'; + @ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP', 'TRANSIT', 'ROUND_TRIP_TRANSIT'], default: 'ONE_WAY' }) + @IsOptional() @IsString() bookingType?: 'ONE_WAY' | 'ROUND_TRIP' | 'TRANSIT' | 'ROUND_TRIP_TRANSIT'; - @ApiProperty({ example: 'schedule-uuid', description: 'Outbound schedule UUID' }) + @ApiProperty({ example: 'schedule-uuid', description: 'Outbound / leg-1 schedule UUID' }) @IsString() scheduleId: string; @ApiProperty({ example: 'hold-uuid', description: 'Outbound seat hold UUID' }) @@ -57,20 +63,50 @@ export class CreateGuestBookingDto { @ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID' }) @IsString() seatClassId: string; - @ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP only: return schedule UUID' }) + @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return seat class UUID' }) + @IsOptional() @IsString() returnSeatClassId?: string; + + @ApiPropertyOptional({ example: 'schedule-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 schedule UUID' }) + @IsOptional() @IsString() leg2ScheduleId?: string; + + @ApiPropertyOptional({ example: 'hold-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 seat hold UUID' }) + @IsOptional() @IsString() leg2HoldId?: string; + + @ApiPropertyOptional({ example: 'station-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: connecting station UUID' }) + @IsOptional() @IsString() transitStationId?: string; + + @ApiPropertyOptional({ example: 'station-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 destination station UUID' }) + @IsOptional() @IsString() leg2DestinationStationId?: string; + + @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'TRANSIT / ROUND_TRIP_TRANSIT: leg-2 seat class UUID' }) + @IsOptional() @IsString() leg2SeatClassId?: string; + + @ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return leg-1 schedule UUID' }) @IsOptional() @IsString() returnScheduleId?: string; - @ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP only: return seat hold UUID' }) + @ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return seat hold UUID' }) @IsOptional() @IsString() returnHoldId?: string; - @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP only: return origin station UUID' }) + @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return origin station UUID' }) @IsOptional() @IsString() returnOriginStationId?: string; - @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP only: return destination station UUID' }) + @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP / ROUND_TRIP_TRANSIT: return destination station UUID' }) @IsOptional() @IsString() returnDestinationStationId?: string; - @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP only: return seat class UUID (defaults to outbound seatClassId)' }) - @IsOptional() @IsString() returnSeatClassId?: string; + @ApiPropertyOptional({ example: 'schedule-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 schedule UUID' }) + @IsOptional() @IsString() returnLeg2ScheduleId?: string; + + @ApiPropertyOptional({ example: 'hold-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 seat hold UUID' }) + @IsOptional() @IsString() returnLeg2HoldId?: string; + + @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP_TRANSIT: return transit station UUID' }) + @IsOptional() @IsString() returnTransitStationId?: string; + + @ApiPropertyOptional({ example: 'station-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 destination station UUID' }) + @IsOptional() @IsString() returnLeg2DestinationStationId?: string; + + @ApiPropertyOptional({ example: 'seat-class-uuid', description: 'ROUND_TRIP_TRANSIT: return leg-2 seat class UUID' }) + @IsOptional() @IsString() returnLeg2SeatClassId?: string; @ApiProperty({ type: [GuestPassengerDto], description: 'Array of passengers. For ROUND_TRIP each passenger must include returnSeatId.' }) @IsArray() @ValidateNested({ each: true }) @Type(() => GuestPassengerDto) passengers: GuestPassengerDto[]; 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 1687e15f4..48b84c4b6 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 @@ -34,9 +34,9 @@ export class GuestBookingService { ) {} async createGuestBooking(dto: CreateGuestBookingDto) { - if (dto.bookingType === 'ROUND_TRIP') { - return this.createGuestRoundTripBooking(dto); - } + if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto); + if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto); + if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto); return this.createGuestOneWayBooking(dto); } @@ -385,21 +385,38 @@ export class GuestBookingService { returnLegStatus: 'NEITHER_USED', userAgent: dto.deviceId, seats: { - create: passengersData.map((p) => ({ - seat: { connect: { id: p.seatId } }, - passengerName: p.passengerName, - dateOfBirth: p.dateOfBirth, - passengerCategory: p.category, - idDocumentType: p.idDocumentType, - passportNumber: p.passportNumber, - passportCountry: p.passportCountry, - verifaydaVerified: p.verifaydaVerified, - verifaydaData: p.verifaydaData || undefined, - fareMinor: p.category === PassengerCategory.ADULT - ? outboundBaseFare + returnBaseFare - : (paidChildrenCount > 0 ? outboundBaseFare + returnBaseFare : 0), - displayCurrency, - })), + create: [ + ...passengersData.map((p) => ({ + seat: { connect: { id: p.seatId } }, + leg: 1, + scheduleId: dto.scheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.category === PassengerCategory.ADULT ? outboundBaseFare : (paidChildrenCount > 0 ? outboundBaseFare : 0), + displayCurrency, + })), + ...passengersData.map((p) => ({ + seat: { connect: { id: p.returnSeatId } }, + leg: 2, + scheduleId: dto.returnScheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.category === PassengerCategory.ADULT ? returnBaseFare : (paidChildrenCount > 0 ? returnBaseFare : 0), + displayCurrency, + })), + ], }, } as any, include: { @@ -436,6 +453,380 @@ export class GuestBookingService { }; } + private async createGuestTransitBooking(dto: CreateGuestBookingDto) { + if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId) { + throw new BadRequestException('leg2ScheduleId, leg2HoldId, transitStationId and leg2DestinationStationId are required for TRANSIT bookings'); + } + + const [leg1Hold, leg2Hold] = await Promise.all([ + this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }), + this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }), + ]); + if (!leg1Hold || leg1Hold.expiresAt < new Date()) throw new BadRequestException('Leg-1 seat hold expired or not found'); + if (!leg2Hold || leg2Hold.expiresAt < new Date()) throw new BadRequestException('Leg-2 seat hold expired or not found'); + + for (const p of dto.passengers) { + if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId is required for each passenger in a TRANSIT booking (missing for ${p.passengerName})`); + } + + const [leg1Schedule, leg2Schedule] = await Promise.all([ + this.prisma.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + }), + this.prisma.trainSchedule.findUnique({ + where: { id: dto.leg2ScheduleId }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + }), + ]); + if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found'); + if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found'); + + const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId); + const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); + const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); + const leg2DestStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId); + 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 not found on leg-2 schedule'); + + // Process passengers (verify identity once) + const passengersData: any[] = []; + let adultCount = 0, childCount = 0; + for (const passenger of dto.passengers) { + const dateOfBirth = new Date(passenger.dateOfBirth); + const age = calculateAge(dateOfBirth); + const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT; + if (category === PassengerCategory.ADULT) adultCount++; else childCount++; + + let passengerName = passenger.passengerName; + let verifaydaVerified = false; + let verifaydaData: Record | undefined; + let nationality = passenger.nationality; + + const isEthiopian = passenger.nationality === 'Ethiopian' || passenger.nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID; + if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) { + const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); + if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`); + passengerName = verification.passengerData?.fullName || passengerName; + verifaydaVerified = true; + verifaydaData = verification.passengerData?.profileData; + nationality = 'Ethiopian'; + } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { + if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport details required for ${passenger.passengerName}`); + nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); + } else { + nationality = nationality || 'Other'; + } + passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + } + + const leg2SeatClassId = dto.leg2SeatClassId || dto.seatClassId; + const primaryNationality = passengersData[0]?.nationality; + const paidChildrenCount = Math.max(0, childCount - 1); + + const [leg1BaseFare, leg2BaseFare] = await Promise.all([ + this.getBaseFare(dto.scheduleId, dto.seatClassId, + `${leg1OriginStop.station.code}-${leg1DestStop.station.code}`, + `${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`, + primaryNationality), + this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId, + `${leg2OriginStop.station.code}-${leg2DestStop.station.code}`, + `${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`, + primaryNationality), + ]); + + const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount; + const leg2Total = leg2BaseFare * adultCount + leg2BaseFare * paidChildrenCount; + const combinedBase = leg1Total + leg2Total; + + let discountMinor = 0; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > new Date()) { + discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); + } + } + const taxesMinor = Math.round(combinedBase * 0.05); + const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor); + + const displayCurrency = dto.displayCurrency || Currency.ETB; + const displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]); + + // Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2 + const booking = await this.prisma.booking.create({ + data: { + bookingRef: generateRef(), + passengerId: guestPassenger.id, + scheduleId: dto.scheduleId, + status: 'PENDING_PAYMENT', + bookingType: 'TRANSIT', + totalMinor, + adultCount, + childCount, + displayCurrency, + displayTotalMinor, + leg2ScheduleId: dto.leg2ScheduleId, + leg2OriginStationId: dto.transitStationId, + leg2DestinationStationId: dto.leg2DestinationStationId, + leg2SeatClassId: leg2SeatClassId, + userAgent: dto.deviceId, + seats: { + create: [ + ...passengersData.map(p => ({ + seat: { connect: { id: p.seatId } }, + leg: 1, + scheduleId: dto.scheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.category === PassengerCategory.ADULT ? leg1BaseFare : (paidChildrenCount > 0 ? leg1BaseFare : 0), + displayCurrency, + })), + ...passengersData.map(p => ({ + seat: { connect: { id: p.leg2SeatId! } }, + leg: 2, + scheduleId: dto.leg2ScheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.category === PassengerCategory.ADULT ? leg2BaseFare : (paidChildrenCount > 0 ? leg2BaseFare : 0), + displayCurrency, + })), + ], + }, + } as any, + include: { + seats: { include: { seat: { include: { coach: true } } } }, + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + }, + }); + + await Promise.all([ + this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)), + this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)), + ]); + this.eventEmitter.emit('booking.created', { booking }); + + return { + ...booking, + createdAccount, + userId, + fareBreakdown: { + leg1BaseFareMinor: leg1BaseFare, + leg2BaseFareMinor: leg2BaseFare, + adultCount, childCount, + freeChildrenCount: Math.min(childCount, 1), + paidChildrenCount, + combinedBaseFareMinor: combinedBase, + discountMinor, taxesFeesMinor: taxesMinor, totalMinor, + currency: 'ETB', displayCurrency, displayTotalMinor, + }, + }; + } + + private async createGuestRoundTripTransitBooking(dto: CreateGuestBookingDto) { + if (!dto.leg2ScheduleId || !dto.leg2HoldId || !dto.transitStationId || !dto.leg2DestinationStationId || + !dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId || + !dto.returnLeg2ScheduleId || !dto.returnLeg2HoldId || !dto.returnTransitStationId || !dto.returnLeg2DestinationStationId) { + throw new BadRequestException( + 'ROUND_TRIP_TRANSIT requires all 4 holds and all transit/return station fields', + ); + } + for (const p of dto.passengers) { + if (!p.leg2SeatId) throw new BadRequestException(`leg2SeatId required for ${p.passengerName}`); + if (!p.returnSeatId) throw new BadRequestException(`returnSeatId required for ${p.passengerName}`); + if (!p.returnLeg2SeatId) throw new BadRequestException(`returnLeg2SeatId required for ${p.passengerName}`); + } + + const now = new Date(); + const [obL1Hold, obL2Hold, retL1Hold, retL2Hold] = await Promise.all([ + this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }), + this.prisma.seatHold.findUnique({ where: { id: dto.leg2HoldId } }), + this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }), + this.prisma.seatHold.findUnique({ where: { id: dto.returnLeg2HoldId } }), + ]); + if (!obL1Hold || obL1Hold.expiresAt < now) throw new BadRequestException('Outbound leg-1 hold expired'); + if (!obL2Hold || obL2Hold.expiresAt < now) throw new BadRequestException('Outbound leg-2 hold expired'); + if (!retL1Hold || retL1Hold.expiresAt < now) throw new BadRequestException('Return leg-1 hold expired'); + if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired'); + + const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([ + this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId },include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + ]); + if (!obL1Sched) throw new NotFoundException('Outbound leg-1 schedule not found'); + if (!obL2Sched) throw new NotFoundException('Outbound leg-2 schedule not found'); + if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found'); + if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found'); + + const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId); + const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId); + const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId); + const obL2Dest = obL2Sched.stopTimes.find(s => s.stationId === dto.leg2DestinationStationId); + const retL1Origin = retL1Sched.stopTimes.find(s => s.stationId === dto.returnOriginStationId); + const retL1Dest = retL1Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId); + const retL2Origin = retL2Sched.stopTimes.find(s => s.stationId === dto.returnTransitStationId); + const retL2Dest = retL2Sched.stopTimes.find(s => s.stationId === dto.returnLeg2DestinationStationId); + if (!obL1Origin || !obL1Dest) throw new NotFoundException('Outbound leg-1: origin or transit stop not found'); + if (!obL2Origin || !obL2Dest) throw new NotFoundException('Outbound leg-2: transit or destination stop not found'); + if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found'); + if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found'); + + // Process passengers (verify once) + const passengersData: any[] = []; + let adultCount = 0, childCount = 0; + for (const passenger of dto.passengers) { + const dateOfBirth = new Date(passenger.dateOfBirth); + const category: PassengerCategory = calculateAge(dateOfBirth) < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT; + if (category === PassengerCategory.ADULT) adultCount++; else childCount++; + let passengerName = passenger.passengerName; + let verifaydaVerified = false; + let verifaydaData: Record | undefined; + let nationality = passenger.nationality; + const isEthiopian = nationality === 'Ethiopian' || nationality === 'ETHIOPIAN' || passenger.idDocumentType === IdDocumentType.NATIONAL_ID; + if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) { + const v = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); + if (!v.verified) throw new BadRequestException(`Verifayda failed for ${passenger.passengerName}: ${v.failureReason}`); + passengerName = v.passengerData?.fullName || passengerName; + verifaydaVerified = true; + verifaydaData = v.passengerData?.profileData; + nationality = 'Ethiopian'; + } else if (!isEthiopian && passenger.idDocumentType === IdDocumentType.PASSPORT) { + if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport details required for ${passenger.passengerName}`); + nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); + } else { + nationality = nationality || 'Other'; + } + passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + } + + const nat = passengersData[0]?.nationality; + const paidChildren = Math.max(0, childCount - 1); + const obL2ClassId = dto.leg2SeatClassId ?? dto.seatClassId; + const retL1ClassId = dto.returnSeatClassId ?? dto.seatClassId; + const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId; + + const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([ + this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat), + this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat), + this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat), + this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat), + ]); + + const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount + + (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * paidChildren; + let discountMinor = 0; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > new Date()) { + discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); + } + } + const taxesMinor = Math.round(combinedBase * 0.05); + const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor); + const displayCurrency = dto.displayCurrency || Currency.ETB; + const displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + const { guestPassenger, userId, createdAccount } = await this.resolveGuestPassenger(dto, passengersData[0]); + + const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: number) => ({ + seat: { connect: { id: seatId } }, + leg, + scheduleId, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData || undefined, + fareMinor: p.category === PassengerCategory.ADULT ? fare : (paidChildren > 0 ? fare : 0), + displayCurrency, + }); + + const booking = await this.prisma.booking.create({ + data: { + bookingRef: generateRef(), + passengerId: guestPassenger.id, + scheduleId: dto.scheduleId, + status: 'PENDING_PAYMENT', + bookingType: 'ROUND_TRIP_TRANSIT', + totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor, + leg2ScheduleId: dto.leg2ScheduleId, + leg2OriginStationId: dto.transitStationId, + leg2DestinationStationId: dto.leg2DestinationStationId, + leg2SeatClassId: obL2ClassId, + returnScheduleId: dto.returnScheduleId, + returnOriginStationId: dto.returnOriginStationId, + returnDestinationStationId: dto.returnDestinationStationId, + returnSeatClassId: retL1ClassId, + returnLeg2ScheduleId: dto.returnLeg2ScheduleId, + returnLeg2OriginStationId: dto.returnTransitStationId, + returnLeg2DestStationId: dto.returnLeg2DestinationStationId, + returnLeg2SeatClassId: retL2ClassId, + returnLegStatus: 'NEITHER_USED', + userAgent: dto.deviceId, + seats: { + create: [ + ...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)), + ...passengersData.map(p => makeSeat(p, p.leg2SeatId!, 2, dto.leg2ScheduleId!, obL2Fare)), + ...passengersData.map(p => makeSeat(p, p.returnSeatId!, 3, dto.returnScheduleId!, retL1Fare)), + ...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId!,4, dto.returnLeg2ScheduleId!,retL2Fare)), + ], + }, + } as any, + include: { + seats: { include: { seat: { include: { coach: true } } } }, + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + }, + }); + + await Promise.all([ + this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)), + this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)), + this.seatsService.confirmSeats(dto.passengers.map(p => p.returnSeatId!)), + this.seatsService.confirmSeats(dto.passengers.map(p => p.returnLeg2SeatId!)), + ]); + this.eventEmitter.emit('booking.created', { booking }); + + return { + ...booking, + createdAccount, + userId, + fareBreakdown: { + outboundLeg1FareMinor: obL1Fare, + outboundLeg2FareMinor: obL2Fare, + returnLeg1FareMinor: retL1Fare, + returnLeg2FareMinor: retL2Fare, + adultCount, childCount, + freeChildrenCount: Math.min(childCount, 1), + paidChildrenCount: paidChildren, + combinedBaseFareMinor: combinedBase, + discountMinor, taxesFeesMinor: taxesMinor, totalMinor, + currency: 'ETB', displayCurrency, displayTotalMinor, + }, + }; + } + private async resolveGuestPassenger( dto: Pick, firstPassenger: any, diff --git a/apps/edr-passenger-api/src/modules/search/search.dto.ts b/apps/edr-passenger-api/src/modules/search/search.dto.ts index b1d4c371f..9eb035ef2 100644 --- a/apps/edr-passenger-api/src/modules/search/search.dto.ts +++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts @@ -71,27 +71,38 @@ export class FareQuoteDto { } export class CoachTypeOptionClass { - @ApiProperty({ example: 'Economy Regular', description: 'Seat class name' }) - name: string; - - @ApiProperty({ example: 35000, description: 'Base fare in ETB minor units per passenger' }) - baseFareMinor: number; + @ApiProperty({ example: 'Economy Regular' }) name: string; + @ApiProperty({ example: 35000 }) baseFareMinor: number; } export class CoachTypeOption { - @ApiProperty({ example: 'coach-type-uuid', description: 'Coach type unique identifier' }) - coachTypeId: string; - - @ApiProperty({ example: 'Economy', description: 'Coach type display name' }) - coachTypeName: string; - - @ApiProperty({ example: 'ECO', description: 'Coach type code' }) - coachTypeCode: string; - - @ApiProperty({ - type: 'array', - items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' }, - description: 'Available seat classes within this coach type with base fares. User selects specific class at seat selection page.', - }) - classes: CoachTypeOptionClass[]; + @ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string; + @ApiProperty({ example: 'Economy' }) coachTypeName: string; + @ApiProperty({ example: 'ECO' }) coachTypeCode: string; + @ApiProperty({ type: 'array', items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' } }) classes: CoachTypeOptionClass[]; +} + +export class TransitLegDto { + @ApiProperty({ example: 'schedule-uuid' }) scheduleId: string; + @ApiProperty() trainNumber: string; + @ApiProperty() trainName: string; + @ApiProperty() origin: object; + @ApiProperty() destination: object; + @ApiProperty() departureAt: Date; + @ApiProperty() arrivalAt: Date; + @ApiProperty() durationMinutes: number; + @ApiProperty() availabilityByClass: object; + @ApiProperty() faresByClass: object[]; + @ApiProperty() coachTypes: CoachTypeOption[]; +} + +export class TransitResultDto { + @ApiProperty({ example: 'TRANSIT' }) type: string; + @ApiProperty({ example: 'station-uuid' }) transitStationId: string; + @ApiProperty({ example: 'Dire Dawa' }) transitStationName: string; + @ApiProperty({ description: 'Connection wait time in minutes' }) connectionMinutes: number; + @ApiProperty({ type: TransitLegDto }) leg1: TransitLegDto; + @ApiProperty({ type: TransitLegDto }) leg2: TransitLegDto; + @ApiProperty({ description: 'Combined minimum fare across all shared classes', example: 70000 }) combinedMinFareMinor: number; + @ApiProperty({ description: 'Total travel time including connection in minutes' }) totalDurationMinutes: number; } diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 58d611e9a..9646c20ea 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -18,31 +18,54 @@ export class SearchService { ) {} async searchTrips(dto: SearchTripsDto) { - const outbound = await this.searchSchedules( - dto.originStationId, - dto.destinationStationId, - dto.date, - dto.adultCount, - dto.childCount, - dto.nationality, - ); - - if (dto.journeyType === 'ROUND_TRIP') { - const allInbound = await this.searchSchedules( - dto.destinationStationId, + const [direct, transit] = await Promise.all([ + this.searchSchedules( dto.originStationId, - dto.returnDate ?? dto.date, + dto.destinationStationId, + dto.date, dto.adultCount, dto.childCount, dto.nationality, - ); + ), + this.searchTransitOptions( + dto.originStationId, + dto.destinationStationId, + dto.date, + dto.adultCount, + dto.childCount, + dto.nationality, + ), + ]); + const outbound = [...direct, ...transit]; + + if (dto.journeyType === 'ROUND_TRIP') { + const [returnDirect, returnTransit] = await Promise.all([ + this.searchSchedules( + dto.destinationStationId, + dto.originStationId, + dto.returnDate ?? dto.date, + dto.adultCount, + dto.childCount, + dto.nationality, + ), + this.searchTransitOptions( + dto.destinationStationId, + dto.originStationId, + dto.returnDate ?? dto.date, + dto.adultCount, + dto.childCount, + dto.nationality, + ), + ]); + + const allReturn = [...returnDirect, ...returnTransit]; const latestOutboundArrival = outbound.length > 0 - ? Math.max(...outbound.map((s) => new Date(s.arrivalAt).getTime())) + ? Math.max(...outbound.map((s: any) => new Date(s.arrivalAt ?? s.leg2?.arrivalAt).getTime())) : Date.now(); - const inbound = allInbound.filter((schedule) => - new Date(schedule.departureAt).getTime() > latestOutboundArrival + const inbound = allReturn.filter((s: any) => + new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival ); return { journeyType: 'ROUND_TRIP', outbound, inbound }; @@ -60,7 +83,7 @@ export class SearchService { nationality?: string, ) { const [y, m, d] = dateStr.split('-').map(Number); - const date = new Date(y, m - 1, d, 0, 0, 0, 0); + const date = new Date(y, m - 1, d, 0, 0, 0, 0); const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0); const totalPassengers = adultCount + (childCount ?? 0); @@ -81,126 +104,211 @@ export class SearchService { }, }); - const results = []; - + const results: any[] = []; for (const schedule of schedules) { - const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId); - const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId); + const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality); + if (result) results.push(result); + } + return results; + } - if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue; + // ── Transit search ───────────────────────────────────────────────────────── + // Finds pairs of schedules (leg1: origin→transit, leg2: transit→destination) + // where the passenger has between MIN_CONNECTION and MAX_CONNECTION minutes + // to change trains at the transit station. + private readonly MIN_CONNECTION_MINUTES = 30; + private readonly MAX_CONNECTION_MINUTES = 360; - const availabilityByClass: Record = {}; + private async searchTransitOptions( + originStationId: string, + destinationStationId: string, + dateStr: string, + adultCount: number, + childCount?: number, + nationality?: string, + ) { + // Find all stations that can serve as transit points: + // they must be a stop after origin on some schedule AND + // a stop before destination on another schedule on the same day. + const [y, m, d] = dateStr.split('-').map(Number); + const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0); + const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const totalPassengers = adultCount + (childCount ?? 0); - for (const assignment of schedule.coachAssignments) { - const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard']; - const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition); + // Load all schedules on this date that pass through origin + const leg1Schedules = await this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + departureAt: { gte: dayStart, lt: dayEnd }, + stopTimes: { some: { stationId: originStationId } }, + }, + include: { + train: true, + originStation: true, + destinationStation: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + coachAssignments: { + include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + }, + }, + }); - if (isBedCoach) { - const bedPositions = ['upper', 'middle', 'lower']; - for (const bedPosition of bedPositions) { - let count = 0; - for (const seat of assignment.coach.seats) { - if (seat.bedPosition !== bedPosition) continue; - if (seat.status === 'BLOCKED') continue; - if (!seat.seatNumber || !seat.seatNumber.trim()) continue; + const results: any[] = []; - const free = await this.segmentsService.isSeatFreeForLeg( - schedule.id, seat.id, - originStop.sequence, destStop.sequence, - ); - if (free) count++; - } + for (const leg1 of leg1Schedules) { + const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId); + if (!originStop) continue; - if (count > 0) { - const matchingClass = seatClassNames.find((className: string) => { - const classNameLower = className.toLowerCase(); - return ( - (bedPosition === 'upper' && classNameLower.includes('upper')) || - (bedPosition === 'middle' && classNameLower.includes('middle')) || - (bedPosition === 'lower' && classNameLower.includes('lower')) - ); - }); - if (matchingClass) { - if (!availabilityByClass[matchingClass]) availabilityByClass[matchingClass] = 0; - availabilityByClass[matchingClass] += count; - } - } - } - } else { - let availableSeatsInCoach = 0; - for (const seat of assignment.coach.seats) { - if (seat.status === 'BLOCKED') continue; - if (!seat.seatNumber || !seat.seatNumber.trim()) continue; - - const free = await this.segmentsService.isSeatFreeForLeg( - schedule.id, seat.id, - originStop.sequence, destStop.sequence, - ); - if (free) availableSeatsInCoach++; - } - - for (const seatClassName of seatClassNames) { - if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0; - availabilityByClass[seatClassName] += availableSeatsInCoach; - } - } - } - - const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; - const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; - - const faresByClass = await this.calculateFaresForSegment( - schedule, - originStationId, - destinationStationId, - nationality, + // Every stop after origin on leg1 is a candidate transit station + const candidateTransitStops = leg1.stopTimes.filter( + (s: any) => s.sequence > originStop.sequence, ); - const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass); + for (const transitStop of candidateTransitStops) { + // leg1 must NOT already contain the final destination + const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId); + if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules - results.push({ - scheduleId: schedule.id, - trainNumber: schedule.train.number, - trainName: schedule.train.name, - origin: { - id: originStop.stationId, - code: originStop.station.code, - name: originStop.station.name, - city: originStop.station.city, - sequence: originStop.sequence, - }, - destination: { - id: destStop.stationId, - code: destStop.station.code, - name: destStop.station.name, - city: destStop.station.city, - sequence: destStop.sequence, - }, - departureAt: legDepartureAt, - arrivalAt: legArrivalAt, - durationMinutes: Math.round( - (new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000, - ), - status: schedule.status, - stops: schedule.stopTimes - .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) - .map((st: any) => ({ - stationId: st.stationId, - stationName: st.station.name, - sequence: st.sequence, - plannedArrivalAt: st.plannedArrivalAt, - plannedDepartureAt: st.plannedDepartureAt, - })), - availabilityByClass, - hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), - faresByClass, - coachTypes, - }); + const transitStationId = transitStop.stationId; + const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt; + + // Find leg2 schedules departing from the transit station within the connection window, + // and reaching the final destination. Search up to the next calendar day to handle + // overnight connections. + const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000); + const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000); + + const leg2Schedules = await this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + departureAt: { gte: connWindowStart, lte: connWindowEnd }, + stopTimes: { some: { stationId: transitStationId } }, + }, + include: { + train: true, + originStation: true, + destinationStation: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + coachAssignments: { + include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + }, + }, + }); + + for (const leg2 of leg2Schedules) { + const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId); + const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId); + + if (!leg2TransitStop || !leg2DestStop) continue; + if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue; + + // Build individual leg result objects (reuse existing per-schedule logic) + const [leg1Result, leg2Result] = await Promise.all([ + this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality), + this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality), + ]); + + if (!leg1Result || !leg2Result) continue; + if (!leg1Result.hasAvailability || !leg2Result.hasAvailability) continue; + + const leg2DepartureAt = leg2TransitStop.plannedDepartureAt ?? leg2.departureAt; + const connectionMinutes = Math.round( + (new Date(leg2DepartureAt).getTime() - new Date(leg1ArrivalAt).getTime()) / 60_000, + ); + + const leg1MinFare = Math.min(...(leg1Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity); + const leg2MinFare = Math.min(...(leg2Result.faresByClass as any[]).map((f: any) => f.baseFareMinor).filter((n: number) => n > 0), Infinity); + const combinedMinFareMinor = (isFinite(leg1MinFare) ? leg1MinFare : 0) + (isFinite(leg2MinFare) ? leg2MinFare : 0); + + results.push({ + type: 'TRANSIT', + transitStationId, + transitStationName: transitStop.station.name, + connectionMinutes, + leg1: leg1Result, + leg2: leg2Result, + combinedMinFareMinor, + // Convenience top-level fields so round-trip filter can read them uniformly + departureAt: leg1Result.departureAt, + arrivalAt: leg2Result.arrivalAt, + totalDurationMinutes: + leg1Result.durationMinutes + connectionMinutes + leg2Result.durationMinutes, + }); + } + } } return results; } + // Builds the same result shape as searchSchedules for a single schedule+leg, + // extracted so both direct and transit paths share identical output. + private async buildScheduleResult( + schedule: any, + originStationId: string, + destinationStationId: string, + totalPassengers: number, + nationality?: string, + ) { + const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId); + const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId); + if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; + + const availabilityByClass: Record = {}; + for (const assignment of schedule.coachAssignments) { + const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard']; + const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition); + + if (isBedCoach) { + for (const bedPosition of ['upper', 'middle', 'lower']) { + let count = 0; + for (const seat of assignment.coach.seats) { + if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; + const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); + if (free) count++; + } + if (count > 0) { + const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition)); + if (matchingClass) availabilityByClass[matchingClass] = (availabilityByClass[matchingClass] ?? 0) + count; + } + } + } else { + let available = 0; + for (const seat of assignment.coach.seats) { + if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; + const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); + if (free) available++; + } + for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available; + } + } + + const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality); + const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass); + const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; + const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; + + return { + type: 'DIRECT', + scheduleId: schedule.id, + trainNumber: schedule.train.number, + trainName: schedule.train.name, + origin: { id: originStop.stationId, code: originStop.station.code, name: originStop.station.name, city: originStop.station.city, sequence: originStop.sequence }, + destination: { id: destStop.stationId, code: destStop.station.code, name: destStop.station.name, city: destStop.station.city, sequence: destStop.sequence }, + departureAt: legDepartureAt, + arrivalAt: legArrivalAt, + durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000), + status: schedule.status, + stops: schedule.stopTimes + .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) + .map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), + availabilityByClass, + hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), + faresByClass, + coachTypes, + }; + } + async getFareQuote(dto: FareQuoteDto) { const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 7d4acea76..568801213 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -90,17 +90,21 @@ export class TicketsController { properties: { validatorId: { type: 'string', example: 'agent-uuid' }, gateId: { type: 'string', example: 'gate-01' }, - leg: { type: 'string', enum: ['OUTBOUND', 'RETURN'], description: 'Required for round-trip bookings' }, + leg: { + type: 'string', + enum: ['OUTBOUND', 'RETURN', 'LEG1', 'LEG2', 'OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'], + description: 'ONE_WAY: omit | TRANSIT: LEG1/LEG2 | ROUND_TRIP: OUTBOUND/RETURN | ROUND_TRIP_TRANSIT: OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2', + }, }, }, }) validate( - @Param('bookingRef') ref: string, + @Param('bookingRef') ref: string, @Body('validatorId') validatorId: string, @Body('gateId') gateId?: string, - @Body('leg') leg?: 'OUTBOUND' | 'RETURN', - ) { - return this.service.validate(ref, validatorId, gateId, leg); + @Body('leg') leg?: string, + ) { + return this.service.validate(ref, validatorId, gateId, leg); } @Get(':ticketId/validation-logs') @@ -140,7 +144,7 @@ export class TicketsController { validatorId: { type: 'string' }, gateId: { type: 'string' }, validatedAt: { type: 'string', format: 'date-time' }, - leg: { type: 'string', enum: ['OUTBOUND', 'RETURN'] }, + leg: { type: 'string', enum: ['OUTBOUND', 'RETURN', 'LEG1', 'LEG2', 'OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'] }, }, }, }, diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index fa4508dd1..83abf0cc2 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -7,7 +7,7 @@ interface OfflineValidation { validatorId: string; gateId?: string; validatedAt: string; - leg?: 'OUTBOUND' | 'RETURN'; + leg?: string; } @Injectable() @@ -74,48 +74,65 @@ export class TicketsService { } async generate(bookingId: string) { - if (!bookingId) { - throw new BadRequestException('Booking ID is required'); - } - + if (!bookingId) throw new BadRequestException('Booking ID is required'); + const booking = await this.prisma.booking.findUnique({ where: { id: bookingId }, - include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, - seats: { include: { seat: { include: { coach: true } } } } + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + seats: { include: { seat: { include: { coach: true } } } }, }, }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); - - const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`); + + // Build a compact multi-leg payload for the QR so gate scanners see all legs + const legSummary = this.buildLegSummary(booking); + const qrData = JSON.stringify({ + ref: booking.bookingRef, + type: booking.bookingType, + legs: legSummary, + }); + const qrPayload = await QRCode.toDataURL(qrData); const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`; - + const ticket = await this.prisma.ticket.upsert({ - where: { bookingId }, + where: { bookingId }, update: { qrPayload, barcodePayload }, create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload }, }); - // Update all booked seats from HELD to BOOKED and create permanent seat blocks + // Block all seats across all legs const seatIds = booking.seats.map(bs => bs.seatId); for (const seatId of seatIds) { - // Update seat status to BOOKED - await this.prisma.seat.update({ - where: { id: seatId }, - data: { status: 'BOOKED' }, - }); - // Create permanent seat blocks for all booked seats + await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BOOKED' } }); await this.prisma.seatBlock.create({ - data: { - seatId, - reason: `Permanently booked in ticket ${ticket.id}`, - blockedBy: 'SYSTEM', - approvedBy: 'SYSTEM', - } - }).catch(() => null); // Ignore if already exists + data: { seatId, reason: `Booked in ticket ${ticket.id}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' }, + }).catch(() => null); } - - return ticket; + + return { ...ticket, legs: legSummary }; + } + + private buildLegSummary(booking: any) { + const seatsByLeg = new Map(); + for (const bs of booking.seats) { + const leg = bs.leg ?? 1; + if (!seatsByLeg.has(leg)) seatsByLeg.set(leg, []); + seatsByLeg.get(leg)!.push(bs); + } + return Array.from(seatsByLeg.entries()) + .sort(([a], [b]) => a - b) + .map(([leg, seats]) => ({ + leg, + scheduleId: (seats[0] as any).scheduleId ?? booking.scheduleId, + passengers: seats.map(bs => ({ + name: bs.passengerName, + category: bs.passengerCategory, + coach: bs.seat?.coach?.number, + seat: bs.seat?.seatNumber, + fareMinor: bs.fareMinor, + })), + })); } async updateSeats(bookingId: string, newSeatIds: string[]) { @@ -218,67 +235,111 @@ export class TicketsService { }; } - async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: 'OUTBOUND' | 'RETURN') { + async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: string) { const booking = await this.prisma.booking.findUnique({ where: { bookingRef } }); if (!booking) throw new NotFoundException('Booking not found'); const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } }); if (!ticket) throw new NotFoundException('Ticket not found'); - const isRoundTrip = booking.bookingType === 'ROUND_TRIP'; - // For one-way bookings use the original single-validation guard - if (!isRoundTrip) { + const type = booking.bookingType; + const now = new Date(); + + // ── ONE_WAY / TRANSIT (single scan) ─────────────────────────────────── + if (type === 'ONE_WAY') { if (ticket.validatedAt) { - await this.prisma.gateValidationLog.create({ - data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' }, - }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } }); throw new BadRequestException('Ticket already validated'); } - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } }); - await this.prisma.gateValidationLog.create({ - data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' }, - }); - return { validated: true, ticketId: ticket.id, validatedAt: new Date() }; + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } }); + return { validated: true, ticketId: ticket.id, validatedAt: now }; } - // Round-trip: track which leg is being boarded - const resolvedLeg = leg ?? 'OUTBOUND'; - const now = new Date(); - const bookingData: Record = {}; - - if (resolvedLeg === 'OUTBOUND') { - if ((booking as any).outboundBoardedAt) { - await this.prisma.gateValidationLog.create({ - data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any, - }); - throw new BadRequestException('Outbound leg already used'); + // ── TRANSIT — leg=LEG1 or leg=LEG2 ────────────────────────────────── + if (type === 'TRANSIT') { + const resolvedLeg = (leg ?? 'LEG1').toUpperCase(); + if (resolvedLeg !== 'LEG1' && resolvedLeg !== 'LEG2') { + throw new BadRequestException('For TRANSIT bookings supply leg=LEG1 or leg=LEG2'); } - bookingData.outboundBoardedAt = now; - // Stamp the ticket's first validation - if (!ticket.validatedAt) { - await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); + const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); + const alreadyValidated = logs.some(l => l.leg === resolvedLeg); + if (alreadyValidated) { + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); + throw new BadRequestException(`${resolvedLeg} already validated`); } - } else { - if ((booking as any).returnBoardedAt) { - await this.prisma.gateValidationLog.create({ - data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any, - }); - throw new BadRequestException('Return leg already used'); - } - bookingData.returnBoardedAt = now; + if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; } - // Derive the new composite status - const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt; - const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt; - if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED'; - else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; // return pending/no-show - else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY'; + // ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ──────────────────────── + if (type === 'ROUND_TRIP') { + const resolvedLeg = (leg ?? 'OUTBOUND').toUpperCase(); + const bookingData: Record = {}; + if (resolvedLeg === 'OUTBOUND') { + if ((booking as any).outboundBoardedAt) { + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any }); + throw new BadRequestException('Outbound leg already used'); + } + bookingData.outboundBoardedAt = now; + if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); + } else if (resolvedLeg === 'RETURN') { + if ((booking as any).returnBoardedAt) { + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any }); + throw new BadRequestException('Return leg already used'); + } + bookingData.returnBoardedAt = now; + } else { + throw new BadRequestException('For ROUND_TRIP bookings supply leg=OUTBOUND or leg=RETURN'); + } + const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt; + const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt; + if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED'; + else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; + else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY'; + await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; + } - await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); - await this.prisma.gateValidationLog.create({ - data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any, - }); - return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; + // ── ROUND_TRIP_TRANSIT — leg=OUTBOUND_LEG1|OUTBOUND_LEG2|RETURN_LEG1|RETURN_LEG2 + if (type === 'ROUND_TRIP_TRANSIT') { + const validLegs = ['OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2']; + const resolvedLeg = (leg ?? '').toUpperCase(); + if (!validLegs.includes(resolvedLeg)) { + throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`); + } + const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); + if (logs.some(l => l.leg === resolvedLeg)) { + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any }); + throw new BadRequestException(`${resolvedLeg} already validated`); + } + const bookingData: Record = {}; + if (resolvedLeg.startsWith('OUTBOUND') && !logs.some(l => l.leg?.startsWith('OUTBOUND') && l.status === 'APPROVED')) { + bookingData.outboundBoardedAt = now; + } + if (resolvedLeg.startsWith('RETURN') && !logs.some(l => l.leg?.startsWith('RETURN') && l.status === 'APPROVED')) { + bookingData.returnBoardedAt = now; + } + const allOutboundDone = ['OUTBOUND_LEG1','OUTBOUND_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED')); + const allReturnDone = ['RETURN_LEG1','RETURN_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED')); + if (allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'BOTH_USED'; + else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; + else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY'; + if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); + if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any }); + return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now }; + } + + // Fallback for unknown booking types — single scan + if (ticket.validatedAt) { + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } }); + throw new BadRequestException('Ticket already validated'); + } + await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } }); + await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } }); + return { validated: true, ticketId: ticket.id, validatedAt: now }; } async getValidationLogs(ticketId: string) { @@ -340,16 +401,19 @@ export class TicketsService { continue; } - if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP') { + if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP' && + booking.bookingType !== 'TRANSIT' && booking.bookingType !== 'ROUND_TRIP_TRANSIT') { results.duplicate++; continue; } - // For round-trip, check per-leg duplication - if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) { - const alreadyUsed = - offlineLeg === 'OUTBOUND' ? !!(booking as any).outboundBoardedAt : !!(booking as any).returnBoardedAt; - if (alreadyUsed) { + // For multi-leg bookings, check per-leg duplication + const isMultiLeg = booking.bookingType === 'ROUND_TRIP' || + booking.bookingType === 'TRANSIT' || + booking.bookingType === 'ROUND_TRIP_TRANSIT'; + if (isMultiLeg && offlineLeg) { + const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }); + if (existingLogs.some(l => l.leg === offlineLeg)) { results.duplicate++; continue; } @@ -371,16 +435,19 @@ export class TicketsService { } as any, }); - // update returnLegStatus for round-trip offline validations - if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) { - const bookingData: Record = - offlineLeg === 'OUTBOUND' ? { outboundBoardedAt: new Date(v.validatedAt) } : { returnBoardedAt: new Date(v.validatedAt) }; - const outboundUsed = offlineLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt; - const returnUsed = offlineLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt; - if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED'; - else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY'; - else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY'; - await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); + // update boarding timestamps for multi-leg bookings + const isMultiLegBooking = booking.bookingType === 'ROUND_TRIP' || + booking.bookingType === 'TRANSIT' || + booking.bookingType === 'ROUND_TRIP_TRANSIT'; + if (isMultiLegBooking && offlineLeg) { + const bookingData: Record = {}; + const isOutbound = (offlineLeg as string) === 'OUTBOUND' || (offlineLeg as string) === 'OUTBOUND_LEG1' || (offlineLeg as string) === 'LEG1'; + const isReturn = (offlineLeg as string) === 'RETURN' || (offlineLeg as string) === 'RETURN_LEG1' || (offlineLeg as string) === 'RETURN_LEG2'; + if (isOutbound && !(booking as any).outboundBoardedAt) bookingData.outboundBoardedAt = new Date(v.validatedAt); + if (isReturn && !(booking as any).returnBoardedAt) bookingData.returnBoardedAt = new Date(v.validatedAt); + if (Object.keys(bookingData).length) { + await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData }); + } } results.success++; diff --git a/apps/edr-passenger-web/backoffice/tailwind.config.js b/apps/edr-passenger-web/backoffice/tailwind.config.js index e4b1c586b..0311de963 100644 --- a/apps/edr-passenger-web/backoffice/tailwind.config.js +++ b/apps/edr-passenger-web/backoffice/tailwind.config.js @@ -37,3 +37,4 @@ module.exports = { }, }, plugins: [], +};