mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
@@ -373,33 +373,37 @@ export class TicketsService {
|
|||||||
|
|
||||||
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
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 = [];
|
const tickets = [];
|
||||||
|
|
||||||
// Group seats by passenger
|
// Group seats by (passengerName, leg)
|
||||||
const passengerSeatsMap = new Map<string, any[]>();
|
const passengerLegSeatsMap = new Map<string, any[]>();
|
||||||
for (const bookingSeat of (booking as any).seats) {
|
for (const bookingSeat of (booking as any).seats) {
|
||||||
const key = bookingSeat.passengerName;
|
const key = `${bookingSeat.passengerName}|${bookingSeat.leg ?? 1}`;
|
||||||
if (!passengerSeatsMap.has(key)) {
|
if (!passengerLegSeatsMap.has(key)) {
|
||||||
passengerSeatsMap.set(key, []);
|
passengerLegSeatsMap.set(key, []);
|
||||||
}
|
}
|
||||||
passengerSeatsMap.get(key)!.push(bookingSeat);
|
passengerLegSeatsMap.get(key)!.push(bookingSeat);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create one ticket per passenger
|
// Create one ticket per (passenger, leg)
|
||||||
for (const [passengerName, passengerSeats] of passengerSeatsMap.entries()) {
|
for (const [key, legSeats] of passengerLegSeatsMap.entries()) {
|
||||||
// Use first seat for primary data
|
const [passengerName] = key.split('|');
|
||||||
const primarySeat = passengerSeats[0];
|
const primarySeat = legSeats[0];
|
||||||
|
const leg = primarySeat.leg ?? 1;
|
||||||
|
|
||||||
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
|
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}L${leg}`;
|
||||||
|
|
||||||
// Re-encode QR with ticketNumber included
|
|
||||||
const qrDataWithTicket = JSON.stringify({
|
const qrDataWithTicket = JSON.stringify({
|
||||||
ref: booking.bookingRef,
|
ref: booking.bookingRef,
|
||||||
ticketNumber: barcodePayload,
|
ticketNumber: barcodePayload,
|
||||||
type: booking.bookingType,
|
type: booking.bookingType,
|
||||||
passenger: passengerName,
|
passenger: passengerName,
|
||||||
seats: passengerSeats.map(ps => ({
|
leg,
|
||||||
|
seats: legSeats.map(ps => ({
|
||||||
seat: ps.seat?.seatNumber,
|
seat: ps.seat?.seatNumber,
|
||||||
coach: ps.seat?.coach?.number,
|
coach: ps.seat?.coach?.number,
|
||||||
leg: ps.leg || 1,
|
leg: ps.leg || 1,
|
||||||
@@ -414,7 +418,7 @@ export class TicketsService {
|
|||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
passengerName,
|
passengerName,
|
||||||
seatId: primarySeat.seatId,
|
seatId: primarySeat.seatId,
|
||||||
leg: primarySeat.leg || 1,
|
leg,
|
||||||
scheduleId: primarySeat.scheduleId || booking.scheduleId,
|
scheduleId: primarySeat.scheduleId || booking.scheduleId,
|
||||||
qrPayload: qrPayloadFinal,
|
qrPayload: qrPayloadFinal,
|
||||||
barcodePayload,
|
barcodePayload,
|
||||||
|
|||||||
@@ -480,10 +480,6 @@ function BookingsPageContent() {
|
|||||||
<p className="text-xs text-muted-foreground mb-2">Payment Status</p>
|
<p className="text-xs text-muted-foreground mb-2">Payment Status</p>
|
||||||
<Badge variant="status" status={b.paymentIntent?.status || 'PENDING'}>{b.paymentIntent?.status || 'PENDING'}</Badge>
|
<Badge variant="status" status={b.paymentIntent?.status || 'PENDING'}>{b.paymentIntent?.status || 'PENDING'}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Field label="Method" value={b.paymentIntent?.method || '—'} />
|
|
||||||
<Field label="Paid At" value={b.paidAt ? formatDateTime(b.paidAt) : 'Not paid'} />
|
|
||||||
<Field label="Display Currency" value={b.displayCurrency || b.currency || 'ETB'} />
|
|
||||||
<Field label="Payment ID" value={b.paymentIntent?.id || '—'} mono truncate />
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -115,11 +115,14 @@ export default function ConfirmationPage() {
|
|||||||
// Keep polling after CONFIRMED until tickets are issued — ticket generation runs
|
// Keep polling after CONFIRMED until tickets are issued — ticket generation runs
|
||||||
// async after the booking transaction commits (see finalizePaymentSuccess in
|
// async after the booking transaction commits (see finalizePaymentSuccess in
|
||||||
// payments.service.ts), so the first CONFIRMED fetch often returns an empty
|
// 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) => {
|
refetchInterval: (query) => {
|
||||||
const data = query.state.data;
|
const data = query.state.data;
|
||||||
if (!data || data.status !== "CONFIRMED") return false;
|
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)
|
// 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
|
// between them — a setTimeout delay would push later saves outside the click's
|
||||||
// synchronous user-activation window and risk iOS Safari silently blocking them.
|
// 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++) {
|
for (let i = 0; i < passengers.length; i++) {
|
||||||
const p = passengers[i];
|
const p = passengers[i];
|
||||||
const matchedTicket =
|
const matchedTicket = consumeTicket(p.name || "", i);
|
||||||
bookingData?.tickets?.find((t) => t.passengerName === p.name) ??
|
|
||||||
bookingData?.tickets?.[i] ??
|
|
||||||
null;
|
|
||||||
const ticketNumber = matchedTicket?.barcodePayload || "Not yet issued";
|
const ticketNumber = matchedTicket?.barcodePayload || "Not yet issued";
|
||||||
|
|
||||||
await generatePassengerVoucherPDF({
|
await generatePassengerVoucherPDF({
|
||||||
|
|||||||
Reference in New Issue
Block a user