mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
175 lines
7.1 KiB
TypeScript
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 } }); }
|
|
}
|
|
}
|