mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
1022 lines
40 KiB
TypeScript
1022 lines
40 KiB
TypeScript
import { Injectable, ConflictException, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
|
import { PrismaService } from '../../common/prisma.service';
|
|
import { HoldSeatsDto, JourneyDirection } from './seats.dto';
|
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
|
import { SegmentsService } from '../segments/segments.service';
|
|
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
|
import { AuditService } from '../../common/audit.service';
|
|
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
|
|
|
@Injectable()
|
|
export class SeatsService {
|
|
private readonly logger = new Logger(SeatsService.name);
|
|
|
|
constructor(
|
|
private prisma: PrismaService,
|
|
private segmentsService: SegmentsService,
|
|
private systemConfig: SystemConfigService,
|
|
private auditService: AuditService,
|
|
) {}
|
|
|
|
async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) {
|
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
|
where: { id: scheduleId },
|
|
select: { originStationId: true, destinationStationId: true },
|
|
});
|
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
|
|
|
const assignments = await this.prisma.coachAssignment.findMany({
|
|
where: {
|
|
scheduleId,
|
|
...(coachTypeId ? { coach: { coachTypeId } } : {}),
|
|
},
|
|
include: {
|
|
coach: {
|
|
include: {
|
|
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
|
|
coachType: { include: { seatClasses: true } },
|
|
},
|
|
},
|
|
},
|
|
orderBy: { positionNumber: 'asc' },
|
|
});
|
|
|
|
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
|
const effectiveStatuses = await this.resolveEffectiveStatuses(
|
|
scheduleId,
|
|
allSeatIds,
|
|
originStationId ?? schedule.originStationId,
|
|
destinationStationId ?? schedule.destinationStationId,
|
|
journeyDirection
|
|
);
|
|
|
|
return {
|
|
coaches: assignments.map((a) => {
|
|
const allSeats = a.coach.seats;
|
|
const coachTypeName = a.coach.coachType?.name ?? '';
|
|
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
|
|
const isBedCoach = this.isBedCoach(coachTypeName);
|
|
// Compute actual beds-per-room from first room to correctly identify VIP (4) vs Economy (6)
|
|
const bedsPerRoom = isBedCoach
|
|
? allSeats.filter((s: any) => s.row === (allSeats[0] as any)?.row).length
|
|
: 0;
|
|
const bedCategory = isBedCoach ? this.getBedCategory(coachTypeName, bedsPerRoom) : null;
|
|
|
|
const mappedSeats = allSeats.map((s: any) => {
|
|
const resolvedBedPosition = isBedCoach
|
|
? this.resolveBedPosition(s.col, s.bedPosition)
|
|
: s.bedPosition;
|
|
const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
|
|
return {
|
|
id: s.id,
|
|
seatNumber: s.seatNumber,
|
|
label: s.seatNumber,
|
|
status: effectiveStatus,
|
|
kind: s.kind,
|
|
row: s.row,
|
|
col: s.col,
|
|
isWindow: s.isWindow,
|
|
isAisle: s.isAisle,
|
|
bedPosition: resolvedBedPosition,
|
|
// Bed-specific fields (only when coach is a bed coach)
|
|
...(isBedCoach ? {
|
|
room_id: `${a.coach.id}-R${s.row}`,
|
|
category: bedCategory,
|
|
position: this.colToPosition(s.col, a.coach.arrangement),
|
|
bed_type: this.bedPositionToType(resolvedBedPosition),
|
|
} : {}),
|
|
};
|
|
});
|
|
|
|
const base = {
|
|
id: a.coach.id,
|
|
assignmentId: a.id,
|
|
coachNumber: a.coach.number,
|
|
label: a.coach.number,
|
|
mode: a.coach.status,
|
|
name: `Coach ${a.coach.number}`,
|
|
coachTypeId: a.coach.coachType?.id ?? null,
|
|
coachTypeName,
|
|
isBedCoach,
|
|
bedCategory,
|
|
seatClasses: seatClassNames,
|
|
seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard',
|
|
positionNumber: a.positionNumber,
|
|
seatArrangement: a.coach.arrangement,
|
|
totalSeats: a.coach.capacity,
|
|
};
|
|
|
|
if (isBedCoach) {
|
|
// Group seats into rooms; row = room number
|
|
const roomMap = new Map<number, any[]>();
|
|
for (const seat of mappedSeats) {
|
|
if (!roomMap.has(seat.row)) roomMap.set(seat.row, []);
|
|
roomMap.get(seat.row)!.push(seat);
|
|
}
|
|
const rooms = Array.from(roomMap.entries())
|
|
.sort(([a], [b]) => a - b)
|
|
.map(([roomNumber, beds]) => ({
|
|
room_id: `${a.coach.id}-R${roomNumber}`,
|
|
roomNumber,
|
|
category: bedCategory,
|
|
totalBeds: beds.length,
|
|
beds,
|
|
}));
|
|
return { ...base, rooms, seats: mappedSeats };
|
|
}
|
|
|
|
return { ...base, seats: mappedSeats };
|
|
}),
|
|
};
|
|
}
|
|
|
|
private isBedCoach(coachTypeName: string): boolean {
|
|
const n = coachTypeName.toLowerCase();
|
|
return n.includes('bed') || n.includes('berth') || n.includes('sleeper') || n.includes('couchette');
|
|
}
|
|
|
|
private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' {
|
|
const n = coachTypeName.toLowerCase();
|
|
// Explicit VIP name check first
|
|
if (n.includes('vip')) return 'VIP_BED';
|
|
// Fall back to actual beds-per-room count: 4 = VIP, 6 = Economy
|
|
if (bedsPerRoom === 4) return 'VIP_BED';
|
|
return 'ECONOMY_BED';
|
|
}
|
|
|
|
// col format: L1, L2, L3, R1, R2, R3 (new) or A, B, C, D (legacy)
|
|
// arrangement e.g. "2+2", "3+3", "2+0" → "leftCount+rightCount"
|
|
private colToPosition(col: string, arrangement?: string): 'LEFT' | 'RIGHT' | null {
|
|
if (!col) return null;
|
|
// New named-col format: L1, L2, R1, R2 …
|
|
if (/^L\d+$/.test(col)) return 'LEFT';
|
|
if (/^R\d+$/.test(col)) return 'RIGHT';
|
|
// Legacy single-letter cols (A, B, C, D …): derive from arrangement
|
|
const colIndex = col.toUpperCase().charCodeAt(0) - 65; // A=0, B=1, C=2 …
|
|
if (arrangement) {
|
|
const [leftStr, rightStr] = arrangement.split('+');
|
|
const rightCount = parseInt(rightStr ?? '0', 10);
|
|
if (rightCount === 0) return 'LEFT'; // single-side berth coach — all LEFT
|
|
const leftCount = parseInt(leftStr, 10) || 0;
|
|
return colIndex < leftCount ? 'LEFT' : 'RIGHT';
|
|
}
|
|
return 'LEFT'; // safe default when no arrangement info
|
|
}
|
|
|
|
private bedPositionToType(bedPosition: string | null): 'LOWER' | 'MIDDLE' | 'UPPER' | null {
|
|
if (!bedPosition) return null;
|
|
const map: Record<string, 'LOWER' | 'MIDDLE' | 'UPPER'> = {
|
|
lower: 'LOWER', middle: 'MIDDLE', upper: 'UPPER',
|
|
};
|
|
return map[bedPosition.toLowerCase()] ?? null;
|
|
}
|
|
|
|
// Derives bedPosition from col when the seat was created with legacy A/B/C columns
|
|
// (new coaches use L1/L2/L3/R1/R2/R3 and store bedPosition explicitly).
|
|
// Col-to-tier mapping: A → lower, B → middle, C → upper, D → upper (4-tier).
|
|
private resolveBedPosition(col: string, storedBedPosition: string | null): string | null {
|
|
if (storedBedPosition) return storedBedPosition;
|
|
const legacyMap: Record<string, string> = { A: 'lower', B: 'middle', C: 'upper', D: 'upper' };
|
|
// Also handle numeric suffix in L/R cols: L1→lower, L2→middle, L3→upper
|
|
if (/^[LR]\d+$/.test(col)) {
|
|
const tier = parseInt(col.slice(1), 10);
|
|
if (tier === 1) return 'lower';
|
|
if (tier === 2) return 'middle';
|
|
return 'upper';
|
|
}
|
|
return legacyMap[col?.toUpperCase()] ?? null;
|
|
}
|
|
|
|
async resolveEffectiveStatuses(
|
|
scheduleId: string,
|
|
seatIds: string[],
|
|
originStationId?: string,
|
|
destinationStationId?: string,
|
|
journeyDirection?: JourneyDirection,
|
|
): Promise<Map<string, string>> {
|
|
const statusMap = new Map<string, string>();
|
|
if (seatIds.length === 0) return statusMap;
|
|
|
|
// Resolve the requested leg's sequence range once
|
|
let reqFrom: number | undefined;
|
|
let reqTo: number | undefined;
|
|
let allStopTimes: { stationId: string; sequence: number }[] | null = null;
|
|
|
|
const getStopTimes = async () => {
|
|
if (!allStopTimes) {
|
|
allStopTimes = await this.prisma.tripStopTime.findMany({
|
|
where: { scheduleId },
|
|
select: { stationId: true, sequence: true },
|
|
});
|
|
}
|
|
return allStopTimes;
|
|
};
|
|
|
|
if (originStationId && destinationStationId) {
|
|
const stops = await getStopTimes();
|
|
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
|
reqFrom = seqOf(originStationId);
|
|
reqTo = seqOf(destinationStationId);
|
|
}
|
|
|
|
// ── Active holds ──────────────────────────────────────────────────────────
|
|
const activeHolds = await this.prisma.seatHold.findMany({
|
|
where: { scheduleId, expiresAt: { gt: new Date() }, seatIds: { hasSome: seatIds } },
|
|
select: { seatIds: true, createdBy: true },
|
|
});
|
|
|
|
const reqDirection = journeyDirection || JourneyDirection.ONE_WAY;
|
|
|
|
for (const hold of activeHolds) {
|
|
let holdFrom: number | undefined;
|
|
let holdTo: number | undefined;
|
|
let holdDirection = JourneyDirection.ONE_WAY;
|
|
|
|
try {
|
|
if (hold.createdBy?.trimStart().startsWith('{')) {
|
|
const meta = JSON.parse(hold.createdBy);
|
|
const stops = await getStopTimes();
|
|
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
|
holdFrom = seqOf(meta.originStationId);
|
|
holdTo = seqOf(meta.destinationStationId);
|
|
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
for (const seatId of hold.seatIds) {
|
|
if (!seatIds.includes(seatId)) continue;
|
|
|
|
// Check leg overlap
|
|
const legsOverlap =
|
|
reqFrom === undefined || reqTo === undefined ||
|
|
holdFrom === undefined || holdTo === undefined ||
|
|
(holdFrom < reqTo && reqFrom < holdTo);
|
|
|
|
// Check direction conflict
|
|
const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection);
|
|
|
|
if (!legsOverlap || !directionsConflict) {
|
|
// This hold does not conflict with the requested leg/direction.
|
|
// Explicitly mark AVAILABLE so the DB's HELD status (set by the
|
|
// opposing-direction hold) does not bleed through via the fallback.
|
|
if (!statusMap.has(seatId)) statusMap.set(seatId, 'AVAILABLE');
|
|
continue;
|
|
}
|
|
|
|
statusMap.set(seatId, 'HELD');
|
|
}
|
|
}
|
|
|
|
// ── Confirmed bookings via JourneySegment ─────────────────────────────────
|
|
const bookedSegments = await this.prisma.journeySegment.findMany({
|
|
where: {
|
|
scheduleId,
|
|
seatId: { in: seatIds },
|
|
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
|
},
|
|
select: { seatId: true, departureStationId: true, arrivalStationId: true },
|
|
});
|
|
|
|
if (reqFrom !== undefined && reqTo !== undefined) {
|
|
const stops = await getStopTimes();
|
|
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
|
for (const seg of bookedSegments) {
|
|
if (!seg.seatId) continue;
|
|
const segFrom = seqOf(seg.departureStationId);
|
|
const segTo = seqOf(seg.arrivalStationId);
|
|
if (segFrom !== undefined && segTo !== undefined) {
|
|
if (segFrom < reqTo && reqFrom < segTo) statusMap.set(seg.seatId, 'BOOKED');
|
|
} else {
|
|
statusMap.set(seg.seatId, 'BOOKED');
|
|
}
|
|
}
|
|
} else {
|
|
for (const seg of bookedSegments) {
|
|
if (seg.seatId) statusMap.set(seg.seatId, 'BOOKED');
|
|
}
|
|
}
|
|
|
|
return statusMap;
|
|
}
|
|
|
|
/**
|
|
* Check if two journey directions conflict (should not be allowed simultaneously)
|
|
* For round-trip bookings: OUTBOUND and RETURN should NOT conflict on same schedule
|
|
*/
|
|
private checkDirectionConflict(current: JourneyDirection, existing: JourneyDirection): boolean {
|
|
// OUTBOUND and RETURN are allowed simultaneously (round-trip on different schedules)
|
|
if ((current === JourneyDirection.OUTBOUND && existing === JourneyDirection.RETURN) ||
|
|
(current === JourneyDirection.RETURN && existing === JourneyDirection.OUTBOUND)) {
|
|
return false;
|
|
}
|
|
|
|
// Same directions conflict (e.g., two OUTBOUND or two RETURN bookings)
|
|
if (current === existing) {
|
|
return true;
|
|
}
|
|
|
|
// ONE_WAY conflicts with other ONE_WAY bookings only
|
|
if (current === JourneyDirection.ONE_WAY && existing === JourneyDirection.ONE_WAY) {
|
|
return true;
|
|
}
|
|
|
|
// ONE_WAY with OUTBOUND/RETURN: conflict (to maintain safety for legacy bookings)
|
|
if (current === JourneyDirection.ONE_WAY || existing === JourneyDirection.ONE_WAY) {
|
|
return true;
|
|
}
|
|
|
|
// Default: no conflict
|
|
return false;
|
|
}
|
|
|
|
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 [holdMinutes, cutoffHours] = await Promise.all([
|
|
this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES),
|
|
this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE),
|
|
]);
|
|
const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000);
|
|
|
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
|
where: { id: dto.scheduleId },
|
|
select: { departureAt: true },
|
|
});
|
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
|
|
|
const msUntilDeparture = schedule.departureAt.getTime() - Date.now();
|
|
const cutoffMs = cutoffHours * 60 * 60 * 1000;
|
|
if (msUntilDeparture <= cutoffMs) {
|
|
throw new BadRequestException(
|
|
`Seats cannot be held within ${cutoffHours} hour${cutoffHours !== 1 ? 's' : ''} of departure`,
|
|
);
|
|
}
|
|
|
|
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' || s.status === 'BOOKED');
|
|
if (blocked.length > 0)
|
|
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
|
|
|
|
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 },
|
|
});
|
|
|
|
// When no stop times exist, fall back to the schedule's own origin/destination
|
|
// with synthetic sequences so the hold can still be created.
|
|
let effectiveStopTimes = stopTimes;
|
|
if (stopTimes.length === 0) {
|
|
const sched = await tx.trainSchedule.findUnique({
|
|
where: { id: dto.scheduleId },
|
|
select: { originStationId: true, destinationStationId: true },
|
|
});
|
|
if (sched) {
|
|
effectiveStopTimes = [
|
|
{ stationId: sched.originStationId, sequence: 0 },
|
|
{ stationId: sched.destinationStationId, sequence: 1 },
|
|
];
|
|
}
|
|
}
|
|
|
|
const seqOf = (stationId: string) => effectiveStopTimes.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 currentDirection = dto.journeyDirection || JourneyDirection.ONE_WAY;
|
|
const activeHolds = await tx.seatHold.findMany({
|
|
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
|
|
select: { seatIds: true, createdBy: true },
|
|
});
|
|
|
|
for (const h of activeHolds) {
|
|
const rawSeatIds = h.seatIds as string[];
|
|
let holdDirection = JourneyDirection.ONE_WAY;
|
|
let holdFrom = 0, holdTo = Number.MAX_SAFE_INTEGER;
|
|
let passengerIds: string[] = [];
|
|
let legUnknown = true;
|
|
|
|
try {
|
|
if (h.createdBy?.trimStart().startsWith('{')) {
|
|
const meta = JSON.parse(h.createdBy);
|
|
holdFrom = seqOf(meta.originStationId) ?? 0;
|
|
holdTo = seqOf(meta.destinationStationId) ?? Number.MAX_SAFE_INTEGER;
|
|
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
|
|
passengerIds = (meta.passengers ?? []).map((p: any) => p.passengerId);
|
|
legUnknown = !meta.originStationId || !meta.destinationStationId;
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
const legsOverlap = legUnknown || (holdFrom < reqTo && reqFrom < holdTo);
|
|
if (!legsOverlap) continue;
|
|
|
|
const directionsConflict = this.checkDirectionConflict(currentDirection, holdDirection);
|
|
if (!directionsConflict) continue;
|
|
|
|
for (const { passengerId, seatId } of dto.passengers) {
|
|
if (rawSeatIds.includes(seatId)) {
|
|
throw new ConflictException(`Seat ${seatLabelById[seatId]} is already held for this leg`);
|
|
}
|
|
if (!legUnknown && passengerIds.includes(passengerId)) {
|
|
throw new ConflictException(`Passenger already holds a seat on this journey leg`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const bookedSegments = await tx.journeySegment.findMany({
|
|
where: {
|
|
scheduleId: dto.scheduleId,
|
|
seatId: { in: seatIds },
|
|
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
|
},
|
|
select: { seatId: true, departureStationId: true, arrivalStationId: true },
|
|
});
|
|
|
|
for (const seg of bookedSegments) {
|
|
if (!seg.seatId) continue;
|
|
const segFrom = seqOf(seg.departureStationId);
|
|
const segTo = seqOf(seg.arrivalStationId);
|
|
const overlaps = (segFrom === undefined || segTo === undefined) ? true : segFrom < reqTo && reqFrom < segTo;
|
|
if (overlaps) {
|
|
throw new ConflictException(`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`);
|
|
}
|
|
}
|
|
|
|
const holdMeta = {
|
|
originStationId: dto.originStationId,
|
|
destinationStationId: dto.destinationStationId,
|
|
journeyDirection: currentDirection,
|
|
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
|
|
};
|
|
|
|
await tx.seat.updateMany({
|
|
where: { id: { in: seatIds } },
|
|
data: { status: 'HELD' },
|
|
});
|
|
|
|
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.$transaction([
|
|
this.prisma.seat.updateMany({
|
|
where: { id: { in: hold.seatIds as string[] }, status: 'HELD' },
|
|
data: { status: 'AVAILABLE' },
|
|
}),
|
|
this.prisma.seatHold.delete({ where: { id: holdId } }),
|
|
]);
|
|
return { released: true, holdId };
|
|
}
|
|
|
|
// Called right after a booking (PNR) is created, and again on successful payment.
|
|
// Extends the SeatHold(s) covering these seats to the booking's actual payment
|
|
// deadline — the same MIN(createdAt + 2h, departureAt - 30min) window TasksService
|
|
// uses to auto-cancel unpaid bookings — instead of leaving them on the original
|
|
// short seat-selection hold (5 min by default). Without this, the hold could expire
|
|
// while the customer was still on the payment page, and a second customer could
|
|
// hold/book the exact same seat out from under them.
|
|
async confirmSeats(seatIds: string[], now: Date = new Date()): Promise<void> {
|
|
if (seatIds.length === 0) return;
|
|
|
|
const holds = await this.prisma.seatHold.findMany({
|
|
where: { seatIds: { hasSome: seatIds } },
|
|
select: { id: true, scheduleId: true, expiresAt: true },
|
|
});
|
|
if (holds.length === 0) return;
|
|
|
|
const scheduleIds = Array.from(new Set(holds.map(h => h.scheduleId)));
|
|
const schedules = await this.prisma.trainSchedule.findMany({
|
|
where: { id: { in: scheduleIds } },
|
|
select: { id: true, departureAt: true },
|
|
});
|
|
const departureById = new Map(schedules.map(s => [s.id, s.departureAt]));
|
|
|
|
let extended = 0;
|
|
await Promise.all(
|
|
holds.map(async (hold) => {
|
|
const departureAt = departureById.get(hold.scheduleId);
|
|
if (!departureAt) return;
|
|
const deadline = computePaymentDeadline(now, departureAt);
|
|
// Only ever extend forward — never shorten a hold that's already valid longer
|
|
// than the payment deadline would give it (e.g. a second confirmSeats call on
|
|
// the same booking, or a hold that was already extended).
|
|
if (deadline <= hold.expiresAt) return;
|
|
await this.prisma.seatHold.update({ where: { id: hold.id }, data: { expiresAt: deadline } });
|
|
extended++;
|
|
}),
|
|
);
|
|
|
|
if (extended > 0) {
|
|
this.logger.log(
|
|
`Extended ${extended} seat hold(s) covering ${seatIds.length} seat(s) to their booking's payment deadline`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Delete the Journey (and its JourneySegments) scoped to this booking.
|
|
async releaseSeats(bookingId: string) {
|
|
await this.prisma.journey.deleteMany({ where: { bookingId } as any });
|
|
}
|
|
|
|
async getCoachesWithAvailability(scheduleId: string, originStationId?: string, destinationStationId?: string) {
|
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
|
where: { id: scheduleId },
|
|
select: { originStationId: true, destinationStationId: true },
|
|
});
|
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
|
|
|
const assignments = await this.prisma.coachAssignment.findMany({
|
|
where: { scheduleId },
|
|
include: {
|
|
coach: {
|
|
include: {
|
|
seats: { select: { id: true, status: true, seatNumber: true } },
|
|
coachType: { include: { seatClasses: { select: { name: true } } } },
|
|
},
|
|
},
|
|
},
|
|
orderBy: { positionNumber: 'asc' },
|
|
});
|
|
|
|
const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id));
|
|
const effectiveStatuses = await this.resolveEffectiveStatuses(
|
|
scheduleId,
|
|
allSeatIds,
|
|
originStationId ?? schedule.originStationId,
|
|
destinationStationId ?? schedule.destinationStationId,
|
|
);
|
|
|
|
return assignments.map(a => {
|
|
const seats = a.coach.seats.filter(s => s.seatNumber && !s.seatNumber.startsWith('-'));
|
|
const totalSeats = seats.length;
|
|
const unavailable = seats.filter(s => {
|
|
const status = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
|
|
return status === 'HELD' || status === 'BOOKED' || status === 'BLOCKED';
|
|
}).length;
|
|
|
|
return {
|
|
coachId: a.coach.id,
|
|
coachTypeId: a.coach.coachType?.id ?? null,
|
|
coachNumber: a.coach.number,
|
|
positionNumber: a.positionNumber,
|
|
coachTypeName: a.coach.coachType?.name ?? '',
|
|
seatClasses: a.coach.coachType?.seatClasses.map(sc => sc.name) ?? [],
|
|
totalSeats,
|
|
availableSeats: totalSeats - unavailable,
|
|
heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE')) === 'HELD').length,
|
|
bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE')) === 'BOOKED').length,
|
|
};
|
|
});
|
|
}
|
|
|
|
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, , row, col, seatNumber] = 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, , 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' } });
|
|
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason } });
|
|
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 } });
|
|
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE' } });
|
|
return { unblocked: true, seatId };
|
|
}
|
|
|
|
async setMaintenance(seatId: string, reason: string) {
|
|
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
|
if (!seat) throw new NotFoundException('Seat not found');
|
|
if (seat.status === 'BOOKED') throw new BadRequestException('Cannot set a booked seat to maintenance');
|
|
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'UNDER_MAINTENANCE' as any } });
|
|
await this.prisma.seatBlock.create({ data: { seatId, reason: `MAINTENANCE: ${reason}`, blockedBy: 'system' } });
|
|
return { maintenance: true, seatId, reason };
|
|
}
|
|
|
|
async clearMaintenance(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' as any } });
|
|
await this.prisma.seatBlock.deleteMany({ where: { seatId } });
|
|
return { maintenance: false, 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 || seat.seatNumber.startsWith('-')) throw new BadRequestException('Seat already removed');
|
|
|
|
// Mark as removed, then renumber all active seats in the coach
|
|
await this.prisma.seat.update({
|
|
where: { id: seatId },
|
|
data: { seatNumber: `-${seat.seatNumber}` },
|
|
});
|
|
|
|
await this.renumberCoachSeats(seat.coachId);
|
|
await this.auditService.log({ action: 'DELETE', entityType: 'Seat', entityId: seatId, oldData: { seatNumber: seat.seatNumber, coachId: seat.coachId } });
|
|
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 with a temporary placeholder number, then renumber
|
|
await this.prisma.seat.update({ where: { id: seatId }, data: { seatNumber: `__restore__${seatId}` } });
|
|
await this.renumberCoachSeats(seat.coachId);
|
|
|
|
const restored = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
|
return { restored: true, seatId, seatNumber: restored?.seatNumber };
|
|
}
|
|
|
|
/**
|
|
* Renumbers all active (non-removed) seats in a coach sequentially starting from 1,
|
|
* ordered by row then col. Removed seats (prefixed with "-") keep their slot but
|
|
* are excluded from the numbering sequence so numbers remain continuous.
|
|
*/
|
|
private async renumberCoachSeats(coachId: string): Promise<void> {
|
|
const allSeats = await this.prisma.seat.findMany({
|
|
where: { coachId },
|
|
orderBy: [{ row: 'asc' }, { col: 'asc' }],
|
|
select: { id: true, seatNumber: true },
|
|
});
|
|
|
|
const activeSeats = allSeats.filter(
|
|
(s) => s.seatNumber && !s.seatNumber.startsWith('-') && !s.seatNumber.startsWith('__restore__'),
|
|
);
|
|
|
|
await Promise.all(
|
|
activeSeats.map((s, idx) =>
|
|
this.prisma.seat.update({
|
|
where: { id: s.id },
|
|
data: { seatNumber: String(idx + 1) },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Runs every minute, but is also safe to call on-demand (e.g. right after a hold's
|
|
// TTL is read back to the client) — expiresAt/now are both absolute UTC instants
|
|
// (Date objects, not wall-clock strings), so this is correct regardless of the
|
|
// server's or a client's local timezone; there's no wall-clock parsing involved.
|
|
@Cron(CronExpression.EVERY_MINUTE)
|
|
async expireHolds() {
|
|
try {
|
|
const result = await this.expireHoldsCore();
|
|
if (result.expiredHolds > 0) {
|
|
this.logger.log(
|
|
`Expired ${result.expiredHolds} hold(s): released ${result.releasedSeatIds.length} seat(s), ` +
|
|
`skipped ${result.skippedSeatIds.length} still held by another active hold on the same schedule`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
// A failed run must not crash the process or silently go unnoticed — the next
|
|
// scheduled run one minute later will retry the same (still-expired) holds,
|
|
// since nothing here is deleted/updated until the queries above succeed.
|
|
this.logger.error('Failed to expire seat holds', error instanceof Error ? error.stack : error);
|
|
}
|
|
}
|
|
|
|
async expireHoldsCore(now: Date = new Date()): Promise<{
|
|
expiredHolds: number;
|
|
releasedSeatIds: string[];
|
|
skippedSeatIds: string[];
|
|
}> {
|
|
const expired = await this.prisma.seatHold.findMany({
|
|
where: { expiresAt: { lt: now } },
|
|
select: { id: true, scheduleId: true, seatIds: true },
|
|
});
|
|
if (expired.length === 0) {
|
|
return { expiredHolds: 0, releasedSeatIds: [], skippedSeatIds: [] };
|
|
}
|
|
|
|
// Still-active holds — scoped per (scheduleId, seatId), not just seatId. The same
|
|
// physical Seat row is reused across every recurring date a coach runs, so the
|
|
// same seatId legitimately appears in unrelated holds for other schedules; without
|
|
// this scoping, an unrelated active hold on a DIFFERENT schedule would wrongly
|
|
// block release of a seat whose hold expired on THIS schedule, leaving it stuck at
|
|
// status 'HELD' indefinitely.
|
|
const activeHolds = await this.prisma.seatHold.findMany({
|
|
where: { expiresAt: { gte: now } },
|
|
select: { scheduleId: true, seatIds: true },
|
|
});
|
|
const stillHeldKeys = new Set(
|
|
activeHolds.flatMap(h => (h.seatIds as string[]).map(seatId => `${h.scheduleId}:${seatId}`)),
|
|
);
|
|
|
|
const releasedSeatIds = new Set<string>();
|
|
const skippedSeatIds = new Set<string>();
|
|
for (const hold of expired) {
|
|
for (const seatId of hold.seatIds as string[]) {
|
|
if (stillHeldKeys.has(`${hold.scheduleId}:${seatId}`)) {
|
|
skippedSeatIds.add(seatId);
|
|
} else {
|
|
releasedSeatIds.add(seatId);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (releasedSeatIds.size > 0) {
|
|
await this.prisma.seat.updateMany({
|
|
where: { id: { in: Array.from(releasedSeatIds) }, status: 'HELD' },
|
|
// heldUntil is cleared alongside status — leaving a stale (past) heldUntil on an
|
|
// AVAILABLE seat is stale data that any future code reading heldUntil directly
|
|
// (instead of re-deriving availability live) would misinterpret.
|
|
data: { status: 'AVAILABLE', heldUntil: null },
|
|
});
|
|
}
|
|
|
|
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: now } } });
|
|
|
|
return {
|
|
expiredHolds: expired.length,
|
|
releasedSeatIds: Array.from(releasedSeatIds),
|
|
skippedSeatIds: Array.from(skippedSeatIds),
|
|
};
|
|
}
|
|
}
|