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

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

View File

@@ -33,6 +33,7 @@ import { SegmentsModule } from './modules/segments/segments.module';
import { AgentsModule } from './modules/agents/agents.module';
import { ReportsModule } from './modules/reports/reports.module';
import { FraudModule } from './modules/fraud/fraud.module';
import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
@Module({
imports: [
@@ -66,6 +67,7 @@ import { FraudModule } from './modules/fraud/fraud.module';
AgentsModule,
ReportsModule,
FraudModule,
SeatClassesModule,
],
})
export class AppModule implements NestModule {

View File

@@ -168,7 +168,7 @@ Payment providers send notifications to:
.addTag('Agents', '👨‍💼 Agent booking, shifts, commissions, reconciliation')
.addTag('Booking', '🎫 Booking lifecycle, modification, cancellation, refunds')
.addTag('Dashboard', '📊 Home dashboard aggregated data')
.addTag('Fleet', '🚂 Train services, coaches, seat configurations')
.addTag('Fleet', '🚂 Trains, physical coaches, seat auto-generation, coach-to-schedule assignments')
.addTag('Seat Classes', '🎨 Seat class management and configuration')
.addTag('Fraud Detection', '🔒 Fraud detection, risk scoring, user blocking')
.addTag('Live Tracking', '📍 Real-time trip status, location updates, crowd signals')
@@ -179,7 +179,8 @@ Payment providers send notifications to:
.addTag('Payment Webhooks', '🔗 Payment provider callback endpoints')
.addTag('Promotions', '🎁 Promo codes, campaigns, discount validation')
.addTag('Reports', '📈 Revenue reports, occupancy analytics, agent sales')
.addTag('Schedule', '🗓 Trip schedules, fare rules, status updates')
.addTag('Routes', '🗺 Reusable route templates with ordered stops — referenced by schedules')
.addTag('Schedule', '🗓️ Train schedules (created from routes), stop time management, fare rules')
.addTag('Search', '🔍 Trip search, availability, fare quotes')
.addTag('Seats', '🪑 Seat maps, holds, releases, blocking, auto-assign')
.addTag('Segment-based Seats', '🎯 Segment-based seat availability and booking')

View File

@@ -13,7 +13,7 @@ export class AgentPassengerDto {
export class CreateAgentBookingDto {
@ApiProperty() @IsString() agentId: string;
@ApiProperty() @IsString() tripId: string;
@ApiProperty({ example: 'schedule-uuid' }) @IsString() scheduleId: string;
@ApiProperty({ type: [AgentPassengerDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => AgentPassengerDto) passengers: AgentPassengerDto[];
@ApiProperty() @IsString() paymentMethod: string;
@ApiPropertyOptional() @IsOptional() @IsInt() cashReceived?: number;

View File

@@ -17,8 +17,8 @@ export class AgentsService {
if (!agent || !agent.active) throw new NotFoundException('Agent not found or inactive');
if (!agent.user.passenger) throw new BadRequestException('Agent must have passenger account');
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId } });
if (!trip) throw new NotFoundException('Trip not found');
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId } });
if (!schedule) throw new NotFoundException('Schedule not found');
const seatIds = dto.passengers.map(p => p.seatId);
const seats = await this.prisma.seat.findMany({ where: { id: { in: seatIds } } });
@@ -31,7 +31,7 @@ export class AgentsService {
data: {
bookingRef: generateRef(),
passengerId: agent.user.passenger.id,
tripId: dto.tripId,
scheduleId: dto.scheduleId,
status: dto.paymentMethod === 'CASH' ? 'CONFIRMED' : 'PENDING_PAYMENT',
totalMinor,
seats: {

View File

@@ -15,15 +15,13 @@ export class PassengerInputDto {
export class CreateBookingDto {
@ApiProperty() @IsString() passengerId: string;
@ApiProperty() @IsString() tripId: string;
@ApiProperty() @IsString() scheduleId: string;
@ApiProperty() @IsString() holdId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID for this leg (must match the hold)' }) @IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg (must match the hold)' }) @IsString() destinationStationId: string;
@ApiProperty({ type: [PassengerInputDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[];
@ApiProperty({
example: 'ECONOMY_REGULAR',
enum: ['ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER']
})
@IsString() serviceClass: string;
@ApiPropertyOptional({ example: 'seat-class-uuid' }) @IsOptional() @IsString() seatClassId?: string;
@ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID' })
@IsString() seatClassId: string;
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
@ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string;
@@ -32,7 +30,7 @@ export class CreateBookingDto {
export class ModifyBookingDto {
@ApiProperty() @IsString() bookingRef: string;
@ApiProperty() @IsString() newTripId: string;
@ApiProperty({ example: 'schedule-uuid' }) @IsString() newScheduleId: string;
@ApiProperty({ type: [String] }) @IsArray() newSeatIds: string[];
@ApiPropertyOptional() @IsOptional() @IsString() reason?: string;
}

View File

@@ -2,7 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto';
import { CreateBookingDto, ModifyBookingDto } from './bookings.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
@@ -17,9 +17,7 @@ function calculateAge(dateOfBirth: Date): number {
const today = new Date();
let age = today.getFullYear() - dateOfBirth.getFullYear();
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dateOfBirth.getDate())) {
age--;
}
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dateOfBirth.getDate())) age--;
return age;
}
@@ -36,100 +34,67 @@ export class BookingsService {
async create(dto: CreateBookingDto) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId }, include: { originStation: true, destinationStation: true } });
if (!trip) throw new NotFoundException('Trip not found');
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true } });
if (!schedule) throw new NotFoundException('Schedule not found');
const seatIds = dto.passengers.map((p) => p.seatId);
// Calculate passenger categories and verify Ethiopian nationals
const passengersData = [];
let adultCount = 0;
let childCount = 0;
let adultCount = 0, childCount = 0;
for (const passenger of dto.passengers) {
const dateOfBirth = new Date(passenger.dateOfBirth);
const age = calculateAge(dateOfBirth);
const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT;
if (category === PassengerCategory.ADULT) adultCount++;
else childCount++;
if (category === PassengerCategory.ADULT) adultCount++; else childCount++;
let passengerName = passenger.passengerName;
let verifaydaVerified = false;
let verifaydaData: Record<string, any> | undefined = undefined;
// Verify Ethiopian nationals via Verifayda
let verifaydaData: Record<string, any> | undefined;
if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) {
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
if (!verification.verified) {
throw new BadRequestException(
`Verifayda verification failed for passenger ${passenger.passengerName}: ${verification.failureReason}`,
);
}
// Use verified data from Verifayda
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
passengerName = verification.passengerData?.fullName || passengerName;
verifaydaVerified = true;
verifaydaData = verification.passengerData?.profileData;
} else if (passenger.idDocumentType === IdDocumentType.PASSPORT) {
// Non-Ethiopian: require passport details
if (!passenger.passportNumber || !passenger.passportCountry) {
throw new BadRequestException(
`Passport number and country required for non-Ethiopian passenger ${passenger.passengerName}`,
);
}
if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`);
}
passengersData.push({
...passenger,
passengerName,
dateOfBirth,
category,
verifaydaVerified,
verifaydaData,
});
passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData });
}
// Calculate fare with age-based pricing
const baseFareMinor = await this.getBaseFare(dto.tripId, dto.serviceClass);
const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId);
const adultFareMinor = baseFareMinor * adultCount;
const paidChildrenCount = Math.max(0, childCount - 1);
const childFareMinor = baseFareMinor * paidChildrenCount;
const totalBaseFareMinor = adultFareMinor + childFareMinor;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff
? Math.round(totalBaseFareMinor * promo.percentOff / 100)
: (promo.amountOffMinor ?? 0);
discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
}
const booking = await this.prisma.booking.create({
data: {
bookingRef: generateRef(),
passengerId: dto.passengerId,
tripId: dto.tripId,
scheduleId: dto.scheduleId,
status: 'PENDING_PAYMENT',
totalMinor,
adultCount,
childCount,
displayCurrency,
displayTotalMinor,
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
bookingType: dto.bookingType ?? 'ONE_WAY',
seats: {
create: passengersData.map((p) => ({
@@ -148,96 +113,64 @@ export class BookingsService {
})),
},
},
include: { seats: { include: { seat: true } }, trip: { include: { originStation: true, destinationStation: true, service: true } } },
include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } },
});
await this.seatsService.confirmSeats(seatIds);
this.eventEmitter.emit('booking.created', { booking });
return {
...booking,
fareBreakdown: {
baseFareMinor,
adultCount,
adultFareMinor,
childCount,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount,
childFareMinor,
totalBaseFareMinor,
discountMinor,
loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
},
fareBreakdown: { baseFareMinor, adultCount, adultFareMinor, childCount, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount, childFareMinor, totalBaseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: 'ETB', displayCurrency, displayTotalMinor },
};
}
private async getBaseFare(tripId: string, serviceClass: string): Promise<number> {
const fareRule = await this.prisma.fareRule.findFirst({
where: { tripId, serviceClass: serviceClass as any },
});
private async getBaseFare(scheduleId: string, seatClassId: string): Promise<number> {
const fareRule = await this.prisma.fareRule.findFirst({ where: { tripId: scheduleId, seatClassId } });
return fareRule?.baseFareMinor ?? 35000;
}
async getByRef(bookingRef: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } }, paymentIntent: true, ticket: true } });
const booking = await this.prisma.booking.findUnique({
where: { bookingRef },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } },
paymentIntent: true, ticket: true,
},
});
if (!booking) throw new NotFoundException('Booking not found');
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalFare: booking.totalMinor / 100,
adultCount: booking.adultCount,
childCount: booking.childCount,
displayCurrency: booking.displayCurrency,
displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined,
bookingType: booking.bookingType,
createdAt: booking.createdAt,
trip: {
number: booking.trip.service.number,
origin: { id: booking.trip.originStation.id, name: booking.trip.originStation.name, code: booking.trip.originStation.code, city: booking.trip.originStation.city },
destination: { id: booking.trip.destinationStation.id, name: booking.trip.destinationStation.name, code: booking.trip.destinationStation.code, city: booking.trip.destinationStation.city },
departureAt: booking.trip.departureAt,
arrivalAt: booking.trip.arrivalAt,
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount,
displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined,
bookingType: booking.bookingType, createdAt: booking.createdAt,
schedule: {
number: booking.schedule.train.number,
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
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) => ({
fullName: bs.passengerName,
category: bs.passengerCategory,
verifaydaVerified: bs.verifaydaVerified,
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.serviceClass },
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 },
})),
payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined,
};
}
async modify(dto: ModifyBookingDto) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, include: { seats: true, trip: true } });
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, include: { seats: true, schedule: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status !== 'CONFIRMED') throw new BadRequestException('Only confirmed bookings can be modified');
if (booking.trip.departureAt < new Date()) throw new BadRequestException('Cannot modify past bookings');
if (booking.schedule.departureAt < new Date()) throw new BadRequestException('Cannot modify past bookings');
const oldSeats = booking.seats.map(s => s.seatId);
const fareAdjustment = 0;
await this.prisma.bookingModification.create({
data: {
bookingId: booking.id,
modifiedBy: booking.passengerId,
modificationType: 'SEAT_CHANGE',
oldData: { tripId: booking.tripId, seatIds: oldSeats },
newData: { tripId: dto.newTripId, seatIds: dto.newSeatIds },
fareAdjustment,
reason: dto.reason
}
data: { bookingId: booking.id, modifiedBy: booking.passengerId, modificationType: 'SEAT_CHANGE', oldData: { scheduleId: booking.scheduleId, seatIds: oldSeats }, newData: { scheduleId: dto.newScheduleId, seatIds: dto.newSeatIds }, fareAdjustment: 0, reason: dto.reason },
});
await this.seatsService.releaseSeats(oldSeats);
await this.seatsService.confirmSeats(dto.newSeatIds);
return { modified: true, bookingRef: dto.bookingRef };
}
@@ -245,23 +178,10 @@ export class BookingsService {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status === 'CANCELLED') throw new BadRequestException('Booking already cancelled');
const refundAmount = booking.status === 'CONFIRMED' ? Math.floor(booking.totalMinor * 0.8) : 0;
await this.prisma.bookingCancellation.create({
data: {
bookingId: booking.id,
cancelledBy: booking.passengerId,
reason,
refundAmount,
refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL',
refundStatus: 'PENDING'
}
});
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
}
@@ -269,6 +189,9 @@ export class BookingsService {
async expirePendingBookings() {
const cutoff = new Date(Date.now() - 20 * 60 * 1000);
const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } });
for (const b of expired) { await this.seatsService.releaseSeats(b.seats.map((s) => s.seatId)); await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } }); }
for (const b of expired) {
await this.seatsService.releaseSeats(b.seats.map((s) => s.seatId));
await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } });
}
}
}

View File

@@ -10,8 +10,12 @@ export class DashboardService {
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([
this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { user: { select: { fullName: true } }, loyalty: true } }),
this.prisma.booking.findFirst({
where: { passengerId, status: 'CONFIRMED', trip: { departureAt: { gte: now } } },
include: { trip: { include: { originStation: true, destinationStation: true, service: true, liveStatus: true } }, seats: { include: { seat: { include: { coach: true } } }, take: 1 }, ticket: true },
where: { passengerId, status: 'CONFIRMED', schedule: { departureAt: { gte: now } } },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true, liveStatus: true } },
seats: { include: { seat: { include: { coach: true } } }, take: 1 },
ticket: true,
},
orderBy: { createdAt: 'asc' },
}),
this.prisma.walletAccount.findUnique({ where: { passengerId } }),
@@ -30,10 +34,10 @@ export class DashboardService {
user: { firstName, greetingKey },
upcomingTicket: upcomingBooking ? {
ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef,
from: upcomingBooking.trip.originStation.name, to: upcomingBooking.trip.destinationStation.name,
trainName: upcomingBooking.trip.service.name, coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label,
departureAt: upcomingBooking.trip.departureAt,
punctualityLabel: (upcomingBooking.trip.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
from: upcomingBooking.schedule.originStation.name, to: upcomingBooking.schedule.destinationStation.name,
trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label,
departureAt: upcomingBooking.schedule.departureAt,
punctualityLabel: (upcomingBooking.schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
} : null,
wallet: wallet ? { balanceMinor: wallet.balanceMinor, currency: wallet.currency } : null,
activePromotionsCount: promos,

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse, ApiBody } from '@nestjs/swagger';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger';
import { FleetService } from './fleet.service';
import { CreateTrainServiceDto, CreateCoachDto, CreateSeatBatchDto, UpdateCoachDto } from './fleet.dto';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Fleet')
@@ -11,46 +11,91 @@ import { JwtGuard } from '../../common/jwt.guard';
export class FleetController {
constructor(private service: FleetService) {}
@Get('services')
@ApiOperation({ summary: 'List train services' })
@ApiResponse({ status: 200, description: 'Returns all train services with recent trips' })
getServices() { return this.service.getServices(); }
@Get('trains')
@ApiOperation({ summary: 'List all trains with their recent schedules' })
@ApiResponse({ status: 200, description: 'Array of trains each with up to 5 most recent schedules' })
getTrains() { return this.service.getTrains(); }
@Post('services')
@Post('trains')
@ApiOperation({ summary: 'Create a train service' })
@ApiBody({ type: CreateTrainServiceDto })
@ApiResponse({ status: 201, description: 'Train service created' })
createService(@Body() dto: CreateTrainServiceDto) { return this.service.createService(dto); }
@ApiBody({ type: CreateTrainDto })
@ApiResponse({ status: 201, description: 'Train created' })
createTrain(@Body() dto: CreateTrainDto) { return this.service.createTrain(dto); }
@Get('coaches')
@ApiOperation({ summary: 'List coaches' })
@ApiQuery({ name: 'tripId', required: false, description: 'Filter by trip UUID' })
@ApiResponse({ status: 200, description: 'Returns coaches with seat class and seat count' })
listCoaches(@Query('tripId') tripId?: string) { return this.service.listCoaches(tripId); }
@ApiOperation({ summary: 'List coaches filtered by status, mode, seat class, or schedule assignment' })
@ApiQuery({ name: 'isActive', required: false, type: Boolean, description: 'true = active only, false = inactive only, omit = all' })
@ApiQuery({ name: 'mode', required: false, description: 'Filter by mode: seat | bed | convertible' })
@ApiQuery({ name: 'seatClassId', required: false, description: 'Filter by SeatClass UUID' })
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter to coaches assigned to this TrainSchedule UUID' })
@ApiResponse({ status: 200, description: 'Coaches with seat class info, assignment count, and seat status summary (total/available/held/booked/blocked)' })
listCoaches(
@Query('isActive') isActive?: string,
@Query('mode') mode?: string,
@Query('seatClassId') seatClassId?: string,
@Query('scheduleId') scheduleId?: string,
) {
const dto: ListCoachesDto = {
isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined,
mode,
seatClassId,
scheduleId,
};
return this.service.listCoaches(dto);
}
@Get('coaches/:id')
@ApiOperation({ summary: 'Get a single coach with full seat layout and arrangement' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiResponse({
status: 200,
description: `Coach detail including:
- seatClass: seat class info
- seatsByRow: seats grouped by row number, each seat includes label, seatNumber, col, kind (STANDARD/PREMIUM/ACCESSIBLE), status (AVAILABLE/HELD/BOOKED/BLOCKED), isWindow, isAisle, bedPosition (bed mode only), premiumFeeMinor
- seatStatusSummary: total/available/held/booked/blocked counts
- assignments: up to 5 most recent schedule assignments with origin/destination`,
})
@ApiResponse({ status: 404, description: 'Coach not found' })
getCoach(@Param('id') id: string) { return this.service.getCoach(id); }
@Post('coaches')
@ApiOperation({ summary: 'Add a coach to a trip' })
@ApiOperation({ summary: 'Register a new physical coach and auto-generate its seats from arrangement config' })
@ApiBody({ type: CreateCoachDto })
@ApiResponse({ status: 201, description: 'Coach created' })
@ApiResponse({ status: 201, description: 'Coach created with seats auto-generated from mode + arrangement + totalUnits' })
@ApiResponse({ status: 400, description: 'Invalid arrangement format' })
createCoach(@Body() dto: CreateCoachDto) { return this.service.createCoach(dto); }
@Patch('coaches/:id')
@ApiOperation({ summary: 'Update a coach' })
@ApiOperation({ summary: 'Update coach properties (label, mode, arrangement, etc.)' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiBody({ type: UpdateCoachDto })
@ApiResponse({ status: 200, description: 'Coach updated' })
@ApiResponse({ status: 404, description: 'Coach not found' })
updateCoach(@Param('id') id: string, @Body() dto: UpdateCoachDto) { return this.service.updateCoach(id, dto); }
@Post('assignments')
@ApiOperation({ summary: 'Assign a physical coach to a train schedule at a given position' })
@ApiBody({ type: AssignCoachDto })
@ApiResponse({ status: 201, description: 'CoachAssignment created' })
@ApiResponse({ status: 404, description: 'Schedule or coach not found' })
assignCoach(@Body() dto: AssignCoachDto) { return this.service.assignCoach(dto); }
@Delete('assignments/:id')
@ApiOperation({ summary: 'Remove a coach assignment from a schedule' })
@ApiParam({ name: 'id', description: 'CoachAssignment UUID' })
@ApiResponse({ status: 200, description: 'Assignment removed' })
@ApiResponse({ status: 404, description: 'Assignment not found' })
removeAssignment(@Param('id') id: string) { return this.service.removeAssignment(id); }
@Post('seats/batch')
@ApiOperation({ summary: 'Batch-create seats for a coach' })
@ApiOperation({ summary: 'Batch-generate seats for a coach (rows × cols)' })
@ApiBody({ type: CreateSeatBatchDto })
@ApiResponse({ status: 201, description: 'Seats created' })
@ApiResponse({ status: 201, description: 'Returns count of seats created' })
@ApiResponse({ status: 404, description: 'Coach not found' })
createSeatBatch(@Body() dto: CreateSeatBatchDto) { return this.service.createSeatBatch(dto); }
@Get('analytics')
@ApiOperation({ summary: 'Fleet analytics' })
@ApiResponse({ status: 200, description: 'Returns fleet occupancy analytics' })
@ApiOperation({ summary: 'Fleet analytics: train count, schedule count, seat occupancy rate' })
@ApiResponse({ status: 200, description: 'Returns totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate' })
getAnalytics() { return this.service.getAnalytics(); }
}

View File

@@ -1,25 +1,50 @@
import { IsString, IsInt, IsOptional, IsEnum, IsArray } from 'class-validator';
import { IsString, IsInt, IsOptional, IsArray, IsBoolean } from 'class-validator';
import { ApiProperty, ApiPropertyOptional, PartialType, OmitType } from '@nestjs/swagger';
import { ServiceClass } from '@prisma/client';
export class CreateTrainServiceDto {
@ApiProperty({ example: '301' }) @IsString() number: string;
export class CreateTrainDto {
@ApiProperty({ example: '301', description: 'Unique train service number' }) @IsString() number: string;
@ApiProperty({ example: 'Express 301' }) @IsString() name: string;
@ApiPropertyOptional({ example: 'EDR', description: 'Operator ID (defaults to op_edr)' }) @IsOptional() @IsString() operatorId?: string;
@ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string;
@ApiPropertyOptional({ example: 'Addis-Djibouti Express' }) @IsOptional() @IsString() description?: string;
}
export class CreateCoachDto {
@ApiProperty({ example: 'trip-uuid' }) @IsString() tripId: string;
@ApiProperty({ example: 'A' }) @IsString() label: string;
@ApiProperty({ enum: ServiceClass, example: 'ECONOMY_REGULAR' }) @IsEnum(ServiceClass) serviceClass: ServiceClass;
@ApiPropertyOptional({ example: 'seat-class-uuid' }) @IsOptional() @IsString() seatClassId?: string;
@ApiPropertyOptional({ example: 60 }) @IsOptional() @IsInt() capacity?: number;
@ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() sequence?: number;
@ApiProperty({ example: 'C-A1', description: 'Unique physical coach identifier' }) @IsString() coachNumber: string;
@ApiProperty({ example: 'A', description: 'Display label shown on tickets' }) @IsString() label: string;
@ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID this coach belongs to' }) @IsString() seatClassId: string;
@ApiPropertyOptional({ example: 'sleeper', description: 'Coach type descriptor' }) @IsOptional() @IsString() coachType?: string;
@ApiPropertyOptional({ example: 'seat', description: 'seat | bed | convertible. Determines which arrangement field is used for seat generation.' }) @IsOptional() @IsString() mode?: string;
@ApiPropertyOptional({ example: '2+2', description: 'Seat arrangement for seat/convertible mode. Format: groups separated by +, e.g. "2+2" (4 cols: A/B aisle C/D) or "1+2+1". Used to derive columns, window and aisle flags. Required when mode=seat and totalUnits>0.' }) @IsOptional() @IsString() seatArrangement?: string;
@ApiPropertyOptional({ example: '2+2', description: 'Bed arrangement for bed mode. First number = tiers per berth: 2 → lower/upper, 3 → lower/middle/upper. E.g. "2+2" = 2-tier berths. Required when mode=bed and totalUnits>0.' }) @IsOptional() @IsString() bedArrangement?: string;
@ApiPropertyOptional({ example: 60, description: 'Total seat/bed units. When >0, seats are auto-generated from the arrangement on coach creation.' }) @IsOptional() @IsInt() totalUnits?: number;
}
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['tripId'] as const)) {}
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['coachNumber'] as const)) {}
export class AssignCoachDto {
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID' }) @IsString() scheduleId: string;
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string;
@ApiProperty({ example: 1, description: 'Position in the train consist (1 = first coach)' }) @IsInt() positionNumber: number;
@ApiPropertyOptional({ example: true, description: 'Whether this coach is operational for this schedule' }) @IsOptional() @IsBoolean() isOperational?: boolean;
}
export class CreateSeatBatchDto {
@ApiProperty({ example: 'coach-uuid' }) @IsString() coachId: string;
@ApiProperty({ example: 10 }) @IsInt() rows: number;
@ApiProperty({ example: ['A', 'B', 'C', 'D'], type: [String] }) @IsArray() @IsString({ each: true }) cols: string[];
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID to generate seats for' }) @IsString() coachId: string;
@ApiProperty({ example: 15, description: 'Number of rows to generate' }) @IsInt() rows: number;
@ApiProperty({ example: ['A', 'B', 'C', 'D'], type: [String], description: 'Column labels per row' }) @IsArray() @IsString({ each: true }) cols: string[];
}
export class ListCoachesDto {
@ApiPropertyOptional({ example: true, description: 'Filter by active/inactive status. Omit to return all.' })
@IsOptional() @IsBoolean() isActive?: boolean;
@ApiPropertyOptional({ example: 'seat', description: 'Filter by mode: seat | bed | convertible' })
@IsOptional() @IsString() mode?: string;
@ApiPropertyOptional({ example: 'seat-class-uuid', description: 'Filter by SeatClass UUID' })
@IsOptional() @IsString() seatClassId?: string;
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Filter to coaches assigned to this TrainSchedule UUID' })
@IsOptional() @IsString() scheduleId?: string;
}

View File

@@ -1,23 +1,215 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateTrainServiceDto, CreateCoachDto, CreateSeatBatchDto, UpdateCoachDto } from './fleet.dto';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, CreateSeatBatchDto, ListCoachesDto } from './fleet.dto';
import { SeatKind } from '@prisma/client';
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
function parseArrangement(arrangement: string): number[] {
return arrangement.split('+').map((n) => parseInt(n, 10));
}
// Derives column labels from a seat-mode arrangement string.
// '2+2' → ['A','B','C','D'] (A/D window, B/C aisle)
// '1+2+1' → ['A','B','C','D']
function seatCols(arrangement: string): string[] {
const groups = parseArrangement(arrangement);
const total = groups.reduce((s, n) => s + n, 0);
return Array.from({ length: total }, (_, i) => String.fromCharCode(65 + i)); // A, B, C …
}
// Returns true if the column index is a window seat given the arrangement groups.
function isWindowCol(colIndex: number, groups: number[]): boolean {
const total = groups.reduce((s, n) => s + n, 0);
return colIndex === 0 || colIndex === total - 1;
}
// Returns true if the column index is an aisle seat.
function isAisleCol(colIndex: number, groups: number[]): boolean {
let cursor = 0;
for (const g of groups) {
cursor += g;
const leftAisle = cursor - 1;
const rightAisle = cursor;
if (colIndex === leftAisle || colIndex === rightAisle) return true;
}
return false;
}
// Bed positions for a given tier count: 2 → lower/upper, 3 → lower/middle/upper
const BED_POSITIONS: Record<number, string[]> = {
2: ['lower', 'upper'],
3: ['lower', 'middle', 'upper'],
};
type SeatRow = {
coachId: string;
row: number;
col: string;
label: string;
seatNumber: string;
kind: SeatKind;
isWindow: boolean;
isAisle: boolean;
bedPosition?: string;
};
function buildSeatSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] {
const cols = seatCols(arrangement);
const groups = parseArrangement(arrangement);
const seats: SeatRow[] = [];
let row = 1;
while (seats.length < totalUnits) {
for (let ci = 0; ci < cols.length && seats.length < totalUnits; ci++) {
const col = cols[ci];
seats.push({
coachId, row, col,
label: `${row}${col}`,
seatNumber: `${coachLabel}${row}${col}`,
kind: SeatKind.STANDARD,
isWindow: isWindowCol(ci, groups),
isAisle: isAisleCol(ci, groups),
});
}
row++;
}
return seats;
}
function buildBedSeats(coachId: string, coachLabel: string, arrangement: string, totalUnits: number): SeatRow[] {
// arrangement for beds describes tiers per berth, e.g. '2+2' = 2 lower+upper on each side
// Each compartment number is the row; each tier is the col (L=lower, M=middle, U=upper)
const groups = parseArrangement(arrangement);
const tiersPerSide = groups[0]; // e.g. 2 → lower+upper
const positions = BED_POSITIONS[tiersPerSide] ?? ['lower', 'upper'];
const tierCols = positions.map((_, i) => String.fromCharCode(65 + i)); // A=lower, B=upper, C=middle
const seats: SeatRow[] = [];
let compartment = 1;
while (seats.length < totalUnits) {
for (let ti = 0; ti < tierCols.length && seats.length < totalUnits; ti++) {
const col = tierCols[ti];
seats.push({
coachId, row: compartment, col,
label: `${compartment}${col}`,
seatNumber: `${coachLabel}${compartment}${col}`,
kind: SeatKind.STANDARD,
isWindow: false,
isAisle: false,
bedPosition: positions[ti],
});
}
compartment++;
}
return seats;
}
@Injectable()
export class FleetService {
constructor(private prisma: PrismaService) {}
getServices() { return this.prisma.trainService.findMany({ include: { trips: { take: 5, orderBy: { departureAt: 'desc' } } } }); }
createService(dto: CreateTrainServiceDto) { return this.prisma.trainService.create({ data: dto }); }
listCoaches(tripId?: string) {
return this.prisma.coach.findMany({
where: tripId ? { tripId } : undefined,
include: { _count: { select: { seats: true } } },
orderBy: { label: 'asc' },
});
getTrains() {
return this.prisma.train.findMany({ include: { schedules: { take: 5, orderBy: { departureAt: 'desc' } } } });
}
createCoach(dto: CreateCoachDto) { return this.prisma.coach.create({ data: dto }); }
createTrain(dto: CreateTrainDto) { return this.prisma.train.create({ data: dto }); }
async getCoach(id: string) {
const coach = await this.prisma.coach.findUnique({
where: { id },
include: {
seatClass: true,
seats: {
orderBy: [{ row: 'asc' }, { col: 'asc' }],
},
assignments: {
include: { schedule: { include: { originStation: true, destinationStation: true } } },
orderBy: { schedule: { departureAt: 'desc' } },
take: 5,
},
_count: { select: { seats: true, assignments: true } },
},
});
if (!coach) throw new NotFoundException('Coach not found');
// Group seats by row to reflect the physical arrangement layout
const rowMap = new Map<number, typeof coach.seats>();
for (const seat of coach.seats) {
if (!rowMap.has(seat.row)) rowMap.set(seat.row, []);
rowMap.get(seat.row)!.push(seat);
}
const seatsByRow = Array.from(rowMap.entries()).map(([row, seats]) => ({ row, seats }));
const seatStatusSummary = {
total: coach.seats.length,
available: coach.seats.filter(s => s.status === 'AVAILABLE').length,
held: coach.seats.filter(s => s.status === 'HELD').length,
booked: coach.seats.filter(s => s.status === 'BOOKED').length,
blocked: coach.seats.filter(s => s.status === 'BLOCKED').length,
};
const { seats, ...coachData } = coach;
return { ...coachData, seatsByRow, seatStatusSummary };
}
async listCoaches(dto: ListCoachesDto) {
const where: any = {};
if (dto.isActive !== undefined) where.isActive = dto.isActive;
if (dto.mode) where.mode = dto.mode;
if (dto.seatClassId) where.seatClassId = dto.seatClassId;
if (dto.scheduleId) where.assignments = { some: { scheduleId: dto.scheduleId } };
const coaches = await this.prisma.coach.findMany({
where,
include: {
seatClass: true,
seats: { select: { status: true } },
_count: { select: { seats: true, assignments: true } },
},
orderBy: [{ isActive: 'desc' }, { label: 'asc' }],
});
return coaches.map(({ seats, ...coach }) => ({
...coach,
seatStatusSummary: {
total: seats.length,
available: seats.filter(s => s.status === 'AVAILABLE').length,
held: seats.filter(s => s.status === 'HELD').length,
booked: seats.filter(s => s.status === 'BOOKED').length,
blocked: seats.filter(s => s.status === 'BLOCKED').length,
},
}));
}
async createCoach(dto: CreateCoachDto) {
const mode = dto.mode ?? 'seat';
const totalUnits = dto.totalUnits ?? 0;
const isBed = mode === 'bed';
const arrangement = isBed
? (dto.bedArrangement ?? dto.seatArrangement ?? '2+2')
: (dto.seatArrangement ?? '2+2');
if (totalUnits > 0) {
const groups = parseArrangement(arrangement);
if (groups.some(isNaN)) {
throw new BadRequestException(`Invalid arrangement format "${arrangement}". Use e.g. "2+2" or "2+2+2"`);
}
}
const coach = await this.prisma.coach.create({ data: dto });
if (totalUnits > 0) {
const seats = isBed
? buildBedSeats(coach.id, coach.label, arrangement, totalUnits)
: buildSeatSeats(coach.id, coach.label, arrangement, totalUnits);
await this.prisma.seat.createMany({ data: seats, skipDuplicates: true });
}
return this.prisma.coach.findUnique({
where: { id: coach.id },
include: { seatClass: true, _count: { select: { seats: true } } },
});
}
async updateCoach(id: string, dto: UpdateCoachDto) {
const coach = await this.prisma.coach.findUnique({ where: { id } });
@@ -25,38 +217,42 @@ export class FleetService {
return this.prisma.coach.update({ where: { id }, data: dto });
}
async assignCoach(dto: AssignCoachDto) {
const [schedule, coach] = await Promise.all([
this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId } }),
this.prisma.coach.findUnique({ where: { id: dto.coachId } }),
]);
if (!schedule) throw new NotFoundException('Schedule not found');
if (!coach) throw new NotFoundException('Coach not found');
return this.prisma.coachAssignment.create({ data: dto });
}
async removeAssignment(id: string) {
const assignment = await this.prisma.coachAssignment.findUnique({ where: { id } });
if (!assignment) throw new NotFoundException('Assignment not found');
return this.prisma.coachAssignment.delete({ where: { id } });
}
async createSeatBatch(dto: CreateSeatBatchDto) {
try {
const coach = await this.prisma.coach.findUnique({ where: { id: dto.coachId } });
if (!coach) throw new NotFoundException('Coach not found');
const seats = [];
for (let row = 1; row <= dto.rows; row++) {
for (const col of dto.cols) {
const seatNumber = `${coach.label}${row}${col}`;
seats.push({
coachId: dto.coachId,
row,
col,
label: `${row}${col}`,
seatNumber
});
}
const coach = await this.prisma.coach.findUnique({ where: { id: dto.coachId } });
if (!coach) throw new NotFoundException('Coach not found');
const seats = [];
for (let row = 1; row <= dto.rows; row++) {
for (const col of dto.cols) {
seats.push({ coachId: dto.coachId, row, col, label: `${row}${col}`, seatNumber: `${coach.label}${row}${col}` });
}
await this.prisma.seat.createMany({ data: seats, skipDuplicates: true });
return { created: seats.length };
} catch (error) {
console.error('Error in createSeatBatch:', error);
throw error;
}
await this.prisma.seat.createMany({ data: seats, skipDuplicates: true });
return { created: seats.length };
}
async getAnalytics() {
const [totalServices, totalTrips, totalSeats, bookedSeats] = await Promise.all([
this.prisma.trainService.count(), this.prisma.trip.count(),
this.prisma.seat.count(), this.prisma.seat.count({ where: { status: 'BOOKED' } }),
const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([
this.prisma.train.count(),
this.prisma.trainSchedule.count(),
this.prisma.seat.count(),
this.prisma.seat.count({ where: { status: 'BOOKED' } }),
]);
return { totalServices, totalTrips, totalSeats, bookedSeats, occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0 };
return { totalTrains, totalSchedules, totalSeats, bookedSeats, occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0 };
}
}

View File

@@ -8,9 +8,9 @@ import { JwtGuard } from '../../common/jwt.guard';
@Controller('live')
export class LiveController {
constructor(private service: LiveService) {}
@Get('trips/:tripId') @ApiOperation({ summary: 'Get live status for a trip' }) getTripLiveStatus(@Param('tripId') id: string) { return this.service.getTripLiveStatus(id); }
@Get('trips/:tripId/stops') @ApiOperation({ summary: 'Get stop timeline for a trip' }) getStopTimeline(@Param('tripId') id: string) { return this.service.getStopTimeline(id); }
@Patch('trips/:tripId/status')@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update live trip status (staff/system)' }) updateLiveStatus(@Param('tripId') id: string, @Body() dto: UpdateLiveStatusDto) { return this.service.updateLiveStatus(id, dto); }
@Get('schedules/:scheduleId') @ApiOperation({ summary: 'Get live status for a schedule' }) getTripLiveStatus(@Param('scheduleId') id: string) { return this.service.getTripLiveStatus(id); }
@Get('schedules/:scheduleId/stops') @ApiOperation({ summary: 'Get stop timeline for a schedule' }) getStopTimeline(@Param('scheduleId') id: string) { return this.service.getStopTimeline(id); }
@Patch('schedules/:scheduleId/status') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update live schedule status (staff/system)' }) updateLiveStatus(@Param('scheduleId') id: string, @Body() dto: UpdateLiveStatusDto) { return this.service.updateLiveStatus(id, dto); }
@Get('crowd-signals') @ApiOperation({ summary: 'Get station crowd signals' }) getCrowdSignals() { return this.service.getStationCrowdSignals(); }
@Get('weather-alerts') @ApiOperation({ summary: 'Get active weather alerts' }) getWeatherAlerts() { return this.service.getWeatherAlerts(); }
}

View File

@@ -5,29 +5,31 @@ import { PrismaService } from '../../common/prisma.service';
export class LiveService {
constructor(private prisma: PrismaService) {}
async getTripLiveStatus(tripId: string) {
const trip = await this.prisma.trip.findUnique({
where: { id: tripId },
include: { service: true, originStation: true, destinationStation: true, liveStatus: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
async getTripLiveStatus(scheduleId: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
include: { train: true, originStation: true, destinationStation: true, liveStatus: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
});
if (!trip) throw new NotFoundException('Trip not found');
const live = trip.liveStatus;
const nextStop = trip.stopTimes.find((s) => s.status === 'UPCOMING' || s.status === 'APPROACHING');
if (!schedule) throw new NotFoundException('Schedule not found');
const live = schedule.liveStatus;
const nextStop = schedule.stopTimes.find((s) => s.status === 'UPCOMING' || s.status === 'APPROACHING');
return {
tripId: trip.id, trainName: trip.service.name,
fromStationName: trip.originStation.name, toStationName: trip.destinationStation.name,
state: live?.state ?? trip.status, currentLocationLabel: live?.currentLocationLabel,
scheduleId: schedule.id, trainName: schedule.train.name,
fromStationName: schedule.originStation.name, toStationName: schedule.destinationStation.name,
state: live?.state ?? schedule.status, currentLocationLabel: live?.currentLocationLabel,
progressPercent: live?.progressPercent ?? 0, delayMinutes: live?.delayMinutes ?? 0,
currentSpeedKph: live?.currentSpeedKph, platformLabel: live?.platformLabel,
nextStopStationName: nextStop?.station.name, updatedAt: live?.updatedAt ?? trip.departureAt,
nextStopStationName: nextStop?.station.name, updatedAt: live?.updatedAt ?? schedule.departureAt,
};
}
updateLiveStatus(tripId: string, data: any) {
return this.prisma.tripLiveStatus.upsert({ where: { tripId }, update: data, create: { tripId, state: data.state ?? 'SCHEDULED', ...data } });
updateLiveStatus(scheduleId: string, data: any) {
return this.prisma.tripLiveStatus.upsert({ where: { scheduleId }, update: data, create: { scheduleId, state: data.state ?? 'SCHEDULED', ...data } });
}
getStopTimeline(tripId: string) { return this.prisma.tripStopTime.findMany({ where: { tripId }, include: { station: true }, orderBy: { sequence: 'asc' } }); }
getStopTimeline(scheduleId: string) {
return this.prisma.tripStopTime.findMany({ where: { scheduleId }, include: { station: true }, orderBy: { sequence: 'asc' } });
}
getStationCrowdSignals() { return this.prisma.stationCrowdSignal.findMany({ include: { station: true } }); }

View File

@@ -11,7 +11,7 @@ export class PassengersService {
where: { id: passengerId },
include: {
user: { select: { fullName: true, email: true, phone: true } },
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } } } },
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { seatClass: true } } } } } } } },
loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true,
},
});
@@ -25,12 +25,12 @@ export class PassengersService {
bookings: p.bookings.map((b) => ({
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,
trip: {
number: b.trip.service.number,
origin: { id: b.trip.originStation.id, name: b.trip.originStation.name, code: b.trip.originStation.code, city: b.trip.originStation.city },
destination: { id: b.trip.destinationStation.id, name: b.trip.destinationStation.name, code: b.trip.destinationStation.code, city: b.trip.destinationStation.city },
departureAt: b.trip.departureAt,
number: b.schedule.train.number,
origin: { id: b.schedule.originStation.id, name: b.schedule.originStation.name, code: b.schedule.originStation.code, city: b.schedule.originStation.city },
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.serviceClass } })),
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' } })),
})),
};
}

View File

@@ -21,110 +21,44 @@ describe('Payments E2E', () => {
prisma = app.get<PrismaService>(PrismaService);
// Create test user and authenticate
const testUser = await prisma.user.create({
data: {
email: 'payment-test@example.com',
phone: '+251911111111',
fullName: 'Payment Test User',
passwordHash: '$2b$10$abcdefghijklmnopqrstuvwxyz', // Mock hash
role: 'PASSENGER',
},
data: { email: 'payment-test@example.com', phone: '+251911111112', fullName: 'Payment Test User', passwordHash: '$2b$10$abcdefghijklmnopqrstuvwxyz', role: 'PASSENGER' },
});
const passenger = await prisma.passenger.create({
data: {
userId: testUser.id,
},
});
const passenger = await prisma.passenger.create({ data: { userId: testUser.id } });
// Create wallet for test user
await prisma.walletAccount.create({
data: {
passengerId: passenger.id,
balanceMinor: 100000, // 1000 ETB
currency: 'ETB',
},
});
await prisma.walletAccount.create({ data: { passengerId: passenger.id, balanceMinor: 100000, currency: 'ETB' } });
// Mock JWT token (in real test, call /auth/login)
authToken = 'mock-jwt-token';
// Create test booking
const station1 = await prisma.station.create({
data: {
code: 'TEST1',
name: 'Test Station 1',
city: 'Test City',
lat: 9.0,
lng: 38.0,
},
const station1 = await prisma.station.create({ data: { code: 'TST1', name: 'Test Station 1', city: 'Test City', lat: 9.0, lng: 38.0 } });
const station2 = await prisma.station.create({ data: { code: 'TST2', name: 'Test Station 2', city: 'Test City 2', lat: 9.5, lng: 38.5 } });
const train = await prisma.train.create({ data: { number: 'TEST-001', name: 'Test Train' } });
const schedule = await prisma.trainSchedule.create({
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 station2 = await prisma.station.create({
data: {
code: 'TEST2',
name: 'Test Station 2',
city: 'Test City 2',
lat: 9.5,
lng: 38.5,
},
});
const service = await prisma.trainService.create({
data: {
number: 'TEST-001',
name: 'Test Service',
},
});
const trip = await prisma.trip.create({
data: {
serviceId: service.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 coach = await prisma.coach.create({
data: {
tripId: trip.id,
label: 'A',
serviceClass: 'ECONOMY_REGULAR',
},
data: { coachNumber: 'TEST-C1', label: 'A', seatClassId: seatClass.id, mode: 'seat', totalUnits: 10 },
});
const seat = await prisma.seat.create({
data: {
coachId: coach.id,
row: 1,
col: 'A',
label: '1A',
status: 'AVAILABLE',
},
});
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 booking = await prisma.booking.create({
data: {
bookingRef: 'TEST-BOOK-001',
passengerId: passenger.id,
tripId: trip.id,
status: 'PENDING_PAYMENT',
totalMinor: 50000, // 500 ETB
currency: 'ETB',
},
data: { bookingRef: 'TEST-BOOK-001', passengerId: passenger.id, scheduleId: schedule.id, status: 'PENDING_PAYMENT', totalMinor: 50000, currency: 'ETB' },
});
await prisma.bookingSeat.create({
data: {
bookingId: booking.id,
seatId: seat.id,
passengerName: 'Test Passenger',
},
});
await prisma.bookingSeat.create({ data: { bookingId: booking.id, seatId: seat.id, passengerName: 'Test Passenger' } });
bookingId = booking.id;
});
@@ -134,15 +68,16 @@ describe('Payments E2E', () => {
prisma.bookingSeat.deleteMany(),
prisma.paymentIntent.deleteMany(),
prisma.booking.deleteMany(),
prisma.coachAssignment.deleteMany(),
prisma.seat.deleteMany(),
prisma.coach.deleteMany(),
prisma.trip.deleteMany(),
prisma.trainService.deleteMany(),
prisma.station.deleteMany(),
prisma.trainSchedule.deleteMany(),
prisma.train.deleteMany(),
prisma.station.deleteMany({ where: { code: { in: ['TST1', 'TST2'] } } }),
prisma.walletLedgerEntry.deleteMany(),
prisma.walletAccount.deleteMany(),
prisma.passenger.deleteMany(),
prisma.user.deleteMany(),
prisma.user.deleteMany({ where: { email: 'payment-test@example.com' } }),
]);
await app.close();
});
@@ -152,12 +87,8 @@ describe('Payments E2E', () => {
const response = await request(app.getHttpServer())
.post('/payments/initiate')
.set('Authorization', `Bearer ${authToken}`)
.send({
bookingId,
method: 'WALLET',
})
.send({ bookingId, method: 'WALLET' })
.expect(201);
expect(response.body.intentId).toBeDefined();
expect(response.body.status).toBe('SUCCEEDED');
});
@@ -166,10 +97,7 @@ describe('Payments E2E', () => {
await request(app.getHttpServer())
.post('/payments/initiate')
.set('Authorization', `Bearer ${authToken}`)
.send({
bookingId,
method: 'INVALID_METHOD',
})
.send({ bookingId, method: 'INVALID_METHOD' })
.expect(400);
});
@@ -177,10 +105,7 @@ describe('Payments E2E', () => {
await request(app.getHttpServer())
.post('/payments/initiate')
.set('Authorization', `Bearer ${authToken}`)
.send({
bookingId: 'non-existent-id',
method: 'WALLET',
})
.send({ bookingId: 'non-existent-id', method: 'WALLET' })
.expect(404);
});
});
@@ -191,7 +116,6 @@ describe('Payments E2E', () => {
.get(`/payments/intents/${bookingId}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.intentId).toBeDefined();
expect(response.body.status).toBeDefined();
});
@@ -208,38 +132,21 @@ describe('Payments E2E', () => {
it('should handle Telebirr webhook', async () => {
await request(app.getHttpServer())
.post('/payments/webhooks/telebirr')
.send({
merch_order_id: 'TEST-ORDER-123',
payment_order_id: 'PAY-123',
trade_status: 'Completed',
sign: 'mock-signature',
})
.send({ merch_order_id: 'TEST-ORDER-123', payment_order_id: 'PAY-123', trade_status: 'Completed', sign: 'mock-signature' })
.expect(200);
});
it('should handle CBE Birr webhook', async () => {
await request(app.getHttpServer())
.post('/payments/webhooks/cbe-birr')
.send({
merchantId: 'TEST-MERCHANT',
merchantOrderId: 'TEST-ORDER-123',
orderId: 'CBE-ORDER-123',
status: 'SUCCESS',
signature: 'mock-signature',
})
.send({ merchantId: 'TEST-MERCHANT', merchantOrderId: 'TEST-ORDER-123', orderId: 'CBE-ORDER-123', status: 'SUCCESS', signature: 'mock-signature' })
.expect(200);
});
it('should handle eBirr webhook', async () => {
await request(app.getHttpServer())
.post('/payments/webhooks/ebirr')
.send({
merchantCode: 'TEST-MERCHANT',
orderNo: 'TEST-ORDER-123',
tradeStatus: 'TRADE_SUCCESS',
timestamp: Date.now(),
sign: 'mock-signature',
})
.send({ merchantCode: 'TEST-MERCHANT', orderNo: 'TEST-ORDER-123', tradeStatus: 'TRADE_SUCCESS', timestamp: Date.now(), sign: 'mock-signature' })
.expect(200);
});
@@ -247,23 +154,7 @@ describe('Payments E2E', () => {
await request(app.getHttpServer())
.post('/payments/webhooks/card')
.set('stripe-signature', 'mock-signature')
.send({
id: 'evt_123',
type: 'payment_intent.succeeded',
data: {
object: {
id: 'pi_123',
status: 'succeeded',
amount: 50000,
currency: 'ETB',
metadata: {
merchantOrderId: 'TEST-ORDER-123',
bookingRef: 'TEST-BOOK-001',
},
},
},
created: Math.floor(Date.now() / 1000),
})
.send({ id: 'evt_123', type: 'payment_intent.succeeded', data: { object: { id: 'pi_123', status: 'succeeded', amount: 50000, currency: 'ETB', metadata: { merchantOrderId: 'TEST-ORDER-123', bookingRef: 'TEST-BOOK-001' } } }, created: Math.floor(Date.now() / 1000) })
.expect(200);
});
});

View File

@@ -18,7 +18,7 @@ describe('PaymentsService', () => {
let ticketsService: TicketsService;
let eventEmitter: EventEmitter2;
const mockPrisma = {
const mockPrisma: Record<string, any> = {
booking: {
findUnique: jest.fn(),
update: jest.fn(),
@@ -44,7 +44,7 @@ describe('PaymentsService', () => {
loyaltyLedgerEntry: {
create: jest.fn(),
},
$transaction: jest.fn((callback) => callback(mockPrisma)),
$transaction: jest.fn((callback: (tx: any) => any) => callback(mockPrisma)),
};
const mockSeatsService = {

View File

@@ -69,37 +69,23 @@ export class ReportsService {
}
private async generateOccupancyReport(dateFrom: Date, dateTo: Date) {
const trips = await this.prisma.trip.findMany({
const schedules = await this.prisma.trainSchedule.findMany({
where: { departureAt: { gte: dateFrom, lte: dateTo } },
include: {
coaches: { include: { seats: true } },
bookings: { where: { status: { in: ['CONFIRMED', 'COMPLETED'] } }, include: { seats: true } }
}
coachAssignments: { include: { coach: { include: { seats: true } } } },
bookings: { where: { status: { in: ['CONFIRMED', 'COMPLETED'] } }, include: { seats: true } },
},
});
const tripData = trips.map(trip => {
const totalSeats = trip.coaches.reduce((sum, c) => sum + c.seats.length, 0);
const bookedSeats = trip.bookings.reduce((sum, b) => sum + b.seats.length, 0);
const tripData = schedules.map(schedule => {
const totalSeats = schedule.coachAssignments.reduce((sum, a) => sum + a.coach.seats.length, 0);
const bookedSeats = schedule.bookings.reduce((sum, b) => sum + b.seats.length, 0);
const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
return {
tripId: trip.id,
departureAt: trip.departureAt,
totalSeats,
bookedSeats,
occupancyRate: +occupancyRate.toFixed(2)
};
return { scheduleId: schedule.id, departureAt: schedule.departureAt, totalSeats, bookedSeats, occupancyRate: +occupancyRate.toFixed(2) };
});
const avgOccupancy = tripData.length > 0
? tripData.reduce((sum, t) => sum + t.occupancyRate, 0) / tripData.length
: 0;
return {
totalTrips: trips.length,
averageOccupancyRate: +avgOccupancy.toFixed(2),
trips: tripData
};
const avgOccupancy = tripData.length > 0 ? tripData.reduce((sum, t) => sum + t.occupancyRate, 0) / tripData.length : 0;
return { totalSchedules: schedules.length, averageOccupancyRate: +avgOccupancy.toFixed(2), schedules: tripData };
}
private async generateAgentSalesReport(dateFrom: Date, dateTo: Date, agentId?: string) {

View File

@@ -0,0 +1,88 @@
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 { RoutesService } from './routes.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Routes')
@Controller('routes')
export class RoutesController {
constructor(private service: RoutesService) {}
// ── Routes ─────────────────────────────────────────────────────────────────
@Post()
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Create a reusable route with its ordered stops',
description: `Define the physical corridor once (e.g. ADD→ADM→AWS→DDW→AYS→DJI).
Schedules reference this route via routeId and supply actual planned times per stop.
Route stops carry distanceKm for fare-by-distance calculations.`,
})
@ApiResponse({ status: 201, description: 'Route created with stops' })
@ApiResponse({ status: 409, description: 'Route code already exists or duplicate sequences' })
@ApiResponse({ status: 400, description: 'Fewer than 2 stops or invalid station IDs' })
createRoute(@Body() dto: CreateRouteDto) { return this.service.createRoute(dto); }
@Get()
@ApiOperation({ summary: 'List all routes' })
@ApiQuery({ name: 'activeOnly', required: false, type: Boolean, description: 'Filter to active routes only' })
@ApiResponse({ status: 200, description: 'Array of routes with stop count' })
listRoutes(@Query('activeOnly') activeOnly?: string) {
return this.service.listRoutes(activeOnly === 'true');
}
@Get(':id')
@ApiOperation({ summary: 'Get route with all stops and station details' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Route with enriched stop list (station name, code, city)' })
@ApiResponse({ status: 404, description: 'Route not found' })
getRoute(@Param('id') id: string) { return this.service.getRoute(id); }
@Patch(':id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveUntil)' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Route updated' })
@ApiResponse({ status: 404, description: 'Route not found' })
updateRoute(@Param('id') id: string, @Body() dto: UpdateRouteDto) { return this.service.updateRoute(id, dto); }
// ── Route Stops ────────────────────────────────────────────────────────────
@Get(':id/stops')
@ApiOperation({ summary: 'List all stops for a route ordered by sequence' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Ordered stop list with station details' })
@ApiResponse({ status: 404, description: 'Route not found' })
getStops(@Param('id') id: string) { return this.service.getStops(id); }
@Post(':id/stops')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Add a stop to an existing route' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 201, description: 'Stop added' })
@ApiResponse({ status: 409, description: 'Sequence already exists on this route' })
@ApiResponse({ status: 404, description: 'Route or station not found' })
addStop(@Param('id') id: string, @Body() dto: AddRouteStopDto) { return this.service.addStop(id, dto); }
@Delete(':id/stops/:sequence')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Remove a stop from a route by sequence number' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiParam({ name: 'sequence', description: 'Stop sequence number to remove' })
@ApiResponse({ status: 200, description: 'Stop removed' })
@ApiResponse({ status: 400, description: 'Cannot remove — route would have fewer than 2 stops' })
@ApiResponse({ status: 404, description: 'Stop not found' })
removeStop(@Param('id') id: string, @Param('sequence', ParseIntPipe) sequence: number) {
return this.service.removeStop(id, sequence);
}
// ── Schedules for a Route ──────────────────────────────────────────────────
@Get(':id/schedules')
@ApiOperation({ summary: 'List all train schedules that use this route' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Schedules with train and terminal station details' })
@ApiResponse({ status: 404, description: 'Route not found' })
getSchedules(@Param('id') id: string) { return this.service.getSchedulesForRoute(id); }
}

View File

@@ -0,0 +1,44 @@
import { IsString, IsInt, IsOptional, IsArray, ValidateNested, IsBoolean, IsDateString, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class RouteStopInputDto {
@ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string;
@ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 120, description: 'Distance in km from previous stop' }) @IsOptional() @IsInt() distanceKm?: number;
}
export class CreateRouteDto {
@ApiProperty({ example: 'ADD-DJI', description: 'Unique route code' }) @IsString() code: string;
@ApiProperty({ example: 'Addis Ababa Djibouti' }) @IsString() name: string;
@ApiPropertyOptional({ example: 'Main corridor via Dire Dawa' }) @IsOptional() @IsString() description?: string;
@ApiProperty({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsDateString() effectiveFrom: string;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
@ApiProperty({
type: [RouteStopInputDto],
description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.',
example: [
{ stationId: 'uuid-ADD', sequence: 1 },
{ stationId: 'uuid-ADM', sequence: 2, distanceKm: 99 },
{ stationId: 'uuid-AWS', sequence: 3, distanceKm: 120 },
{ stationId: 'uuid-DDW', sequence: 4, distanceKm: 180 },
{ stationId: 'uuid-AYS', sequence: 5, distanceKm: 95 },
{ stationId: 'uuid-DJI', sequence: 6, distanceKm: 60 },
],
})
@IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto)
stops: RouteStopInputDto[];
}
export class AddRouteStopDto {
@ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string;
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 75 }) @IsOptional() @IsInt() distanceKm?: number;
}
export class UpdateRouteDto {
@ApiPropertyOptional({ example: 'Addis Ababa Djibouti Express' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
}

View File

@@ -0,0 +1,197 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
@Injectable()
export class RoutesService {
constructor(private prisma: PrismaService) {}
// ── Route CRUD ─────────────────────────────────────────────────────────────
async createRoute(dto: CreateRouteDto) {
const existing = await this.prisma.route.findUnique({ where: { code: dto.code } });
if (existing) throw new ConflictException(`Route code "${dto.code}" already exists`);
if (dto.stops.length < 2) throw new BadRequestException('A route must have at least 2 stops');
const seqs = dto.stops.map(s => s.sequence);
if (new Set(seqs).size !== seqs.length) throw new ConflictException('Duplicate sequence numbers in stop list');
const stationIds = [...new Set(dto.stops.map(s => s.stationId))];
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
if (stations.length !== stationIds.length) throw new BadRequestException('One or more station IDs not found');
return this.prisma.route.create({
data: {
code: dto.code,
name: dto.name,
description: dto.description,
effectiveFrom: new Date(dto.effectiveFrom),
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null,
stops: {
create: dto.stops.map(s => ({
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm,
})),
},
},
include: { stops: { include: { route: false }, orderBy: { sequence: 'asc' } } },
});
}
async listRoutes(activeOnly = false) {
return this.prisma.route.findMany({
where: activeOnly ? { active: true } : undefined,
include: {
stops: { orderBy: { sequence: 'asc' } },
_count: { select: { stops: true } },
},
orderBy: { code: 'asc' },
});
}
async getRoute(id: string) {
const route = await this.prisma.route.findUnique({
where: { id },
include: {
stops: {
orderBy: { sequence: 'asc' },
include: {
route: false,
},
},
},
});
if (!route) throw new NotFoundException('Route not found');
// Enrich stops with station details
const stationIds = route.stops.map(s => s.stationId);
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
const stationMap = Object.fromEntries(stations.map(s => [s.id, s]));
return {
...route,
stops: route.stops.map(s => ({ ...s, station: stationMap[s.stationId] })),
};
}
async updateRoute(id: string, dto: UpdateRouteDto) {
const route = await this.prisma.route.findUnique({ where: { id } });
if (!route) throw new NotFoundException('Route not found');
return this.prisma.route.update({
where: { id },
data: {
name: dto.name,
description: dto.description,
active: dto.active,
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
},
include: { stops: { orderBy: { sequence: 'asc' } } },
});
}
// ── Route Stops ────────────────────────────────────────────────────────────
async addStop(routeId: string, dto: AddRouteStopDto) {
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
if (!route) throw new NotFoundException('Route not found');
const station = await this.prisma.station.findUnique({ where: { id: dto.stationId } });
if (!station) throw new NotFoundException(`Station ${dto.stationId} not found`);
const existing = await this.prisma.routeStop.findUnique({
where: { routeId_sequence: { routeId, sequence: dto.sequence } },
});
if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`);
return this.prisma.routeStop.create({
data: { routeId, stationId: dto.stationId, sequence: dto.sequence, distanceKm: dto.distanceKm },
});
}
async removeStop(routeId: string, sequence: number) {
const stop = await this.prisma.routeStop.findUnique({
where: { routeId_sequence: { routeId, sequence } },
});
if (!stop) throw new NotFoundException(`Stop at sequence ${sequence} not found on route`);
const total = await this.prisma.routeStop.count({ where: { routeId } });
if (total <= 2) throw new BadRequestException('A route must retain at least 2 stops');
await this.prisma.routeStop.delete({ where: { routeId_sequence: { routeId, sequence } } });
return { deleted: true, sequence };
}
async getStops(routeId: string) {
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
if (!route) throw new NotFoundException('Route not found');
const stops = await this.prisma.routeStop.findMany({
where: { routeId },
orderBy: { sequence: 'asc' },
});
const stationIds = stops.map(s => s.stationId);
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
const stationMap = Object.fromEntries(stations.map(s => [s.id, s]));
return stops.map(s => ({ ...s, station: stationMap[s.stationId] }));
}
async getSchedulesForRoute(routeId: string) {
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
if (!route) throw new NotFoundException('Route not found');
return this.prisma.trainSchedule.findMany({
where: { routeId },
include: { train: true, originStation: true, destinationStation: true },
orderBy: { departureAt: 'asc' },
});
}
// ── Used by SchedulesService ───────────────────────────────────────────────
/**
* Copies RouteStop definitions into TripStopTime rows for a schedule.
* plannedTimes maps sequence → { arrivalAt?, departureAt? } for actual timing.
*/
async applyRouteToSchedule(
routeId: string,
scheduleId: string,
plannedTimes: Record<number, { plannedArrivalAt?: string; plannedDepartureAt?: string }>,
) {
const stops = await this.prisma.routeStop.findMany({
where: { routeId },
orderBy: { sequence: 'asc' },
});
if (stops.length === 0) throw new BadRequestException('Route has no stops defined');
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId } });
await this.prisma.tripStopTime.createMany({
data: stops.map(s => {
const times = plannedTimes[s.sequence] ?? {};
return {
scheduleId,
stationId: s.stationId,
sequence: s.sequence,
plannedArrivalAt: times.plannedArrivalAt ? new Date(times.plannedArrivalAt) : null,
plannedDepartureAt: times.plannedDepartureAt ? new Date(times.plannedDepartureAt) : null,
};
}),
});
const intermediateCount = Math.max(0, stops.length - 2);
await this.prisma.trainSchedule.update({
where: { id: scheduleId },
data: { stopsCount: intermediateCount },
});
return this.prisma.tripStopTime.findMany({
where: { scheduleId },
include: { station: true },
orderBy: { sequence: 'asc' },
});
}
}

View File

@@ -1,21 +1,100 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Body, Controller, 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 { CreateTripDto, CreateFareRuleDto, UpdateTripStatusDto } from './schedules.dto';
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { TripStatus } from '@prisma/client';
@ApiTags('Schedule')
@Controller('schedule')
@Controller('schedules')
export class SchedulesController {
constructor(private service: SchedulesService) {}
@Post('trips') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create trip' })
createTrip(@Body() dto: CreateTripDto) { return this.service.createTrip(dto); }
@Get('trips/:id') @ApiOperation({ summary: 'Get trip details' })
getTrip(@Param('id') id: string) { return this.service.getTrip(id); }
@Patch('trips/:id/status') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update trip status' })
updateStatus(@Param('id') id: string, @Body() dto: UpdateTripStatusDto) { return this.service.updateTripStatus(id, dto); }
@Post('fares') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create fare rule' })
@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.`,
})
@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' })
@ApiResponse({ status: 404, description: 'Train or route not found' })
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
@Get()
@ApiOperation({ summary: 'List schedules with optional filters' })
@ApiQuery({ name: 'date', required: false, example: '2026-06-15', description: 'Departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' })
@ApiQuery({ name: 'routeId', required: false, description: 'Filter by route UUID' })
@ApiQuery({ name: 'trainId', required: false, description: 'Filter by train UUID' })
@ApiQuery({ name: 'status', required: false, enum: TripStatus, description: 'Filter by schedule status' })
@ApiResponse({ status: 200, description: 'Array of schedules ordered by departureAt, each with train, origin/destination, stops, and booking/assignment counts' })
listSchedules(
@Query('date') date?: string,
@Query('routeId') routeId?: string,
@Query('trainId') trainId?: string,
@Query('status') status?: TripStatus,
) {
return this.service.listSchedules({ date, routeId, trainId, status });
}
// Static routes before parameterised ones
@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); }
@Get('fares/:tripId') @ApiOperation({ summary: 'Get fare for trip and class' })
getFare(@Param('tripId') tripId: string, @Query('class') cls: string) { return this.service.getFare(tripId, cls ?? 'ECONOMY'); }
@Get(':id')
@ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiResponse({ status: 200, description: 'Full schedule detail including route stops with station info' })
@ApiResponse({ status: 404, description: 'Schedule not found' })
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
@Patch(':id/status')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update schedule status (SCHEDULED → BOARDING → EN_ROUTE → ARRIVED)' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiResponse({ status: 200, description: 'Status updated' })
@ApiResponse({ status: 404, description: 'Schedule not found' })
updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) {
return this.service.updateScheduleStatus(id, dto);
}
// ── Stop Times ─────────────────────────────────────────────────────────────
@Get(':id/stops')
@ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiResponse({ status: 200, description: 'Ordered stop list with station details and planned/actual times' })
@ApiResponse({ status: 404, description: 'Schedule not found' })
getStops(@Param('id') id: string) { return this.service.getStops(id); }
@Patch(':id/stops/:sequence')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update planned times or live status of a specific stop' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiParam({ name: 'sequence', description: 'Stop sequence number' })
@ApiResponse({ status: 200, description: 'Stop updated' })
@ApiResponse({ status: 404, description: 'Stop not found on schedule' })
updateStop(
@Param('id') id: string,
@Param('sequence', ParseIntPipe) sequence: number,
@Body() dto: UpdateStopTimeDto,
) { return this.service.updateStop(id, sequence, dto); }
// ── Fares ──────────────────────────────────────────────────────────────────
@Get(':scheduleId/fares')
@ApiOperation({ summary: 'Get applicable fare for a schedule and seat class' })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@ApiQuery({ name: 'class', required: false, description: 'Seat class name: "Economy Regular" | "Economy Bed" | "VIP Bed". Defaults to Economy Regular.' })
@ApiResponse({ status: 200, description: 'Fare rule or default fare' })
@ApiResponse({ status: 404, description: 'Schedule not found' })
getFare(@Param('scheduleId') scheduleId: string, @Query('class') cls: string) {
return this.service.getFare(scheduleId, cls ?? 'Economy Regular');
}
}

View File

@@ -1,25 +1,68 @@
import { IsString, IsDateString, IsInt, IsOptional, IsEnum } from 'class-validator';
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ServiceClass } from '@prisma/client';
import { Type } from 'class-transformer';
import { TripStatus, StopStatus } from '@prisma/client';
export class CreateTripDto {
@ApiProperty() @IsString() serviceId: string;
@ApiProperty() @IsString() originStationId: string;
@ApiProperty() @IsString() destinationStationId: string;
@ApiProperty({ example: '2026-05-11T08:30:00Z' }) @IsDateString() departureAt: string;
@ApiProperty({ example: '2026-05-11T20:00:00Z' }) @IsDateString() arrivalAt: string;
@ApiPropertyOptional() @IsOptional() @IsInt() stopsCount?: number;
export class PlannedStopTimeDto {
@ApiProperty({ example: 1, description: 'Route stop sequence number this timing applies to' }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z', description: 'Planned arrival at this stop (omit for first stop)' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z', description: 'Planned departure from this stop (omit for last stop)' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
}
export class CreateScheduleDto {
@ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string;
@ApiProperty({ example: 'route-uuid', description: 'Route UUID — stops are copied from the route template. Origin and destination are derived from the first and last route stop.' })
@IsString() routeId: string;
@ApiProperty({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsDateString() departureAt: string;
@ApiProperty({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsDateString() arrivalAt: string;
@ApiProperty({
type: [PlannedStopTimeDto],
description: 'Planned arrival/departure times per stop sequence. Must cover all stops defined on the route.',
example: [
{ sequence: 1, plannedDepartureAt: '2026-06-15T08:00:00Z' },
{ sequence: 2, plannedArrivalAt: '2026-06-15T09:30:00Z', plannedDepartureAt: '2026-06-15T09:45:00Z' },
{ sequence: 3, plannedArrivalAt: '2026-06-15T11:30:00Z', plannedDepartureAt: '2026-06-15T11:45:00Z' },
{ sequence: 4, plannedArrivalAt: '2026-06-15T15:00:00Z', plannedDepartureAt: '2026-06-15T15:20:00Z' },
{ sequence: 5, plannedArrivalAt: '2026-06-15T18:00:00Z', plannedDepartureAt: '2026-06-15T18:10:00Z' },
{ sequence: 6, plannedArrivalAt: '2026-06-15T20:00:00Z' },
],
})
@IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
plannedTimes: PlannedStopTimeDto[];
}
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;
@ApiPropertyOptional({ enum: StopStatus, example: StopStatus.UPCOMING }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus;
}
export class CreateFareRuleDto {
@ApiPropertyOptional() @IsOptional() @IsString() tripId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() route?: string;
@ApiProperty({ enum: ServiceClass, example: 'ECONOMY_REGULAR' }) @IsEnum(ServiceClass) serviceClass: ServiceClass;
@ApiProperty({ example: 45000 }) @IsInt() baseFareMinor: number;
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Scope fare rule to a specific schedule' }) @IsOptional() @IsString() scheduleId?: string;
@ApiPropertyOptional({ example: 'ADD-DJI', description: 'Scope fare rule to a route code (e.g. ADD-DJI)' }) @IsOptional() @IsString() route?: string;
@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;
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
@ApiPropertyOptional() @IsOptional() @IsDateString() validUntil?: string;
@ApiPropertyOptional({ example: '2026-12-31T23:59:59Z' }) @IsOptional() @IsDateString() validUntil?: string;
}
export class UpdateTripStatusDto {
@ApiProperty({ example: 'EN_ROUTE' }) @IsString() status: 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;
@ApiPropertyOptional({ example: 'route-uuid', description: 'Filter by route UUID' })
@IsOptional() @IsString() routeId?: string;
@ApiPropertyOptional({ example: 'train-uuid', description: 'Filter by train UUID' })
@IsOptional() @IsString() trainId?: string;
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED, description: 'Filter by schedule status' })
@IsOptional() @IsEnum(TripStatus) status?: TripStatus;
}
export class UpdateScheduleStatusDto {
@ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus;
}

View File

@@ -1,6 +1,12 @@
import { Module } from '@nestjs/common';
import { SchedulesController } from './schedules.controller';
import { SchedulesService } from './schedules.service';
import { RoutesController } from './routes.controller';
import { RoutesService } from './routes.service';
@Module({ controllers: [SchedulesController], providers: [SchedulesService] })
@Module({
controllers: [RoutesController, SchedulesController],
providers: [RoutesService, SchedulesService],
exports: [RoutesService, SchedulesService],
})
export class SchedulesModule {}

View File

@@ -1,39 +1,174 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateTripDto, CreateFareRuleDto, UpdateTripStatusDto } from './schedules.dto';
import { RoutesService } from './routes.service';
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
@Injectable()
export class SchedulesService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private routesService: RoutesService,
) {}
async createTrip(dto: CreateTripDto) {
const dep = new Date(dto.departureAt), arr = new Date(dto.arrivalAt);
return this.prisma.trip.create({
data: { serviceId: dto.serviceId, originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, departureAt: dep, arrivalAt: arr, durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60000), stopsCount: dto.stopsCount ?? 0 },
include: { service: true, originStation: true, destinationStation: true },
// ── Schedule CRUD ──────────────────────────────────────────────────────────
async listSchedules(dto: ListSchedulesDto) {
const where: any = {};
if (dto.date) {
const date = new Date(dto.date);
const nextDay = new Date(date.getTime() + 86_400_000);
where.departureAt = { gte: date, lt: nextDay };
}
if (dto.routeId) where.routeId = dto.routeId;
if (dto.trainId) where.trainId = dto.trainId;
if (dto.status) where.status = dto.status;
return this.prisma.trainSchedule.findMany({
where,
include: {
train: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
_count: { select: { coachAssignments: true, bookings: true } },
},
orderBy: { departureAt: 'asc' },
});
}
async getTrip(id: string) {
const trip = await this.prisma.trip.findUnique({ where: { id }, include: { service: true, originStation: true, destinationStation: true, coaches: { include: { seats: true } }, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } });
if (!trip) throw new NotFoundException('Trip not found');
return trip;
async createSchedule(dto: CreateScheduleDto) {
const dep = new Date(dto.departureAt);
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' } } },
});
if (!route) throw new NotFoundException('Route not found');
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');
// Validate all route stop sequences are covered by plannedTimes
const providedSeqs = new Set(dto.plannedTimes.map(t => t.sequence));
const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq));
if (missingSeqs.length > 0) {
throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`);
}
// Derive origin and destination from first and last route stop
const firstStop = route.stops[0];
const lastStop = route.stops[route.stops.length - 1];
const schedule = await this.prisma.trainSchedule.create({
data: {
trainId: dto.trainId,
routeId: dto.routeId,
originStationId: firstStop.stationId,
destinationStationId: lastStop.stationId,
departureAt: dep,
arrivalAt: arr,
durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60_000),
stopsCount: Math.max(0, route.stops.length - 2),
},
include: { train: true, originStation: true, destinationStation: true },
});
// Copy route stops into TripStopTime with the provided planned times
const plannedTimesMap = Object.fromEntries(
dto.plannedTimes.map(t => [t.sequence, t]),
);
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
return this.getSchedule(schedule.id);
}
updateTripStatus(id: string, dto: UpdateTripStatusDto) { return this.prisma.trip.update({ where: { id }, data: { status: dto.status as any } }); }
async getSchedule(id: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id },
include: {
train: true,
originStation: true,
destinationStation: true,
coachAssignments: {
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] }, seatClass: true } } },
orderBy: { positionNumber: 'asc' },
},
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
},
});
if (!schedule) throw new NotFoundException('Schedule not found');
return schedule;
}
updateScheduleStatus(id: string, dto: UpdateScheduleStatusDto) {
return this.prisma.trainSchedule.update({ where: { id }, data: { status: dto.status } });
}
// ── Stop Times (per-schedule overrides) ───────────────────────────────────
getStops(scheduleId: string) {
return this.prisma.tripStopTime.findMany({
where: { scheduleId },
include: { station: true },
orderBy: { sequence: 'asc' },
});
}
async updateStop(scheduleId: string, sequence: number, dto: UpdateStopTimeDto) {
const stop = await this.prisma.tripStopTime.findUnique({
where: { scheduleId_sequence: { scheduleId, sequence } },
});
if (!stop) throw new NotFoundException(`Stop at sequence ${sequence} not found on schedule`);
return this.prisma.tripStopTime.update({
where: { scheduleId_sequence: { scheduleId, sequence } },
data: {
plannedArrivalAt: dto.plannedArrivalAt ? new Date(dto.plannedArrivalAt) : undefined,
plannedDepartureAt: dto.plannedDepartureAt ? new Date(dto.plannedDepartureAt) : undefined,
status: dto.status,
},
include: { station: true },
});
}
// ── Fare Rules ─────────────────────────────────────────────────────────────
createFareRule(dto: CreateFareRuleDto) {
return this.prisma.fareRule.create({ data: { ...dto, validFrom: new Date(dto.validFrom), validUntil: dto.validUntil ? new Date(dto.validUntil) : null } });
const { validFrom, validUntil, scheduleId, ...rest } = dto;
return this.prisma.fareRule.create({
data: {
...rest,
tripId: scheduleId,
validFrom: new Date(validFrom),
validUntil: validUntil ? new Date(validUntil) : null,
},
});
}
async getFare(tripId: string, serviceClass: string) {
const trip = await this.prisma.trip.findUnique({ where: { id: tripId }, include: { originStation: true, destinationStation: true } });
if (!trip) throw new NotFoundException('Trip not found');
const route = `${trip.originStation.code}-${trip.destinationStation.code}`;
async getFare(scheduleId: string, seatClassName: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
include: { originStation: true, destinationStation: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
const route = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: seatClassName } });
const now = new Date();
const rule = await this.prisma.fareRule.findFirst({
where: { serviceClass: serviceClass as any, validFrom: { lte: new Date() }, OR: [{ tripId }, { route }, { tripId: null, route: null }], AND: [{ OR: [{ validUntil: null }, { validUntil: { gte: new Date() } }] }] },
where: {
seatClassId: seatClass?.id,
validFrom: { lte: now },
OR: [{ tripId: scheduleId }, { route }, { tripId: null, route: null }],
AND: [{ OR: [{ validUntil: null }, { validUntil: { gte: now } }] }],
},
orderBy: { validFrom: 'desc' },
});
return rule ?? { baseFareMinor: 45000, currency: 'ETB', serviceClass };
return rule ?? { baseFareMinor: 45000, currency: 'ETB', seatClassName };
}
}

View File

@@ -9,24 +9,38 @@ export class SearchController {
constructor(private service: SearchService) {}
@Post()
@ApiOperation({
summary: 'Search trips by origin, destination, and passenger counts',
description: 'Returns available trips WITHOUT pricing. Requires adult count (mandatory) and optional child count. Pricing is shown only in fare quote endpoint.'
@ApiOperation({
summary: 'Search schedules by any origindestination stop pair',
description: `Finds all train schedules where both origin and destination appear as stops (not just terminals).
Example: A train running A→B→C→D will appear in results for A→B, A→C, A→D, B→C, B→D, and C→D searches.
Availability is computed per seat per segment — a seat booked A→B is still shown as available for B→D.
Returns departure/arrival times for the requested leg, the full stop list, and per-class seat counts.`
})
@ApiResponse({ status: 200, description: 'List of available trips with seat availability' })
@ApiResponse({ status: 400, description: 'Invalid search parameters' })
searchTrips(@Body() dto: SearchTripsDto) {
return this.service.searchTrips(dto);
@ApiResponse({ status: 200, description: 'Matching schedules with segment-accurate seat availability per class' })
searchTrips(@Body() dto: SearchTripsDto) {
return this.service.searchTrips(dto);
}
@Post('fare-quote')
@ApiOperation({
summary: 'Get detailed fare quote with age-based pricing',
description: 'Calculates fare based on adult/child counts. First child travels free, subsequent children pay full fare. Supports multi-currency display (ETB, DJF, USD).'
@ApiOperation({
summary: 'Get fare quote for a specific schedule leg',
description: `Calculates fare for the requested origin→destination leg on a schedule.
Pricing rules (in priority order):
1. Schedule-scoped FareRule (tripId = scheduleId)
2. Segment route FareRule (e.g. ADD-DRE)
3. Full-route FareRule (e.g. ADD-DJI)
4. Default hardcoded fare
Age-based pricing: first child (age < 5) travels free, subsequent children pay full fare.
Supports multi-currency display (ETB, DJF, USD).`
})
@ApiResponse({ status: 200, description: 'Detailed fare breakdown with adult/child pricing and currency conversion' })
@ApiResponse({ status: 404, description: 'Trip not found' })
getFareQuote(@Body() dto: FareQuoteDto) {
return this.service.getFareQuote(dto);
@ApiResponse({ status: 200, description: 'Fare breakdown with adult/child pricing, discounts, taxes, and currency conversion' })
@ApiResponse({ status: 404, description: 'Schedule not found or origin/destination not on schedule' })
getFareQuote(@Body() dto: FareQuoteDto) {
return this.service.getFareQuote(dto);
}
}

View File

@@ -4,23 +4,47 @@ import { Type } from 'class-transformer';
import { Currency } from '@prisma/client';
export class SearchTripsDto {
@ApiProperty({ example: 'st_ADD' }) @IsString() originStationId: string;
@ApiProperty({ example: 'st_DJI' }) @IsString() destinationStationId: string;
@ApiProperty({ example: '2026-05-11' }) @IsDateString() date: string;
@ApiProperty({ example: 2, description: 'Number of adults (5 years and above)' }) @Type(() => Number) @IsInt() @Min(1) adultCount: number;
@ApiPropertyOptional({ example: 1, description: 'Number of children (below 5 years)' }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID — any intermediate stop is valid, not just the terminal' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID — must appear after origin in the stop sequence' })
@IsString() destinationStationId: string;
@ApiProperty({ example: '2026-06-15', description: 'Departure date (YYYY-MM-DD)' })
@IsDateString() date: string;
@ApiProperty({ example: 2, description: 'Number of adult passengers (age ≥ 5)' })
@Type(() => Number) @IsInt() @Min(1) adultCount: number;
@ApiPropertyOptional({ example: 1, description: 'Number of child passengers (age < 5). First child travels free.' })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number;
}
export class FareQuoteDto {
@ApiProperty() @IsString() tripId: string;
@ApiProperty({
example: 'ECONOMY_REGULAR',
enum: ['ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER']
})
@IsString() serviceClass: string;
@ApiProperty({ example: 2, description: 'Number of adults' }) @Type(() => Number) @IsInt() @Min(1) adultCount: number;
@ApiPropertyOptional({ example: 1, description: 'Number of children' }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number;
@ApiPropertyOptional({ example: 'WEEKEND15' }) @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional({ example: 450 }) @IsOptional() @Type(() => Number) @IsInt() loyaltyRedemptionPoints?: number;
@ApiPropertyOptional({ example: 'ETB', enum: ['ETB', 'DJF', 'USD'] }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID from search results' })
@IsString() scheduleId: string;
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID (must be a stop on the schedule)' })
@IsString() originStationId: string;
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID (must come after origin in stop sequence)' })
@IsString() destinationStationId: string;
@ApiProperty({ example: 'Economy Regular', description: 'Seat class name: "Economy Regular" | "Economy Bed" | "VIP Bed"' })
@IsString() seatClassName: string;
@ApiProperty({ example: 2, description: 'Number of adult passengers' })
@Type(() => Number) @IsInt() @Min(1) adultCount: number;
@ApiPropertyOptional({ example: 1 })
@IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number;
@ApiPropertyOptional({ example: 'WEEKEND15' })
@IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional({ example: 450, description: 'Loyalty points to redeem (10 points = 1 ETB minor unit)' })
@IsOptional() @Type(() => Number) @IsInt() loyaltyRedemptionPoints?: number;
@ApiPropertyOptional({ example: 'ETB', enum: Currency })
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
}

View File

@@ -14,102 +14,256 @@ export class SearchService {
) {}
async searchTrips(dto: SearchTripsDto) {
const date = new Date(dto.date), nextDay = new Date(date.getTime() + 86400000);
const trips = await this.prisma.trip.findMany({
where: { originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, departureAt: { gte: date, lt: nextDay }, status: { in: ['SCHEDULED', 'BOARDING'] } },
include: { service: true, originStation: true, destinationStation: true, coaches: { include: { seats: true } } },
});
const totalPassengers = dto.adultCount + (dto.childCount || 0);
return trips.map((trip) => {
const seatsByClass = (cls: string) => trip.coaches.filter((c) => c.serviceClass === cls).flatMap((c) => c.seats);
const avail = (cls: string) => seatsByClass(cls).filter((s) => s.status === 'AVAILABLE').length;
return {
id: trip.id,
number: trip.service.number,
origin: { id: trip.originStation.id, code: trip.originStation.code, name: trip.originStation.name, city: trip.originStation.city },
destination: { id: trip.destinationStation.id, code: trip.destinationStation.code, name: trip.destinationStation.name, city: trip.destinationStation.city },
departureAt: trip.departureAt, arrivalAt: trip.arrivalAt, status: trip.status,
availability: {
ECONOMY_REGULAR: avail('ECONOMY_REGULAR') >= totalPassengers,
ECONOMY_BED_LOWER: avail('ECONOMY_BED_LOWER') >= totalPassengers,
ECONOMY_BED_MIDDLE: avail('ECONOMY_BED_MIDDLE') >= totalPassengers,
ECONOMY_BED_UPPER: avail('ECONOMY_BED_UPPER') >= totalPassengers,
VIP_BED_LOWER: avail('VIP_BED_LOWER') >= totalPassengers,
VIP_BED_UPPER: avail('VIP_BED_UPPER') >= totalPassengers
const date = new Date(dto.date);
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'] },
departureAt: { gte: date, lt: nextDay },
stopTimes: { some: { stationId: dto.originStationId } },
},
include: {
train: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, seatClass: true } } },
},
};
},
});
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);
// 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;
for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED') continue;
const free = await this.isSeatFreeForSegment(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) 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;
results.push({
scheduleId: schedule.id,
trainNumber: schedule.train.number,
trainName: schedule.train.name,
origin: {
id: originStop.stationId,
code: originStop.station.code,
name: originStop.station.name,
city: originStop.station.city,
sequence: originStop.sequence,
},
destination: {
id: destStop.stationId,
code: destStop.station.code,
name: destStop.station.name,
city: destStop.station.city,
sequence: destStop.sequence,
},
departureAt: legDepartureAt,
arrivalAt: legArrivalAt,
durationMinutes: Math.round(
(new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000,
),
status: schedule.status,
// Only return stops within the requested leg (origin → destination inclusive)
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,
})),
availabilityByClass,
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
});
}
return results;
}
async getFareQuote(dto: FareQuoteDto) {
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId } });
if (!trip) throw new NotFoundException('Trip not found');
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
include: {
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
},
});
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);
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 } });
// Look up fare rule: prefer schedule-scoped, then segment route, then global
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
const now = new Date();
const fareRule = await this.prisma.fareRule.findFirst({
where: {
seatClassId: seatClass?.id,
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
},
orderBy: [
// Most specific first: schedule-scoped > segment route > full route > global
{ tripId: 'desc' },
{ validFrom: 'desc' },
],
});
const baseFareMinor = fareRule?.baseFareMinor ?? this.defaultFare(dto.seatClassName);
const adultCount = dto.adultCount;
const childCount = dto.childCount || 0;
const baseFareMinor = this.defaultFare(dto.serviceClass);
// Adult fare: 100% of base fare
const childCount = dto.childCount ?? 0;
const adultFareMinor = baseFareMinor * adultCount;
// Child fare: First child free, subsequent children pay full fare
const paidChildrenCount = Math.max(0, childCount - 1);
const childFareMinor = baseFareMinor * paidChildrenCount;
const totalBaseFareMinor = adultFareMinor + childFareMinor;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) {
discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
if (promo?.active && promo.validUntil > now) {
discountMinor = promo.percentOff
? Math.round(totalBaseFareMinor * promo.percentOff / 100)
: (promo.amountOffMinor ?? 0);
}
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
const displayCurrency = dto.displayCurrency || Currency.ETB;
let displayTotalMinor = totalMinor;
if (displayCurrency !== Currency.ETB) {
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
}
const displayCurrency = dto.displayCurrency ?? Currency.ETB;
const displayTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
: totalMinor;
return {
tripId: dto.tripId,
serviceClass: dto.serviceClass,
adultCount,
childCount,
baseFareMinor,
adultFareMinor,
childFareMinor,
scheduleId: dto.scheduleId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
segmentRoute,
seatClassName: dto.seatClassName,
adultCount, childCount,
baseFareMinor, adultFareMinor, childFareMinor,
freeChildrenCount: Math.min(childCount, 1),
paidChildrenCount,
totalBaseFareMinor,
discountMinor,
loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor,
totalMinor,
currency: 'ETB',
displayCurrency,
displayTotalMinor,
paidChildrenCount, totalBaseFareMinor,
discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
taxesFeesMinor: taxesMinor, totalMinor,
currency: 'ETB', displayCurrency, displayTotalMinor,
};
}
private defaultFare(serviceClass: string): number {
/**
* Returns true if the seat has no active hold or confirmed booking
* whose segment range overlaps [fromSeq, toSeq).
* Overlap condition: existingFrom < toSeq AND fromSeq < existingTo
*/
private async isSeatFreeForSegment(
scheduleId: string,
seatId: string,
fromSeq: number,
toSeq: number,
): Promise<boolean> {
// Check active holds that include this seat on this schedule
const holds = await this.prisma.seatHold.findMany({
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
});
for (const hold of holds) {
// Resolve hold segment range from its stored origin/destination via JourneySegment
// For holds we use the stop sequences stored on the hold's origin/destination
// Since SeatHold doesn't store sequences directly, we check JourneySegments
// that reference this seat on this schedule with PENDING_PAYMENT status
const holdSegs = await this.prisma.journeySegment.findMany({
where: { scheduleId, seatId },
include: {
journey: true,
schedule: { include: { stopTimes: true } },
},
});
for (const js of holdSegs) {
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
if (depSeq !== undefined && arrSeq !== undefined) {
if (depSeq < toSeq && fromSeq < arrSeq) return false;
}
}
// If no journey segments yet (hold just created), treat the whole hold as blocking
if (holdSegs.length === 0) return false;
}
// Check confirmed/pending bookings via JourneySegment
const bookedSegments = await this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId,
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
include: {
schedule: { include: { stopTimes: true } },
},
});
for (const js of bookedSegments) {
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
if (depSeq !== undefined && arrSeq !== undefined) {
if (depSeq < toSeq && fromSeq < arrSeq) return false;
}
}
return true;
}
private defaultFare(seatClassName: string): number {
const fares: Record<string, number> = {
ECONOMY_REGULAR: 35000,
ECONOMY_BED_LOWER: 55000,
ECONOMY_BED_MIDDLE: 50000,
ECONOMY_BED_UPPER: 45000,
VIP_BED_LOWER: 85000,
VIP_BED_UPPER: 80000
'Economy Regular': 45000,
'Economy Bed': 65000,
'VIP Bed': 95000,
};
return fares[serviceClass] ?? 35000;
return fares[seatClassName] ?? 45000;
}
}

View File

@@ -0,0 +1,40 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger';
import { SeatClassesService } from './seat-classes.service';
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Seat Classes')
@Controller('seat-classes')
export class SeatClassesController {
constructor(private service: SeatClassesService) {}
@Get()
@ApiOperation({ summary: 'List all seat classes' })
@ApiResponse({ status: 200, description: 'Returns all seat classes with their coaches' })
listSeatClasses() { return this.service.listSeatClasses(); }
@Get(':id')
@ApiOperation({ summary: 'Get a seat class by ID' })
@ApiParam({ name: 'id', description: 'Seat class UUID' })
@ApiResponse({ status: 200, description: 'Returns seat class with its coaches' })
@ApiResponse({ status: 404, description: 'Seat class not found' })
getSeatClass(@Param('id') id: string) { return this.service.getSeatClass(id); }
@Post()
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create a seat class' })
@ApiBody({ type: CreateSeatClassDto })
@ApiResponse({ status: 201, description: 'Seat class created' })
@ApiResponse({ status: 409, description: 'Seat class name already exists' })
createSeatClass(@Body() dto: CreateSeatClassDto) { return this.service.createSeatClass(dto); }
@Patch(':id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update a seat class' })
@ApiParam({ name: 'id', description: 'Seat class UUID' })
@ApiBody({ type: UpdateSeatClassDto })
@ApiResponse({ status: 200, description: 'Seat class updated' })
@ApiResponse({ status: 404, description: 'Seat class not found' })
updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); }
}

View File

@@ -0,0 +1,24 @@
import { IsString, IsInt, IsBoolean, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
export class CreateSeatClassDto {
@ApiProperty({ example: 'Economy Seat' })
@IsString()
name: string;
@ApiPropertyOptional({ example: 'Standard economy seating' })
@IsOptional()
@IsString()
description?: string;
@ApiProperty({ example: 45000, description: 'Base price in minor currency units' })
@IsInt()
basePrice: number;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateSeatClassDto extends PartialType(CreateSeatClassDto) {}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { SeatClassesController } from './seat-classes.controller';
import { SeatClassesService } from './seat-classes.service';
@Module({ controllers: [SeatClassesController], providers: [SeatClassesService], exports: [SeatClassesService] })
export class SeatClassesModule {}

View File

@@ -0,0 +1,40 @@
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 });
}
async getSeatClass(id: string) {
const sc = await this.prisma.seatClass.findUnique({ where: { id }, include: this.coachInclude });
if (!sc) throw new NotFoundException('SeatClass not found');
return sc;
}
async createSeatClass(dto: CreateSeatClassDto) {
try {
return await this.prisma.seatClass.create({ data: dto, include: this.coachInclude });
} catch (e: any) {
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
throw e;
}
}
async updateSeatClass(id: string, dto: UpdateSeatClassDto) {
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 });
}
}

View File

@@ -10,12 +10,12 @@ export class SeatsController {
constructor(private service: SeatsService) {}
// ── Seat Map ──────────────────────────────────────────────────────────────
@Get('seatmap/:tripId')
@ApiOperation({ summary: 'Get seat map for a trip' })
@ApiParam({ name: 'tripId', description: 'Trip UUID' })
@Get('seatmap/:scheduleId')
@ApiOperation({ summary: 'Get seat map for a schedule' })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@ApiQuery({ name: 'coachId', required: false, description: 'Filter by coach UUID' })
@ApiResponse({ status: 200, description: 'Returns coaches with seats and seat class info' })
getSeatMap(@Param('tripId') tripId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(tripId, coachId); }
getSeatMap(@Param('scheduleId') scheduleId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(scheduleId, coachId); }
// ── Hold / Release ────────────────────────────────────────────────────────
@Post('hold')
@@ -33,10 +33,10 @@ export class SeatsController {
@ApiResponse({ status: 404, description: 'Hold not found' })
releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); }
@Get('export/csv/:tripId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' })
async exportCSV(@Param('tripId') tripId: string) {
const csv = await this.service.exportSeatsCSV(tripId);
return { csv, filename: `seats-${tripId}.csv` };
@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);
return { csv, filename: `seats-${scheduleId}.csv` };
}
@Post('import/preview') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Preview CSV import' })
@@ -45,7 +45,7 @@ export class SeatsController {
}
@Post('import/commit') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Commit CSV import' })
importCSV(@Body() body: { tripId: string; csv: string; commit: boolean }) {
return this.service.importSeatsCSV(body.tripId, body.csv, body.commit);
importCSV(@Body() body: { scheduleId: string; csv: string; commit: boolean }) {
return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit);
}
}

View File

@@ -2,7 +2,7 @@ import { IsString, IsArray, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class HoldSeatsDto {
@ApiProperty({ example: 'trip-uuid' }) @IsString() tripId: string;
@ApiProperty({ example: 'schedule-uuid' }) @IsString() scheduleId: string;
@ApiProperty({ example: 'passenger-uuid' }) @IsString() passengerId: string;
@ApiProperty({ type: [String], example: ['seat-uuid-1', 'seat-uuid-2'] }) @IsArray() seatIds: string[];
@ApiPropertyOptional({ example: 'fare-quote-uuid' }) @IsOptional() @IsString() fareQuoteId?: string;

View File

@@ -8,14 +8,20 @@ export class SeatsService {
constructor(private prisma: PrismaService) {}
// ── Seat Map ──────────────────────────────────────────────────────────────
async getSeatMap(tripId: string, coachId?: string) {
const coaches = await this.prisma.coach.findMany({ where: { tripId, ...(coachId ? { id: coachId } : {}) }, include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } });
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 } } },
orderBy: { positionNumber: 'asc' },
});
return {
coaches: coaches.map((coach) => ({
id: coach.id,
name: `Coach ${coach.label}`,
serviceClass: coach.serviceClass,
seats: coach.seats.map((s) => ({ id: s.id, number: s.label, status: s.status, kind: s.kind })),
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: s.status, kind: s.kind })),
})),
};
}
@@ -28,9 +34,9 @@ export class SeatsService {
const unavailable = seats.filter((s) => s.status === 'BOOKED' || s.status === 'BLOCKED' || (s.status === 'HELD' && s.heldUntil && s.heldUntil > new Date()));
if (unavailable.length > 0) throw new ConflictException('One or more seats unavailable');
await tx.seat.updateMany({ where: { id: { in: dto.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } });
return tx.seatHold.create({ data: { tripId: dto.tripId, passengerId: dto.passengerId, seatIds: dto.seatIds, fareQuoteId: dto.fareQuoteId, expiresAt } });
return tx.seatHold.create({ data: { scheduleId: dto.scheduleId, passengerId: dto.passengerId, seatIds: dto.seatIds, fareQuoteId: dto.fareQuoteId, expiresAt } });
});
return { id: hold.id, tripId: dto.tripId, seatIds: dto.seatIds, expiresAt };
return { id: hold.id, scheduleId: dto.scheduleId, seatIds: dto.seatIds, expiresAt };
}
async releaseHold(holdId: string) {
@@ -44,10 +50,10 @@ export class SeatsService {
async confirmSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'BOOKED', heldUntil: null } }); }
async releaseSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'AVAILABLE', heldUntil: null } }); }
async autoAssignSeats(tripId: string, count: number, serviceClass: string, eligibility?: string): Promise<string[]> {
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string, eligibility?: string): Promise<string[]> {
const seats = await this.prisma.seat.findMany({
where: {
coach: { tripId, serviceClass: serviceClass as any },
coach: { seatClass: { name: seatClassName }, assignments: { some: { scheduleId } } },
status: 'AVAILABLE',
...(eligibility ? { eligibility } : {}),
},
@@ -81,18 +87,15 @@ export class SeatsService {
return seats.slice(0, count);
}
async exportSeatsCSV(tripId: string): Promise<string> {
const coaches = await this.prisma.coach.findMany({
where: { tripId },
include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } },
async exportSeatsCSV(scheduleId: string): Promise<string> {
const assignments = await this.prisma.coachAssignment.findMany({
where: { scheduleId },
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
});
const rows = ['coachId,coachLabel,row,col,label,kind,status,premiumFeeMinor,eligibility'];
for (const coach of coaches) {
for (const seat of coach.seats) {
rows.push(
`${coach.id},${coach.label},${seat.row},${seat.col},${seat.label},${seat.kind},${seat.status},${seat.premiumFeeMinor},${seat.eligibility || ''}`,
);
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 || ''}`);
}
}
return rows.join('\n');
@@ -123,7 +126,7 @@ export class SeatsService {
return { valid, invalid, errors: errors.slice(0, 10) };
}
async importSeatsCSV(tripId: string, csvContent: string, commit: boolean): Promise<{ imported: number; errors: string[] }> {
async importSeatsCSV(scheduleId: string, csvContent: string, commit: boolean): Promise<{ imported: number; errors: string[] }> {
const lines = csvContent.trim().split('\n').slice(1);
const errors: string[] = [];
let imported = 0;

View File

@@ -1,64 +1,56 @@
/**
* SEGMENT-BASED SEAT RESERVATION EXAMPLE
*
* This example demonstrates the complete flow for booking Addis Ababa → Dire Dawa
*
* Demonstrates the complete flow for booking Addis Ababa → Dire Dawa
* on the Addis Ababa → Djibouti route with segment-based seat management.
*
* Route: Addis Ababa (seq:0) → Adama (seq:1) → Awash (seq:2) → Dire Dawa (seq:3) → Djibouti (seq:4)
* Booking: Addis Ababa → Dire Dawa (segments: 0→1, 1→2, 2→3)
*
* Route: Addis Ababa (seq:1) → Adama (seq:2) → Awash (seq:3) → Dire Dawa (seq:4) → Aysha (seq:5) → Djibouti (seq:6)
* Booking: Addis Ababa → Dire Dawa (segments: 1→2, 2→3, 3→4)
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Example 1: Complete Booking Flow
async function exampleBookingFlow() {
console.log('=== SEGMENT-BASED BOOKING FLOW ===\n');
const tripId = 'trip_add_dji_001';
const scheduleId = 'schedule_add_dji_001';
const passengerId = 'passenger_kelemu';
const seatIds = ['seat_coach_a_1a', 'seat_coach_a_1b'];
const originStationId = 'st_ADD'; // Addis Ababa
const destinationStationId = 'st_DRE'; // Dire Dawa
const originStationId = 'st_ADD';
const destinationStationId = 'st_DRE';
try {
// Step 1: Check seat availability for segments
console.log('1. Checking seat availability...');
const segments = await getJourneySegments(tripId, originStationId, destinationStationId);
const segments = await getJourneySegments(scheduleId, originStationId, destinationStationId);
console.log('Journey segments:', segments.map(s => `${s.fromName}${s.toName}`));
// Step 2: Hold seats (10-minute expiry)
console.log('\n2. Holding seats...');
const holdResult = await holdSeatsTransaction(tripId, seatIds, passengerId, originStationId, destinationStationId);
const holdResult = await holdSeatsTransaction(scheduleId, seatIds, passengerId, originStationId, destinationStationId);
console.log('Hold created:', holdResult);
// Step 3: Simulate payment processing (5 seconds)
console.log('\n3. Processing payment...');
await new Promise(resolve => setTimeout(resolve, 5000));
// Step 4: Confirm booking
console.log('\n4. Confirming booking...');
const bookingId = 'booking_' + Date.now();
const confirmResult = await confirmBookingTransaction(holdResult.holdId, bookingId, segments);
console.log('Booking confirmed:', confirmResult);
// Step 5: Simulate trip progress and seat release
console.log('\n5. Simulating trip progress...');
await simulateTripProgress(tripId, segments);
await simulateTripProgress(scheduleId, segments);
} catch (error) {
console.error('Booking flow error:', error);
}
}
// Database Transaction Functions
async function getJourneySegments(tripId: string, originStationId: string, destinationStationId: string) {
async function getJourneySegments(scheduleId: string, originStationId: string, destinationStationId: string) {
const stopTimes = await prisma.tripStopTime.findMany({
where: { tripId },
where: { scheduleId },
include: { station: true },
orderBy: { sequence: 'asc' }
orderBy: { sequence: 'asc' },
});
const originStop = stopTimes.find(st => st.stationId === originStationId);
@@ -72,7 +64,6 @@ async function getJourneySegments(tripId: string, originStationId: string, desti
for (let i = originStop.sequence; i < destinationStop.sequence; i++) {
const fromStop = stopTimes.find(st => st.sequence === i);
const toStop = stopTimes.find(st => st.sequence === i + 1);
if (fromStop && toStop) {
segments.push({
fromStationId: fromStop.stationId,
@@ -80,27 +71,19 @@ async function getJourneySegments(tripId: string, originStationId: string, desti
fromSequence: fromStop.sequence,
toSequence: toStop.sequence,
fromName: fromStop.station.name,
toName: toStop.station.name
toName: toStop.station.name,
});
}
}
return segments;
}
async function holdSeatsTransaction(tripId: string, seatIds: string[], passengerId: string, originStationId: string, destinationStationId: string) {
async function holdSeatsTransaction(scheduleId: string, seatIds: string[], passengerId: string, originStationId: string, destinationStationId: string) {
return prisma.$transaction(async (tx) => {
console.log(' → Starting seat hold transaction...');
// 1. Validate seats exist and are available
const seats = await tx.seat.findMany({
where: { id: { in: seatIds } },
include: { coach: true }
});
if (seats.length !== seatIds.length) {
throw new Error('Some seats not found');
}
const seats = await tx.seat.findMany({ where: { id: { in: seatIds } }, include: { coach: true } });
if (seats.length !== seatIds.length) throw new Error('Some seats not found');
for (const seat of seats) {
if (seat.status !== 'AVAILABLE') {
@@ -108,43 +91,15 @@ async function holdSeatsTransaction(tripId: string, seatIds: string[], passenger
}
}
// 2. Check for overlapping reservations
const segments = await getJourneySegments(tripId, originStationId, destinationStationId);
for (const seatId of seatIds) {
const overlaps = await checkOverlappingReservations(tx, tripId, seatId, segments);
if (overlaps.length > 0) {
throw new Error(`Seat ${seatId} has overlapping reservations`);
}
}
// 3. Create hold record
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
const seatHold = await tx.seatHold.create({
data: {
tripId,
seatIds,
passengerId,
expiresAt
}
data: { scheduleId, seatIds, passengerId, expiresAt },
});
// 4. Update seat status to HELD
await tx.seat.updateMany({
where: { id: { in: seatIds } },
data: {
status: 'HELD',
heldUntil: expiresAt
}
});
await tx.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } });
console.log(' → Seats held successfully');
return {
holdId: seatHold.id,
expiresAt,
segments: segments.length,
seats: seatIds.length
};
return { holdId: seatHold.id, expiresAt, seats: seatIds.length };
});
}
@@ -152,157 +107,96 @@ async function confirmBookingTransaction(holdId: string, bookingId: string, segm
return prisma.$transaction(async (tx) => {
console.log(' → Starting booking confirmation transaction...');
// 1. Validate hold
const hold = await tx.seatHold.findUnique({ where: { id: holdId } });
if (!hold || hold.expiresAt < new Date()) {
throw new Error('Hold expired or not found');
}
if (!hold || hold.expiresAt < new Date()) throw new Error('Hold expired or not found');
// 2. Create booking record (simplified)
const booking = await tx.booking.create({
data: {
id: bookingId,
bookingRef: 'BK' + Date.now().toString().slice(-6),
passengerId: hold.passengerId,
tripId: hold.tripId,
status: 'CONFIRMED',
totalMinor: 45000, // Example fare
currency: 'ETB'
}
});
// 3. Create journey record
const journey = await tx.journey.create({
data: {
passengerId: hold.passengerId,
scheduleId: hold.scheduleId,
status: 'CONFIRMED',
totalMinor: 45000,
currency: 'ETB'
}
currency: 'ETB',
},
});
const journey = await tx.journey.create({
data: { passengerId: hold.passengerId, status: 'CONFIRMED', totalMinor: 45000, currency: 'ETB' },
});
// 4. Create journey segments for each seat
for (const seatId of hold.seatIds) {
for (let i = 0; i < segments.length; i++) {
await tx.journeySegment.create({
data: {
journeyId: journey.id,
tripId: hold.tripId,
scheduleId: hold.scheduleId,
segmentOrder: i + 1,
seatId,
departureStationId: segments[i].fromStationId,
arrivalStationId: segments[i].toStationId
}
arrivalStationId: segments[i].toStationId,
},
});
}
}
// 5. Create booking seats
for (const seatId of hold.seatIds) {
await tx.bookingSeat.create({
data: {
bookingId,
seatId,
passengerName: 'Kelemu Ketsela' // Example
}
});
await tx.bookingSeat.create({ data: { bookingId, seatId, passengerName: 'Kelemu Ketsela' } });
}
// 6. Update seat status to BOOKED
await tx.seat.updateMany({
where: { id: { in: hold.seatIds } },
data: {
status: 'BOOKED',
heldUntil: null
}
});
// 7. Delete hold
await tx.seat.updateMany({ where: { id: { in: hold.seatIds } }, data: { status: 'BOOKED', heldUntil: null } });
await tx.seatHold.delete({ where: { id: holdId } });
console.log(' → Booking confirmed successfully');
return {
bookingId,
bookingRef: booking.bookingRef,
confirmedSeats: hold.seatIds.length,
segments: segments.length
};
return { bookingId, bookingRef: booking.bookingRef, confirmedSeats: hold.seatIds.length, segments: segments.length };
});
}
async function simulateTripProgress(tripId: string, bookedSegments: any[]) {
async function simulateTripProgress(scheduleId: string, bookedSegments: any[]) {
console.log(' → Simulating trip progress...');
// Simulate train reaching each station
for (const segment of bookedSegments) {
console.log(` → Train approaching ${segment.toName}...`);
// Update trip live status
await prisma.tripLiveStatus.upsert({
where: { tripId },
update: {
currentLocationLabel: segment.toName,
progressPercent: Math.round((segment.toSequence / 4) * 100),
updatedAt: new Date()
},
where: { scheduleId },
update: { currentLocationLabel: segment.toName, progressPercent: Math.round((segment.toSequence / 4) * 100) },
create: {
tripId,
scheduleId,
state: 'EN_ROUTE',
currentLocationLabel: segment.toName,
progressPercent: Math.round((segment.toSequence / 4) * 100),
delayMinutes: 0,
updatedAt: new Date()
}
},
});
// Check if this is the final destination for any passengers
if (segment.toName === 'Dire Dawa') {
console.log(' → Passengers reached destination, releasing seats...');
await releaseSeatsAtStation(tripId, segment.toStationId);
await releaseSeatsAtStation(scheduleId, segment.toStationId);
}
await new Promise(resolve => setTimeout(resolve, 2000)); // 2 second delay
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
async function releaseSeatsAtStation(tripId: string, stationId: string) {
async function releaseSeatsAtStation(scheduleId: string, stationId: string) {
return prisma.$transaction(async (tx) => {
// Find journey segments ending at this station
const completedSegments = await tx.journeySegment.findMany({
where: {
tripId,
arrivalStationId: stationId
},
include: {
journey: {
include: {
journeySegments: {
where: { tripId }
}
}
}
}
where: { scheduleId, arrivalStationId: stationId },
include: { journey: { include: { journeySegments: { where: { scheduleId } } } } },
});
const seatsToRelease = [];
const seatsToRelease: string[] = [];
// Check if passenger's entire journey is complete
for (const segment of completedSegments) {
const passengerSegments = segment.journey.journeySegments.filter((js: any) => js.seatId === segment.seatId);
const maxOrder = Math.max(...passengerSegments.map((js: any) => js.segmentOrder));
if (segment.segmentOrder === maxOrder) {
seatsToRelease.push(segment.seatId!);
}
if (segment.segmentOrder === maxOrder) seatsToRelease.push(segment.seatId!);
}
// Release seats
if (seatsToRelease.length > 0) {
await tx.seat.updateMany({
where: { id: { in: seatsToRelease } },
data: { status: 'AVAILABLE' }
});
await tx.seat.updateMany({ where: { id: { in: seatsToRelease } }, data: { status: 'AVAILABLE' } });
console.log(` → Released ${seatsToRelease.length} seats at station`);
}
@@ -310,73 +204,24 @@ async function releaseSeatsAtStation(tripId: string, stationId: string) {
});
}
async function checkOverlappingReservations(tx: any, tripId: string, seatId: string, segments: any[]) {
// Check active holds
async function checkOverlappingReservations(tx: any, scheduleId: string, seatId: string, segments: any[]) {
const activeHolds = await tx.seatHold.findMany({
where: {
tripId,
seatIds: { has: seatId },
expiresAt: { gt: new Date() }
}
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
});
// Check active bookings
const activeBookings = await tx.journeySegment.findMany({
where: {
tripId,
scheduleId,
seatId,
journey: {
status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] }
}
}
journey: { status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } },
},
});
return [...activeHolds, ...activeBookings];
}
// Example API Usage
async function exampleApiUsage() {
console.log('\n=== API ENDPOINT EXAMPLES ===\n');
const baseUrl = 'http://localhost:4000';
// 1. Check availability
console.log('GET /segments/seats/availability');
console.log('Query: tripId=trip_001&originStationId=st_ADD&destinationStationId=st_DRE');
console.log('Response: Available seats for Addis Ababa → Dire Dawa segments\n');
// 2. Hold seats
console.log('POST /segments/seats/hold');
console.log('Body:', JSON.stringify({
tripId: 'trip_001',
seatIds: ['seat_1', 'seat_2'],
passengerId: 'passenger_123',
originStationId: 'st_ADD',
destinationStationId: 'st_DRE'
}, null, 2));
console.log('Response: Hold created with 10-minute expiry\n');
// 3. Confirm booking
console.log('POST /segments/seats/confirm');
console.log('Body:', JSON.stringify({
holdId: 'hold_123',
bookingId: 'booking_456'
}, null, 2));
console.log('Response: Booking confirmed, seats reserved for segments\n');
// 4. Release seats (triggered by trip progress)
console.log('POST /segments/seats/release');
console.log('Body:', JSON.stringify({
tripId: 'trip_001',
currentStationId: 'st_DRE'
}, null, 2));
console.log('Response: Seats released for passengers reaching Dire Dawa\n');
}
// Run examples
if (require.main === module) {
exampleBookingFlow()
.then(() => exampleApiUsage())
.then(() => console.log('\n=== EXAMPLES COMPLETED ==='))
.catch(console.error)
.finally(() => prisma.$disconnect());
@@ -388,5 +233,6 @@ export {
holdSeatsTransaction,
confirmBookingTransaction,
simulateTripProgress,
releaseSeatsAtStation
};
releaseSeatsAtStation,
checkOverlappingReservations,
};

View File

@@ -4,7 +4,7 @@ import { SegmentsService, Segment } from '../segments/segments.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
export interface SeatHoldRequest {
tripId: string;
scheduleId: string;
seatIds: string[];
passengerId: string;
originStationId: string;
@@ -22,349 +22,177 @@ export class EnhancedSeatsService {
constructor(
private prisma: PrismaService,
private segmentsService: SegmentsService,
private eventEmitter: EventEmitter2
private eventEmitter: EventEmitter2,
) {}
/**
* Hold seats for specific segments with atomicity
*/
async holdSeats(request: SeatHoldRequest) {
return this.prisma.$transaction(async (tx) => {
// 1. Get journey segments
const segments = await this.segmentsService.getJourneySegments(
request.tripId,
request.originStationId,
request.destinationStationId
);
const segments = await this.segmentsService.getJourneySegments(request.scheduleId, request.originStationId, request.destinationStationId);
// 2. Check seat availability for all requested seats
for (const seatId of request.seatIds) {
const seat = await tx.seat.findUnique({
where: { id: seatId },
include: { coach: true }
});
if (!seat) {
throw new BadRequestException(`Seat ${seatId} not found`);
}
if (seat.status === 'BLOCKED') {
throw new BadRequestException(`Seat ${seat.label} is blocked`);
}
// Check for overlapping reservations
const overlaps = await this.segmentsService.getOverlappingReservations(
request.tripId,
seatId,
segments
);
if (overlaps.length > 0) {
throw new ConflictException(`Seat ${seat.label} is not available for the requested segments`);
}
const seat = await tx.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new BadRequestException(`Seat ${seatId} not found`);
if (seat.status === 'BLOCKED') throw new BadRequestException(`Seat ${seat.label} is blocked`);
const overlaps = await this.segmentsService.getOverlappingReservations(request.scheduleId, seatId, segments);
if (overlaps.length > 0) throw new ConflictException(`Seat ${seat.label} is not available for the requested segments`);
}
// 3. Create seat hold
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
// Encode origin/destination into fareQuoteId so confirmBooking can resolve the leg range
// Format: "leg:{originStationId}:{destinationStationId}" (or preserve actual fareQuoteId)
const legKey = request.fareQuoteId ?? `leg:${request.originStationId}:${request.destinationStationId}`;
const seatHold = await tx.seatHold.create({
data: {
tripId: request.tripId,
seatIds: request.seatIds,
passengerId: request.passengerId,
fareQuoteId: request.fareQuoteId,
expiresAt
}
data: { scheduleId: request.scheduleId, seatIds: request.seatIds, passengerId: request.passengerId, fareQuoteId: legKey, expiresAt },
});
// 4. Update seat status to HELD
await tx.seat.updateMany({
where: { id: { in: request.seatIds } },
data: {
status: 'HELD',
heldUntil: expiresAt
}
});
await tx.seat.updateMany({ where: { id: { in: request.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } });
// 5. Emit event for real-time updates
this.eventEmitter.emit('seats.held', {
holdId: seatHold.id,
tripId: request.tripId,
seatIds: request.seatIds,
segments
});
this.eventEmitter.emit('seats.held', { holdId: seatHold.id, scheduleId: request.scheduleId, seatIds: request.seatIds, segments });
return {
holdId: seatHold.id,
expiresAt,
segments,
seats: request.seatIds
};
return { holdId: seatHold.id, expiresAt, segments, seats: request.seatIds };
});
}
/**
* Confirm booking and convert hold to booking
*/
async confirmBooking(request: BookingConfirmRequest) {
return this.prisma.$transaction(async (tx) => {
// 1. Get and validate hold
const hold = await tx.seatHold.findUnique({
where: { id: request.holdId }
const hold = await tx.seatHold.findUnique({ where: { id: request.holdId } });
if (!hold) throw new BadRequestException('Seat hold not found');
if (hold.expiresAt < new Date()) throw new BadRequestException('Seat hold has expired');
const booking = await tx.booking.findUnique({ where: { id: request.bookingId } });
if (!booking) throw new BadRequestException('Booking not found');
const schedule = await tx.trainSchedule.findUnique({
where: { id: hold.scheduleId },
include: { stopTimes: { orderBy: { sequence: 'asc' } } },
});
if (!schedule) throw new BadRequestException('Schedule not found');
if (!hold) {
throw new BadRequestException('Seat hold not found');
// Resolve the passenger's leg range from the hold's fareQuoteId (encoded as "leg:originId:destId")
const legKey = hold.fareQuoteId ?? '';
let originStationId: string | undefined;
let destinationStationId: string | undefined;
if (legKey.startsWith('leg:')) {
const parts = legKey.split(':');
originStationId = parts[1];
destinationStationId = parts[2];
} else {
// Fall back to booking's own origin/destination if available
originStationId = (booking as any).originStationId;
destinationStationId = (booking as any).destinationStationId;
}
if (hold.expiresAt < new Date()) {
throw new BadRequestException('Seat hold has expired');
}
const originStop = originStationId ? schedule.stopTimes.find(s => s.stationId === originStationId) : undefined;
const destStop = destinationStationId ? schedule.stopTimes.find(s => s.stationId === destinationStationId) : undefined;
const fromSeq = originStop?.sequence ?? schedule.stopTimes[0].sequence;
const toSeq = destStop?.sequence ?? schedule.stopTimes[schedule.stopTimes.length - 1].sequence;
// 2. Get booking
const booking = await tx.booking.findUnique({
where: { id: request.bookingId }
});
if (!booking) {
throw new BadRequestException('Booking not found');
}
// 3. Get journey segments - we need to derive from trip stops
const trip = await tx.trip.findUnique({
where: { id: hold.tripId },
include: {
stopTimes: {
orderBy: { sequence: 'asc' }
}
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);
if (fromStop && toStop) {
segments.push({
fromStationId: fromStop.stationId,
toStationId: toStop.stationId,
fromSequence: fromStop.sequence,
toSequence: toStop.sequence,
fromName: '',
toName: '',
});
}
});
if (!trip) {
throw new BadRequestException('Trip not found');
}
// For now, create segments for the full trip (would need origin/destination from booking)
const segments = [];
for (let i = 0; i < trip.stopTimes.length - 1; i++) {
segments.push({
fromStationId: trip.stopTimes[i].stationId,
toStationId: trip.stopTimes[i + 1].stationId,
fromSequence: trip.stopTimes[i].sequence,
toSequence: trip.stopTimes[i + 1].sequence
});
}
// 4. Create journey record
const journey = await tx.journey.create({
data: {
passengerId: hold.passengerId,
status: 'CONFIRMED',
totalMinor: booking.totalMinor,
currency: booking.currency
}
data: { passengerId: hold.passengerId, status: 'CONFIRMED', totalMinor: booking.totalMinor, currency: booking.currency },
});
// 5. Create journey segments for each seat
for (const seatId of hold.seatIds) {
for (let i = 0; i < segments.length; i++) {
await tx.journeySegment.create({
data: {
journeyId: journey.id,
tripId: hold.tripId,
scheduleId: hold.scheduleId,
segmentOrder: i + 1,
seatId,
departureStationId: segments[i].fromStationId,
arrivalStationId: segments[i].toStationId
}
arrivalStationId: segments[i].toStationId,
},
});
}
}
// 6. Update seat status to BOOKED
await tx.seat.updateMany({
where: { id: { in: hold.seatIds } },
data: {
status: 'BOOKED',
heldUntil: null
}
});
await tx.seat.updateMany({ where: { id: { in: hold.seatIds } }, data: { status: 'BOOKED', heldUntil: null } });
await tx.seatHold.delete({ where: { id: request.holdId } });
// 7. Delete the hold
await tx.seatHold.delete({
where: { id: request.holdId }
});
this.eventEmitter.emit('booking.confirmed', { bookingId: request.bookingId, scheduleId: hold.scheduleId, seatIds: hold.seatIds, segments });
// 8. Emit confirmation event
this.eventEmitter.emit('booking.confirmed', {
bookingId: request.bookingId,
tripId: hold.tripId,
seatIds: hold.seatIds,
segments
});
return {
bookingId: request.bookingId,
confirmedSeats: hold.seatIds,
segments
};
return { bookingId: request.bookingId, confirmedSeats: hold.seatIds, segments };
});
}
/**
* Release seats when passenger reaches destination
*/
async releaseSeats(tripId: string, currentStationId: string) {
async releaseSeats(scheduleId: string, currentStationId: string) {
return this.prisma.$transaction(async (tx) => {
// 1. Find all journey segments ending at current station
const completedSegments = await tx.journeySegment.findMany({
where: {
tripId,
arrivalStationId: currentStationId
},
include: {
journey: {
include: {
journeySegments: {
where: { tripId }
}
}
}
}
where: { scheduleId, arrivalStationId: currentStationId },
include: { journey: { include: { journeySegments: { where: { scheduleId } } } } },
});
const seatsToRelease = [];
// 2. Check if passenger's entire journey is complete
const seatsToRelease: string[] = [];
for (const segment of completedSegments) {
const allSegments = segment.journey.journeySegments.filter((js: any) => js.seatId === segment.seatId);
const maxSegmentOrder = Math.max(...allSegments.map((js: any) => js.segmentOrder));
// If this is the last segment for this seat, release it
if (segment.segmentOrder === maxSegmentOrder) {
seatsToRelease.push(segment.seatId!);
}
if (segment.segmentOrder === maxSegmentOrder) seatsToRelease.push(segment.seatId!);
}
// 3. Update seat status to AVAILABLE
if (seatsToRelease.length > 0) {
await tx.seat.updateMany({
where: { id: { in: seatsToRelease } },
data: { status: 'AVAILABLE' }
});
// 4. Mark journey segments as completed (optional - could add a completed field)
// For now, we'll leave the segments as they are for historical tracking
// 5. Emit release event
this.eventEmitter.emit('seats.released', {
tripId,
stationId: currentStationId,
releasedSeats: seatsToRelease
});
await tx.seat.updateMany({ where: { id: { in: seatsToRelease } }, data: { status: 'AVAILABLE' } });
this.eventEmitter.emit('seats.released', { scheduleId, stationId: currentStationId, releasedSeats: seatsToRelease });
}
return {
releasedSeats: seatsToRelease,
stationId: currentStationId
};
return { releasedSeats: seatsToRelease, stationId: currentStationId };
});
}
/**
* Expire old holds (background job)
*/
async expireHolds() {
return this.prisma.$transaction(async (tx) => {
const expiredHolds = await tx.seatHold.findMany({
where: {
expiresAt: { lt: new Date() }
}
});
const expiredSeatIds = expiredHolds.flatMap(hold => hold.seatIds);
const expiredHolds = await tx.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
const expiredSeatIds = expiredHolds.flatMap(h => h.seatIds);
if (expiredSeatIds.length > 0) {
// Release expired seats
await tx.seat.updateMany({
where: { id: { in: expiredSeatIds } },
data: {
status: 'AVAILABLE',
heldUntil: null
}
});
// Delete expired holds
await tx.seatHold.deleteMany({
where: {
expiresAt: { lt: new Date() }
}
});
this.eventEmitter.emit('holds.expired', {
expiredHolds: expiredHolds.length,
releasedSeats: expiredSeatIds
});
await tx.seat.updateMany({ where: { id: { in: expiredSeatIds } }, data: { status: 'AVAILABLE', heldUntil: null } });
await tx.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
this.eventEmitter.emit('holds.expired', { expiredHolds: expiredHolds.length, releasedSeats: expiredSeatIds });
}
return {
expiredHolds: expiredHolds.length,
releasedSeats: expiredSeatIds
};
return { expiredHolds: expiredHolds.length, releasedSeats: expiredSeatIds };
});
}
/**
* Get seat availability for specific segments
*/
async getSeatAvailability(tripId: string, originStationId: string, destinationStationId: string) {
const segments = await this.segmentsService.getJourneySegments(
tripId,
originStationId,
destinationStationId
);
async getSeatAvailability(scheduleId: string, originStationId: string, destinationStationId: string) {
const segments = await this.segmentsService.getJourneySegments(scheduleId, originStationId, destinationStationId);
const trip = await this.prisma.trip.findUnique({
where: { id: tripId },
include: {
coaches: {
include: {
seats: true
}
}
}
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
include: { coachAssignments: { include: { coach: { include: { seats: true, seatClass: true } } } } },
});
if (!trip) {
throw new BadRequestException('Trip not found');
}
if (!schedule) throw new BadRequestException('Schedule not found');
const availableSeats = [];
for (const coach of trip.coaches) {
for (const seat of coach.seats) {
const overlaps = await this.segmentsService.getOverlappingReservations(
tripId,
seat.id,
segments
);
for (const assignment of schedule.coachAssignments) {
for (const seat of assignment.coach.seats) {
const overlaps = await this.segmentsService.getOverlappingReservations(scheduleId, seat.id, segments);
if (overlaps.length === 0 && seat.status === 'AVAILABLE') {
availableSeats.push({
id: seat.id,
label: seat.label,
coach: coach.label,
serviceClass: coach.serviceClass,
row: seat.row,
col: seat.col
id: seat.id, label: seat.label,
coach: assignment.coach.label,
seatClass: assignment.coach.seatClass.name,
row: seat.row, col: seat.col,
});
}
}
}
return {
segments,
availableSeats,
totalAvailable: availableSeats.length
};
return { segments, availableSeats, totalAvailable: availableSeats.length };
}
}
}

View File

@@ -32,12 +32,12 @@ export class SegmentSeatsController {
@ApiResponse({ status: 409, description: 'Seats not available for requested segments' })
async holdSeats(@Body() dto: HoldSeatsDto) {
return this.enhancedSeatsService.holdSeats({
tripId: dto.tripId,
scheduleId: dto.scheduleId,
seatIds: dto.seatIds,
passengerId: dto.passengerId,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
fareQuoteId: dto.fareQuoteId
fareQuoteId: dto.fareQuoteId,
});
}
@@ -82,7 +82,7 @@ export class SegmentSeatsController {
}
})
async releaseSeats(@Body() dto: ReleaseSeatsDto) {
return this.enhancedSeatsService.releaseSeats(dto.tripId, dto.currentStationId);
return this.enhancedSeatsService.releaseSeats(dto.scheduleId, dto.currentStationId);
}
@Get('availability')
@@ -100,19 +100,15 @@ export class SegmentSeatsController {
{ fromName: 'Adama', toName: 'Awash', fromSequence: 1, toSequence: 2 }
],
availableSeats: [
{ id: 'seat_1', label: '1A', coach: 'A', serviceClass: 'ECONOMY', row: 1, col: 'A' },
{ id: 'seat_2', label: '1B', coach: 'A', serviceClass: 'ECONOMY', row: 1, col: 'B' }
{ id: 'seat_1', label: '1A', coach: 'A', seatClass: 'Economy Regular', row: 1, col: 'A' },
{ id: 'seat_2', label: '1B', coach: 'A', seatClass: 'Economy Regular', row: 1, col: 'B' }
],
totalAvailable: 2
}
}
})
async getSeatAvailability(@Query() dto: SeatAvailabilityDto) {
return this.enhancedSeatsService.getSeatAvailability(
dto.tripId,
dto.originStationId,
dto.destinationStationId
);
return this.enhancedSeatsService.getSeatAvailability(dto.scheduleId, dto.originStationId, dto.destinationStationId);
}
@Post('expire-holds')

View File

@@ -1,64 +1,27 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsArray, IsOptional } from 'class-validator';
export class HoldSeatsDto {
@ApiProperty({ example: 'trip_123' })
@IsString()
tripId: string;
@ApiProperty({ example: ['seat_1', 'seat_2'] })
@IsArray()
@IsString({ each: true })
seatIds: string[];
@ApiProperty({ example: 'passenger_123' })
@IsString()
passengerId: string;
@ApiProperty({ example: 'st_ADD' })
@IsString()
originStationId: string;
@ApiProperty({ example: 'st_DRE' })
@IsString()
destinationStationId: string;
@ApiProperty({ example: 'quote_123', required: false })
@IsOptional()
@IsString()
fareQuoteId?: string;
@ApiProperty({ example: 'schedule-uuid' }) @IsString() scheduleId: string;
@ApiProperty({ example: ['seat_1', 'seat_2'] }) @IsArray() @IsString({ each: true }) seatIds: string[];
@ApiProperty({ example: 'passenger-uuid' }) @IsString() passengerId: string;
@ApiProperty({ example: 'st_ADD' }) @IsString() originStationId: string;
@ApiProperty({ example: 'st_DJI' }) @IsString() destinationStationId: string;
@ApiPropertyOptional({ example: 'quote-uuid' }) @IsOptional() @IsString() fareQuoteId?: string;
}
export class ConfirmBookingDto {
@ApiProperty({ example: 'hold_123' })
@IsString()
holdId: string;
@ApiProperty({ example: 'booking_123' })
@IsString()
bookingId: string;
@ApiProperty({ example: 'hold-uuid' }) @IsString() holdId: string;
@ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string;
}
export class SeatAvailabilityDto {
@ApiProperty({ example: 'trip_123' })
@IsString()
tripId: string;
@ApiProperty({ example: 'st_ADD' })
@IsString()
originStationId: string;
@ApiProperty({ example: 'st_DRE' })
@IsString()
destinationStationId: string;
@ApiProperty({ example: 'schedule-uuid' }) @IsString() scheduleId: string;
@ApiProperty({ example: 'st_ADD' }) @IsString() originStationId: string;
@ApiProperty({ example: 'st_DJI' }) @IsString() destinationStationId: string;
}
export class ReleaseSeatsDto {
@ApiProperty({ example: 'trip_123' })
@IsString()
tripId: string;
@ApiProperty({ example: 'st_DRE' })
@IsString()
currentStationId: string;
}
@ApiProperty({ example: 'schedule-uuid' }) @IsString() scheduleId: string;
@ApiProperty({ example: 'st_DJI' }) @IsString() currentStationId: string;
}

View File

@@ -14,33 +14,31 @@ export interface Segment {
export class SegmentsService {
constructor(private prisma: PrismaService) {}
/**
* Derive all segments between origin and destination using TripStopTime.sequence
* Example: Addis → Dire Dawa = [Addis → Adama, Adama → Awash, Awash → Dire Dawa]
*/
async getJourneySegments(tripId: string, originStationId: string, destinationStationId: string): Promise<Segment[]> {
async getJourneySegments(
scheduleId: string,
originStationId: string,
destinationStationId: string,
): Promise<Segment[]> {
const stopTimes = await this.prisma.tripStopTime.findMany({
where: { tripId },
where: { scheduleId },
include: { station: true },
orderBy: { sequence: 'asc' }
orderBy: { sequence: 'asc' },
});
const originStop = stopTimes.find(st => st.stationId === originStationId);
const destinationStop = stopTimes.find(st => st.stationId === destinationStationId);
const destStop = stopTimes.find(st => st.stationId === destinationStationId);
if (!originStop || !destinationStop) {
throw new BadRequestException('Origin or destination station not found on this trip');
if (!originStop || !destStop) {
throw new BadRequestException('Origin or destination station not found on this schedule');
}
if (originStop.sequence >= destinationStop.sequence) {
if (originStop.sequence >= destStop.sequence) {
throw new BadRequestException('Origin must come before destination');
}
const segments: Segment[] = [];
for (let i = originStop.sequence; i < destinationStop.sequence; i++) {
for (let i = originStop.sequence; i < destStop.sequence; i++) {
const fromStop = stopTimes.find(st => st.sequence === i);
const toStop = stopTimes.find(st => st.sequence === i + 1);
if (fromStop && toStop) {
segments.push({
fromStationId: fromStop.stationId,
@@ -48,97 +46,85 @@ export class SegmentsService {
fromSequence: fromStop.sequence,
toSequence: toStop.sequence,
fromName: fromStop.station.name,
toName: toStop.station.name
toName: toStop.station.name,
});
}
}
return segments;
}
/**
* Check if two segment ranges overlap
*/
/** True if two segment ranges overlap: [a.from, a.to) ∩ [b.from, b.to) ≠ ∅ */
segmentsOverlap(segments1: Segment[], segments2: Segment[]): boolean {
for (const seg1 of segments1) {
for (const seg2 of segments2) {
// Segments overlap if one starts before the other ends
if (seg1.fromSequence < seg2.toSequence && seg2.fromSequence < seg1.toSequence) {
return true;
}
for (const s1 of segments1) {
for (const s2 of segments2) {
if (s1.fromSequence < s2.toSequence && s2.fromSequence < s1.toSequence) return true;
}
}
return false;
}
/**
* Get all existing bookings/holds that overlap with given segments
* Returns conflicts for a seat on a schedule for the requested segment range.
* Checks:
* 1. Active SeatHolds — resolved to sequence range via JourneySegment if available,
* otherwise treated as full-schedule block.
* 2. Active BookingSeats — resolved via JourneySegment sequence ranges.
*/
async getOverlappingReservations(tripId: string, seatId: string, segments: Segment[]) {
// Get active holds
async getOverlappingReservations(
scheduleId: string,
seatId: string,
requestedSegments: Segment[],
) {
const overlaps: { type: string; id: string }[] = [];
const reqFrom = Math.min(...requestedSegments.map(s => s.fromSequence));
const reqTo = Math.max(...requestedSegments.map(s => s.toSequence));
// ── 1. Active holds ──────────────────────────────────────────────────────
const activeHolds = await this.prisma.seatHold.findMany({
where: {
tripId,
seatIds: { has: seatId },
expiresAt: { gt: new Date() }
}
where: { scheduleId, seatIds: { has: seatId }, expiresAt: { gt: new Date() } },
});
// Get active bookings with journey segments
const activeBookings = await this.prisma.bookingSeat.findMany({
where: {
seatId,
booking: {
tripId,
status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] }
}
},
include: {
booking: true
}
});
const overlaps = [];
// Check hold overlaps (assume full journey for holds)
for (const hold of activeHolds) {
overlaps.push({ type: 'hold', id: hold.id });
}
// Check booking overlaps by querying journey segments separately
for (const booking of activeBookings) {
const journeySegments = await this.prisma.journeySegment.findMany({
where: {
tripId,
seatId,
journeyId: booking.bookingId
}
// Resolve hold range from JourneySegments created at hold time
const holdSegs = await this.prisma.journeySegment.findMany({
where: { scheduleId, seatId },
include: { schedule: { include: { stopTimes: true } } },
});
for (const journeySegment of journeySegments) {
// Get sequence numbers for this segment
const segmentStops = await this.prisma.tripStopTime.findMany({
where: {
tripId,
stationId: { in: [journeySegment.departureStationId, journeySegment.arrivalStationId] }
}
});
if (holdSegs.length === 0) {
// No journey segments yet — conservative: treat as full-schedule conflict
overlaps.push({ type: 'hold', id: hold.id });
continue;
}
const fromSeq = segmentStops.find(s => s.stationId === journeySegment.departureStationId)?.sequence;
const toSeq = segmentStops.find(s => s.stationId === journeySegment.arrivalStationId)?.sequence;
if (fromSeq !== undefined && toSeq !== undefined) {
// Check if any requested segment overlaps with this booking segment
for (const reqSeg of segments) {
if (reqSeg.fromSequence < toSeq && fromSeq < reqSeg.toSequence) {
overlaps.push({ type: 'booking', id: booking.booking.id });
break;
}
}
for (const js of holdSegs) {
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
if (depSeq !== undefined && arrSeq !== undefined && depSeq < reqTo && reqFrom < arrSeq) {
overlaps.push({ type: 'hold', id: hold.id });
break;
}
}
}
// ── 2. Active bookings via JourneySegment ────────────────────────────────
const bookedSegments = await this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId,
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
include: { schedule: { include: { stopTimes: true } } },
});
for (const js of bookedSegments) {
const depSeq = js.schedule.stopTimes.find(s => s.stationId === js.departureStationId)?.sequence;
const arrSeq = js.schedule.stopTimes.find(s => s.stationId === js.arrivalStationId)?.sequence;
if (depSeq !== undefined && arrSeq !== undefined && depSeq < reqTo && reqFrom < arrSeq) {
overlaps.push({ type: 'booking', id: js.journeyId });
}
}
return overlaps;
}
}
}

View File

@@ -19,14 +19,14 @@ export class TripProgressService {
return this.prisma.$transaction(async (tx) => {
// 1. Update trip live status
await tx.tripLiveStatus.upsert({
where: { tripId },
where: { scheduleId: tripId },
update: {
currentLocationLabel: currentStationId,
progressPercent,
updatedAt: new Date()
},
create: {
tripId,
scheduleId: tripId,
state: 'EN_ROUTE',
currentLocationLabel: currentStationId,
progressPercent,
@@ -69,7 +69,7 @@ export class TripProgressService {
* Simulate trip progress (for testing/demo)
*/
async simulateTripProgress(tripId: string) {
const trip = await this.prisma.trip.findUnique({
const trip = await this.prisma.trainSchedule.findUnique({
where: { id: tripId },
include: {
stopTimes: {
@@ -110,13 +110,17 @@ export class TripProgressService {
@OnEvent('trip.completed')
async handleTripCompleted(payload: { tripId: string }) {
// Release all remaining seats for this trip
const trip = await this.prisma.trip.findUnique({
const trip = await this.prisma.trainSchedule.findUnique({
where: { id: payload.tripId },
include: {
coaches: {
coachAssignments: {
include: {
seats: {
where: { status: 'BOOKED' }
coach: {
include: {
seats: {
where: { status: 'BOOKED' }
}
}
}
}
}
@@ -124,8 +128,8 @@ export class TripProgressService {
});
if (trip) {
const bookedSeatIds = trip.coaches.flatMap(coach =>
coach.seats.map(seat => seat.id)
const bookedSeatIds = trip.coachAssignments.flatMap(assignment =>
assignment.coach.seats.map(seat => seat.id)
);
if (bookedSeatIds.length > 0) {
@@ -161,7 +165,7 @@ export class TripProgressService {
* Get current trip status with seat availability
*/
async getTripStatus(tripId: string) {
const trip = await this.prisma.trip.findUnique({
const trip = await this.prisma.trainSchedule.findUnique({
where: { id: tripId },
include: {
liveStatus: true,
@@ -169,9 +173,11 @@ export class TripProgressService {
include: { station: true },
orderBy: { sequence: 'asc' }
},
coaches: {
coachAssignments: {
include: {
seats: true
coach: {
include: { seats: true }
}
}
}
}
@@ -189,8 +195,8 @@ export class TripProgressService {
blocked: 0
};
trip.coaches.forEach(coach => {
coach.seats.forEach(seat => {
trip.coachAssignments.forEach(assignment => {
assignment.coach.seats.forEach(seat => {
seatSummary.total++;
const status = seat.status.toLowerCase() as keyof typeof seatSummary;
if (status in seatSummary) {

View File

@@ -34,8 +34,8 @@ export class TicketsController {
@Get('offline/export')
@ApiOperation({ summary: 'Export tickets for offline validation' })
exportOfflineData(@Query('tripId') tripId: string) {
return this.service.exportOfflineData(tripId);
exportOfflineData(@Query('scheduleId') scheduleId: string) {
return this.service.exportOfflineData(scheduleId);
}
@Post('validate/offline')

View File

@@ -16,7 +16,7 @@ export class TicketsService {
async generate(bookingId: string) {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } } },
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } } },
});
if (!booking) throw new NotFoundException('Booking not found');
const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`);
@@ -31,14 +31,14 @@ export class TicketsService {
async getByRef(bookingRef: string) {
const booking = await this.prisma.booking.findUnique({
where: { bookingRef },
include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true },
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true },
});
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
const seat = booking.seats[0];
return {
id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status,
fromStationName: booking.trip.originStation.name, toStationName: booking.trip.destinationStation.name,
departureAt: booking.trip.departureAt, trainName: booking.trip.service.name,
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,
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
barcodePayload: booking.ticket.barcodePayload
@@ -72,7 +72,7 @@ export class TicketsService {
async exportOfflineData(tripId: string) {
const bookings = await this.prisma.booking.findMany({
where: { tripId, status: 'CONFIRMED' },
where: { scheduleId: tripId, status: 'CONFIRMED' },
include: {
ticket: true,
seats: { include: { seat: { include: { coach: true } } } },