Fare engine added

This commit is contained in:
Roba Boru
2026-05-26 16:25:24 +03:00
parent 80cea364c7
commit 769294d12f
12 changed files with 570 additions and 45 deletions

View File

@@ -89,12 +89,45 @@ Origin and destination are derived from the first and last route stop — no nee
// ── Fares ──────────────────────────────────────────────────────────────────
@Get(':scheduleId/fares')
@ApiOperation({ summary: 'Get applicable fare for a schedule and seat class' })
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@ApiQuery({ name: 'class', required: false, description: 'Seat class name: "Economy Regular" | "Economy Bed" | "VIP Bed". Defaults to Economy Regular.' })
@ApiResponse({ status: 200, description: 'Fare rule or default fare' })
@ApiQuery({ name: 'seatClassId', required: true, description: 'SeatClass UUID' })
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency (Ethiopian→ETB, Djiboutian→DJF, other→USD)' })
@ApiResponse({ status: 200, description: 'Live fare breakdown from fare engine' })
@ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' })
@ApiResponse({ status: 404, description: 'Schedule or seat class not found' })
getFare(
@Param('scheduleId') scheduleId: string,
@Query('seatClassId') seatClassId: string,
@Query('nationality') nationality?: string,
) {
return this.service.getFareFromEngine(scheduleId, seatClassId, nationality);
}
@Get(':scheduleId/fares/all')
@ApiOperation({ summary: 'Get fares for all active seat classes on a schedule' })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency' })
@ApiResponse({ status: 200, description: 'Array of fare breakdowns for every active seat class, ordered by price ascending' })
@ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' })
@ApiResponse({ status: 404, description: 'Schedule not found' })
getFare(@Param('scheduleId') scheduleId: string, @Query('class') cls: string) {
return this.service.getFare(scheduleId, cls ?? 'Economy Regular');
getAllFares(
@Param('scheduleId') scheduleId: string,
@Query('nationality') nationality?: string,
) {
return this.service.getAllFaresFromEngine(scheduleId, nationality);
}
@Post(':id/fares/sync')
@ApiOperation({
summary: 'Sync fares from fare engine',
description: 'Recalculates fares for all active seat classes using the fare engine (km × ratePerKm + tax) and upserts them as FareRule records scoped to this schedule. Previous active rules are expired.',
})
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiResponse({ status: 201, description: 'Fares synced — returns count of synced rules and any errors' })
@ApiResponse({ status: 400, description: 'Schedule has no associated route or missing distanceKm on stops' })
@ApiResponse({ status: 404, description: 'Schedule not found' })
syncFares(@Param('id') id: string) {
return this.service.syncFaresFromEngine(id);
}
}

View File

@@ -3,8 +3,10 @@ import { SchedulesController } from './schedules.controller';
import { SchedulesService } from './schedules.service';
import { RoutesController } from './routes.controller';
import { RoutesService } from './routes.service';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
@Module({
imports: [FareEngineModule],
controllers: [RoutesController, SchedulesController],
providers: [RoutesService, SchedulesService],
exports: [RoutesService, SchedulesService],

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { RoutesService } from './routes.service';
import { FareEngineService } from '../fare-engine/fare-engine.service';
import { CreateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto } from './schedules.dto';
@Injectable()
@@ -8,6 +9,7 @@ export class SchedulesService {
constructor(
private prisma: PrismaService,
private routesService: RoutesService,
private fareEngine: FareEngineService,
) {}
// ── Schedule CRUD ──────────────────────────────────────────────────────────
@@ -149,27 +151,51 @@ export class SchedulesService {
});
}
async getFare(scheduleId: string, seatClassName: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
include: { originStation: true, destinationStation: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
getFareFromEngine(scheduleId: string, seatClassId: string, nationality?: string) {
return this.fareEngine.calculateForSchedule(scheduleId, seatClassId, nationality);
}
const route = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: seatClassName } });
getAllFaresFromEngine(scheduleId: string, nationality?: string) {
return this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
}
/**
* Recalculate fares for all active seat classes on a schedule using the fare engine
* and upsert them as FareRule records scoped to this schedule.
*/
async syncFaresFromEngine(scheduleId: string): Promise<{ synced: number; errors: string[] }> {
const results = await this.fareEngine.calculateAllForSchedule(scheduleId);
const errors: string[] = [];
let synced = 0;
const now = new Date();
const rule = await this.prisma.fareRule.findFirst({
where: {
seatClassId: seatClass?.id,
validFrom: { lte: now },
OR: [{ tripId: scheduleId }, { route }, { tripId: null, route: null }],
AND: [{ OR: [{ validUntil: null }, { validUntil: { gte: now } }] }],
},
orderBy: { validFrom: 'desc' },
});
for (const fare of results as any[]) {
try {
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: fare.seatClassName } });
if (!seatClass) { errors.push(`Seat class not found: ${fare.seatClassName}`); continue; }
return rule ?? { baseFareMinor: 45000, currency: 'ETB', seatClassName };
// Expire any existing active rule for this schedule + seat class
await this.prisma.fareRule.updateMany({
where: { tripId: scheduleId, seatClassId: seatClass.id, validUntil: null },
data: { validUntil: now },
});
await this.prisma.fareRule.create({
data: {
tripId: scheduleId,
seatClassId: seatClass.id,
baseFareMinor: fare.totalMinor,
currency: 'ETB',
validFrom: now,
validUntil: null,
},
});
synced++;
} catch (err) {
errors.push(`${fare.seatClassName}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return { synced, errors };
}
}