Refactor business logic for train,schedule,coach,seat and search modules

This commit is contained in:
Roba Boru
2026-05-22 14:47:38 +03:00
parent 9151110fd8
commit 096c717bfa
48 changed files with 2254 additions and 2865 deletions

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse, ApiBody } from '@nestjs/swagger';
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 { CreateTrainServiceDto, CreateCoachDto, CreateSeatBatchDto, UpdateCoachDto } from './fleet.dto';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Fleet')
@@ -11,46 +11,91 @@ import { JwtGuard } from '../../common/jwt.guard';
export class FleetController {
constructor(private service: FleetService) {}
@Get('services')
@ApiOperation({ summary: 'List train services' })
@ApiResponse({ status: 200, description: 'Returns all train services with recent trips' })
getServices() { return this.service.getServices(); }
@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(); }
@Post('services')
@Post('trains')
@ApiOperation({ summary: 'Create a train service' })
@ApiBody({ type: CreateTrainServiceDto })
@ApiResponse({ status: 201, description: 'Train service created' })
createService(@Body() dto: CreateTrainServiceDto) { return this.service.createService(dto); }
@ApiBody({ type: CreateTrainDto })
@ApiResponse({ status: 201, description: 'Train created' })
createTrain(@Body() dto: CreateTrainDto) { return this.service.createTrain(dto); }
@Get('coaches')
@ApiOperation({ summary: 'List coaches' })
@ApiQuery({ name: 'tripId', required: false, description: 'Filter by trip UUID' })
@ApiResponse({ status: 200, description: 'Returns coaches with seat class and seat count' })
listCoaches(@Query('tripId') tripId?: string) { return this.service.listCoaches(tripId); }
@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);
}
@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: 'Add a coach to a trip' })
@ApiOperation({ summary: 'Register a new physical coach and auto-generate its seats from arrangement config' })
@ApiBody({ type: CreateCoachDto })
@ApiResponse({ status: 201, description: 'Coach created' })
@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 a coach' })
@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); }
@Post('assignments')
@ApiOperation({ summary: 'Assign a physical coach to a train schedule at a given position' })
@ApiBody({ type: AssignCoachDto })
@ApiResponse({ status: 201, description: 'CoachAssignment created' })
@ApiResponse({ status: 404, description: 'Schedule or coach not found' })
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' })
@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-create seats for a coach' })
@ApiOperation({ summary: 'Batch-generate seats for a coach (rows × cols)' })
@ApiBody({ type: CreateSeatBatchDto })
@ApiResponse({ status: 201, description: 'Seats created' })
@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); }
@Get('analytics')
@ApiOperation({ summary: 'Fleet analytics' })
@ApiResponse({ status: 200, description: 'Returns fleet occupancy 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(); }
}

View File

@@ -1,25 +1,50 @@
import { IsString, IsInt, IsOptional, IsEnum, IsArray } from 'class-validator';
import { IsString, IsInt, IsOptional, IsArray, IsBoolean } from 'class-validator';
import { ApiProperty, ApiPropertyOptional, PartialType, OmitType } from '@nestjs/swagger';
import { ServiceClass } from '@prisma/client';
export class CreateTrainServiceDto {
@ApiProperty({ example: '301' }) @IsString() number: string;
export class CreateTrainDto {
@ApiProperty({ example: '301', description: 'Unique train service number' }) @IsString() number: string;
@ApiProperty({ example: 'Express 301' }) @IsString() name: string;
@ApiPropertyOptional({ example: 'EDR', description: 'Operator ID (defaults to op_edr)' }) @IsOptional() @IsString() operatorId?: string;
@ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string;
@ApiPropertyOptional({ example: 'Addis-Djibouti Express' }) @IsOptional() @IsString() description?: string;
}
export class CreateCoachDto {
@ApiProperty({ example: 'trip-uuid' }) @IsString() tripId: string;
@ApiProperty({ example: 'A' }) @IsString() label: string;
@ApiProperty({ enum: ServiceClass, example: 'ECONOMY_REGULAR' }) @IsEnum(ServiceClass) serviceClass: ServiceClass;
@ApiPropertyOptional({ example: 'seat-class-uuid' }) @IsOptional() @IsString() seatClassId?: string;
@ApiPropertyOptional({ example: 60 }) @IsOptional() @IsInt() capacity?: number;
@ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() sequence?: number;
@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;
}
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['tripId'] as const)) {}
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['coachNumber'] 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' }) @IsString() coachId: string;
@ApiProperty({ example: 10 }) @IsInt() rows: number;
@ApiProperty({ example: ['A', 'B', 'C', 'D'], type: [String] }) @IsArray() @IsString({ each: true }) cols: string[];
@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[];
}
export class ListCoachesDto {
@ApiPropertyOptional({ example: true, description: 'Filter by active/inactive status. Omit to return all.' })
@IsOptional() @IsBoolean() isActive?: boolean;
@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' })
@IsOptional() @IsString() scheduleId?: string;
}

View File

@@ -1,23 +1,215 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateTrainServiceDto, CreateCoachDto, CreateSeatBatchDto, UpdateCoachDto } from './fleet.dto';
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) {}
getServices() { return this.prisma.trainService.findMany({ include: { trips: { take: 5, orderBy: { departureAt: 'desc' } } } }); }
createService(dto: CreateTrainServiceDto) { return this.prisma.trainService.create({ data: dto }); }
listCoaches(tripId?: string) {
return this.prisma.coach.findMany({
where: tripId ? { tripId } : undefined,
include: { _count: { select: { seats: true } } },
orderBy: { label: 'asc' },
});
getTrains() {
return this.prisma.train.findMany({ include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } } });
}
createCoach(dto: CreateCoachDto) { return this.prisma.coach.create({ data: dto }); }
createTrain(dto: CreateTrainDto) { return this.prisma.train.create({ 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 } });
@@ -25,38 +217,42 @@ export class FleetService {
return this.prisma.coach.update({ where: { id }, data: dto });
}
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) {
try {
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) {
const seatNumber = `${coach.label}${row}${col}`;
seats.push({
coachId: dto.coachId,
row,
col,
label: `${row}${col}`,
seatNumber
});
}
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 };
} catch (error) {
console.error('Error in createSeatBatch:', error);
throw error;
}
await this.prisma.seat.createMany({ data: seats, skipDuplicates: true });
return { created: seats.length };
}
async getAnalytics() {
const [totalServices, totalTrips, totalSeats, bookedSeats] = await Promise.all([
this.prisma.trainService.count(), this.prisma.trip.count(),
this.prisma.seat.count(), this.prisma.seat.count({ where: { status: 'BOOKED' } }),
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 { totalServices, totalTrips, 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 };
}
}