mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 10:10:57 +00:00
277 lines
10 KiB
TypeScript
277 lines
10 KiB
TypeScript
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<number, string[]> = {
|
|
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 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 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<number, typeof coach.seats>();
|
|
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 deleteTrain(id: string) {
|
|
const train = await this.prisma.train.findUnique({ where: { id } });
|
|
if (!train) throw new NotFoundException('Train not found');
|
|
return this.prisma.train.delete({ where: { id } });
|
|
}
|
|
|
|
async deleteCoach(id: string) {
|
|
const coach = await this.prisma.coach.findUnique({ where: { id } });
|
|
if (!coach) throw new NotFoundException('Coach not found');
|
|
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 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 };
|
|
}
|
|
}
|