import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto'; import { SeatKind } from '@prisma/client'; // Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2] function parseArrangement(arrangement: string): number[] { return arrangement.split('+').map((n) => parseInt(n, 10)); } // Derives column labels from arrangement: '2+2' → ['A','B','C','D'] function seatCols(arrangement: string): string[] { const groups = parseArrangement(arrangement); const total = groups.reduce((s, n) => s + n, 0); return Array.from({ length: total }, (_, i) => String.fromCharCode(65 + i)); } // Returns true if column is a window seat function isWindowCol(colIndex: number, groups: number[]): boolean { const total = groups.reduce((s, n) => s + n, 0); return colIndex === 0 || colIndex === total - 1; } // Returns true if column is an aisle seat function isAisleCol(colIndex: number, groups: number[]): boolean { let cursor = 0; for (const g of groups) { cursor += g; const leftAisle = cursor - 1; const rightAisle = cursor; if (colIndex === leftAisle || colIndex === rightAisle) return true; } return false; } type BedCategory = 'ECONOMY_BED' | 'VIP_BED' | null; // Default beds per room for each category when not explicitly configured const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = { VIP_BED: 4, ECONOMY_BED: 6, }; // Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type function detectBedCategory(coachTypeName: string): BedCategory { const name = coachTypeName.toLowerCase(); const isBed = name.includes('bed') || name.includes('berth') || name.includes('sleeper') || name.includes('couchette'); if (!isBed) return null; if (name.includes('vip')) return 'VIP_BED'; return 'ECONOMY_BED'; } // Resolves bed type names per side from beds-per-side count: // 2/side → ['LOWER','UPPER'] (VIP style) // 3/side → ['LOWER','MIDDLE','UPPER'] (Economy style) function resolveBedTypes(bedsPerSide: number): string[] { if (bedsPerSide === 1) return ['LOWER']; if (bedsPerSide === 2) return ['LOWER', 'UPPER']; if (bedsPerSide === 3) return ['LOWER', 'MIDDLE', 'UPPER']; return Array.from({ length: bedsPerSide }, (_, i) => { if (i === 0) return 'LOWER'; if (i === bedsPerSide - 1) return 'UPPER'; return 'MIDDLE'; }); } // Generates the flat seat/bed list for a bed coach. // Row = room number; col = position-relative label (L1, L2 … R1, R2 …). function buildBedSeats( coachId: string, capacity: number, bedsPerRoom: number, ): SeatRow[] { const bedsPerSide = bedsPerRoom / 2; const bedTypeNames = resolveBedTypes(bedsPerSide); const layout: Array<{ position: 'LEFT' | 'RIGHT'; bedType: string }> = [ ...bedTypeNames.map(bt => ({ position: 'LEFT' as const, bedType: bt })), ...bedTypeNames.map(bt => ({ position: 'RIGHT' as const, bedType: bt })), ]; const roomCount = Math.ceil(capacity / bedsPerRoom); const seats: SeatRow[] = []; let seatNumber = 1; for (let room = 1; room <= roomCount; room++) { const posCount: Record = {}; for (let slot = 0; slot < bedsPerRoom && seats.length < capacity; slot++) { const { position, bedType } = layout[slot]; posCount[position] = (posCount[position] ?? 0) + 1; const col = `${position[0]}${posCount[position]}`; seats.push({ coachId, row: room, col, seatNumber: `${seatNumber}`, kind: SeatKind.STANDARD, bedPosition: bedType.toLowerCase(), isWindow: false, isAisle: false, }); seatNumber++; } } return seats; } function buildRegularSeats(coachId: string, arrangement: string, capacity: number): SeatRow[] { const cols = seatCols(arrangement); const groups = parseArrangement(arrangement); const seats: SeatRow[] = []; let row = 1; let seatNumber = 1; let seatIndex = 0; while (seatIndex < capacity) { for (let ci = 0; ci < cols.length && seatIndex < capacity; ci++) { const col = cols[ci]; seats.push({ coachId, row, col, seatNumber: `${seatNumber}`, kind: SeatKind.STANDARD, bedPosition: null, isWindow: isWindowCol(ci, groups), isAisle: isAisleCol(ci, groups), }); seatNumber++; seatIndex++; } row++; } return seats; } type SeatRow = { coachId: string; row: number; col: string; seatNumber: string; kind: SeatKind; bedPosition?: string | null; isWindow?: boolean; isAisle?: boolean; }; @Injectable() export class FleetService { constructor(private prisma: PrismaService) {} async createCoachType(dto: CreateCoachTypeDto) { return this.prisma.coachType.create({ data: { code: dto.code, name: dto.name, type: dto.type || 'passenger', }, include: { seatClasses: true, coaches: true, }, }); } async getCoachTypes() { return this.prisma.coachType.findMany({ include: { seatClasses: true, coaches: true, }, orderBy: { createdAt: 'desc' }, }); } async updateCoachType(id: string, dto: UpdateCoachTypeDto) { const coachType = await this.prisma.coachType.findUnique({ where: { id } }); if (!coachType) throw new NotFoundException('Coach type not found'); const data: any = {}; if (dto.code !== undefined) data.code = dto.code; if (dto.name !== undefined) data.name = dto.name; if (dto.type !== undefined) data.type = dto.type; return this.prisma.coachType.update({ where: { id }, data, include: { seatClasses: true, coaches: true, }, }); } async deleteCoachType(id: string) { const coachType = await this.prisma.coachType.findUnique({ where: { id }, include: { coaches: true, seatClasses: true, }, }); if (!coachType) throw new NotFoundException('Coach type not found'); // Check for related records if (coachType.coaches.length > 0) { throw new BadRequestException( `Cannot delete coach type. ${coachType.coaches.length} coach(es) are still using this coach type. Please reassign or delete the coaches first.` ); } if (coachType.seatClasses.length > 0) { throw new BadRequestException( `Cannot delete coach type. ${coachType.seatClasses.length} seat class(es) are still using this coach type. Please reassign or delete the seat classes first.` ); } return this.prisma.coachType.delete({ where: { id } }); } async createClass(dto: CreateClassDto) { return this.prisma.seatClass.create({ data: { coachTypeId: dto.coachTypeId, name: dto.name, description: dto.description, baseFareMinor: dto.baseFareMinor, }, }); } async getClasses(coachTypeId?: string) { const where = coachTypeId ? { coachTypeId } : {}; return this.prisma.seatClass.findMany({ where, include: { coachType: true }, orderBy: { createdAt: 'desc' }, }); } async updateClass(id: string, dto: UpdateClassDto) { const seatClass = await this.prisma.seatClass.findUnique({ where: { id } }); if (!seatClass) throw new NotFoundException('Seat class not found'); const updateData: any = { coachTypeId: dto.coachTypeId, name: dto.name, description: dto.description, baseFareMinor: dto.baseFareMinor, }; if (dto.isActive !== undefined) { updateData.isActive = dto.isActive; } return this.prisma.seatClass.update({ where: { id }, data: updateData, include: { coachType: true }, }); } async deleteClass(id: string) { const seatClass = await this.prisma.seatClass.findUnique({ where: { id }, include: { fareRules: true, routeFareRules: true, segmentFares: true, }, }); if (!seatClass) throw new NotFoundException('Seat class not found'); // Check for related records const relatedRecords = [ ...seatClass.fareRules, ...seatClass.routeFareRules, ...seatClass.segmentFares, ]; if (relatedRecords.length > 0) { throw new BadRequestException( `Cannot delete seat class. ${relatedRecords.length} fare rule(s) are still using this seat class. Please delete the fare rules first.` ); } return this.prisma.seatClass.delete({ where: { id } }); } createSeatClass(dto: CreateClassDto) { return this.createClass(dto); } getSeatClasses(coachTypeId?: string) { return this.getClasses(coachTypeId); } async updateSeatClass(id: string, dto: UpdateClassDto) { return this.updateClass(id, dto); } async deleteSeatClass(id: string) { return this.deleteClass(id); } getTrains() { return this.prisma.train.findMany({ include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } } }); } createTrain(dto: CreateTrainDto) { return this.prisma.train.create({ data: dto }); } async updateTrain(id: string, dto: CreateTrainDto) { const train = await this.prisma.train.findUnique({ where: { id } }); if (!train) throw new NotFoundException('Train not found'); return this.prisma.train.update({ where: { id }, data: dto }); } async deleteTrain(id: string) { const train = await this.prisma.train.findUnique({ where: { id }, include: { schedules: true, }, }); if (!train) throw new NotFoundException('Train not found'); // Check for active schedules if (train.schedules.length > 0) { throw new BadRequestException( `Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.` ); } return this.prisma.train.delete({ where: { id } }); } async getCoach(id: string) { const coach = await this.prisma.coach.findUnique({ where: { id }, include: { coachType: true, seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, assignments: { include: { schedule: { include: { originStation: true, destinationStation: true } } }, orderBy: { schedule: { departureAt: 'desc' } }, take: 5, }, }, }); if (!coach) throw new NotFoundException('Coach not found'); return coach; } async listCoaches(dto: ListCoachesDto) { const where: any = {}; if (dto.status) where.status = dto.status; if (dto.scheduleId) where.assignments = { some: { scheduleId: dto.scheduleId } }; return this.prisma.coach.findMany({ where, include: { coachType: true }, orderBy: { sequence: 'asc' }, }); } async createCoach(dto: CreateCoachDto) { const groups = parseArrangement(dto.arrangement); if (groups.some(isNaN)) { throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`); } // Get the next sequence number for this coach type const lastCoach = await this.prisma.coach.findFirst({ where: { coachTypeId: dto.coachTypeId }, orderBy: { sequence: 'desc' }, }); const nextSequence = (lastCoach?.sequence ?? 0) + 1; const coach = await this.prisma.coach.create({ data: { coachTypeId: dto.coachTypeId, number: dto.number, sequence: nextSequence, arrangement: dto.arrangement, capacity: dto.capacity, status: dto.status || 'ACTIVE', }, include: { coachType: true }, }); if (dto.capacity > 0) { // dto.bedCategory takes priority; fall back to name-based detection const bedCategory: BedCategory = dto.bedCategory ?? detectBedCategory(coach.coachType?.name || ''); let seats: SeatRow[]; if (bedCategory) { const bedsPerRoom = dto.bedsPerRoom ?? DEFAULT_BEDS_PER_ROOM[bedCategory]; if (bedsPerRoom < 2 || bedsPerRoom % 2 !== 0) { throw new BadRequestException('bedsPerRoom must be an even number ≥ 2'); } seats = buildBedSeats(coach.id, dto.capacity, bedsPerRoom); } else { seats = buildRegularSeats(coach.id, dto.arrangement, dto.capacity); } await this.prisma.seat.createMany({ data: seats }); } return coach; } async updateCoach(id: string, dto: UpdateCoachDto) { const coach = await this.prisma.coach.findUnique({ where: { id } }); if (!coach) throw new NotFoundException('Coach not found'); return this.prisma.coach.update({ where: { id }, data: { arrangement: dto.arrangement, capacity: dto.capacity, status: dto.status, sequence: dto.sequence, }, include: { coachType: true }, }); } async deleteCoach(id: string) { const coach = await this.prisma.coach.findUnique({ where: { id }, include: { assignments: true, seats: { include: { bookingSeats: true, blocks: true, ticketSeats: true, }, }, }, }); if (!coach) throw new NotFoundException('Coach not found'); // Check for active assignments if (coach.assignments.length > 0) { throw new BadRequestException( `Cannot delete coach. This coach is assigned to ${coach.assignments.length} schedule(s). Please remove the assignments first.` ); } // Check for booked seats const bookedSeats = coach.seats.filter(seat => seat.bookingSeats.length > 0); if (bookedSeats.length > 0) { throw new BadRequestException( `Cannot delete coach. ${bookedSeats.length} seat(s) have active bookings. Please wait for bookings to complete or cancel them first.` ); } // Check for blocked seats const blockedSeats = coach.seats.filter(seat => seat.blocks.length > 0); if (blockedSeats.length > 0) { throw new BadRequestException( `Cannot delete coach. ${blockedSeats.length} seat(s) are blocked. Please unblock them first.` ); } // Check for tickets const seatsWithTickets = coach.seats.filter(seat => seat.ticketSeats.length > 0); if (seatsWithTickets.length > 0) { throw new BadRequestException( `Cannot delete coach. ${seatsWithTickets.length} seat(s) have issued tickets. Please wait for travel completion.` ); } // Delete related seats first (now safe to do) await this.prisma.seat.deleteMany({ where: { coachId: id } }); return this.prisma.coach.delete({ where: { id } }); } async assignCoach(dto: AssignCoachDto) { const [schedule, coach] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId } }), this.prisma.coach.findUnique({ where: { id: dto.coachId } }), ]); if (!schedule) throw new NotFoundException('Schedule not found'); if (!coach) throw new NotFoundException('Coach not found'); return this.prisma.coachAssignment.create({ data: dto }); } async removeAssignment(id: string) { const assignment = await this.prisma.coachAssignment.findUnique({ where: { id } }); if (!assignment) throw new NotFoundException('Assignment not found'); return this.prisma.coachAssignment.delete({ where: { id } }); } async generateSeatMapPreview(dto: GenerateSeatMapDto) { const { coachCount, roomsPerCoach, roomType } = dto; const bedsPerRoom = DEFAULT_BEDS_PER_ROOM[roomType]; const bedTypeNames = resolveBedTypes(bedsPerRoom / 2); const layout: Array<{ position: 'LEFT' | 'RIGHT'; bedType: string }> = [ ...bedTypeNames.map(bt => ({ position: 'LEFT' as const, bedType: bt })), ...bedTypeNames.map(bt => ({ position: 'RIGHT' as const, bedType: bt })), ]; const seats: object[] = []; let globalSeq = 1; for (let c = 1; c <= coachCount; c++) { const coachLabel = `C${c}`; for (let r = 1; r <= roomsPerCoach; r++) { const roomLabel = `R${r}`; const posCount: Record = {}; for (let s = 0; s < bedsPerRoom; s++) { const { position, bedType } = layout[s]; posCount[position] = (posCount[position] ?? 0) + 1; seats.push({ seat_id: `${coachLabel}-${roomLabel}-S${globalSeq}`, coach_id: coachLabel, room_id: `${coachLabel}-${roomLabel}`, category: roomType, position, col: `${position[0]}${posCount[position]}`, bed_type: bedType, sequence_number: globalSeq, status: 'AVAILABLE', }); globalSeq++; } } } return { coachCount, roomsPerCoach, roomType, bedsPerRoom, totalBeds: seats.length, seats, }; } async getAnalytics() { const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([ this.prisma.train.count(), this.prisma.trainSchedule.count(), this.prisma.seat.count(), this.prisma.seat.count({ where: { status: 'BOOKED' } }), ]); return { totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0, }; } }