Adding train delay minutes and also disabling no schedule days in the booking selection

This commit is contained in:
Muluhabt
2026-07-27 14:42:57 +03:00
parent d175514f85
commit d71f6dc1b3
19 changed files with 845 additions and 54 deletions

View File

@@ -369,6 +369,24 @@ export class BookingsController {
return this.guestService.issueBookingFromReservation(seatId, dto, actingUserId);
}
@Delete("reservations/:seatId")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Cancel a seat's pending-payment reservation and release the seat",
description:
"For a seat with an active PASSENGER-kind reservation (payment link sent, not yet paid): cancels that booking and releases the seat's hold, so it's genuinely free for someone else. The old payment link stops working immediately (the booking is no longer PENDING_PAYMENT).",
})
@ApiQuery({ name: "scheduleId", required: true, description: "TrainSchedule UUID the reservation was issued on" })
cancelReservationForSeat(
@Param("seatId") seatId: string,
@Query("scheduleId") scheduleId: string,
@Req() req: any,
) {
const actingUserId = req.user?.id ?? req.user?.sub ?? null;
return this.service.cancelReservationForSeat(seatId, scheduleId, actingUserId);
}
@Get("pay/:token")
@SetMetadata("isPublic", true)
@ApiOperation({

View File

@@ -2138,6 +2138,39 @@ export class BookingsService {
return { cancelled: true, refundAmount: refundAmount / 100, currency: booking.displayCurrency};
}
/**
* Staff releasing a seat that already has an in-flight backoffice reservation must not
* leave that booking dangling as PENDING_PAYMENT with a still-payable link — the traveler
* could pay for a seat that's since been given away. Finds the active reservation covering
* this exact seat+schedule and cancels it via the normal cancel() path (refund=0, since it's
* still unpaid), then separately releases the SeatHold issueBookingFromReservation created —
* cancel()'s releaseSeats() only deletes Journey/JourneySegment rows, which don't exist yet
* for an unpaid reservation, so without this the seat would stay held until the hold's own
* expiry. Once status flips to CANCELLED, getByPayToken's existing status check already
* rejects the old payToken with "This booking is no longer awaiting payment" — no separate
* payToken invalidation needed.
*/
async cancelReservationForSeat(seatId: string, scheduleId: string, actingUserId: string | null) {
const bookingSeat = await this.prisma.bookingSeat.findFirst({
where: {
seatId,
scheduleId,
booking: { source: 'BACKOFFICE_RESERVATION', status: 'PENDING_PAYMENT' },
},
include: { booking: true },
});
if (!bookingSeat) throw new NotFoundException('No pending reservation found for this seat');
const { bookingRef } = bookingSeat.booking;
const result = await this.cancel(bookingRef, 'Seat released by staff before payment', actingUserId ?? undefined);
await this.prisma.seatHold.deleteMany({
where: { scheduleId, seatIds: { hasSome: [seatId] } },
});
return { ...result, bookingRef };
}
async update(id: string, dto: any) {
const booking = await this.prisma.booking.findUnique({ where: { id } });
if (!booking) throw new NotFoundException('Booking not found');

View File

@@ -2,5 +2,5 @@ import { Module } from '@nestjs/common';
import { LiveController } from './live.controller';
import { LiveService } from './live.service';
@Module({ controllers: [LiveController], providers: [LiveService] })
@Module({ controllers: [LiveController], providers: [LiveService], exports: [LiveService] })
export class LiveModule {}

View File

@@ -312,6 +312,18 @@ export class NotificationsService {
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
const passengerId = booking?.passengerId ?? payload.booking.passengerId;
// A backoffice-issued reservation already sends its own purpose-built message —
// GuestBookingService.issueBookingFromReservation texts /reserve/pay/<payToken> for a
// PASSENGER-kind booking (the traveler has no portal session, so this generic template's
// /booking/detail?ref= link doesn't work), and for STAFF kind the booking is finalized
// immediately after this event fires, so onPaymentSucceeded's "ticket ready" message is
// the correct one to send, not a redundant/contradictory "awaiting payment" notice.
const source = (booking as any)?.source ?? payload.booking?.source;
if (source === 'BACKOFFICE_RESERVATION') {
this.logger.log(`Skipping generic booking.created notification for ${ref} — reservation flow sends its own`);
return;
}
const template = await this.prisma.notificationTemplate.findUnique({
where: { code: 'booking.created' },
});

View File

@@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseInt
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SchedulesService } from './schedules.service';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus, ApplyDelayDto } from './schedules.dto';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@@ -171,6 +171,22 @@ export class SchedulesController {
@Body() dto: UpdateStopTimeDto,
) { return this.service.updateStop(id, sequence, dto); }
@Post(':id/delay')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Report a delay — pushes every downstream stop\'s planned times (and check-in cutoffs) back by the same amount',
description: `Shifts plannedArrivalAt/plannedDepartureAt on every stop not yet BOARDED/COMPLETED (or from fromSequence
onward, if given) by delayMinutes. Since check-in cutoffs are derived directly from these planned
times, this is the only action needed for booking closure to reflect the delay — no separate cutoff
update. Also shifts the schedule's own departureAt/arrivalAt when the origin stop is included, and
records the accumulated delay on the schedule's live status. Does not change schedule/stop status.`,
})
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiResponse({ status: 200, description: 'Schedule with shifted stop times' })
applyDelay(@Param('id') id: string, @Body() dto: ApplyDelayDto) {
return this.service.applyDelay(id, dto);
}
@Put(':scheduleId/fares/:seatClassId')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({

View File

@@ -113,6 +113,14 @@ export class UpdateScheduleStatusDto {
@ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus;
}
export class ApplyDelayDto {
@ApiProperty({ example: 60, description: 'Minutes to shift downstream stop times by. Negative to correct an over-reported delay.' })
@IsInt() delayMinutes: number;
@ApiPropertyOptional({ example: 3, description: 'Only shift stops from this sequence onward. Omit to default to every stop not yet BOARDED/COMPLETED.' })
@IsOptional() @IsInt() @Min(1) fromSequence?: number;
}
export class BulkCreateSchedulesDto {
@ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string;
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;

View File

@@ -5,9 +5,10 @@ import { RoutesController } from './routes.controller';
import { RoutesService } from './routes.service';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
import { AuditModule } from '../../common/audit.module';
import { LiveModule } from '../live/live.module';
@Module({
imports: [FareEngineModule, AuditModule],
imports: [FareEngineModule, AuditModule, LiveModule],
controllers: [RoutesController, SchedulesController],
providers: [RoutesService, SchedulesService],
exports: [RoutesService, SchedulesService],

View File

@@ -2,10 +2,11 @@ import { Injectable, Logger, NotFoundException, BadRequestException } from '@nes
import { PrismaService } from '../../common/prisma.service';
import { RoutesService } from './routes.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, ApplyDelayDto } from './schedules.dto';
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
import { AuditService } from '../../common/audit.service';
import { LiveService } from '../live/live.service';
@Injectable()
export class SchedulesService {
@@ -16,6 +17,7 @@ export class SchedulesService {
private routesService: RoutesService,
private fareEngine: FareEngineService,
private auditService: AuditService,
private liveService: LiveService,
) { }
/**
@@ -126,6 +128,7 @@ export class SchedulesService {
include: { coach: true },
orderBy: { positionNumber: 'asc' },
},
liveStatus: { select: { delayMinutes: true } },
_count: { select: { coachAssignments: true, bookings: true } },
},
orderBy: { departureAt: 'asc' },
@@ -246,6 +249,7 @@ export class SchedulesService {
orderBy: { positionNumber: 'asc' },
},
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
liveStatus: { select: { delayMinutes: true } },
},
});
if (!schedule) throw new NotFoundException('Schedule not found');
@@ -462,6 +466,70 @@ export class SchedulesService {
});
}
/**
* Shifts stored planned times additively rather than reusing updateSchedulePartial's
* recompute-from-route-interpolation path — that path also guards `departureAt must be in the
* future`, which a delay report for an already-departed/EN_ROUTE train would legitimately
* fail. Check-in cutoffs (resolveCheckinCutoff, SeatsService.holdSeats) are both derived
* directly from TripStopTime.plannedArrivalAt/plannedDepartureAt at read time, so shifting the
* stored values here is the entire fix — neither of those needs to change.
*/
async applyDelay(scheduleId: string, dto: ApplyDelayDto) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
if (!schedule) throw new NotFoundException('Schedule not found');
const stopWhere: any = { scheduleId };
if (dto.fromSequence != null) {
stopWhere.sequence = { gte: dto.fromSequence };
} else {
// Default: only stops the train hasn't reached yet — a delay report must not retroactively
// move a stop that's already BOARDED/COMPLETED.
stopWhere.status = { notIn: ['BOARDED', 'COMPLETED'] };
}
const stopsToShift = await this.prisma.tripStopTime.findMany({ where: stopWhere });
const shiftMs = dto.delayMinutes * 60_000;
const includesOrigin = stopsToShift.some((s) => s.sequence === 1);
await this.prisma.$transaction(async (tx) => {
for (const stop of stopsToShift) {
await tx.tripStopTime.update({
where: { id: stop.id },
data: {
plannedArrivalAt: stop.plannedArrivalAt ? new Date(stop.plannedArrivalAt.getTime() + shiftMs) : undefined,
plannedDepartureAt: stop.plannedDepartureAt ? new Date(stop.plannedDepartureAt.getTime() + shiftMs) : undefined,
},
});
}
// Origin stop shifted → the schedule's own departureAt/arrivalAt drive search's day-window
// queries and the displayed departure time, so they must move too (both together, so
// durationMinutes stays correct).
if (includesOrigin) {
await tx.trainSchedule.update({
where: { id: scheduleId },
data: {
departureAt: new Date(schedule.departureAt.getTime() + shiftMs),
arrivalAt: new Date(schedule.arrivalAt.getTime() + shiftMs),
},
});
}
});
const currentLive = await this.prisma.tripLiveStatus.findUnique({ where: { scheduleId } });
const accumulatedDelayMinutes = Math.max(0, (currentLive?.delayMinutes ?? 0) + dto.delayMinutes);
await this.liveService.updateLiveStatus(scheduleId, { delayMinutes: accumulatedDelayMinutes });
await this.auditService.log({
action: 'UPDATE',
entityType: 'Schedule',
entityId: scheduleId,
newData: { delayMinutes: dto.delayMinutes, fromSequence: dto.fromSequence, accumulatedDelayMinutes },
});
return this.getSchedule(scheduleId);
}
async upsertScheduleFare(
scheduleId: string,
seatClassId: string,

View File

@@ -2,7 +2,7 @@ import { Body, Controller, Post, Get, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SearchService } from './search.service';
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto';
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, AvailableDatesQueryDto } from './search.dto';
@ApiTags('Search')
@Controller('search')
@@ -67,6 +67,21 @@ Nationality-Based:
return this.service.getFareQuote(dto);
}
@Get('available-dates')
@ApiOperation({
summary: 'Which dates in a range have a bookable schedule for an origin/destination pair',
description: `Used to disable schedule-less dates on the search date picker before the user submits a search.
For each date in the (server-clamped, max 90-day) range, a date is "available" if at least one
schedule exists for the origin→destination pair whose status/package/coach state is bookable and
whose check-in cutoff has not yet passed. This does not check seat-level availability — a date
can be marked available and still turn out fully booked when actually searched.`,
})
@ApiResponse({ status: 200, description: 'routeExists flag plus a per-date availability list' })
getAvailableDates(@Query() dto: AvailableDatesQueryDto) {
return this.service.getAvailableDates(dto);
}
@Get('fare-breakdown')
@ApiOperation({
summary: 'Per-passenger fare breakdown for booking review page',

View File

@@ -29,6 +29,20 @@ export class SearchTripsDto {
@IsOptional() @IsDateString() returnDate?: string;
}
export class AvailableDatesQueryDto {
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
@IsString() destinationStationId: string;
@ApiProperty({ example: '2026-06-15', description: 'Start of the date range (YYYY-MM-DD)' })
@IsDateString() from: string;
@ApiProperty({ example: '2026-09-13', description: 'End of the date range (YYYY-MM-DD), inclusive — server clamps to a max 90-day span' })
@IsDateString() to: string;
}
export class FareQuoteDto {
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID from search results' })
@IsString() scheduleId: string;

View File

@@ -5,6 +5,7 @@ import {
FareQuoteDto,
FareBreakdownRequestDto,
FareBreakdownPassengerDto,
AvailableDatesQueryDto,
} from "./search.dto";
import { CurrencyService } from "../currency/currency.service";
import { FareEngineService } from "../fare-engine/fare-engine.service";
@@ -383,16 +384,9 @@ export class SearchService {
// 1. Does any active route connect these two stations, in this direction, at all —
// ignoring date entirely?
const candidateRoutes = await this.prisma.route.findMany({
where: { active: true, stops: { some: { stationId: originStationId } } },
select: { stops: { select: { stationId: true, sequence: true } } },
});
const routeExists = candidateRoutes.some((r) => {
const o = r.stops.find((s) => s.stationId === originStationId);
const d = r.stops.find((s) => s.stationId === destinationStationId);
return !!o && !!d && o.sequence < d.sequence;
});
if (!routeExists) return withCode(Passenger.SearchEmptyReasonCode.NoRoute);
if (!(await this.routeExistsForPair(originStationId, destinationStationId))) {
return withCode(Passenger.SearchEmptyReasonCode.NoRoute);
}
// 2. A route exists — is there any schedule at all on the requested date for this pair
// (regardless of status/package/coach/cutoff — those are checked next)?
@@ -425,12 +419,7 @@ export class SearchService {
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
// (right status, not package-only, has at least one coach assigned).
const bookable = sameDayForPair.filter(
(s) =>
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
!s.isPackageOnly &&
s.coachAssignments.length > 0,
);
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s));
if (bookable.length === 0) {
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
@@ -452,6 +441,110 @@ export class SearchService {
return withCode(Passenger.SearchEmptyReasonCode.FullyBooked);
}
/**
* Whether any active route connects originStationId → destinationStationId in this
* direction, ignoring date/schedule state entirely. Shared by classifyEmptySearch and
* getAvailableDates.
*/
private async routeExistsForPair(originStationId: string, destinationStationId: string): Promise<boolean> {
const candidateRoutes = await this.prisma.route.findMany({
where: { active: true, stops: { some: { stationId: originStationId } } },
select: { stops: { select: { stationId: true, sequence: true } } },
});
return candidateRoutes.some((r) => {
const o = r.stops.find((s) => s.stationId === originStationId);
const d = r.stops.find((s) => s.stationId === destinationStationId);
return !!o && !!d && o.sequence < d.sequence;
});
}
/** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */
private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean {
return (
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
!s.isPackageOnly &&
s.coachAssignments.length > 0
);
}
private readonly MAX_AVAILABLE_DATES_SPAN_DAYS = 90;
private readonly ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000;
private readonly ONE_DAY_MS = 24 * 60 * 60 * 1000;
/** Converts an absolute instant to its calendar date string in Africa/Addis_Ababa (fixed UTC+3, no DST). */
private toAddisDateStr(d: Date): string {
return new Date(d.getTime() + this.ADDIS_OFFSET_MS).toISOString().slice(0, 10);
}
/**
* For each date in the (server-clamped) range, whether at least one bookable schedule exists
* for originStationId → destinationStationId — used to disable schedule-less dates on the
* search date picker before the user submits a search. Reuses the same route-existence and
* bookability checks as classifyEmptySearch, plus the same check-in cutoff resolution used
* throughout this service, but does not compute seat-level availability (see buildScheduleResult)
* — a date can be marked available and still turn out fully booked when actually searched.
*/
async getAvailableDates(dto: AvailableDatesQueryDto) {
const { originStationId, destinationStationId } = dto;
const todayStr = this.toAddisDateStr(new Date());
const from = dto.from > todayStr ? dto.from : todayStr;
const fromDate = new Date(`${from}T00:00:00+03:00`);
const maxToDate = new Date(fromDate.getTime() + this.MAX_AVAILABLE_DATES_SPAN_DAYS * this.ONE_DAY_MS);
const requestedToDate = new Date(`${dto.to}T00:00:00+03:00`);
const toDate = requestedToDate < maxToDate ? requestedToDate : maxToDate;
const to = this.toAddisDateStr(toDate);
if (!(await this.routeExistsForPair(originStationId, destinationStationId))) {
return {
originStationId,
destinationStationId,
from,
to,
routeExists: false,
dates: [] as { date: string; available: boolean }[],
};
}
const rangeEnd = new Date(toDate.getTime() + this.ONE_DAY_MS);
const schedules = await this.prisma.trainSchedule.findMany({
where: {
departureAt: { gte: fromDate, lt: rangeEnd },
stopTimes: { some: { stationId: originStationId } },
},
select: {
departureAt: true,
status: true,
isPackageOnly: true,
route: {
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
},
stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } },
coachAssignments: { select: { id: true } },
},
});
const now = Date.now();
const availableDays = new Set<string>();
for (const s of schedules) {
const originStop = s.stopTimes.find((st) => st.stationId === originStationId);
const destinationStop = s.stopTimes.find((st) => st.stationId === destinationStationId);
if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) continue;
if (!this.isBookableSchedule(s)) continue;
if (now >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime()) continue;
availableDays.add(this.toAddisDateStr(s.departureAt));
}
const dates: { date: string; available: boolean }[] = [];
for (let cursor = fromDate; cursor <= toDate; cursor = new Date(cursor.getTime() + this.ONE_DAY_MS)) {
const dateStr = this.toAddisDateStr(cursor);
dates.push({ date: dateStr, available: availableDays.has(dateStr) });
}
return { originStationId, destinationStationId, from, to, routeExists: true, dates };
}
// ── Transit search ─────────────────────────────────────────────────────────
private readonly MIN_CONNECTION_MINUTES = 30;
private readonly MAX_CONNECTION_MINUTES = 360;

View File

@@ -45,13 +45,16 @@ export class SeatsService {
});
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(
scheduleId,
allSeatIds,
originStationId ?? schedule.originStationId,
destinationStationId ?? schedule.destinationStationId,
journeyDirection
);
const [effectiveStatuses, reservations] = await Promise.all([
this.resolveEffectiveStatuses(
scheduleId,
allSeatIds,
originStationId ?? schedule.originStationId,
destinationStationId ?? schedule.destinationStationId,
journeyDirection
),
this.resolveActiveReservations(allSeatIds, scheduleId),
]);
return {
coaches: assignments.map((a) => {
@@ -70,6 +73,7 @@ export class SeatsService {
? this.resolveBedPosition(s.col, s.bedPosition)
: s.bedPosition;
const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
const reservation = reservations.get(s.id);
return {
id: s.id,
seatNumber: s.seatNumber,
@@ -88,6 +92,15 @@ export class SeatsService {
position: this.colToPosition(s.col, a.coach.arrangement),
bed_type: this.bedPositionToType(resolvedBedPosition),
} : {}),
// Backoffice-issued reservation covering this seat, if any — lets staff see who's
// paying/ticketed for a HELD (awaiting payment) or BLOCKED (ticketed) seat without
// leaving the seat map. See resolveActiveReservations.
...(reservation ? {
bookingRef: reservation.bookingRef,
reservationStatus: reservation.status,
reservationPassengerName: reservation.passengerName,
reservationContactPhone: reservation.contactPhone,
} : {}),
};
});
@@ -259,6 +272,46 @@ export class SeatsService {
return statusMap;
}
/**
* Batch-resolves the backoffice-issued reservation (if any) covering each of these seats on
* this schedule — a booking created via GuestBookingService.issueBookingFromReservation
* (`source: 'BACKOFFICE_RESERVATION'`), still PENDING_PAYMENT (payment link sent, not yet
* paid) or already CONFIRMED (ticketed). Used to surface the booking reference on the
* backoffice seat map so staff can see who's paying/ticketed for a given seat without
* looking it up separately.
*/
private async resolveActiveReservations(
seatIds: string[],
scheduleId: string,
): Promise<Map<string, { bookingRef: string; status: string; passengerName: string | null; contactPhone: string | null }>> {
const map = new Map<string, { bookingRef: string; status: string; passengerName: string | null; contactPhone: string | null }>();
if (seatIds.length === 0) return map;
const bookingSeats = await this.prisma.bookingSeat.findMany({
where: {
seatId: { in: seatIds },
scheduleId,
booking: { source: 'BACKOFFICE_RESERVATION', status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } },
},
select: {
seatId: true,
passengerName: true,
booking: { select: { bookingRef: true, status: true, contactPhone: true } },
},
});
for (const bs of bookingSeats) {
if (!bs.seatId) continue;
map.set(bs.seatId, {
bookingRef: bs.booking.bookingRef,
status: bs.booking.status,
passengerName: bs.passengerName,
contactPhone: bs.booking.contactPhone,
});
}
return map;
}
async holdSeats(dto: HoldSeatsDto) {
const passengerIds = dto.passengers.map(p => p.passengerId);
const seatIds = dto.passengers.map(p => p.seatId);