import { Injectable, ConflictException, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { HoldSeatsDto } from './seats.dto'; import { Cron, CronExpression } from '@nestjs/schedule'; @Injectable() export class SeatsService { constructor(private prisma: PrismaService) {} // ── Seat Map ────────────────────────────────────────────────────────────── async getSeatMap(tripId: string, coachId?: string) { const coaches = await this.prisma.coach.findMany({ where: { tripId, ...(coachId ? { id: coachId } : {}) }, include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } }); return { coaches: coaches.map((coach) => ({ id: coach.id, name: `Coach ${coach.label}`, serviceClass: coach.serviceClass, seats: coach.seats.map((s) => ({ id: s.id, number: s.label, status: s.status, kind: s.kind })), })), }; } // ── Hold / Release ──────────────────────────────────────────────────────── async holdSeats(dto: HoldSeatsDto) { const expiresAt = new Date(Date.now() + 15 * 60 * 1000); const hold = await this.prisma.$transaction(async (tx) => { const seats = await tx.seat.findMany({ where: { id: { in: dto.seatIds } }, select: { id: true, status: true, heldUntil: true } }); const unavailable = seats.filter((s) => s.status === 'BOOKED' || s.status === 'BLOCKED' || (s.status === 'HELD' && s.heldUntil && s.heldUntil > new Date())); if (unavailable.length > 0) throw new ConflictException('One or more seats unavailable'); await tx.seat.updateMany({ where: { id: { in: dto.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } }); return tx.seatHold.create({ data: { tripId: dto.tripId, passengerId: dto.passengerId, seatIds: dto.seatIds, fareQuoteId: dto.fareQuoteId, expiresAt } }); }); return { id: hold.id, tripId: dto.tripId, seatIds: dto.seatIds, expiresAt }; } async releaseHold(holdId: string) { const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } }); if (!hold) throw new NotFoundException('Hold not found'); await this.prisma.seat.updateMany({ where: { id: { in: hold.seatIds }, status: 'HELD' }, data: { status: 'AVAILABLE', heldUntil: null } }); await this.prisma.seatHold.delete({ where: { id: holdId } }); return { released: true }; } async confirmSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'BOOKED', heldUntil: null } }); } async releaseSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'AVAILABLE', heldUntil: null } }); } async autoAssignSeats(tripId: string, count: number, serviceClass: string, eligibility?: string): Promise { const seats = await this.prisma.seat.findMany({ where: { coach: { tripId, serviceClass: serviceClass as any }, status: 'AVAILABLE', ...(eligibility ? { eligibility } : {}), }, orderBy: [{ coach: { label: 'asc' } }, { row: 'asc' }, { col: 'asc' }], }); if (seats.length < count) { throw new ConflictException(`Only ${seats.length} seats available, requested ${count}`); } const assigned = this.findContiguousSeats(seats, count); return assigned.map((s) => s.id); } private findContiguousSeats(seats: any[], count: number): any[] { if (count === 1) return [seats[0]]; const grouped = new Map(); for (const seat of seats) { const key = `${seat.coachId}-${seat.row}`; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key)!.push(seat); } for (const rowSeats of grouped.values()) { if (rowSeats.length >= count) { return rowSeats.slice(0, count); } } return seats.slice(0, count); } async exportSeatsCSV(tripId: string): Promise { const coaches = await this.prisma.coach.findMany({ where: { tripId }, include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } }, }); const rows = ['coachId,coachLabel,row,col,label,kind,status,premiumFeeMinor,eligibility']; for (const coach of coaches) { for (const seat of coach.seats) { rows.push( `${coach.id},${coach.label},${seat.row},${seat.col},${seat.label},${seat.kind},${seat.status},${seat.premiumFeeMinor},${seat.eligibility || ''}`, ); } } return rows.join('\n'); } async previewSeatsCSV(csvContent: string): Promise<{ valid: number; invalid: number; errors: string[] }> { const lines = csvContent.trim().split('\n').slice(1); const errors: string[] = []; let valid = 0; let invalid = 0; for (let i = 0; i < lines.length; i++) { const parts = lines[i].split(','); if (parts.length < 8) { errors.push(`Line ${i + 2}: Invalid format`); invalid++; continue; } const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor] = parts; if (!coachId || !row || !col || !label) { errors.push(`Line ${i + 2}: Missing required fields`); invalid++; continue; } valid++; } return { valid, invalid, errors: errors.slice(0, 10) }; } async importSeatsCSV(tripId: string, csvContent: string, commit: boolean): Promise<{ imported: number; errors: string[] }> { const lines = csvContent.trim().split('\n').slice(1); const errors: string[] = []; let imported = 0; if (!commit) { return { imported: 0, errors: ['Preview mode - use commit=true to apply changes'] }; } for (let i = 0; i < lines.length; i++) { try { const parts = lines[i].split(','); const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor, eligibility] = parts; await this.prisma.seat.upsert({ where: { coachId_row_col: { coachId, row: parseInt(row), col } }, update: { label, kind: kind as any, status: status as any, premiumFeeMinor: parseInt(premiumFeeMinor) || 0, eligibility: eligibility || null, }, create: { coachId, row: parseInt(row), col, label, kind: kind as any, status: status as any, premiumFeeMinor: parseInt(premiumFeeMinor) || 0, eligibility: eligibility || null, }, }); imported++; } catch (err) { errors.push(`Line ${i + 2}: ${err instanceof Error ? err.message : String(err)}`); } } return { imported, errors: errors.slice(0, 10) }; } @Cron(CronExpression.EVERY_MINUTE) async expireHolds() { const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } }); for (const hold of expired) { await this.releaseSeats(hold.seatIds); await this.prisma.seatHold.delete({ where: { id: hold.id } }); } } }