mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
Coaches, seats, schedules, and pricing related updates
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, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@ApiTags('Fleet')
|
||||
@@ -11,16 +11,128 @@ import { JwtGuard } from '../../common/jwt.guard';
|
||||
export class FleetController {
|
||||
constructor(private service: FleetService) {}
|
||||
|
||||
// Coach Type Endpoints
|
||||
@Get('coach-types')
|
||||
@ApiOperation({ summary: 'List all coach types' })
|
||||
@ApiResponse({ status: 200, description: 'Array of coach types' })
|
||||
getCoachTypes() {
|
||||
return this.service.getCoachTypes();
|
||||
}
|
||||
|
||||
@Post('coach-types')
|
||||
@ApiOperation({ summary: 'Create a coach type' })
|
||||
@ApiBody({ type: CreateCoachTypeDto })
|
||||
@ApiResponse({ status: 201, description: 'Coach type created' })
|
||||
createCoachType(@Body() dto: CreateCoachTypeDto) {
|
||||
return this.service.createCoachType(dto);
|
||||
}
|
||||
|
||||
@Patch('coach-types/:id')
|
||||
@ApiOperation({ summary: 'Update a coach type' })
|
||||
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
|
||||
@ApiBody({ type: UpdateCoachTypeDto })
|
||||
@ApiResponse({ status: 200, description: 'Coach type updated' })
|
||||
@ApiResponse({ status: 404, description: 'Coach type not found' })
|
||||
updateCoachType(@Param('id') id: string, @Body() dto: UpdateCoachTypeDto) {
|
||||
return this.service.updateCoachType(id, dto);
|
||||
}
|
||||
|
||||
@Delete('coach-types/:id')
|
||||
@ApiOperation({ summary: 'Delete a coach type' })
|
||||
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Coach type deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Coach type not found' })
|
||||
deleteCoachType(@Param('id') id: string) {
|
||||
return this.service.deleteCoachType(id);
|
||||
}
|
||||
|
||||
// Class Endpoints
|
||||
@Get('classes')
|
||||
@ApiOperation({ summary: 'List all classes' })
|
||||
@ApiQuery({ name: 'coachTypeId', required: false, description: 'Filter by coach type' })
|
||||
@ApiResponse({ status: 200, description: 'Array of classes' })
|
||||
getClasses(@Query('coachTypeId') coachTypeId?: string) {
|
||||
return this.service.getClasses(coachTypeId);
|
||||
}
|
||||
|
||||
@Post('classes')
|
||||
@ApiOperation({ summary: 'Create a class' })
|
||||
@ApiBody({ type: CreateClassDto })
|
||||
@ApiResponse({ status: 201, description: 'Class created' })
|
||||
createClass(@Body() dto: CreateClassDto) {
|
||||
return this.service.createClass(dto);
|
||||
}
|
||||
|
||||
@Patch('classes/:id')
|
||||
@ApiOperation({ summary: 'Update a class' })
|
||||
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||
@ApiBody({ type: UpdateClassDto })
|
||||
@ApiResponse({ status: 200, description: 'Class updated' })
|
||||
@ApiResponse({ status: 404, description: 'Class not found' })
|
||||
updateClass(@Param('id') id: string, @Body() dto: UpdateClassDto) {
|
||||
return this.service.updateClass(id, dto);
|
||||
}
|
||||
|
||||
@Delete('classes/:id')
|
||||
@ApiOperation({ summary: 'Delete a class' })
|
||||
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Class deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Class not found' })
|
||||
deleteClass(@Param('id') id: string) {
|
||||
return this.service.deleteClass(id);
|
||||
}
|
||||
|
||||
// Seat Class Endpoints (DEPRECATED - use Classes endpoints instead)
|
||||
@Get('seat-classes')
|
||||
@ApiOperation({ summary: 'List all classes (DEPRECATED - use /fleet/classes)' })
|
||||
@ApiQuery({ name: 'coachTypeId', required: false, description: 'Filter by coach type' })
|
||||
@ApiResponse({ status: 200, description: 'Array of classes' })
|
||||
getSeatClasses(@Query('coachTypeId') coachTypeId?: string) {
|
||||
return this.service.getClasses(coachTypeId);
|
||||
}
|
||||
|
||||
@Post('seat-classes')
|
||||
@ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' })
|
||||
@ApiBody({ type: CreateClassDto })
|
||||
@ApiResponse({ status: 201, description: 'Class created' })
|
||||
createSeatClass(@Body() dto: CreateClassDto) {
|
||||
return this.service.createClass(dto);
|
||||
}
|
||||
|
||||
@Patch('seat-classes/:id')
|
||||
@ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' })
|
||||
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||
@ApiBody({ type: UpdateClassDto })
|
||||
@ApiResponse({ status: 200, description: 'Class updated' })
|
||||
@ApiResponse({ status: 404, description: 'Class not found' })
|
||||
updateSeatClass(@Param('id') id: string, @Body() dto: UpdateClassDto) {
|
||||
return this.service.updateClass(id, dto);
|
||||
}
|
||||
|
||||
@Delete('seat-classes/:id')
|
||||
@ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' })
|
||||
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Class deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Class not found' })
|
||||
deleteSeatClass(@Param('id') id: string) {
|
||||
return this.service.deleteClass(id);
|
||||
}
|
||||
|
||||
// Train Endpoints
|
||||
@Get('trains')
|
||||
@ApiOperation({ summary: 'List all trains with their recent schedules' })
|
||||
@ApiResponse({ status: 200, description: 'Array of trains each with up to 5 most recent schedules' })
|
||||
getTrains() { return this.service.getTrains(); }
|
||||
@ApiResponse({ status: 200, description: 'Array of trains' })
|
||||
getTrains() {
|
||||
return this.service.getTrains();
|
||||
}
|
||||
|
||||
@Post('trains')
|
||||
@ApiOperation({ summary: 'Create a train service' })
|
||||
@ApiBody({ type: CreateTrainDto })
|
||||
@ApiResponse({ status: 201, description: 'Train created' })
|
||||
createTrain(@Body() dto: CreateTrainDto) { return this.service.createTrain(dto); }
|
||||
createTrain(@Body() dto: CreateTrainDto) {
|
||||
return this.service.createTrain(dto);
|
||||
}
|
||||
|
||||
@Patch('trains/:id')
|
||||
@ApiOperation({ summary: 'Update a train service' })
|
||||
@@ -28,96 +140,95 @@ export class FleetController {
|
||||
@ApiBody({ type: CreateTrainDto })
|
||||
@ApiResponse({ status: 200, description: 'Train updated' })
|
||||
@ApiResponse({ status: 404, description: 'Train not found' })
|
||||
updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) { return this.service.updateTrain(id, dto); }
|
||||
|
||||
@Get('coaches')
|
||||
@ApiOperation({ summary: 'List coaches filtered by status, mode, seat class, or schedule assignment' })
|
||||
@ApiQuery({ name: 'isActive', required: false, type: Boolean, description: 'true = active only, false = inactive only, omit = all' })
|
||||
@ApiQuery({ name: 'mode', required: false, description: 'Filter by mode: seat | bed | convertible' })
|
||||
@ApiQuery({ name: 'seatClassId', required: false, description: 'Filter by SeatClass UUID' })
|
||||
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter to coaches assigned to this TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Coaches with seat class info, assignment count, and seat status summary (total/available/held/booked/blocked)' })
|
||||
listCoaches(
|
||||
@Query('isActive') isActive?: string,
|
||||
@Query('mode') mode?: string,
|
||||
@Query('seatClassId') seatClassId?: string,
|
||||
@Query('scheduleId') scheduleId?: string,
|
||||
) {
|
||||
const dto: ListCoachesDto = {
|
||||
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
|
||||
mode,
|
||||
seatClassId,
|
||||
scheduleId,
|
||||
};
|
||||
return this.service.listCoaches(dto);
|
||||
updateTrain(@Param('id') id: string, @Body() dto: CreateTrainDto) {
|
||||
return this.service.updateTrain(id, dto);
|
||||
}
|
||||
|
||||
@Get('coaches/:id')
|
||||
@ApiOperation({ summary: 'Get a single coach with full seat layout and arrangement' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: `Coach detail including:
|
||||
- seatClass: seat class info
|
||||
- seatsByRow: seats grouped by row number, each seat includes label, seatNumber, col, kind (STANDARD/PREMIUM/ACCESSIBLE), status (AVAILABLE/HELD/BOOKED/BLOCKED), isWindow, isAisle, bedPosition (bed mode only), premiumFeeMinor
|
||||
- seatStatusSummary: total/available/held/booked/blocked counts
|
||||
- assignments: up to 5 most recent schedule assignments with origin/destination`,
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||
getCoach(@Param('id') id: string) { return this.service.getCoach(id); }
|
||||
|
||||
@Post('coaches')
|
||||
@ApiOperation({ summary: 'Register a new physical coach and auto-generate its seats from arrangement config' })
|
||||
@ApiBody({ type: CreateCoachDto })
|
||||
@ApiResponse({ status: 201, description: 'Coach created with seats auto-generated from mode + arrangement + totalUnits' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
|
||||
createCoach(@Body() dto: CreateCoachDto) { return this.service.createCoach(dto); }
|
||||
|
||||
@Patch('coaches/:id')
|
||||
@ApiOperation({ summary: 'Update coach properties (label, mode, arrangement, etc.)' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
@ApiBody({ type: UpdateCoachDto })
|
||||
@ApiResponse({ status: 200, description: 'Coach updated' })
|
||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) { return this.service.updateCoach(id, dto); }
|
||||
|
||||
@Delete('trains/:id')
|
||||
@ApiOperation({ summary: 'Delete a train service' })
|
||||
@ApiParam({ name: 'id', description: 'Train UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Train deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Train not found' })
|
||||
deleteTrain(@Param('id') id: string) { return this.service.deleteTrain(id); }
|
||||
deleteTrain(@Param('id') id: string) {
|
||||
return this.service.deleteTrain(id);
|
||||
}
|
||||
|
||||
// Coach Endpoints
|
||||
@Get('coaches')
|
||||
@ApiOperation({ summary: 'List coaches with seat status summary' })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'Filter by status: ACTIVE, INACTIVE' })
|
||||
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter coaches assigned to schedule' })
|
||||
@ApiResponse({ status: 200, description: 'Array of coaches' })
|
||||
listCoaches(
|
||||
@Query('status') status?: string,
|
||||
@Query('scheduleId') scheduleId?: string,
|
||||
) {
|
||||
const dto: ListCoachesDto = {
|
||||
status,
|
||||
scheduleId,
|
||||
};
|
||||
return this.service.listCoaches(dto);
|
||||
}
|
||||
|
||||
@Get('coaches/:id')
|
||||
@ApiOperation({ summary: 'Get single coach with seat layout' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Coach detail with seats by row' })
|
||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||
getCoach(@Param('id') id: string) {
|
||||
return this.service.getCoach(id);
|
||||
}
|
||||
|
||||
@Post('coaches')
|
||||
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
|
||||
@ApiBody({ type: CreateCoachDto })
|
||||
@ApiResponse({ status: 201, description: 'Coach created' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
|
||||
createCoach(@Body() dto: CreateCoachDto) {
|
||||
return this.service.createCoach(dto);
|
||||
}
|
||||
|
||||
@Patch('coaches/:id')
|
||||
@ApiOperation({ summary: 'Update coach properties' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
@ApiBody({ type: UpdateCoachDto })
|
||||
@ApiResponse({ status: 200, description: 'Coach updated' })
|
||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) {
|
||||
return this.service.updateCoach(id, dto);
|
||||
}
|
||||
|
||||
@Delete('coaches/:id')
|
||||
@ApiOperation({ summary: 'Delete a coach' })
|
||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Coach deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||
deleteCoach(@Param('id') id: string) { return this.service.deleteCoach(id); }
|
||||
deleteCoach(@Param('id') id: string) {
|
||||
return this.service.deleteCoach(id);
|
||||
}
|
||||
|
||||
@Post('assignments')
|
||||
@ApiOperation({ summary: 'Assign a physical coach to a train schedule at a given position' })
|
||||
@ApiOperation({ summary: 'Assign a coach to a schedule' })
|
||||
@ApiBody({ type: AssignCoachDto })
|
||||
@ApiResponse({ status: 201, description: 'CoachAssignment created' })
|
||||
@ApiResponse({ status: 201, description: 'Coach assigned' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule or coach not found' })
|
||||
assignCoach(@Body() dto: AssignCoachDto) { return this.service.assignCoach(dto); }
|
||||
assignCoach(@Body() dto: AssignCoachDto) {
|
||||
return this.service.assignCoach(dto);
|
||||
}
|
||||
|
||||
@Delete('assignments/:id')
|
||||
@ApiOperation({ summary: 'Remove a coach assignment from a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'CoachAssignment UUID' })
|
||||
@ApiOperation({ summary: 'Remove a coach assignment' })
|
||||
@ApiParam({ name: 'id', description: 'Assignment UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Assignment removed' })
|
||||
@ApiResponse({ status: 404, description: 'Assignment not found' })
|
||||
removeAssignment(@Param('id') id: string) { return this.service.removeAssignment(id); }
|
||||
|
||||
@Post('seats/batch')
|
||||
@ApiOperation({ summary: 'Batch-generate seats for a coach (rows × cols)' })
|
||||
@ApiBody({ type: CreateSeatBatchDto })
|
||||
@ApiResponse({ status: 201, description: 'Returns count of seats created' })
|
||||
@ApiResponse({ status: 404, description: 'Coach not found' })
|
||||
createSeatBatch(@Body() dto: CreateSeatBatchDto) { return this.service.createSeatBatch(dto); }
|
||||
removeAssignment(@Param('id') id: string) {
|
||||
return this.service.removeAssignment(id);
|
||||
}
|
||||
|
||||
@Get('analytics')
|
||||
@ApiOperation({ summary: 'Fleet analytics: train count, schedule count, seat occupancy rate' })
|
||||
@ApiResponse({ status: 200, description: 'Returns totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate' })
|
||||
getAnalytics() { return this.service.getAnalytics(); }
|
||||
@ApiOperation({ summary: 'Fleet analytics and occupancy metrics' })
|
||||
@ApiResponse({ status: 200, description: 'Occupancy statistics' })
|
||||
getAnalytics() {
|
||||
return this.service.getAnalytics();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,41 +10,48 @@ export class CreateTrainDto {
|
||||
}
|
||||
|
||||
export class CreateCoachDto {
|
||||
@ApiProperty({ example: 'C-A1', description: 'Unique physical coach identifier' }) @IsString() coachNumber: string;
|
||||
@ApiProperty({ example: 'A', description: 'Display label shown on tickets' }) @IsString() label: string;
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID this coach belongs to' }) @IsString() seatClassId: string;
|
||||
@ApiPropertyOptional({ example: 'sleeper', description: 'Coach type descriptor' }) @IsOptional() @IsString() coachType?: string;
|
||||
@ApiPropertyOptional({ example: 'seat', description: 'seat | bed | convertible. Determines which arrangement field is used for seat generation.' }) @IsOptional() @IsString() mode?: string;
|
||||
@ApiPropertyOptional({ example: '2+2', description: 'Seat arrangement for seat/convertible mode. Format: groups separated by +, e.g. "2+2" (4 cols: A/B aisle C/D) or "1+2+1". Used to derive columns, window and aisle flags. Required when mode=seat and totalUnits>0.' }) @IsOptional() @IsString() seatArrangement?: string;
|
||||
@ApiPropertyOptional({ example: '2+2', description: 'Bed arrangement for bed mode. First number = tiers per berth: 2 → lower/upper, 3 → lower/middle/upper. E.g. "2+2" = 2-tier berths. Required when mode=bed and totalUnits>0.' }) @IsOptional() @IsString() bedArrangement?: string;
|
||||
@ApiPropertyOptional({ example: 60, description: 'Total seat/bed units. When >0, seats are auto-generated from the arrangement on coach creation.' }) @IsOptional() @IsInt() totalUnits?: number;
|
||||
@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;
|
||||
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string;
|
||||
}
|
||||
|
||||
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['coachNumber'] as const)) {}
|
||||
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {}
|
||||
|
||||
export class AssignCoachDto {
|
||||
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) @IsString() scheduleId: string;
|
||||
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string;
|
||||
@ApiProperty({ example: 1, description: 'Position in the train consist (1 = first coach)' }) @IsInt() positionNumber: number;
|
||||
@ApiPropertyOptional({ example: true, description: 'Whether this coach is operational for this schedule' }) @IsOptional() @IsBoolean() isOperational?: boolean;
|
||||
}
|
||||
|
||||
export class CreateSeatBatchDto {
|
||||
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID to generate seats for' }) @IsString() coachId: string;
|
||||
@ApiProperty({ example: 15, description: 'Number of rows to generate' }) @IsInt() rows: number;
|
||||
@ApiProperty({ example: ['A', 'B', 'C', 'D'], type: [String], description: 'Column labels per row' }) @IsArray() @IsString({ each: true }) cols: string[];
|
||||
@ApiProperty({ example: 1, description: 'Position in the train consist' }) @IsInt() positionNumber: number;
|
||||
@ApiPropertyOptional({ example: true, description: 'Whether this coach is operational' }) @IsOptional() @IsBoolean() isOperational?: boolean;
|
||||
}
|
||||
|
||||
export class ListCoachesDto {
|
||||
@ApiPropertyOptional({ example: true, description: 'Filter by active/inactive status. Omit to return all.' })
|
||||
@IsOptional() @IsBoolean() isActive?: boolean;
|
||||
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Filter by status: ACTIVE, INACTIVE' })
|
||||
@IsOptional() @IsString() status?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'seat', description: 'Filter by mode: seat | bed | convertible' })
|
||||
@IsOptional() @IsString() mode?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'Filter by SeatClass UUID' })
|
||||
@IsOptional() @IsString() seatClassId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Filter to coaches assigned to this TrainSchedule UUID' })
|
||||
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Filter coaches assigned to this schedule' })
|
||||
@IsOptional() @IsString() scheduleId?: string;
|
||||
}
|
||||
|
||||
// Legacy DTO types for backward compatibility
|
||||
export class CreateCoachTypeDto {
|
||||
@ApiProperty({ example: 'sleeper' }) @IsString() code: string;
|
||||
@ApiProperty({ example: 'Sleeper Coach' }) @IsString() name: string;
|
||||
@IsOptional() @IsString() type?: string;
|
||||
}
|
||||
|
||||
export class UpdateCoachTypeDto {
|
||||
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() code?: string;
|
||||
@ApiPropertyOptional({ example: 'Sleeper Coach' }) @IsOptional() @IsString() name?: string;
|
||||
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() type?: string;
|
||||
}
|
||||
|
||||
export class CreateClassDto {
|
||||
@ApiProperty({ example: 'coach-type-uuid' }) @IsString() coachTypeId: string;
|
||||
@ApiProperty({ example: 'Economy' }) @IsString() name: string;
|
||||
@IsOptional() @IsString() description?: string;
|
||||
@ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number;
|
||||
}
|
||||
|
||||
export class UpdateClassDto extends PartialType(OmitType(CreateClassDto, ['coachTypeId'] as const)) {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto';
|
||||
import { SeatKind } from '@prisma/client';
|
||||
|
||||
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
|
||||
@@ -8,22 +8,20 @@ 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']
|
||||
// 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)); // A, B, C …
|
||||
return Array.from({ length: total }, (_, i) => String.fromCharCode(65 + i));
|
||||
}
|
||||
|
||||
// Returns true if the column index is a window seat given the arrangement groups.
|
||||
// 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 the column index is an aisle seat.
|
||||
// Returns true if column is an aisle seat
|
||||
function isAisleCol(colIndex: number, groups: number[]): boolean {
|
||||
let cursor = 0;
|
||||
for (const g of groups) {
|
||||
@@ -35,82 +33,180 @@ function isAisleCol(colIndex: number, groups: number[]): boolean {
|
||||
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[] {
|
||||
function buildSeats(coachId: string, coachNumber: string, arrangement: string, capacity: number, seatClass?: string): 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++) {
|
||||
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 seat number cycling
|
||||
if (isBedCoach) {
|
||||
if (totalCols === 3) {
|
||||
// Economy bed (3 levels): 1L, 2M, 3U, 4L, 5M, 6U...
|
||||
const posMod = ((seatNumber - 1) % 3);
|
||||
if (posMod === 0) bedPosition = 'lower';
|
||||
else if (posMod === 1) bedPosition = 'middle';
|
||||
else if (posMod === 2) bedPosition = 'upper';
|
||||
} else if (totalCols === 2) {
|
||||
// VIP bed (2 levels): 1L, 2U, 3L, 4U...
|
||||
const posMod = ((seatNumber - 1) % 2);
|
||||
if (posMod === 0) bedPosition = 'lower';
|
||||
else if (posMod === 1) bedPosition = 'upper';
|
||||
}
|
||||
}
|
||||
|
||||
seats.push({
|
||||
coachId, row, col,
|
||||
label: `${row}${col}`,
|
||||
seatNumber: `${coachLabel}${row}${col}`,
|
||||
coachId,
|
||||
row,
|
||||
col,
|
||||
seatNumber: `${seatNumber}`,
|
||||
kind: SeatKind.STANDARD,
|
||||
isWindow: isWindowCol(ci, groups),
|
||||
isAisle: isAisleCol(ci, groups),
|
||||
bedPosition,
|
||||
});
|
||||
seatNumber++;
|
||||
seatIndex++;
|
||||
}
|
||||
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;
|
||||
}
|
||||
type SeatRow = {
|
||||
coachId: string;
|
||||
row: number;
|
||||
col: string;
|
||||
seatNumber: string;
|
||||
kind: SeatKind;
|
||||
bedPosition?: string | null;
|
||||
};
|
||||
|
||||
@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 } });
|
||||
if (!coachType) throw new NotFoundException('Coach type not found');
|
||||
|
||||
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');
|
||||
|
||||
return this.prisma.seatClass.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
baseFareMinor: dto.baseFareMinor,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteClass(id: string) {
|
||||
const seatClass = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
|
||||
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 }); }
|
||||
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 } });
|
||||
@@ -118,120 +214,112 @@ export class FleetService {
|
||||
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 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: { number: '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"`);
|
||||
}
|
||||
|
||||
const coach = await this.prisma.coach.create({
|
||||
data: {
|
||||
coachTypeId: dto.coachTypeId,
|
||||
number: dto.number,
|
||||
arrangement: dto.arrangement,
|
||||
capacity: dto.capacity,
|
||||
status: dto.status || 'ACTIVE',
|
||||
},
|
||||
include: { coachType: true },
|
||||
});
|
||||
|
||||
if (dto.capacity > 0) {
|
||||
const seatClass = coach.coachType?.name || '';
|
||||
const seats = buildSeats(coach.id, coach.number, dto.arrangement, dto.capacity, seatClass);
|
||||
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,
|
||||
},
|
||||
include: { coachType: true },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCoach(id: string) {
|
||||
const coach = await this.prisma.coach.findUnique({ where: { id } });
|
||||
if (!coach) throw new NotFoundException('Coach not found');
|
||||
|
||||
// Get all seat IDs for this coach
|
||||
const seats = await this.prisma.seat.findMany({ where: { coachId: id }, select: { id: true } });
|
||||
const seatIds = seats.map(s => s.id);
|
||||
|
||||
// Delete in order of foreign key dependencies
|
||||
if (seatIds.length > 0) {
|
||||
// 1. Delete seat blocks (references seats)
|
||||
await this.prisma.seatBlock.deleteMany({ where: { seatId: { in: seatIds } } });
|
||||
|
||||
// 2. Delete ticket seats (references seats)
|
||||
await this.prisma.ticketSeat.deleteMany({ where: { seatId: { in: seatIds } } });
|
||||
|
||||
// 3. Delete booking seats (references seats)
|
||||
await this.prisma.bookingSeat.deleteMany({ where: { seatId: { in: seatIds } } });
|
||||
|
||||
// 4. Delete journey segments with these seats
|
||||
await this.prisma.journeySegment.deleteMany({ where: { seatId: { in: seatIds } } });
|
||||
}
|
||||
|
||||
// 5. Delete all associated seats
|
||||
await this.prisma.seat.deleteMany({ where: { coachId: id } });
|
||||
|
||||
// 6. Delete coach assignments
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { coachId: id } });
|
||||
|
||||
// 7. Finally delete the coach
|
||||
return this.prisma.coach.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -251,19 +339,6 @@ export class FleetService {
|
||||
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(),
|
||||
@@ -271,6 +346,12 @@ export class FleetService {
|
||||
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 };
|
||||
return {
|
||||
totalTrains,
|
||||
totalSchedules,
|
||||
totalSeats,
|
||||
bookedSeats,
|
||||
occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user