mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Added group booking option
This commit is contained in:
@@ -37,6 +37,7 @@ import {
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
import { SeatsService } from "../seats/seats.service";
|
||||
|
||||
@ApiTags("Booking")
|
||||
@Controller("bookings")
|
||||
@@ -45,6 +46,7 @@ export class BookingsController {
|
||||
constructor(
|
||||
private service: BookingsService,
|
||||
private guestService: GuestBookingService,
|
||||
private seatsService: SeatsService,
|
||||
) {}
|
||||
|
||||
@Get("my")
|
||||
@@ -359,6 +361,37 @@ export class BookingsController {
|
||||
return this.guestService.createGuestBooking(dto, req);
|
||||
}
|
||||
|
||||
@Post("group")
|
||||
@PassengerStaff([PASSENGER_PERMS.bookings.manage])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group",
|
||||
description: `Staff-only entry point for bulk/group bookings (e.g. tour groups booked via an uploaded passenger list and auto-assigned seats from POST /seats/auto-assign-hold).
|
||||
|
||||
Same body shape as POST /bookings/guest (CreateGuestBookingDto) and the same underlying pipeline — fare engine, ADULT/CHILD age pricing — just gated to staff and always ONE_WAY.
|
||||
|
||||
Skips Verifayda national-ID verification: the roster comes from a staff-uploaded spreadsheet, not a live Fayda identity flow, so there is nothing to verify an ID number against. Passenger fields (name, DOB, nationality) are trusted exactly as uploaded.
|
||||
|
||||
Deliberately does NOT forward the staff caller's identity into booking creation: the acting staff member is not a Passenger, so the underlying guest-booking flow (which tries to resolve an authenticated caller as an existing Passenger profile) would reject the request. The booking is created exactly like a guest booking — a fresh passenger record, contact info from the first passenger in the list — with staff authorization enforced only at this route.
|
||||
|
||||
If booking creation fails after the seats were already held, the hold is released immediately so the seats don't sit locked for the rest of the hold TTL.`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: "Group booking created successfully with fareBreakdown" })
|
||||
@ApiResponse({ status: 400, description: "Missing required seat IDs" })
|
||||
async createGroup(@Body() dto: CreateGuestBookingDto) {
|
||||
try {
|
||||
return await this.guestService.createGuestBooking({ ...dto, bookingType: "ONE_WAY", skipIdentityVerification: true });
|
||||
} catch (err) {
|
||||
try {
|
||||
await this.seatsService.releaseHold(dto.holdId);
|
||||
} catch (releaseErr) {
|
||||
// Best-effort — the hold may already be gone (e.g. it expired mid-request). The
|
||||
// original booking-creation error is what the caller actually needs to see.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@Post("reservations/:seatId/issue")
|
||||
@PassengerStaffStrict(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
|
||||
@@ -168,6 +168,15 @@ export class CreateGuestBookingDto {
|
||||
|
||||
@ApiPropertyOptional({ description: 'Total amount in minor units (ETB) as computed and displayed on the review page. When provided, overrides the fare engine total — use to pass the exact berth-specific amount the user saw.' })
|
||||
@IsOptional() @IsNumber() reviewedTotalMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Skip Verifayda national-ID verification and trust passenger fields as given (name, DOB, nationality). ' +
|
||||
'For staff-entered/bulk-uploaded rosters (e.g. group bookings) where there is no live Fayda identity ' +
|
||||
'flow to verify against — calling Verifayda for typed-in ID numbers either returns dev-mode mock data ' +
|
||||
'(overwriting the real name) or, once configured, would reject the whole booking on a non-match.',
|
||||
})
|
||||
@IsOptional() @IsBoolean() skipIdentityVerification?: boolean;
|
||||
}
|
||||
|
||||
export class SavedPassengerProfileDto {
|
||||
|
||||
@@ -196,7 +196,7 @@ export class GuestBookingService {
|
||||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
||||
|
||||
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||||
if (passenger.idDocumentNumber) {
|
||||
if (passenger.idDocumentNumber && !dto.skipIdentityVerification) {
|
||||
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||||
if (!verification.verified) {
|
||||
throw new BadRequestException(
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
ApiBody,
|
||||
} from "@nestjs/swagger";
|
||||
import { SeatsService } from "./seats.service";
|
||||
import { BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaintenanceDto } from "./seats.dto";
|
||||
import { AutoAssignHoldDto, BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaintenanceDto } from "./seats.dto";
|
||||
import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user";
|
||||
import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
@@ -186,6 +186,30 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
return this.service.holdSeats(dto);
|
||||
}
|
||||
|
||||
@Post("auto-assign-hold")
|
||||
@PassengerStaff([PASSENGER_PERMS.bookings.manage])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only",
|
||||
description: `Picks the requested number of available seats of the given class (preferring a contiguous row) and holds them in one step, so the caller never shows an assignment it could lose to a race before the passenger data is submitted.
|
||||
|
||||
No manual seat selection — this is for bulk/group booking flows where staff upload a passenger list rather than picking seats on a seat map. Returns the same hold shape as POST /seats/hold.
|
||||
|
||||
Throws 409 with no partial hold created if fewer than the requested seats are available in that class.`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: "Seats auto-assigned and held" })
|
||||
@ApiResponse({ status: 409, description: "Not enough seats available in the requested class" })
|
||||
autoAssignHold(@Body() dto: AutoAssignHoldDto) {
|
||||
const passengerCount = dto.adultCount + (dto.childCount ?? 0);
|
||||
return this.service.autoAssignAndHold(
|
||||
dto.scheduleId,
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
dto.seatClassName,
|
||||
passengerCount,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete("hold/:holdId")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum } from 'class-validator';
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum, IsInt, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
@@ -49,6 +49,26 @@ export class HoldSeatsDto {
|
||||
passengers: PassengerSeatDto[];
|
||||
}
|
||||
|
||||
export class AutoAssignHoldDto {
|
||||
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' })
|
||||
@IsString() scheduleId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID for this leg' })
|
||||
@IsString() originStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiProperty({ example: 'Economy Regular', description: 'Seat class name to auto-assign from — must match a class returned by POST /search for this schedule.' })
|
||||
@IsString() seatClassName: string;
|
||||
|
||||
@ApiProperty({ example: 4, minimum: 1, description: 'Number of adult passengers to assign seats for.' })
|
||||
@IsInt() @Min(0) adultCount: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1, minimum: 0, description: 'Number of child passengers to assign seats for.' })
|
||||
@IsOptional() @IsInt() @Min(0) childCount?: number;
|
||||
}
|
||||
|
||||
export class ReleaseHoldDto {
|
||||
@ApiProperty({ example: 'hold-uuid', description: 'SeatHold UUID to release' })
|
||||
@IsString() holdId: string;
|
||||
|
||||
@@ -18,6 +18,10 @@ describe('SeatsService - Auto Assign', () => {
|
||||
findMany: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
seatClass: {
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
tripStopTime: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
@@ -72,6 +76,14 @@ describe('SeatsService - Auto Assign', () => {
|
||||
]);
|
||||
mockPrisma.seatBlock.findMany.mockResolvedValue([]);
|
||||
mockSegmentsService.getSeatAvailabilityMap.mockResolvedValue(new Map());
|
||||
mockPrisma.seatClass.findFirst.mockResolvedValue({
|
||||
coachTypeId: 'coach-type-1',
|
||||
nationalityType: 'INTERNATIONAL',
|
||||
coachType: { name: 'Hard Seat Coach' },
|
||||
});
|
||||
mockPrisma.seatClass.findMany.mockResolvedValue([
|
||||
{ bedPosition: null },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('assertNoRouteSeatConflict', () => {
|
||||
@@ -114,25 +126,27 @@ describe('SeatsService - Auto Assign', () => {
|
||||
});
|
||||
|
||||
describe('autoAssignSeats', () => {
|
||||
it('should assign contiguous seats in same row', async () => {
|
||||
it('should assign seats in ascending seat-number order (not row/insertion order)', async () => {
|
||||
// Deliberately out of order and non-contiguous-by-row to prove the sort is driven by
|
||||
// seatNumber, not by the order seats came back from the query or their row grouping.
|
||||
const mockSeats = [
|
||||
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
|
||||
{ id: 'seat-2', coachId: 'coach-1', row: 1, col: 'B' },
|
||||
{ id: 'seat-3', coachId: 'coach-1', row: 1, col: 'C' },
|
||||
{ id: 'seat-4', coachId: 'coach-1', row: 2, col: 'A' },
|
||||
{ id: 'seat-3', seatNumber: '3', coachId: 'coach-1', row: 2, col: 'A' },
|
||||
{ id: 'seat-1', seatNumber: '1', coachId: 'coach-1', row: 1, col: 'A' },
|
||||
{ id: 'seat-2', seatNumber: '2', coachId: 'coach-1', row: 1, col: 'B' },
|
||||
{ id: 'seat-10', seatNumber: '10', coachId: 'coach-1', row: 3, col: 'A' },
|
||||
];
|
||||
|
||||
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
|
||||
|
||||
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// Numeric order (1, 2) — a lexicographic sort would have put '10' before '2'.
|
||||
expect(result).toEqual(['seat-1', 'seat-2']);
|
||||
});
|
||||
|
||||
it('should throw error if not enough seats available', async () => {
|
||||
mockPrisma.seat.findMany.mockResolvedValue([
|
||||
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
|
||||
{ id: 'seat-1', seatNumber: '1', coachId: 'coach-1', row: 1, col: 'A' },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
@@ -140,22 +154,9 @@ describe('SeatsService - Auto Assign', () => {
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('should respect eligibility filter', async () => {
|
||||
const mockSeats = [
|
||||
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A', eligibility: 'ACCESSIBLE' },
|
||||
{ id: 'seat-2', coachId: 'coach-1', row: 1, col: 'B', eligibility: 'ACCESSIBLE' },
|
||||
];
|
||||
|
||||
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
|
||||
|
||||
const result = await service.autoAssignSeats('trip-1', 2, 'ECONOMY_REGULAR');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should assign single seat', async () => {
|
||||
const mockSeats = [
|
||||
{ id: 'seat-1', coachId: 'coach-1', row: 1, col: 'A' },
|
||||
{ id: 'seat-1', seatNumber: '1', coachId: 'coach-1', row: 1, col: 'A' },
|
||||
];
|
||||
|
||||
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
|
||||
@@ -164,5 +165,75 @@ describe('SeatsService - Auto Assign', () => {
|
||||
|
||||
expect(result).toEqual(['seat-1']);
|
||||
});
|
||||
|
||||
it('should fill Lower, then Middle, then Upper — a fixed physical order, not fare order', async () => {
|
||||
mockPrisma.seatClass.findFirst.mockResolvedValue({
|
||||
coachTypeId: 'coach-type-hbc',
|
||||
nationalityType: 'INTERNATIONAL',
|
||||
coachType: { name: 'Hard Berth Coach' },
|
||||
});
|
||||
mockPrisma.seatClass.findMany.mockResolvedValue([
|
||||
{ bedPosition: 'UPPER' },
|
||||
{ bedPosition: 'MIDDLE' },
|
||||
{ bedPosition: 'LOWER' },
|
||||
]);
|
||||
// Upper is the cheapest tier in the seed data (4000 vs 5500 Middle vs 6000 Lower) — this
|
||||
// deliberately picks seats so a fare-order algorithm and a lower-first algorithm disagree.
|
||||
const mockSeats = [
|
||||
{ id: 'upper-1', seatNumber: '16', coachId: 'coach-1', row: 4, col: 'A', bedPosition: 'UPPER' },
|
||||
{ id: 'upper-2', seatNumber: '17', coachId: 'coach-1', row: 4, col: 'B', bedPosition: 'UPPER' },
|
||||
{ id: 'middle-1', seatNumber: '11', coachId: 'coach-1', row: 3, col: 'A', bedPosition: 'MIDDLE' },
|
||||
{ id: 'lower-1', seatNumber: '6', coachId: 'coach-1', row: 2, col: 'A', bedPosition: 'LOWER' },
|
||||
{ id: 'lower-2', seatNumber: '7', coachId: 'coach-1', row: 2, col: 'B', bedPosition: 'LOWER' },
|
||||
];
|
||||
mockPrisma.seat.findMany.mockResolvedValue(mockSeats);
|
||||
|
||||
const result = await service.autoAssignSeats('trip-1', 3, 'Economy Bed Upper (Intl)');
|
||||
|
||||
// Both Lower seats first, then spill into Middle — Upper is untouched even though it's cheaper.
|
||||
expect(result).toEqual(['lower-1', 'lower-2', 'middle-1']);
|
||||
});
|
||||
|
||||
it('should count all fare tiers toward availability, not just one tier', async () => {
|
||||
mockPrisma.seatClass.findFirst.mockResolvedValue({
|
||||
coachTypeId: 'coach-type-hbc',
|
||||
nationalityType: 'INTERNATIONAL',
|
||||
coachType: { name: 'Hard Berth Coach' },
|
||||
});
|
||||
mockPrisma.seatClass.findMany.mockResolvedValue([
|
||||
{ bedPosition: 'UPPER' },
|
||||
{ bedPosition: 'LOWER' },
|
||||
]);
|
||||
mockPrisma.seat.findMany.mockResolvedValue([
|
||||
{ id: 'upper-1', seatNumber: '16', coachId: 'coach-1', row: 4, col: 'A', bedPosition: 'UPPER' },
|
||||
{ id: 'lower-1', seatNumber: '6', coachId: 'coach-1', row: 2, col: 'A', bedPosition: 'LOWER' },
|
||||
]);
|
||||
|
||||
const result = await service.autoAssignSeats('trip-1', 2, 'VIP Bed Upper (Intl)');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should match bed-tier seats regardless of case (SeatClass.bedPosition is seeded uppercase, Seat.bedPosition is stored lowercase in production data)', async () => {
|
||||
mockPrisma.seatClass.findFirst.mockResolvedValue({
|
||||
coachTypeId: 'coach-type-sbc',
|
||||
nationalityType: 'INTERNATIONAL',
|
||||
coachType: { name: 'Soft Berth Coach' },
|
||||
});
|
||||
mockPrisma.seatClass.findMany.mockResolvedValue([
|
||||
{ bedPosition: 'UPPER' },
|
||||
{ bedPosition: 'LOWER' },
|
||||
]);
|
||||
mockPrisma.seat.findMany.mockResolvedValue([
|
||||
{ id: 'upper-1', seatNumber: '16', coachId: 'coach-1', row: 4, col: 'A', bedPosition: 'upper' },
|
||||
{ id: 'lower-1', seatNumber: '6', coachId: 'coach-1', row: 3, col: 'A', bedPosition: 'lower' },
|
||||
]);
|
||||
|
||||
const result = await service.autoAssignSeats('trip-1', 2, 'VIP Bed Upper (Intl)');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// Lower fills before Upper regardless of case.
|
||||
expect(result).toEqual(['lower-1', 'upper-1']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { BlockSeatDto, HoldSeatsDto, JourneyDirection, SeatBlockReasonCategory } from './seats.dto';
|
||||
import { ActingUser } from '../../common/acting-user';
|
||||
@@ -824,14 +825,56 @@ export class SeatsService {
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const seats = await this.prisma.seat.findMany({
|
||||
where: {
|
||||
coach: { assignments: { some: { scheduleId } } },
|
||||
seatNumber: { not: '' },
|
||||
NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }, { status: 'UNDER_MAINTENANCE' as any }],
|
||||
},
|
||||
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
|
||||
// Resolve the requested class to its actual SeatClass row, then pool seats across every
|
||||
// fare tier (bed position) that shares its coachTypeId + nationalityType. "Economy"/"VIP"
|
||||
// are coach categories, not one physical seat pool — Upper/Middle/Lower berths are
|
||||
// genuinely different seats priced differently — but the whole group is billed one uniform
|
||||
// rate (the cheapest tier, which is what callers pass as seatClassName; see
|
||||
// bookings.controller's group endpoint). A coach type with no bed split (e.g. Economy
|
||||
// Regular) has exactly one tier, so this collapses to plain seat-number order for it.
|
||||
const seatClass = await this.prisma.seatClass.findFirst({
|
||||
where: { name: seatClassName },
|
||||
select: { coachTypeId: true, nationalityType: true, coachType: { select: { name: true } } },
|
||||
});
|
||||
if (!seatClass) throw new NotFoundException(`Seat class "${seatClassName}" not found`);
|
||||
|
||||
const siblingClasses = await this.prisma.seatClass.findMany({
|
||||
where: { coachTypeId: seatClass.coachTypeId, nationalityType: seatClass.nationalityType },
|
||||
select: { bedPosition: true },
|
||||
});
|
||||
// SeatClass.bedPosition is seeded uppercase ('UPPER'), but Seat.bedPosition is stored
|
||||
// lowercase ('upper') — normalize both sides or every bed-tier seat silently fails to match.
|
||||
const validBedPositions = new Set(siblingClasses.map((sc) => (sc.bedPosition ?? '').toLowerCase()));
|
||||
// Fixed physical fill order — lower berths first, then middle, then upper — not fare-driven.
|
||||
const BED_POSITION_ORDER: Record<string, number> = { lower: 0, middle: 1, upper: 2 };
|
||||
|
||||
const allSeatsOnSchedule = await this.prisma.seat.findMany({
|
||||
where: {
|
||||
coach: {
|
||||
coachTypeId: seatClass.coachTypeId,
|
||||
assignments: { some: { scheduleId } },
|
||||
},
|
||||
seatNumber: { not: '' },
|
||||
// Only 'BLOCKED' is a real SeatStatus value (AVAILABLE|HELD|BOOKED|BLOCKED) — this
|
||||
// method never wrote 'UNDER_MAINTENANCE' before, and Prisma validates enum values at
|
||||
// the query level regardless of an `as any` cast, so that clause would throw at
|
||||
// runtime the moment this method was ever actually called. Maintenance-blocked seats
|
||||
// are still excluded below via the schedule-scoped SeatBlock check.
|
||||
NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }],
|
||||
},
|
||||
orderBy: [{ coach: { number: 'asc' } }],
|
||||
});
|
||||
|
||||
// Lower → Middle → Upper, then ascending seat number within a tier (seatNumber is a string
|
||||
// column, so DB/lexicographic ordering would sort "10" before "2" — compare numerically here).
|
||||
const seats = allSeatsOnSchedule
|
||||
.filter((s) => validBedPositions.has((s.bedPosition ?? '').toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
const tierDiff = (BED_POSITION_ORDER[(a.bedPosition ?? '').toLowerCase()] ?? 0)
|
||||
- (BED_POSITION_ORDER[(b.bedPosition ?? '').toLowerCase()] ?? 0);
|
||||
if (tierDiff !== 0) return tierDiff;
|
||||
return parseInt(a.seatNumber, 10) - parseInt(b.seatNumber, 10);
|
||||
});
|
||||
|
||||
const allSeatIds = seats.map(s => s.id);
|
||||
const stopTimes = await this.prisma.tripStopTime.findMany({
|
||||
@@ -856,30 +899,42 @@ export class SeatsService {
|
||||
const availableSeats = seats.filter(s => !unavailable.has(s.id) && !scheduleBlockedIds.has(s.id));
|
||||
|
||||
if (availableSeats.length < count) {
|
||||
throw new ConflictException(`Only ${availableSeats.length} seats available, requested ${count}`);
|
||||
throw new ConflictException(`Only ${availableSeats.length} seats available in ${seatClass.coachType.name} (across all fare tiers), requested ${count}`);
|
||||
}
|
||||
|
||||
const assigned = this.findContiguousSeats(availableSeats, count);
|
||||
return assigned.map((s) => s.id);
|
||||
// availableSeats is already ordered lower→middle→upper, ascending seat number within a
|
||||
// tier — take the first `count` in that order, spilling into the next tier once one runs out.
|
||||
return availableSeats.slice(0, count).map((s) => s.id);
|
||||
}
|
||||
|
||||
private findContiguousSeats(seats: any[], count: number): any[] {
|
||||
if (count === 1) return [seats[0]];
|
||||
|
||||
const grouped = new Map<string, any[]>();
|
||||
for (const seat of seats) {
|
||||
const key = `${seat.coachId}-${seat.row}`;
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key)!.push(seat);
|
||||
}
|
||||
|
||||
for (const rowSeats of grouped.values()) {
|
||||
if (rowSeats.length >= count) {
|
||||
return rowSeats.slice(0, count);
|
||||
}
|
||||
}
|
||||
|
||||
return seats.slice(0, count);
|
||||
/**
|
||||
* Auto-assigns `count` seats of `seatClassName` and immediately holds them in one request,
|
||||
* for callers (like bulk/group booking) that must never show an assignment the caller could
|
||||
* lose to a race before confirming it. Reuses `holdSeats` as-is — a single hold already
|
||||
* supports many seats/passengers in one row (see `SeatHold.seatIds: String[]`), so this is
|
||||
* pure orchestration, not a new hold mechanism.
|
||||
*/
|
||||
async autoAssignAndHold(
|
||||
scheduleId: string,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
seatClassName: string,
|
||||
passengerCount: number,
|
||||
) {
|
||||
const seatIds = await this.autoAssignSeats(scheduleId, passengerCount, seatClassName);
|
||||
// Scope the synthetic passengerId to this attempt (not just its row index) — a fixed
|
||||
// "group-1", "group-2"... would collide with any other still-active group-booking hold on
|
||||
// the same schedule (e.g. an abandoned/retried attempt, or two staff members booking the
|
||||
// same train within the hold TTL), tripping holdSeats' "passenger already holds a seat on
|
||||
// this journey leg" conflict check for two entirely unrelated bookings.
|
||||
const attemptId = randomUUID();
|
||||
const passengers = seatIds.map((seatId, i) => ({ passengerId: `group-${attemptId}-${i + 1}`, seatId }));
|
||||
return this.holdSeats({
|
||||
scheduleId,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
passengers,
|
||||
} as HoldSeatsDto);
|
||||
}
|
||||
|
||||
async exportSeatsCSV(scheduleId: string): Promise<string> {
|
||||
|
||||
Reference in New Issue
Block a user