mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
337 lines
12 KiB
TypeScript
337 lines
12 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;
|
|
}
|
|
|
|
@Injectable()
|
|
export class TicketsService {
|
|
constructor(private prisma: PrismaService) {}
|
|
|
|
async listTickets(filters: { search?: string; status?: 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 = { status: filters.status };
|
|
}
|
|
const tickets = await this.prisma.ticket.findMany({
|
|
where,
|
|
include: {
|
|
booking: {
|
|
include: {
|
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
|
seats: { include: { seat: { include: { coach: 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,
|
|
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`);
|
|
|
|
const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`);
|
|
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 },
|
|
});
|
|
|
|
// Update all booked seats from HELD to BOOKED and create permanent seat blocks
|
|
const seatIds = booking.seats.map(bs => bs.seatId);
|
|
for (const seatId of seatIds) {
|
|
// Update seat status to BOOKED
|
|
await this.prisma.seat.update({
|
|
where: { id: seatId },
|
|
data: { status: 'BOOKED' },
|
|
});
|
|
// Create permanent seat blocks for all booked seats
|
|
await this.prisma.seatBlock.create({
|
|
data: {
|
|
seatId,
|
|
reason: `Permanently booked in ticket ${ticket.id}`,
|
|
blockedBy: 'SYSTEM',
|
|
approvedBy: 'SYSTEM',
|
|
}
|
|
}).catch(() => null); // Ignore if already exists
|
|
}
|
|
|
|
return ticket;
|
|
}
|
|
|
|
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(bookingRef: string, validatorId: string, gateId?: string) {
|
|
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');
|
|
if (ticket.validatedAt) {
|
|
await this.prisma.gateValidationLog.create({
|
|
data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' }
|
|
});
|
|
throw new BadRequestException('Ticket already validated');
|
|
}
|
|
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
|
|
await this.prisma.gateValidationLog.create({
|
|
data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' }
|
|
});
|
|
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
|
|
}
|
|
|
|
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: 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,
|
|
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) {
|
|
if (processedRefs.has(v.bookingRef)) {
|
|
results.duplicate++;
|
|
continue;
|
|
}
|
|
processedRefs.add(v.bookingRef);
|
|
|
|
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) {
|
|
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,
|
|
status: 'APPROVED',
|
|
validatedAt: new Date(v.validatedAt),
|
|
},
|
|
});
|
|
|
|
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 };
|
|
}
|
|
}
|