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

@@ -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;
}
}