mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 14:38:12 +00:00
Fare engine added
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user