Added group booking option

This commit is contained in:
Roba Boru
2026-08-24 18:04:13 +03:00
parent 8e2bab27cf
commit a5385e1462
13 changed files with 2000 additions and 51 deletions

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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")

View File

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

View File

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

View File

@@ -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> {