Files
edr-platform/apps/edr-passenger-api/src/modules/seats/seats.service.ts
2026-05-21 14:31:07 +03:00

175 lines
7.1 KiB
TypeScript

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<string[]> {
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<string, any[]>();
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<string> {
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 } }); }
}
}