mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Update seatmap configuration
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger';
|
||||
import { FleetService } from './fleet.service';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@ApiTags('Fleet')
|
||||
@@ -317,6 +317,39 @@ export class FleetController {
|
||||
return this.service.removeAssignment(id);
|
||||
}
|
||||
|
||||
@Post('seatmap/generate')
|
||||
@ApiOperation({
|
||||
summary: 'Preview bed seat map — ECONOMY_BED or VIP_BED',
|
||||
description: `Generates a structured seat map for bed coaches without persisting anything.
|
||||
|
||||
**ECONOMY_BED**: 6 beds per room — Left(Lower/Middle/Upper) + Right(Lower/Middle/Upper)
|
||||
|
||||
**VIP_BED**: 4 beds per room — Left(Lower/Upper) + Right(Lower/Upper)
|
||||
|
||||
Use this to preview the full flat seat list before creating coaches.`,
|
||||
})
|
||||
@ApiBody({ type: GenerateSeatMapDto })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Generated seat map preview',
|
||||
schema: {
|
||||
example: {
|
||||
coachCount: 1, roomsPerCoach: 2, roomType: 'ECONOMY_BED', bedsPerRoom: 6, totalBeds: 12,
|
||||
seats: [
|
||||
{ seat_id: 'C1-C1-R1-S1', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'LOWER', sequence_number: 1, status: 'AVAILABLE' },
|
||||
{ seat_id: 'C1-C1-R1-S2', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'MIDDLE', sequence_number: 2, status: 'AVAILABLE' },
|
||||
{ seat_id: 'C1-C1-R1-S3', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'UPPER', sequence_number: 3, status: 'AVAILABLE' },
|
||||
{ seat_id: 'C1-C1-R1-S4', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'LOWER', sequence_number: 4, status: 'AVAILABLE' },
|
||||
{ seat_id: 'C1-C1-R1-S5', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'MIDDLE', sequence_number: 5, status: 'AVAILABLE' },
|
||||
{ seat_id: 'C1-C1-R1-S6', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'UPPER', sequence_number: 6, status: 'AVAILABLE' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
generateSeatMap(@Body() dto: GenerateSeatMapDto) {
|
||||
return this.service.generateSeatMapPreview(dto);
|
||||
}
|
||||
|
||||
@Get('analytics')
|
||||
@ApiOperation({ summary: 'Fleet analytics and occupancy metrics' })
|
||||
@ApiResponse({ status: 200, description: 'Occupancy statistics' })
|
||||
|
||||
@@ -12,9 +12,20 @@ export class CreateTrainDto {
|
||||
export class CreateCoachDto {
|
||||
@ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string;
|
||||
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string;
|
||||
@ApiProperty({ example: '2+2', description: 'Seat arrangement (e.g., "2+2", "3+2")' }) @IsString() arrangement: string;
|
||||
@ApiProperty({ example: 60, description: 'Total seat capacity' }) @IsInt() capacity: number;
|
||||
@ApiProperty({ example: '2+2', description: 'Seat arrangement for regular coaches (e.g., "2+2", "3+2"). Ignored for bed coaches.' }) @IsString() arrangement: string;
|
||||
@ApiProperty({ example: 60, description: 'Total seat/bed capacity' }) @IsInt() capacity: number;
|
||||
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string;
|
||||
@ApiPropertyOptional({
|
||||
enum: ['ECONOMY_BED', 'VIP_BED'],
|
||||
description: 'Bed coach category. Set to generate bed/sleeper compartments instead of regular seats. Overrides name-based detection.',
|
||||
example: 'VIP_BED',
|
||||
})
|
||||
@IsOptional() @IsString() bedCategory?: 'ECONOMY_BED' | 'VIP_BED';
|
||||
@ApiPropertyOptional({
|
||||
example: 4,
|
||||
description: 'Beds per compartment/room. Must be even (split equally left/right). Defaults: VIP_BED=4, ECONOMY_BED=6. Only applies when bedCategory is set.',
|
||||
})
|
||||
@IsOptional() @IsInt() bedsPerRoom?: number;
|
||||
}
|
||||
|
||||
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {
|
||||
@@ -67,3 +78,14 @@ export class UpdateClassDto {
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class GenerateSeatMapDto {
|
||||
@ApiProperty({ example: 2, description: 'Number of coaches' })
|
||||
@IsInt() coachCount: number;
|
||||
|
||||
@ApiProperty({ example: 9, description: 'Number of rooms (compartments) per coach' })
|
||||
@IsInt() roomsPerCoach: number;
|
||||
|
||||
@ApiProperty({ enum: ['ECONOMY_BED', 'VIP_BED'], example: 'ECONOMY_BED', description: 'ECONOMY_BED = 6 beds/room (L/M/U × Left/Right), VIP_BED = 4 beds/room (L/U × Left/Right)' })
|
||||
@IsString() roomType: 'ECONOMY_BED' | 'VIP_BED';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto';
|
||||
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]
|
||||
@@ -33,41 +33,97 @@ function isAisleCol(colIndex: number, groups: number[]): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildSeats(coachId: string, coachNumber: string, arrangement: string, capacity: number, seatClass?: string): SeatRow[] {
|
||||
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('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;
|
||||
const isBedCoach = seatClass?.toLowerCase().includes('bed');
|
||||
const totalCols = cols.length;
|
||||
|
||||
while (seatIndex < capacity) {
|
||||
for (let ci = 0; ci < cols.length && seatIndex < capacity; ci++) {
|
||||
const col = cols[ci];
|
||||
let bedPosition = null;
|
||||
|
||||
// Set bedPosition for bed coaches based on ROW cycling (not seat number)
|
||||
if (isBedCoach) {
|
||||
if (totalCols === 3) {
|
||||
// Economy bed (3-row cycle): upper, middle, lower
|
||||
if (row % 3 === 1) bedPosition = 'upper';
|
||||
else if (row % 3 === 2) bedPosition = 'middle';
|
||||
else bedPosition = 'lower';
|
||||
} else if (totalCols === 2) {
|
||||
// VIP bed (2-row cycle): upper, lower
|
||||
bedPosition = row % 2 === 1 ? 'upper' : 'lower';
|
||||
}
|
||||
}
|
||||
|
||||
seats.push({
|
||||
coachId,
|
||||
row,
|
||||
col,
|
||||
seatNumber: `${seatNumber}`,
|
||||
kind: SeatKind.STANDARD,
|
||||
bedPosition,
|
||||
bedPosition: null,
|
||||
isWindow: isWindowCol(ci, groups),
|
||||
isAisle: isAisleCol(ci, groups),
|
||||
});
|
||||
seatNumber++;
|
||||
seatIndex++;
|
||||
@@ -84,6 +140,8 @@ type SeatRow = {
|
||||
seatNumber: string;
|
||||
kind: SeatKind;
|
||||
bedPosition?: string | null;
|
||||
isWindow?: boolean;
|
||||
isAisle?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -332,8 +390,20 @@ export class FleetService {
|
||||
});
|
||||
|
||||
if (dto.capacity > 0) {
|
||||
const seatClass = coach.coachType?.name || '';
|
||||
const seats = buildSeats(coach.id, coach.number, dto.arrangement, dto.capacity, seatClass);
|
||||
// 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 });
|
||||
}
|
||||
|
||||
@@ -425,6 +495,50 @@ export class FleetService {
|
||||
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(),
|
||||
|
||||
Reference in New Issue
Block a user