Merge pull request #903 from Tria-plc/alpha

Ticketing updates
This commit is contained in:
Stephanos A.
2026-07-22 12:55:47 +03:00
committed by GitHub
6 changed files with 98 additions and 2 deletions

View File

@@ -9,9 +9,10 @@ import { VerifaydaModule } from '../verifayda/verifayda.module';
import { CurrencyModule } from '../currency/currency.module';
import { AuthModule } from '../auth/auth.module';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
import { TicketsModule } from '../tickets/tickets.module';
@Module({
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule],
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule],
controllers: [BookingsController],
providers: [BookingsService, GuestBookingService],
exports: [BookingsService, GuestBookingService]

View File

@@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
@@ -101,6 +102,7 @@ export class BookingsService {
private readonly prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
private readonly seatsService: SeatsService,
private readonly ticketsService: TicketsService,
private readonly eventEmitter: EventEmitter2,
private readonly verifaydaService: VerifaydaService,
private readonly currencyService: CurrencyService,
@@ -1903,6 +1905,34 @@ export class BookingsService {
};
}
// Auto-heal: if booking is CONFIRMED, payment SUCCEEDED, but tickets are missing
// (ticket generation failed silently after payment — see finalizePaymentSuccess in
// payments.service.ts), attempt to generate them now so the confirmation page
// doesn't show "Not yet issued".
if (
booking.status === 'CONFIRMED' &&
(booking as any).tickets?.length === 0 &&
(booking as any).paymentIntent?.status === 'SUCCEEDED'
) {
try {
await this.ticketsService.generate(booking.id);
// Re-fetch to include the newly created tickets
const refreshed = await this.prisma.booking.findUnique({
where: { id: booking.id },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: true,
priceTier: { select: { priceMinor: true } },
},
});
if (refreshed) Object.assign(booking, refreshed);
} catch (err) {
this.logger.warn(`getByRef: auto-generate tickets failed for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}`);
}
}
const outboundSegment = this.resolveSegmentStations(
(booking as any).schedule,
(booking as any).originStationId,

View File

@@ -9,6 +9,17 @@ import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
export class TicketsController {
constructor(private service: TicketsService) {}
@Post('generate-missing')
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Generate tickets for all confirmed bookings that are missing them',
description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed counts.',
})
generateMissing() {
return this.service.generateMissing();
}
@Post('smart-assign/:bookingId')
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
@ApiBearerAuth('IAM-auth')

View File

@@ -830,6 +830,34 @@ export class TicketsService {
};
}
async generateMissing(): Promise<{ processed: number; generated: number; failed: number; details: any[] }> {
const confirmedWithNoTickets = await this.prisma.booking.findMany({
where: {
status: 'CONFIRMED',
tickets: { none: {} },
paymentIntent: { status: 'SUCCEEDED' },
},
select: { id: true, bookingRef: true },
});
const details: any[] = [];
let generated = 0;
let failed = 0;
for (const booking of confirmedWithNoTickets) {
try {
await this.generate(booking.id);
generated++;
details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'generated' });
} catch (err) {
failed++;
details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'failed', error: err instanceof Error ? err.message : String(err) });
}
}
return { processed: confirmedWithNoTickets.length, generated, failed, details };
}
async delete(id: string) {
const ticket = await this.prisma.ticket.findUnique({ where: { id } });
if (!ticket) throw new NotFoundException('Ticket not found');

View File

@@ -98,6 +98,21 @@ export default function TicketsPage() {
queryFn: () => apiClient.get('/fleet/coaches'),
});
const [generateMissingResult, setGenerateMissingResult] = useState<any>(null);
const generateMissingMutation = useMutation({
mutationFn: () => ticketsApi.generateMissing(),
onSuccess: (result: any) => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
setGenerateMissingResult(result);
setSuccessMessage(`Generated ${result.generated} ticket(s) for ${result.processed} booking(s)${result.failed ? ` (${result.failed} failed)` : ''}`);
setTimeout(() => setSuccessMessage(''), 6000);
},
onError: (error: any) => {
alert(error?.response?.data?.message || error?.message || 'Failed to generate missing tickets');
},
});
const boardMutation = useMutation({
mutationFn: ({ ticketId, leg }: { ticketId: string; leg?: 'outbound' | 'inbound' }) =>
ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString(), leg: leg === 'inbound' ? 'RETURN' : 'OUTBOUND' }),
@@ -546,7 +561,17 @@ export default function TicketsPage() {
<h1 className="text-2xl font-bold text-foreground">Tickets</h1>
<p className="text-muted-foreground">Manage tickets and validations</p>
</div>
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
<div className="flex items-center gap-2">
<ActionButton
icon={Download}
variant="secondary"
loading={generateMissingMutation.isPending}
onClick={() => generateMissingMutation.mutate()}
>
Generate Missing
</ActionButton>
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
</div>
{/* Filters */}

View File

@@ -224,6 +224,7 @@ export const ticketsApi = {
return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get<any>(`/tickets/${id}`),
generateMissing: () => apiClient.post<any>('/tickets/generate-missing', {}),
validate: (ticketId: string, data: any) => apiClient.post<any>(`/tickets/${ticketId}/validate`, data),
scanAndBoard: (qrCodeOrRef: string, data: any) => apiClient.post<any>(`/tickets/scan-board/${encodeURIComponent(qrCodeOrRef)}`, data),
regenerate: (ticketId: string) => apiClient.post<any>(`/tickets/${ticketId}/regenerate`),