From e59b79747754e8eeecfade076f0a6b8b0ffc054d Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Fri, 17 Jul 2026 00:49:35 +0300 Subject: [PATCH 01/10] Minor updates --- apps/edr-passenger-api/src/main.ts | 2 +- apps/edr-passenger-web/backoffice/src/app/login/page.tsx | 2 +- apps/edr-passenger-web/backoffice/src/app/settings/page.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index f48a228d9..486094c3f 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -322,7 +322,7 @@ Payment providers send notifications to: - \`POST /payments/webhooks/card\` (International) ## Support -- **Email:** support@edr-platform.com +- **Email:** edr_@edrsc.com - **Documentation:** https://docs.edr-platform.com - **Status Page:** https://status.edr-platform.com `, diff --git a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx index c0d84cae8..4f0098792 100644 --- a/apps/edr-passenger-web/backoffice/src/app/login/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/login/page.tsx @@ -365,7 +365,7 @@ export default function LoginPage() { Back-office · v1.0 - Need help? support@edr.com + Need help? edr_@edrsc.com diff --git a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx index 3f0a266fd..873e81578 100644 --- a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx @@ -94,11 +94,11 @@ export default function SettingsPage() {
- +
- +
From 2366b28610f496e4abf2e174751bdd91342b4bc6 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Fri, 17 Jul 2026 08:42:42 +0300 Subject: [PATCH 02/10] remove the cron job --- .../src/modules/tasks/tasks.service.ts | 433 ------------------ 1 file changed, 433 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index ab275ee09..4fb3a4f0f 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -1,6 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; -import { SeatStatus } from '@prisma/client'; import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { CurrencyService } from '../currency/currency.service'; @@ -254,438 +253,6 @@ export class TasksService { } } - // ───────────────────────────────────────────────────────────────────────── - // Every 1 min: detect and resolve duplicate seat assignments. - // - // Root cause: a stale RabbitMQ message, delivered after system recovery, - // re-confirmed a cancelled booking whose seat had already been assigned to - // a new booking — leaving two CONFIRMED bookings holding the same seat on - // the same schedule. - // - // Resolution (FCFS): - // • Earliest confirmed booking keeps the original seat. - // • All later duplicates are reassigned to the next free seat within the - // SAME coach type (same coach preferred; any coach of same type as - // fallback). - // • If no seat is available in that coach type the booking is flagged for - // manual intervention and logged as unresolved. - // - // Idempotent: after reassignment the BookingSeat/JourneySegment rows no - // longer share the same (seatId, scheduleId) key, so the next tick finds - // nothing to do for the same pair. - // - // Scope: only schedules departing in the last 24 h or in the future, to - // keep the per-tick DB scan bounded. - // ───────────────────────────────────────────────────────────────────────── - @Cron('*/1 * * * *') - async resolveDuplicateSeatAssignments() { - const BATCH_SIZE = 20; - const since = new Date(Date.now() - 24 * 60 * 60 * 1000); - - // Fetch all BookingSeat rows for CONFIRMED bookings on upcoming/recent schedules. - const confirmedSeats = await this.prisma.bookingSeat.findMany({ - where: { - booking: { - status: 'CONFIRMED', - schedule: { departureAt: { gte: since } }, - }, - }, - include: { - booking: { - select: { - id: true, - bookingRef: true, - scheduleId: true, - createdAt: true, - contactPhone: true, - schedule: { - include: { - originStation: { select: { name: true } }, - destinationStation: { select: { name: true } }, - }, - }, - }, - }, - seat: { - include: { - coach: { - include: { coachType: { select: { id: true, name: true } } }, - }, - }, - }, - }, - }); - - // Group by (seatId, scheduleId). BookingSeat.scheduleId is per-leg for - // round-trips; fall back to Booking.scheduleId for single-leg bookings. - const groups = new Map(); - for (const bs of confirmedSeats) { - if (!bs.seatId) continue; - const scheduleId = bs.scheduleId ?? bs.booking.scheduleId; - if (!scheduleId) continue; - const key = `${bs.seatId}:${scheduleId}`; - if (!groups.has(key)) groups.set(key, []); - groups.get(key)!.push(bs); - } - - const duplicateGroups = [...groups.values()] - .filter(g => g.length > 1) - .slice(0, BATCH_SIZE); - - if (duplicateGroups.length === 0) return; - - this.logger.warn(`Seat dedup: ${duplicateGroups.length} duplicate seat group(s) detected`); - - // Track seats newly assigned within this run to prevent double-assignment. - const newlyAssigned = new Map>(); // scheduleId → Set - let resolved = 0; - let unresolved = 0; - - for (const group of duplicateGroups) { - // FCFS: earliest confirmed booking keeps the seat. - const sorted = [...group].sort( - (a, b) => - new Date(a.booking.createdAt as Date).getTime() - - new Date(b.booking.createdAt as Date).getTime(), - ); - const [keeper, ...duplicates] = sorted; - - for (const dup of duplicates) { - const scheduleId = (dup.scheduleId ?? dup.booking.scheduleId)!; - const coachTypeId = dup.seat?.coach?.coachTypeId; - const oldCoachId = dup.seat?.coachId; - - if (!coachTypeId) { - this.logger.error( - `Seat dedup: missing coachTypeId for BookingSeat ${dup.id}, booking ${dup.booking.bookingRef}`, - ); - unresolved++; - continue; - } - - if (!newlyAssigned.has(scheduleId)) newlyAssigned.set(scheduleId, new Set()); - const takenThisRun = newlyAssigned.get(scheduleId)!; - - // All seats already taken: confirmed bookings + those assigned this tick. - const occupiedIds = new Set([ - ...confirmedSeats - .filter(bs => (bs.scheduleId ?? bs.booking.scheduleId) === scheduleId && bs.seatId) - .map(bs => bs.seatId as string), - ...takenThisRun, - ]); - - try { - const newSeat = await this.findReplacementSeat(scheduleId, coachTypeId, oldCoachId, occupiedIds); - - if (!newSeat) { - this.logger.warn( - `Seat dedup: no available seat for booking ${dup.booking.bookingRef} ` + - `(schedule ${scheduleId}, coachType ${coachTypeId}) — manual intervention required`, - ); - unresolved++; - continue; - } - - await this.prisma.$transaction(async (tx) => { - // 1. Update BookingSeat to the new seat. - await tx.bookingSeat.update({ - where: { id: dup.id }, - data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber }, - }); - - // 2. Update JourneySegment — look up journeyId first to avoid a - // nested-relation filter in updateMany (not supported in all Prisma versions). - const journey = await tx.journey.findUnique({ - where: { bookingId: dup.booking.id } as any, - select: { id: true }, - }); - if (journey) { - await tx.journeySegment.updateMany({ - where: { journeyId: journey.id, seatId: dup.seatId!, scheduleId }, - data: { seatId: newSeat.id, coachId: newSeat.coachId }, - }); - } - - // 3. Update Ticket seat reference (QR payload regeneration is out of scope - // here; the backoffice can trigger that separately if required). - await tx.ticket.updateMany({ - where: { bookingId: dup.booking.id, seatId: dup.seatId! }, - data: { seatId: newSeat.id }, - }); - }); - - takenThisRun.add(newSeat.id); - - const oldLabel = dup.seat?.seatNumber ?? dup.seatId ?? '?'; - const newCoach = (newSeat as any).coach; - const coachTypeName = newCoach?.coachType?.name ?? ''; - const coachNumber = newCoach?.number ?? ''; - const origin = dup.booking.schedule?.originStation?.name ?? ''; - const dest = dup.booking.schedule?.destinationStation?.name ?? ''; - - if (dup.booking.contactPhone) { - const message = - `EDR: Your booking ${dup.booking.bookingRef} (${origin} → ${dest}): ` + - `your seat has been changed from ${oldLabel} to seat ${newSeat.seatNumber} ` + - `in coach ${coachNumber} (${coachTypeName}). ` + - `We apologize for the inconvenience.`; - await this.sms.sendSms({ to: dup.booking.contactPhone, message }).catch(() => null); - } - - this.logger.log( - `Seat dedup resolved: booking ${dup.booking.bookingRef} ` + - `seat ${oldLabel} → ${newSeat.seatNumber} (coach ${coachNumber}, ${coachTypeName}), ` + - `keeper: ${keeper.booking.bookingRef}`, - ); - resolved++; - } catch (err) { - this.logger.error( - `Seat dedup error for booking ${dup.booking.bookingRef}: ` + - `${err instanceof Error ? err.message : String(err)}`, - ); - unresolved++; - } - } - } - - this.logger.log(`Seat dedup run: ${resolved} resolved, ${unresolved} unresolved`); - } - - private async findReplacementSeat( - scheduleId: string, - coachTypeId: string, - preferredCoachId: string | undefined, - occupiedIds: Set, - ) { - const includeCoach = { - coach: { include: { coachType: { select: { id: true, name: true } } } }, - }; - const baseWhere = (coachId?: string) => ({ - ...(coachId ? { coachId } : {}), - seatNumber: { not: '' }, - id: { notIn: [...occupiedIds] }, - coach: { coachTypeId, assignments: { some: { scheduleId } } }, - NOT: [ - { seatNumber: { startsWith: '-' } }, - { status: SeatStatus.BLOCKED }, - ], - }); - - // 1. Prefer the exact same coach. - if (preferredCoachId) { - const seat = await this.prisma.seat.findFirst({ - where: baseWhere(preferredCoachId), - include: includeCoach, - orderBy: [{ row: 'asc' }, { col: 'asc' }], - }); - if (seat) return seat; - } - - // 2. Any coach of the same coach type assigned to this schedule. - return this.prisma.seat.findFirst({ - where: baseWhere(), - include: includeCoach, - orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], - }); - } - - // ───────────────────────────────────────────────────────────────────────── - // Every 1 min: detect and resolve duplicate seat assignments caused by - // RabbitMQ-recovered events re-confirming already-cancelled bookings. - // - // Detection: group confirmed BookingSeat rows by (scheduleId, seatId, leg). - // Any group with >1 row means multiple bookings share the same physical seat. - // - // Resolution (FCFS): the booking created first keeps the seat; all later - // bookings are reassigned to an available seat in: - // 1. Same coach + same coach type (preferred) - // 2. Same coach type, any coach (fallback) - // 3. No seat available → logged, needs manual intervention - // - // Idempotency: once a duplicate's BookingSeat is updated to a new seatId it - // no longer appears in the duplicate group on the next tick — naturally safe - // to re-run without any extra flag. - // ───────────────────────────────────────────────────────────────────────── - @Cron('*/1 * * * *') - async deduplicateSeatAssignments() { - this.logger.log('Seat dedup cron started'); - // Scan at most 500 confirmed seat rows per run to stay lightweight. - const confirmedSeats = await this.prisma.bookingSeat.findMany({ - where: { booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } }, - select: { - id: true, - seatId: true, - scheduleId: true, - leg: true, - passengerName: true, - booking: { - select: { - id: true, - bookingRef: true, - scheduleId: true, - createdAt: true, - contactPhone: true, - }, - }, - seat: { - select: { - id: true, - seatNumber: true, - coachId: true, - coach: { - select: { - id: true, - number: true, - coachTypeId: true, - coachType: { select: { id: true, name: true } }, - }, - }, - }, - }, - }, - take: 500, - }); - - // Group by (effectiveScheduleId :: seatId :: leg) - type BsRow = (typeof confirmedSeats)[number]; - const groups = new Map(); - for (const bs of confirmedSeats) { - const schedId = bs.scheduleId ?? bs.booking.scheduleId; - if (!schedId) continue; - const key = `${schedId}::${bs.seatId}::${bs.leg}`; - if (!groups.has(key)) groups.set(key, []); - groups.get(key)!.push(bs); - } - - const duplicateGroups = [...groups.values()].filter(g => g.length > 1); - if (duplicateGroups.length === 0) return; - - this.logger.warn(`Seat dedup: ${duplicateGroups.length} conflict(s) detected`); - - // Build taken-seat sets keyed by (scheduleId::leg) — used when finding - // a replacement seat so we don't assign an already-occupied seat. - const takenByScheduleLeg = new Map>(); - for (const bs of confirmedSeats) { - const schedId = bs.scheduleId ?? bs.booking.scheduleId; - if (!schedId) continue; - const key = `${schedId}::${bs.leg}`; - if (!takenByScheduleLeg.has(key)) takenByScheduleLeg.set(key, new Set()); - takenByScheduleLeg.get(key)!.add(bs.seatId); - } - - let resolved = 0; - let unresolved = 0; - - for (const group of duplicateGroups) { - // FCFS: earliest booking keeps the seat - group.sort((a, b) => - new Date(a.booking.createdAt).getTime() - new Date(b.booking.createdAt).getTime(), - ); - - const [winner, ...duplicates] = group; - const schedId = winner.scheduleId ?? winner.booking.scheduleId; - const coachTypeId = winner.seat.coach.coachTypeId; - const origCoachId = winner.seat.coachId; - const taken = takenByScheduleLeg.get(`${schedId}::${winner.leg}`) ?? new Set(); - - for (const dup of duplicates) { - try { - // 1st choice: same coach + same coach type - const newSeat = - (await this.prisma.seat.findFirst({ - where: { - id: { notIn: [...taken] }, - status: { not: SeatStatus.BLOCKED }, - coachId: origCoachId, - coach: { - coachTypeId, - assignments: { some: { scheduleId: schedId } }, - }, - }, - select: { - id: true, seatNumber: true, coachId: true, - coach: { select: { number: true, coachType: { select: { name: true } } } }, - }, - })) ?? - // 2nd choice: any coach within same coach type - (await this.prisma.seat.findFirst({ - where: { - id: { notIn: [...taken] }, - status: { not: SeatStatus.BLOCKED }, - coach: { - coachTypeId, - assignments: { some: { scheduleId: schedId } }, - }, - }, - select: { - id: true, seatNumber: true, coachId: true, - coach: { select: { number: true, coachType: { select: { name: true } } } }, - }, - })); - - if (!newSeat) { - this.logger.warn( - `Seat dedup: no available seat in coach type for ` + - `booking ${dup.booking.bookingRef} (${dup.passengerName}) — manual intervention required`, - ); - unresolved++; - continue; - } - - // Atomically update BookingSeat + Ticket + JourneySegment - await this.prisma.$transaction(async (tx) => { - await tx.bookingSeat.update({ - where: { id: dup.id }, - data: { seatId: newSeat!.id, seatLabelSnapshot: newSeat!.seatNumber }, - }); - await tx.ticket.updateMany({ - where: { bookingId: dup.booking.id, seatId: dup.seatId, leg: dup.leg }, - data: { seatId: newSeat!.id }, - }); - await tx.journeySegment.updateMany({ - where: { - journey: { bookingId: dup.booking.id }, - seatId: dup.seatId, - scheduleId: schedId, - }, - data: { seatId: newSeat!.id, coachId: newSeat!.coachId }, - }); - }); - - // Claim the new seat so subsequent duplicates in this run don't use it - taken.add(newSeat.id); - - const message = - `EDR: Your seat for booking ${dup.booking.bookingRef} has been updated ` + - `due to a system correction. ` + - `New seat: ${newSeat.seatNumber}, Coach: ${newSeat.coach.number} ` + - `(${newSeat.coach.coachType.name}). We apologize for the inconvenience.`; - - if (dup.booking.contactPhone) { - await this.sms.sendSms({ to: dup.booking.contactPhone, message }).catch(() => null); - } - - this.logger.log( - `Seat dedup: booking ${dup.booking.bookingRef} (${dup.passengerName}) ` + - `seat ${dup.seat.seatNumber} → ${newSeat.seatNumber} (coach ${newSeat.coach.number})`, - ); - resolved++; - } catch (err) { - this.logger.error( - `Seat dedup error for ${dup.booking.bookingRef}: ` + - `${err instanceof Error ? err.message : String(err)}`, - ); - unresolved++; - } - } - } - - this.logger.log( - `Seat dedup complete: ${resolved} reassigned, ${unresolved} unresolved ` + - `across ${duplicateGroups.length} conflict(s)`, - ); - } - // ───────────────────────────────────────────────────────────────────────── // Daily at 02:00 EAT: purge expired/stale records to enforce data retention. // ───────────────────────────────────────────────────────────────────────── From 610580e15e1445cbe67b3525cd88fabb4c5d8100 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Fri, 17 Jul 2026 10:20:35 +0300 Subject: [PATCH 03/10] Added discrepancy management for duplicate seats --- .../src/modules/seats/duplicate-seats.dto.ts | 33 ++ .../src/modules/seats/seats.controller.ts | 88 +++ .../src/modules/seats/seats.module.ts | 3 +- .../src/modules/seats/seats.service.ts | 414 ++++++++++++++ .../backoffice/src/app/discrepancy/layout.tsx | 5 + .../backoffice/src/app/discrepancy/page.tsx | 519 ++++++++++++++++++ .../src/components/layout/Sidebar.tsx | 4 +- .../backoffice/src/lib/api/index.ts | 4 + 8 files changed, 1068 insertions(+), 2 deletions(-) create mode 100644 apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts create mode 100644 apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx diff --git a/apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts b/apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts new file mode 100644 index 000000000..9018d77fb --- /dev/null +++ b/apps/edr-passenger-api/src/modules/seats/duplicate-seats.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsDateString, IsOptional, IsUUID } from 'class-validator'; + +export class GetDuplicateSeatsQuery { + @ApiProperty({ example: '2026-07-17', description: 'Schedule date (YYYY-MM-DD)' }) + @IsDateString() + date: string; + + @ApiPropertyOptional({ description: 'Filter to a specific schedule ID' }) + @IsOptional() + @IsUUID() + scheduleId?: string; +} + +export class ResolveDuplicatesDto { + @ApiProperty({ + description: 'BookingSeat IDs of the duplicate bookings to reassign', + type: [String], + example: ['uuid-booking-seat-1', 'uuid-booking-seat-2'], + }) + @IsArray() + @IsUUID(undefined, { each: true }) + bookingSeatIds: string[]; + + @ApiProperty({ + description: 'Coach IDs to source replacement seats from (searched in order; first available seat per coach is used)', + type: [String], + example: ['uuid-coach-1', 'uuid-coach-2'], + }) + @IsArray() + @IsUUID(undefined, { each: true }) + coachIds: string[]; +} 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 596c88bb3..834973c71 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -17,9 +17,11 @@ import { ApiParam, ApiQuery, ApiResponse, + ApiBody, } from "@nestjs/swagger"; import { SeatsService } from "./seats.service"; import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto"; +import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto"; import { JwtGuard } from "../../common/jwt.guard"; import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; @@ -306,4 +308,90 @@ This makes it clear which segment of the route each seat is held for, enabling s ) { return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit); } + + // ── Duplicate seat management (backoffice) ──────────────────────────────── + + @Get("duplicates") + @UseGuards(JwtGuard) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ + summary: "List duplicate seat assignments by schedule date", + description: + "Returns all schedules on the given date that have bookings sharing " + + "the same seat, grouped by coach. Each coach entry includes the duplicate " + + "groups (with full booking info) and the list of currently available seats " + + "that can be used for reassignment.", + }) + @ApiQuery({ name: "date", example: "2026-07-17", description: "Schedule date (YYYY-MM-DD)" }) + @ApiQuery({ name: "scheduleId", required: false, description: "Filter to a specific schedule" }) + @ApiResponse({ + status: 200, + description: "Duplicate seat report grouped by schedule → coach", + schema: { + example: { + date: "2026-07-17", + totalDuplicates: 1, + schedules: [{ + scheduleId: "uuid", + departureAt: "2026-07-17T06:00:00.000Z", + origin: "Addis Ababa", + destination: "Dire Dawa", + coaches: [{ + coachId: "uuid", + coachNumber: "C1", + coachTypeName: "SBC", + duplicates: [{ + seatId: "uuid", + seatNumber: "12A", + leg: 1, + bookings: [ + { bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "ATPC9F", passengerName: "Abebe", contactPhone: "+251911000000", createdAt: "2026-07-16T10:00:00.000Z" }, + { bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "XYZ123", passengerName: "Kebede", contactPhone: "+251922000000", createdAt: "2026-07-16T11:00:00.000Z" }, + ], + }], + availableSeats: [ + { seatId: "uuid", seatNumber: "14B" }, + { seatId: "uuid", seatNumber: "15A" }, + ], + }], + }], + }, + }, + }) + getDuplicateSeats(@Query() query: GetDuplicateSeatsQuery) { + return this.service.getDuplicateSeats(query.date, query.scheduleId); + } + + @Post("duplicates/resolve") + @UseGuards(JwtGuard) + @ApiBearerAuth("JWT-auth") + @ApiOperation({ + summary: "Auto-assign duplicate bookings to seats in selected coaches", + description: + "Staff selects which duplicate BookingSeat IDs to fix and which coaches to pull replacement seats from. " + + "The system automatically picks the first available (non-blocked, non-occupied) seat in the given coaches " + + "for each booking, updates BookingSeat + Ticket + JourneySegment atomically so the seatmap reflects the " + + "change immediately, then sends an SMS notification to the passenger. " + + "Coaches are searched in the order provided; seats within each coach are assigned by row then column.", + }) + @ApiBody({ type: ResolveDuplicatesDto }) + @ApiResponse({ + status: 200, + description: "Resolution summary — resolved count, unresolved count, per-booking results", + schema: { + example: { + resolved: 2, + unresolved: 0, + results: [ + { bookingRef: "XYZ123", oldSeatNumber: "1A", newSeatNumber: "14B", contactPhone: "+251922000000" }, + { bookingRef: "ABC456", oldSeatNumber: "1A", newSeatNumber: "15A", contactPhone: "+251933000000" }, + ], + }, + }, + }) + @ApiResponse({ status: 400, description: "Booking not in CONFIRMED/BOARDED status" }) + @ApiResponse({ status: 404, description: "BookingSeat ID not found" }) + resolveDuplicateSeats(@Body() dto: ResolveDuplicatesDto) { + return this.service.resolveDuplicateSeats(dto.bookingSeatIds, dto.coachIds); + } } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.module.ts b/apps/edr-passenger-api/src/modules/seats/seats.module.ts index 0725f4393..2a3c4ee26 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.module.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.module.ts @@ -5,9 +5,10 @@ import { SeatsService } from './seats.service'; import { SegmentsModule } from '../segments/segments.module'; import { SystemConfigModule } from '../system-config/system-config.module'; import { AuditModule } from '../../common/audit.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule], + imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule, NotificationsModule], controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService], 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 10368f380..29d03206c 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -5,6 +5,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { SegmentsService } from '../segments/segments.service'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import { AuditService } from '../../common/audit.service'; +import { SmsClientService } from '../notifications/sms-client.service'; import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; import { checkDirectionConflict } from '../../common/utils/journey-direction.utils'; @@ -17,6 +18,7 @@ export class SeatsService { private segmentsService: SegmentsService, private systemConfig: SystemConfigService, private auditService: AuditService, + private sms: SmsClientService, ) {} async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) { @@ -960,4 +962,416 @@ export class SeatsService { skippedSeatIds: Array.from(skippedSeatIds), // kept for logging/API compat; no DB writes needed }; } + + // ───────────────────────────────────────────────────────────────────────── + // Duplicate-seat management (backoffice) + // ───────────────────────────────────────────────────────────────────────── + + async getDuplicateSeats(date: string, scheduleId?: string) { + const dayStart = new Date(`${date}T00:00:00.000Z`); + const dayEnd = new Date(`${date}T23:59:59.999Z`); + + const schedules = await this.prisma.trainSchedule.findMany({ + where: { + departureAt: { gte: dayStart, lte: dayEnd }, + ...(scheduleId ? { id: scheduleId } : {}), + }, + orderBy: { departureAt: 'asc' }, + include: { + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + coachAssignments: { + orderBy: { positionNumber: 'asc' }, + include: { + coach: { + include: { + coachType: { select: { name: true } }, + seats: { + orderBy: [{ row: 'asc' }, { col: 'asc' }], + select: { id: true, seatNumber: true, status: true, coachId: true }, + }, + }, + }, + }, + }, + }, + }); + + const result = []; + + for (const schedule of schedules) { + // All confirmed BookingSeat rows for this schedule + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { + OR: [ + { scheduleId: schedule.id }, + { scheduleId: null, booking: { scheduleId: schedule.id } }, + ], + booking: { status: { in: ['CONFIRMED', 'BOARDED'] } }, + }, + select: { + id: true, seatId: true, scheduleId: true, leg: true, passengerName: true, + seat: { select: { coachId: true } }, + booking: { + select: { + id: true, bookingRef: true, scheduleId: true, + createdAt: true, contactPhone: true, + }, + }, + }, + }); + + // Seats occupied by any confirmed journey on this schedule (source of truth) + const journeySegments = await this.prisma.journeySegment.findMany({ + where: { + scheduleId: schedule.id, + seatId: { not: null }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, + }, + select: { seatId: true }, + }); + const occupiedIds = new Set(journeySegments.map(js => js.seatId!)); + + // Group BookingSeat rows by (seatId::leg) to detect duplicates + type BS = (typeof bookingSeats)[number]; + const groups = new Map(); + for (const bs of bookingSeats) { + const key = `${bs.seatId}::${bs.leg}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(bs); + } + + // All seats held by any confirmed BookingSeat — union of JourneySegment-based + // occupancy AND BookingSeat-based occupancy so that seats whose JourneySegments + // are missing (e.g. created via enhanced-seats path without bookingId) are still + // excluded from the available list. + const bookedSeatIds = new Set([ + ...occupiedIds, + ...bookingSeats.map(bs => bs.seatId).filter((id): id is string => id !== null && id !== undefined), + ]); + + const coachReports = []; + + for (const assignment of schedule.coachAssignments) { + const coach = assignment.coach; + + // Duplicate groups whose seat belongs to this coach + const duplicates = []; + for (const [key, group] of groups) { + if (group.length <= 1) continue; + if (group[0].seat.coachId !== coach.id) continue; + const [seatId] = key.split('::'); + const seat = coach.seats.find(s => s.id === seatId); + duplicates.push({ + seatId, + seatNumber: seat?.seatNumber ?? seatId, + leg: group[0].leg, + bookings: group.map(bs => ({ + bookingSeatId: bs.id, + bookingId: bs.booking.id, + bookingRef: bs.booking.bookingRef, + passengerName: bs.passengerName, + contactPhone: bs.booking.contactPhone, + createdAt: bs.booking.createdAt, + })), + }); + } + + // Free seats in this coach — excludes BLOCKED, all confirmed BookingSeat + // assignments, and all confirmed JourneySegment occupancies. + const availableSeats = coach.seats + .filter(s => + (s.status as string) !== 'BLOCKED' && + !s.seatNumber.startsWith('-') && + !bookedSeatIds.has(s.id), + ) + .map(s => ({ seatId: s.id, seatNumber: s.seatNumber })); + + coachReports.push({ + coachId: coach.id, + coachNumber: coach.number, + coachTypeName: coach.coachType.name, + duplicates, + availableSeats, + }); + } + + if (coachReports.some(c => c.duplicates.length > 0)) { + result.push({ + scheduleId: schedule.id, + departureAt: schedule.departureAt, + origin: schedule.originStation.name, + destination: schedule.destinationStation.name, + coaches: coachReports, + }); + } + } + + const totalDuplicates = result.reduce( + (sum, s) => sum + s.coaches.reduce((cs, c) => cs + c.duplicates.length, 0), + 0, + ); + + return { date, schedules: result, totalDuplicates }; + } + + async resolveDuplicateSeats(bookingSeatIds: string[], coachIds: string[]) { + if (bookingSeatIds.length === 0) return { resolved: 0, unresolved: 0, results: [] }; + + // Load BookingSeat rows with full booking + schedule context + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { id: { in: bookingSeatIds } }, + select: { + id: true, seatId: true, leg: true, scheduleId: true, + seat: { select: { seatNumber: true } }, + booking: { + select: { + id: true, bookingRef: true, scheduleId: true, + status: true, contactPhone: true, passengerId: true, + totalMinor: true, currency: true, + originStationId: true, destinationStationId: true, + schedule: { + select: { + originStationId: true, + destinationStationId: true, + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + departureAt: true, + }, + }, + }, + }, + }, + }); + + if (bookingSeats.length !== bookingSeatIds.length) { + const found = new Set(bookingSeats.map(bs => bs.id)); + const missing = bookingSeatIds.filter(id => !found.has(id)); + throw new NotFoundException(`BookingSeat(s) not found: ${missing.join(', ')}`); + } + + const invalid = bookingSeats.filter(bs => !['CONFIRMED', 'BOARDED'].includes(bs.booking.status)); + if (invalid.length > 0) { + throw new BadRequestException( + `Bookings must be CONFIRMED or BOARDED: ${invalid.map(bs => bs.booking.bookingRef).join(', ')}`, + ); + } + + // Load all non-blocked, non-removed seats from the selected coaches (ordered for deterministic pick) + const coachSeats = await this.prisma.seat.findMany({ + where: { + coachId: { in: coachIds }, + status: { not: 'BLOCKED' }, + NOT: { seatNumber: { startsWith: '-' } }, + }, + select: { id: true, seatNumber: true, coachId: true, row: true, col: true }, + orderBy: [{ coachId: 'asc' }, { row: 'asc' }, { col: 'asc' }], + }); + + // Build occupied-seat sets per schedule from confirmed JourneySegments + const scheduleIds = [ + ...new Set( + bookingSeats + .map(bs => bs.scheduleId ?? bs.booking.scheduleId) + .filter((id): id is string => id !== null && id !== undefined), + ), + ]; + + const occupiedBySchedule = new Map>(); + await Promise.all( + scheduleIds.map(async scheduleId => { + const segments = await this.prisma.journeySegment.findMany({ + where: { + scheduleId, + seatId: { not: null }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT', 'BOARDED'] } }, + }, + select: { seatId: true }, + }); + occupiedBySchedule.set(scheduleId, new Set(segments.map(s => s.seatId!))); + }), + ); + + // Track seats assigned within this batch to prevent double-assignment + const assignedInBatch = new Set(); + + const results: { bookingRef: string; oldSeatNumber: string; newSeatNumber: string; contactPhone: string | null }[] = []; + const unresolved: { bookingRef: string; reason: string }[] = []; + + for (const bs of bookingSeats) { + const scheduleId = (bs.scheduleId ?? bs.booking.scheduleId)!; + const occupied = occupiedBySchedule.get(scheduleId) ?? new Set(); + + // Pick the first available seat across the selected coaches + const newSeat = coachSeats.find( + seat => + !occupied.has(seat.id) && + !assignedInBatch.has(seat.id) && + seat.id !== bs.seatId, + ); + + if (!newSeat) { + unresolved.push({ + bookingRef: bs.booking.bookingRef, + reason: 'No available seat found in selected coaches', + }); + this.logger.warn( + `Duplicate resolve: no seat available for ${bs.booking.bookingRef} (schedule ${scheduleId})`, + ); + continue; + } + + await this.prisma.$transaction(async tx => { + // 1. Change the seat on the booking and ticket. + await tx.bookingSeat.update({ + where: { id: bs.id }, + data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber }, + }); + await tx.ticket.updateMany({ + where: { bookingId: bs.booking.id, seatId: bs.seatId, leg: bs.leg }, + data: { seatId: newSeat.id }, + }); + + // 2. Point the existing JourneySegments to the new seat. + // The Journey is already linked to this booking via bookingId; + // just update the seatId in its hop rows for this schedule. + const journey = await tx.journey.findFirst({ + where: { bookingId: bs.booking.id }, + select: { id: true }, + }); + + if (!journey) { + // No Journey/JourneySegment for this booking (e.g. duplicate that was never + // processed by finalizePaymentSuccess). Create them now using the same logic, + // scoped to the booking's origin→destination leg so the seatmap shows BOOKED + // only for the correct range of stops. + const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId; + const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId; + + const stopTimes = await tx.tripStopTime.findMany({ + where: { scheduleId }, + orderBy: { sequence: 'asc' }, + select: { stationId: true }, + }); + + const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0; + const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1; + const fromIdx = originIdx >= 0 ? originIdx : 0; + const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1; + + const newJourney = await tx.journey.create({ + data: { + passengerId: bs.booking.passengerId, + bookingId: bs.booking.id, + status: 'CONFIRMED', + totalMinor: bs.booking.totalMinor, + currency: bs.booking.currency, + } as any, + }); + + const segments = []; + for (let i = fromIdx; i < toIdx; i++) { + segments.push({ + journeyId: newJourney.id, + scheduleId, + segmentOrder: i - fromIdx, + seatId: newSeat.id, + coachId: newSeat.coachId, + departureStationId: stopTimes[i].stationId, + arrivalStationId: stopTimes[i + 1].stationId, + }); + } + if (segments.length > 0) { + await tx.journeySegment.createMany({ data: segments, skipDuplicates: true }); + } + this.logger.log( + `No Journey for ${bs.booking.bookingRef} — created Journey + ${segments.length} segment(s) for seat ${newSeat.seatNumber}`, + ); + return; + } + + const { count } = await tx.journeySegment.updateMany({ + where: { journeyId: journey.id, scheduleId, seatId: bs.seatId }, + data: { seatId: newSeat.id }, + }); + + // Journey exists but had no segments (e.g. booking confirmed via a path + // that skipped JourneySegment creation). Create them now for the new seat + // so the seatmap reflects BOOKED. + if (count === 0) { + const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId; + const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId; + const stopTimes = await tx.tripStopTime.findMany({ + where: { scheduleId }, + orderBy: { sequence: 'asc' }, + select: { stationId: true }, + }); + const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0; + const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1; + const fromIdx = originIdx >= 0 ? originIdx : 0; + const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1; + const segments = []; + for (let i = fromIdx; i < toIdx; i++) { + segments.push({ + journeyId: journey.id, + scheduleId, + segmentOrder: i - fromIdx, + seatId: newSeat.id, + coachId: newSeat.coachId, + departureStationId: stopTimes[i].stationId, + arrivalStationId: stopTimes[i + 1].stationId, + }); + } + if (segments.length > 0) { + await tx.journeySegment.createMany({ data: segments, skipDuplicates: true }); + } + this.logger.log( + `Seat reassigned: ${bs.booking.bookingRef} ` + + `${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` + + `(0 existing segments — created ${segments.length} new hop(s))`, + ); + } else { + this.logger.log( + `Seat reassigned: ${bs.booking.bookingRef} ` + + `${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` + + `(${count} segment hop(s) updated)`, + ); + } + }); + + // Mark as taken so the next booking in this batch doesn't get the same seat + assignedInBatch.add(newSeat.id); + occupied.add(newSeat.id); + + const oldSeatNumber = bs.seat?.seatNumber ?? '?'; + const origin = bs.booking.schedule?.originStation?.name ?? ''; + const dest = bs.booking.schedule?.destinationStation?.name ?? ''; + + if (bs.booking.contactPhone) { + const message = + `EDR: Your booking ${bs.booking.bookingRef} (${origin} → ${dest}): ` + + `your seat has been changed from seat ${oldSeatNumber} to seat ${newSeat.seatNumber}. ` + + `We apologize for any inconvenience.`; + await this.sms.sendSms({ to: bs.booking.contactPhone, message }).catch(() => null); + } + + this.logger.log( + `Duplicate resolved: ${bs.booking.bookingRef} seat ${oldSeatNumber} → ${newSeat.seatNumber}`, + ); + + results.push({ + bookingRef: bs.booking.bookingRef, + oldSeatNumber, + newSeatNumber: newSeat.seatNumber, + contactPhone: bs.booking.contactPhone, + }); + } + + return { + resolved: results.length, + unresolved: unresolved.length, + results, + ...(unresolved.length > 0 ? { unresolvedDetails: unresolved } : {}), + }; + } } diff --git a/apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx new file mode 100644 index 000000000..4b98bd933 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/discrepancy/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function DiscrepancyLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx b/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx new file mode 100644 index 000000000..9545c1d2a --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx @@ -0,0 +1,519 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react'; +import { seatsApi } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; + +// ── Types ───────────────────────────────────────────────────────────────── + +interface DuplicateBooking { + bookingSeatId: string; + bookingId: string; + bookingRef: string; + passengerName: string; + contactPhone: string | null; + createdAt: string; +} + +interface DuplicateSeatGroup { + seatId: string; + seatNumber: string; + leg: number; + bookings: DuplicateBooking[]; +} + +interface AvailableSeat { + seatId: string; + seatNumber: string; +} + +interface CoachReport { + coachId: string; + coachNumber: string; + coachTypeName: string; + duplicates: DuplicateSeatGroup[]; + availableSeats: AvailableSeat[]; +} + +interface ScheduleReport { + scheduleId: string; + origin: string; + destination: string; + departureAt: string; + coaches: CoachReport[]; +} + +interface DuplicatesResponse { + date: string; + schedules: ScheduleReport[]; + totalDuplicates: number; +} + +// ── Helpers ─────────────────────────────────────────────────────────────── + +function today() { + return new Date().toISOString().slice(0, 10); +} + +function scheduleDuplicateCount(s: ScheduleReport) { + return s.coaches.reduce((sum, c) => sum + c.duplicates.length, 0); +} + +// ── Resolve modal ───────────────────────────────────────────────────────── + +interface ResolveModalProps { + schedule: ScheduleReport; + coach: CoachReport; + onClose: () => void; + onSuccess: () => void; +} + +function ResolveModal({ schedule, coach, onClose, onSuccess }: ResolveModalProps) { + // Default: pre-select all-but-first passenger in every duplicate group + const defaultSelected = new Set( + coach.duplicates.flatMap(g => g.bookings.slice(1).map(b => b.bookingSeatId)), + ); + const [selectedSeats, setSelectedSeats] = useState>(defaultSelected); + + // Coaches that have at least one available seat (pre-select all) + const coachesWithSeats = schedule.coaches.filter(c => c.availableSeats.length > 0); + const [selectedCoachIds, setSelectedCoachIds] = useState>( + new Set(coachesWithSeats.map(c => c.coachId)), + ); + + const [successMsg, setSuccessMsg] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + const mutation = useMutation({ + mutationFn: (data: { bookingSeatIds: string[]; coachIds: string[] }) => + seatsApi.resolveDuplicates(data), + onSuccess: (res) => { + setSuccessMsg( + `${res.resolved ?? 0} passenger(s) successfully reassigned.` + + (res.unresolved > 0 ? ` ${res.unresolved} could not be resolved (no available seat).` : ''), + ); + setErrorMsg(null); + onSuccess(); + }, + onError: (err: any) => { + setErrorMsg(err?.response?.data?.message ?? 'Failed to resolve duplicates.'); + }, + }); + + function toggleBookingSeat(id: string) { + setSelectedSeats(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + function toggleCoach(id: string) { + setSelectedCoachIds(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + function handleAssign() { + setErrorMsg(null); + if (selectedSeats.size === 0) { + setErrorMsg('Select at least one passenger to reassign.'); + return; + } + if (selectedCoachIds.size === 0) { + setErrorMsg('Select at least one coach to source the replacement seat from.'); + return; + } + mutation.mutate({ + bookingSeatIds: [...selectedSeats], + coachIds: [...selectedCoachIds], + }); + } + + return ( +
+
+ + {/* Header */} +
+
+

+ Resolve Duplicates — {coach.coachNumber} +

+

+ {schedule.origin} → {schedule.destination} · {formatDateTime(schedule.departureAt)} +

+
+ +
+ +
+ + {/* Duplicate seat groups */} +
+

+ Duplicate seat assignments +

+

+ Check the passengers you want to reassign to a new seat. Unchecked passengers keep their current seat. +

+ + {coach.duplicates.map(group => ( +
+
+ + + Seat {group.seatNumber} — {group.bookings.length} passengers assigned + +
+
+ {group.bookings.map((b, idx) => { + const checked = selectedSeats.has(b.bookingSeatId); + return ( + + ); + })} +
+
+ ))} +
+ + {/* Coach selection */} +
+

+ Reassign to seats in +

+

+ The system picks the first available seat in the selected coaches. +

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

No coaches have available seats on this schedule.

+ ) : ( + coachesWithSeats.map(c => ( + + )) + )} +
+
+ + {/* Feedback */} + {errorMsg && ( +
+ +

{errorMsg}

+
+ )} + {successMsg && ( +
+ +

{successMsg}

+
+ )} +
+ + {/* Footer */} +
+ + {!successMsg && ( + + )} +
+
+
+ ); +} + +// ── Coach card ──────────────────────────────────────────────────────────── + +interface CoachCardProps { + coach: CoachReport; + schedule: ScheduleReport; + onResolve: () => void; +} + +function CoachCard({ coach, schedule, onResolve }: CoachCardProps) { + const [expanded, setExpanded] = useState(false); + const hasDuplicates = coach.duplicates.length > 0; + + return ( +
+ {/* Card header */} +
+
+
+ {coach.coachNumber} + {coach.coachTypeName} +
+
+ {coach.availableSeats.length} available seats + {hasDuplicates && ( + + + {coach.duplicates.length} duplicate{coach.duplicates.length > 1 ? 's' : ''} + + )} +
+
+ +
+ {hasDuplicates && ( + + )} + {hasDuplicates && ( + + )} +
+
+ + {/* Expanded passenger list */} + {expanded && hasDuplicates && ( +
+ {coach.duplicates.map(group => ( +
+

+ Seat {group.seatNumber} — {group.bookings.length} passengers +

+
+ {group.bookings.map((b, idx) => ( +
+ + {idx + 1} + +
+ {b.passengerName || '—'} + {b.bookingRef} +
+ {b.contactPhone ?? '—'} +
+ ))} +
+
+ ))} +
+ )} +
+ ); +} + +// ── Main page ───────────────────────────────────────────────────────────── + +export default function DiscrepancyPage() { + const [date, setDate] = useState(today()); + const [searchDate, setSearchDate] = useState(''); + const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['seat-duplicates', searchDate], + queryFn: () => seatsApi.getDuplicates(searchDate), + enabled: !!searchDate, + }); + + function handleSearch() { + if (date) setSearchDate(date); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') handleSearch(); + } + + return ( +
+ + {/* Page header */} +
+
+ +
+
+

Seat Discrepancy

+

+ Detect and resolve duplicate seat assignments by schedule date +

+
+
+ + {/* Date picker */} +
+ setDate(e.target.value)} + onKeyDown={handleKeyDown} + className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+ + {/* Error */} + {isError && ( +
+ + Failed to load duplicate seat data. Please try again. +
+ )} + + {/* Summary banner */} + {data && ( +
0 + ? 'bg-orange-50 dark:bg-orange-950/20 border-orange-200 dark:border-orange-800' + : 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800' + }`}> + {data.totalDuplicates > 0 ? ( + + ) : ( + + )} + 0 + ? 'text-orange-800 dark:text-orange-300' + : 'text-green-800 dark:text-green-300' + }`}> + {data.totalDuplicates > 0 + ? `${data.totalDuplicates} duplicate seat assignment${data.totalDuplicates > 1 ? 's' : ''} found across ${data.schedules.length} schedule${data.schedules.length > 1 ? 's' : ''} on ${data.date}` + : `No duplicate seat assignments found on ${data.date}`} + +
+ )} + + {/* Results per schedule */} + {data?.schedules.map(schedule => ( +
+ {/* Schedule header */} +
+
+

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

+

+ {formatDateTime(schedule.departureAt)} · {scheduleDuplicateCount(schedule)} duplicate{scheduleDuplicateCount(schedule) !== 1 ? 's' : ''} +

+
+
+ + {/* Coach cards grid */} +
+ {schedule.coaches.map(coach => ( + setResolveTarget({ schedule, coach })} + /> + ))} +
+
+ ))} + + {/* Empty state when searched but no results */} + {data && data.schedules.length === 0 && data.totalDuplicates === 0 && searchDate && ( +
+ +

All seats are correctly assigned for {data.date}

+
+ )} + + {/* Resolve modal */} + {resolveTarget && ( + setResolveTarget(null)} + onSuccess={() => { + refetch(); + // Keep modal open to show success message; user closes manually + }} + /> + )} +
+ ); +} 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 45da6d8ca..62fa655c7 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -37,6 +37,7 @@ import { Banknote, Activity, Smartphone, + Layers, } from 'lucide-react'; import { useAuthStore } from '@/lib/auth-store'; import { cn } from '@/lib/utils'; @@ -64,7 +65,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { 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.view }, - { name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view }, + { name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view }, + { name: 'Discrepancy', href: '/discrepancy', icon: Layers, permission: PERMS.seats.manage }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index e8ed56195..2453eafa2 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -159,6 +159,10 @@ export const seatsApi = { undoRemove: (seatId: string) => apiClient.patch(`/seats/${seatId}/undo-remove`, {}), setMaintenance: (seatId: string, reason: string) => apiClient.post(`/seats/${seatId}/maintenance`, { reason }), clearMaintenance: (seatId: string) => apiClient.delete(`/seats/${seatId}/maintenance`), + getDuplicates: (date: string, scheduleId?: string) => + apiClient.get(`/seats/duplicates?date=${date}${scheduleId ? `&scheduleId=${scheduleId}` : ''}`), + resolveDuplicates: (data: { bookingSeatIds: string[]; coachIds: string[] }) => + apiClient.post('/seats/duplicates/resolve', data), }; // Payments API From 5a1e0dba4ddc063403d087fb59db12c0f3bb81a1 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 17 Jul 2026 07:43:17 +0000 Subject: [PATCH 04/10] Comment out payment event handling for local demos in BillingService --- .../src/modules/billing/billing.service.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 2bdc6ee16..774234000 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1034,16 +1034,16 @@ export class BillingService { // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // billing must not simulate it. Kept commented for local demos only. - if (!result.immediateSuccess) { - await this.payment.handlePaymentEvent({ - eventType: "payment.succeeded", - eventId: `demo-${result.intentId}`, - referenceId: invoice.sourceId, - intentId: result.intentId, - providerTxnId: result.providerTxnId, - paidAt: (result.paidAt ?? new Date()).toISOString(), - }); - } + // if (!result.immediateSuccess) { + // await this.payment.handlePaymentEvent({ + // eventType: "payment.succeeded", + // eventId: `demo-${result.intentId}`, + // referenceId: invoice.sourceId, + // intentId: result.intentId, + // providerTxnId: result.providerTxnId, + // paidAt: (result.paidAt ?? new Date()).toISOString(), + // }); + // } if (result.immediateSuccess) { await this.settleByPaymentId( From e115b9b7533712a07c70a16b79039b2e467c84b2 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 12:43:54 +0000 Subject: [PATCH 05/10] fix(warehouse): resolve Load-to-Train bookings from wagon allocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Load-to-Train queue was permanently empty for real traffic. loadableTrains() and trainLoadableItems() gated on freight.train_schedule_bookings, but nothing in the application writes that table — only the demo seeders do. Real wagon allocation writes wagon_booking_allocations, reached via train_schedules -> train_sets -> train_set_wagons, so an allocated export booking never satisfied the EXISTS gate and no train ever appeared. Both queries now resolve a schedule's bookings through a shared sched_bookings CTE that unions the wagon-allocation chain with train_schedule_bookings, so real allocations show up and the seeded demo scenarios keep working. The panel already groups the returned rows by booking with their containers, so the queue now lists the train, its bookings and their containers for selection. Export flow this serves: booked -> paid -> received at the warehouse (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the booking. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 07c720cff..70abf635b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1542,11 +1542,38 @@ export class WarehouseInventoryService { // their already-allocated wagons. Reuses the single-item load() machinery. /** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */ + /** + * The bookings riding a train schedule. + * + * Export flow: booked -> paid -> received at the warehouse (first-mile or + * self-haul) -> GRN -> loaded onto the wagons allocated to it. A booking + * actually reaches a train through WAGON ALLOCATION + * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations), + * which is what the allocation UI writes. `train_schedule_bookings` is only + * ever written by the demo seeders — keying off it alone left this queue + * permanently empty for real traffic — so both sources are unioned. + */ + private readonly SCHEDULE_BOOKINGS_CTE = ` + sched_bookings AS ( + SELECT ts.id AS schedule_id, wba.booking_id + FROM freight.train_schedules ts + JOIN freight.train_set_wagons tsw + ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL + JOIN freight.wagon_booking_allocations wba + ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL + WHERE ts.deleted_at IS NULL + UNION + SELECT tsb.train_schedule_id, tsb.booking_id + FROM freight.train_schedule_bookings tsb + WHERE tsb.deleted_at IS NULL + )`; + async loadableTrains(): Promise { const rows: Array< LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null } > = await this.dataSource.query( - `SELECT ts.id AS "scheduleId", + `WITH ${this.SCHEDULE_BOOKINGS_CTE} + SELECT ts.id AS "scheduleId", ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", @@ -1554,15 +1581,15 @@ export class WarehouseInventoryService { dy.country AS "destinationCountry", ts.status AS "status", ts.scheduled_departure_date AS "departureTime", - (SELECT count(*) FROM freight.train_schedule_bookings tsb + (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv - ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL - WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL + ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = ts.id AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount", - (SELECT count(*) FROM freight.train_schedule_bookings tsb + (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv - ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL - WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL + ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = ts.id AND inv.status = 'LOADED') AS "loadedCount" FROM freight.train_schedules ts LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id @@ -1570,10 +1597,10 @@ export class WarehouseInventoryService { WHERE ts.deleted_at IS NULL AND ts.status = ANY($1) AND EXISTS ( - SELECT 1 FROM freight.train_schedule_bookings tsb2 + SELECT 1 FROM sched_bookings sb2 JOIN freight.warehouse_inventory inv2 - ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL - WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL + ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL + WHERE sb2.schedule_id = ts.id AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') ) ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, @@ -1599,7 +1626,8 @@ export class WarehouseInventoryService { */ async trainLoadableItems(scheduleId: string): Promise { const rows: Array> = await this.dataSource.query( - `SELECT inv.id AS "id", + `WITH ${this.SCHEDULE_BOOKINGS_CTE} + SELECT inv.id AS "id", inv.booking_id AS "bookingId", b.reference AS "bookingReference", company.name AS "customerName", @@ -1612,9 +1640,9 @@ export class WarehouseInventoryService { wl.wagon_id AS "wagonId", wl.wagon_number AS "wagonNumber", wl.sequence_no AS "sequenceNo" - FROM freight.train_schedule_bookings tsb - JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id - JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + FROM sched_bookings sb + JOIN freight.train_schedules ts ON ts.id = sb.schedule_id + JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id @@ -1631,7 +1659,7 @@ export class WarehouseInventoryService { ORDER BY tsw.sequence_no ASC NULLS LAST LIMIT 1 ) wl ON true - WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + WHERE sb.schedule_id = $1 AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`, [scheduleId], From 2798fed6d8bd488c67e4f2446ed16178db6f4a2f Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:09:20 +0000 Subject: [PATCH 06/10] fix(train-scheduling): block dispatch when allocated cargo is not loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispatchSchedule guarded status, Djibouti departure rules, locomotives and wagons — but never checked the cargo. A train could be dispatched while the bookings allocated to it sat received in the warehouse, silently leaving them behind. Dispatch now refuses when an allocated booking has warehouse inventory in RECEIVED/STORED/READY_FOR_LOADING, naming the bookings and pointing at the two ways out: load them, or drop the wagon allocation so they ride a later train. Bookings with no inventory at all are not blocked — allocating a wagon before the goods arrive is normal planning. Also drops RESERVED from the Load-to-Train filters: reserved stock is not awaiting loading. The sched_bookings CTE moves to common/schedule-bookings.sql so the warehouse loading queue and this dispatch guard resolve a train's bookings identically — if they drift, a train departs leaving cargo the warehouse still expects to load. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/common/schedule-bookings.sql.ts | 28 +++++++++++++++ .../train-scheduling.service.ts | 34 +++++++++++++++++++ .../warehouses/warehouse-inventory.service.ts | 34 +++++-------------- 3 files changed, 70 insertions(+), 26 deletions(-) create mode 100644 apps/edr-freight-api/src/common/schedule-bookings.sql.ts diff --git a/apps/edr-freight-api/src/common/schedule-bookings.sql.ts b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts new file mode 100644 index 000000000..177b8549b --- /dev/null +++ b/apps/edr-freight-api/src/common/schedule-bookings.sql.ts @@ -0,0 +1,28 @@ +/** + * SQL CTE resolving the bookings riding a train schedule, as `sched_bookings + * (schedule_id, booking_id)`. Use as: `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT ...`. + * + * A booking reaches a train through WAGON ALLOCATION + * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations), + * which is what the allocation UI writes. `train_schedule_bookings` is only ever + * written by the demo seeders, so both sources are unioned: real allocations work + * and the seeded scenarios keep working. + * + * Shared so the warehouse loading queue and the train dispatch guard agree on + * exactly which bookings are on a train — if they drift, a train can be + * dispatched leaving cargo the warehouse still thinks it should load. + */ +export const SCHEDULE_BOOKINGS_CTE = ` + sched_bookings AS ( + SELECT ts.id AS schedule_id, wba.booking_id + FROM freight.train_schedules ts + JOIN freight.train_set_wagons tsw + ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL + JOIN freight.wagon_booking_allocations wba + ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL + WHERE ts.deleted_at IS NULL + UNION + SELECT tsb.train_schedule_id, tsb.booking_id + FROM freight.train_schedule_bookings tsb + WHERE tsb.deleted_at IS NULL + )`; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index b92a7ea90..97984be5f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -19,6 +19,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; +import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { DataSource, EntityManager, @@ -2029,6 +2030,37 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } + /** + * A train must not leave carrying nothing while its cargo sits in the shed. + * Blocks dispatch when a booking allocated to this train has warehouse + * inventory that never made it onto a wagon (received / stored / ready but not + * LOADED). Either load it from the warehouse Load-to-Train queue, or drop the + * booking's wagon allocation so it travels on a later train. + * + * Bookings with no warehouse inventory at all are NOT blocked — allocating a + * wagon before the goods arrive is normal planning; they simply aren't aboard. + */ + private async assertAllocatedCargoLoaded(scheduleId: string): Promise { + const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query( + `WITH ${SCHEDULE_BOOKINGS_CTE} + SELECT DISTINCT b.reference AS "reference", inv.status AS "status" + FROM sched_bookings sb + JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL + JOIN freight.warehouse_inventory inv + ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE sb.schedule_id = $1 + AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`, + [scheduleId], + ); + if (rows.length) { + const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', '); + throw new BadRequestException( + `Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` + + `Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`, + ); + } + } + async dispatchSchedule(scheduleId: string) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { @@ -2038,6 +2070,8 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); + // Don't leave received cargo behind on the platform. + await this.assertAllocatedCargoLoaded(scheduleId); // A locomotive may sit on many future schedules, but it can only pull one train // at a time — block dispatch while any set locomotive is out on a dispatched train. const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 70abf635b..3ad71b6fc 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { Booking } from '../bookings/entities/booking.entity'; import { Cargo } from '../cargoes/entities/cargoes.entity'; import { Company } from '../companies/entities/company.entity'; @@ -1543,30 +1544,11 @@ export class WarehouseInventoryService { /** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */ /** - * The bookings riding a train schedule. - * - * Export flow: booked -> paid -> received at the warehouse (first-mile or - * self-haul) -> GRN -> loaded onto the wagons allocated to it. A booking - * actually reaches a train through WAGON ALLOCATION - * (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations), - * which is what the allocation UI writes. `train_schedule_bookings` is only - * ever written by the demo seeders — keying off it alone left this queue - * permanently empty for real traffic — so both sources are unioned. + * Export flow this queue serves: booked -> paid -> received at the warehouse + * (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the + * booking. Which bookings ride a train comes from the shared CTE. */ - private readonly SCHEDULE_BOOKINGS_CTE = ` - sched_bookings AS ( - SELECT ts.id AS schedule_id, wba.booking_id - FROM freight.train_schedules ts - JOIN freight.train_set_wagons tsw - ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL - JOIN freight.wagon_booking_allocations wba - ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL - WHERE ts.deleted_at IS NULL - UNION - SELECT tsb.train_schedule_id, tsb.booking_id - FROM freight.train_schedule_bookings tsb - WHERE tsb.deleted_at IS NULL - )`; + private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE; async loadableTrains(): Promise { const rows: Array< @@ -1585,7 +1567,7 @@ export class WarehouseInventoryService { JOIN freight.warehouse_inventory inv ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL WHERE sb.schedule_id = ts.id - AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount", + AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING')) AS "readyCount", (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL @@ -1601,7 +1583,7 @@ export class WarehouseInventoryService { JOIN freight.warehouse_inventory inv2 ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL WHERE sb2.schedule_id = ts.id - AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') + AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED') ) ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, [['DRAFT', 'SCHEDULED']], @@ -1660,7 +1642,7 @@ export class WarehouseInventoryService { LIMIT 1 ) wl ON true WHERE sb.schedule_id = $1 - AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED') + AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED') ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`, [scheduleId], ); From d9db4cbc0cd663683acb13aa83510efd102f12bf Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:21:55 +0000 Subject: [PATCH 07/10] fix(train-scheduling): scope the not-loaded dispatch guard to EXPORT only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard fired on every dispatch. Loading out of an origin warehouse is an export concept — import cargo isn't loaded from a warehouse, so its warehouse inventory says nothing about what's aboard and the check would have blocked legitimate import dispatches. Derive the route direction (reusing deriveTradeDirection, as the warehouse loading queue does) and return early for anything that isn't EXPORT. Import and domestic behave exactly as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../train-scheduling.service.ts | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 97984be5f..bb85b4422 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2031,16 +2031,37 @@ export class TrainSchedulingService { } /** - * A train must not leave carrying nothing while its cargo sits in the shed. - * Blocks dispatch when a booking allocated to this train has warehouse - * inventory that never made it onto a wagon (received / stored / ready but not - * LOADED). Either load it from the warehouse Load-to-Train queue, or drop the - * booking's wagon allocation so it travels on a later train. + * EXPORT ONLY. An export train must not leave carrying nothing while its cargo + * sits in the shed: the goods are received into the origin warehouse, GRN'd and + * loaded onto the wagons allocated to the booking, so anything still in the + * warehouse at dispatch is being left behind. Blocks dispatch when an allocated + * booking has warehouse inventory that never made it onto a wagon (received / + * stored / ready but not LOADED) — either load it from the Load-to-Train queue, + * or drop the booking's wagon allocation so it rides a later train. + * + * Import/domestic are untouched: their cargo isn't loaded out of an origin + * warehouse, so warehouse inventory says nothing about what's aboard. * * Bookings with no warehouse inventory at all are NOT blocked — allocating a * wagon before the goods arrive is normal planning; they simply aren't aboard. */ private async assertAllocatedCargoLoaded(scheduleId: string): Promise { + const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> = + await this.dataSource.query( + `SELECT oy.country AS "originCountry", dy.country AS "destinationCountry" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.id = $1 AND ts.deleted_at IS NULL`, + [scheduleId], + ); + if (!route) return; + const direction = deriveTradeDirection( + { country: route.originCountry }, + { country: route.destinationCountry }, + ); + if (direction !== 'EXPORT') return; + const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query( `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT DISTINCT b.reference AS "reference", inv.status AS "status" @@ -2070,7 +2091,7 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); - // Don't leave received cargo behind on the platform. + // Export only: don't leave received cargo behind in the warehouse. await this.assertAllocatedCargoLoaded(scheduleId); // A locomotive may sit on many future schedules, but it can only pull one train // at a time — block dispatch while any set locomotive is out on a dispatched train. From a26fa61d6685fd867a72766601fb036847a52522 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:32:38 +0000 Subject: [PATCH 08/10] feat(warehouse): require a GRN before export cargo can be loaded onto a train MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export chain is: paid -> received into the warehouse -> GRN raised on arrival -> loaded onto the allocated wagon. Receipt was already structural (the inventory row only exists once receive() runs) and the wagon was already required, but the GRN was merely displayed, never enforced — so cargo could be loaded and dispatched without one. - loadable now also requires a GRN, so the queue won't offer un-GRN'd cargo. - loadItemsOntoTrain skips items with no GRN, so the rule holds server-side and a hand-made API call can't bypass it. - Read the GRN from inv.grn_number (what receive() stamps) and fall back to the note only for legacy/seeded rows; it previously read the note alone, which the real receive path merely mirrors. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 3ad71b6fc..19b1c0159 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1616,7 +1616,12 @@ export class WarehouseInventoryService { ct.container_number AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", inv.weight AS "weight", - substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber", + -- receive() stamps the GRN onto the row and mirrors it into the + -- note; prefer the column and fall back for legacy/seeded rows. + COALESCE( + inv.grn_number, + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) AS "grnNumber", inv.inspection_status AS "inspectionStatus", inv.status AS "status", wl.wagon_id AS "wagonId", @@ -1649,7 +1654,11 @@ export class WarehouseInventoryService { return rows.map((r) => ({ ...r, - loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId), + // Export flow: received at the warehouse -> GRN -> loaded onto its wagon. + // The row only exists once the goods were received, so requiring a GRN and + // an allocated wagon completes the chain. + loadable: + r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber), })); } @@ -1702,6 +1711,9 @@ export class WarehouseInventoryService { if (!item) { skip('Not assigned to this train'); continue; } if (item.status === 'LOADED') { skip('Already loaded'); continue; } if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; } + // Export: the GRN is raised when the goods arrive at the warehouse, and + // nothing rides a train without one. + if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; } if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } try { From a95667fde45c3805ecb4179783389fac38bd9b3e Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:44:02 +0000 Subject: [PATCH 09/10] fix(warehouse): raise a GRN on every warehouse receipt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requiring a GRN before loading only works if every path into the warehouse issues one. Two did not: autoUnloadArrived and unloadBooking created RECEIVED inventory with a null grn_number, so cargo that genuinely arrived — by first mile or self haul — would have been stuck un-loadable behind the new gate. Both now stamp a GRN, derived from the booking's trade direction, matching receive/bulkReceive/autoUnloadArrivedBookings. unloadBooking keeps an already-issued GRN when it re-unloads an existing row rather than reissuing one. Every path that creates warehouse inventory now issues a GRN, so the chain is seamless: booking arrives (first mile or self haul) -> received -> GRN -> loadable onto its allocated wagon. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 19b1c0159..3630aa9de 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1032,6 +1032,8 @@ export class WarehouseInventoryService { result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' }); continue; } + // Goods reaching the warehouse always get a GRN, whichever path brought + // them in — nothing loads onto a train without one. const saved = await this.inventoryRepository.create({ warehouseId: location.warehouseId, yardId: location.yardId, @@ -1041,6 +1043,11 @@ export class WarehouseInventoryService { weight: Number(booking.weight) || 0, status: 'RECEIVED', arrivedAt: new Date(), + grnNumber: this.generateGrnNumber( + booking.tradeDirection ?? 'WH', + booking.id, + new Date(), + ), notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', }); result.processedCount += 1; @@ -1061,6 +1068,14 @@ export class WarehouseInventoryService { /** Unload a single arrived booking into a chosen (or default) location. */ async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise { const existing = await this.inventoryRepository.findAll({ where: { bookingId } }); + // Goods reaching the warehouse always get a GRN, whichever path brought them + // in — nothing loads onto a train without one. + const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query( + `SELECT trade_direction AS "tradeDirection" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + const grnDirection = bookingRow?.tradeDirection ?? 'WH'; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1081,6 +1096,10 @@ export class WarehouseInventoryService { zoneId: location.zoneId, status: 'RECEIVED', arrivedAt, + // Keep an already-issued GRN; only raise one if this row never got it. + ...(existing[0].grnNumber + ? {} + : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1095,6 +1114,7 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, + grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); From fc636fbe4f77945c0ae3368041e789f8ce5a61b2 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 13:45:50 +0000 Subject: [PATCH 10/10] fix(warehouse): scope the arrival GRN stamp to EXPORT only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit made autoUnloadArrived and unloadBooking raise a GRN for any direction, which changed import behaviour. Import keeps its own GRN handling (autoUnloadArrivedBookings) and is left exactly as it was. Both paths now stamp a GRN only when the booking is EXPORT — the direction whose cargo needs one to be loaded onto a train. Import and domestic behave as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 3630aa9de..c489b6f89 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1032,8 +1032,8 @@ export class WarehouseInventoryService { result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' }); continue; } - // Goods reaching the warehouse always get a GRN, whichever path brought - // them in — nothing loads onto a train without one. + // EXPORT goods get their GRN on arrival at the warehouse — nothing loads + // onto a train without one. Import GRN handling is left untouched. const saved = await this.inventoryRepository.create({ warehouseId: location.warehouseId, yardId: location.yardId, @@ -1043,11 +1043,9 @@ export class WarehouseInventoryService { weight: Number(booking.weight) || 0, status: 'RECEIVED', arrivedAt: new Date(), - grnNumber: this.generateGrnNumber( - booking.tradeDirection ?? 'WH', - booking.id, - new Date(), - ), + ...(booking.tradeDirection === 'EXPORT' + ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) } + : {}), notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', }); result.processedCount += 1; @@ -1068,14 +1066,14 @@ export class WarehouseInventoryService { /** Unload a single arrived booking into a chosen (or default) location. */ async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise { const existing = await this.inventoryRepository.findAll({ where: { bookingId } }); - // Goods reaching the warehouse always get a GRN, whichever path brought them - // in — nothing loads onto a train without one. + // EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto + // a train without one. Import GRN handling is left untouched. const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query( `SELECT trade_direction AS "tradeDirection" FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, [bookingId], ); - const grnDirection = bookingRow?.tradeDirection ?? 'WH'; + const isExport = bookingRow?.tradeDirection === 'EXPORT'; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1096,10 +1094,10 @@ export class WarehouseInventoryService { zoneId: location.zoneId, status: 'RECEIVED', arrivedAt, - // Keep an already-issued GRN; only raise one if this row never got it. - ...(existing[0].grnNumber - ? {} - : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), + // Export only, and keep an already-issued GRN rather than reissuing. + ...(isExport && !existing[0].grnNumber + ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) } + : {}), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1114,7 +1112,9 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, - grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), + ...(isExport + ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) } + : {}), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id);