mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 02:30:55 +00:00
Implemened verifayda and currency modules
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { SearchService } from './search.service';
|
||||
import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
||||
|
||||
@@ -7,6 +7,26 @@ import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
||||
@Controller('search')
|
||||
export class SearchController {
|
||||
constructor(private service: SearchService) {}
|
||||
@Post() @ApiOperation({ summary: 'Search trips' }) searchTrips(@Body() dto: SearchTripsDto) { return this.service.searchTrips(dto); }
|
||||
@Post('fare-quote')@ApiOperation({ summary: 'Get fare quote' }) getFareQuote(@Body() dto: FareQuoteDto) { return this.service.getFareQuote(dto); }
|
||||
|
||||
@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.'
|
||||
})
|
||||
@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);
|
||||
}
|
||||
|
||||
@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).'
|
||||
})
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { IsString, IsDateString, IsInt, IsOptional, Min } from 'class-validator';
|
||||
import { IsString, IsDateString, IsInt, IsOptional, Min, IsEnum } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
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;
|
||||
@ApiPropertyOptional({ example: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) passengers?: number;
|
||||
@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;
|
||||
}
|
||||
|
||||
export class FareQuoteDto {
|
||||
@@ -16,7 +18,9 @@ export class FareQuoteDto {
|
||||
enum: ['ECONOMY_REGULAR', 'ECONOMY_BED_LOWER', 'ECONOMY_BED_MIDDLE', 'ECONOMY_BED_UPPER', 'VIP_BED_LOWER', 'VIP_BED_UPPER']
|
||||
})
|
||||
@IsString() serviceClass: string;
|
||||
@ApiPropertyOptional({ example: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) passengerCount?: number;
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SearchController } from './search.controller';
|
||||
import { SearchService } from './search.service';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
|
||||
@Module({ controllers: [SearchController], providers: [SearchService], exports: [SearchService] })
|
||||
@Module({
|
||||
imports: [CurrencyModule],
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
exports: [SearchService]
|
||||
})
|
||||
export class SearchModule {}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SearchTripsDto, FareQuoteDto } from './search.dto';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
const POINTS_TO_MINOR = 10;
|
||||
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private currencyService: CurrencyService,
|
||||
) {}
|
||||
|
||||
async searchTrips(dto: SearchTripsDto) {
|
||||
const date = new Date(dto.date), nextDay = new Date(date.getTime() + 86400000);
|
||||
@@ -14,6 +19,9 @@ export class SearchService {
|
||||
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;
|
||||
@@ -24,20 +32,12 @@ export class SearchService {
|
||||
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'),
|
||||
ECONOMY_BED_LOWER: avail('ECONOMY_BED_LOWER'),
|
||||
ECONOMY_BED_MIDDLE: avail('ECONOMY_BED_MIDDLE'),
|
||||
ECONOMY_BED_UPPER: avail('ECONOMY_BED_UPPER'),
|
||||
VIP_BED_LOWER: avail('VIP_BED_LOWER'),
|
||||
VIP_BED_UPPER: avail('VIP_BED_UPPER')
|
||||
},
|
||||
fares: {
|
||||
ECONOMY_REGULAR: this.defaultFare('ECONOMY_REGULAR') / 100,
|
||||
ECONOMY_BED_LOWER: this.defaultFare('ECONOMY_BED_LOWER') / 100,
|
||||
ECONOMY_BED_MIDDLE: this.defaultFare('ECONOMY_BED_MIDDLE') / 100,
|
||||
ECONOMY_BED_UPPER: this.defaultFare('ECONOMY_BED_UPPER') / 100,
|
||||
VIP_BED_LOWER: this.defaultFare('VIP_BED_LOWER') / 100,
|
||||
VIP_BED_UPPER: this.defaultFare('VIP_BED_UPPER') / 100
|
||||
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
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -46,26 +46,69 @@ export class SearchService {
|
||||
async getFareQuote(dto: FareQuoteDto) {
|
||||
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId } });
|
||||
if (!trip) throw new NotFoundException('Trip not found');
|
||||
const count = dto.passengerCount ?? 1;
|
||||
const baseFareMinor = this.defaultFare(dto.serviceClass) * count;
|
||||
|
||||
const adultCount = dto.adultCount;
|
||||
const childCount = dto.childCount || 0;
|
||||
|
||||
const baseFareMinor = this.defaultFare(dto.serviceClass);
|
||||
|
||||
// Adult fare: 100% of base fare
|
||||
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(baseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
||||
if (promo?.active && promo.validUntil > new Date()) {
|
||||
discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
|
||||
const taxesMinor = Math.round(baseFareMinor * 0.05);
|
||||
return { tripId: dto.tripId, serviceClass: dto.serviceClass, passengerCount: count, baseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor: Math.max(0, baseFareMinor - discountMinor - loyaltyMinor + taxesMinor), currency: 'ETB' };
|
||||
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);
|
||||
}
|
||||
|
||||
return {
|
||||
tripId: dto.tripId,
|
||||
serviceClass: dto.serviceClass,
|
||||
adultCount,
|
||||
childCount,
|
||||
baseFareMinor,
|
||||
adultFareMinor,
|
||||
childFareMinor,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
paidChildrenCount,
|
||||
totalBaseFareMinor,
|
||||
discountMinor,
|
||||
loyaltyRedemptionMinor: loyaltyMinor,
|
||||
taxesFeesMinor: taxesMinor,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
};
|
||||
}
|
||||
|
||||
private defaultFare(serviceClass: string): number {
|
||||
const fares: Record<string, number> = {
|
||||
ECONOMY_REGULAR: 35000, // 350 ETB
|
||||
ECONOMY_BED_LOWER: 55000, // 550 ETB
|
||||
ECONOMY_BED_MIDDLE: 50000, // 500 ETB
|
||||
ECONOMY_BED_UPPER: 45000, // 450 ETB
|
||||
VIP_BED_LOWER: 85000, // 850 ETB
|
||||
VIP_BED_UPPER: 80000 // 800 ETB
|
||||
ECONOMY_REGULAR: 35000,
|
||||
ECONOMY_BED_LOWER: 55000,
|
||||
ECONOMY_BED_MIDDLE: 50000,
|
||||
ECONOMY_BED_UPPER: 45000,
|
||||
VIP_BED_LOWER: 85000,
|
||||
VIP_BED_UPPER: 80000
|
||||
};
|
||||
return fares[serviceClass] ?? 35000;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user