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,13 +825,55 @@ export class SeatsService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
const seats = await this.prisma.seat.findMany({
// 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: { assignments: { some: { scheduleId } } },
seatNumber: { not: '' },
NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }, { status: 'UNDER_MAINTENANCE' as any }],
coach: {
coachTypeId: seatClass.coachTypeId,
assignments: { some: { scheduleId } },
},
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
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);
@@ -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> {

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function GroupBookingLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,7 @@ import {
Activity,
Smartphone,
Layers,
UsersRound,
} from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils';
@@ -63,6 +64,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
title: 'Operations',
items: [
{ name: 'Bookings', href: '/bookings', icon: Ticket, permission: PERMS.bookings.view },
{ name: 'Group Booking', href: '/group-booking', icon: UsersRound, permission: PERMS.bookings.manage },
{ name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view },
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
{ name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.manage },

View File

@@ -0,0 +1,219 @@
import { apiClient } from '@/lib/api-client';
// ── Search (POST /search) ──────────────────────────────────────────────────
export interface SearchTripsRequest {
originStationId: string;
destinationStationId: string;
date: string;
adultCount: number;
childCount?: number;
journeyType: 'ONE_WAY';
/** Drives which fare tier (Local vs International) gets quoted — see fareTier in the page component. */
nationality?: string;
}
export interface ScheduleClassOption {
name: string;
baseFareMinor: number;
displayCurrency: string;
displayAmountMinor: number;
available: number;
}
export interface ScheduleCoachType {
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
coachId: string;
classes: ScheduleClassOption[];
}
export interface ScheduleResult {
type: 'DIRECT';
scheduleId: string;
trainNumber: string;
trainName: string;
origin: { id: string; code: string; name: string; city: string; sequence: number };
destination: { id: string; code: string; name: string; city: string; sequence: number };
departureAt: string;
arrivalAt: string;
durationMinutes: number;
status: string;
hasAvailability: boolean;
displayCurrency: string;
coachTypes: ScheduleCoachType[];
}
export type SearchEmptyReasonCode =
| 'NO_ROUTE'
| 'NO_SCHEDULE_ON_DATE'
| 'CANCELLED'
| 'PACKAGE_ONLY'
| 'CHECKIN_CLOSED'
| 'FULLY_BOOKED';
/** Structured, not a string — always render via a code→message lookup, never directly. */
export interface SearchEmptyReason {
code: SearchEmptyReasonCode;
originStationName: string;
destinationStationName: string;
}
export interface SearchTripsResponse {
journeyType: string;
outbound: ScheduleResult[];
requestedDate: string;
outboundReason?: SearchEmptyReason;
/** Nearby schedules for the same station pair on a different date, offered when `outbound` is empty. */
alternativeOutbound?: ScheduleResult[];
}
// ── Seat classes (GET /seat-classes) ───────────────────────────────────────
export interface SeatClassOption {
id: string;
name: string;
}
// ── Auto-assign + hold (POST /seats/auto-assign-hold) ─────────────────────
export interface AutoAssignHoldRequest {
scheduleId: string;
originStationId: string;
destinationStationId: string;
seatClassName: string;
adultCount: number;
childCount?: number;
}
export interface HeldPassengerSeat {
passengerId: string;
seat: {
id: string;
label?: string;
seatNumber?: string;
coach?: string;
row?: number;
col?: string;
};
}
export interface AutoAssignHoldResponse {
holdId: string;
expiresAt: string;
ttlSeconds: number;
schedule: { id: string; trainNumber: string; trainName: string; departureAt: string; arrivalAt: string } | null;
passengers: HeldPassengerSeat[];
}
// ── Group booking creation (POST /bookings/group) ──────────────────────────
export interface GroupBookingPassengerInput {
seatId: string;
passengerName: string;
dateOfBirth: string;
idDocumentType: 'NATIONAL_ID' | 'PASSPORT' | 'DRIVING_LICENSE' | 'OTHER';
idDocumentNumber?: string;
passportNumber?: string;
passportCountry?: string;
nationality?: string;
phone?: string;
email?: string;
}
export interface CreateGroupBookingRequest {
scheduleId: string;
holdId: string;
originStationId: string;
destinationStationId: string;
seatClassId: string;
bookingType: 'ONE_WAY';
passengers: GroupBookingPassengerInput[];
}
export interface GroupBookingSeat {
seatId: string;
passengerName: string;
passengerCategory: 'ADULT' | 'CHILD';
seat: { seatNumber: string; bedPosition?: string | null; coach: { number: string } };
}
export interface CreateGroupBookingResponse {
id: string;
bookingRef: string;
status: string;
totalMinor: number;
currency: string;
adultCount: number;
childCount: number;
seats: GroupBookingSeat[];
schedule: {
departureAt: string;
arrivalAt: string;
train: { number: string; name: string };
originStation: { name: string };
destinationStation: { name: string };
};
}
// ── Payment (GET /payments/methods, POST /payments/initiate) ───────────────
export type PaymentMethodType =
| 'TELEBIRR' | 'CBE_BIRR' | 'EBIRR' | 'WAAFI' | 'DMONEY' | 'CAC_BANK' | 'CARD' | 'WALLET' | 'CBE_BILL';
export interface SupportedPaymentMethod {
id: string;
type: PaymentMethodType;
displayName: string;
region: string;
currency: string;
enabled: boolean;
}
export interface InitiatePaymentRequest {
bookingId: string;
method: PaymentMethodType;
paymentMethodId?: string;
platform?: 'web' | 'mobile' | 'inapp';
}
export interface PaymentClientAction {
type: 'REDIRECT' | 'LAUNCH_APP' | 'INVOKE_BRIDGE' | 'COLLECT_OTP' | 'AWAIT_PUSH' | 'SHOW_BILL_REFERENCE';
url?: string;
/** Set when type=SHOW_BILL_REFERENCE (CBE bill payment) — the number the payer enters at any CBE channel. */
billReference?: string;
instructions?: string;
expiresAt?: string;
message?: string;
payerAccountMasked?: string;
}
export interface InitiatePaymentResponse {
intentId: string;
status: string;
clientAction?: PaymentClientAction;
merchantOrderId?: string;
failureCode?: string;
failureMessage?: string;
sessionExpiresAt?: string;
paymentDeadline?: string;
}
export const groupBookingApi = {
searchTrips: (dto: SearchTripsRequest) =>
apiClient.post<SearchTripsResponse>('/search', dto),
getSeatClasses: () => apiClient.get<SeatClassOption[]>('/seat-classes'),
autoAssignHold: (dto: AutoAssignHoldRequest) =>
apiClient.post<AutoAssignHoldResponse>('/seats/auto-assign-hold', dto),
createGroupBooking: (dto: CreateGroupBookingRequest) =>
apiClient.post<CreateGroupBookingResponse>('/bookings/group', dto),
getPaymentMethods: () => apiClient.get<SupportedPaymentMethod[]>('/payments/methods'),
initiatePayment: (dto: InitiatePaymentRequest) =>
apiClient.post<InitiatePaymentResponse>('/payments/initiate', dto),
};

View File

@@ -0,0 +1,149 @@
import ExcelJS from 'exceljs';
// Brand palette — matches finance-workbook.ts / ActionButton's primary variant, kept as a
// small local copy rather than a shared import since these are two unrelated export domains.
const BRAND = 'FF14714C';
const BRAND_TINT = 'FFEAF5EF';
const INK = 'FF1F2937';
const MUTED = 'FF6B7280';
const BORDER = 'FFE2E5E1';
const WHITE = 'FFFFFFFF';
const THIN_BORDER: Partial<ExcelJS.Borders> = {
top: { style: 'thin', color: { argb: BORDER } },
left: { style: 'thin', color: { argb: BORDER } },
bottom: { style: 'thin', color: { argb: BORDER } },
right: { style: 'thin', color: { argb: BORDER } },
};
/** Column order is the contract — passenger-excel.ts reads by this same header order. */
export const PASSENGER_TEMPLATE_COLUMNS = [
'Full Name',
'Date of Birth (YYYY-MM-DD)',
'Passenger Type',
'ID Document Type',
'ID Document Number',
'Passport Number',
'Passport Country',
'Nationality',
'Phone',
'Email',
] as const;
const REQUIRED_ROW = 200;
export interface PassengerTemplateInput {
trainNumber: string;
origin: string;
destination: string;
travelDate: string;
seatClassName: string;
adultCount: number;
childCount: number;
}
export async function buildPassengerTemplate(input: PassengerTemplateInput): Promise<Blob> {
const wb = new ExcelJS.Workbook();
wb.creator = 'EDR Passenger Backoffice';
wb.created = new Date();
const ws = wb.addWorksheet('Passengers', { views: [{ state: 'frozen', ySplit: 5 }] });
ws.columns = PASSENGER_TEMPLATE_COLUMNS.map((h) => ({ width: h.length < 14 ? 18 : h.length + 4 }));
// ── Title + trip context banner ──────────────────────────────────────────
ws.mergeCells(1, 1, 1, PASSENGER_TEMPLATE_COLUMNS.length);
const title = ws.getCell(1, 1);
title.value = 'EDR Group Booking — Passenger Template';
title.font = { bold: true, size: 16, color: { argb: WHITE } };
title.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } };
title.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
ws.getRow(1).height = 30;
for (let c = 1; c <= PASSENGER_TEMPLATE_COLUMNS.length; c++) ws.getCell(1, c).fill = title.fill;
ws.mergeCells(2, 1, 2, PASSENGER_TEMPLATE_COLUMNS.length);
const subtitle = ws.getCell(2, 1);
subtitle.value = `Train ${input.trainNumber} · ${input.origin}${input.destination} · ${input.travelDate} · ${input.seatClassName}`;
subtitle.font = { size: 11, color: { argb: INK } };
subtitle.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
ws.getRow(2).height = 20;
ws.mergeCells(3, 1, 3, PASSENGER_TEMPLATE_COLUMNS.length);
const requirement = ws.getCell(3, 1);
const total = input.adultCount + input.childCount;
requirement.value = `Fill in exactly ${total} passenger row${total === 1 ? '' : 's'} below — ${input.adultCount} Adult${input.adultCount === 1 ? '' : 's'} + ${input.childCount} Child${input.childCount === 1 ? '' : 'ren'}. One row per passenger, in any order.`;
requirement.font = { italic: true, size: 10, color: { argb: MUTED } };
requirement.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 };
ws.getRow(3).height = 18;
ws.mergeCells(4, 1, 4, PASSENGER_TEMPLATE_COLUMNS.length);
const instructions = ws.getCell(4, 1);
instructions.value =
'Columns marked * are required. Date of Birth must be YYYY-MM-DD and not in the future — it determines Adult/Child pricing (under 5 = Child). ' +
'ID Document Type must be one of: NATIONAL_ID, PASSPORT, DRIVING_LICENSE, OTHER. Do not rename or reorder columns.';
instructions.font = { size: 9, color: { argb: MUTED } };
instructions.alignment = { vertical: 'middle', horizontal: 'left', indent: 1, wrapText: true };
ws.getRow(4).height = 28;
// ── Header row ────────────────────────────────────────────────────────────
const headerRow = ws.getRow(5);
const requiredCols = new Set([0, 1, 2, 3]); // Full Name, DOB, Passenger Type, ID Document Type
PASSENGER_TEMPLATE_COLUMNS.forEach((h, i) => {
const cell = headerRow.getCell(i + 1);
cell.value = requiredCols.has(i) ? `${h} *` : h;
cell.font = { bold: true, color: { argb: WHITE }, size: 11 };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } };
cell.alignment = { vertical: 'middle', horizontal: 'left', wrapText: true };
cell.border = THIN_BORDER;
});
headerRow.height = 30;
// ── One filled example row so the format is obvious at a glance ──────────
const example = ws.getRow(6);
const exampleValues = [
'Abebe Kebede',
'1990-05-15',
'Adult',
'NATIONAL_ID',
'ET123456789',
'',
'',
'Ethiopian',
'+251911234567',
'abebe@example.com',
];
exampleValues.forEach((v, i) => {
const cell = example.getCell(i + 1);
cell.value = v;
cell.font = { italic: true, color: { argb: MUTED } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND_TINT } };
cell.border = THIN_BORDER;
});
// ── Blank rows with borders + dropdown validation for Passenger Type / ID Document Type ──
// exceljs's types only expose per-cell `cell.dataValidation`, not a worksheet-level range API.
for (let r = 7; r <= REQUIRED_ROW; r++) {
const row = ws.getRow(r);
for (let c = 1; c <= PASSENGER_TEMPLATE_COLUMNS.length; c++) {
row.getCell(c).border = THIN_BORDER;
}
row.getCell(3).dataValidation = {
type: 'list',
allowBlank: true,
formulae: ['"Adult,Child"'],
showErrorMessage: true,
errorTitle: 'Invalid Passenger Type',
error: 'Choose Adult or Child.',
};
row.getCell(4).dataValidation = {
type: 'list',
allowBlank: true,
formulae: ['"NATIONAL_ID,PASSPORT,DRIVING_LICENSE,OTHER"'],
showErrorMessage: true,
errorTitle: 'Invalid ID Document Type',
error: 'Choose NATIONAL_ID, PASSPORT, DRIVING_LICENSE, or OTHER.',
};
}
const buffer = await wb.xlsx.writeBuffer();
return new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}

View File

@@ -0,0 +1,229 @@
import ExcelJS from 'exceljs';
const VALID_ID_TYPES = ['NATIONAL_ID', 'PASSPORT', 'DRIVING_LICENSE', 'OTHER'];
/**
* Mirrors guest-booking.service.ts's per-passenger nationality inference exactly (NATIONAL_ID
* always forces 'Ethiopian'; PASSPORT falls back to Djiboutian/Other by passport country), so a
* row that will actually be priced at a different fare tier than the one quoted at search time
* is caught here instead of silently mispricing the group later.
*/
function inferredFareTier(docType: string, nationality: string, passportCountry: string): 'LOCAL' | 'INTERNATIONAL' {
const natUpper = nationality.trim().toUpperCase();
const isEthiopian = natUpper === 'ETHIOPIAN' || docType === 'NATIONAL_ID';
let resolved = nationality;
if (isEthiopian && docType === 'NATIONAL_ID') resolved = 'Ethiopian';
else if (!isEthiopian && docType === 'PASSPORT') resolved = nationality || (passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other');
else if (isEthiopian && docType === 'PASSPORT') resolved = 'Ethiopian';
const resolvedUpper = resolved.trim().toUpperCase();
return resolvedUpper === 'ETHIOPIAN' || resolvedUpper === 'DJIBOUTIAN' ? 'LOCAL' : 'INTERNATIONAL';
}
export interface ParsedPassengerRow {
/** 1-based row number in the sheet, for error messages ("row 8"). */
rowNumber: number;
fullName: string;
dateOfBirth: string; // normalized YYYY-MM-DD, empty if invalid/missing
passengerType: 'Adult' | 'Child' | '';
idDocumentType: string;
idDocumentNumber: string;
passportNumber: string;
passportCountry: string;
nationality: string;
phone: string;
email: string;
errors: string[];
warnings: string[];
}
export interface ParsePassengerExcelResult {
rows: ParsedPassengerRow[];
/** Structural problems (wrong file, missing columns) — nothing in `rows` can be trusted if this is non-empty. */
fileErrors: string[];
}
function cellText(row: ExcelJS.Row, colIndex: number): string {
if (colIndex < 1) return '';
const v = row.getCell(colIndex).value;
if (v === null || v === undefined) return '';
if (v instanceof Date) return v.toISOString().split('T')[0];
if (typeof v === 'object') {
const anyV = v as any;
if (typeof anyV.text === 'string') return anyV.text.trim();
if (anyV.result !== undefined) return String(anyV.result).trim();
if (anyV.richText) return anyV.richText.map((t: any) => t.text).join('').trim();
}
return String(v).trim();
}
/** Strips a trailing " *" (required-column marker) so header matching survives the template's own formatting. */
function normalizeHeader(h: string): string {
return h.replace(/\s*\*\s*$/, '').trim();
}
export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' | 'INTERNATIONAL'): Promise<ParsePassengerExcelResult> {
const buffer = await file.arrayBuffer();
const wb = new ExcelJS.Workbook();
try {
await wb.xlsx.load(buffer);
} catch {
return {
rows: [],
fileErrors: ['Could not read this file. Make sure it is a valid .xlsx or .xls file exported from the downloaded template.'],
};
}
const ws = wb.worksheets[0];
if (!ws) return { rows: [], fileErrors: ['The workbook has no sheets.'] };
// Locate the header row by scanning the first several rows for one starting with "Full Name" —
// the template puts it at row 5 (after the title/instruction banners), but scanning is more
// forgiving of an edited file than hardcoding a row number.
let headerRowIndex = -1;
let headers: string[] = [];
for (let r = 1; r <= 10; r++) {
const row = ws.getRow(r);
const values: string[] = [];
for (let c = 1; c <= 12; c++) values.push(normalizeHeader(cellText(row, c)));
if (values.some((v) => v.toLowerCase().startsWith('full name'))) {
headerRowIndex = r;
headers = values;
break;
}
}
if (headerRowIndex === -1) {
return {
rows: [],
fileErrors: ['Could not find the expected header row (starting with "Full Name"). Please use the downloaded template without changing its structure.'],
};
}
const colFor = (label: string) => headers.findIndex((h) => h.toLowerCase().startsWith(label.toLowerCase())) + 1;
const idx = {
fullName: colFor('Full Name'),
dob: colFor('Date of Birth'),
type: colFor('Passenger Type'),
docType: colFor('ID Document Type'),
docNumber: colFor('ID Document Number'),
passportNumber: colFor('Passport Number'),
passportCountry: colFor('Passport Country'),
nationality: colFor('Nationality'),
phone: colFor('Phone'),
email: colFor('Email'),
};
if (idx.fullName < 1 || idx.dob < 1 || idx.type < 1 || idx.docType < 1) {
return {
rows: [],
fileErrors: ['One or more required columns (Full Name, Date of Birth, Passenger Type, ID Document Type) are missing. Please use the downloaded template.'],
};
}
const rows: ParsedPassengerRow[] = [];
const lastRow = ws.actualRowCount || ws.rowCount;
for (let r = headerRowIndex + 1; r <= lastRow; r++) {
const row = ws.getRow(r);
const fullName = cellText(row, idx.fullName);
const dobRaw = cellText(row, idx.dob);
const typeRaw = cellText(row, idx.type);
const docTypeRaw = cellText(row, idx.docType).toUpperCase();
const docNumber = cellText(row, idx.docNumber);
const passportNumber = cellText(row, idx.passportNumber);
const passportCountry = cellText(row, idx.passportCountry);
const nationality = cellText(row, idx.nationality);
const phone = cellText(row, idx.phone);
const email = cellText(row, idx.email);
// Skip fully blank trailing rows (the template pre-formats borders down to row 200).
if (![fullName, dobRaw, typeRaw, docTypeRaw, docNumber, passportNumber, nationality, phone, email].some((v) => v)) {
continue;
}
const errors: string[] = [];
const warnings: string[] = [];
if (!fullName) errors.push('Full Name is required');
let dateOfBirth = '';
let ageYears: number | null = null;
if (!dobRaw) {
errors.push('Date of Birth is required');
} else {
const parsed = new Date(dobRaw);
if (isNaN(parsed.getTime())) {
errors.push(`Date of Birth "${dobRaw}" is not a valid date (use YYYY-MM-DD)`);
} else if (parsed.getTime() > Date.now()) {
errors.push('Date of Birth cannot be in the future');
} else {
dateOfBirth = parsed.toISOString().split('T')[0];
ageYears = (Date.now() - parsed.getTime()) / (365.25 * 24 * 60 * 60 * 1000);
}
}
let passengerType: 'Adult' | 'Child' | '' = '';
const normalizedType = typeRaw.trim().toLowerCase();
if (normalizedType === 'adult') passengerType = 'Adult';
else if (normalizedType === 'child') passengerType = 'Child';
else errors.push(`Passenger Type "${typeRaw}" must be "Adult" or "Child"`);
// The backend computes ADULT/CHILD from date of birth alone (under 5 = Child), regardless
// of this column — flag a mismatch so the uploader notices before it surprises them later.
if (passengerType && ageYears !== null) {
const impliedType = ageYears < 5 ? 'Child' : 'Adult';
if (impliedType !== passengerType) {
warnings.push(`Date of Birth implies ${impliedType}, but Passenger Type is set to ${passengerType} — seats/fare are priced by age, not this column`);
}
}
if (!docTypeRaw) {
errors.push('ID Document Type is required');
} else if (!VALID_ID_TYPES.includes(docTypeRaw)) {
errors.push(`ID Document Type "${docTypeRaw}" must be one of NATIONAL_ID, PASSPORT, DRIVING_LICENSE, OTHER`);
}
if (docTypeRaw === 'PASSPORT' && !passportNumber) {
warnings.push('Passport Number is empty for a PASSPORT document type');
}
// The whole group is priced at one uniform fare tier (the one quoted at search time) — a
// row whose document type/nationality would actually resolve to the other tier will be
// priced wrong (over- or under-charged) with no per-passenger fare split to fix it.
if (quotedFareTier && VALID_ID_TYPES.includes(docTypeRaw)) {
const rowTier = inferredFareTier(docTypeRaw, nationality, passportCountry);
if (rowTier !== quotedFareTier) {
warnings.push(
`This passenger's documents imply ${rowTier === 'LOCAL' ? 'Local (Ethiopian/Djiboutian)' : 'International'} pricing, but the group was quoted at ${quotedFareTier === 'LOCAL' ? 'Local' : 'International'} rates — this passenger's actual fare will differ from the group rate`,
);
}
}
rows.push({
rowNumber: r,
fullName,
dateOfBirth,
passengerType,
idDocumentType: docTypeRaw,
idDocumentNumber: docNumber,
passportNumber,
passportCountry,
nationality,
phone,
email,
errors,
warnings,
});
}
if (rows.length === 0) {
return { rows: [], fileErrors: ['No passenger rows found below the header. Fill in at least one row and try again.'] };
}
return { rows, fileErrors: [] };
}
export function countByType(rows: ParsedPassengerRow[]): { adults: number; children: number } {
return {
adults: rows.filter((r) => r.passengerType === 'Adult').length,
children: rows.filter((r) => r.passengerType === 'Child').length,
};
}