Files
edr-platform/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts
2026-06-17 22:23:24 +03:00

504 lines
23 KiB
TypeScript

import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import * as QRCode from 'qrcode';
interface OfflineValidation {
bookingRef: string;
validatorId: string;
gateId?: string;
validatedAt: string;
leg?: string;
}
@Injectable()
export class TicketsService {
constructor(private prisma: PrismaService) {}
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) {
const where: any = {};
if (filters.search) {
where.OR = [
{ bookingRef: { contains: filters.search, mode: 'insensitive' } },
{ barcodePayload: { contains: filters.search, mode: 'insensitive' } },
{ booking: { bookingRef: { contains: filters.search, mode: 'insensitive' } } },
];
}
if (filters.status) {
where.booking = { ...where.booking, status: filters.status };
}
if (filters.originStationId) {
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
}
if (filters.destinationStationId) {
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } };
}
if (filters.arrivalDate) {
const start = new Date(filters.arrivalDate);
const end = new Date(filters.arrivalDate);
end.setDate(end.getDate() + 1);
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } };
}
const tickets = await this.prisma.ticket.findMany({
where,
include: {
booking: {
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } },
},
},
},
skip: filters.skip,
take: filters.take,
orderBy: { issuedAt: 'desc' },
});
const total = await this.prisma.ticket.count({ where });
return {
items: tickets.map((t) => ({
id: t.id,
ticketNumber: t.barcodePayload,
bookingRef: t.bookingRef,
booking: {
bookingRef: t.booking.bookingRef,
status: t.booking.status,
bookingType: t.booking.bookingType,
returnLegStatus: (t.booking as any).returnLegStatus ?? null,
outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
totalMinor: t.booking.totalMinor,
currency: t.booking.currency,
displayCurrency: t.booking.displayCurrency,
displayTotalMinor: t.booking.displayTotalMinor,
passenger: t.booking.passenger?.user || { fullName: 'Guest', email: t.booking.contactEmail },
contactEmail: t.booking.contactEmail,
},
schedule: t.booking.schedule,
seat: t.booking.seats[0]?.seat,
status: t.status,
validatedAt: t.validatedAt,
createdAt: t.issuedAt,
})),
total,
skip: filters.skip,
take: filters.take,
};
}
async generate(bookingId: string) {
if (!bookingId) throw new BadRequestException('Booking ID is required');
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } },
},
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
// Build a compact multi-leg payload for the QR so gate scanners see all legs
const legSummary = this.buildLegSummary(booking);
const qrData = JSON.stringify({
ref: booking.bookingRef,
type: booking.bookingType,
legs: legSummary,
});
const qrPayload = await QRCode.toDataURL(qrData);
const barcodePayload = `EDR${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
const ticket = await this.prisma.ticket.upsert({
where: { bookingId },
update: { qrPayload, barcodePayload },
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
});
// Block all seats across all legs
const seatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of seatIds) {
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BOOKED' } });
await this.prisma.seatBlock.create({
data: { seatId, reason: `Booked in ticket ${ticket.id}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
}).catch(() => null);
}
return { ...ticket, legs: legSummary };
}
private buildLegSummary(booking: any) {
const seatsByLeg = new Map<number, any[]>();
for (const bs of booking.seats) {
const leg = bs.leg ?? 1;
if (!seatsByLeg.has(leg)) seatsByLeg.set(leg, []);
seatsByLeg.get(leg)!.push(bs);
}
return Array.from(seatsByLeg.entries())
.sort(([a], [b]) => a - b)
.map(([leg, seats]) => ({
leg,
scheduleId: (seats[0] as any).scheduleId ?? booking.scheduleId,
passengers: seats.map(bs => ({
name: bs.passengerName,
category: bs.passengerCategory,
coach: bs.seat?.coach?.number,
seat: bs.seat?.seatNumber,
fareMinor: bs.fareMinor,
})),
}));
}
async updateSeats(bookingId: string, newSeatIds: string[]) {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { seats: true, ticket: true },
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (!booking.ticket) throw new BadRequestException('No ticket found for this booking');
// Remove old seat blocks
const oldSeatIds = booking.seats.map(bs => bs.seatId);
for (const seatId of oldSeatIds) {
await this.prisma.seatBlock.deleteMany({
where: {
seatId,
reason: { contains: booking.ticket.id }
}
});
}
// Remove old booking seats
await this.prisma.bookingSeat.deleteMany({ where: { bookingId } });
// Create new seat blocks
for (const seatId of newSeatIds) {
await this.prisma.seatBlock.create({
data: {
seatId,
reason: `Permanently booked in ticket ${booking.ticket.id}`,
blockedBy: 'SYSTEM',
approvedBy: 'SYSTEM',
}
}).catch(() => null);
}
// Create new booking seats (placeholder with minimal data)
for (let i = 0; i < newSeatIds.length; i++) {
await this.prisma.bookingSeat.create({
data: {
bookingId,
seatId: newSeatIds[i],
passengerName: `Passenger ${i + 1}`,
}
});
}
return { success: true, updatedSeats: newSeatIds.length };
}
async getByMerchantOrderId(merchantOrderId: string) {
const intent = await this.prisma.paymentIntent.findUnique({
where: { merchantOrderId },
select: { bookingId: true },
});
if (!intent) throw new NotFoundException(`No payment intent found for order ${merchantOrderId}`);
const booking = await this.prisma.booking.findUnique({
where: { id: intent.bookingId },
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true },
});
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
const seat = booking.seats[0];
return {
id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status,
fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name,
departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name,
coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName,
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
barcodePayload: booking.ticket.barcodePayload,
};
}
async getByRef(bookingRef: string) {
const booking = await this.prisma.booking.findUnique({
where: { bookingRef },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: true } } } },
ticket: true
},
});
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
const seat = booking.seats[0];
return {
id: booking.ticket.id,
bookingId: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
fromStationName: booking.schedule.originStation.name,
toStationName: booking.schedule.destinationStation.name,
departureAt: booking.schedule.departureAt,
trainName: booking.schedule.train.name,
coachLabel: seat?.seat.coach.number,
seatLabel: seat?.seat.seatNumber,
passengerName: seat?.passengerName,
priceMinor: booking.totalMinor,
currency: booking.currency,
qrPayload: booking.ticket.qrPayload,
barcodePayload: booking.ticket.barcodePayload
};
}
async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) {
// Accept either a ticket UUID or a bookingRef
let bookingRef = ticketIdOrRef;
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ticketIdOrRef);
if (isUuid) {
const ticket = await this.prisma.ticket.findUnique({ where: { id: ticketIdOrRef }, select: { bookingRef: true } });
if (!ticket) throw new NotFoundException('Ticket not found');
bookingRef = ticket.bookingRef;
}
const resolvedValidatorId = validatorId || 'BACKOFFICE';
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
if (!booking) throw new NotFoundException('Booking not found');
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
if (!ticket) throw new NotFoundException('Ticket not found');
const type = booking.bookingType;
const now = new Date();
// ── ONE_WAY / TRANSIT (single scan) ───────────────────────────────────
if (type === 'ONE_WAY') {
if (ticket.validatedAt) {
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
return { validated: true, ticketId: ticket.id, validatedAt: now };
}
// ── TRANSIT — leg=LEG1 or leg=LEG2 ──────────────────────────────────
if (type === 'TRANSIT') {
const resolvedLeg = (leg ?? 'LEG1').toUpperCase();
if (resolvedLeg !== 'LEG1' && resolvedLeg !== 'LEG2') {
throw new BadRequestException('For TRANSIT bookings supply leg=LEG1 or leg=LEG2');
}
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
const alreadyValidated = logs.some(l => l.leg === resolvedLeg);
if (alreadyValidated) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
throw new BadRequestException(`${resolvedLeg} already validated`);
}
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
// ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ────────────────────────
if (type === 'ROUND_TRIP') {
let resolvedLeg = (leg ?? '').toUpperCase();
// Auto-detect next unused leg when called from backoffice without a leg param
if (!resolvedLeg) {
resolvedLeg = !(booking as any).outboundBoardedAt ? 'OUTBOUND' : 'RETURN';
}
const bookingData: Record<string, any> = {};
if (resolvedLeg === 'OUTBOUND') {
if ((booking as any).outboundBoardedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any });
throw new BadRequestException('Outbound leg already used');
}
bookingData.outboundBoardedAt = now;
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
} else if (resolvedLeg === 'RETURN') {
if ((booking as any).returnBoardedAt) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any });
throw new BadRequestException('Return leg already used');
}
bookingData.returnBoardedAt = now;
} else {
throw new BadRequestException('For ROUND_TRIP bookings supply leg=OUTBOUND or leg=RETURN');
}
const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt;
if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED';
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
// ── ROUND_TRIP_TRANSIT — leg=OUTBOUND_LEG1|OUTBOUND_LEG2|RETURN_LEG1|RETURN_LEG2
if (type === 'ROUND_TRIP_TRANSIT') {
const validLegs = ['OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'];
const resolvedLeg = (leg ?? '').toUpperCase();
if (!validLegs.includes(resolvedLeg)) {
throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`);
}
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
if (logs.some(l => l.leg === resolvedLeg)) {
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
throw new BadRequestException(`${resolvedLeg} already validated`);
}
const bookingData: Record<string, any> = {};
if (resolvedLeg.startsWith('OUTBOUND') && !logs.some(l => l.leg?.startsWith('OUTBOUND') && l.status === 'APPROVED')) {
bookingData.outboundBoardedAt = now;
}
if (resolvedLeg.startsWith('RETURN') && !logs.some(l => l.leg?.startsWith('RETURN') && l.status === 'APPROVED')) {
bookingData.returnBoardedAt = now;
}
const allOutboundDone = ['OUTBOUND_LEG1','OUTBOUND_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
const allReturnDone = ['RETURN_LEG1','RETURN_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
if (allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'BOTH_USED';
else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY';
if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
}
// Fallback for unknown booking types — single scan
if (ticket.validatedAt) {
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
}
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
return { validated: true, ticketId: ticket.id, validatedAt: now };
}
async getValidationLogs(ticketId: string) {
return this.prisma.gateValidationLog.findMany({
where: { ticketId },
orderBy: { validatedAt: 'desc' }
});
}
async exportOfflineData(tripId: string) {
const bookings = await this.prisma.booking.findMany({
where: { scheduleId: tripId, status: 'CONFIRMED' },
include: {
ticket: true,
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } },
},
});
return bookings.map((b) => ({
bookingRef: b.bookingRef,
ticketId: b.ticket?.id,
passengerName: b.seats[0]?.passengerName,
seatLabel: b.seats[0]?.seat.seatNumber,
coachLabel: b.seats[0]?.seat.coach.number,
qrPayload: b.ticket?.qrPayload,
status: b.status,
bookingType: b.bookingType,
returnLegStatus: (b as any).returnLegStatus ?? null,
validatedAt: b.ticket?.validatedAt,
}));
}
async validateOfflineBatch(validations: OfflineValidation[]) {
const results = { success: 0, failed: 0, duplicate: 0, errors: [] as string[] };
const processedRefs = new Set<string>();
for (const v of validations) {
const offlineLeg = v.leg;
const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef;
if (processedRefs.has(dedupKey)) {
results.duplicate++;
continue;
}
processedRefs.add(dedupKey);
try {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
if (!booking) {
results.failed++;
results.errors.push(`Booking ${v.bookingRef} not found`);
continue;
}
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
if (!ticket) {
results.failed++;
results.errors.push(`Ticket for ${v.bookingRef} not found`);
continue;
}
if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP' &&
booking.bookingType !== 'TRANSIT' && booking.bookingType !== 'ROUND_TRIP_TRANSIT') {
results.duplicate++;
continue;
}
// For multi-leg bookings, check per-leg duplication
const isMultiLeg = booking.bookingType === 'ROUND_TRIP' ||
booking.bookingType === 'TRANSIT' ||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
if (isMultiLeg && offlineLeg) {
const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
if (existingLogs.some(l => l.leg === offlineLeg)) {
results.duplicate++;
continue;
}
}
await this.prisma.ticket.update({
where: { id: ticket.id },
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
});
await this.prisma.gateValidationLog.create({
data: {
ticketId: ticket.id,
validatorId: v.validatorId,
gateId: v.gateId,
leg: v.leg ?? null,
status: 'APPROVED',
validatedAt: new Date(v.validatedAt),
} as any,
});
// update boarding timestamps for multi-leg bookings
const isMultiLegBooking = booking.bookingType === 'ROUND_TRIP' ||
booking.bookingType === 'TRANSIT' ||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
if (isMultiLegBooking && offlineLeg) {
const bookingData: Record<string, any> = {};
const isOutbound = (offlineLeg as string) === 'OUTBOUND' || (offlineLeg as string) === 'OUTBOUND_LEG1' || (offlineLeg as string) === 'LEG1';
const isReturn = (offlineLeg as string) === 'RETURN' || (offlineLeg as string) === 'RETURN_LEG1' || (offlineLeg as string) === 'RETURN_LEG2';
if (isOutbound && !(booking as any).outboundBoardedAt) bookingData.outboundBoardedAt = new Date(v.validatedAt);
if (isReturn && !(booking as any).returnBoardedAt) bookingData.returnBoardedAt = new Date(v.validatedAt);
if (Object.keys(bookingData).length) {
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
}
}
results.success++;
} catch (err) {
results.failed++;
results.errors.push(`Error processing ${v.bookingRef}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return results;
}
async delete(id: string) {
const ticket = await this.prisma.ticket.findUnique({ where: { id } });
if (!ticket) throw new NotFoundException('Ticket not found');
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: id } });
// Remove seat blocks associated with this ticket
await this.prisma.seatBlock.deleteMany({
where: {
reason: { contains: id }
}
});
await this.prisma.ticket.delete({ where: { id } });
return { deleted: true, ticketId: id };
}
}