mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
Refactor business logic for train,schedule,coach,seat and search modules
This commit is contained in:
@@ -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 origin–destination 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user