Files
edr-platform/apps/edr-passenger-api/src/modules/seats/seats.service.ts

561 lines
19 KiB
TypeScript

import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { HoldSeatsDto } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service';
@Injectable()
export class SeatsService {
constructor(
private prisma: PrismaService,
private segmentsService: SegmentsService,
) {}
async getSeatMap(scheduleId: string, coachId?: string) {
const assignments = await this.prisma.coachAssignment.findMany({
where: { scheduleId, ...(coachId ? { coachId } : {}) },
include: {
coach: {
include: {
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
coachType: { include: { seatClasses: true } },
},
},
},
orderBy: { positionNumber: 'asc' },
});
console.log(`[getSeatMap] scheduleId=${scheduleId}, coachId=${coachId}, found ${assignments.length} coach assignments`);
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds);
const response = {
coaches: assignments.map((a) => {
const allSeats = a.coach.seats;
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
return {
id: a.coach.id,
assignmentId: a.id,
coachNumber: a.coach.number,
label: a.coach.number,
mode: a.coach.status,
name: `Coach ${a.coach.number}`,
seatClasses: seatClassNames,
seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard',
positionNumber: a.positionNumber,
seatArrangement: a.coach.arrangement,
totalSeats: a.coach.capacity,
seats: allSeats.map((s) => ({
id: s.id,
seatNumber: s.seatNumber,
number: s.seatNumber,
label: s.seatNumber,
status: effectiveStatuses.get(s.id) ?? s.status,
kind: s.kind,
row: s.row,
col: s.col,
isWindow: s.isWindow,
isAisle: s.isAisle,
bedPosition: s.bedPosition,
coach: {
id: a.coach.id,
coachNumber: a.coach.number,
label: a.coach.number,
},
})),
};
}),
};
console.log(`[getSeatMap] returning ${response.coaches.length} coaches with seats`);
return response;
}
async resolveEffectiveStatuses(
scheduleId: string,
seatIds: string[],
): Promise<Map<string, string>> {
const statusMap = new Map<string, string>();
if (seatIds.length === 0) return statusMap;
const activeHolds = await this.prisma.seatHold.findMany({
where: {
scheduleId,
expiresAt: { gt: new Date() },
seatIds: { hasSome: seatIds },
},
select: { seatIds: true },
});
for (const hold of activeHolds) {
for (const seatId of hold.seatIds) {
if (seatIds.includes(seatId)) statusMap.set(seatId, 'HELD');
}
}
const bookedSegments = await this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId: { in: seatIds },
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
select: { seatId: true },
});
for (const seg of bookedSegments) {
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
}
return statusMap;
}
async holdSeats(dto: HoldSeatsDto) {
const passengerIds = dto.passengers.map(p => p.passengerId);
const seatIds = dto.passengers.map(p => p.seatId);
if (new Set(passengerIds).size !== passengerIds.length)
throw new BadRequestException('Duplicate passengerId in passengers list');
if (new Set(seatIds).size !== seatIds.length)
throw new BadRequestException('Duplicate seatId in passengers list');
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
const hold = await this.prisma.$transaction(async (tx) => {
const seats = await tx.seat.findMany({
where: { id: { in: seatIds } },
select: { id: true, status: true, seatNumber: true },
});
if (seats.length !== seatIds.length) {
const found = new Set(seats.map(s => s.id));
const missing = seatIds.filter(id => !found.has(id));
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
}
const blocked = seats.filter(s => s.status === 'BLOCKED');
if (blocked.length > 0)
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are blocked`);
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
const stopTimes = await tx.tripStopTime.findMany({
where: { scheduleId: dto.scheduleId },
select: { stationId: true, sequence: true },
});
const seqOf = (stationId: string) =>
stopTimes.find(s => s.stationId === stationId)?.sequence;
const reqFrom = seqOf(dto.originStationId);
const reqTo = seqOf(dto.destinationStationId);
if (reqFrom === undefined || reqTo === undefined)
throw new BadRequestException('Origin or destination station not found');
if (reqFrom >= reqTo)
throw new BadRequestException('Origin must come before destination');
const activeHolds = await tx.seatHold.findMany({
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
select: { seatIds: true, createdBy: true },
});
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = [];
for (const h of activeHolds) {
try {
if (h.createdBy?.trimStart().startsWith('{')) {
const meta = JSON.parse(h.createdBy);
const holdFrom = seqOf(meta.originStationId);
const holdTo = seqOf(meta.destinationStationId);
if (holdFrom !== undefined && holdTo !== undefined) {
parsedHolds.push({
seatIds: h.seatIds,
from: holdFrom,
to: holdTo,
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
});
}
}
} catch { /* ignore */ }
}
for (const { passengerId, seatId } of dto.passengers) {
for (const hold of parsedHolds) {
const legsOverlap = hold.from < reqTo && reqFrom < hold.to;
if (!legsOverlap) continue;
if (hold.seatIds.includes(seatId)) {
throw new ConflictException(
`Seat ${seatLabelById[seatId]} is already held for this leg`,
);
}
if (hold.passengerIds.includes(passengerId)) {
throw new ConflictException(
`Passenger already holds a seat on this journey leg`,
);
}
}
}
const holdMeta = {
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
};
return tx.seatHold.create({
data: {
scheduleId: dto.scheduleId,
passengerId: dto.passengers[0].passengerId,
seatIds,
createdBy: JSON.stringify(holdMeta),
expiresAt,
},
});
});
return this.enrichHold(hold);
}
async getHolds(scheduleId?: string, passengerId?: string) {
const holds = await this.prisma.seatHold.findMany({
where: {
expiresAt: { gt: new Date() },
...(scheduleId ? { scheduleId } : {}),
...(passengerId ? { passengerId } : {}),
},
orderBy: { createdAt: 'desc' },
});
return Promise.all(holds.map(h => this.enrichHold(h)));
}
async getHold(holdId: string) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
if (!hold) throw new NotFoundException('Hold not found');
return this.enrichHold(hold);
}
private async enrichHold(hold: any) {
let originStationId: string | null = null;
let destinationStationId: string | null = null;
let passengerSeatMap: { passengerId: string; seatId: string }[] = [];
try {
if (hold.createdBy) {
const raw = hold.createdBy;
if (typeof raw === 'string' && raw.trimStart().startsWith('{')) {
const meta = JSON.parse(raw);
originStationId = meta.originStationId ?? null;
destinationStationId = meta.destinationStationId ?? null;
passengerSeatMap = Array.isArray(meta.passengers) ? meta.passengers : [];
}
}
} catch { /* ignore */ }
const seatIds = hold.seatIds as string[];
const [schedule, originStation, destinationStation, seats] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: hold.scheduleId },
include: { train: true, originStation: true, destinationStation: true },
}),
originStationId ? this.prisma.station.findUnique({ where: { id: originStationId } }) : null,
destinationStationId ? this.prisma.station.findUnique({ where: { id: destinationStationId } }) : null,
this.prisma.seat.findMany({
where: { id: { in: seatIds } },
include: { coach: true },
}),
]);
let originSequence: number | null = null;
let destinationSequence: number | null = null;
if (originStationId && destinationStationId) {
const stopTimes = await this.prisma.tripStopTime.findMany({
where: { scheduleId: hold.scheduleId, stationId: { in: [originStationId, destinationStationId] } },
select: { stationId: true, sequence: true },
});
originSequence = stopTimes.find(s => s.stationId === originStationId)?.sequence ?? null;
destinationSequence = stopTimes.find(s => s.stationId === destinationStationId)?.sequence ?? null;
}
const seatById = Object.fromEntries(seats.map(s => [s.id, s]));
const passengers = passengerSeatMap.length > 0
? passengerSeatMap.map(({ passengerId, seatId }) => {
const s = seatById[seatId];
return {
passengerId,
seat: s ? {
id: s.id,
label: s.seatNumber,
seatNumber: s.seatNumber,
coach: s.coach.number,
seatClass: 'Standard',
row: s.row,
col: s.col,
} : { id: seatId },
};
})
: seatIds.map(seatId => {
const s = seatById[seatId];
return {
passengerId: hold.passengerId,
seat: s ? {
id: s.id,
label: s.seatNumber,
seatNumber: s.seatNumber,
coach: s.coach.number,
seatClass: 'Standard',
row: s.row,
col: s.col,
} : { id: seatId },
};
});
return {
holdId: hold.id,
expiresAt: hold.expiresAt,
createdAt: hold.createdAt,
ttlSeconds: Math.max(0, Math.floor((hold.expiresAt.getTime() - Date.now()) / 1000)),
schedule: schedule ? {
id: schedule.id,
trainNumber: schedule.train.number,
trainName: schedule.train.name,
departureAt: schedule.departureAt,
arrivalAt: schedule.arrivalAt,
fullRouteOrigin: schedule.originStation.name,
fullRouteDestination: schedule.destinationStation.name,
} : null,
leg: {
originStationId,
originStationName: originStation?.name ?? null,
originStationCode: originStation?.code ?? null,
originSequence,
destinationStationId,
destinationStationName: destinationStation?.name ?? null,
destinationStationCode: destinationStation?.code ?? null,
destinationSequence,
},
passengers,
};
}
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.seatHold.delete({ where: { id: holdId } });
return { released: true, holdId };
}
async confirmSeats(seatIds: string[]) {
// No-op
}
async releaseSeats(seatIds: string[]) {
if (seatIds.length > 0) {
await this.prisma.journeySegment.deleteMany({
where: { seatId: { in: seatIds } },
});
}
}
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
const seats = await this.prisma.seat.findMany({
where: {
coach: { assignments: { some: { scheduleId } } },
status: 'AVAILABLE',
seatNumber: { not: '' },
NOT: { seatNumber: { startsWith: '-' } },
},
orderBy: [{ coach: { number: '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(scheduleId: string): Promise<string> {
const assignments = await this.prisma.coachAssignment.findMany({
where: { scheduleId },
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
});
const rows = ['coachId,coachLabel,row,col,seatNumber,kind,status,premiumFeeMinor'];
for (const a of assignments) {
for (const seat of a.coach.seats) {
rows.push(`${a.coach.id},${a.coach.number},${seat.row},${seat.col},${seat.seatNumber},${seat.kind},${seat.status},${seat.premiumFeeMinor}`);
}
}
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, seatNumber, kind, status, premiumFeeMinor] = parts;
if (!coachId || !row || !col || !seatNumber) {
errors.push(`Line ${i + 2}: Missing required fields`);
invalid++;
continue;
}
valid++;
}
return { valid, invalid, errors: errors.slice(0, 10) };
}
async importSeatsCSV(scheduleId: 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'] };
}
for (let i = 0; i < lines.length; i++) {
try {
const parts = lines[i].split(',');
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
await this.prisma.seat.upsert({
where: { coachId_row_col: { coachId, row: parseInt(row), col } },
update: {
seatNumber,
kind: kind as any,
status: status as any,
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
},
create: {
coachId,
row: parseInt(row),
col,
seatNumber,
kind: kind as any,
status: status as any,
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
},
});
imported++;
} catch (err) {
errors.push(`Line ${i + 2}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return { imported, errors: errors.slice(0, 10) };
}
async blockSeat(seatId: string, reason: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
await this.prisma.seat.update({
where: { id: seatId },
data: { status: 'BLOCKED' },
});
await this.prisma.seatBlock.create({
data: {
seatId,
reason,
blockedBy: 'system',
},
});
return { blocked: true, seatId, reason };
}
async unblockSeat(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
await this.prisma.seat.update({
where: { id: seatId },
data: { status: 'AVAILABLE' },
});
await this.prisma.seatBlock.deleteMany({
where: { seatId },
});
return { unblocked: true, seatId };
}
async removeSeat(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
if (!seat.seatNumber) throw new BadRequestException('Seat already removed');
// Mark removed seat with negative seatNumber (e.g., '1' → '-1') to show empty space
const negatedNumber = `-${seat.seatNumber}`;
await this.prisma.seat.update({
where: { id: seatId },
data: { seatNumber: negatedNumber },
});
return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
}
async undoRemoveSeat(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
if (!seat.seatNumber || !seat.seatNumber.startsWith('-')) {
throw new BadRequestException('Seat is not removed');
}
// Restore original seatNumber by removing the negative sign
const originalNumber = seat.seatNumber.slice(1);
await this.prisma.seat.update({
where: { id: seatId },
data: { seatNumber: originalNumber },
});
return { restored: true, seatId, seatNumber: originalNumber };
}
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
const now = new Date();
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } });
if (expired.length === 0) return;
const expiredIds = expired.map(h => h.id);
for (const hold of expired) {
await this.releaseSeats(hold.seatIds);
}
await this.prisma.seatHold.deleteMany({ where: { id: { in: expiredIds } } });
}
}