mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
918 lines
30 KiB
TypeScript
918 lines
30 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';
|
|
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
|
import { AuditService } from '../../common/audit.service';
|
|
import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions';
|
|
import { snapshot } from '../../common/audit-snapshot';
|
|
|
|
/** Fields carried into audit rows, per entity. Deliberately narrow — see audit-snapshot.ts. */
|
|
const COACH_TYPE_AUDIT_FIELDS = ['code', 'name', 'type'] as const;
|
|
const SEAT_CLASS_AUDIT_FIELDS = [
|
|
'coachTypeId',
|
|
'name',
|
|
'description',
|
|
'baseFareMinor',
|
|
'premiumMinor',
|
|
'insuranceFeeMinor',
|
|
'isActive',
|
|
] as const;
|
|
const TRAIN_AUDIT_FIELDS = [
|
|
'number',
|
|
'name',
|
|
'operatorId',
|
|
'operatorName',
|
|
'description',
|
|
'isActive',
|
|
] as const;
|
|
const COACH_AUDIT_FIELDS = [
|
|
'number',
|
|
'coachTypeId',
|
|
'arrangement',
|
|
'capacity',
|
|
'status',
|
|
'sequence',
|
|
] as const;
|
|
const COACH_ASSIGNMENT_AUDIT_FIELDS = ['scheduleId', 'coachId', 'positionNumber', 'isOperational'] as const;
|
|
|
|
// 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 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, private auditService: AuditService) {}
|
|
|
|
async createCoachType(dto: CreateCoachTypeDto) {
|
|
const coachType = await this.prisma.coachType.create({
|
|
data: {
|
|
code: dto.code,
|
|
name: dto.name,
|
|
type: dto.type || 'passenger',
|
|
},
|
|
include: {
|
|
seatClasses: true,
|
|
coaches: true,
|
|
},
|
|
});
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.CREATE,
|
|
entityType: AUDIT_ENTITIES.CoachType,
|
|
entityId: coachType.id,
|
|
newData: snapshot(coachType, COACH_TYPE_AUDIT_FIELDS),
|
|
});
|
|
return coachType;
|
|
}
|
|
|
|
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;
|
|
|
|
const updated = await this.prisma.coachType.update({
|
|
where: { id },
|
|
data,
|
|
include: {
|
|
seatClasses: true,
|
|
coaches: true,
|
|
},
|
|
});
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.UPDATE,
|
|
entityType: AUDIT_ENTITIES.CoachType,
|
|
entityId: id,
|
|
oldData: snapshot(coachType, COACH_TYPE_AUDIT_FIELDS),
|
|
newData: snapshot(updated, COACH_TYPE_AUDIT_FIELDS),
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
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');
|
|
|
|
const constraints = [];
|
|
if (coachType.coaches.length > 0) {
|
|
constraints.push({
|
|
entityName: 'coach',
|
|
count: coachType.coaches.length,
|
|
action: 'reassign' as const
|
|
});
|
|
}
|
|
|
|
if (coachType.seatClasses.length > 0) {
|
|
constraints.push({
|
|
entityName: 'seat class',
|
|
count: coachType.seatClasses.length,
|
|
action: 'reassign' as const
|
|
});
|
|
}
|
|
|
|
if (constraints.length > 0) {
|
|
throw new DeleteOperationException('Coach Type', coachType.name, constraints);
|
|
}
|
|
|
|
const deleted = await this.prisma.coachType.delete({ where: { id } });
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.DELETE,
|
|
entityType: AUDIT_ENTITIES.CoachType,
|
|
entityId: id,
|
|
oldData: snapshot(coachType, COACH_TYPE_AUDIT_FIELDS),
|
|
});
|
|
return deleted;
|
|
}
|
|
|
|
async createClass(dto: CreateClassDto) {
|
|
const seatClass = await this.prisma.seatClass.create({
|
|
data: {
|
|
coachTypeId: dto.coachTypeId,
|
|
name: dto.name,
|
|
description: dto.description,
|
|
baseFareMinor: dto.baseFareMinor,
|
|
...(dto.premiumMinor !== undefined && { premiumMinor: dto.premiumMinor }),
|
|
...(dto.insuranceFeeMinor !== undefined && { insuranceFeeMinor: dto.insuranceFeeMinor }),
|
|
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
|
},
|
|
});
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.CREATE,
|
|
entityType: AUDIT_ENTITIES.SeatClass,
|
|
entityId: seatClass.id,
|
|
newData: snapshot(seatClass, SEAT_CLASS_AUDIT_FIELDS),
|
|
});
|
|
return seatClass;
|
|
}
|
|
|
|
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,
|
|
premiumMinor: dto.premiumMinor,
|
|
insuranceFeeMinor: dto.insuranceFeeMinor,
|
|
};
|
|
|
|
if (dto.isActive !== undefined) {
|
|
updateData.isActive = dto.isActive;
|
|
}
|
|
|
|
const updated = await this.prisma.seatClass.update({
|
|
where: { id },
|
|
data: updateData,
|
|
include: { coachType: true },
|
|
});
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.UPDATE,
|
|
entityType: AUDIT_ENTITIES.SeatClass,
|
|
entityId: id,
|
|
oldData: snapshot(seatClass, SEAT_CLASS_AUDIT_FIELDS),
|
|
newData: snapshot(updated, SEAT_CLASS_AUDIT_FIELDS),
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
async deleteClass(id: string, cascade = false) {
|
|
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');
|
|
|
|
if (!cascade) {
|
|
const totalFareRules = seatClass.fareRules.length + seatClass.routeFareRules.length + seatClass.segmentFares.length;
|
|
const constraints = [];
|
|
|
|
if (totalFareRules > 0) {
|
|
constraints.push({
|
|
entityName: 'fare rule',
|
|
count: totalFareRules,
|
|
action: 'delete' as const
|
|
});
|
|
}
|
|
|
|
if (constraints.length > 0) {
|
|
throw new DeleteOperationException('Seat Class', seatClass.name, constraints);
|
|
}
|
|
}
|
|
|
|
if (cascade) {
|
|
await this.prisma.fareRule.deleteMany({ where: { seatClassId: id } });
|
|
await this.prisma.routeFareRule.deleteMany({ where: { seatClassId: id } });
|
|
await this.prisma.segmentFareRule.deleteMany({ where: { seatClassId: id } });
|
|
}
|
|
|
|
const deleted = await this.prisma.seatClass.delete({ where: { id } });
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.DELETE,
|
|
entityType: AUDIT_ENTITIES.SeatClass,
|
|
entityId: id,
|
|
oldData: snapshot(seatClass, SEAT_CLASS_AUDIT_FIELDS),
|
|
newData: { cascade },
|
|
});
|
|
return deleted;
|
|
}
|
|
|
|
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({
|
|
where: { isActive: true },
|
|
include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } },
|
|
});
|
|
}
|
|
|
|
async createTrain(dto: CreateTrainDto) {
|
|
const train = await this.prisma.train.create({
|
|
data: {
|
|
number: dto.number,
|
|
name: dto.name,
|
|
operatorId: dto.operatorId,
|
|
operatorName: dto.operatorName,
|
|
description: dto.description,
|
|
isActive: dto.isActive ?? true,
|
|
},
|
|
});
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.CREATE,
|
|
entityType: AUDIT_ENTITIES.Train,
|
|
entityId: train.id,
|
|
newData: snapshot(train, TRAIN_AUDIT_FIELDS),
|
|
});
|
|
return train;
|
|
}
|
|
|
|
async updateTrain(id: string, dto: CreateTrainDto) {
|
|
const train = await this.prisma.train.findUnique({ where: { id } });
|
|
if (!train) throw new NotFoundException('Train not found');
|
|
const updated = await this.prisma.train.update({
|
|
where: { id },
|
|
data: {
|
|
number: dto.number,
|
|
name: dto.name,
|
|
operatorId: dto.operatorId,
|
|
operatorName: dto.operatorName,
|
|
description: dto.description,
|
|
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
|
},
|
|
});
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.UPDATE,
|
|
entityType: AUDIT_ENTITIES.Train,
|
|
entityId: id,
|
|
oldData: snapshot(train, TRAIN_AUDIT_FIELDS),
|
|
newData: snapshot(updated, TRAIN_AUDIT_FIELDS),
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
async deleteTrain(id: string, cascade = false) {
|
|
const train = await this.prisma.train.findUnique({
|
|
where: { id },
|
|
include: { schedules: true },
|
|
});
|
|
if (!train) throw new NotFoundException('Train not found');
|
|
|
|
if (!cascade) {
|
|
const constraints = [];
|
|
if (train.schedules.length > 0) {
|
|
constraints.push({
|
|
entityName: 'schedule',
|
|
count: train.schedules.length,
|
|
action: 'delete' as const
|
|
});
|
|
}
|
|
|
|
if (constraints.length > 0) {
|
|
throw new DeleteOperationException('Train', `${train.number} (${train.name})`, constraints);
|
|
}
|
|
}
|
|
|
|
if (cascade && train.schedules.length > 0) {
|
|
const scheduleIds = train.schedules.map((s: any) => s.id);
|
|
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
|
|
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
|
|
await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
|
|
await this.prisma.menuItem.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
|
|
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: { in: scheduleIds } } });
|
|
|
|
const bookings = await this.prisma.booking.findMany({
|
|
where: { OR: [{ scheduleId: { in: scheduleIds } }, { returnScheduleId: { in: scheduleIds } }] },
|
|
select: { id: true },
|
|
});
|
|
if (bookings.length > 0) {
|
|
const bookingIds = bookings.map(b => b.id);
|
|
const tickets = await this.prisma.ticket.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
|
|
if (tickets.length > 0) {
|
|
await this.prisma.gateValidationLog.deleteMany({ where: { ticketId: { in: tickets.map(t => t.id) } } });
|
|
}
|
|
await this.prisma.ticket.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
|
const foodOrders = await this.prisma.foodOrder.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
|
|
if (foodOrders.length > 0) {
|
|
await this.prisma.foodOrderItem.deleteMany({ where: { orderId: { in: foodOrders.map(o => o.id) } } });
|
|
}
|
|
await this.prisma.foodOrder.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
|
const paymentIntents = await this.prisma.paymentIntent.findMany({ where: { bookingId: { in: bookingIds } }, select: { id: true } });
|
|
if (paymentIntents.length > 0) {
|
|
await this.prisma.paymentRefund.deleteMany({ where: { paymentIntentId: { in: paymentIntents.map(p => p.id) } } });
|
|
}
|
|
await this.prisma.paymentIntent.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
|
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
|
await this.prisma.agentBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
|
await this.prisma.bookingModification.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
|
await this.prisma.bookingCancellation.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
|
await this.prisma.baggageBooking.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
|
await this.prisma.excessBaggageCharge.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
|
await this.prisma.journey.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
|
await this.prisma.booking.deleteMany({ where: { id: { in: bookingIds } } });
|
|
}
|
|
|
|
const packages = await this.prisma.travelPackage.findMany({
|
|
where: { OR: [{ outboundScheduleId: { in: scheduleIds } }, { returnScheduleId: { in: scheduleIds } }] },
|
|
select: { id: true },
|
|
});
|
|
if (packages.length > 0) {
|
|
const packageIds = packages.map(p => p.id);
|
|
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: { in: packageIds } } });
|
|
await this.prisma.travelPackage.deleteMany({ where: { id: { in: packageIds } } });
|
|
}
|
|
|
|
await this.prisma.trainSchedule.deleteMany({ where: { id: { in: scheduleIds } } });
|
|
}
|
|
|
|
const deleted = await this.prisma.train.delete({ where: { id } });
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.DELETE,
|
|
entityType: AUDIT_ENTITIES.Train,
|
|
entityId: id,
|
|
oldData: snapshot(train, TRAIN_AUDIT_FIELDS),
|
|
newData: { cascade },
|
|
});
|
|
return deleted;
|
|
}
|
|
|
|
async restoreTrain(id: string) {
|
|
const train = await this.prisma.train.findUnique({ where: { id } });
|
|
if (!train) throw new NotFoundException('Train not found');
|
|
const restored = await this.prisma.train.update({ where: { id }, data: { isActive: true } });
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.RESTORE,
|
|
entityType: AUDIT_ENTITIES.Train,
|
|
entityId: id,
|
|
oldData: { isActive: train.isActive },
|
|
newData: { isActive: true },
|
|
});
|
|
return restored;
|
|
}
|
|
|
|
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"`);
|
|
}
|
|
|
|
// Use user-provided sequence or auto-assign the next one
|
|
let resolvedSequence = dto.sequence;
|
|
if (resolvedSequence === undefined || resolvedSequence === null) {
|
|
const lastCoach = await this.prisma.coach.findFirst({
|
|
orderBy: { sequence: 'desc' },
|
|
});
|
|
resolvedSequence = (lastCoach?.sequence ?? 0) + 1;
|
|
}
|
|
|
|
const coach = await this.prisma.coach.create({
|
|
data: {
|
|
coachTypeId: dto.coachTypeId,
|
|
number: dto.number,
|
|
sequence: resolvedSequence,
|
|
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 });
|
|
}
|
|
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.CREATE,
|
|
entityType: AUDIT_ENTITIES.Coach,
|
|
entityId: coach.id,
|
|
newData: snapshot(coach, COACH_AUDIT_FIELDS),
|
|
});
|
|
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');
|
|
|
|
const updated = await this.prisma.coach.update({
|
|
where: { id },
|
|
data: {
|
|
number: dto.number,
|
|
arrangement: dto.arrangement,
|
|
capacity: dto.capacity,
|
|
status: dto.status,
|
|
sequence: dto.sequence,
|
|
},
|
|
include: { coachType: true },
|
|
});
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.UPDATE,
|
|
entityType: AUDIT_ENTITIES.Coach,
|
|
entityId: id,
|
|
oldData: snapshot(coach, COACH_AUDIT_FIELDS),
|
|
newData: snapshot(updated, COACH_AUDIT_FIELDS),
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
async deleteCoach(id: string, cascade = false) {
|
|
const coach = await this.prisma.coach.findUnique({
|
|
where: { id },
|
|
include: {
|
|
assignments: true,
|
|
seats: {
|
|
include: {
|
|
bookingSeats: true,
|
|
blocks: true,
|
|
tickets: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!coach) throw new NotFoundException('Coach not found');
|
|
|
|
if (!cascade) {
|
|
const constraints = [];
|
|
|
|
if ((coach as any).assignments.length > 0) {
|
|
constraints.push({
|
|
entityName: 'schedule assignment',
|
|
count: (coach as any).assignments.length,
|
|
action: 'reassign' as const
|
|
});
|
|
}
|
|
|
|
const bookedSeats = (coach as any).seats.filter((seat: any) => seat.bookingSeats.length > 0);
|
|
if (bookedSeats.length > 0) {
|
|
constraints.push({
|
|
entityName: 'booked seat',
|
|
count: bookedSeats.length,
|
|
action: 'complete' as const
|
|
});
|
|
}
|
|
|
|
const blockedSeats = (coach as any).seats.filter((seat: any) => seat.blocks.length > 0);
|
|
if (blockedSeats.length > 0) {
|
|
constraints.push({
|
|
entityName: 'blocked seat',
|
|
count: blockedSeats.length,
|
|
action: 'delete' as const
|
|
});
|
|
}
|
|
|
|
const seatsWithTickets = (coach as any).seats.filter((seat: any) => seat.tickets.length > 0);
|
|
if (seatsWithTickets.length > 0) {
|
|
constraints.push({
|
|
entityName: 'seat with issued ticket',
|
|
count: seatsWithTickets.length,
|
|
action: 'complete' as const
|
|
});
|
|
}
|
|
|
|
if (constraints.length > 0) {
|
|
throw new DeleteOperationException('Coach', coach.number, constraints);
|
|
}
|
|
}
|
|
|
|
if (cascade) {
|
|
const seatIds = (coach as any).seats.map((s: any) => s.id);
|
|
if (seatIds.length > 0) {
|
|
await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } });
|
|
await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } });
|
|
await this.prisma.ticket.deleteMany({ where: { seatId: { in: seatIds } } });
|
|
}
|
|
await this.prisma.coachAssignment.deleteMany({ where: { coachId: id } });
|
|
await this.prisma.routeCoachTemplate.deleteMany({ where: { coachId: id } });
|
|
}
|
|
|
|
await this.prisma.seat.deleteMany({ where: { coachId: id } });
|
|
|
|
const deleted = await this.prisma.coach.delete({ where: { id } });
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.DELETE,
|
|
entityType: AUDIT_ENTITIES.Coach,
|
|
entityId: id,
|
|
oldData: snapshot(coach, COACH_AUDIT_FIELDS),
|
|
newData: { cascade },
|
|
});
|
|
return deleted;
|
|
}
|
|
|
|
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');
|
|
if (coach.status !== 'ACTIVE') throw new BadRequestException('Coach is not active');
|
|
const assignment = await this.prisma.coachAssignment.create({ data: dto });
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.ASSIGN,
|
|
entityType: AUDIT_ENTITIES.CoachAssignment,
|
|
entityId: assignment.id,
|
|
newData: {
|
|
...snapshot(assignment, COACH_ASSIGNMENT_AUDIT_FIELDS),
|
|
coachNumber: coach.number,
|
|
},
|
|
});
|
|
return assignment;
|
|
}
|
|
|
|
async removeAssignment(id: string) {
|
|
const assignment = await this.prisma.coachAssignment.findUnique({ where: { id } });
|
|
if (!assignment) throw new NotFoundException('Assignment not found');
|
|
const deleted = await this.prisma.coachAssignment.delete({ where: { id } });
|
|
await this.auditService.log({
|
|
action: AUDIT_ACTIONS.UNASSIGN,
|
|
entityType: AUDIT_ENTITIES.CoachAssignment,
|
|
entityId: id,
|
|
oldData: snapshot(assignment, COACH_ASSIGNMENT_AUDIT_FIELDS),
|
|
});
|
|
return deleted;
|
|
}
|
|
|
|
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 getCoachUtilization(scheduleId?: string) {
|
|
const where = scheduleId ? { scheduleId } : {};
|
|
|
|
const coaches = await this.prisma.coach.findMany({
|
|
where: scheduleId
|
|
? {
|
|
assignments: {
|
|
some: { scheduleId },
|
|
},
|
|
}
|
|
: {},
|
|
include: {
|
|
coachType: true,
|
|
seats: {
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
bookingSeats: {
|
|
where,
|
|
select: { id: true },
|
|
},
|
|
blocks: {
|
|
where,
|
|
select: { id: true, reasonCategory: true },
|
|
},
|
|
},
|
|
},
|
|
assignments: {
|
|
where,
|
|
include: {
|
|
schedule: {
|
|
select: { id: true, departureAt: true, status: true, _count: { select: { bookings: true } } },
|
|
},
|
|
},
|
|
orderBy: { schedule: { departureAt: 'desc' } },
|
|
take: 10,
|
|
},
|
|
},
|
|
orderBy: { sequence: 'asc' },
|
|
});
|
|
|
|
return coaches.map((coach) => {
|
|
const totalSeats = coach.seats.length;
|
|
const bookedSeats = coach.seats.filter((s) => (s.bookingSeats?.length ?? 0) > 0).length;
|
|
const blockedSeats = coach.seats.filter((s) => (s.blocks?.length ?? 0) > 0).length;
|
|
const maintenanceSeats = coach.seats.filter((s) => (s.blocks ?? []).some((b) => b.reasonCategory === 'MAINTENANCE')).length;
|
|
const availableSeats = scheduleId
|
|
? Math.max(totalSeats - bookedSeats - blockedSeats - maintenanceSeats, 0)
|
|
: coach.seats.filter((s) => s.status === 'AVAILABLE').length;
|
|
const totalAssignments = coach.assignments.length;
|
|
const totalBookings = coach.assignments.reduce((sum, a) => sum + ((a.schedule as any)._count?.bookings ?? 0), 0);
|
|
const utilizationRate = totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0;
|
|
|
|
return {
|
|
id: coach.id,
|
|
number: coach.number,
|
|
sequence: coach.sequence,
|
|
coachType: coach.coachType?.name,
|
|
status: coach.status,
|
|
totalSeats,
|
|
availableSeats,
|
|
bookedSeats,
|
|
blockedSeats,
|
|
maintenanceSeats,
|
|
utilizationRate,
|
|
totalAssignments,
|
|
totalBookings,
|
|
recentSchedules: coach.assignments.slice(0, 5).map((a) => ({
|
|
scheduleId: a.scheduleId,
|
|
departureAt: a.schedule.departureAt,
|
|
scheduleStatus: a.schedule.status,
|
|
bookings: (a.schedule as any)._count?.bookings ?? 0,
|
|
})),
|
|
};
|
|
});
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
}
|