Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Abubeker Yasin
2026-07-14 20:17:02 +03:00
152 changed files with 9371 additions and 1278 deletions

View File

@@ -857,7 +857,7 @@ export class BookingsService {
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
totalMinor: resolvedTotalMinor / 100,
totalMinor: resolvedTotalMinor,
adultCount,
childCount,
displayCurrency,
@@ -1728,13 +1728,40 @@ export class BookingsService {
);
}
// Resolves the passenger's actual boarding/alighting stations for one leg from
// originStationId/destinationStationId (set when the booking covers only part of a
// longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D) via
// the schedule's stopTimes, falling back to the schedule's own full-route endpoints
// when there's no segment override (older records, or a booking that covers the
// whole run). Mirrors notifications.service.ts's resolveSegmentStations — that's
// already applied to SMS/email; this brings the booking API (voucher, detail page,
// confirmation) to the same behavior instead of always showing the train's full route.
private resolveSegmentStations(
schedule: any,
originStationId: string | null | undefined,
destinationStationId: string | null | undefined,
): { origin: any; destination: any } {
const stopTimes: any[] = schedule?.stopTimes ?? [];
const findStation = (stationId: string | null | undefined, fallback: any) => {
if (stationId && stopTimes.length > 0) {
const stop = stopTimes.find((st: any) => st.stationId === stationId);
if (stop?.station) return stop.station;
}
return fallback ?? null;
};
return {
origin: findStation(originStationId, schedule?.originStation),
destination: findStation(destinationStationId, schedule?.destinationStation),
};
}
async getByRef(bookingRefOrId: string) {
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId);
const booking = await this.prisma.booking.findUnique({
where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId },
include: {
schedule: { include: { originStation: true, destinationStation: true, train: true } },
returnSchedule: { include: { originStation: true, destinationStation: true, train: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: true,
priceTier: { select: { priceMinor: true } },
@@ -1803,6 +1830,19 @@ export class BookingsService {
};
}
const outboundSegment = this.resolveSegmentStations(
(booking as any).schedule,
(booking as any).originStationId,
(booking as any).destinationStationId,
);
const returnSegment = (booking as any).returnSchedule
? this.resolveSegmentStations(
(booking as any).returnSchedule,
(booking as any).returnOriginStationId,
(booking as any).returnDestinationStationId,
)
: null;
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB',
@@ -1819,8 +1859,8 @@ export class BookingsService {
id: (booking as any).schedule.id,
trainNumber: (booking as any).schedule.train.number,
trainName: (booking as any).schedule.train.name,
origin: { id: (booking as any).schedule.originStation.id, name: (booking as any).schedule.originStation.name, code: (booking as any).schedule.originStation.code, city: (booking as any).schedule.originStation.city },
destination: { id: (booking as any).schedule.destinationStation.id, name: (booking as any).schedule.destinationStation.name, code: (booking as any).schedule.destinationStation.code, city: (booking as any).schedule.destinationStation.city },
origin: { id: outboundSegment.origin.id, name: outboundSegment.origin.name, code: outboundSegment.origin.code, city: outboundSegment.origin.city },
destination: { id: outboundSegment.destination.id, name: outboundSegment.destination.name, code: outboundSegment.destination.code, city: outboundSegment.destination.city },
departureAt: (booking as any).schedule.departureAt, arrivalAt: (booking as any).schedule.arrivalAt,
},
returnSchedule: (booking as any).returnSchedule
@@ -1828,8 +1868,8 @@ export class BookingsService {
id: (booking as any).returnSchedule.id,
trainNumber: (booking as any).returnSchedule.train.number,
trainName: (booking as any).returnSchedule.train.name,
origin: { id: (booking as any).returnSchedule.originStation.id, name: (booking as any).returnSchedule.originStation.name, code: (booking as any).returnSchedule.originStation.code, city: (booking as any).returnSchedule.originStation.city },
destination: { id: (booking as any).returnSchedule.destinationStation.id, name: (booking as any).returnSchedule.destinationStation.name, code: (booking as any).returnSchedule.destinationStation.code, city: (booking as any).returnSchedule.destinationStation.city },
origin: { id: returnSegment!.origin.id, name: returnSegment!.origin.name, code: returnSegment!.origin.code, city: returnSegment!.origin.city },
destination: { id: returnSegment!.destination.id, name: returnSegment!.destination.name, code: returnSegment!.destination.code, city: returnSegment!.destination.city },
departureAt: (booking as any).returnSchedule.departureAt, arrivalAt: (booking as any).returnSchedule.arrivalAt,
}
: null,

View File

@@ -103,6 +103,18 @@ export class FareEngineService {
},
});
// Route-level fare override: checked after segment (most specific) but before
// schedule-scoped rules and the global seat-class tariff (least specific).
const routeFareOverride = segmentOverride ? null : await this.prisma.routeFareRule.findFirst({
where: {
routeId: route.id,
seatClassId: nationalitySeatClass.id,
validFrom: { lte: now },
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
orderBy: { validFrom: 'desc' },
});
if (segmentOverride) {
baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
if (nationalityType === 'INTERNATIONAL' && !segmentOverride.nationality) {
@@ -110,6 +122,20 @@ export class FareEngineService {
}
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
fareSource = 'SEGMENT_FARE_RULE';
} else if (routeFareOverride) {
// Stored as a per-km rate (same unit as SeatClass.baseFareMinor × 100).
// Insurance factor and USD→ETB conversion are applied identically to the
// global seat-class formula so the override is a pure rate substitution.
const ratePerKmEtb = routeFareOverride.baseFareMinor / 100;
insuranceFactor = nationalitySeatClass.insuranceFeeMinor > 0
? nationalitySeatClass.insuranceFeeMinor / 100 : 1;
usdToEtbRate = await this.currencyService.getExchangeRate(Currency.USD, Currency.ETB);
ratePerKmMinor = routeFareOverride.baseFareMinor;
baseFarePerPassengerMinor = Math.round(
totalDistanceKm * ratePerKmEtb * insuranceFactor * usdToEtbRate,
);
fareSource = 'ROUTE_FARE_OVERRIDE';
insuranceAlreadyInBase = true;
} else if (fareRule?.tripId) {
baseFarePerPassengerMinor = fareRule.baseFareMinor;
if (nationalityType === 'INTERNATIONAL' && !fareRule.nationality) {
@@ -172,7 +198,7 @@ export class FareEngineService {
const calculation = [
`Distance: ${totalDistanceKm} km (${originStation?.name}${destStation?.name})`,
`Nationality: ${dto.nationality ?? 'unspecified'}${nationalityType}${nationalityType === 'INTERNATIONAL' ? ' (2× surcharge applied)' : ''}${nationalitySeatClass.name}`,
`Rate per km: ${nationalitySeatClass.baseFareMinor} minor → ${nationalitySeatClass.baseFareMinor / 100} ETB/km`,
`Rate per km: ${routeFareOverride ? routeFareOverride.baseFareMinor : nationalitySeatClass.baseFareMinor} minor → ${(routeFareOverride ? routeFareOverride.baseFareMinor : nationalitySeatClass.baseFareMinor) / 100} ETB/km${routeFareOverride ? ' [ROUTE OVERRIDE]' : ''}`,
`Insurance: ${nationalitySeatClass.insuranceFeeMinor} minor → factor ${insuranceFactor}${insuranceAlreadyInBase ? ' (baked into base fare)' : ''}`,
`USD→ETB rate: ${usdToEtbRate}`,
`Base fare/pax: ${totalDistanceKm} km × (${nationalitySeatClass.baseFareMinor} / 100) × ${insuranceFactor} × ${usdToEtbRate} = ${baseFarePerPassengerMinor} ETB minor`,

View File

@@ -69,6 +69,40 @@ export class SchedulesController {
@ApiOperation({ summary: 'Create a segment fare rule' })
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
// Static sub-path MUST come before routes/:routeId/* to avoid :routeId swallowing 'fare-rules'
@Delete('routes/fare-rules/:id')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a route-level fare override' })
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
deleteRouteFareRule(@Param('id') id: string) {
return this.service.deleteRouteFareRule(id);
}
@Patch('routes/fare-rules/:id')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update a route-level fare override' })
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
updateRouteFareRule(@Param('id') id: string, @Body() dto: any) {
return this.service.updateRouteFareRule(id, dto);
}
@Get('routes/:routeId/fare-rules')
@IsPublic()
@ApiOperation({ summary: 'List route-level fare overrides for a route' })
@ApiParam({ name: 'routeId', description: 'Route UUID' })
listRouteFareRules(@Param('routeId') routeId: string) {
return this.service.listRouteFareRules(routeId);
}
@Post('routes/:routeId/fare-rules')
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create a route-level fare override' })
@ApiParam({ name: 'routeId', description: 'Route UUID' })
createRouteFareRule(@Param('routeId') routeId: string, @Body() dto: any) {
return this.service.createRouteFareRule({ ...dto, routeId });
}
@Get('routes/:routeId/segment-fares')
@IsPublic()
@ApiOperation({ summary: 'List all segment fare rules for a route' })

View File

@@ -676,4 +676,63 @@ export class SchedulesService {
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
return { message: 'Coach assignment removed' };
}
// ── Route Fare Rule Overrides ──────────────────────────────────────────────
listRouteFareRules(routeId: string) {
return this.prisma.routeFareRule.findMany({
where: { routeId },
include: { seatClass: true, route: true },
orderBy: { createdAt: 'desc' },
});
}
async createRouteFareRule(dto: {
routeId: string;
seatClassId: string;
passengerCategory?: string;
baseFareMinor: number;
validFrom: string;
validUntil?: string;
}) {
const [route, seatClass] = await Promise.all([
this.prisma.route.findUnique({ where: { id: dto.routeId } }),
this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } }),
]);
if (!route) throw new NotFoundException('Route not found');
if (!seatClass) throw new NotFoundException('Seat class not found');
return this.prisma.routeFareRule.create({
data: {
routeId: dto.routeId,
seatClassId: dto.seatClassId,
passengerCategory: (dto.passengerCategory as any) ?? 'ADULT',
baseFareMinor: dto.baseFareMinor,
validFrom: parseEthiopianTime(dto.validFrom),
validUntil: dto.validUntil ? parseEthiopianTime(dto.validUntil) : null,
},
include: { seatClass: true, route: true },
});
}
async updateRouteFareRule(id: string, dto: { baseFareMinor?: number; surchargeMinor?: number; validFrom?: string; validUntil?: string }) {
const rule = await this.prisma.routeFareRule.findUnique({ where: { id } });
if (!rule) throw new NotFoundException('Route fare rule not found');
return this.prisma.routeFareRule.update({
where: { id },
data: {
...(dto.baseFareMinor !== undefined && { baseFareMinor: dto.baseFareMinor }),
...(dto.surchargeMinor !== undefined && { surchargeMinor: dto.surchargeMinor }),
...(dto.validFrom && { validFrom: parseEthiopianTime(dto.validFrom) }),
...(dto.validUntil !== undefined && { validUntil: dto.validUntil ? parseEthiopianTime(dto.validUntil) : null }),
},
include: { seatClass: true, route: true },
});
}
async deleteRouteFareRule(id: string) {
const rule = await this.prisma.routeFareRule.findUnique({ where: { id } });
if (!rule) throw new NotFoundException('Route fare rule not found');
await this.prisma.routeFareRule.delete({ where: { id } });
return { deleted: true, id };
}
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SeatClassesService } from './seat-classes.service';
@@ -49,5 +49,7 @@ export class SeatClassesController {
@ApiParam({ name: 'id', description: 'Seat class UUID' })
@ApiResponse({ status: 200, description: 'Seat class deleted' })
@ApiResponse({ status: 404, description: 'Seat class not found' })
deleteSeatClass(@Param('id') id: string) { return this.service.deleteSeatClass(id); }
deleteSeatClass(@Param('id') id: string, @Query('cascade') cascade?: string) {
return this.service.deleteSeatClass(id, cascade === 'true');
}
}

View File

@@ -45,7 +45,7 @@ export class SeatClassesService {
}
}
async deleteSeatClass(id: string) {
async deleteSeatClass(id: string, cascade = false) {
const sc = await this.prisma.seatClass.findUnique({
where: { id },
include: {
@@ -59,11 +59,17 @@ export class SeatClassesService {
(sc as any)._count.routeFareRules +
(sc as any)._count.segmentFares;
if (totalFareRules > 0)
if (totalFareRules > 0 && !cascade)
throw new DeleteOperationException('Seat Class', sc.name, [
{ entityName: 'fare rule', count: totalFareRules, action: 'delete' },
]);
if (cascade) {
await this.prisma.fareRule.deleteMany({ where: { seatClassId: id } });
await this.prisma.routeFareRule.deleteMany({ where: { seatClassId: id } });
await this.prisma.segmentFareRule.deleteMany({ where: { seatClassId: id } });
}
return this.prisma.seatClass.delete({ where: { id } });
}
}