mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user