Files
edr-platform/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts
2026-06-25 15:50:27 +03:00

558 lines
17 KiB
TypeScript

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<string, number> = {};
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<string, number> = {};
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,
};
}
}