mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 04:15:43 +00:00
Round trip booking and additional enhancements
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@@ -30,6 +30,10 @@ export class TicketsController {
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List all tickets with optional filters' })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'ACTIVE | USED | CANCELLED' })
|
||||
@ApiQuery({ name: 'skip', required: false })
|
||||
@ApiQuery({ name: 'take', required: false })
|
||||
listTickets(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@@ -77,14 +81,26 @@ export class TicketsController {
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Validate ticket at gate with audit logging',
|
||||
description: 'Validates ticket QR/barcode at station gate. Records validation in audit log with timestamp, gate, and validator.'
|
||||
description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.'
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['validatorId'],
|
||||
properties: {
|
||||
validatorId: { type: 'string', example: 'agent-uuid' },
|
||||
gateId: { type: 'string', example: 'gate-01' },
|
||||
leg: { type: 'string', enum: ['OUTBOUND', 'RETURN'], description: 'Required for round-trip bookings' },
|
||||
},
|
||||
},
|
||||
})
|
||||
validate(
|
||||
@Param('bookingRef') ref: string,
|
||||
@Body('validatorId') validatorId: string,
|
||||
@Body('gateId') gateId?: string
|
||||
@Body('gateId') gateId?: string,
|
||||
@Body('leg') leg?: 'OUTBOUND' | 'RETURN',
|
||||
) {
|
||||
return this.service.validate(ref, validatorId, gateId);
|
||||
return this.service.validate(ref, validatorId, gateId, leg);
|
||||
}
|
||||
|
||||
@Get(':ticketId/validation-logs')
|
||||
@@ -106,7 +122,31 @@ export class TicketsController {
|
||||
@Post('validate/offline')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Batch import offline validations' })
|
||||
@ApiOperation({
|
||||
summary: 'Batch import offline validations',
|
||||
description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.'
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
validations: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['bookingRef', 'validatorId', 'validatedAt'],
|
||||
properties: {
|
||||
bookingRef: { type: 'string' },
|
||||
validatorId: { type: 'string' },
|
||||
gateId: { type: 'string' },
|
||||
validatedAt: { type: 'string', format: 'date-time' },
|
||||
leg: { type: 'string', enum: ['OUTBOUND', 'RETURN'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
validateOfflineBatch(@Body() body: { validations: any[] }) {
|
||||
return this.service.validateOfflineBatch(body.validations);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ interface OfflineValidation {
|
||||
validatorId: string;
|
||||
gateId?: string;
|
||||
validatedAt: string;
|
||||
leg?: 'OUTBOUND' | 'RETURN';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -49,6 +50,10 @@ export class TicketsService {
|
||||
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,
|
||||
@@ -213,22 +218,67 @@ export class TicketsService {
|
||||
};
|
||||
}
|
||||
|
||||
async validate(bookingRef: string, validatorId: string, gateId?: string) {
|
||||
async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: 'OUTBOUND' | 'RETURN') {
|
||||
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) {
|
||||
|
||||
const isRoundTrip = booking.bookingType === 'ROUND_TRIP';
|
||||
// For one-way bookings use the original single-validation guard
|
||||
if (!isRoundTrip) {
|
||||
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: 'REJECTED', reason: 'ALREADY_VALIDATED' }
|
||||
data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' },
|
||||
});
|
||||
throw new BadRequestException('Ticket already validated');
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
|
||||
}
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
|
||||
|
||||
// Round-trip: track which leg is being boarded
|
||||
const resolvedLeg = leg ?? 'OUTBOUND';
|
||||
const now = new Date();
|
||||
const bookingData: Record<string, any> = {};
|
||||
|
||||
if (resolvedLeg === 'OUTBOUND') {
|
||||
if ((booking as any).outboundBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({
|
||||
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any,
|
||||
});
|
||||
throw new BadRequestException('Outbound leg already used');
|
||||
}
|
||||
bookingData.outboundBoardedAt = now;
|
||||
// Stamp the ticket's first validation
|
||||
if (!ticket.validatedAt) {
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } });
|
||||
}
|
||||
} else {
|
||||
if ((booking as any).returnBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({
|
||||
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any,
|
||||
});
|
||||
throw new BadRequestException('Return leg already used');
|
||||
}
|
||||
bookingData.returnBoardedAt = now;
|
||||
}
|
||||
|
||||
// Derive the new composite status
|
||||
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'; // return pending/no-show
|
||||
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, gateId, status: 'APPROVED' }
|
||||
data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any,
|
||||
});
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: new Date() };
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
|
||||
}
|
||||
|
||||
async getValidationLogs(ticketId: string) {
|
||||
@@ -256,6 +306,8 @@ export class TicketsService {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
@@ -265,11 +317,13 @@ export class TicketsService {
|
||||
const processedRefs = new Set<string>();
|
||||
|
||||
for (const v of validations) {
|
||||
if (processedRefs.has(v.bookingRef)) {
|
||||
const offlineLeg = v.leg;
|
||||
const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef;
|
||||
if (processedRefs.has(dedupKey)) {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
processedRefs.add(v.bookingRef);
|
||||
processedRefs.add(dedupKey);
|
||||
|
||||
try {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
|
||||
@@ -286,11 +340,21 @@ export class TicketsService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ticket.validatedAt) {
|
||||
if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP') {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// For round-trip, check per-leg duplication
|
||||
if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) {
|
||||
const alreadyUsed =
|
||||
offlineLeg === 'OUTBOUND' ? !!(booking as any).outboundBoardedAt : !!(booking as any).returnBoardedAt;
|
||||
if (alreadyUsed) {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
|
||||
@@ -301,11 +365,24 @@ export class TicketsService {
|
||||
ticketId: ticket.id,
|
||||
validatorId: v.validatorId,
|
||||
gateId: v.gateId,
|
||||
leg: v.leg ?? null,
|
||||
status: 'APPROVED',
|
||||
validatedAt: new Date(v.validatedAt),
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
// update returnLegStatus for round-trip offline validations
|
||||
if (booking.bookingType === 'ROUND_TRIP' && offlineLeg) {
|
||||
const bookingData: Record<string, any> =
|
||||
offlineLeg === 'OUTBOUND' ? { outboundBoardedAt: new Date(v.validatedAt) } : { returnBoardedAt: new Date(v.validatedAt) };
|
||||
const outboundUsed = offlineLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
|
||||
const returnUsed = offlineLeg === '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 });
|
||||
}
|
||||
|
||||
results.success++;
|
||||
} catch (err) {
|
||||
results.failed++;
|
||||
|
||||
Reference in New Issue
Block a user