import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } 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 a seat-mode arrangement string. // '2+2' → ['A','B','C','D'] (A/D window, B/C aisle) // '1+2+1' → ['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)); // A, B, C … } // Returns true if the column index is a window seat given the arrangement groups. 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 the column index 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; } // Bed positions for a given tier count: 2 → lower/upper, 3 → lower/middle/upper const BED_POSITIONS: Record = { 2: ['lower', 'upper'], 3: ['lower', 'middle', 'upper'], }; type SeatRow = { coachId: string; row: number; col: string; label: string; seatNumber: string; kind: SeatKind; isWindow: boolean; isAisle: boolean; bedPosition?: string; }; function buildSeatSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] { const cols = seatCols(arrangement); const groups = parseArrangement(arrangement); const seats: SeatRow[] = []; let row = 1; while (seats.length < totalUnits) { for (let ci = 0; ci < cols.length && seats.length < totalUnits; ci++) { const col = cols[ci]; seats.push({ coachId, row, col, label: `${row}${col}`, seatNumber: `${coachLabel}${row}${col}`, kind: SeatKind.STANDARD, isWindow: isWindowCol(ci, groups), isAisle: isAisleCol(ci, groups), }); } row++; } return seats; } function buildBedSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] { // arrangement for beds describes tiers per berth, e.g. '2+2' = 2 lower+upper on each side // Each compartment number is the row; each tier is the col (L=lower, M=middle, U=upper) const groups = parseArrangement(arrangement); const tiersPerSide = groups[0]; // e.g. 2 → lower+upper const positions = BED_POSITIONS[tiersPerSide] ?? ['lower', 'upper']; const tierCols = positions.map((_, i) => String.fromCharCode(65 + i)); // A=lower, B=upper, C=middle const seats: SeatRow[] = []; let compartment = 1; while (seats.length < totalUnits) { for (let ti = 0; ti < tierCols.length && seats.length < totalUnits; ti++) { const col = tierCols[ti]; seats.push({ coachId, row: compartment, col, label: `${compartment}${col}`, seatNumber: `${coachLabel}${compartment}${col}`, kind: SeatKind.STANDARD, isWindow: false, isAisle: false, bedPosition: positions[ti], }); } compartment++; } return seats; } @Injectable() export class FleetService { constructor(private prisma: PrismaService) {} getTrains() { return this.prisma.train.findMany({ include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } } }); } createTrain(dto: CreateTrainDto) { return this.prisma.train.create({ data: dto }); } async getCoach(id: string) { const coach = await this.prisma.coach.findUnique({ where: { id }, include: { seatClass: true, seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }], }, assignments: { include: { schedule: { include: { originStation: true, destinationStation: true } } }, orderBy: { schedule: { departureAt: 'desc' } }, take: 5, }, _count: { select: { seats: true, assignments: true } }, }, }); if (!coach) throw new NotFoundException('Coach not found'); // Group seats by row to reflect the physical arrangement layout const rowMap = new Map(); for (const seat of coach.seats) { if (!rowMap.has(seat.row)) rowMap.set(seat.row, []); rowMap.get(seat.row)!.push(seat); } const seatsByRow = Array.from(rowMap.entries()).map(([row, seats]) => ({ row, seats })); const seatStatusSummary = { total: coach.seats.length, available: coach.seats.filter(s => s.status === 'AVAILABLE').length, held: coach.seats.filter(s => s.status === 'HELD').length, booked: coach.seats.filter(s => s.status === 'BOOKED').length, blocked: coach.seats.filter(s => s.status === 'BLOCKED').length, }; const { seats, ...coachData } = coach; return { ...coachData, seatsByRow, seatStatusSummary }; } async listCoaches(dto: ListCoachesDto) { const where: any = {}; if (dto.isActive !== undefined) where.isActive = dto.isActive; if (dto.mode) where.mode = dto.mode; if (dto.seatClassId) where.seatClassId = dto.seatClassId; if (dto.scheduleId) where.assignments = { some: { scheduleId: dto.scheduleId } }; const coaches = await this.prisma.coach.findMany({ where, include: { seatClass: true, seats: { select: { status: true } }, _count: { select: { seats: true, assignments: true } }, }, orderBy: [{ isActive: 'desc' }, { label: 'asc' }], }); return coaches.map(({ seats, ...coach }) => ({ ...coach, seatStatusSummary: { total: seats.length, available: seats.filter(s => s.status === 'AVAILABLE').length, held: seats.filter(s => s.status === 'HELD').length, booked: seats.filter(s => s.status === 'BOOKED').length, blocked: seats.filter(s => s.status === 'BLOCKED').length, }, })); } async createCoach(dto: CreateCoachDto) { const mode = dto.mode ?? 'seat'; const totalUnits = dto.totalUnits ?? 0; const isBed = mode === 'bed'; const arrangement = isBed ? (dto.bedArrangement ?? dto.seatArrangement ?? '2+2') : (dto.seatArrangement ?? '2+2'); if (totalUnits > 0) { const groups = parseArrangement(arrangement); if (groups.some(isNaN)) { throw new BadRequestException(`Invalid arrangement format "${arrangement}". Use e.g. "2+2" or "2+2+2"`); } } const coach = await this.prisma.coach.create({ data: dto }); if (totalUnits > 0) { const seats = isBed ? buildBedSeats(coach.id, coach.label, arrangement, totalUnits) : buildSeatSeats(coach.id, coach.label, arrangement, totalUnits); await this.prisma.seat.createMany({ data: seats, skipDuplicates: true }); } return this.prisma.coach.findUnique({ where: { id: coach.id }, include: { seatClass: true, _count: { select: { seats: true } } }, }); } 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: dto }); } 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 createSeatBatch(dto: CreateSeatBatchDto) { const coach = await this.prisma.coach.findUnique({ where: { id: dto.coachId } }); if (!coach) throw new NotFoundException('Coach not found'); const seats = []; for (let row = 1; row <= dto.rows; row++) { for (const col of dto.cols) { seats.push({ coachId: dto.coachId, row, col, label: `${row}${col}`, seatNumber: `${coach.label}${row}${col}` }); } } await this.prisma.seat.createMany({ data: seats, skipDuplicates: true }); return { created: seats.length }; } 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 }; } }