From 838cedae0d683e55117b7d968f21276d70b184d8 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 10:29:48 +0300 Subject: [PATCH] Ticket generation updates --- .../src/modules/tickets/tickets.service.ts | 38 ++++++++++--------- .../backoffice/src/app/bookings/page.tsx | 4 -- .../src/app/booking/confirmation/page.tsx | 26 ++++++++++--- 3 files changed, 41 insertions(+), 27 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index ada0b1ed6..1cff652e0 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -373,33 +373,37 @@ export class TicketsService { await this.prisma.ticket.deleteMany({ where: { bookingId } }); - // Generate one ticket per unique passenger (grouped by passengerName) + // Generate one ticket per passenger per leg. + // Round-trip / transit bookings have seats on multiple legs — each leg needs its own + // ticket so the voucher can match by (passengerName, leg) and gate scanners can + // validate each leg independently. const tickets = []; - - // Group seats by passenger - const passengerSeatsMap = new Map(); + + // Group seats by (passengerName, leg) + const passengerLegSeatsMap = new Map(); for (const bookingSeat of (booking as any).seats) { - const key = bookingSeat.passengerName; - if (!passengerSeatsMap.has(key)) { - passengerSeatsMap.set(key, []); + const key = `${bookingSeat.passengerName}|${bookingSeat.leg ?? 1}`; + if (!passengerLegSeatsMap.has(key)) { + passengerLegSeatsMap.set(key, []); } - passengerSeatsMap.get(key)!.push(bookingSeat); + passengerLegSeatsMap.get(key)!.push(bookingSeat); } - // Create one ticket per passenger - for (const [passengerName, passengerSeats] of passengerSeatsMap.entries()) { - // Use first seat for primary data - const primarySeat = passengerSeats[0]; - - const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`; + // Create one ticket per (passenger, leg) + for (const [key, legSeats] of passengerLegSeatsMap.entries()) { + const [passengerName] = key.split('|'); + const primarySeat = legSeats[0]; + const leg = primarySeat.leg ?? 1; + + const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}L${leg}`; - // Re-encode QR with ticketNumber included const qrDataWithTicket = JSON.stringify({ ref: booking.bookingRef, ticketNumber: barcodePayload, type: booking.bookingType, passenger: passengerName, - seats: passengerSeats.map(ps => ({ + leg, + seats: legSeats.map(ps => ({ seat: ps.seat?.seatNumber, coach: ps.seat?.coach?.number, leg: ps.leg || 1, @@ -414,7 +418,7 @@ export class TicketsService { bookingRef: booking.bookingRef, passengerName, seatId: primarySeat.seatId, - leg: primarySeat.leg || 1, + leg, scheduleId: primarySeat.scheduleId || booking.scheduleId, qrPayload: qrPayloadFinal, barcodePayload, diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 102ad329d..07013269a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -480,10 +480,6 @@ function BookingsPageContent() {

Payment Status

{b.paymentIntent?.status || 'PENDING'} - - - - diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index e9ed308d8..cd24bbe0c 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -115,11 +115,14 @@ export default function ConfirmationPage() { // Keep polling after CONFIRMED until tickets are issued — ticket generation runs // async after the booking transaction commits (see finalizePaymentSuccess in // payments.service.ts), so the first CONFIRMED fetch often returns an empty - // tickets array. + // tickets array. Free children have no ticket, so compare against adult count + // (at minimum 1 ticket must exist) rather than total passenger count. refetchInterval: (query) => { const data = query.state.data; if (!data || data.status !== "CONFIRMED") return false; - return (data.tickets?.length ?? 0) >= passengers.length ? false : FAST_POLL_INTERVAL_MS; + const adultCount = searchCriteria?.adultCount ?? passengers.filter((p) => !isChild(p)).length; + const expectedTickets = Math.max(1, adultCount); + return (data.tickets?.length ?? 0) >= expectedTickets ? false : FAST_POLL_INTERVAL_MS; }, }); @@ -266,12 +269,23 @@ export default function ConfirmationPage() { // Separate file per passenger, saved back-to-back with no macrotask (setTimeout) // between them — a setTimeout delay would push later saves outside the click's // synchronous user-activation window and risk iOS Safari silently blocking them. + // Build a consumed-ticket set so each ticket is used at most once even when + // multiple passengers share the same name (e.g. parent + child same surname). + const remainingTickets = [...(bookingData?.tickets ?? [])]; + const consumeTicket = (name: string, idx: number) => { + // 1. exact name match + let ti = remainingTickets.findIndex((t) => t.passengerName === name); + // 2. case-insensitive match (Verifayda may rewrite casing server-side) + if (ti === -1) ti = remainingTickets.findIndex((t) => t.passengerName?.toLowerCase() === name.toLowerCase()); + // 3. positional fallback + if (ti === -1 && idx < remainingTickets.length) ti = idx; + if (ti === -1) return null; + return remainingTickets.splice(ti, 1)[0]; + }; + for (let i = 0; i < passengers.length; i++) { const p = passengers[i]; - const matchedTicket = - bookingData?.tickets?.find((t) => t.passengerName === p.name) ?? - bookingData?.tickets?.[i] ?? - null; + const matchedTicket = consumeTicket(p.name || "", i); const ticketNumber = matchedTicket?.barcodePayload || "Not yet issued"; await generatePassengerVoucherPDF({