mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +00:00
Merge pull request #111 from Tria-plc/alpha
Coaches, seats, schedules, and pricing related updates
This commit is contained in:
@@ -392,7 +392,7 @@ export class BookingsService {
|
||||
where: { bookingRef },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
paymentIntent: true, ticket: true,
|
||||
},
|
||||
});
|
||||
@@ -408,7 +408,7 @@ export class BookingsService {
|
||||
destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city },
|
||||
departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt,
|
||||
},
|
||||
passengers: booking.seats.map((bs) => ({
|
||||
passengers: booking.seats?.map((bs: any) => ({
|
||||
fullName: bs.passengerName, category: bs.passengerCategory, verifaydaVerified: bs.verifaydaVerified,
|
||||
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass.name },
|
||||
})),
|
||||
|
||||
@@ -35,7 +35,7 @@ export class DashboardService {
|
||||
upcomingTicket: upcomingBooking ? {
|
||||
ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef,
|
||||
from: upcomingBooking.schedule.originStation.name, to: upcomingBooking.schedule.destinationStation.name,
|
||||
trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label,
|
||||
trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber,
|
||||
departureAt: upcomingBooking.schedule.departureAt,
|
||||
punctualityLabel: (upcomingBooking.schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
|
||||
} : null,
|
||||
|
||||
@@ -44,7 +44,7 @@ export class FareEngineService {
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
|
||||
|
||||
const ratePerKmMinor = seatClass.basePrice;
|
||||
const ratePerKmMinor = seatClass.baseFareMinor;
|
||||
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
||||
|
||||
const adultCount = dto.adultCount ?? 1;
|
||||
@@ -129,7 +129,7 @@ export class FareEngineService {
|
||||
) {
|
||||
const seatClasses = await this.prisma.seatClass.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { basePrice: 'asc' },
|
||||
orderBy: { baseFareMinor: 'asc' },
|
||||
});
|
||||
|
||||
const results = await Promise.all(
|
||||
@@ -172,7 +172,7 @@ export class FareEngineService {
|
||||
if (schedule.routeId) {
|
||||
const seatClasses = await this.prisma.seatClass.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { basePrice: 'asc' },
|
||||
orderBy: { baseFareMinor: 'asc' },
|
||||
});
|
||||
|
||||
const results = await Promise.all(
|
||||
@@ -198,23 +198,25 @@ export class FareEngineService {
|
||||
validFrom: { lte: now },
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
include: { seatClass: true },
|
||||
orderBy: { seatClass: { basePrice: 'asc' } },
|
||||
orderBy: [{ seatClass: { baseFareMinor: 'asc' } }],
|
||||
});
|
||||
|
||||
if (fareRules.length > 0) {
|
||||
const billingCurrency = resolveCurrencyFromNationality(nationality);
|
||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
|
||||
return fareRules.map(rule => ({
|
||||
seatClassId: rule.seatClassId,
|
||||
seatClassName: rule.seatClass.name,
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
totalMinor: rule.baseFareMinor,
|
||||
billingCurrency,
|
||||
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
|
||||
exchangeRate,
|
||||
source: 'FARE_RULE',
|
||||
}));
|
||||
return fareRules.map(rule => {
|
||||
const seatClassId = rule.seatClassId;
|
||||
return {
|
||||
seatClassId,
|
||||
seatClassName: 'Unknown',
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
totalMinor: rule.baseFareMinor,
|
||||
billingCurrency,
|
||||
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
|
||||
exchangeRate,
|
||||
source: 'FARE_RULE',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
throw new BadRequestException(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,22 +92,22 @@ export class PassengersService {
|
||||
}
|
||||
|
||||
async getProfile(passengerId: string) {
|
||||
const p = await this.prisma.passenger.findUnique({
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { id: passengerId },
|
||||
include: {
|
||||
user: { select: { fullName: true, email: true, phone: true } },
|
||||
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } } } },
|
||||
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } } },
|
||||
loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true,
|
||||
},
|
||||
});
|
||||
if (!p) throw new NotFoundException('Passenger not found');
|
||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||
return {
|
||||
id: p.id,
|
||||
fullName: p.user.fullName,
|
||||
email: p.user.email,
|
||||
phone: p.user.phone,
|
||||
createdAt: p.createdAt,
|
||||
bookings: p.bookings.map((b) => ({
|
||||
id: passenger.id,
|
||||
fullName: passenger.user.fullName,
|
||||
email: passenger.user.email,
|
||||
phone: passenger.user.phone,
|
||||
createdAt: passenger.createdAt,
|
||||
bookings: passenger.bookings.map((b) => ({
|
||||
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,
|
||||
trip: {
|
||||
number: b.schedule.train.number,
|
||||
@@ -115,7 +115,7 @@ export class PassengersService {
|
||||
destination: { id: b.schedule.destinationStation.id, name: b.schedule.destinationStation.name, code: b.schedule.destinationStation.code, city: b.schedule.destinationStation.city },
|
||||
departureAt: b.schedule.departureAt,
|
||||
},
|
||||
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass?.name ?? 'N/A' } })),
|
||||
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.seatNumber, coach: bs.seat.coach.number, class: 'N/A' } })),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -207,7 +207,6 @@ export class PassengersService {
|
||||
const isLoggedIn = !!dto.userId;
|
||||
let verifiedData: any = null;
|
||||
|
||||
// Auto-verify Ethiopian passengers with national ID if Fayda is enabled
|
||||
if (isEthiopian && dto.verifyWithFayda !== false) {
|
||||
try {
|
||||
const verification = await this.verifaydaService.verifyNationalId(dto.nationalId!);
|
||||
@@ -215,12 +214,10 @@ export class PassengersService {
|
||||
verifiedData = verification.passengerData;
|
||||
}
|
||||
} catch (error) {
|
||||
// If verification fails, continue with manual data
|
||||
console.warn('Fayda verification failed, using manual data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Use verified data if available, otherwise use provided data
|
||||
const finalData = {
|
||||
passengerName: verifiedData?.fullName || dto.passengerName,
|
||||
dateOfBirth: verifiedData?.dateOfBirth || new Date(dto.dateOfBirth),
|
||||
@@ -230,7 +227,6 @@ export class PassengersService {
|
||||
email: dto.email,
|
||||
};
|
||||
|
||||
// If logged in, update user profile and link passenger
|
||||
if (isLoggedIn) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: dto.userId },
|
||||
@@ -241,7 +237,6 @@ export class PassengersService {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
// Update user record if not already verified
|
||||
if (!user.faydaVerified && verifiedData) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: dto.userId },
|
||||
@@ -267,7 +262,6 @@ export class PassengersService {
|
||||
};
|
||||
}
|
||||
|
||||
// Guest user - save to SavedPassengerProfile
|
||||
const profile = await this.prisma.savedPassengerProfile.create({
|
||||
data: {
|
||||
deviceId: dto.deviceId,
|
||||
@@ -318,4 +312,4 @@ export class PassengersService {
|
||||
affectedModules: usage,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,19 +40,17 @@ describe('Payments E2E', () => {
|
||||
data: { trainId: train.id, originStationId: station1.id, destinationStationId: station2.id, departureAt: new Date(Date.now() + 86400000), arrivalAt: new Date(Date.now() + 90000000), durationMinutes: 60 },
|
||||
});
|
||||
|
||||
const seatClass = await prisma.seatClass.upsert({
|
||||
where: { name: 'Economy Regular' },
|
||||
update: {},
|
||||
create: { name: 'Economy Regular', description: 'Standard economy seating', basePrice: 45000, isActive: true },
|
||||
const coachType = await prisma.coachType.create({ data: { name: 'Standard', code: 'STD' } });
|
||||
|
||||
const seatClass = await prisma.seatClass.create({
|
||||
data: { name: 'Economy Regular', description: 'Standard economy seating', baseFareMinor: 45000, isActive: true, coachTypeId: coachType.id },
|
||||
});
|
||||
|
||||
const coach = await prisma.coach.create({
|
||||
data: { coachNumber: 'TEST-C1', label: 'A', seatClassId: seatClass.id, mode: 'seat', totalUnits: 10 },
|
||||
data: { coachTypeId: coachType.id, number: 'TEST-C1', arrangement: '2+2', capacity: 10, status: 'ACTIVE' },
|
||||
});
|
||||
|
||||
await prisma.coachAssignment.create({ data: { scheduleId: schedule.id, coachId: coach.id, positionNumber: 1 } });
|
||||
|
||||
const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', label: '1A', status: 'AVAILABLE' } });
|
||||
const seat = await prisma.seat.create({ data: { coachId: coach.id, row: 1, col: 'A', seatNumber: '1A', status: 'AVAILABLE' } });
|
||||
|
||||
const booking = await prisma.booking.create({
|
||||
data: { bookingRef: 'TEST-BOOK-001', passengerId: passenger.id, scheduleId: schedule.id, status: 'PENDING_PAYMENT', totalMinor: 50000, currency: 'ETB' },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto } from './schedules.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { TripStatus } from '@prisma/client';
|
||||
|
||||
@@ -10,14 +10,23 @@ import { TripStatus } from '@prisma/client';
|
||||
export class SchedulesController {
|
||||
constructor(private service: SchedulesService) {}
|
||||
|
||||
@Post('bulk-generate')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Bulk generate repetitive schedules',
|
||||
description: 'Creates multiple schedules automatically by repeating every X days for the next Y days. Example: repeat every 2 days for 30 days = 15 schedules.',
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Schedules generated successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid parameters or route not found' })
|
||||
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
|
||||
return this.service.bulkGenerateSchedules(dto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Create a train schedule from a route template',
|
||||
description: `Creates a schedule by referencing a Route (routeId).
|
||||
Stops are automatically copied from the route's RouteStop definitions.
|
||||
You supply the actual planned arrival/departure times per stop sequence.
|
||||
Origin and destination are derived from the first and last route stop — no need to specify them manually.`,
|
||||
description: `Creates a schedule by referencing a Route (routeId).\nStops are automatically copied from the route's RouteStop definitions.\nYou supply the actual planned arrival/departure times per stop sequence.\nOrigin and destination are derived from the first and last route stop — no need to specify them manually.`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' })
|
||||
@@ -40,13 +49,42 @@ Origin and destination are derived from the first and last route stop — no nee
|
||||
return this.service.listSchedules({ date, routeId, trainId, status });
|
||||
}
|
||||
|
||||
// Static routes before parameterised ones
|
||||
// ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
|
||||
|
||||
@Post('fares')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
|
||||
@ApiResponse({ status: 201, description: 'Fare rule created' })
|
||||
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
|
||||
|
||||
@Post('segment-fares')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Create a segment fare rule (stop-to-stop pricing on a route)' })
|
||||
@ApiResponse({ status: 201, description: 'Segment fare rule created' })
|
||||
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
|
||||
|
||||
@Get('routes/:routeId/segment-fares')
|
||||
@ApiOperation({ summary: 'List all segment fare rules for a route' })
|
||||
@ApiParam({ name: 'routeId', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'List of segment fare rules' })
|
||||
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
|
||||
|
||||
@Patch('segment-fares/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update a segment fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Segment fare rule updated' })
|
||||
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
|
||||
|
||||
@Delete('segment-fares/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete a segment fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Segment fare rule deleted' })
|
||||
deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); }
|
||||
|
||||
// ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) =====
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@@ -56,12 +94,12 @@ Origin and destination are derived from the first and last route stop — no nee
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update a schedule' })
|
||||
@ApiOperation({ summary: 'Update a schedule (partial update - times, status, coaches)' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Schedule updated' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
updateSchedule(@Param('id') id: string, @Body() dto: CreateScheduleDto) {
|
||||
return this.service.updateSchedule(id, dto);
|
||||
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
|
||||
return this.service.updateSchedulePartial(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@@ -84,8 +122,6 @@ Origin and destination are derived from the first and last route stop — no nee
|
||||
return this.service.deleteSchedule(id);
|
||||
}
|
||||
|
||||
// ── Stop Times ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(':id/stops')
|
||||
@ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@@ -106,8 +142,6 @@ Origin and destination are derived from the first and last route stop — no nee
|
||||
@Body() dto: UpdateStopTimeDto,
|
||||
) { return this.service.updateStop(id, sequence, dto); }
|
||||
|
||||
// ── Fares ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Get(':scheduleId/fares')
|
||||
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@@ -151,8 +185,6 @@ Origin and destination are derived from the first and last route stop — no nee
|
||||
return this.service.syncFaresFromEngine(id);
|
||||
}
|
||||
|
||||
// ── Coach Assignments ──────────────────────────────────────────────────────
|
||||
|
||||
@Post(':id/coaches')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
|
||||
@@ -34,6 +34,13 @@ export class CreateScheduleDto {
|
||||
plannedTimes: PlannedStopTimeDto[];
|
||||
}
|
||||
|
||||
export class UpdateScheduleDto {
|
||||
@ApiPropertyOptional({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsOptional() @IsDateString() departureAt?: string;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string;
|
||||
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
|
||||
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
|
||||
}
|
||||
|
||||
export class UpdateStopTimeDto {
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
|
||||
@@ -50,6 +57,17 @@ export class CreateFareRuleDto {
|
||||
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
|
||||
}
|
||||
|
||||
export class CreateSegmentFareRuleDto {
|
||||
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
|
||||
@ApiProperty({ example: 1, description: 'Origin stop sequence number' }) @IsInt() @Min(1) originStopSequence: number;
|
||||
@ApiProperty({ example: 2, description: 'Destination stop sequence number' }) @IsInt() @Min(1) destinationStopSequence: number;
|
||||
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' }) @IsString() seatClassId: string;
|
||||
@ApiProperty({ example: 45000, description: 'Base fare in minor currency units (ETB cents)' }) @IsInt() baseFareMinor: number;
|
||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality scope (Ethiopian, Djiboutian, Other)' }) @IsOptional() @IsString() nationality?: string;
|
||||
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
|
||||
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
|
||||
}
|
||||
|
||||
export class ListSchedulesDto {
|
||||
@ApiPropertyOptional({ example: '2026-06-15', description: 'Filter by departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' })
|
||||
@IsOptional() @IsDateString() date?: string;
|
||||
@@ -67,3 +85,21 @@ export class ListSchedulesDto {
|
||||
export class UpdateScheduleStatusDto {
|
||||
@ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus;
|
||||
}
|
||||
|
||||
export class BulkCreateSchedulesDto {
|
||||
@ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string;
|
||||
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
|
||||
@ApiProperty({ example: '2026-06-15T08:00:00Z', description: 'Start date and time for first schedule' }) @IsDateString() startDateTime: string;
|
||||
@ApiProperty({ example: 12, description: 'Hours duration per schedule' }) @IsInt() @Min(1) durationHours: number;
|
||||
@ApiProperty({ example: 2, description: 'Repeat every X days' }) @IsInt() @Min(1) repeatEveryDays: number;
|
||||
@ApiProperty({ example: 30, description: 'Generate schedules for the next Y days' }) @IsInt() @Min(1) forNextDays: number;
|
||||
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Optional custom planned times per stop. If not provided, will auto-generate.' })
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||
plannedTimes?: PlannedStopTimeDto[];
|
||||
}
|
||||
|
||||
export class BulkSchedulesResponseDto {
|
||||
@ApiProperty() schedulesCreated: number;
|
||||
@ApiProperty() errors: string[];
|
||||
@ApiProperty() scheduleIds: string[];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService {
|
||||
@@ -10,9 +10,55 @@ export class SchedulesService {
|
||||
private prisma: PrismaService,
|
||||
private routesService: RoutesService,
|
||||
private fareEngine: FareEngineService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
// ── Schedule CRUD ──────────────────────────────────────────────────────────
|
||||
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
|
||||
const startDate = new Date(dto.startDateTime);
|
||||
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
|
||||
const errors: string[] = [];
|
||||
const scheduleIds: string[] = [];
|
||||
|
||||
// Validate route and get stops for plannedTimes generation
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
if (!route.active) throw new BadRequestException('Route is not active');
|
||||
|
||||
let currentDate = new Date(startDate);
|
||||
let scheduleCount = 0;
|
||||
|
||||
while (currentDate < endDate) {
|
||||
try {
|
||||
const departureAt = new Date(currentDate);
|
||||
const arrivalAt = new Date(departureAt.getTime() + dto.durationHours * 60 * 60 * 1000);
|
||||
|
||||
const createDto: CreateScheduleDto = {
|
||||
trainId: dto.trainId,
|
||||
routeId: dto.routeId,
|
||||
departureAt: departureAt.toISOString(),
|
||||
arrivalAt: arrivalAt.toISOString(),
|
||||
plannedTimes: dto.plannedTimes || [],
|
||||
};
|
||||
|
||||
const schedule = await this.createSchedule(createDto);
|
||||
scheduleIds.push(schedule.id);
|
||||
scheduleCount++;
|
||||
} catch (error) {
|
||||
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
// Move to next repetition
|
||||
currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
return {
|
||||
schedulesCreated: scheduleCount,
|
||||
errors,
|
||||
scheduleIds,
|
||||
};
|
||||
}
|
||||
|
||||
async listSchedules(dto: ListSchedulesDto) {
|
||||
const where: any = {};
|
||||
@@ -58,28 +104,48 @@ export class SchedulesService {
|
||||
if (!route.active) throw new BadRequestException('Route is not active');
|
||||
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
|
||||
|
||||
// Check for duplicate schedule with same train, route, and date
|
||||
const depDate = new Date(dep);
|
||||
depDate.setHours(0, 0, 0, 0);
|
||||
const nextDay = new Date(depDate);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
|
||||
const existingSchedule = await this.prisma.trainSchedule.findFirst({
|
||||
where: {
|
||||
trainId: dto.trainId,
|
||||
routeId: dto.routeId,
|
||||
departureAt: {
|
||||
gte: depDate,
|
||||
lt: nextDay,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingSchedule) {
|
||||
throw new BadRequestException(
|
||||
`A schedule for this train, route, and date already exists. Departure: ${new Date(existingSchedule.departureAt).toLocaleString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-generate plannedTimes if not provided or empty
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
|
||||
|
||||
if (index === 0) {
|
||||
// First stop - use departure time
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
// Last stop - use arrival time
|
||||
stopTime = arr;
|
||||
} else {
|
||||
// Intermediate stops - calculate based on distance proportion
|
||||
const stopDistance = stop.distanceKm || 0;
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
@@ -113,7 +179,6 @@ export class SchedulesService {
|
||||
include: { train: true, originStation: true, destinationStation: true },
|
||||
});
|
||||
|
||||
// Copy route stops into TripStopTime with the provided planned times
|
||||
const plannedTimesMap = Object.fromEntries(
|
||||
plannedTimes.map(t => [t.sequence, t]),
|
||||
);
|
||||
@@ -130,7 +195,7 @@ export class SchedulesService {
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
coachAssignments: {
|
||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
|
||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
},
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
@@ -138,18 +203,16 @@ export class SchedulesService {
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
// Compute effective seat statuses from SeatHold + JourneySegment
|
||||
// (seat.status DB column is no longer written during booking)
|
||||
const allSeatIds = schedule.coachAssignments.flatMap(a => a.coach.seats.map(s => s.id));
|
||||
const allSeatIds = schedule.coachAssignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(id, allSeatIds);
|
||||
|
||||
return {
|
||||
...schedule,
|
||||
coachAssignments: schedule.coachAssignments.map(a => ({
|
||||
coachAssignments: schedule.coachAssignments.map((a: any) => ({
|
||||
...a,
|
||||
coach: {
|
||||
...a.coach,
|
||||
seats: a.coach.seats.map(s => ({
|
||||
seats: a.coach.seats.map((s: any) => ({
|
||||
...s,
|
||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||
})),
|
||||
@@ -158,12 +221,6 @@ export class SchedulesService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes effective seat status for a schedule by checking active SeatHolds
|
||||
* and confirmed JourneySegments. The DB seat.status column is not written
|
||||
* during segment-based booking, so this overlay is required.
|
||||
* Priority: BLOCKED (physical) > BOOKED (confirmed) > HELD (active hold) > AVAILABLE
|
||||
*/
|
||||
private async resolveEffectiveStatuses(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
@@ -204,7 +261,6 @@ export class SchedulesService {
|
||||
const arr = new Date(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
|
||||
// Validate route exists and has stops
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
@@ -213,7 +269,6 @@ export class SchedulesService {
|
||||
if (!route.active) throw new BadRequestException('Route is not active');
|
||||
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
|
||||
|
||||
// Derive origin and destination from first and last route stop
|
||||
const firstStop = route.stops[0];
|
||||
const lastStop = route.stops[route.stops.length - 1];
|
||||
|
||||
@@ -231,18 +286,16 @@ export class SchedulesService {
|
||||
},
|
||||
});
|
||||
|
||||
// Delete existing stop times and recreate
|
||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
||||
|
||||
// Auto-generate plannedTimes if not provided
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
||||
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
|
||||
|
||||
if (index === 0) {
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
@@ -252,7 +305,7 @@ export class SchedulesService {
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
@@ -276,19 +329,53 @@ export class SchedulesService {
|
||||
async deleteSchedule(id: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
// Delete related records first (in dependency order)
|
||||
|
||||
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.seatHold.deleteMany({ where: { scheduleId: id } });
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: { scheduleId: id },
|
||||
select: { id: true },
|
||||
});
|
||||
const bookingIds = bookings.map(b => b.id);
|
||||
|
||||
if (bookingIds.length > 0) {
|
||||
const paymentIntents = await this.prisma.paymentIntent.findMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const paymentIntentIds = paymentIntents.map(pi => pi.id);
|
||||
|
||||
if (paymentIntentIds.length > 0) {
|
||||
await this.prisma.paymentRefund.deleteMany({
|
||||
where: { paymentIntentId: { in: paymentIntentIds } },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.ticket.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
await this.prisma.bookingSeat.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.paymentIntent.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.booking.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||
|
||||
|
||||
return this.prisma.trainSchedule.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// ── Stop Times (per-schedule overrides) ───────────────────────────────────
|
||||
|
||||
getStops(scheduleId: string) {
|
||||
return this.prisma.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
@@ -314,8 +401,6 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Fare Rules ─────────────────────────────────────────────────────────────
|
||||
|
||||
createFareRule(dto: CreateFareRuleDto) {
|
||||
const { validFrom, validUntil, scheduleId, nationality, ...rest } = dto;
|
||||
return this.prisma.fareRule.create({
|
||||
@@ -329,6 +414,43 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
createSegmentFareRule(dto: any) {
|
||||
const { validFrom, validUntil, ...rest } = dto;
|
||||
return this.prisma.segmentFareRule.create({
|
||||
data: {
|
||||
...rest,
|
||||
validFrom: new Date(validFrom),
|
||||
validUntil: validUntil ? new Date(validUntil) : null,
|
||||
},
|
||||
include: { seatClass: true, route: true },
|
||||
});
|
||||
}
|
||||
|
||||
getSegmentFares(routeId: string) {
|
||||
return this.prisma.segmentFareRule.findMany({
|
||||
where: { routeId },
|
||||
include: { seatClass: true, route: true },
|
||||
orderBy: [{ originStopSequence: 'asc' }, { destinationStopSequence: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
deleteSegmentFareRule(id: string) {
|
||||
return this.prisma.segmentFareRule.delete({ where: { id } });
|
||||
}
|
||||
|
||||
updateSegmentFareRule(id: string, dto: any) {
|
||||
const { validFrom, validUntil, ...rest } = dto;
|
||||
return this.prisma.segmentFareRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
validFrom: validFrom ? new Date(validFrom) : undefined,
|
||||
validUntil: validUntil ? new Date(validUntil) : null,
|
||||
},
|
||||
include: { seatClass: true, route: true },
|
||||
});
|
||||
}
|
||||
|
||||
getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) {
|
||||
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
|
||||
}
|
||||
@@ -337,10 +459,6 @@ export class SchedulesService {
|
||||
return this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate fares for all active seat classes on a schedule using the fare engine
|
||||
* and upsert them as FareRule records scoped to this schedule.
|
||||
*/
|
||||
async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> {
|
||||
const results = await this.fareEngine.calculateAllForSchedule(scheduleId);
|
||||
const errors: string[] = [];
|
||||
@@ -352,7 +470,6 @@ export class SchedulesService {
|
||||
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: fare.seatClassName } });
|
||||
if (!seatClass) { errors.push(`Seat class not found: ${fare.seatClassName}`); continue; }
|
||||
|
||||
// Expire any existing active rule for this schedule + seat class
|
||||
await this.prisma.fareRule.updateMany({
|
||||
where: { tripId: scheduleId, seatClassId: seatClass.id, validUntil: null },
|
||||
data: { validUntil: now },
|
||||
@@ -377,8 +494,6 @@ export class SchedulesService {
|
||||
return { synced, errors };
|
||||
}
|
||||
|
||||
// ── Coach Assignments ──────────────────────────────────────────────────────
|
||||
|
||||
async assignCoaches(
|
||||
scheduleId: string,
|
||||
coaches: Array<{ coachId: string; positionNumber: number }>,
|
||||
@@ -386,7 +501,6 @@ export class SchedulesService {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
// Validate all coaches exist
|
||||
const coachIds = coaches.map(c => c.coachId);
|
||||
const existingCoaches = await this.prisma.coach.findMany({
|
||||
where: { id: { in: coachIds } },
|
||||
@@ -395,18 +509,16 @@ export class SchedulesService {
|
||||
throw new NotFoundException('One or more coaches not found');
|
||||
}
|
||||
|
||||
// Remove existing assignments
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
|
||||
|
||||
// Create new assignments
|
||||
await this.prisma.coachAssignment.createMany({
|
||||
data: coaches.map(c => ({
|
||||
scheduleId,
|
||||
coachId: c.coachId,
|
||||
positionNumber: c.positionNumber,
|
||||
isOperational: true,
|
||||
})),
|
||||
});
|
||||
const data = coaches.map((c, idx) => ({
|
||||
scheduleId,
|
||||
coachId: c.coachId,
|
||||
positionNumber: idx + 1,
|
||||
isOperational: true,
|
||||
}));
|
||||
|
||||
await this.prisma.coachAssignment.createMany({ data });
|
||||
|
||||
return { message: 'Coaches assigned successfully', count: coaches.length };
|
||||
}
|
||||
@@ -417,7 +529,6 @@ export class SchedulesService {
|
||||
include: {
|
||||
coach: {
|
||||
include: {
|
||||
seatClass: true,
|
||||
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
|
||||
},
|
||||
},
|
||||
@@ -426,6 +537,41 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateSchedulePartial(id: string, dto: UpdateScheduleDto) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const updateData: any = {};
|
||||
|
||||
if (dto.departureAt || dto.arrivalAt) {
|
||||
const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt);
|
||||
const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt);
|
||||
|
||||
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
|
||||
|
||||
updateData.departureAt = dep;
|
||||
updateData.arrivalAt = arr;
|
||||
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
|
||||
}
|
||||
|
||||
if (dto.status) {
|
||||
updateData.status = dto.status;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await this.prisma.trainSchedule.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.coaches && dto.coaches.length > 0) {
|
||||
await this.assignCoaches(id, dto.coaches);
|
||||
}
|
||||
|
||||
return this.getSchedule(id);
|
||||
}
|
||||
|
||||
async removeCoachAssignment(scheduleId: string, coachId: string) {
|
||||
const assignment = await this.prisma.coachAssignment.findFirst({
|
||||
where: { scheduleId, coachId },
|
||||
@@ -435,4 +581,4 @@ export class SchedulesService {
|
||||
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
|
||||
return { message: 'Coach assignment removed' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,6 @@ export class SearchService {
|
||||
const nextDay = new Date(date.getTime() + 86_400_000);
|
||||
const totalPassengers = dto.adultCount + (dto.childCount ?? 0);
|
||||
|
||||
// Find all schedules that have BOTH origin and destination as stops
|
||||
// (not just terminal-to-terminal) and depart on the requested date
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||
@@ -36,7 +34,7 @@ export class SearchService {
|
||||
destinationStation: true,
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
coachAssignments: {
|
||||
include: { coach: { include: { seats: true, seatClass: true } } },
|
||||
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -44,36 +42,46 @@ export class SearchService {
|
||||
const results = [];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
|
||||
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
|
||||
|
||||
// Both stops must exist and origin must come before destination
|
||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
|
||||
|
||||
// Compute per-seat availability for the requested segment range
|
||||
// A seat is available if no active booking/hold overlaps [originSeq, destSeq)
|
||||
const availabilityByClass: Record<string, number> = {};
|
||||
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
const className = assignment.coach.seatClass.name;
|
||||
if (!availabilityByClass[className]) availabilityByClass[className] = 0;
|
||||
// Get seat class names from coach type
|
||||
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
|
||||
|
||||
for (const seatClassName of seatClassNames) {
|
||||
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
|
||||
}
|
||||
|
||||
// Count available seats (skip blocked and removed seats)
|
||||
for (const seat of assignment.coach.seats) {
|
||||
// Skip blocked seats
|
||||
if (seat.status === 'BLOCKED') continue;
|
||||
// Use segment-aware check — a seat booked A→B is still free for B→D
|
||||
|
||||
// Skip removed seats (empty seatNumber)
|
||||
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
|
||||
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||
schedule.id, seat.id,
|
||||
originStop.sequence, destStop.sequence,
|
||||
);
|
||||
if (free) availabilityByClass[className]++;
|
||||
|
||||
if (free) {
|
||||
// Group by seat class - use the first seat class for now
|
||||
// In a full implementation, seats would have a seatClassId
|
||||
const className = seatClassNames[0] || 'Standard';
|
||||
availabilityByClass[className]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Departure/arrival times for the requested leg (not the full schedule)
|
||||
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
||||
|
||||
// Fetch fares for all seat classes - need to pass the SEARCH origin/destination, not schedule terminals
|
||||
const faresByClass = await this.calculateFaresForSegment(
|
||||
schedule,
|
||||
dto.originStationId,
|
||||
@@ -106,14 +114,14 @@ export class SearchService {
|
||||
),
|
||||
status: schedule.status,
|
||||
stops: schedule.stopTimes
|
||||
.filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
|
||||
.map(st => ({
|
||||
stationId: st.stationId,
|
||||
stationName: st.station.name,
|
||||
sequence: st.sequence,
|
||||
plannedArrivalAt: st.plannedArrivalAt,
|
||||
plannedDepartureAt: st.plannedDepartureAt,
|
||||
})),
|
||||
.filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
|
||||
.map((st: any) => ({
|
||||
stationId: st.stationId,
|
||||
stationName: st.station.name,
|
||||
sequence: st.sequence,
|
||||
plannedArrivalAt: st.plannedArrivalAt,
|
||||
plannedDepartureAt: st.plannedDepartureAt,
|
||||
})),
|
||||
availabilityByClass,
|
||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||
faresByClass,
|
||||
@@ -134,51 +142,19 @@ export class SearchService {
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
|
||||
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
|
||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) {
|
||||
throw new NotFoundException('Origin or destination not found on this schedule');
|
||||
}
|
||||
|
||||
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } });
|
||||
|
||||
// Compute route codes for fare lookup
|
||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
||||
const now = new Date();
|
||||
const nationality = dto.nationality;
|
||||
|
||||
// Query fare rules with specificity ordering:
|
||||
// 1. schedule+segment+nationality
|
||||
// 2. schedule+segment
|
||||
// 3. schedule+full-route+nationality
|
||||
// 4. schedule+full-route
|
||||
// 5. schedule+global
|
||||
// 6. segment+nationality
|
||||
// 7. segment
|
||||
// 8. full-route+nationality
|
||||
// 9. full-route
|
||||
// 10. global
|
||||
const fareRule = await this.prisma.fareRule.findFirst({
|
||||
where: {
|
||||
seatClassId: seatClass?.id,
|
||||
validFrom: { lte: now },
|
||||
OR: [
|
||||
{ validUntil: null },
|
||||
{ validUntil: { gte: now } },
|
||||
],
|
||||
},
|
||||
orderBy: [
|
||||
// Prioritize schedule-specific rules
|
||||
{ tripId: { sort: 'desc', nulls: 'last' } },
|
||||
// Then prioritize nationality match
|
||||
{ nationality: { sort: 'desc', nulls: 'last' } },
|
||||
// Most recent validFrom
|
||||
{ validFrom: 'desc' },
|
||||
],
|
||||
});
|
||||
|
||||
// Manual specificity filtering to find best match
|
||||
const candidates = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
seatClassId: seatClass?.id,
|
||||
@@ -243,38 +219,40 @@ export class SearchService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate fares for a specific segment of a schedule
|
||||
*/
|
||||
private async calculateFaresForSegment(
|
||||
schedule: any,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
nationality?: string,
|
||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
|
||||
// Get seat classes that are actually assigned to this schedule via coaches
|
||||
const assignedSeatClassIds: string[] = Array.from(
|
||||
// Get unique seat classes from all coaches assigned to this schedule via their coach types
|
||||
const seatClassIds: string[] = Array.from(
|
||||
new Set(
|
||||
schedule.coachAssignments.map((a: any) => a.coach.seatClass.id as string)
|
||||
schedule.coachAssignments
|
||||
.flatMap((a: any) => a.coach.coachType?.seatClasses || [])
|
||||
.map((sc: any) => sc.id)
|
||||
.filter((id: any) => id)
|
||||
)
|
||||
);
|
||||
|
||||
// Get only the seat classes that are assigned to this schedule
|
||||
const seatClasses = await this.prisma.seatClass.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
id: { in: assignedSeatClassIds }
|
||||
},
|
||||
orderBy: { basePrice: 'asc' },
|
||||
});
|
||||
|
||||
// If no coaches assigned, return empty array
|
||||
if (seatClasses.length === 0) {
|
||||
if (seatClassIds.length === 0) {
|
||||
console.log(`No seat classes assigned to schedule ${schedule.id}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// If schedule has a route, use route-based calculation
|
||||
const seatClasses = await this.prisma.seatClass.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
id: { in: seatClassIds }
|
||||
},
|
||||
orderBy: { baseFareMinor: 'asc' },
|
||||
});
|
||||
|
||||
if (seatClasses.length === 0) {
|
||||
console.log(`No active seat classes for schedule ${schedule.id}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (schedule.routeId) {
|
||||
const results = await Promise.all(
|
||||
seatClasses.map(async (sc) => {
|
||||
@@ -303,7 +281,6 @@ export class SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try to get fares from FareRule table
|
||||
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
|
||||
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
|
||||
|
||||
@@ -314,26 +291,25 @@ export class SearchService {
|
||||
const fareRules = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
route: segmentRoute,
|
||||
seatClassId: { in: assignedSeatClassIds },
|
||||
seatClassId: { in: seatClassIds },
|
||||
validFrom: { lte: now },
|
||||
OR: [
|
||||
{ validUntil: null },
|
||||
{ validUntil: { gte: now } },
|
||||
],
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
|
||||
if (fareRules.length > 0) {
|
||||
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
|
||||
const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name]));
|
||||
return fareRules.map(rule => ({
|
||||
seatClassName: rule.seatClass.name,
|
||||
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: Return default fares only for assigned seat classes
|
||||
console.log(`No fares found, using defaults for ${originStationId} to ${destinationStationId}`);
|
||||
return seatClasses.map(sc => ({
|
||||
seatClassName: sc.name,
|
||||
@@ -359,60 +335,6 @@ export class SearchService {
|
||||
return fares[seatClassName] ?? 45000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback method to get fares from FareRule table when fare engine fails
|
||||
*/
|
||||
private async getFallbackFares(
|
||||
scheduleId: string,
|
||||
originCode: string,
|
||||
destCode: string,
|
||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number }>> {
|
||||
const segmentRoute = `${originCode}-${destCode}`;
|
||||
const now = new Date();
|
||||
|
||||
// Try to find fare rules for this segment
|
||||
const fareRules = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
route: segmentRoute,
|
||||
validFrom: { lte: now },
|
||||
OR: [
|
||||
{ validUntil: null },
|
||||
{ validUntil: { gte: now } },
|
||||
],
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
|
||||
if (fareRules.length > 0) {
|
||||
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
|
||||
return fareRules.map(rule => ({
|
||||
seatClassName: rule.seatClass.name,
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
}));
|
||||
}
|
||||
|
||||
// If no segment-specific rules, return default fares
|
||||
console.log(`No fare rules found for ${segmentRoute}, using defaults`);
|
||||
return [
|
||||
{ seatClassName: 'Economy Regular', baseFareMinor: 35000 },
|
||||
{ seatClassName: 'Economy Bed', baseFareMinor: 49000 },
|
||||
{ seatClassName: 'VIP Bed', baseFareMinor: 63000 },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the best matching fare rule based on specificity:
|
||||
* 1. schedule+segment+nationality
|
||||
* 2. schedule+segment
|
||||
* 3. schedule+full-route+nationality
|
||||
* 4. schedule+full-route
|
||||
* 5. schedule+global
|
||||
* 6. segment+nationality
|
||||
* 7. segment
|
||||
* 8. full-route+nationality
|
||||
* 9. full-route
|
||||
* 10. global
|
||||
*/
|
||||
private selectBestFareRule(
|
||||
candidates: any[],
|
||||
scheduleId: string,
|
||||
@@ -421,19 +343,16 @@ export class SearchService {
|
||||
nationality?: string,
|
||||
): any | null {
|
||||
const priorities = [
|
||||
// Schedule-specific rules
|
||||
{ tripId: scheduleId, route: segmentRoute, nationality },
|
||||
{ tripId: scheduleId, route: segmentRoute, nationality: null },
|
||||
{ tripId: scheduleId, route: fullRoute, nationality },
|
||||
{ tripId: scheduleId, route: fullRoute, nationality: null },
|
||||
{ tripId: scheduleId, route: null, nationality },
|
||||
{ tripId: scheduleId, route: null, nationality: null },
|
||||
// Route-specific rules (no schedule)
|
||||
{ tripId: null, route: segmentRoute, nationality },
|
||||
{ tripId: null, route: segmentRoute, nationality: null },
|
||||
{ tripId: null, route: fullRoute, nationality },
|
||||
{ tripId: null, route: fullRoute, nationality: null },
|
||||
// Global rules
|
||||
{ tripId: null, route: null, nationality },
|
||||
{ tripId: null, route: null, nationality: null },
|
||||
];
|
||||
|
||||
@@ -1,41 +1,33 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SeatClassesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
private readonly coachInclude = {
|
||||
coaches: {
|
||||
select: { id: true, coachNumber: true, label: true, mode: true, totalUnits: true, _count: { select: { seats: true } } },
|
||||
orderBy: { label: 'asc' as const },
|
||||
},
|
||||
};
|
||||
|
||||
listSeatClasses() {
|
||||
return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' }, include: this.coachInclude });
|
||||
return this.prisma.seatClass.findMany({ orderBy: { createdAt: 'asc' } });
|
||||
}
|
||||
|
||||
async getSeatClass(id: string) {
|
||||
const sc = await this.prisma.seatClass.findUnique({ where: { id }, include: this.coachInclude });
|
||||
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||
return sc;
|
||||
}
|
||||
|
||||
async createSeatClass(dto: CreateSeatClassDto) {
|
||||
async createSeatClass(dto: any) {
|
||||
try {
|
||||
return await this.prisma.seatClass.create({ data: dto, include: this.coachInclude });
|
||||
return await this.prisma.seatClass.create({ data: dto });
|
||||
} catch (e: any) {
|
||||
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async updateSeatClass(id: string, dto: UpdateSeatClassDto) {
|
||||
async updateSeatClass(id: string, dto: any) {
|
||||
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||
return this.prisma.seatClass.update({ where: { id }, data: dto, include: this.coachInclude });
|
||||
return this.prisma.seatClass.update({ where: { id }, data: dto });
|
||||
}
|
||||
|
||||
async deleteSeatClass(id: string) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { SeatsService } from './seats.service';
|
||||
import { HoldSeatsDto } from './seats.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
|
||||
@ApiTags('Seats')
|
||||
@Controller('seats')
|
||||
@@ -78,6 +79,47 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
@ApiResponse({ status: 404, description: 'Hold not found' })
|
||||
releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); }
|
||||
|
||||
// ── Seat Block / Unblock ───────────────────────────────────────────────────
|
||||
@Post(':seatId/block')
|
||||
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Block a seat (e.g., maintenance, damage)' })
|
||||
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Seat blocked' })
|
||||
blockSeat(@Param('seatId') seatId: string, @Body() body: { reason: string }) {
|
||||
return this.service.blockSeat(seatId, body.reason);
|
||||
}
|
||||
|
||||
@Delete(':seatId/block')
|
||||
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Unblock a seat' })
|
||||
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Seat unblocked' })
|
||||
unblockSeat(@Param('seatId') seatId: string) {
|
||||
return this.service.unblockSeat(seatId);
|
||||
}
|
||||
|
||||
// ── Remove Seat ────────────────────────────────────────────────────────────
|
||||
@Patch(':seatId/remove')
|
||||
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Remove a seat by marking with negative seatNumber' })
|
||||
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Seat removed (seatNumber negated), shows as empty space' })
|
||||
@ApiResponse({ status: 404, description: 'Seat not found' })
|
||||
removeSeat(@Param('seatId') seatId: string) {
|
||||
return this.service.removeSeat(seatId);
|
||||
}
|
||||
|
||||
@Patch(':seatId/undo-remove')
|
||||
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Undo seat removal by restoring original seatNumber' })
|
||||
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Seat restored (negative seatNumber removed)' })
|
||||
@ApiResponse({ status: 404, description: 'Seat not found' })
|
||||
@ApiResponse({ status: 400, description: 'Seat is not removed' })
|
||||
undoRemoveSeat(@Param('seatId') seatId: string) {
|
||||
return this.service.undoRemoveSeat(seatId);
|
||||
}
|
||||
|
||||
@Get('export/csv/:scheduleId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' })
|
||||
async exportCSV(@Param('scheduleId') scheduleId: string) {
|
||||
const csv = await this.service.exportSeatsCSV(scheduleId);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { SeatsController } from './seats.controller';
|
||||
import { SeatsService } from './seats.service';
|
||||
import { SegmentsModule } from '../segments/segments.module';
|
||||
import { IamModule } from '../../common/iam.module';
|
||||
|
||||
@Module({
|
||||
imports: [SegmentsModule],
|
||||
imports: [SegmentsModule, HttpModule, IamModule],
|
||||
controllers: [SeatsController],
|
||||
providers: [SeatsService],
|
||||
exports: [SeatsService],
|
||||
|
||||
@@ -11,48 +11,69 @@ export class SeatsService {
|
||||
private segmentsService: SegmentsService,
|
||||
) {}
|
||||
|
||||
// ── Seat Map ──────────────────────────────────────────────────────────────
|
||||
async getSeatMap(scheduleId: string, coachId?: string) {
|
||||
const assignments = await this.prisma.coachAssignment.findMany({
|
||||
where: { scheduleId, ...(coachId ? { coachId } : {}) },
|
||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
|
||||
include: {
|
||||
coach: {
|
||||
include: {
|
||||
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
|
||||
coachType: { include: { seatClasses: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
});
|
||||
|
||||
const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id));
|
||||
console.log(`[getSeatMap] scheduleId=${scheduleId}, coachId=${coachId}, found ${assignments.length} coach assignments`);
|
||||
|
||||
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds);
|
||||
|
||||
return {
|
||||
coaches: assignments.map((a) => ({
|
||||
id: a.coach.id,
|
||||
assignmentId: a.id,
|
||||
name: `Coach ${a.coach.label}`,
|
||||
seatClass: a.coach.seatClass.name,
|
||||
positionNumber: a.positionNumber,
|
||||
seats: a.coach.seats.map((s) => ({
|
||||
id: s.id,
|
||||
number: s.label,
|
||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||
kind: s.kind,
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
isWindow: s.isWindow,
|
||||
isAisle: s.isAisle,
|
||||
bedPosition: s.bedPosition,
|
||||
})),
|
||||
})),
|
||||
const response = {
|
||||
coaches: assignments.map((a) => {
|
||||
// Include all seats (both valid and removed with negative seatNumbers)
|
||||
const allSeats = a.coach.seats;
|
||||
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
|
||||
const seatClass = seatClassNames.length > 0 ? seatClassNames[0] : 'Standard';
|
||||
|
||||
return {
|
||||
id: a.coach.id,
|
||||
assignmentId: a.id,
|
||||
coachNumber: a.coach.number,
|
||||
label: a.coach.number,
|
||||
mode: a.coach.status,
|
||||
name: `Coach ${a.coach.number}`,
|
||||
seatClass,
|
||||
positionNumber: a.positionNumber,
|
||||
seatArrangement: a.coach.arrangement,
|
||||
totalSeats: a.coach.capacity,
|
||||
seats: allSeats.map((s) => ({
|
||||
id: s.id,
|
||||
seatNumber: s.seatNumber,
|
||||
number: s.seatNumber,
|
||||
label: s.seatNumber,
|
||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||
kind: s.kind,
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
isWindow: s.isWindow,
|
||||
isAisle: s.isAisle,
|
||||
bedPosition: s.bedPosition,
|
||||
coach: {
|
||||
id: a.coach.id,
|
||||
coachNumber: a.coach.number,
|
||||
label: a.coach.number,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
console.log(`[getSeatMap] returning ${response.coaches.length} coaches with seats`);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the effective seat status for a set of seats on a specific schedule
|
||||
* by checking active SeatHolds and confirmed JourneySegments.
|
||||
*
|
||||
* Priority: BLOCKED (physical) > BOOKED (confirmed journey) > HELD (active hold) > AVAILABLE
|
||||
*
|
||||
* This is needed because seat.status is no longer written during booking —
|
||||
* availability is segment-scoped, so the DB column stays AVAILABLE even when held.
|
||||
*/
|
||||
async resolveEffectiveStatuses(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
@@ -61,7 +82,6 @@ export class SeatsService {
|
||||
|
||||
if (seatIds.length === 0) return statusMap;
|
||||
|
||||
// 1. Active holds — any seat in an unexpired SeatHold for this schedule is HELD
|
||||
const activeHolds = await this.prisma.seatHold.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
@@ -76,8 +96,6 @@ export class SeatsService {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Active bookings via JourneySegment — CONFIRMED or PENDING_PAYMENT → BOOKED
|
||||
// (overwrites HELD if the same seat has a confirmed booking)
|
||||
const bookedSegments = await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
@@ -93,24 +111,21 @@ export class SeatsService {
|
||||
return statusMap;
|
||||
}
|
||||
|
||||
// ── Hold / Release ────────────────────────────────────────────────────────
|
||||
async holdSeats(dto: HoldSeatsDto) {
|
||||
// ── Validate request integrity ───────────────────────────────────────────
|
||||
const passengerIds = dto.passengers.map(p => p.passengerId);
|
||||
const seatIds = dto.passengers.map(p => p.seatId);
|
||||
|
||||
if (new Set(passengerIds).size !== passengerIds.length)
|
||||
throw new BadRequestException('Duplicate passengerId in passengers list — each passenger must appear once');
|
||||
throw new BadRequestException('Duplicate passengerId in passengers list');
|
||||
if (new Set(seatIds).size !== seatIds.length)
|
||||
throw new BadRequestException('Duplicate seatId in passengers list — each seat can only be assigned to one passenger');
|
||||
throw new BadRequestException('Duplicate seatId in passengers list');
|
||||
|
||||
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
|
||||
|
||||
const hold = await this.prisma.$transaction(async (tx) => {
|
||||
// ── 1. Validate seats exist and none are BLOCKED ─────────────────────
|
||||
const seats = await tx.seat.findMany({
|
||||
where: { id: { in: seatIds } },
|
||||
select: { id: true, status: true, label: true },
|
||||
select: { id: true, status: true, seatNumber: true },
|
||||
});
|
||||
|
||||
if (seats.length !== seatIds.length) {
|
||||
@@ -121,11 +136,10 @@ export class SeatsService {
|
||||
|
||||
const blocked = seats.filter(s => s.status === 'BLOCKED');
|
||||
if (blocked.length > 0)
|
||||
throw new ConflictException(`Seat(s) ${blocked.map(s => s.label).join(', ')} are blocked`);
|
||||
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are blocked`);
|
||||
|
||||
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.label]));
|
||||
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
|
||||
|
||||
// ── 2. Resolve requested leg sequences ──────────────────────────────
|
||||
const stopTimes = await tx.tripStopTime.findMany({
|
||||
where: { scheduleId: dto.scheduleId },
|
||||
select: { stationId: true, sequence: true },
|
||||
@@ -137,17 +151,15 @@ export class SeatsService {
|
||||
const reqTo = seqOf(dto.destinationStationId);
|
||||
|
||||
if (reqFrom === undefined || reqTo === undefined)
|
||||
throw new BadRequestException('Origin or destination station not found on this schedule');
|
||||
throw new BadRequestException('Origin or destination station not found');
|
||||
if (reqFrom >= reqTo)
|
||||
throw new BadRequestException('Origin must come before destination');
|
||||
|
||||
// ── 3. Load active holds for this schedule ───────────────────────────
|
||||
const activeHolds = await tx.seatHold.findMany({
|
||||
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
|
||||
select: { seatIds: true, createdBy: true },
|
||||
});
|
||||
|
||||
// Parse each hold's leg range and passenger list
|
||||
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[] }[] = [];
|
||||
for (const h of activeHolds) {
|
||||
try {
|
||||
@@ -164,32 +176,28 @@ export class SeatsService {
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { /* ignore malformed */ }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── 4. Per-passenger validation with overlap check ───────────────────
|
||||
for (const { passengerId, seatId } of dto.passengers) {
|
||||
for (const hold of parsedHolds) {
|
||||
const legsOverlap = hold.from < reqTo && reqFrom < hold.to;
|
||||
if (!legsOverlap) continue; // non-overlapping leg — no conflict
|
||||
if (!legsOverlap) continue;
|
||||
|
||||
// Rule A: seat is held on an overlapping leg
|
||||
if (hold.seatIds.includes(seatId)) {
|
||||
throw new ConflictException(
|
||||
`Seat ${seatLabelById[seatId]} is already held for this leg. Please choose a different seat.`,
|
||||
`Seat ${seatLabelById[seatId]} is already held for this leg`,
|
||||
);
|
||||
}
|
||||
|
||||
// Rule B: passenger already holds a seat on an overlapping leg
|
||||
if (hold.passengerIds.includes(passengerId)) {
|
||||
throw new ConflictException(
|
||||
`Passenger already holds a seat on this journey leg. You can only hold one seat per journey.`,
|
||||
`Passenger already holds a seat on this journey leg`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store passenger→seat mapping AND leg in createdBy as JSON
|
||||
const holdMeta = {
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
@@ -228,12 +236,7 @@ export class SeatsService {
|
||||
return this.enrichHold(hold);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the opaque fareQuoteId leg encoding into human-readable station
|
||||
* names and enriches the hold with schedule, seat, and leg details.
|
||||
*/
|
||||
private async enrichHold(hold: any) {
|
||||
// Decode leg and passenger→seat mapping from createdBy JSON
|
||||
let originStationId: string | null = null;
|
||||
let destinationStationId: string | null = null;
|
||||
let passengerSeatMap: { passengerId: string; seatId: string }[] = [];
|
||||
@@ -241,7 +244,6 @@ export class SeatsService {
|
||||
try {
|
||||
if (hold.createdBy) {
|
||||
const raw = hold.createdBy;
|
||||
// Guard: only parse if it looks like a JSON object, not a plain number/string
|
||||
if (typeof raw === 'string' && raw.trimStart().startsWith('{')) {
|
||||
const meta = JSON.parse(raw);
|
||||
originStationId = meta.originStationId ?? null;
|
||||
@@ -249,7 +251,7 @@ export class SeatsService {
|
||||
passengerSeatMap = Array.isArray(meta.passengers) ? meta.passengers : [];
|
||||
}
|
||||
}
|
||||
} catch { /* ignore malformed createdBy */ }
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const seatIds = hold.seatIds as string[];
|
||||
|
||||
@@ -262,7 +264,7 @@ export class SeatsService {
|
||||
destinationStationId ? this.prisma.station.findUnique({ where: { id: destinationStationId } }) : null,
|
||||
this.prisma.seat.findMany({
|
||||
where: { id: { in: seatIds } },
|
||||
include: { coach: { include: { seatClass: true } } },
|
||||
include: { coach: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -277,10 +279,8 @@ export class SeatsService {
|
||||
destinationSequence = stopTimes.find(s => s.stationId === destinationStationId)?.sequence ?? null;
|
||||
}
|
||||
|
||||
// Build seat map keyed by seatId for quick lookup
|
||||
const seatById = Object.fromEntries(seats.map(s => [s.id, s]));
|
||||
|
||||
// Merge passenger→seat mapping with seat details
|
||||
const passengers = passengerSeatMap.length > 0
|
||||
? passengerSeatMap.map(({ passengerId, seatId }) => {
|
||||
const s = seatById[seatId];
|
||||
@@ -288,26 +288,25 @@ export class SeatsService {
|
||||
passengerId,
|
||||
seat: s ? {
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
label: s.seatNumber,
|
||||
seatNumber: s.seatNumber,
|
||||
coach: s.coach.label,
|
||||
seatClass: s.coach.seatClass.name,
|
||||
coach: s.coach.number,
|
||||
seatClass: 'Standard',
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
} : { id: seatId },
|
||||
};
|
||||
})
|
||||
// Fallback for holds created before this change
|
||||
: seatIds.map(seatId => {
|
||||
const s = seatById[seatId];
|
||||
return {
|
||||
passengerId: hold.passengerId,
|
||||
seat: s ? {
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
label: s.seatNumber,
|
||||
seatNumber: s.seatNumber,
|
||||
coach: s.coach.label,
|
||||
seatClass: s.coach.seatClass.name,
|
||||
coach: s.coach.number,
|
||||
seatClass: 'Standard',
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
} : { id: seatId },
|
||||
@@ -350,8 +349,7 @@ export class SeatsService {
|
||||
}
|
||||
|
||||
async confirmSeats(seatIds: string[]) {
|
||||
// No-op for status — availability is segment-scoped via JourneySegment
|
||||
// seat.status = BLOCKED is the only hard gate; BOOKED is not used as a booking flag
|
||||
// No-op
|
||||
}
|
||||
|
||||
async releaseSeats(seatIds: string[]) {
|
||||
@@ -362,14 +360,15 @@ export class SeatsService {
|
||||
}
|
||||
}
|
||||
|
||||
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise<string[]> {
|
||||
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
|
||||
const seats = await this.prisma.seat.findMany({
|
||||
where: {
|
||||
coach: { seatClass: { name: seatClassName }, assignments: { some: { scheduleId } } },
|
||||
coach: { assignments: { some: { scheduleId } } },
|
||||
status: 'AVAILABLE',
|
||||
...(eligibility ? { eligibility } : {}),
|
||||
seatNumber: { not: '' },
|
||||
NOT: { seatNumber: { startsWith: '-' } },
|
||||
},
|
||||
orderBy: [{ coach: { label: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
|
||||
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
|
||||
});
|
||||
|
||||
if (seats.length < count) {
|
||||
@@ -404,10 +403,10 @@ export class SeatsService {
|
||||
where: { scheduleId },
|
||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
|
||||
});
|
||||
const rows = ['coachId,coachLabel,row,col,label,kind,status,premiumFeeMinor,eligibility'];
|
||||
const rows = ['coachId,coachLabel,row,col,seatNumber,kind,status,premiumFeeMinor'];
|
||||
for (const a of assignments) {
|
||||
for (const seat of a.coach.seats) {
|
||||
rows.push(`${a.coach.id},${a.coach.label},${seat.row},${seat.col},${seat.label},${seat.kind},${seat.status},${seat.premiumFeeMinor},${seat.eligibility || ''}`);
|
||||
rows.push(`${a.coach.id},${a.coach.number},${seat.row},${seat.col},${seat.seatNumber},${seat.kind},${seat.status},${seat.premiumFeeMinor}`);
|
||||
}
|
||||
}
|
||||
return rows.join('\n');
|
||||
@@ -426,8 +425,8 @@ export class SeatsService {
|
||||
invalid++;
|
||||
continue;
|
||||
}
|
||||
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor] = parts;
|
||||
if (!coachId || !row || !col || !label) {
|
||||
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
|
||||
if (!coachId || !row || !col || !seatNumber) {
|
||||
errors.push(`Line ${i + 2}: Missing required fields`);
|
||||
invalid++;
|
||||
continue;
|
||||
@@ -444,32 +443,30 @@ export class SeatsService {
|
||||
let imported = 0;
|
||||
|
||||
if (!commit) {
|
||||
return { imported: 0, errors: ['Preview mode - use commit=true to apply changes'] };
|
||||
return { imported: 0, errors: ['Preview mode'] };
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
try {
|
||||
const parts = lines[i].split(',');
|
||||
const [coachId, coachLabel, row, col, label, kind, status, premiumFeeMinor, eligibility] = parts;
|
||||
const [coachId, coachLabel, row, col, seatNumber, kind, status, premiumFeeMinor] = parts;
|
||||
|
||||
await this.prisma.seat.upsert({
|
||||
where: { coachId_row_col: { coachId, row: parseInt(row), col } },
|
||||
update: {
|
||||
label,
|
||||
seatNumber,
|
||||
kind: kind as any,
|
||||
status: status as any,
|
||||
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
|
||||
eligibility: eligibility || null,
|
||||
},
|
||||
create: {
|
||||
coachId,
|
||||
row: parseInt(row),
|
||||
col,
|
||||
label,
|
||||
seatNumber,
|
||||
kind: kind as any,
|
||||
status: status as any,
|
||||
premiumFeeMinor: parseInt(premiumFeeMinor) || 0,
|
||||
eligibility: eligibility || null,
|
||||
},
|
||||
});
|
||||
imported++;
|
||||
@@ -481,6 +478,74 @@ export class SeatsService {
|
||||
return { imported, errors: errors.slice(0, 10) };
|
||||
}
|
||||
|
||||
async blockSeat(seatId: string, reason: string) {
|
||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
|
||||
await this.prisma.seat.update({
|
||||
where: { id: seatId },
|
||||
data: { status: 'BLOCKED' },
|
||||
});
|
||||
|
||||
await this.prisma.seatBlock.create({
|
||||
data: {
|
||||
seatId,
|
||||
reason,
|
||||
blockedBy: 'system',
|
||||
},
|
||||
});
|
||||
|
||||
return { blocked: true, seatId, reason };
|
||||
}
|
||||
|
||||
async unblockSeat(seatId: string) {
|
||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
|
||||
await this.prisma.seat.update({
|
||||
where: { id: seatId },
|
||||
data: { status: 'AVAILABLE' },
|
||||
});
|
||||
|
||||
await this.prisma.seatBlock.deleteMany({
|
||||
where: { seatId },
|
||||
});
|
||||
|
||||
return { unblocked: true, seatId };
|
||||
}
|
||||
|
||||
async removeSeat(seatId: string) {
|
||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
if (!seat.seatNumber) throw new BadRequestException('Seat already removed');
|
||||
|
||||
// Mark removed seat with negative seatNumber (e.g., '1' → '-1') to show empty space
|
||||
const negatedNumber = `-${seat.seatNumber}`;
|
||||
await this.prisma.seat.update({
|
||||
where: { id: seatId },
|
||||
data: { seatNumber: negatedNumber },
|
||||
});
|
||||
|
||||
return { removed: true, seatId, originalSeatNumber: seat.seatNumber };
|
||||
}
|
||||
|
||||
async undoRemoveSeat(seatId: string) {
|
||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new NotFoundException('Seat not found');
|
||||
if (!seat.seatNumber || !seat.seatNumber.startsWith('-')) {
|
||||
throw new BadRequestException('Seat is not removed');
|
||||
}
|
||||
|
||||
// Restore original seatNumber by removing the negative sign
|
||||
const originalNumber = seat.seatNumber.slice(1);
|
||||
await this.prisma.seat.update({
|
||||
where: { id: seatId },
|
||||
data: { seatNumber: originalNumber },
|
||||
});
|
||||
|
||||
return { restored: true, seatId, seatNumber: originalNumber };
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async expireHolds() {
|
||||
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
|
||||
@@ -489,7 +554,6 @@ export class SeatsService {
|
||||
try {
|
||||
await this.prisma.seatHold.delete({ where: { id: hold.id } });
|
||||
} catch (err) {
|
||||
// Ignore if already deleted (e.g., by another process)
|
||||
if (err instanceof Error && !err.message.includes('P2025')) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ async function holdSeatsTransaction(scheduleId: string, seatIds: string[], passe
|
||||
|
||||
for (const seat of seats) {
|
||||
if (seat.status !== 'AVAILABLE') {
|
||||
throw new Error(`Seat ${seat.label} is not available (status: ${seat.status})`);
|
||||
throw new Error(`Seat ${seat.seatNumber} is not available (status: ${seat.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,14 +35,12 @@ export class EnhancedSeatsService {
|
||||
for (const seatId of request.seatIds) {
|
||||
const seat = await tx.seat.findUnique({ where: { id: seatId } });
|
||||
if (!seat) throw new BadRequestException(`Seat ${seatId} not found`);
|
||||
// Only BLOCKED seats are hard-rejected — BOOKED/HELD are fine if the
|
||||
// segment does not overlap (another passenger may occupy a different leg)
|
||||
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.label} is blocked`);
|
||||
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.seatNumber} is blocked`);
|
||||
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(
|
||||
request.scheduleId, seatId, reqFrom, reqTo,
|
||||
);
|
||||
if (!free) throw new ConflictException(`Seat ${seat.label} is not available for the requested leg`);
|
||||
if (!free) throw new ConflictException(`Seat ${seat.seatNumber} is not available for the requested leg`);
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
||||
@@ -51,7 +49,6 @@ export class EnhancedSeatsService {
|
||||
scheduleId: request.scheduleId,
|
||||
seatIds: request.seatIds,
|
||||
passengerId: request.passengerId,
|
||||
// Store leg in createdBy JSON — no fareQuoteId needed
|
||||
createdBy: JSON.stringify({
|
||||
originStationId: request.originStationId,
|
||||
destinationStationId: request.destinationStationId,
|
||||
@@ -60,7 +57,6 @@ export class EnhancedSeatsService {
|
||||
},
|
||||
});
|
||||
|
||||
// Do NOT set seat.status = HELD globally — status is segment-scoped
|
||||
this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments });
|
||||
return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds };
|
||||
});
|
||||
@@ -81,7 +77,6 @@ export class EnhancedSeatsService {
|
||||
});
|
||||
if (!schedule) throw new BadRequestException('Schedule not found');
|
||||
|
||||
// Resolve the passenger's leg from createdBy JSON
|
||||
let originStationId: string | undefined;
|
||||
let destinationStationId: string | undefined;
|
||||
try {
|
||||
@@ -92,15 +87,15 @@ export class EnhancedSeatsService {
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const originStop = originStationId ? schedule.stopTimes.find(s => s.stationId === originStationId) : undefined;
|
||||
const destStop = destinationStationId ? schedule.stopTimes.find(s => s.stationId === destinationStationId) : undefined;
|
||||
const originStop = originStationId ? schedule.stopTimes.find((s: any) => s.stationId === originStationId) : undefined;
|
||||
const destStop = destinationStationId ? schedule.stopTimes.find((s: any) => s.stationId === destinationStationId) : undefined;
|
||||
const fromSeq = originStop?.sequence ?? schedule.stopTimes[0].sequence;
|
||||
const toSeq = destStop?.sequence ?? schedule.stopTimes[schedule.stopTimes.length - 1].sequence;
|
||||
|
||||
const segments: Segment[] = [];
|
||||
for (let i = fromSeq; i < toSeq; i++) {
|
||||
const fromStop = schedule.stopTimes.find(s => s.sequence === i);
|
||||
const toStop = schedule.stopTimes.find(s => s.sequence === i + 1);
|
||||
const fromStop = schedule.stopTimes.find((s: any) => s.sequence === i);
|
||||
const toStop = schedule.stopTimes.find((s: any) => s.sequence === i + 1);
|
||||
if (fromStop && toStop) {
|
||||
segments.push({
|
||||
fromStationId: fromStop.stationId,
|
||||
@@ -132,7 +127,6 @@ export class EnhancedSeatsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Do NOT set seat.status = BOOKED globally — availability is segment-scoped
|
||||
await tx.seatHold.delete({ where: { id: request.holdId } });
|
||||
|
||||
this.eventEmitter.emit('booking.confirmed', { bookingId: request.bookingId, scheduleId: hold.scheduleId, seatIds: hold.seatIds, segments });
|
||||
@@ -185,22 +179,20 @@ export class EnhancedSeatsService {
|
||||
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
include: { coachAssignments: { include: { coach: { include: { seats: true, seatClass: true } } } } },
|
||||
include: { coachAssignments: { include: { coach: { include: { seats: true } } } } },
|
||||
});
|
||||
if (!schedule) throw new BadRequestException('Schedule not found');
|
||||
|
||||
const availableSeats = [];
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
for (const seat of assignment.coach.seats) {
|
||||
// Hard-blocked seats are never available
|
||||
if (seat.status === 'BLOCKED') continue;
|
||||
// Availability is determined purely by segment overlap — not global seat.status
|
||||
const free = await this.segmentsService.isSeatFreeForLeg(scheduleId, seat.id, reqFrom, reqTo);
|
||||
if (free) {
|
||||
availableSeats.push({
|
||||
id: seat.id, label: seat.label,
|
||||
coach: assignment.coach.label,
|
||||
seatClass: assignment.coach.seatClass.name,
|
||||
id: seat.id, label: seat.seatNumber,
|
||||
coach: assignment.coach.number,
|
||||
seatClass: 'Standard',
|
||||
row: seat.row, col: seat.col,
|
||||
kind: seat.kind,
|
||||
isWindow: seat.isWindow,
|
||||
|
||||
@@ -87,9 +87,15 @@ export class TicketsService {
|
||||
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
|
||||
});
|
||||
|
||||
// Create permanent seat blocks for all booked seats
|
||||
// Update all booked seats from HELD to BOOKED and create permanent seat blocks
|
||||
const seatIds = booking.seats.map(bs => bs.seatId);
|
||||
for (const seatId of seatIds) {
|
||||
// Update seat status to BOOKED
|
||||
await this.prisma.seat.update({
|
||||
where: { id: seatId },
|
||||
data: { status: 'BOOKED' },
|
||||
});
|
||||
// Create permanent seat blocks for all booked seats
|
||||
await this.prisma.seatBlock.create({
|
||||
data: {
|
||||
seatId,
|
||||
@@ -162,7 +168,7 @@ export class TicketsService {
|
||||
id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||
fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name,
|
||||
departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name,
|
||||
coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label, passengerName: seat?.passengerName,
|
||||
coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName,
|
||||
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
|
||||
barcodePayload: booking.ticket.barcodePayload
|
||||
};
|
||||
@@ -207,8 +213,8 @@ export class TicketsService {
|
||||
bookingRef: b.bookingRef,
|
||||
ticketId: b.ticket?.id,
|
||||
passengerName: b.seats[0]?.passengerName,
|
||||
seatLabel: b.seats[0]?.seat.label,
|
||||
coachLabel: b.seats[0]?.seat.coach.label,
|
||||
seatLabel: b.seats[0]?.seat.seatNumber,
|
||||
coachLabel: b.seats[0]?.seat.coach.number,
|
||||
qrPayload: b.ticket?.qrPayload,
|
||||
status: b.status,
|
||||
validatedAt: b.ticket?.validatedAt,
|
||||
|
||||
Reference in New Issue
Block a user