Round trip journeys, feedback items, backoffice documentation

This commit is contained in:
Stephanos A
2026-06-16 13:33:03 +03:00
parent 28e183b086
commit 75c8423f50
46 changed files with 7222 additions and 570 deletions

View File

@@ -11,17 +11,24 @@ export class SearchController {
@Post()
@ApiOperation({
summary: 'Search trips by origin, destination, date, passengers, and nationality',
description: `Finds all train schedules matching search criteria with real-time seat availability.
description: `Finds all train schedules matching search criteria with real-time seat availability and coach type options.
**Coach Type Selection Flow:**
- Users browse available coach types (Economy, VIP, etc.)
- Each coach type displays available seat classes and base fares
- Users select a coach type to proceed to seat selection
- At seat selection, users choose specific seat and class (actual price confirmed here)
- Final fare may adjust based on seat position/amenities selected
**Features:**
- Any origin→destination stop pair (not just terminals)
- Age-based passenger counts (adults ≥5 years, children <5 years)
- Nationality filtering (Ethiopian, Djiboutian, Other)
- Real-time seat availability per class
- Multi-currency fare display
- Example: Train A→B→C→D appears in results for A→B, A→C, A→D, B→C, B→D, C→D
- Availability: Segment-based (seat booked A→B is still available B→D)`
- Segment-based availability (seat booked A→B still available B→D)`
})
@ApiResponse({ status: 200, description: 'Matching schedules with segment-accurate seat availability per class' })
@ApiResponse({ status: 200, description: 'Matching schedules with coachTypes array showing available coach types with seat classes and base fares' })
searchTrips(@Body() dto: SearchTripsDto) {
return this.service.searchTrips(dto);
}

View File

@@ -21,6 +21,12 @@ export class SearchTripsDto {
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality: Ethiopian (Verifayda verification), Djiboutian (Waafi payment), Other (international payments)' })
@IsOptional() @IsString() nationality?: string;
@ApiPropertyOptional({ example: 'ONE_WAY', enum: ['ONE_WAY', 'ROUND_TRIP'], description: 'Journey type: ONE_WAY or ROUND_TRIP' })
@IsOptional() @IsEnum(['ONE_WAY', 'ROUND_TRIP']) journeyType?: string;
@ApiPropertyOptional({ example: '2026-06-20', description: 'Return date (YYYY-MM-DD) — required for ROUND_TRIP, must be after outbound date' })
@IsOptional() @IsDateString() returnDate?: string;
}
export class FareQuoteDto {
@@ -53,4 +59,39 @@ export class FareQuoteDto {
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality for payment method filtering' })
@IsOptional() @IsString() nationality?: string;
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Return schedule UUID (required for ROUND_TRIP journeys)' })
@IsOptional() @IsString() returnScheduleId?: string;
@ApiPropertyOptional({ example: 'station-uuid', description: 'Return origin station ID (required for ROUND_TRIP)' })
@IsOptional() @IsString() returnOriginStationId?: string;
@ApiPropertyOptional({ example: 'station-uuid', description: 'Return destination station ID (required for ROUND_TRIP)' })
@IsOptional() @IsString() returnDestinationStationId?: string;
}
export class CoachTypeOptionClass {
@ApiProperty({ example: 'Economy Regular', description: 'Seat class name' })
name: string;
@ApiProperty({ example: 35000, description: 'Base fare in ETB minor units per passenger' })
baseFareMinor: number;
}
export class CoachTypeOption {
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach type unique identifier' })
coachTypeId: string;
@ApiProperty({ example: 'Economy', description: 'Coach type display name' })
coachTypeName: string;
@ApiProperty({ example: 'ECO', description: 'Coach type code' })
coachTypeCode: string;
@ApiProperty({
type: 'array',
items: { type: 'object', $ref: '#/components/schemas/CoachTypeOptionClass' },
description: 'Available seat classes within this coach type with base fares. User selects specific class at seat selection page.',
})
classes: CoachTypeOptionClass[];
}

View File

@@ -18,15 +18,57 @@ export class SearchService {
) {}
async searchTrips(dto: SearchTripsDto) {
const date = new Date(dto.date);
const nextDay = new Date(date.getTime() + 86_400_000);
const totalPassengers = dto.adultCount + (dto.childCount ?? 0);
const outbound = await this.searchSchedules(
dto.originStationId,
dto.destinationStationId,
dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
);
if (dto.journeyType === 'ROUND_TRIP') {
const allInbound = await this.searchSchedules(
dto.destinationStationId,
dto.originStationId,
dto.returnDate ?? dto.date,
dto.adultCount,
dto.childCount,
dto.nationality,
);
const latestOutboundArrival = outbound.length > 0
? Math.max(...outbound.map((s) => new Date(s.arrivalAt).getTime()))
: Date.now();
const inbound = allInbound.filter((schedule) =>
new Date(schedule.departureAt).getTime() > latestOutboundArrival
);
return { journeyType: 'ROUND_TRIP', outbound, inbound };
}
return { journeyType: 'ONE_WAY', outbound };
}
private async searchSchedules(
originStationId: string,
destinationStationId: string,
dateStr: string,
adultCount: number,
childCount?: number,
nationality?: string,
) {
const [y, m, d] = dateStr.split('-').map(Number);
const date = new Date(y, m - 1, d, 0, 0, 0, 0);
const nextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
const totalPassengers = adultCount + (childCount ?? 0);
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
departureAt: { gte: date, lt: nextDay },
stopTimes: { some: { stationId: dto.originStationId } },
stopTimes: { some: { stationId: originStationId } },
},
include: {
train: true,
@@ -42,8 +84,8 @@ export class SearchService {
const results = [];
for (const schedule of schedules) {
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) continue;
@@ -61,14 +103,14 @@ export class SearchService {
if (seat.bedPosition !== bedPosition) continue;
if (seat.status === 'BLOCKED') continue;
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) count++;
}
if (count > 0) {
const matchingClass = seatClassNames.find((className: string) => {
const classNameLower = className.toLowerCase();
@@ -89,14 +131,14 @@ export class SearchService {
for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED') continue;
if (!seat.seatNumber || !seat.seatNumber.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(
schedule.id, seat.id,
originStop.sequence, destStop.sequence,
);
if (free) availableSeatsInCoach++;
}
for (const seatClassName of seatClassNames) {
if (!availabilityByClass[seatClassName]) availabilityByClass[seatClassName] = 0;
availabilityByClass[seatClassName] += availableSeatsInCoach;
@@ -109,11 +151,13 @@ export class SearchService {
const faresByClass = await this.calculateFaresForSegment(
schedule,
dto.originStationId,
dto.destinationStationId,
dto.nationality,
originStationId,
destinationStationId,
nationality,
);
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
results.push({
scheduleId: schedule.id,
trainNumber: schedule.train.number,
@@ -150,6 +194,7 @@ export class SearchService {
availabilityByClass,
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
faresByClass,
coachTypes,
});
}
@@ -258,14 +303,14 @@ export class SearchService {
.filter((id: any) => id)
)
);
if (seatClassIds.length === 0) {
console.log(`No seat classes assigned to schedule ${schedule.id}`);
return [];
}
const seatClasses = await this.prisma.seatClass.findMany({
where: {
where: {
isActive: true,
id: { in: seatClassIds }
},
@@ -307,7 +352,7 @@ export class SearchService {
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
if (originStation && destStation) {
const segmentRoute = `${originStation.code}-${destStation.code}`;
const now = new Date();
@@ -341,6 +386,62 @@ export class SearchService {
}));
}
private async buildCoachTypeDetails(
schedule: any,
faresByClass: Array<{ seatClassName: string; baseFareMinor: number }>,
): Promise<Array<{
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
classes: Array<{ name: string; baseFareMinor: number }>;
}>> {
const coachTypeMap = new Map<
string,
{ coachType: any; classNames: Set<string> }
>();
for (const assignment of schedule.coachAssignments) {
const coachType = assignment.coach.coachType;
if (!coachType) continue;
if (!coachTypeMap.has(coachType.id)) {
coachTypeMap.set(coachType.id, {
coachType,
classNames: new Set(),
});
}
const entry = coachTypeMap.get(coachType.id)!;
coachType.seatClasses?.forEach((sc: any) => entry.classNames.add(sc.name));
}
const result = [];
for (const [, { coachType, classNames }] of coachTypeMap) {
const classes = Array.from(classNames)
.map((className) => {
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
return {
name: className,
baseFareMinor: fareInfo?.baseFareMinor ?? this.getDefaultFareForClass(className),
};
})
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
result.push({
coachTypeId: coachType.id,
coachTypeName: coachType.name,
coachTypeCode: coachType.code,
classes,
});
}
return result.sort((a, b) => {
const minPriceA = Math.min(...a.classes.map((c) => c.baseFareMinor));
const minPriceB = Math.min(...b.classes.map((c) => c.baseFareMinor));
return minPriceA - minPriceB;
});
}
private getDefaultFareForClass(className: string): number {
const defaults: Record<string, number> = {
'Economy Regular': 35000,