mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
Nationality, guest booking, waafi adapter, overall booking flow updates
This commit is contained in:
@@ -10,14 +10,16 @@ export class SearchController {
|
||||
|
||||
@Post()
|
||||
@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).
|
||||
summary: 'Search trips by origin, destination, date, passengers, and nationality',
|
||||
description: `Finds all train schedules matching search criteria with real-time seat availability.
|
||||
|
||||
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.`
|
||||
- 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)`
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Matching schedules with segment-accurate seat availability per class' })
|
||||
searchTrips(@Body() dto: SearchTripsDto) {
|
||||
@@ -26,17 +28,29 @@ Returns departure/arrival times for the requested leg, the full stop list, and p
|
||||
|
||||
@Post('fare-quote')
|
||||
@ApiOperation({
|
||||
summary: 'Get fare quote for a specific schedule leg',
|
||||
description: `Calculates fare for the requested origin→destination leg on a schedule.
|
||||
summary: 'Get fare quote with age-based pricing and multi-currency support',
|
||||
description: `Calculates detailed fare breakdown for a specific schedule leg.
|
||||
|
||||
Pricing rules (in priority order):
|
||||
Age-Based Pricing:
|
||||
- ADULT (≥5 years): 100% of base fare
|
||||
- CHILD (<5 years): First child FREE, subsequent children 100%
|
||||
- Example: 2 adults + 3 children = 4× base fare
|
||||
|
||||
Pricing Rules (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).`
|
||||
Multi-Currency:
|
||||
- Transaction currency: ETB
|
||||
- Display currencies: ETB, DJF, USD
|
||||
- Real-time exchange rate conversion
|
||||
|
||||
Nationality-Based:
|
||||
- Ethiopian: National ID verification required
|
||||
- Djiboutian: Passport details, Waafi payment available
|
||||
- Other: Passport details, international payments`
|
||||
})
|
||||
@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' })
|
||||
|
||||
@@ -13,11 +13,14 @@ export class SearchTripsDto {
|
||||
@ApiProperty({ example: '2026-06-15', description: 'Departure date (YYYY-MM-DD)' })
|
||||
@IsDateString() date: string;
|
||||
|
||||
@ApiProperty({ example: 2, description: 'Number of adult passengers (age ≥ 5)' })
|
||||
@ApiProperty({ example: 2, description: 'Number of adult passengers (age ≥5 years) - pay 100% of base fare' })
|
||||
@Type(() => Number) @IsInt() @Min(1) adultCount: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1, description: 'Number of child passengers (age < 5). First child travels free.' })
|
||||
@ApiPropertyOptional({ example: 1, description: 'Number of child passengers (age <5 years). First child travels FREE, subsequent children pay 100%.' })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality: Ethiopian (Verifayda verification), Djiboutian (Waafi payment), Other (international payments)' })
|
||||
@IsOptional() @IsString() nationality?: string;
|
||||
}
|
||||
|
||||
export class FareQuoteDto {
|
||||
@@ -33,10 +36,10 @@ export class FareQuoteDto {
|
||||
@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' })
|
||||
@ApiProperty({ example: 2, description: 'Number of adult passengers (≥5 years) - each pays 100% of base fare' })
|
||||
@Type(() => Number) @IsInt() @Min(1) adultCount: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1 })
|
||||
@ApiPropertyOptional({ example: 1, description: 'Number of child passengers (<5 years) - first child FREE, subsequent children 100%' })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(0) childCount?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'WEEKEND15' })
|
||||
@@ -45,6 +48,9 @@ export class FareQuoteDto {
|
||||
@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 })
|
||||
@ApiPropertyOptional({ example: 'USD', enum: Currency, description: 'Display currency: ETB (default), DJF, USD. Transaction always in ETB.' })
|
||||
@IsOptional() @IsEnum(Currency) displayCurrency?: Currency;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Ethiopian', description: 'Passenger nationality for payment method filtering' })
|
||||
@IsOptional() @IsString() nationality?: string;
|
||||
}
|
||||
|
||||
@@ -129,11 +129,23 @@ export class SearchService {
|
||||
|
||||
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } });
|
||||
|
||||
// Look up fare rule: prefer schedule-scoped, then segment route, then global
|
||||
// Compute route codes for fare lookup
|
||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
||||
const now = new Date();
|
||||
const nationality = dto.nationality;
|
||||
|
||||
// Query fare rules with specificity ordering:
|
||||
// 1. schedule+segment+nationality
|
||||
// 2. schedule+segment
|
||||
// 3. schedule+full-route+nationality
|
||||
// 4. schedule+full-route
|
||||
// 5. schedule+global
|
||||
// 6. segment+nationality
|
||||
// 7. segment
|
||||
// 8. full-route+nationality
|
||||
// 9. full-route
|
||||
// 10. global
|
||||
const fareRule = await this.prisma.fareRule.findFirst({
|
||||
where: {
|
||||
seatClassId: seatClass?.id,
|
||||
@@ -144,13 +156,36 @@ export class SearchService {
|
||||
],
|
||||
},
|
||||
orderBy: [
|
||||
// Most specific first: schedule-scoped > segment route > full route > global
|
||||
{ tripId: 'desc' },
|
||||
// Prioritize schedule-specific rules
|
||||
{ tripId: { sort: 'desc', nulls: 'last' } },
|
||||
// Then prioritize nationality match
|
||||
{ nationality: { sort: 'desc', nulls: 'last' } },
|
||||
// Most recent validFrom
|
||||
{ validFrom: 'desc' },
|
||||
],
|
||||
});
|
||||
|
||||
const baseFareMinor = fareRule?.baseFareMinor ?? this.defaultFare(dto.seatClassName);
|
||||
// Manual specificity filtering to find best match
|
||||
const candidates = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
seatClassId: seatClass?.id,
|
||||
validFrom: { lte: now },
|
||||
OR: [
|
||||
{ validUntil: null },
|
||||
{ validUntil: { gte: now } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const bestMatch = this.selectBestFareRule(
|
||||
candidates,
|
||||
dto.scheduleId,
|
||||
segmentRoute,
|
||||
fullRoute,
|
||||
nationality,
|
||||
);
|
||||
|
||||
const baseFareMinor = bestMatch?.baseFareMinor ?? this.defaultFare(dto.seatClassName);
|
||||
|
||||
const adultCount = dto.adultCount;
|
||||
const childCount = dto.childCount ?? 0;
|
||||
@@ -184,6 +219,7 @@ export class SearchService {
|
||||
destinationStationId: dto.destinationStationId,
|
||||
segmentRoute,
|
||||
seatClassName: dto.seatClassName,
|
||||
nationality: dto.nationality,
|
||||
adultCount, childCount,
|
||||
baseFareMinor, adultFareMinor, childFareMinor,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
@@ -266,4 +302,55 @@ export class SearchService {
|
||||
};
|
||||
return fares[seatClassName] ?? 45000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the best matching fare rule based on specificity:
|
||||
* 1. schedule+segment+nationality
|
||||
* 2. schedule+segment
|
||||
* 3. schedule+full-route+nationality
|
||||
* 4. schedule+full-route
|
||||
* 5. schedule+global
|
||||
* 6. segment+nationality
|
||||
* 7. segment
|
||||
* 8. full-route+nationality
|
||||
* 9. full-route
|
||||
* 10. global
|
||||
*/
|
||||
private selectBestFareRule(
|
||||
candidates: any[],
|
||||
scheduleId: string,
|
||||
segmentRoute: string,
|
||||
fullRoute: string,
|
||||
nationality?: string,
|
||||
): any | null {
|
||||
const priorities = [
|
||||
// Schedule-specific rules
|
||||
{ tripId: scheduleId, route: segmentRoute, nationality },
|
||||
{ tripId: scheduleId, route: segmentRoute, nationality: null },
|
||||
{ tripId: scheduleId, route: fullRoute, nationality },
|
||||
{ tripId: scheduleId, route: fullRoute, nationality: null },
|
||||
{ tripId: scheduleId, route: null, nationality },
|
||||
{ tripId: scheduleId, route: null, nationality: null },
|
||||
// Route-specific rules (no schedule)
|
||||
{ tripId: null, route: segmentRoute, nationality },
|
||||
{ tripId: null, route: segmentRoute, nationality: null },
|
||||
{ tripId: null, route: fullRoute, nationality },
|
||||
{ tripId: null, route: fullRoute, nationality: null },
|
||||
// Global rules
|
||||
{ tripId: null, route: null, nationality },
|
||||
{ tripId: null, route: null, nationality: null },
|
||||
];
|
||||
|
||||
for (const priority of priorities) {
|
||||
const match = candidates.find(
|
||||
(c) =>
|
||||
c.tripId === priority.tripId &&
|
||||
c.route === priority.route &&
|
||||
c.nationality === priority.nationality,
|
||||
);
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user