From a5385e1462efb3b9a234d01c08407e7d38545141 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Mon, 24 Aug 2026 18:04:13 +0300 Subject: [PATCH 1/3] Added group booking option --- .../modules/bookings/bookings.controller.ts | 33 + .../src/modules/bookings/guest-booking.dto.ts | 9 + .../modules/bookings/guest-booking.service.ts | 2 +- .../src/modules/seats/seats.controller.ts | 26 +- .../src/modules/seats/seats.dto.ts | 22 +- .../src/modules/seats/seats.service.spec.ts | 113 +- .../src/modules/seats/seats.service.ts | 109 +- .../src/app/group-booking/layout.tsx | 5 + .../backoffice/src/app/group-booking/page.tsx | 1133 +++++++++++++++++ .../src/components/layout/Sidebar.tsx | 2 + .../backoffice/src/lib/api/group-booking.ts | 219 ++++ .../src/lib/export/passenger-template.ts | 149 +++ .../src/lib/import/passenger-excel.ts | 229 ++++ 13 files changed, 2000 insertions(+), 51 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/app/group-booking/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts create mode 100644 apps/edr-passenger-web/backoffice/src/lib/export/passenger-template.ts create mode 100644 apps/edr-passenger-web/backoffice/src/lib/import/passenger-excel.ts diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 482c34268..6bfc042d8 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -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") diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts index 7065b6183..b59b65eb6 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.dto.ts @@ -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 { diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 5484aa658..a887df62a 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -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( diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index d19b6f024..2f95b22e6 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -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") diff --git a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts index 1d177e719..6e135b853 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.dto.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.dto.ts @@ -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; diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts index e80966d6a..98a019577 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.spec.ts @@ -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']); + }); }); }); diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 00aff331b..e6256c28a 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -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 = { 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(); - 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 { diff --git a/apps/edr-passenger-web/backoffice/src/app/group-booking/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/group-booking/layout.tsx new file mode 100644 index 000000000..aadccdf54 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/group-booking/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function GroupBookingLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx b/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx new file mode 100644 index 000000000..de50c6ff9 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx @@ -0,0 +1,1133 @@ +'use client'; + +import { useMemo, useRef, useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import Link from 'next/link'; +import { + AlertTriangle, ArmchairIcon, ArrowLeft, ArrowRight, BedDouble, Check, CheckCircle2, + Copy, CreditCard, Download, FileSpreadsheet, Landmark, Loader2, MapPin, Search, Smartphone, + Sparkles, Star, Train, Upload, Users, Wallet, X, +} from 'lucide-react'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; +import { apiClient } from '@/lib/api-client'; +import { + groupBookingApi, + type AutoAssignHoldResponse, + type CreateGroupBookingResponse, + type InitiatePaymentResponse, + type ScheduleClassOption, + type ScheduleResult, + type SearchEmptyReason, + type SeatClassOption, + type SupportedPaymentMethod, +} from '@/lib/api/group-booking'; +import { buildPassengerTemplate } from '@/lib/export/passenger-template'; +import { countByType, parsePassengerExcel, type ParsedPassengerRow } from '@/lib/import/passenger-excel'; +import ActionButton from '@/components/ui/ActionButton'; +import DatePicker from '@/components/ui/DatePicker'; +import Skeleton from '@/components/ui/Skeleton'; +import { cn, formatCurrency, formatDate, formatDateTime } from '@/lib/utils'; + +interface StationOption { + id: string; + name: string; + code: string; +} + +type Step = 'search' | 'results' | 'passengers' | 'confirm' | 'success'; + +interface SelectedClass { + scheduleId: string; + /** Raw SeatClass.name (e.g. "Economy Bed Upper (Local)") — used for API lookups, never shown to staff. */ + className: string; + /** Coach category shown to staff: Regular / Economy / VIP. */ + category: string; + fareMinor: number; + displayCurrency: string; + available: number; +} + +const STEPS: { key: Step; label: string }[] = [ + { key: 'search', label: 'Search' }, + { key: 'results', label: 'Trip & Class' }, + { key: 'passengers', label: 'Passengers' }, + { key: 'confirm', label: 'Seats & Confirm' }, + { key: 'success', label: 'Done' }, +]; + +/** outboundReason is a structured object, not a string — never render it directly. */ +function emptySearchMessage(reason: SearchEmptyReason | undefined): string { + if (!reason) return 'Try a different date or station pair.'; + const { originStationName: o, destinationStationName: d } = reason; + switch (reason.code) { + case 'NO_ROUTE': + return `No route connects ${o} to ${d} in this direction.`; + case 'NO_SCHEDULE_ON_DATE': + return `No scheduled departures from ${o} to ${d} on this date — try another date.`; + case 'CANCELLED': + return `Every departure from ${o} to ${d} on this date was cancelled.`; + case 'PACKAGE_ONLY': + return `Departures on this date are reserved for travel packages, not regular ticketing.`; + case 'CHECKIN_CLOSED': + return `Check-in has already closed for every departure on this date.`; + case 'FULLY_BOOKED': + return `This departure is fully booked for the requested party size — try fewer passengers or another date.`; + default: + return 'Try a different date or station pair.'; + } +} + +/** + * The API exposes one SeatClass row per bed position × nationality tier (e.g. "Economy Bed + * Upper (Local)"), but staff should only ever pick a coach category — the cheapest class + * within that category is used to price and assign the whole group automatically. + */ +const COACH_CATEGORY: Record = { HSC: 'Regular', HBC: 'Economy', SBC: 'VIP' }; + +/** Per-seat berth tier (Lower/Middle/Upper) — coach types with no bed split (Regular) report null. */ +function seatTypeLabel(bedPosition: string | null | undefined): string { + if (!bedPosition) return 'Standard'; + return bedPosition.charAt(0).toUpperCase() + bedPosition.slice(1).toLowerCase(); +} + +function categoryLabel(coachTypeCode: string, coachTypeName: string): string { + return COACH_CATEGORY[coachTypeCode] ?? coachTypeName; +} + +function categoryIcon(coachTypeCode: string) { + if (coachTypeCode === 'SBC') return Star; + if (coachTypeCode === 'HBC') return BedDouble; + return ArmchairIcon; +} + +interface CategoryOption { + coachTypeId: string; + coachTypeCode: string; + label: string; + cheapestClass: ScheduleClassOption; + /** Sum of available seats across every fare tier in this category (e.g. Upper+Middle+Lower) — this + * is what auto-assignment can actually draw from, since it fills the cheapest tier first and spills + * into pricier ones, all still billed at cheapestClass's rate. */ + totalAvailable: number; +} + +function ScheduleCard({ + schedule, + totalPassengers, + onChoose, +}: { + schedule: ScheduleResult; + totalPassengers: number; + onChoose: (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => void; +}) { + const categories: CategoryOption[] = schedule.coachTypes.map((ct) => ({ + coachTypeId: ct.coachTypeId, + coachTypeCode: ct.coachTypeCode, + label: categoryLabel(ct.coachTypeCode, ct.coachTypeName), + cheapestClass: ct.classes.reduce((min, c) => (c.baseFareMinor < min.baseFareMinor ? c : min), ct.classes[0]), + totalAvailable: ct.classes.reduce((sum, c) => sum + c.available, 0), + })); + const cheapestFare = Math.min(...categories.map((c) => c.cheapestClass.baseFareMinor)); + + return ( +
+
+
+
+ +
+
+

{schedule.trainNumber} · {schedule.trainName}

+

+ {schedule.origin.name} → {schedule.destination.name} +

+
+
+
+

{formatDateTime(schedule.departureAt)} → {formatDateTime(schedule.arrivalAt)}

+

{Math.floor(schedule.durationMinutes / 60)}h {schedule.durationMinutes % 60}m journey

+
+
+
+ {categories.map((cat) => { + const Icon = categoryIcon(cat.coachTypeCode); + const cls = cat.cheapestClass; + const enough = cat.totalAvailable >= totalPassengers; + const isBestValue = cls.baseFareMinor === cheapestFare; + return ( + + ); + })} +
+
+ ); +} + +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +function Stepper({ current }: { current: Step }) { + const idx = STEPS.findIndex((s) => s.key === current); + return ( +
+
+ {STEPS.map((s, i) => { + const done = i < idx; + const active = i === idx; + return ( +
+
+
+ {done ? : i + 1} +
+ + {s.label} + +
+ {i < STEPS.length - 1 && ( +
+ )} +
+ ); + })} +
+
+ ); +} + +function GroupBookingPageContent() { + const [step, setStep] = useState('search'); + + // ── Step 1: search ────────────────────────────────────────────────────── + const [originStationId, setOriginStationId] = useState(''); + const [destinationStationId, setDestinationStationId] = useState(''); + const [travelDate, setTravelDate] = useState(''); + const [adultCount, setAdultCount] = useState(1); + const [childCount, setChildCount] = useState(0); + const [searchTouched, setSearchTouched] = useState(false); + /** Local (Ethiopian/Djiboutian) vs International fare rates — the whole group is quoted and + * priced at one uniform tier, chosen up front since actual passenger nationalities aren't + * known until the Excel upload two steps later. */ + const [fareTier, setFareTier] = useState<'LOCAL' | 'INTERNATIONAL'>('LOCAL'); + + const { data: stations = [] } = useQuery({ + queryKey: ['stations'], + queryFn: () => apiClient.get('/stations'), + }); + const { data: seatClassOptions = [] } = useQuery({ + queryKey: ['seat-classes'], + queryFn: () => groupBookingApi.getSeatClasses(), + }); + + const totalPassengers = (adultCount || 0) + (childCount || 0); + const searchValid = !!originStationId && !!destinationStationId && originStationId !== destinationStationId + && !!travelDate && totalPassengers > 0; + + const searchMutation = useMutation({ + mutationFn: () => + groupBookingApi.searchTrips({ + originStationId, + destinationStationId, + date: travelDate, + adultCount: adultCount || 0, + childCount: childCount || 0, + journeyType: 'ONE_WAY', + nationality: fareTier === 'LOCAL' ? 'Ethiopian' : 'Other', + }), + }); + + const runSearch = () => { + setSearchTouched(true); + if (!searchValid) return; + setStep('results'); + searchMutation.mutate(); + }; + + // ── Step 2: results / class selection ─────────────────────────────────── + const [selectedSchedule, setSelectedSchedule] = useState(null); + const [selectedClass, setSelectedClass] = useState(null); + + const seatClassId = useMemo(() => { + if (!selectedClass) return null; + return seatClassOptions.find((sc) => sc.name === selectedClass.className)?.id ?? null; + }, [selectedClass, seatClassOptions]); + + const chooseClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => { + setSelectedSchedule(schedule); + setSelectedClass({ + scheduleId: schedule.scheduleId, + className: cls.name, + category, + fareMinor: cls.baseFareMinor, + displayCurrency: cls.displayCurrency, + available: totalAvailable, + }); + setStep('passengers'); + }; + + // ── Step 3: passenger Excel ────────────────────────────────────────────── + const fileInputRef = useRef(null); + const [dragOver, setDragOver] = useState(false); + const [passengerRows, setPassengerRows] = useState([]); + const [fileErrors, setFileErrors] = useState([]); + const [fileName, setFileName] = useState(null); + const [parsing, setParsing] = useState(false); + + const { adults: uploadedAdults, children: uploadedChildren } = countByType(passengerRows); + const rowsHaveErrors = passengerRows.some((r) => r.errors.length > 0); + const countMismatch = passengerRows.length > 0 && (uploadedAdults !== adultCount || uploadedChildren !== childCount); + const passengersValid = passengerRows.length > 0 && fileErrors.length === 0 && !rowsHaveErrors && !countMismatch; + + const downloadTemplate = async () => { + if (!selectedSchedule || !selectedClass) return; + const blob = await buildPassengerTemplate({ + trainNumber: selectedSchedule.trainNumber, + origin: selectedSchedule.origin.name, + destination: selectedSchedule.destination.name, + travelDate, + seatClassName: selectedClass.category, + adultCount, + childCount, + }); + downloadBlob(blob, `group-booking-passengers-${selectedSchedule.trainNumber}-${travelDate}.xlsx`); + }; + + const processFile = async (file: File) => { + setParsing(true); + setFileName(file.name); + try { + const result = await parsePassengerExcel(file, fareTier); + setPassengerRows(result.rows); + setFileErrors(result.fileErrors); + } finally { + setParsing(false); + } + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (file) await processFile(file); + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files?.[0]; + if (file) await processFile(file); + }; + + const clearUpload = () => { + setPassengerRows([]); + setFileErrors([]); + setFileName(null); + }; + + // ── Step 4: auto-assign + hold ─────────────────────────────────────────── + const [assignError, setAssignError] = useState(null); + const [hold, setHold] = useState(null); + + const autoAssignMutation = useMutation({ + mutationFn: () => { + if (!selectedSchedule || !selectedClass) throw new Error('No schedule/class selected'); + return groupBookingApi.autoAssignHold({ + scheduleId: selectedSchedule.scheduleId, + originStationId: selectedSchedule.origin.id, + destinationStationId: selectedSchedule.destination.id, + seatClassName: selectedClass.className, + adultCount, + childCount, + }); + }, + onSuccess: (data) => { + setHold(data); + setAssignError(null); + setStep('confirm'); + }, + onError: (err: any) => { + setAssignError(err?.response?.data?.message ?? err?.message ?? 'Not enough seats are available for this class.'); + }, + }); + + const proceedToAssignment = () => { + if (!passengersValid) return; + setAssignError(null); + autoAssignMutation.mutate(); + }; + + // Pairs each validated passenger row (upload order) with its auto-assigned seat (same order). + const seatAssignments = useMemo(() => { + if (!hold) return []; + return passengerRows.map((row, i) => ({ + row, + seat: hold.passengers[i]?.seat ?? null, + })); + }, [hold, passengerRows]); + + // ── Step 5: create booking ─────────────────────────────────────────────── + const [bookingError, setBookingError] = useState(null); + const [booking, setBooking] = useState(null); + + const createBookingMutation = useMutation({ + mutationFn: () => { + if (!selectedSchedule || !selectedClass || !hold || !seatClassId) { + throw new Error('Missing schedule, class, or hold — go back and try again.'); + } + return groupBookingApi.createGroupBooking({ + scheduleId: selectedSchedule.scheduleId, + holdId: hold.holdId, + originStationId: selectedSchedule.origin.id, + destinationStationId: selectedSchedule.destination.id, + seatClassId, + bookingType: 'ONE_WAY', + passengers: seatAssignments.map(({ row, seat }) => ({ + seatId: seat!.id, + passengerName: row.fullName, + dateOfBirth: row.dateOfBirth, + idDocumentType: row.idDocumentType as any, + idDocumentNumber: row.idDocumentNumber || undefined, + passportNumber: row.passportNumber || undefined, + passportCountry: row.passportCountry || undefined, + nationality: row.nationality || undefined, + phone: row.phone || undefined, + email: row.email || undefined, + })), + }); + }, + onSuccess: (data) => { + setBooking(data); + setBookingError(null); + setStep('success'); + }, + onError: (err: any) => { + // The hold was released server-side on failure — a retry needs a fresh one. + setHold(null); + setBookingError(err?.response?.data?.message ?? err?.message ?? 'Could not create the booking.'); + }, + }); + + const retryAfterBookingFailure = () => { + setBookingError(null); + setStep('passengers'); + }; + + // ── Step 6: pay ─────────────────────────────────────────────────────────── + const { data: paymentMethods = [] } = useQuery({ + queryKey: ['payment-methods'], + queryFn: () => groupBookingApi.getPaymentMethods(), + enabled: step === 'success', + staleTime: 5 * 60 * 1000, + }); + const enabledPaymentMethods = paymentMethods.filter((m) => m.enabled); + + const [selectedPaymentType, setSelectedPaymentType] = useState(null); + if (selectedPaymentType === null && enabledPaymentMethods.length > 0) { + const cbeBill = enabledPaymentMethods.find((m) => m.type === 'CBE_BILL'); + setSelectedPaymentType((cbeBill ?? enabledPaymentMethods[0]).type); + } + + const [paymentResult, setPaymentResult] = useState(null); + const [paymentError, setPaymentError] = useState(null); + const [billCopied, setBillCopied] = useState(false); + + const initiatePaymentMutation = useMutation({ + mutationFn: () => { + const method = enabledPaymentMethods.find((m) => m.type === selectedPaymentType); + if (!booking || !method) throw new Error('Select a payment method first.'); + return groupBookingApi.initiatePayment({ + bookingId: booking.id, + method: method.type, + paymentMethodId: method.id, + platform: 'web', + }); + }, + onSuccess: (data) => { + setPaymentResult(data); + setPaymentError(null); + }, + onError: (err: any) => { + setPaymentResult(null); + setPaymentError(err?.response?.data?.message ?? err?.message ?? 'Could not initiate payment.'); + }, + }); + + const paymentMethodIcon = (type: string) => { + if (type === 'CARD') return CreditCard; + if (type === 'WALLET') return Wallet; + if (type === 'CBE_BIRR' || type === 'CBE_BILL' || type === 'CAC_BANK') return Landmark; + return Smartphone; + }; + + const startOver = () => { + setStep('search'); + setSelectedSchedule(null); + setSelectedClass(null); + clearUpload(); + setHold(null); + setAssignError(null); + setBooking(null); + setBookingError(null); + setSelectedPaymentType(null); + setPaymentResult(null); + setPaymentError(null); + setBillCopied(false); + searchMutation.reset(); + }; + + return ( +
+
+

Group Booking

+

+ Search availability, upload a passenger list, and create one PNR for the whole group — seats are assigned automatically. +

+
+ + + + {/* Selection summary bar — visible from Step 2 onward */} + {selectedSchedule && selectedClass && step !== 'search' && step !== 'results' && ( +
+
+
+ +
+ {selectedSchedule.trainNumber} + {selectedSchedule.origin.name} → {selectedSchedule.destination.name} + · {formatDateTime(selectedSchedule.departureAt)} + · {selectedClass.category} + · {fareTier === 'LOCAL' ? 'Local' : 'International'} rates + · {adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? ` + ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''} +
+ +
+ )} + + {/* ── Step 1: Search ─────────────────────────────────────────────── */} + {step === 'search' && ( +
+
+
+ +
+

Search Availability

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + setAdultCount(e.target.value === '' ? 0 : Math.max(0, parseInt(e.target.value, 10) || 0))} + /> +
+
+ + setChildCount(e.target.value === '' ? 0 : Math.max(0, parseInt(e.target.value, 10) || 0))} + /> +
+
+ +
+ +
+ {(['LOCAL', 'INTERNATIONAL'] as const).map((tier) => ( + + ))} +
+

+ The whole group is quoted and charged at one uniform rate — pick the tier that matches most of the roster. + A passenger whose ID document implies the other tier will be flagged when you upload the passenger list. +

+
+ + {originStationId && destinationStationId && travelDate && totalPassengers > 0 && ( +
+ + Searching for {totalPassengers} passenger{totalPassengers === 1 ? '' : 's'} ({adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? `, ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}) +
+ )} + + {searchTouched && !searchValid && ( +

+ {totalPassengers <= 0 + ? 'Enter at least one adult or child.' + : originStationId && originStationId === destinationStationId + ? 'Origin and destination must be different.' + : 'Fill in origin, destination, and travel date.'} +

+ )} + + + Search Availability + +
+ )} + + {/* ── Step 2: Results ────────────────────────────────────────────── */} + {step === 'results' && ( +
+ + + {searchMutation.isPending && ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+
+ + +
+ +
+
+ {Array.from({ length: 3 }).map((_, j) => ( + + ))} +
+
+ ))} +
+ )} + + {searchMutation.isError && ( +
+ + + {(searchMutation.error as any)?.response?.data?.message ?? 'Search failed. Please try again.'} + + searchMutation.mutate()}>Retry +
+ )} + + {searchMutation.isSuccess && searchMutation.data.outbound.length === 0 && ( +
+ +

No schedules found for this search.

+

{emptySearchMessage(searchMutation.data.outboundReason)}

+
+ )} + + {searchMutation.isSuccess && (searchMutation.data.alternativeOutbound?.length ?? 0) > 0 && ( +
+

+ Nearby schedules for the same route +

+ {searchMutation.data!.alternativeOutbound!.map((schedule) => ( + + ))} +
+ )} + + {(searchMutation.data?.outbound ?? []).map((schedule) => ( + + ))} +
+ )} + + {/* ── Step 3: Passenger Information ─────────────────────────────── */} + {step === 'passengers' && selectedSchedule && selectedClass && ( +
+ + +
+
+
+ +
+

Passenger Information

+
+

+ Download the template, fill in one row per passenger, then upload the completed file. + Need exactly {totalPassengers} passenger{totalPassengers === 1 ? '' : 's'} ({adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? `, ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}). +

+ +
+ + Download Passenger Template + +
+ +
fileInputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + > + {fileName ? ( + <> + +

{fileName}

+ + + ) : ( + <> + +

{parsing ? 'Reading file…' : 'Drag & drop the completed template here'}

+

or click to browse — .xlsx or .xls, using the downloaded template

+ + )} + +
+ + {fileErrors.length > 0 && ( +
+ {fileErrors.map((e, i) =>

{e}

)} +
+ )} + + {countMismatch && ( +
+ Expected {totalPassengers} passengers ({adultCount} Adult{adultCount === 1 ? '' : 's'}, {childCount} Child{childCount === 1 ? '' : 'ren'}) — + the uploaded file has {passengerRows.length} ({uploadedAdults} Adult{uploadedAdults === 1 ? '' : 's'}, {uploadedChildren} Child{uploadedChildren === 1 ? '' : 'ren'}). + Fix the file to match and re-upload. +
+ )} +
+ + {passengerRows.length > 0 && ( +
+
+

+ Passenger Preview ({passengerRows.length}) +

+
+
+ + + + {['Row', 'Name', 'DOB', 'Type', 'ID Document', 'Nationality', 'Status'].map((h) => ( + + ))} + + + + {passengerRows.map((row) => ( + 0 ? 'bg-red-50/60 dark:bg-red-950/20' : undefined}> + + + + + + + + + ))} + +
{h}
{row.rowNumber}{row.fullName || '—'}{row.dateOfBirth || '—'}{row.passengerType || '—'}{row.idDocumentType || '—'}{row.nationality || '—'} + {row.errors.length === 0 ? ( + + Valid + {row.warnings.length > 0 && ({row.warnings.length} warning{row.warnings.length === 1 ? '' : 's'})} + + ) : ( + + + {row.errors.join('; ')} + + )} + {row.errors.length === 0 && row.warnings.length > 0 && ( +

{row.warnings.join('; ')}

+ )} +
+
+
+ + {passengersValid && } + {passengersValid ? 'All passengers valid — ready to continue.' : 'Fix the issues above before continuing.'} + + + Continue to Seat Assignment + +
+
+ )} + + {assignError && ( +
+ {assignError} +
+ )} +
+ )} + + {/* ── Step 4: Seat Assignment Preview + Step 5: Confirm ─────────── */} + {step === 'confirm' && hold && selectedSchedule && selectedClass && ( +
+
+
+
+
+ +
+

+ Seats Assigned Automatically +

+
+ Held for {Math.floor(hold.ttlSeconds / 60)}m {hold.ttlSeconds % 60}s +
+

Read-only — seats are assigned by the system, not selected manually.

+
+ {seatAssignments.map(({ row, seat }) => ( +
+
+ {seat ? (seat.seatNumber ?? seat.label ?? '?') : '—'} +
+
+

{row.fullName}

+

{row.passengerType} · {seat?.coach ?? '—'}

+
+
+ ))} +
+
+ + {bookingError && ( +
+ {bookingError} + Reassign & Retry +
+ )} + +
+
+ {totalPassengers} passenger{totalPassengers === 1 ? '' : 's'} · {selectedClass.category} · {formatCurrency(selectedClass.fareMinor * totalPassengers, selectedClass.displayCurrency)} estimated total +
+ createBookingMutation.mutate()} + > + Create Booking + +
+
+ )} + + {/* ── Step 6: Success ────────────────────────────────────────────── */} + {step === 'success' && booking && ( +
+
+
+
+
+ +
+
+

Group Booking Created

+

{booking.bookingRef}

+

Status: {booking.status}

+
+
+
+
+
+

Train

+

{booking.schedule.train.number} · {booking.schedule.train.name}

+
+
+

Travel Date

+

{formatDate(booking.schedule.departureAt)}

+
+
+

Origin → Destination

+

{booking.schedule.originStation.name} → {booking.schedule.destinationStation.name}

+
+
+

Departure → Arrival

+

{formatDateTime(booking.schedule.departureAt)} → {formatDateTime(booking.schedule.arrivalAt)}

+
+
+

Coach / Class

+

{selectedClass?.category ?? '—'}

+
+
+

Adults / Children

+

{booking.adultCount} / {booking.childCount}

+
+
+

Total Passengers

+

{booking.seats.length}

+
+
+

Total Fare

+

{formatCurrency(booking.totalMinor, booking.currency)}

+
+
+
+ +
+
+

Passenger List

+
+
+ {booking.seats.map((s, i) => ( +
+
+ {s.seat?.seatNumber ?? '?'} +
+
+

{s.passengerName}

+

+ {s.passengerCategory} · {seatTypeLabel(s.seat?.bedPosition)} · Seat {s.seat?.seatNumber ?? '—'} · Coach {s.seat?.coach?.number} +

+
+
+ ))} +
+
+ + {/* ── Pay now ─────────────────────────────────────────────────── */} + {!paymentResult && ( +
+

+ Collect Payment +

+ {enabledPaymentMethods.length === 0 ? ( +

Loading payment options…

+ ) : ( + <> +
+ {enabledPaymentMethods.map((m) => { + const Icon = paymentMethodIcon(m.type); + const active = selectedPaymentType === m.type; + return ( + + ); + })} +
+ {paymentError && ( +
+ {paymentError} +
+ )} + initiatePaymentMutation.mutate()} + disabled={!selectedPaymentType || initiatePaymentMutation.isPending} + > + {initiatePaymentMutation.isPending ? ( + + Initiating… + + ) : ( + 'Initiate Payment' + )} + + + )} +
+ )} + + {paymentResult && ( +
+

+ Payment {paymentResult.status === 'SUCCEEDED' ? 'Complete' : 'Initiated'} +

+ + {paymentResult.status === 'SUCCEEDED' && ( +
+ Payment succeeded. +
+ )} + + {(paymentResult.status === 'FAILED' || paymentResult.status === 'CANCELLED') && ( +
+ + {paymentResult.failureMessage ?? 'Payment was not completed.'} +
+ )} + + {paymentResult.clientAction?.type === 'SHOW_BILL_REFERENCE' && ( +
+

+ {paymentResult.clientAction.instructions ?? + 'Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD.'} +

+
+ + {paymentResult.clientAction.billReference} + + { + if (paymentResult.clientAction?.billReference) { + await navigator.clipboard.writeText(paymentResult.clientAction.billReference); + setBillCopied(true); + setTimeout(() => setBillCopied(false), 2000); + } + }} + > + {billCopied ? 'Copied' : 'Copy'} + +
+
+

Amount: {formatCurrency(booking.totalMinor, booking.currency)}

+ {paymentResult.clientAction.expiresAt && ( +

Pay before: {formatDateTime(paymentResult.clientAction.expiresAt)}

+ )} +
+
+ )} + + {paymentResult.clientAction && !['SHOW_BILL_REFERENCE'].includes(paymentResult.clientAction.type) && ( +

+ {paymentResult.clientAction.message ?? `Next step: ${paymentResult.clientAction.type}.`} +

+ )} + +

Payment reference: {paymentResult.intentId}

+ + +
+ )} + +
+ + View Booking + + +
+
+ )} +
+ ); +} + +export default function GroupBookingPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index de8619577..86253a385 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -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 }, diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts b/apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts new file mode 100644 index 000000000..0effa7d5b --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts @@ -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('/search', dto), + + getSeatClasses: () => apiClient.get('/seat-classes'), + + autoAssignHold: (dto: AutoAssignHoldRequest) => + apiClient.post('/seats/auto-assign-hold', dto), + + createGroupBooking: (dto: CreateGroupBookingRequest) => + apiClient.post('/bookings/group', dto), + + getPaymentMethods: () => apiClient.get('/payments/methods'), + + initiatePayment: (dto: InitiatePaymentRequest) => + apiClient.post('/payments/initiate', dto), +}; diff --git a/apps/edr-passenger-web/backoffice/src/lib/export/passenger-template.ts b/apps/edr-passenger-web/backoffice/src/lib/export/passenger-template.ts new file mode 100644 index 000000000..4e2f20964 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/export/passenger-template.ts @@ -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 = { + 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 { + 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' }); +} diff --git a/apps/edr-passenger-web/backoffice/src/lib/import/passenger-excel.ts b/apps/edr-passenger-web/backoffice/src/lib/import/passenger-excel.ts new file mode 100644 index 000000000..2a37d3a80 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/import/passenger-excel.ts @@ -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 { + 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, + }; +} From 5c2100e76d72a8066fce9094090e07a9927b60e1 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Mon, 24 Aug 2026 18:14:10 +0300 Subject: [PATCH 2/3] Fix passenger list alignment --- .../edr-passenger-web/backoffice/src/app/group-booking/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx b/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx index de50c6ff9..f77980c78 100644 --- a/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/group-booking/page.tsx @@ -968,7 +968,7 @@ function GroupBookingPageContent() {

Passenger List

-
+
{booking.seats.map((s, i) => (
From d5a5085d6d22f14245c0b767c705bf9b9a44e453 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 24 Aug 2026 23:49:20 +0000 Subject: [PATCH 3/3] feat: enhance booking and audit log functionalities - Implemented read-only locking for customer-requested container sizes and billing currency in the GlCreateBookingForm component. - Added functionality to lock partner quantities based on shipment requests in the ConsolidationPartnerPanel. - Introduced a new Leave action in the LogPassYardWorkModal to unassign bookings from trains. - Enhanced the AuditLogsPage to support filtering by action and added a Go button for direct navigation to entity detail pages. - Updated WagonCancellationsPage to handle odd-20ft credits requiring partner selection during rebooking. - Improved TrainScheduleV2DetailPage to allow manual loading of cargo and display warnings for unassigned bookings. - Added a new reference field to the audit logs for better searchability and tracking of actions. - Created a migration to add the reference column to the audit logs table and established an index for efficient querying. - Defined a registry for audit reference sources to streamline the retrieval of human identifiers for various entities. --- .../3690000000000-AuditLogReference.ts | 45 +++++ .../src/modules/audit/audit-endpoints.ts | 42 +++-- .../src/modules/audit/audit-log.repository.ts | 114 +++++++++-- .../modules/audit/audit-reference.registry.ts | 39 ++++ .../src/modules/audit/audit.controller.ts | 9 + .../src/modules/audit/audit.service.ts | 42 +++++ .../modules/audit/dto/audit-log-query.dto.ts | 38 ++++ .../audit/entities/audit-log.entity.ts | 14 ++ ...booking-wagon-cancellation.service.spec.ts | 67 +++++++ .../booking-wagon-cancellation.service.ts | 178 +++++++++++++++++- .../modules/bookings/bookings.controller.ts | 24 +++ .../modules/bookings/bookings.repository.ts | 52 +++++ .../bookings/dto/wagon-cancellation.dto.ts | 9 + .../contract-booking.completion.spec.ts | 82 ++++++++ .../contracts/contract-booking.service.ts | 77 ++++++++ .../train-scheduling.controller.ts | 3 +- .../dto/record-checkpoint.dto.ts | 18 ++ .../services/train-scheduling.service.ts | 91 ++++++++- .../contracts/GlCreateBookingForm.tsx | 84 ++++++++- .../ConsolidationPartnerPanel.tsx | 12 ++ .../trainScheduling/LogPassYardWorkModal.tsx | 95 +++++++--- .../backoffice/src/pages/AuditLogsPage.tsx | 87 ++++++++- .../pages/bookings/WagonCancellationsPage.tsx | 85 ++++++++- .../TrainScheduleV2DetailPage.tsx | 74 +++++++- .../src/services/auditLogs.service.ts | 20 ++ .../backoffice/src/types/trainScheduling.ts | 7 + .../components/WagonCancellationCard.tsx | 15 +- .../src/pages/bookings/RebookWagonsButton.tsx | 19 ++ 28 files changed, 1356 insertions(+), 86 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3690000000000-AuditLogReference.ts create mode 100644 apps/edr-freight-api/src/modules/audit/audit-reference.registry.ts diff --git a/apps/edr-freight-api/src/migrations/3690000000000-AuditLogReference.ts b/apps/edr-freight-api/src/migrations/3690000000000-AuditLogReference.ts new file mode 100644 index 000000000..499944a05 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3690000000000-AuditLogReference.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds `reference` to freight.audit_logs — the human identifier of the entity + * the action touched (booking reference, schedule number, train number, …), + * resolved at write time by the audit interceptor. `resource_id` stays the + * machine id; this column is what staff actually type into the search box. + * + * Production safety: + * - `ADD COLUMN ... NOT NULL DEFAULT ''` is metadata-only on Postgres 11+: + * no table rewrite, no long lock, existing rows read '' without being + * touched. Rows written before this migration keep '' permanently — + * capture starts from deploy, by design (no backfill). + * - Everything is IF NOT EXISTS so a hand-patched database converges + * instead of failing the deploy. + * - No existing column is altered and nothing is dropped: zero data-loss + * surface. + * + * The index is an expression index on upper(reference) with + * text_pattern_ops so the search endpoint's case-insensitive prefix match + * (`upper(reference) LIKE upper($1) || '%'`) is indexed. '' rows are + * excluded to keep it small — they are never searched for. + */ +export class AuditLogReference3690000000000 implements MigrationInterface { + name = 'AuditLogReference3690000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.audit_logs + ADD COLUMN IF NOT EXISTS reference varchar(64) NOT NULL DEFAULT '' + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_audit_logs_reference_upper + ON freight.audit_logs (upper(reference) text_pattern_ops) + WHERE reference <> '' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Down discards every captured reference — acceptable only because down + // migrations are never run against production here. + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_audit_logs_reference_upper`); + await queryRunner.query(`ALTER TABLE freight.audit_logs DROP COLUMN IF EXISTS reference`); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts index 019d99920..50093ea67 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -12,7 +12,7 @@ * humanized handler name where a route has none. * * Excludes the AI Assist and Account entities. - * Generated from the controllers under src/ — 517 endpoints. + * Generated from the controllers under src/ — 528 endpoints. */ /** [title, method, entity] for one auditable route. */ export type AuditEndpointMeta = readonly [title: string, method: string, entity: string]; @@ -38,6 +38,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"], "POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"], "POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration/skip": ["GL ET skips the draft-declaration round: no estimate is sent to the customer, the real declaration is filed directly and duty & tax passes by default", "POST", "Booking"], "POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"], "POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"], "POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"], @@ -51,7 +52,12 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"], "PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"], "POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"], + "POST /api/bookings/:id/clearance/charges/:chargeId/accept": ["Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge", "POST", "Booking"], + "POST /api/bookings/:id/clearance/charges/:chargeId/reject": ["Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends", "POST", "Booking"], "POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"], + "POST /api/bookings/:id/additional-charges": ["Finance raises a new additional charge — draft, or send to the customer immediately", "POST", "Booking"], + "POST /api/bookings/:id/additional-charges/:chargeId/send": ["Issue the draft charge's payable invoice and notify the customer", "POST", "Booking"], + "POST /api/bookings/:id/additional-charges/:chargeId/cancel": ["Withdraw a draft or unpaid additional charge", "POST", "Booking"], "POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"], "POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"], "POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"], @@ -73,7 +79,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"], "PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"], "POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"], - "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"], + // "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"], "POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"], "POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"], "POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"], @@ -85,7 +91,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"], "POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"], "POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"], - "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"], + // "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"], "POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"], "POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"], "POST /api/bookings/consolidation-approvals/:approvalId/approve": ["Approve a shared wagon: both bookings leave the gate and continue to Operations together.", "POST", "Booking"], @@ -207,7 +213,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"], "POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"], "POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"], - "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"], + // "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"], "POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"], "POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"], "POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"], @@ -240,7 +246,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"], "PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"], "DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"], - "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"], + // "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"], // Driver "POST /api/drivers": ["Create a new driver", "POST", "Driver"], @@ -266,6 +272,8 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"], "POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"], "POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"], + "POST /api/invoices/eims/bulk-register": ["Submit multiple invoices to MoR EIMS in one call. Asynchronous — this only confirms MoR", "POST", "EIMS Invoice"], + "POST /api/eims/webhook/bulk-register": ["EIMS bulk-register webhook callback (MoR reports per-invoice results)", "POST", "EIMS Invoice"], // Exchange Setting "PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"], @@ -373,6 +381,14 @@ export const AUDIT_ENDPOINTS: Readonly> = { "PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"], "POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"], + // Operations Standard + "PATCH /api/operations-standards": ["Change one or more operating standards", "PATCH", "Operations Standard"], + + // Operations Target + "POST /api/operations-targets": ["Create a planned target", "POST", "Operations Target"], + "PATCH /api/operations-targets/:id": ["Update a planned target", "PATCH", "Operations Target"], + "DELETE /api/operations-targets/:id": ["Soft-delete a planned target", "DELETE", "Operations Target"], + // Organization User "PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"], "POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"], @@ -445,7 +461,8 @@ export const AUDIT_ENDPOINTS: Readonly> = { // Two controllers register this same path; Nest serves whichever module loads first. "POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"], "POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"], - "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"], + "POST /api/train-scheduling/schedules/:id/reschedule/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"], + // "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"], // Service Type "POST /api/service-types": ["Create a service type", "POST", "Service Type"], @@ -463,7 +480,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { // Shipping Line Booking "POST /api/shipping-line-bookings/initiate": ["Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", "POST", "Shipping Line Booking"], "POST /api/shipping-line-bookings/:id/cancel": ["Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", "POST", "Shipping Line Booking"], - "POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"], + // "POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"], "POST /api/shipping-line-bookings/:id/complete": ["Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day.", "POST", "Shipping Line Booking"], // Shipping Line Credit @@ -510,6 +527,8 @@ export const AUDIT_ENDPOINTS: Readonly> = { "PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"], "PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"], "POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"], + "PATCH /api/train-builder/:id/wagons/:wagonId/yard": ["Move one coupled wagon to another yard — refused while any live schedule has the wagon allocated", "PATCH", "Train Build"], + "PATCH /api/train-builder/:id/wagons/yard": ["Move several coupled wagons to another yard in one transaction — refused outright if any is allocated to a live schedule", "PATCH", "Train Build"], "POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"], "DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"], "POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"], @@ -520,16 +539,16 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"], "POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"], "POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"], - "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"], + // "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"], - "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"], + // "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"], "PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"], - "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"], + // "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"], "POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"], "POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"], @@ -564,6 +583,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"], "POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"], "PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/wagon-yards": ["Re-plan the yard this departure boards wagons from and/or cuts them at (schedule-only; physical yards untouched, dispatch requires alignment)", "PATCH", "Train Schedule"], "POST /api/train-scheduling/schedules/:id/merge": ["Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", "POST", "Train Schedule"], "PATCH /api/train-scheduling/schedules/:id/checkpoints/:sequenceNo": ["Edit a logged leg", "PATCH", "Train Schedule"], @@ -611,7 +631,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"], "PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"], "DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"], - "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"], + // "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"], "POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"], "PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"], "DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"], diff --git a/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts b/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts index 91d6c9901..dcbe53306 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts @@ -1,10 +1,11 @@ import { Injectable } from '@nestjs/common'; import { BaseRepository } from '@edr/api-common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Between, FindOptionsWhere, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import { Repository } from 'typeorm'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { AuditLog } from './entities/audit-log.entity'; +import type { AuditReferenceSource } from './audit-reference.registry'; export interface AuditLogQuery { type?: string; @@ -12,6 +13,10 @@ export interface AuditLogQuery { method?: string; isSuccess?: boolean; resourceId?: string; + reference?: string; + userName?: string; + title?: string; + q?: string; from?: Date; to?: Date; skip: number; @@ -40,30 +45,91 @@ export class AuditLogRepository extends BaseRepository { ); } + /** + * Resolve the human identifier for one entity row (`WHERE id = $1`). + * + * `source` comes from the static `AUDIT_REFERENCE_SOURCES` registry — never + * from user input — so interpolating its table/column is safe; the id is + * bound as a parameter. Returns null when the row doesn't exist or the + * identifier column is empty. + */ + async lookupReference( + source: AuditReferenceSource, + id: string, + ): Promise { + const rows = await this.auditLogRepository.manager.query< + { reference: string | null }[] + >( + `SELECT ${source.column}::varchar AS reference FROM ${source.table} WHERE id = $1::uuid`, + [id], + ); + return rows[0]?.reference || null; + } + /** * Paginated, filtered read. Newest first — every index on this table is * ordered `created_at DESC` to match. + * + * Query builder rather than `findAndCount`: `q` needs an OR across four + * columns, and `reference` needs the `upper(...) LIKE` shape that matches + * the expression index — neither fits `FindOptionsWhere`. */ async search(query: AuditLogQuery): Promise<[AuditLog[], number]> { - const where: FindOptionsWhere = {}; + const qb = this.auditLogRepository.createQueryBuilder('audit_log'); - if (query.type) where.type = query.type; - if (query.userId) where.userId = query.userId; - if (query.method) where.method = query.method; - if (query.resourceId) where.resourceId = query.resourceId; - if (query.isSuccess !== undefined) where.isSuccess = query.isSuccess; + if (query.type) qb.andWhere('audit_log.type = :type', { type: query.type }); + if (query.userId) qb.andWhere('audit_log.user_id = :userId', { userId: query.userId }); + if (query.method) qb.andWhere('audit_log.method = :method', { method: query.method }); + if (query.resourceId) { + qb.andWhere('audit_log.resource_id = :resourceId', { resourceId: query.resourceId }); + } + if (query.isSuccess !== undefined) { + qb.andWhere('audit_log.is_success = :isSuccess', { isSuccess: query.isSuccess }); + } + + // Case-insensitive prefix match, shaped to hit idx_audit_logs_reference_upper. + // The explicit <> '' repeats the index's partial predicate — without it the + // planner cannot prove the partial index applies and falls back to a scan. + if (query.reference) { + qb.andWhere("audit_log.reference <> ''").andWhere( + "upper(audit_log.reference) LIKE upper(:reference) || '%'", + { reference: escapeLike(query.reference) }, + ); + } + if (query.userName) { + qb.andWhere('audit_log.user_name ILIKE :userName', { + userName: `%${escapeLike(query.userName)}%`, + }); + } + if (query.title) { + qb.andWhere('audit_log.title ILIKE :title', { + title: `%${escapeLike(query.title)}%`, + }); + } + + // One search box across the columns staff actually search by. + // ponytail: ILIKE %…% scans the time-bounded window; add pg_trgm GIN + // indexes if the table grows past a few million rows. + if (query.q) { + const q = `%${escapeLike(query.q)}%`; + qb.andWhere( + `(audit_log.reference ILIKE :q + OR audit_log.resource_id ILIKE :q + OR audit_log.user_name ILIKE :q + OR audit_log.title ILIKE :q)`, + { q }, + ); + } // Date range: either bound may be supplied alone. - if (query.from && query.to) where.createdAt = Between(query.from, query.to); - else if (query.from) where.createdAt = MoreThanOrEqual(query.from); - else if (query.to) where.createdAt = LessThanOrEqual(query.to); + if (query.from) qb.andWhere('audit_log.created_at >= :from', { from: query.from }); + if (query.to) qb.andWhere('audit_log.created_at <= :to', { to: query.to }); - return this.auditLogRepository.findAndCount({ - where, - order: { createdAt: 'DESC' }, - skip: query.skip, - take: query.take, - }); + return qb + .orderBy('audit_log.created_at', 'DESC') + .skip(query.skip) + .take(query.take) + .getManyAndCount(); } /** Distinct entity types present, for populating a filter dropdown. */ @@ -76,4 +142,20 @@ export class AuditLogRepository extends BaseRepository { return rows.map((row) => row.type); } + + /** Distinct action titles present, for the action filter dropdown. */ + async distinctTitles(): Promise { + const rows = await this.auditLogRepository + .createQueryBuilder('audit_log') + .select('DISTINCT audit_log.title', 'title') + .orderBy('audit_log.title', 'ASC') + .getRawMany<{ title: string }>(); + + return rows.map((row) => row.title); + } +} + +/** Escape LIKE wildcards so a literal `%`/`_` in the search text stays literal. */ +function escapeLike(value: string): string { + return value.replace(/[\\%_]/g, (ch) => `\\${ch}`); } diff --git a/apps/edr-freight-api/src/modules/audit/audit-reference.registry.ts b/apps/edr-freight-api/src/modules/audit/audit-reference.registry.ts new file mode 100644 index 000000000..9cba31abb --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit-reference.registry.ts @@ -0,0 +1,39 @@ +/** + * Where each audited entity type keeps its human identifier — the value staff + * search by (booking reference, train number, invoice number). + * + * Used by `AuditService.record` for a single indexed primary-key lookup at + * write time. Types not listed simply get `reference = ''`; the lookup is + * best-effort and an audit row is never lost over it. + * + * Table and column names are static values from this file — never user input — + * so interpolating them into SQL is safe. Ids are always bound as parameters. + */ +export interface AuditReferenceSource { + /** Schema-qualified table holding the entity. */ + readonly table: string; + /** Column with the human identifier. */ + readonly column: string; +} + +export const AUDIT_REFERENCE_SOURCES: Readonly> = { + Booking: { table: 'freight.bookings', column: 'reference' }, + Contract: { table: 'freight.contracts', column: 'reference' }, + // "Schedule" (reschedule module) and "Train Schedule" are the same table. + Schedule: { table: 'freight.train_schedules', column: 'reference' }, + 'Train Schedule': { table: 'freight.train_schedules', column: 'reference' }, + Train: { table: 'freight.trains', column: 'train_number' }, + // Train Build routes carry the train id in :id. + 'Train Build': { table: 'freight.trains', column: 'train_number' }, + Wagon: { table: 'freight.wagons', column: 'wagon_number' }, + Locomotive: { table: 'freight.locomotives', column: 'code' }, + 'EIMS Invoice': { table: 'freight.invoices', column: 'invoice_number' }, + // Payment paths mostly carry an invoice id; the ones that don't (e.g. + // redirect-success/:bookingId) miss the lookup and fall back to ''. + Payment: { table: 'freight.invoices', column: 'invoice_number' }, + Vehicle: { table: 'freight.vehicles', column: 'plate_number' }, + Company: { table: 'freight.companies', column: 'name' }, +}; + +/** Lookups run `WHERE id = $1::uuid` — guard non-uuid ids (template codes…). */ +export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/apps/edr-freight-api/src/modules/audit/audit.controller.ts b/apps/edr-freight-api/src/modules/audit/audit.controller.ts index 1a07a5fe5..2e08b2cd9 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.controller.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.controller.ts @@ -43,4 +43,13 @@ export class AuditController { types(): Promise { return this.auditService.listTypes(); } + + @Get('actions') + @BookingStaff(FREIGHT_PERMS.auditLog.view) + @ApiOperation({ + summary: 'Distinct action titles present in the audit log (filter dropdown)', + }) + actions(): Promise { + return this.auditService.listActions(); + } } diff --git a/apps/edr-freight-api/src/modules/audit/audit.service.ts b/apps/edr-freight-api/src/modules/audit/audit.service.ts index de7dfd956..9816d79f5 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.service.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.service.ts @@ -4,6 +4,10 @@ import { PaginatedResponse } from '@edr/types'; import { AuditLog } from './entities/audit-log.entity'; import { AuditLogRepository } from './audit-log.repository'; import { AuditLogQueryDto } from './dto/audit-log-query.dto'; +import { + AUDIT_REFERENCE_SOURCES, + UUID_PATTERN, +} from './audit-reference.registry'; import { buildPaginationMeta, normalizePagination, @@ -25,6 +29,7 @@ export class AuditService { */ async record(entry: Partial): Promise { try { + entry.reference = await this.resolveReference(entry.type, entry.resourceId); await this.auditLogRepository.record(entry); } catch (error) { this.logger.error( @@ -35,6 +40,34 @@ export class AuditService { } } + /** + * Best-effort human identifier (booking reference, train number, …) for the + * entity the action touched — one primary-key lookup against the table + * registered for the type. Always returns a string: '' when the type has no + * registered source, the id isn't a uuid (template codes), the row is gone, + * or the lookup itself fails. A missing reference must never cost the audit + * row, so failures degrade to '' rather than throwing. + */ + private async resolveReference( + type: string | undefined, + resourceId: string | null | undefined, + ): Promise { + const source = type ? AUDIT_REFERENCE_SOURCES[type] : undefined; + if (!source || !resourceId || !UUID_PATTERN.test(resourceId)) return ''; + + try { + const reference = await this.auditLogRepository.lookupReference(source, resourceId); + return reference?.slice(0, 64) ?? ''; + } catch (error) { + this.logger.warn( + `Reference lookup failed for ${type} ${resourceId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return ''; + } + } + /** Paginated, filtered audit history, newest first. */ async search(query: AuditLogQueryDto): Promise> { const { page, pageSize, skip, take } = normalizePagination(query); @@ -53,6 +86,10 @@ export class AuditService { userId: query.userId, method: query.method, resourceId: query.resourceId, + reference: query.reference, + userName: query.userName, + title: query.title, + q: query.q, isSuccess: query.isSuccess === undefined ? undefined : query.isSuccess === 'true', from, @@ -68,4 +105,9 @@ export class AuditService { async listTypes(): Promise { return this.auditLogRepository.distinctTypes(); } + + /** Distinct action titles, for the action filter dropdown. */ + async listActions(): Promise { + return this.auditLogRepository.distinctTitles(); + } } diff --git a/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts b/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts index 5a5199538..dbd41e14c 100644 --- a/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts +++ b/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts @@ -39,6 +39,44 @@ export class AuditLogQueryDto extends PaginationQueryDto { @MaxLength(64) resourceId?: string; + @ApiPropertyOptional({ + description: + 'Human identifier of the affected record — booking reference, schedule number, train number. Case-insensitive prefix match.', + example: 'S-2026-00045', + }) + @IsOptional() + @IsString() + @MaxLength(64) + reference?: string; + + @ApiPropertyOptional({ + description: 'Staff name, case-insensitive substring match.', + example: 'Mulu', + }) + @IsOptional() + @IsString() + @MaxLength(150) + userName?: string; + + @ApiPropertyOptional({ + description: 'Action title, case-insensitive substring match.', + example: 'Cancel booking', + }) + @IsOptional() + @IsString() + @MaxLength(255) + title?: string; + + @ApiPropertyOptional({ + description: + 'Free-text search across reference, resource id, staff name and action title.', + example: 'B-2026-00120', + }) + @IsOptional() + @IsString() + @MaxLength(100) + q?: string; + @ApiPropertyOptional({ description: 'Filter by outcome: true = succeeded, false = failed.', }) diff --git a/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts b/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts index 0ff7727ff..8261fc61c 100644 --- a/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts +++ b/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts @@ -86,6 +86,20 @@ export class AuditLog { @Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true }) resourceId?: string | null; + /** + * Human identifier of the affected record — booking reference, schedule + * number, train number — resolved at write time from + * `AUDIT_REFERENCE_SOURCES`. This is what staff type into the search box; + * `resourceId` stays the machine id. + * + * `''` (never NULL) when the entity type has no registered source, the + * lookup found nothing, or the row predates the column. Empty string keeps + * search SQL to one shape and matches how pre-existing rows read after the + * metadata-only migration. + */ + @Column({ name: 'reference', type: 'varchar', length: 64, default: '' }) + reference!: string; + /** * Sanitized request body. Secrets are replaced with `[REDACTED]` and uploads * are reduced to `{ __file, originalName, mimeType, size }` descriptors — diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts index 8babc8c2e..07d4c4e06 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts @@ -40,3 +40,70 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => { expect(cut.weightTons).toBeCloseTo(62.625, 3); }); }); + +/** + * Odd-20ft credit rebook: the rebooked booking shares a wagon again, so GL + * must pick the consolidation partner — no partner, no rebook; a partner + * already paired elsewhere is refused. + */ +describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () => { + const units = Array.from({ length: 3 }, (_, i) => ({ + containerSize: '20ft', + containerNumber: `CONT${i}`, + sealNumber: null, + vgmTons: 10, + isHazardous: false, + isReefer: false, + })); + const row = { + id: 'wc1', + bookingId: 'b1', + status: 'CREDIT_AVAILABLE', + creditAmount: 100, + cancelledQuantities: { bySize: { '20ft': 3 }, units }, + }; + const source = { + id: 'b1', + contractId: 'c1', + paymentCurrency: 'USD', + originYardId: 'y1', + destinationYardId: 'y2', + tradeDirection: 'IMPORT', + }; + + const makeSvc = (partner?: unknown) => { + const svc = Object.create(BookingWagonCancellationService.prototype) as Record< + string, + unknown + > & { + rebook(id: string, dto: unknown): Promise; + }; + svc.repo = { findById: async () => row }; + svc.bookingsRepository = { + findById: async () => source, + findByIdWithFiles: async () => partner ?? null, + }; + return svc; + }; + + it('refuses an odd-20ft rebook without a GL-picked partner', async () => { + await expect( + makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }), + ).rejects.toThrow(/pick a consolidation partner/i); + }); + + it('refuses a partner that already shares a wagon', async () => { + const paired = { + id: 'p1', + reference: 'BK-1', + status: 'SUBMITTED', + consolidationPartnerId: 'someone-else', + }; + await expect( + makeSvc(paired).rebook('wc1', { + scheduledDate: '2026-09-01', + partnerBookingId: 'p1', + }), + ).rejects.toThrow(/already shares a wagon/i); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index d3e3edfe5..17d1ba2dc 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -7,7 +7,7 @@ import { Logger, NotFoundException, } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; +import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; import { ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, In, IsNull } from 'typeorm'; @@ -126,6 +126,7 @@ export class BookingWagonCancellationService { @Inject(forwardRef(() => FirstMileService)) private readonly firstMile: FirstMileService, private readonly inbox: NotificationInboxService, + private readonly events: EventEmitter2, ) {} // ── T1: request ──────────────────────────────────────────────────────────── @@ -782,6 +783,26 @@ export class BookingWagonCancellationService { const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers); // Same currency as the source booking — the credit is in it. createDto.paymentCurrency = source.paymentCurrency ?? undefined; + + // An odd-20ft credit shares a wagon again on rebook. GL picks who — never + // the auto-matcher (it could claim a partner behind GL's back), so the + // create below runs with auto-consolidation off and the chosen partner is + // linked once the booking exists and is PAID. + const oddFt20 = this.creditFt20(row) % 2 === 1; + let partner: Booking | null = null; + if (oddFt20) { + createDto.skipAutoConsolidation = true; + if (!dto.partnerBookingId) { + throw new BadRequestException( + 'This credit carries an odd 20ft container — pick a consolidation partner booking to share its wagon (see the rebook-partners list).', + ); + } + partner = await this.loadRebookPartner( + source, + dto.partnerBookingId, + dto.scheduledDate, + ); + } const created = await this.contractBooking.createUnderContract( source.contractId, createDto, @@ -814,12 +835,19 @@ export class BookingWagonCancellationService { `First-mile accept failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`, ); } - try { - await this.bookingBatch.ensurePaidBookingAllocated(newBookingId); - } catch (err) { - this.logger.error( - `Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`, - ); + if (partner) { + // Consolidated rebook: never allocate the half-wagon booking alone. It + // rides PAID and the batch engine settles the pair atomically once the + // partner's own invoice is paid. + await this.pairRebookedBooking(newBookingId, partner); + } else { + try { + await this.bookingBatch.ensurePaidBookingAllocated(newBookingId); + } catch (err) { + this.logger.error( + `Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } } const updated = (await this.repo.update(row.id, { @@ -837,6 +865,142 @@ export class BookingWagonCancellationService { return { cancellation: updated, bookingId: newBookingId }; } + /** Total 20ft units the credit carries (odd ⇒ the rebook shares a wagon again). */ + private creditFt20(row: BookingWagonCancellation): number { + return Object.entries(row.cancelledQuantities?.bySize ?? {}) + .filter(([size]) => sizeFtOf(size) === 20) + .reduce((sum, [, qty]) => sum + Number(qty || 0), 0); + } + + /** + * Partner candidates for rebooking an odd-20ft credit — what the GL rebook + * form lists. Empty when the credit is even (no shared wagon) or spent. + */ + async rebookPartnerCandidates( + cancellationId: string, + scheduledDate: string, + ): Promise< + Array<{ + id: string; + reference: string; + companyName: string | null; + status: string; + scheduledDate: string | null; + ft20Quantity: number; + }> + > { + const row = await this.mustFind(cancellationId); + if (row.status !== 'CREDIT_AVAILABLE') return []; + if (this.creditFt20(row) % 2 === 0) return []; + const source = await this.bookingsRepository.findById(row.bookingId); + if (!source) return []; + const rows = await this.bookingsRepository.findRebookConsolidationCandidates( + source, + new Date(scheduledDate), + ); + return rows.map((b) => ({ + id: b.id, + reference: b.reference, + companyName: b.company?.name ?? null, + status: b.status, + scheduledDate: b.scheduledDate ? b.scheduledDate.toISOString() : null, + ft20Quantity: (b.bookingContainers ?? []) + .filter((line) => Number(line.containerType?.sizeFt) === 20) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0), + })); + } + + /** The GL-picked partner, validated to actually fit the rebooked shared wagon. */ + private async loadRebookPartner( + source: Booking, + partnerId: string, + scheduledDate: string, + ): Promise { + const partner = await this.bookingsRepository.findByIdWithFiles(partnerId); + if (!partner) { + throw new NotFoundException(`Partner booking ${partnerId} not found.`); + } + if (partner.consolidationPartnerId) { + throw new ConflictException( + `Booking ${partner.reference} already shares a wagon with another booking.`, + ); + } + if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) { + throw new BadRequestException( + `Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`, + ); + } + if ( + partner.originYardId !== source.originYardId || + partner.destinationYardId !== source.destinationYardId || + partner.tradeDirection !== source.tradeDirection + ) { + throw new BadRequestException( + `Booking ${partner.reference} rides a different route/direction — it cannot share a wagon with this rebooking.`, + ); + } + const eatDay = (d: Date | string) => + new Date(d).toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' }); + if (!partner.scheduledDate || eatDay(partner.scheduledDate) !== eatDay(scheduledDate)) { + throw new BadRequestException( + `Booking ${partner.reference} is not booked for ${eatDay(scheduledDate)} — a shared wagon must board one train.`, + ); + } + const ft20 = (partner.bookingContainers ?? []) + .filter((line) => Number(line.containerType?.sizeFt) === 20) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0); + if (ft20 % 2 !== 1) { + throw new BadRequestException( + `Booking ${partner.reference} has no odd 20ft container — nothing to consolidate.`, + ); + } + return partner; + } + + /** + * Link the rebooked (already PAID) booking with the GL-picked partner. A + * parked partner is resumed the way pairConsolidation would resume it — + * but only the partner: the rebooked side's PAID status must survive, so + * the link is written directly. The paired event then runs the partner's + * deferred contract finalize (invoice → pay window); the shared wagon + * boards once that invoice is paid. + */ + private async pairRebookedBooking( + newBookingId: string, + partner: Booking, + ): Promise { + // ponytail: validate-then-link without a row lock — a concurrent claim in + // this window loses silently; move to pairConsolidationIfUnpaired-style + // locking if it ever bites. + const fresh = await this.dataSource.getRepository(Booking).findOne({ + where: { id: partner.id }, + select: { id: true, consolidationPartnerId: true, status: true }, + }); + if (!fresh || fresh.consolidationPartnerId) { + throw new ConflictException( + `Booking ${partner.reference} was claimed by another consolidation while rebooking — pick another partner.`, + ); + } + if (fresh.status === 'PENDING_CONSOLIDATION') { + await this.dataSource.getRepository(Booking).update(partner.id, { + status: partner.consolidationResumeStatus ?? 'SUBMITTED', + consolidationResumeStatus: null, + }); + } + await this.bookingsRepository.linkConsolidationPartners( + newBookingId, + partner.id, + ); + this.events.emit('booking.consolidation.paired', { + bookingIds: [partner.id], + }); + this.notifyCustomer( + partner, + 'Consolidation partner found', + `${partner.reference} now shares a wagon with a rebooked shipment. Pay your booking to board — the shared wagon ships once both halves are paid.`, + ); + } + // ── History ──────────────────────────────────────────────────────────────── list(filter: WagonCancellationListFilter) { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 04ebc2d41..c76655240 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -725,6 +725,30 @@ export class BookingsController { return this.wagonCancellationService.withdraw(cancellationId); } + @Get("wagon-cancellations/:cancellationId/rebook-partners") + @ApiOperation({ + summary: + "Consolidation partner candidates for rebooking an odd-20ft credit on the given day (GL picks who shares the rebooked wagon)", + }) + async listRebookPartners( + @Param("cancellationId", ParseUUIDPipe) cancellationId: string, + @Query("scheduledDate") scheduledDate: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertWagonCancellationActor( + cancellationId, + user, + FREIGHT_PERMS.bookings.wagonCancellationRebook, + ); + if (!scheduledDate) { + throw new BadRequestException("scheduledDate is required."); + } + return this.wagonCancellationService.rebookPartnerCandidates( + cancellationId, + scheduledDate, + ); + } + @Post("wagon-cancellations/:cancellationId/rebook") @ApiOperation({ summary: diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index ff0888bef..885486031 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -377,6 +377,58 @@ export class BookingsRepository extends BaseRepository { }); } + /** + * Candidate partners for rebooking an odd-20ft cancellation credit: unpaired + * odd-20ft bookings on the same route/direction riding the requested day — + * SUBMITTED (committed direct booking) or parked PENDING_CONSOLIDATION. + * Unlike {@link findManualConsolidationCandidates} this is not customs-only: + * GL picks who shares the rebooked wagon whatever the contract kind. + */ + async findRebookConsolidationCandidates( + booking: Booking, + scheduledDate: Date, + limit = 50, + ): Promise { + const rows = await this.repository + .createQueryBuilder('b') + .leftJoinAndSelect('b.bookingContainers', 'bc') + .leftJoinAndSelect('bc.containerType', 'ct') + .leftJoinAndSelect('b.company', 'company') + .where('b.id != :bookingId', { bookingId: booking.id }) + .andWhere('b.consolidationPartnerId IS NULL') + .andWhere('b.originYardId = :originYardId', { + originYardId: booking.originYardId, + }) + .andWhere('b.destinationYardId = :destinationYardId', { + destinationYardId: booking.destinationYardId, + }) + .andWhere('b.tradeDirection = :tradeDirection', { + tradeDirection: booking.tradeDirection, + }) + .andWhere('b.status IN (:...statuses)', { + statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'], + }) + // Same EAT booking day as the rebook — the pair shares one physical + // wagon, so it must board one train. + .andWhere( + `DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`, + { bookingDate: scheduledDate }, + ) + .orderBy('b.createdAt', 'ASC') + .take(limit) + .getMany(); + + // Odd-20ft test in memory (two 20ft per wagon: odd + odd = whole wagons). + return rows.filter((row) => { + const lines = row.bookingContainers ?? []; + if (lines.length === 0) return false; + const ft20 = lines + .filter((line) => Number(line.containerType?.sizeFt) === 20) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0); + return ft20 % 2 === 1; + }); + } + /** * Find another booking whose container quantity complements this one to fill whole wagon(s) * (same route, same container type, partial wagon on both sides). Only 20ft lines ever diff --git a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts index 624762fac..631e78109 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts @@ -121,6 +121,15 @@ export class RebookCancelledWagonsDto { @ValidateNested({ each: true }) @Type(() => RebookContainerLineDto) containers?: RebookContainerLineDto[]; + + @ApiPropertyOptional({ + description: + 'Required when the credit carries an odd 20ft count: the odd-20ft booking ' + + 'GL picked to share the rebooked wagon (see the rebook-partners endpoint).', + }) + @IsOptional() + @IsUUID() + partnerBookingId?: string; } export class FilterWagonCancellationsDto { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index bdb8d74cd..565164995 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -227,3 +227,85 @@ describe('ContractBookingService — quantity-cap completion', () => { }); }); }); + +/** + * The customer's shipment request is the order: GL may not change its container + * sizes/quantities or billing currency at completion — only per-unit details. + */ +describe('ContractBookingService — shipment-request lock at completion', () => { + type WithAssert = { + assertMatchesShipmentRequest( + bookingId: string, + dto: { + paymentCurrency?: string; + containers?: Array<{ containerSize: string; quantity: number }>; + bulkLines?: Array<{ cargoWeightTons?: number }>; + }, + ): Promise; + }; + + const serviceWithRequest = (request: unknown): WithAssert => { + const svc = Object.create(ContractBookingService.prototype) as WithAssert & { + dataSource: unknown; + }; + svc.dataSource = { + getRepository: () => ({ findOne: async () => request }), + }; + return svc; + }; + + const request = { + paymentCurrency: 'USD', + requestedLines: { + containers: [ + { containerSize: '20ft', quantity: 2 }, + { containerSize: '40ft', quantity: 1 }, + ], + }, + }; + + it('accepts the exact requested quantities and currency', async () => { + await expect( + serviceWithRequest(request).assertMatchesShipmentRequest('b1', { + paymentCurrency: 'USD', + containers: [ + { containerSize: '40ft', quantity: 1 }, + { containerSize: '20ft', quantity: 2 }, + ], + }), + ).resolves.toBeUndefined(); + }); + + it('rejects changed quantities', async () => { + await expect( + serviceWithRequest(request).assertMatchesShipmentRequest('b1', { + paymentCurrency: 'USD', + containers: [ + { containerSize: '20ft', quantity: 4 }, + { containerSize: '40ft', quantity: 1 }, + ], + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects a changed billing currency', async () => { + await expect( + serviceWithRequest(request).assertMatchesShipmentRequest('b1', { + paymentCurrency: 'ETB', + containers: [ + { containerSize: '20ft', quantity: 2 }, + { containerSize: '40ft', quantity: 1 }, + ], + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('is a no-op without a linked request', async () => { + await expect( + serviceWithRequest(null).assertMatchesShipmentRequest('b1', { + paymentCurrency: 'ETB', + containers: [{ containerSize: '20ft', quantity: 9 }], + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 313364d8c..051d0eabe 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -36,6 +36,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { hasFreightPermission } from '../../common/freight-permission.util'; +import { BookingRequest } from './entities/booking-request.entity'; import { Contract } from './entities/contract.entity'; import { ContractRoute } from './entities/contract-route.entity'; import { @@ -395,6 +396,10 @@ export class ContractBookingService { if ( withContainers && freightType === 'CONTAINER' && + // A rebooked cancellation credit carries `skipAutoConsolidation`: its + // shared-wagon partner is picked by GL in the rebook flow, so nothing may + // auto-claim (or park) it here behind GL's back. + !dto.skipAutoConsolidation && (await this.consolidationService.needsConsolidationFromBooking( withContainers, )) @@ -863,6 +868,12 @@ export class ContractBookingService { direction: contract.tradeDirection ?? null, }); + // The customer's shipment request is the order: sizes, quantities and + // billing currency are theirs — GL enters everything else. Both halves of a + // consolidated pair pass through here, so each is checked against its OWN + // request. + await this.assertMatchesShipmentRequest(booking.id, dto); + const freightType = contract.freightType; let hasCargo = (booking.bookingContainers?.length ?? 0) > 0 || @@ -1052,6 +1063,72 @@ export class ContractBookingService { return { booking: completed, warnings }; } + /** + * The linked shipment request (customs Path B) is the customer's order: + * container sizes + quantities and the billing currency are the customer's + * choices, and GL may not change them at completion — only per-unit details + * (numbers, seals, VGM, handling) are GL's to enter. No linked request, or a + * legacy request without lines/currency ⇒ nothing to enforce. Container lines + * are checked only when the payload restates cargo (a day-only resubmit keeps + * the already-validated persisted cargo). + */ + private async assertMatchesShipmentRequest( + bookingId: string, + dto: CreateBookingUnderContractDto, + ): Promise { + const request = await this.dataSource.getRepository(BookingRequest).findOne({ + where: { createdBookingId: bookingId }, + }); + if (!request) return; + const lines = request.requestedLines ?? {}; + + if (request.paymentCurrency) { + if (dto.paymentCurrency && dto.paymentCurrency !== request.paymentCurrency) { + throw new BadRequestException( + `The customer chose ${request.paymentCurrency} on the shipment request — the billing currency cannot be changed.`, + ); + } + dto.paymentCurrency = request.paymentCurrency; + } + + if (dto.containers?.length && lines.containers?.length) { + // Compare per size in ft ("20ft" vs "20FT"/"20" spellings must not differ). + const byFt = (rows: Array<{ containerSize: string; quantity: number }>) => { + const map = new Map(); + for (const row of rows) { + const ft = parseInt(String(row.containerSize), 10); + map.set(ft, (map.get(ft) ?? 0) + Number(row.quantity || 0)); + } + return map; + }; + const requested = byFt(lines.containers); + const given = byFt(dto.containers); + const same = + requested.size === given.size && + [...requested].every(([ft, qty]) => given.get(ft) === qty); + if (!same) { + const summary = [...requested] + .map(([ft, qty]) => `${qty} × ${ft}ft`) + .join(', '); + throw new BadRequestException( + `The customer requested exactly ${summary} — container sizes and quantities cannot be changed at completion.`, + ); + } + } + + if (dto.bulkLines?.length && lines.bulk?.cargoWeightTons != null) { + const givenTons = dto.bulkLines.reduce( + (sum, l) => sum + Number(l.cargoWeightTons || 0), + 0, + ); + if (givenTons !== Number(lines.bulk.cargoWeightTons)) { + throw new BadRequestException( + `The customer requested ${lines.bulk.cargoWeightTons} tons on the shipment request — the bulk quantity cannot be changed at completion.`, + ); + } + } + } + /** * Search for a complementary partner for a parked-eligible drawdown, pair it or * park it in PENDING_CONSOLIDATION with the resume status it should return to. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index e9a747784..494522595 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -566,8 +566,9 @@ export class TrainSchedulingController { dispatchSchedule( @Param("id", ParseUUIDPipe) id: string, @Body() dto: DispatchScheduleDto, + @CurrentUser() user: AuthUserPayload, ) { - return this.trainSchedulingService.dispatchSchedule(id, dto); + return this.trainSchedulingService.dispatchSchedule(id, dto, resolveAuthUserId(user)); } @Get("intercity/bookings") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts index 1495185f0..f190a60b9 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts @@ -1,11 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { TrainCheckpointKind } from '@edr/types'; import { + IsArray, IsEnum, IsInt, IsISO8601, IsOptional, IsString, + IsUUID, MaxLength, Min, } from 'class-validator'; @@ -67,4 +69,20 @@ export class DispatchScheduleDto { @IsOptional() @IsISO8601() actualDepartureAt?: string; + + /** + * Loading is a manual staff decision. When present, only these bookings are + * auto-loaded at the origin; every other unloaded origin boarder is left + * behind — deallocated from its wagon and returned to the booking pool. + * Absent (older clients) = load every origin boarder, the historic behavior. + */ + @ApiProperty({ + required: false, + description: + 'Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.', + }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + loadedBookingIds?: string[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 5ac6ed0bb..0fd0304b3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -2360,16 +2360,22 @@ export class TrainSchedulingService { if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } - if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { - throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule'); - } - const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId); if (!link) { throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`); } const booking = await this.bookingsRepository.findById(bookingId); + // A dispatched train may still shed a booking staff left behind at its + // boarding yard (dispatch dialog / log-pass "leave") — but never one whose + // cargo is actually on the train. + const leftBehindWhileDispatched = + schedule.status === 'DISPATCHED' && + !booking?.loadedAt && + booking?.status !== 'IN_TRANSIT'; + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status) && !leftBehindWhileDispatched) { + throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule'); + } if (booking?.isGovernment) { throw new BadRequestException( 'Government bookings cannot be removed from a train. They can only be switched onto another allocation.', @@ -2437,6 +2443,21 @@ export class TrainSchedulingService { for (const slot of survivingSlots) { const slotAllocations = slot.allocations ?? []; if (slotAllocations.length === 0) { + // A dispatched train pinned its wagons (ASSIGNED + schedule id) at + // departure — freeing the slot must also free the physical wagon, or + // the checkpoint position-fix keeps dragging it along the corridor. + if (schedule.status === 'DISPATCHED' && slot.physicalWagonId) { + const wagon = await manager + .getRepository(Wagon) + .findOne({ where: { id: slot.physicalWagonId } }); + if (wagon && wagon.currentTrainScheduleId === scheduleId) { + await manager.getRepository(Wagon).update(wagon.id, { + currentTrainScheduleId: null, + trainSetWagonId: null, + status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + }); + } + } await manager.getRepository(TrainSetWagon).delete(slot.id); continue; } @@ -2466,8 +2487,10 @@ export class TrainSchedulingService { // Freed wagons may un-full the train — re-derive the window status (this // also revives a DONE window pre-departure so the freed space is bookable - // again for import/export). - await this.bookingBatchService?.refreshWindowStatus(scheduleId); + // again for import/export). A dispatched train's window stays CLOSED. + if (schedule.status !== 'DISPATCHED') { + await this.bookingBatchService?.refreshWindowStatus(scheduleId); + } await this.trainCompositionRemovalLogRepository.create({ scheduleId, @@ -2832,14 +2855,35 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } - async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}, userId?: string) { + let schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } if (schedule.status !== TrainScheduleStatusEnum.Scheduled) { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } + // Loading is a manual staff decision: when the dispatch dialog sends the + // checked list, every other unloaded origin boarder is left behind — + // deallocated from its wagon and returned to the booking pool — so the + // origin auto-load below only ever touches confirmed cargo. Government + // bookings cannot be unassigned and keep the historic auto-load. + if (dto.loadedBookingIds) { + const keep = new Set(dto.loadedBookingIds); + const candidates = await this.unloadedOriginBoarderIds(scheduleId, schedule.originStationId); + const leftBehind = candidates.filter((id) => !keep.has(id)); + for (const bookingId of leftBehind) { + await this.unassignBooking(scheduleId, bookingId, userId); + } + if (leftBehind.length) { + // Unassign deleted allocations and slots — reload the graph dispatch works on. + const reloaded = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!reloaded) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + schedule = reloaded; + } + } // Staff may record the departure after the fact — past is fine, future is not. const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date(); this.assertNotFuture(now, 'Departure time'); @@ -3061,6 +3105,34 @@ export class TrainSchedulingService { }); } + /** + * Origin boarders the dispatch dialog decides over: unloaded (no journey + * load, no workspace LOADED flag), boardable, non-government. Boardable is + * PAID — or FULLY_EXECUTED for shipping-line bookings, which never prepay + * (their charge sits on the credit ledger) yet ride from accept. + */ + private async unloadedOriginBoarderIds( + scheduleId: string, + originYardId: string, + ): Promise { + const rows: Array<{ id: string }> = await this.dataSource.query( + `SELECT b.id + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id + WHERE tsb.train_schedule_id = $1 + AND tsb.deleted_at IS NULL + AND b.deleted_at IS NULL + AND b.origin_yard_id = $2 + AND b.loaded_at IS NULL + AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED' + AND b.is_government = false + AND (b.status = 'PAID' + OR (b.shipping_line_company_id IS NOT NULL AND b.status = 'FULLY_EXECUTED'))`, + [scheduleId, originYardId], + ); + return rows.map((r) => r.id); + } + async getImportDjiboutiOperation(scheduleId: string) { const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); @@ -9798,6 +9870,9 @@ export class TrainSchedulingService { loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded, wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId), isGovernment: Boolean(sb.booking?.isGovernment), + // Shipping-line bookings never prepay (credit ledger) — the dispatch + // dialog needs this to know FULLY_EXECUTED means boardable for them. + shippingLineCompanyId: sb.booking?.shippingLineCompanyId ?? null, })) ?? [], // Ordered corridor stops (route milestones; falls back to the two // endpoints) — lets the UI draw per-segment occupancy and label legs. diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index e208328cc..0ca021937 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -88,6 +88,7 @@ import { import { ConsolidationPartnerPanel, emptyPartnerLine, + emptyPartnerUnit, } from "./gl-booking-form/ConsolidationPartnerPanel"; import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker"; @@ -250,6 +251,17 @@ export default function GlCreateBookingForm() { enabled: Boolean(requestId), }); + // The shipment request is the customer's order: container sizes/quantities + // and the billing currency are the customer's choices and stay read-only — + // GL enters only per-unit details (numbers, seals, VGM, handling). The + // server enforces the same on completion. + const requestContainersLocked = Boolean( + bookingRequest?.requestedLines?.containers?.length, + ); + const requestBulkLocked = + bookingRequest?.requestedLines?.bulk?.cargoWeightTons != null; + const requestCurrencyLocked = Boolean(bookingRequest?.paymentCurrency); + // The expired booking a Rebook is copying from (its cargo seeds the form). const { data: copyFromBooking } = useQuery({ queryKey: ["rebook-copy-from", copyFromParam], @@ -328,6 +340,39 @@ export default function GlCreateBookingForm() { const [partner, setPartner] = useState(null); const [partnerLines, setPartnerLines] = useState([]); const [partnerCargoDescription, setPartnerCargoDescription] = useState(""); + + // The partner is its own customer: if a shipment request created it, that + // request locks the partner's quantities and billing currency the same way + // this booking's request locks this side (server enforces both halves). + const { data: partnerContractRequests } = useQuery({ + queryKey: ["shipment-requests-for-contract", partner?.contractId], + queryFn: () => contractsService.listBookingRequests(partner!.contractId!), + enabled: Boolean(partner?.contractId), + }); + const partnerRequest = + (partner && + partnerContractRequests?.find( + (r) => r.createdBookingId === partner.id, + )) || + null; + const partnerLocked = Boolean(partnerRequest?.requestedLines?.containers?.length); + + // Seed (and lock) the partner's lines from its request once it loads. + useEffect(() => { + const requested = partnerRequest?.requestedLines?.containers; + if (!partner || !requested?.length) return; + setPartnerLines( + requested.map((c) => ({ + containerSize: c.containerSize, + quantity: String(Math.max(1, c.quantity)), + hazardousQuantity: "0", + reeferQuantity: "0", + returnQuantity: "0", + units: Array.from({ length: Math.max(1, c.quantity) }, emptyPartnerUnit), + })), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [partner?.id, partnerRequest?.id]); const seededRef = useRef(false); const returnSeededRef = useRef(false); @@ -490,6 +535,14 @@ export default function GlCreateBookingForm() { if (bookingRequest.contractRouteId) setContractRouteId(bookingRequest.contractRouteId); if (bookingRequest.notes) setNotes(bookingRequest.notes); + // Currency is the customer's choice on the request — seed it here; the + // selector below is disabled while the request specifies one. + if ( + bookingRequest.paymentCurrency === "USD" || + bookingRequest.paymentCurrency === "ETB" + ) { + setPaymentCurrency(bookingRequest.paymentCurrency); + } }, [bookingRequest, prefilled]); // Rebook seed: copy the source booking's container lines once. (Bulk weight / @@ -1114,7 +1167,13 @@ export default function GlCreateBookingForm() { if (!partner || !consolidationActive) return null; const payload: Freight.CreateBookingUnderContractDto = { - paymentCurrency: effectiveCurrency, + // The partner's customer chose its own currency on its shipment request; + // only a partner without a request falls back to this booking's currency. + paymentCurrency: + partnerRequest?.paymentCurrency === "USD" || + partnerRequest?.paymentCurrency === "ETB" + ? partnerRequest.paymentCurrency + : effectiveCurrency, ...(scheduledDate ? { scheduledDate: new Date(scheduledDate).toISOString() } : {}), @@ -1663,6 +1722,12 @@ export default function GlCreateBookingForm() { label="Quantity *" min={0} value={line.quantity} + disabled={requestContainersLocked} + description={ + requestContainersLocked + ? "Requested by the customer — quantity cannot be changed." + : undefined + } error={ showErrors ? (lineErrors[lineIdx]?.quantity ?? @@ -1924,6 +1989,7 @@ export default function GlCreateBookingForm() { showReefer={Boolean(contract.isReefer)} showErrors={showErrors} error={partnerError} + lockQuantities={partnerLocked} /> ) : null} @@ -1946,6 +2012,12 @@ export default function GlCreateBookingForm() { placeholder="e.g. 1200" min={0} step={0.01} + disabled={requestBulkLocked} + description={ + requestBulkLocked + ? "Requested by the customer — quantity cannot be changed." + : undefined + } value={bulk.cargoWeightTons} error={ showErrors && bulkUom === "PER_TON" @@ -2147,14 +2219,16 @@ export default function GlCreateBookingForm() { Billing currency - {isImport - ? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." - : "Shipments are invoiced in ETB."} + {requestCurrencyLocked + ? "The customer chose the billing currency on the shipment request — it cannot be changed." + : isImport + ? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." + : "Shipments are invoiced in ETB."} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx index e52cce097..a3be22ea1 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx @@ -86,6 +86,11 @@ interface Props { /** Surface field errors only after the operator tried to continue. */ showErrors: boolean; error?: string; + /** + * The partner's shipment request fixed its sizes/quantities — the quantity + * fields render read-only and GL enters only per-unit details. + */ + lockQuantities?: boolean; } export function ConsolidationPartnerPanel({ @@ -97,6 +102,7 @@ export function ConsolidationPartnerPanel({ showReefer, showErrors, error, + lockQuantities, }: Props) { const patchLine = (index: number, patch: Partial) => { onLinesChange( @@ -149,6 +155,12 @@ export function ConsolidationPartnerPanel({ label="Quantity *" min={0} value={line.quantity} + disabled={lockQuantities} + description={ + lockQuantities + ? "Requested by the partner's customer — quantity cannot be changed." + : undefined + } onChange={(e) => patchLine(lineIdx, { quantity: e.currentTarget.value })} // Sync off the typed value, not the captured `line` — that snapshot // still holds the pre-edit quantity and would write it back. diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx index c35e87a5a..b2de28f3b 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx @@ -115,6 +115,7 @@ export function LogPassYardWorkModal({ const { toast } = useToast(); const { user } = useAuth(); const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load); + const canLeave = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.update); const [justLogged, setJustLogged] = useState(false); // When the train was here — defaults to now, past allowed (recorded after the fact). const [passAt, setPassAt] = useState(null); @@ -134,6 +135,10 @@ export function LogPassYardWorkModal({ api.trainScheduling.recordCheckpoint.mutationOptions(), ); const load = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions()); + // "Leave behind": the cargo is not on the train — unassign frees its wagons + // and returns the booking to the pool for a later schedule. Reversible (the + // booking can be re-assigned), so no extra confirm step. + const leave = useMutation(api.trainScheduling.unassignBooking.mutationOptions()); const yard = yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId); const boarders: YardWorkBookingRow[] = yard?.toLoad ?? []; @@ -196,6 +201,28 @@ export function LogPassYardWorkModal({ ); }; + const doLeave = (row: YardWorkBookingRow) => { + leave.mutate( + { id: scheduleId, bookingId: row.id }, + { + onSuccess: () => { + toast({ + title: `${row.reference ?? "Booking"} left behind`, + description: + "Removed from this train — wagons freed, booking returned to the pool for a later schedule.", + }); + void yardWorkQuery.refetch(); + }, + onError: (err) => + toast({ + title: "Could not leave booking behind", + description: parseError(err, "Please try again"), + variant: "destructive", + }), + }, + ); + }; + const hasWork = boarders.length > 0 || arrivals.length > 0; return ( @@ -355,30 +382,54 @@ export function LogPassYardWorkModal({ {!row.loadedAt ? ( - - - + + + + + + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx index eac18ca2a..83a20d20f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx @@ -1,7 +1,9 @@ import { useMemo, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { Badge, + Button, Card, Code, Group, @@ -42,6 +44,26 @@ const OUTCOME_OPTIONS = [ { value: "false", label: "Failed" }, ]; +/** + * Entity type → detail page for that record. Drives the row's "Go" button; + * types without a detail page (Wagon, Locomotive, …) simply have no button. + */ +const ENTITY_ROUTES: Record string> = { + Booking: (id) => `/dashboard/booking-requests/${id}`, + Contract: (id) => `/dashboard/contract-requests/${id}`, + Schedule: (id) => `/dashboard/operations/train-scheduling-v2/${id}`, + "Train Schedule": (id) => `/dashboard/operations/train-scheduling-v2/${id}`, + Train: (id) => `/dashboard/trains/${id}`, + "Train Build": (id) => `/dashboard/trains/${id}`, + "EIMS Invoice": (id) => `/dashboard/invoices/${id}`, + Payment: (id) => `/dashboard/invoices/${id}`, + Vehicle: (id) => `/dashboard/vehicles/${id}`, + Company: (id) => `/dashboard/customers/${id}`, +}; + +const entityRoute = (log: AuditLog): string | null => + log.resourceId ? (ENTITY_ROUTES[log.type]?.(log.resourceId) ?? null) : null; + /** `YYYY-MM-DD` → inclusive ISO bounds, so a single day covers its full range. */ const startOfDay = (date: string) => `${date}T00:00:00.000Z`; const endOfDay = (date: string) => `${date}T23:59:59.999Z`; @@ -49,13 +71,20 @@ const endOfDay = (date: string) => `${date}T23:59:59.999Z`; const formatTimestamp = (value: string) => new Date(value).toLocaleString(); const AuditLogsPage = () => { + const navigate = useNavigate(); + // Entity pages deep-link here as /dashboard/audit-logs?type=Booking&resourceId= + // to show one record's full history with the filters already applied. + const [searchParams] = useSearchParams(); + // Server-side filters. Unlike most freight lists (which filter an // already-fetched array via useListControls), audit_logs is append-only and // grows without bound, so filtering and paging both happen in the API. - const [search, setSearch] = useState(""); + const [search, setSearch] = useState(searchParams.get("q") ?? ""); const [dateFrom, setDateFrom] = useState(null); const [dateTo, setDateTo] = useState(null); - const [type, setType] = useState(null); + const [type, setType] = useState(searchParams.get("type")); + const [resourceId] = useState(searchParams.get("resourceId")); + const [action, setAction] = useState(null); const [method, setMethod] = useState(null); const [outcome, setOutcome] = useState(null); const [selected, setSelected] = useState(null); @@ -69,13 +98,16 @@ const AuditLogsPage = () => { type: type ?? undefined, method: (method as AuditMethod | null) ?? undefined, isSuccess: outcome === null ? undefined : outcome === "true", - // The API filters by record id; the search box is the natural place to - // paste one when tracing what happened to a specific contract/booking. - resourceId: search.trim() || undefined, + // Free-text: matches reference (booking/schedule/train number), record + // id, staff name and action title server-side. + q: search.trim() || undefined, + title: action ?? undefined, + // Set only via deep link from an entity page's "History" button. + resourceId: resourceId ?? undefined, from: dateFrom ? startOfDay(dateFrom) : undefined, to: dateTo ? endOfDay(dateTo) : undefined, }), - [pagination, type, method, outcome, search, dateFrom, dateTo], + [pagination, type, method, outcome, search, action, resourceId, dateFrom, dateTo], ); const logsQuery = useQuery({ @@ -88,12 +120,17 @@ const AuditLogsPage = () => { queryFn: () => auditLogsService.types(), }); + const actionsQuery = useQuery({ + queryKey: ["audit-logs", "actions"], + queryFn: () => auditLogsService.actions(), + }); + const rows = logsQuery.data?.items ?? []; const totalCount = logsQuery.data?.meta.total ?? 0; const pageCount = logsQuery.data?.meta.totalPages ?? 0; const hasFilters = Boolean( - search || dateFrom || dateTo || type || method || outcome, + search || dateFrom || dateTo || type || action || method || outcome, ); const resetFilters = () => { @@ -101,6 +138,7 @@ const AuditLogsPage = () => { setDateFrom(null); setDateTo(null); setType(null); + setAction(null); setMethod(null); setOutcome(null); setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); @@ -135,7 +173,7 @@ const AuditLogsPage = () => { { searchable w={200} /> + { Action Entity + Reference Method User Outcome When + @@ -214,6 +264,11 @@ const AuditLogsPage = () => { {log.type} + + + {log.reference || "—"} + + {log.method} @@ -250,6 +305,21 @@ const AuditLogsPage = () => { {formatTimestamp(log.createdAt)} + + {entityRoute(log) ? ( + + ) : null} + ))} @@ -277,6 +347,7 @@ const AuditLogsPage = () => { + diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx index 3aa6a785b..4ca8100a2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx @@ -74,6 +74,23 @@ interface WagonCancellation { }; } +interface RebookPartnerCandidate { + id: string; + reference: string; + companyName: string | null; + status: string; + scheduledDate: string | null; + ft20Quantity: number; +} + +/** Odd 20ft in the credit ⇒ the rebooked booking shares a wagon and GL must pick the partner. */ +const hasOddFt20 = (r: WagonCancellation): boolean => + Object.entries(r.cancelledQuantities?.bySize ?? {}) + .filter(([size]) => parseInt(size, 10) === 20) + .reduce((sum, [, qty]) => sum + Number(qty || 0), 0) % + 2 === + 1; + /** Editable rebook unit — prefilled from the cancelled snapshot. */ interface RebookUnitDraft { containerSize: string; @@ -147,10 +164,12 @@ export default function WagonCancellationsPage() { ); const [rebooking, setRebooking] = useState(null); const [rebookDate, setRebookDate] = useState(null); + const [rebookPartnerId, setRebookPartnerId] = useState(null); const [rebookDrafts, setRebookDrafts] = useState([]); const openRebook = (r: WagonCancellation) => { setRebooking(r); setRebookDate(null); + setRebookPartnerId(null); setRebookDrafts( (r.cancelledQuantities?.units ?? []).map((u) => ({ containerSize: u.containerSize, @@ -179,9 +198,30 @@ export default function WagonCancellationsPage() { api.post(`/bookings/wagon-cancellations/${rebooking!.id}/rebook`, { scheduledDate: toDayString(rebookDate!), ...(rebookDrafts.length ? { containers: rebookContainersPayload() } : {}), + ...(rebookPartnerId ? { partnerBookingId: rebookPartnerId } : {}), }), }); + // Odd-20ft credit: the rebooked booking shares a wagon again, so GL must pick + // the odd partner booking riding the chosen day. It ships once that partner pays. + const rebookNeedsPartner = rebooking ? hasOddFt20(rebooking) : false; + const rebookPartners = useQuery({ + queryKey: [ + "wagon-cancellations", + rebooking?.id, + "rebook-partners", + rebookDate ? toDayString(rebookDate) : null, + ], + enabled: Boolean(rebooking && rebookNeedsPartner && rebookDate), + queryFn: async () => { + const res = await api.get( + `/bookings/wagon-cancellations/${rebooking!.id}/rebook-partners`, + { params: { scheduledDate: toDayString(rebookDate!) } }, + ); + return res.data; + }, + }); + const resetPage = () => setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); @@ -301,11 +341,12 @@ export default function WagonCancellationsPage() { const r = row.original; const showVoid = r.status === "FEE_PENDING" && canVoid; // Customs credits are GL's to rebook; non-customs ones the customer - // rebooks from the portal. + // rebooks from the portal — EXCEPT odd-20ft credits: those must be + // re-paired with a partner booking, which only GL can pick. const showRebook = r.status === "CREDIT_AVAILABLE" && canRebook && - Boolean(r.booking?.customsClearingEnabled) && + (Boolean(r.booking?.customsClearingEnabled) || hasOddFt20(r)) && Number(r.creditAmount) > 0; if (!showVoid && !showRebook) return null; return ( @@ -495,9 +536,43 @@ export default function WagonCancellationsPage() { label="Shipment day" placeholder="Pick the day" value={rebookDate} - onChange={(v) => setRebookDate(v ? new Date(v) : null)} + onChange={(v) => { + setRebookDate(v ? new Date(v) : null); + setRebookPartnerId(null); + }} radius="md" /> + {rebookNeedsPartner && ( +